feat: polish invoice editor and viewer UI with custom NumberInput

component

- Create custom NumberInput component with increment/decrement buttons
- Add 0.25 step increments for hours and rates in invoice forms
- Implement emerald-themed styling with hover states and accessibility
- Add keyboard navigation (arrow keys) and proper ARIA support
- Condense invoice editor tax/totals section into efficient grid layout
- Update client dropdown to single-line format (name + email)
- Add fixed footer with floating action bar pattern matching business
  forms
- Redesign invoice viewer with better space utilization and visual
  hierarchy
- Maintain professional appearance and consistent design system
- Fix Next.js 15 params Promise handling across all invoice pages
- Resolve TypeScript compilation errors and type-only imports
This commit is contained in:
2025-07-15 00:29:02 -04:00
parent 89de059501
commit f331136090
79 changed files with 9944 additions and 4223 deletions

View File

@@ -1,33 +1,45 @@
"use client";
import Link from "next/link";
import { useSession, signOut } from "next-auth/react";
import { useState } from "react";
import { Button } from "~/components/ui/button";
import { Skeleton } from "~/components/ui/skeleton";
import { Logo } from "./logo";
import { SidebarTrigger } from "./SidebarTrigger";
export function Navbar() {
const { data: session } = useSession();
const { data: session, status } = useSession();
const [isMobileNavOpen, setIsMobileNavOpen] = useState(false);
return (
<header className="fixed top-4 right-4 left-4 z-30 md:top-6 md:right-6 md:left-6">
<div className="rounded-xl border-0 bg-white/60 shadow-2xl backdrop-blur-md dark:bg-gray-900/60">
<header className="fixed top-2 right-2 left-2 z-30 md:top-3 md:right-3 md:left-3">
<div className="bg-background/60 border-border/40 relative rounded-2xl border shadow-lg backdrop-blur-xl backdrop-saturate-150">
<div className="flex h-14 items-center justify-between px-4 md:h-16 md:px-8">
<div className="flex items-center gap-4 md:gap-6">
<SidebarTrigger />
<SidebarTrigger
isOpen={isMobileNavOpen}
onToggle={() => setIsMobileNavOpen(!isMobileNavOpen)}
/>
<Link href="/dashboard" className="flex items-center gap-2">
<Logo size="md" />
</Link>
</div>
<div className="flex items-center gap-2 md:gap-4">
{session?.user ? (
{status === "loading" ? (
<>
<span className="hidden text-xs font-medium text-gray-700 sm:inline md:text-sm dark:text-gray-300">
<Skeleton className="bg-muted/20 hidden h-5 w-20 sm:inline" />
<Skeleton className="bg-muted/20 h-8 w-16" />
</>
) : session?.user ? (
<>
<span className="text-muted-foreground hidden text-xs font-medium sm:inline md:text-sm">
{session.user.name ?? session.user.email}
</span>
<Button
variant="outline"
size="sm"
onClick={() => signOut({ callbackUrl: "/" })}
className="border-gray-300 text-xs text-gray-700 hover:bg-gray-50 md:text-sm dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800"
className="border-border/40 hover:bg-accent/50 text-xs md:text-sm"
>
Sign Out
</Button>
@@ -38,7 +50,7 @@ export function Navbar() {
<Button
variant="ghost"
size="sm"
className="text-xs text-gray-700 hover:bg-gray-100 md:text-sm dark:text-gray-300 dark:hover:bg-gray-800"
className="hover:bg-accent/50 text-xs md:text-sm"
>
Sign In
</Button>
@@ -46,7 +58,7 @@ export function Navbar() {
<Link href="/auth/register">
<Button
size="sm"
className="bg-gradient-to-r from-emerald-600 to-teal-600 text-xs font-medium text-white hover:from-emerald-700 hover:to-teal-700 md:text-sm dark:from-emerald-500 dark:to-teal-500 dark:hover:from-emerald-600 dark:hover:to-teal-600"
className="bg-gradient-to-r from-emerald-600 to-teal-600 text-xs font-medium text-white shadow-md transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-lg md:text-sm"
>
Register
</Button>

View File

@@ -2,66 +2,62 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
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 },
];
import { useSession } from "next-auth/react";
import { Skeleton } from "~/components/ui/skeleton";
import { navigationConfig } from "~/lib/navigation";
export function Sidebar() {
const pathname = usePathname();
const { status } = useSession();
return (
<aside className="fixed top-28 bottom-6 left-6 z-20 hidden w-64 flex-col justify-between rounded-xl border-0 bg-white/60 p-8 shadow-2xl backdrop-blur-md md:flex dark:bg-gray-900/60">
<nav className="flex flex-col gap-1">
<div className="mb-2 text-xs font-semibold tracking-wider text-gray-400 uppercase dark:text-gray-500">
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-4 py-2 text-base font-medium transition-all duration-200 ${
pathname === link.href
? "bg-emerald-100 text-emerald-700 shadow-lg dark:bg-emerald-900/30 dark:text-emerald-400"
: "text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
}`}
>
<Icon className="h-5 w-5" />
{link.name}
</Link>
);
})}
<aside className="border-border/40 bg-background/60 fixed top-[5.75rem] bottom-3 left-3 z-20 hidden w-64 flex-col justify-between rounded-2xl border p-6 shadow-lg backdrop-blur-xl backdrop-saturate-150 md:flex">
<nav className="flex flex-col">
{navigationConfig.map((section, sectionIndex) => (
<div key={section.title} className={sectionIndex > 0 ? "mt-6" : ""}>
{sectionIndex > 0 && (
<div className="border-border/40 my-4 border-t" />
)}
<div className="text-muted-foreground mb-3 text-xs font-semibold tracking-wider uppercase">
{section.title}
</div>
<div className="flex flex-col gap-0.5">
{status === "loading" ? (
<>
{Array.from({ length: section.links.length }).map((_, i) => (
<div
key={i}
className="flex items-center gap-3 rounded-lg px-3 py-2.5"
>
<Skeleton className="bg-muted/20 h-4 w-4" />
<Skeleton className="bg-muted/20 h-4 w-20" />
</div>
))}
</>
) : (
section.links.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.5 text-sm font-medium transition-all duration-200 ${
pathname === link.href
? "bg-gradient-to-r from-emerald-600/10 to-teal-600/10 text-emerald-700 shadow-sm dark:from-emerald-500/20 dark:to-teal-500/20 dark:text-emerald-400"
: "text-foreground hover:bg-accent/50 hover:text-accent-foreground"
}`}
>
<Icon className="h-4 w-4" />
{link.name}
</Link>
);
})
)}
</div>
</div>
))}
</nav>
<div>
<div className="my-4 border-t border-gray-200 dark:border-gray-700" />
<div className="mb-2 text-xs font-semibold tracking-wider text-gray-400 uppercase dark:text-gray-500">
Account
</div>
<Link
href="/dashboard/settings"
className={`flex items-center gap-3 rounded-lg px-4 py-2 text-base font-medium transition-all duration-200 ${
pathname === "/dashboard/settings"
? "bg-emerald-100 text-emerald-700 shadow-lg dark:bg-emerald-900/30 dark:text-emerald-400"
: "text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
}`}
>
<Settings className="h-5 w-5" />
Settings
</Link>
</div>
</aside>
);
}

View File

@@ -1,97 +1,95 @@
"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 { Skeleton } from "~/components/ui/skeleton";
import { MenuIcon, X } from "lucide-react";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useSession } from "next-auth/react";
import { navigationConfig } from "~/lib/navigation";
const navLinks = [
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{ name: "Clients", href: "/dashboard/clients", icon: Users },
{ name: "Invoices", href: "/dashboard/invoices", icon: FileText },
];
interface SidebarTriggerProps {
isOpen: boolean;
onToggle: () => void;
}
export function SidebarTrigger() {
export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
const pathname = usePathname();
const [open, setOpen] = useState(false);
const { status } = useSession();
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button
variant="outline"
size="icon"
aria-label="Open sidebar"
className="h-8 w-8 border-gray-200 bg-white/80 shadow-lg backdrop-blur-sm hover:bg-white md:hidden dark:border-gray-600 dark:bg-gray-900/80 dark:hover:bg-gray-800"
>
<MenuIcon className="h-4 w-4" />
</Button>
</SheetTrigger>
<SheetContent
side="left"
className="w-80 max-w-[85vw] border-0 bg-white/95 p-0 backdrop-blur-sm dark:bg-gray-900/95"
<>
<Button
variant="outline"
size="icon"
aria-label="Toggle navigation"
onClick={onToggle}
className="bg-card/80 h-8 w-8 shadow-lg backdrop-blur-sm md:hidden"
>
<SheetHeader className="border-b border-gray-200 p-4 dark:border-gray-700">
<SheetTitle className="dark:text-white">Navigation</SheetTitle>
</SheetHeader>
{isOpen ? <X className="h-4 w-4" /> : <MenuIcon className="h-4 w-4" />}
</Button>
{/* Navigation */}
<nav className="flex flex-1 flex-col gap-1 p-4">
<div className="mb-2 text-xs font-semibold tracking-wider text-gray-400 uppercase dark:text-gray-500">
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 dark:bg-emerald-900/30 dark:text-emerald-400"
: "text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
}`}
onClick={() => setOpen(false)}
{/* Mobile dropdown navigation */}
{isOpen && (
<div className="bg-background/95 border-border/40 absolute top-full right-0 left-0 z-40 mt-2 rounded-2xl border shadow-2xl backdrop-blur-xl md:hidden">
{/* Navigation content */}
<nav className="flex flex-col p-4">
{navigationConfig.map((section, sectionIndex) => (
<div
key={section.title}
className={sectionIndex > 0 ? "mt-4" : ""}
>
<Icon className="h-5 w-5" />
{link.name}
</Link>
);
})}
<div className="my-4 border-t border-gray-200 dark:border-gray-700" />
<div className="mb-2 text-xs font-semibold tracking-wider text-gray-400 uppercase dark:text-gray-500">
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 dark:bg-emerald-900/30 dark:text-emerald-400"
: "text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
}`}
onClick={() => setOpen(false)}
>
<Settings className="h-5 w-5" />
Settings
</Link>
</nav>
</SheetContent>
</Sheet>
{sectionIndex > 0 && (
<div className="border-border/40 my-3 border-t" />
)}
<div className="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase">
{section.title}
</div>
<div className="flex flex-col gap-0.5">
{status === "loading" ? (
<>
{Array.from({ length: section.links.length }).map(
(_, i) => (
<div
key={i}
className="flex items-center gap-3 rounded-lg px-3 py-2.5"
>
<Skeleton className="bg-muted/20 h-4 w-4" />
<Skeleton className="bg-muted/20 h-4 w-20" />
</div>
),
)}
</>
) : (
section.links.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.5 text-sm font-medium transition-all duration-200 ${
pathname === link.href
? "bg-gradient-to-r from-emerald-600/10 to-teal-600/10 text-emerald-700 shadow-sm dark:from-emerald-500/20 dark:to-teal-500/20 dark:text-emerald-400"
: "text-foreground hover:bg-accent/50 hover:text-accent-foreground"
}`}
onClick={onToggle}
>
<Icon className="h-4 w-4" />
{link.name}
</Link>
);
})
)}
</div>
</div>
))}
</nav>
</div>
)}
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,36 +1,72 @@
"use client";
import { Building, Mail, MapPin, Phone, Save } from "lucide-react";
import { UserPlus, Mail, Phone, Save, Loader2, ArrowLeft } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useEffect, useState, useRef } from "react";
import { toast } from "sonner";
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 { AddressForm } from "~/components/ui/address-form";
import { FloatingActionBar } from "~/components/ui/floating-action-bar";
import { api } from "~/trpc/react";
import {
formatPhoneNumber,
isValidEmail,
VALIDATION_MESSAGES,
PLACEHOLDERS,
} from "~/lib/form-constants";
interface ClientFormProps {
clientId?: string;
mode: "create" | "edit";
}
interface FormData {
name: string;
email: string;
phone: string;
addressLine1: string;
addressLine2: string;
city: string;
state: string;
postalCode: string;
country: string;
}
interface FormErrors {
name?: string;
email?: string;
phone?: string;
addressLine1?: string;
city?: string;
state?: string;
postalCode?: string;
country?: string;
}
const initialFormData: FormData = {
name: "",
email: "",
phone: "",
addressLine1: "",
addressLine2: "",
city: "",
state: "",
postalCode: "",
country: "United States",
};
export function ClientForm({ clientId, mode }: ClientFormProps) {
const router = useRouter();
const [formData, setFormData] = useState({
name: "",
email: "",
phone: "",
addressLine1: "",
addressLine2: "",
city: "",
state: "",
postalCode: "",
country: "",
});
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState<FormData>(initialFormData);
const [errors, setErrors] = useState<FormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [isDirty, setIsDirty] = useState(false);
const footerRef = useRef<HTMLDivElement>(null);
// Fetch client data if editing
const { data: client, isLoading: isLoadingClient } =
@@ -71,14 +107,80 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
city: client.city ?? "",
state: client.state ?? "",
postalCode: client.postalCode ?? "",
country: client.country ?? "",
country: client.country ?? "United States",
});
}
}, [client, mode]);
const handleInputChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
setIsDirty(true);
// Clear error for this field when user starts typing
if (errors[field as keyof FormErrors]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
};
const handlePhoneChange = (value: string) => {
const formatted = formatPhoneNumber(value);
handleInputChange("phone", formatted);
};
const validateForm = (): boolean => {
const newErrors: FormErrors = {};
// Required fields
if (!formData.name.trim()) {
newErrors.name = VALIDATION_MESSAGES.required;
}
// Email validation
if (formData.email && !isValidEmail(formData.email)) {
newErrors.email = VALIDATION_MESSAGES.email;
}
// Phone validation (basic check for US format)
if (formData.phone) {
const phoneDigits = formData.phone.replace(/\D/g, "");
if (phoneDigits.length > 0 && phoneDigits.length < 10) {
newErrors.phone = VALIDATION_MESSAGES.phone;
}
}
// Address validation if any address field is filled
const hasAddressData =
formData.addressLine1 ||
formData.city ||
formData.state ||
formData.postalCode;
if (hasAddressData) {
if (!formData.addressLine1)
newErrors.addressLine1 = VALIDATION_MESSAGES.required;
if (!formData.city) newErrors.city = VALIDATION_MESSAGES.required;
if (!formData.country) newErrors.country = VALIDATION_MESSAGES.required;
if (formData.country === "US") {
if (!formData.state) newErrors.state = VALIDATION_MESSAGES.required;
if (!formData.postalCode)
newErrors.postalCode = VALIDATION_MESSAGES.required;
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
if (!validateForm()) {
toast.error("Please correct the errors in the form");
return;
}
setIsSubmitting(true);
try {
if (mode === "create") {
@@ -90,551 +192,233 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
});
}
} finally {
setLoading(false);
setIsSubmitting(false);
}
};
const handleInputChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
// Phone number formatting
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 handleCancel = () => {
if (isDirty) {
const confirmed = window.confirm(
"You have unsaved changes. Are you sure you want to leave?",
);
if (!confirmed) return;
}
router.push("/dashboard/clients");
};
const handlePhoneChange = (value: string) => {
const formatted = formatPhoneNumber(value);
handleInputChange("phone", formatted);
};
const US_STATES = [
"",
"AL",
"AK",
"AZ",
"AR",
"CA",
"CO",
"CT",
"DE",
"FL",
"GA",
"HI",
"ID",
"IL",
"IN",
"IA",
"KS",
"KY",
"LA",
"ME",
"MD",
"MA",
"MI",
"MN",
"MS",
"MO",
"MT",
"NE",
"NV",
"NH",
"NJ",
"NM",
"NY",
"NC",
"ND",
"OH",
"OK",
"OR",
"PA",
"RI",
"SC",
"SD",
"TN",
"TX",
"UT",
"VT",
"VA",
"WA",
"WV",
"WI",
"WY",
];
const MOST_USED_COUNTRIES = [
"United States",
"United Kingdom",
"Canada",
"Australia",
"Germany",
"France",
"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",
"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.includes(c),
).sort();
if (mode === "edit" && isLoadingClient) {
return (
<Card className="my-8 w-full border-0 bg-white/80 px-0 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
<CardContent className="p-8">
<FormSkeleton />
</CardContent>
</Card>
);
return <FormSkeleton />;
}
return (
<Card className="my-8 w-full border-0 bg-white/80 px-0 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
<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 dark:text-emerald-400">
<Building className="h-5 w-5" />
<h3 className="text-lg font-semibold dark:text-white">
Business Information
</h3>
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="mx-auto max-w-6xl">
<form onSubmit={handleSubmit} className="space-y-6">
{/* Main Form Container - styled like data table */}
<div className="space-y-4">
{/* Basic Information */}
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-r from-emerald-600/10 to-teal-600/10">
<UserPlus className="h-5 w-5 text-emerald-700 dark:text-emerald-400" />
</div>
<div>
<CardTitle>Basic Information</CardTitle>
<p className="text-muted-foreground mt-1 text-sm">
Enter the client's primary details
</p>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label
htmlFor="name"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
Business Name / Full Name *
<Label htmlFor="name" className="text-sm font-medium">
Client Name<span className="text-destructive ml-1">*</span>
</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => handleInputChange("name", e.target.value)}
required
placeholder="Enter business name or full name"
className="h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder={PLACEHOLDERS.name}
className={`${errors.name ? "border-destructive" : ""}`}
disabled={isSubmitting}
/>
{errors.name && (
<p className="text-destructive text-sm">{errors.name}</p>
)}
</div>
<div className="space-y-2">
<Label
htmlFor="email"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
Email Address
</Label>
<div className="relative">
<Mail className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400 dark:text-gray-500" />
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="email" className="text-sm font-medium">
Email
<span className="text-muted-foreground ml-1 text-xs font-normal">
(Optional)
</span>
</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => handleInputChange("email", e.target.value)}
placeholder="business@example.com"
className="h-12 border-gray-200 pl-10 focus:border-emerald-500 focus:ring-emerald-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder={PLACEHOLDERS.email}
className={`${errors.email ? "border-destructive" : ""}`}
disabled={isSubmitting}
/>
{errors.email && (
<p className="text-destructive text-sm">{errors.email}</p>
)}
</div>
</div>
</div>
</div>
{/* Contact Information Section */}
<div className="space-y-6">
<div className="flex items-center space-x-2 text-emerald-700 dark:text-emerald-400">
<Phone className="h-5 w-5" />
<h3 className="text-lg font-semibold dark:text-white">
Contact Information
</h3>
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="space-y-2">
<Label
htmlFor="phone"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
Phone Number
</Label>
<div className="relative">
<Phone className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400 dark:text-gray-500" />
<div className="space-y-2">
<Label htmlFor="phone" className="text-sm font-medium">
Phone
<span className="text-muted-foreground ml-1 text-xs font-normal">
(Optional)
</span>
</Label>
<Input
id="phone"
type="tel"
value={formData.phone}
onChange={(e) => handlePhoneChange(e.target.value)}
placeholder="(555) 123-4567"
maxLength={14}
className="h-12 border-gray-200 pl-10 focus:border-emerald-500 focus:ring-emerald-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder={PLACEHOLDERS.phone}
className={`${errors.phone ? "border-destructive" : ""}`}
disabled={isSubmitting}
/>
{errors.phone && (
<p className="text-destructive text-sm">{errors.phone}</p>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
Format: (555) 123-4567
</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Address Section */}
<div className="space-y-6">
<div className="flex items-center space-x-2 text-emerald-700 dark:text-emerald-400">
<MapPin className="h-5 w-5" />
<h3 className="text-lg font-semibold dark:text-white">
Address Information
</h3>
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="space-y-2">
<Label
htmlFor="addressLine1"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
Address Line 1
</Label>
<Input
id="addressLine1"
value={formData.addressLine1}
onChange={(e) =>
handleInputChange("addressLine1", e.target.value)
}
placeholder="123 Main Street"
className="h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
{/* Address */}
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-r from-emerald-600/10 to-teal-600/10">
<svg
className="h-5 w-5 text-emerald-700 dark:text-emerald-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</div>
<div>
<CardTitle>Address</CardTitle>
<p className="text-muted-foreground mt-1 text-sm">
Client's physical location
</p>
</div>
</div>
<div className="space-y-2">
<Label
htmlFor="addressLine2"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
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 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
<div className="space-y-2">
<Label
htmlFor="city"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
City
</Label>
<Input
id="city"
value={formData.city}
onChange={(e) => handleInputChange("city", e.target.value)}
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 dark:text-gray-300"
>
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-sm focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
{US_STATES.map((state) => (
<option key={state} value={state}>
{state || "Select State"}
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label
htmlFor="postalCode"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
Postal Code
</Label>
<Input
id="postalCode"
value={formData.postalCode}
onChange={(e) =>
handleInputChange("postalCode", e.target.value)
}
placeholder="12345"
className="h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
</div>
<div className="space-y-2">
<Label
htmlFor="country"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
Country
</Label>
<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-sm focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="">Select Country</option>
<optgroup label="Most Used">
{MOST_USED_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>
</CardHeader>
<CardContent>
<AddressForm
addressLine1={formData.addressLine1}
addressLine2={formData.addressLine2}
city={formData.city}
state={formData.state}
postalCode={formData.postalCode}
country={formData.country}
onChange={handleInputChange}
errors={errors}
required={false}
/>
</CardContent>
</Card>
</div>
{/* Submit Button */}
<div className="flex gap-3 pt-6">
{/* Form Actions - original position */}
<div
ref={footerRef}
className="border-border/40 bg-background/60 flex items-center justify-between rounded-2xl border p-4 shadow-lg backdrop-blur-xl backdrop-saturate-150"
>
<p className="text-muted-foreground text-sm">
{mode === "create"
? "Creating a new client"
: "Editing client details"}
</p>
<div className="flex items-center gap-3">
<Button
type="button"
variant="outline"
onClick={handleCancel}
disabled={isSubmitting}
className="border-border/40 hover:bg-accent/50"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Cancel
</Button>
<Button
type="submit"
disabled={loading}
className="bg-gradient-to-r from-emerald-600 to-teal-600 font-medium text-white shadow-lg transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-xl"
disabled={isSubmitting || !isDirty}
className="bg-gradient-to-r from-emerald-600 to-teal-600 shadow-md transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-lg"
>
{loading ? (
{isSubmitting ? (
<>
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
{mode === "create" ? "Creating..." : "Updating..."}
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{mode === "create" ? "Creating..." : "Saving..."}
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
{mode === "create" ? "Create Client" : "Update Client"}
{mode === "create" ? "Create Client" : "Save Changes"}
</>
)}
</Button>
<Button
type="button"
variant="outline"
onClick={() => router.push("/dashboard/clients")}
className="border-gray-300 font-medium text-gray-700 hover:bg-gray-50"
>
Cancel
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</form>
<FloatingActionBar
triggerRef={footerRef}
title={
mode === "create" ? "Creating a new client" : "Editing client details"
}
>
<Button
type="button"
variant="outline"
onClick={handleCancel}
disabled={isSubmitting}
className="border-border/40 hover:bg-accent/50"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={isSubmitting || !isDirty}
className="bg-gradient-to-r from-emerald-600 to-teal-600 shadow-md transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-lg"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{mode === "create" ? "Creating..." : "Saving..."}
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
{mode === "create" ? "Create Client" : "Save Changes"}
</>
)}
</Button>
</FloatingActionBar>
</div>
);
}

View File

@@ -1,13 +1,29 @@
"use client";
import { AlertCircle, Clock, DollarSign, Eye, FileText, Trash2, Upload, Users } from "lucide-react";
import {
AlertCircle,
Clock,
DollarSign,
Eye,
FileText,
Trash2,
Upload,
Users,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { DatePicker } from "~/components/ui/date-picker";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { FileUpload } from "~/components/ui/file-upload";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
@@ -47,12 +63,15 @@ export function CSVImportPage() {
const [files, setFiles] = useState<FileData[]>([]);
const [globalClientId, setGlobalClientId] = useState("");
const [previewModalOpen, setPreviewModalOpen] = useState(false);
const [selectedFileIndex, setSelectedFileIndex] = useState<number | null>(null);
const [selectedFileIndex, setSelectedFileIndex] = useState<number | null>(
null,
);
const [isProcessing, setIsProcessing] = useState(false);
const [progressCount, setProgressCount] = useState(0);
// Fetch clients for dropdown
const { data: clients, isLoading: loadingClients } = api.clients.getAll.useQuery();
const { data: clients, isLoading: loadingClients } =
api.clients.getAll.useQuery();
const createInvoice = api.invoices.create.useMutation({
onSuccess: () => {
@@ -65,7 +84,7 @@ export function CSVImportPage() {
const parseCSVLine = (line: string): string[] => {
const result: string[] = [];
let current = '';
let current = "";
let inQuotes = false;
let i = 0;
@@ -83,10 +102,10 @@ export function CSVImportPage() {
inQuotes = !inQuotes;
i++;
}
} else if (char === ',' && !inQuotes) {
} else if (char === "," && !inQuotes) {
// End of field
result.push(current.trim());
current = '';
current = "";
i++;
} else {
// Regular character
@@ -101,39 +120,40 @@ export function CSVImportPage() {
};
const parseCSV = (csvText: string): CSVRow[] => {
const lines = csvText.split('\n');
const headers = parseCSVLine(lines[0] ?? '');
const lines = csvText.split("\n");
const headers = parseCSVLine(lines[0] ?? "");
// Validate headers
const requiredHeaders = ['DATE', 'DESCRIPTION', 'HOURS', 'RATE', 'AMOUNT'];
const missingHeaders = requiredHeaders.filter(h => !headers?.includes(h));
const requiredHeaders = ["DATE", "DESCRIPTION", "HOURS", "RATE", "AMOUNT"];
const missingHeaders = requiredHeaders.filter((h) => !headers?.includes(h));
if (missingHeaders.length > 0) {
throw new Error(`Missing required headers: ${missingHeaders.join(', ')}`);
throw new Error(`Missing required headers: ${missingHeaders.join(", ")}`);
}
return lines.slice(1)
.filter(line => line.trim())
.map(line => {
return lines
.slice(1)
.filter((line) => line.trim())
.map((line) => {
const values = parseCSVLine(line);
return {
DATE: values[0] ?? '',
DESCRIPTION: values[1] ?? '',
HOURS: parseFloat(values[2] ?? '0') || 0,
RATE: parseFloat(values[3] ?? '0') || 0,
AMOUNT: parseFloat(values[4] ?? '0') || 0,
DATE: values[0] ?? "",
DESCRIPTION: values[1] ?? "",
HOURS: parseFloat(values[2] ?? "0") || 0,
RATE: parseFloat(values[3] ?? "0") || 0,
AMOUNT: parseFloat(values[4] ?? "0") || 0,
};
})
.filter(row => row.DESCRIPTION && row.HOURS > 0 && row.RATE > 0);
.filter((row) => row.DESCRIPTION && row.HOURS > 0 && row.RATE > 0);
};
const parseDate = (dateStr: string): Date => {
// Handle m/dd/yy format
const parts = dateStr.split('/');
const parts = dateStr.split("/");
if (parts.length === 3) {
const month = parseInt(parts[0] ?? '1') - 1; // 0-based month
const day = parseInt(parts[1] ?? '1');
const year = parseInt(parts[2] ?? '2000') + 2000; // Assume 20xx
const month = parseInt(parts[0] ?? "1") - 1; // 0-based month
const day = parseInt(parts[1] ?? "1");
const year = parseInt(parts[2] ?? "2000") + 2000; // Assume 20xx
return new Date(year, month, day);
}
// Fallback to standard date parsing
@@ -169,7 +189,7 @@ export function CSVImportPage() {
const csvData = parseCSV(text);
// Parse items for invoice creation
const items = csvData.map(row => ({
const items = csvData.map((row) => ({
date: parseDate(row.DATE),
description: row.DESCRIPTION,
hours: row.HOURS,
@@ -181,24 +201,29 @@ export function CSVImportPage() {
file,
parsedItems: items,
previewData: csvData,
invoiceNumber: issueDate ? `INV-${issueDate.toISOString().slice(0, 10).replace(/-/g, '')}-${Date.now().toString().slice(-6)}` : `INV-${Date.now()}`,
invoiceNumber: issueDate
? `INV-${issueDate.toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`
: `INV-${Date.now()}`,
clientId: globalClientId, // Use global client if set
issueDate,
dueDate,
status: errors.length > 0 ? "error" : "pending",
errors,
hasDateError
hasDateError,
};
setFiles(prev => [...prev, fileData]);
setFiles((prev) => [...prev, fileData]);
if (errors.length > 0) {
toast.error(`${file.name} has ${errors.length} error${errors.length > 1 ? 's' : ''}`);
toast.error(
`${file.name} has ${errors.length} error${errors.length > 1 ? "s" : ""}`,
);
} else {
toast.success(`Parsed ${items.length} items from ${file.name}`);
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
const errorMessage =
error instanceof Error ? error.message : "Unknown error occurred";
const fileData: FileData = {
file,
parsedItems: [],
@@ -209,61 +234,74 @@ export function CSVImportPage() {
dueDate: null,
status: "error",
errors: [`Error parsing CSV: ${errorMessage}`],
hasDateError: true
hasDateError: true,
};
setFiles(prev => [...prev, fileData]);
setFiles((prev) => [...prev, fileData]);
toast.error(`Error parsing ${file.name}: ${errorMessage}`);
}
}
};
const removeFile = (index: number) => {
setFiles(prev => prev.filter((_, i) => i !== index));
setFiles((prev) => prev.filter((_, i) => i !== index));
};
// Apply global client to all files that don't have a client selected
const applyGlobalClient = (clientId: string) => {
setFiles(prev => prev.map(file => ({
...file,
clientId: file.clientId || clientId // Only apply if no client is already selected
})));
setFiles((prev) =>
prev.map((file) => ({
...file,
clientId: file.clientId || clientId, // Only apply if no client is already selected
})),
);
};
const updateFileData = (index: number, updates: Partial<FileData>) => {
setFiles(prev => prev.map((file, i) => {
if (i !== index) return file;
const updatedFile = { ...file, ...updates };
// Recalculate errors if issue date or due date was updated
if (updates.issueDate !== undefined || updates.dueDate !== undefined) {
const newErrors = [...updatedFile.errors];
// Remove filename format error if a valid issue date is now set
if (updatedFile.issueDate && newErrors.includes("Filename must be in YYYY-MM-DD.csv format")) {
const errorIndex = newErrors.indexOf("Filename must be in YYYY-MM-DD.csv format");
if (errorIndex > -1) {
newErrors.splice(errorIndex, 1);
setFiles((prev) =>
prev.map((file, i) => {
if (i !== index) return file;
const updatedFile = { ...file, ...updates };
// Recalculate errors if issue date or due date was updated
if (updates.issueDate !== undefined || updates.dueDate !== undefined) {
const newErrors = [...updatedFile.errors];
// Remove filename format error if a valid issue date is now set
if (
updatedFile.issueDate &&
newErrors.includes("Filename must be in YYYY-MM-DD.csv format")
) {
const errorIndex = newErrors.indexOf(
"Filename must be in YYYY-MM-DD.csv format",
);
if (errorIndex > -1) {
newErrors.splice(errorIndex, 1);
}
}
}
// Remove invalid date error if a valid issue date is now set
if (updatedFile.issueDate && newErrors.includes("Invalid date in filename")) {
const errorIndex = newErrors.indexOf("Invalid date in filename");
if (errorIndex > -1) {
newErrors.splice(errorIndex, 1);
// Remove invalid date error if a valid issue date is now set
if (
updatedFile.issueDate &&
newErrors.includes("Invalid date in filename")
) {
const errorIndex = newErrors.indexOf("Invalid date in filename");
if (errorIndex > -1) {
newErrors.splice(errorIndex, 1);
}
}
updatedFile.errors = newErrors;
updatedFile.status = newErrors.length > 0 ? "error" : "pending";
updatedFile.hasDateError = newErrors.some(
(error) =>
error.includes("Filename") || error.includes("Invalid date"),
);
}
updatedFile.errors = newErrors;
updatedFile.status = newErrors.length > 0 ? "error" : "pending";
updatedFile.hasDateError = newErrors.some(error =>
error.includes("Filename") || error.includes("Invalid date")
);
}
return updatedFile;
}));
return updatedFile;
}),
);
};
const openPreview = (index: number) => {
@@ -273,13 +311,13 @@ export function CSVImportPage() {
const validateFiles = () => {
const errors: string[] = [];
files.forEach((fileData) => {
// Check for existing errors
if (fileData.errors.length > 0) {
errors.push(`${fileData.file.name}: ${fileData.errors.join(', ')}`);
errors.push(`${fileData.file.name}: ${fileData.errors.join(", ")}`);
}
if (!fileData.clientId && !globalClientId) {
errors.push(`${fileData.file.name}: Client not selected`);
}
@@ -300,7 +338,7 @@ export function CSVImportPage() {
const processBatch = async () => {
const errors = validateFiles();
if (errors.length > 0) {
toast.error(`Please fix the following issues:\n${errors.join('\n')}`);
toast.error(`Please fix the following issues:\n${errors.join("\n")}`);
return;
}
@@ -336,7 +374,7 @@ export function CSVImportPage() {
dueDate: fileData.dueDate,
status: "draft" as const,
notes: `Imported from CSV: ${fileData.file.name}`,
items: fileData.parsedItems.map(item => ({
items: fileData.parsedItems.map((item) => ({
date: item.date,
description: item.description,
hours: item.hours,
@@ -345,26 +383,36 @@ export function CSVImportPage() {
})),
};
console.log('Creating invoice with data:', invoiceData);
console.log("Creating invoice with data:", invoiceData);
await createInvoice.mutateAsync(invoiceData);
console.log('Invoice created successfully');
console.log("Invoice created successfully");
successCount++;
} catch (error) {
errorCount++;
console.error(`Failed to create invoice for ${fileData.file.name}:`, error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
toast.error(`Failed to create invoice for ${fileData.file.name}: ${errorMessage}`);
console.error(
`Failed to create invoice for ${fileData.file.name}:`,
error,
);
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
toast.error(
`Failed to create invoice for ${fileData.file.name}: ${errorMessage}`,
);
}
setProgressCount(prev => prev + 1);
setProgressCount((prev) => prev + 1);
}
setIsProcessing(false);
if (successCount > 0) {
toast.success(`Successfully created ${successCount} invoice${successCount > 1 ? 's' : ''}`);
toast.success(
`Successfully created ${successCount} invoice${successCount > 1 ? "s" : ""}`,
);
}
if (errorCount > 0) {
toast.error(`Failed to create ${errorCount} invoice${errorCount > 1 ? 's' : ''}`);
toast.error(
`Failed to create ${errorCount} invoice${errorCount > 1 ? "s" : ""}`,
);
}
if (successCount > 0) {
@@ -373,19 +421,24 @@ export function CSVImportPage() {
};
const totalFiles = files.length;
const readyFiles = files.filter(f =>
f.errors.length === 0 &&
(f.clientId || globalClientId) &&
f.issueDate &&
f.dueDate
const readyFiles = files.filter(
(f) =>
f.errors.length === 0 &&
(f.clientId || globalClientId) &&
f.issueDate &&
f.dueDate,
).length;
const totalItems = files.reduce((sum, f) => sum + f.parsedItems.length, 0);
const totalAmount = files.reduce((sum, f) => sum + f.parsedItems.reduce((itemSum, item) => itemSum + item.amount, 0), 0);
const totalAmount = files.reduce(
(sum, f) =>
sum + f.parsedItems.reduce((itemSum, item) => itemSum + item.amount, 0),
0,
);
return (
<div className="space-y-6">
{/* Global Client Selection */}
<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="flex items-center gap-2 text-emerald-800">
<Users className="h-5 w-5" />
@@ -394,7 +447,7 @@ export function CSVImportPage() {
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="global-client" className="text-sm font-medium text-gray-700">
<Label htmlFor="global-client" className="text-sm font-medium">
Select Default Client (Optional)
</Label>
<select
@@ -411,19 +464,22 @@ export function CSVImportPage() {
disabled={loadingClients}
>
<option value="">No default client (select individually)</option>
{clients?.map(client => (
<option key={client.id} value={client.id}>{client.name}</option>
{clients?.map((client) => (
<option key={client.id} value={client.id}>
{client.name}
</option>
))}
</select>
<p className="text-xs text-gray-500">
This client will be automatically selected for all uploaded files. You can still change individual files below.
This client will be automatically selected for all uploaded files.
You can still change individual files below.
</p>
</div>
</CardContent>
</Card>
{/* File Upload Area */}
<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="flex items-center gap-2 text-emerald-800">
<Upload className="h-5 w-5" />
@@ -442,24 +498,33 @@ export function CSVImportPage() {
{/* Summary Stats */}
{totalFiles > 0 && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 p-4 bg-emerald-50/50 rounded-lg">
<div className="grid grid-cols-2 gap-4 rounded-lg bg-emerald-50/50 p-4 md:grid-cols-4">
<div className="text-center">
<div className="text-2xl font-bold text-emerald-600">{totalFiles}</div>
<div className="text-2xl font-bold text-emerald-600">
{totalFiles}
</div>
<div className="text-sm text-gray-600">Files</div>
<div className="text-xs text-gray-500">of 50 max</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-emerald-600">{totalItems}</div>
<div className="text-2xl font-bold text-emerald-600">
{totalItems}
</div>
<div className="text-sm text-gray-600">Total Items</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-emerald-600">
{totalAmount.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}
{totalAmount.toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}
</div>
<div className="text-sm text-gray-600">Total Amount</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-emerald-600">{readyFiles}/{totalFiles}</div>
<div className="text-2xl font-bold text-emerald-600">
{readyFiles}/{totalFiles}
</div>
<div className="text-sm text-gray-600">Ready</div>
</div>
</div>
@@ -469,21 +534,30 @@ export function CSVImportPage() {
{/* File List */}
{files.length > 0 && (
<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-emerald-800">Uploaded Files</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{files.map((fileData, index) => (
<div key={index} className="border border-gray-200 rounded-lg p-4 bg-white">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
<div
key={index}
className="rounded-lg border border-gray-200 bg-white p-4"
>
<div className="mb-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<FileText className="h-5 w-5 text-emerald-600" />
<div>
<h3 className="font-medium text-gray-900 truncate">{fileData.file.name}</h3>
<h3 className="truncate font-medium text-gray-900">
{fileData.file.name}
</h3>
<p className="text-sm text-gray-500">
{fileData.parsedItems.length} items {fileData.parsedItems.reduce((sum, item) => sum + item.hours, 0).toFixed(1)} hours
{fileData.parsedItems.length} items {" "}
{fileData.parsedItems
.reduce((sum, item) => sum + item.hours, 0)
.toFixed(1)}{" "}
hours
</p>
</div>
</div>
@@ -508,62 +582,81 @@ export function CSVImportPage() {
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-2">
<Label className="text-xs font-medium text-gray-700">Invoice Number</Label>
<Label className="text-xs font-medium text-gray-700">
Invoice Number
</Label>
<Input
value={fileData.invoiceNumber}
className="h-9 text-sm bg-gray-50"
className="h-9 text-sm"
placeholder="Auto-generated"
readOnly
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-gray-700">Client</Label>
<Label className="text-xs font-medium">Client</Label>
<select
value={fileData.clientId}
onChange={(e) => updateFileData(index, { clientId: e.target.value })}
className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 py-1 text-sm text-gray-700 focus:border-emerald-500 focus:ring-emerald-500"
onChange={(e) =>
updateFileData(index, { clientId: e.target.value })
}
className="h-9 w-full rounded-md border px-3 py-1 text-sm"
>
<option value="">Select client</option>
{clients?.map(client => (
<option key={client.id} value={client.id}>{client.name}</option>
{clients?.map((client) => (
<option key={client.id} value={client.id}>
{client.name}
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-gray-700">Issue Date</Label>
<Label className="text-xs font-medium text-gray-700">
Issue Date
</Label>
<DatePicker
date={fileData.issueDate ?? undefined}
onDateChange={(date) => updateFileData(index, { issueDate: date ?? null })}
onDateChange={(date) =>
updateFileData(index, { issueDate: date ?? null })
}
placeholder="Select issue date"
className="h-9"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-gray-700">Due Date</Label>
<Label className="text-xs font-medium text-gray-700">
Due Date
</Label>
<DatePicker
date={fileData.dueDate ?? undefined}
onDateChange={(date) => updateFileData(index, { dueDate: date ?? null })}
onDateChange={(date) =>
updateFileData(index, { dueDate: date ?? null })
}
placeholder="Select due date"
className="h-9"
/>
</div>
</div>
</div>
{/* Error Display */}
{fileData.errors.length > 0 && (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<div className="mt-4 rounded-lg border border-red-200 bg-red-50 p-3">
<div className="mb-2 flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-red-600" />
<span className="text-sm font-medium text-red-800">Issues Found</span>
<span className="text-sm font-medium text-red-800">
Issues Found
</span>
</div>
<ul className="text-sm text-red-700 space-y-1">
<ul className="space-y-1 text-sm text-red-700">
{fileData.errors.map((error, errorIndex) => (
<li key={errorIndex} className="flex items-start gap-2">
<li
key={errorIndex}
className="flex items-start gap-2"
>
<span className="text-red-600"></span>
<span>{error}</span>
</li>
@@ -574,20 +667,39 @@ export function CSVImportPage() {
<div className="mt-4 flex items-center justify-between">
<div className="text-sm text-gray-600">
Total: {fileData.parsedItems.reduce((sum, item) => sum + item.amount, 0).toLocaleString('en-US', { style: 'currency', currency: 'USD' })}
Total:{" "}
{fileData.parsedItems
.reduce((sum, item) => sum + item.amount, 0)
.toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}
</div>
<div className="flex items-center gap-2">
{fileData.errors.length > 0 && (
<Badge variant="destructive" className="text-xs">
{fileData.errors.length} Error{fileData.errors.length !== 1 ? 's' : ''}
{fileData.errors.length} Error
{fileData.errors.length !== 1 ? "s" : ""}
</Badge>
)}
<Badge variant={
fileData.errors.length > 0 ? "destructive" :
(fileData.clientId || globalClientId) && fileData.issueDate && fileData.dueDate ? "default" : "secondary"
}>
{fileData.errors.length > 0 ? "Has Errors" :
(fileData.clientId || globalClientId) && fileData.issueDate && fileData.dueDate ? "Ready" : "Pending"}
<Badge
variant={
fileData.errors.length > 0
? "destructive"
: (fileData.clientId || globalClientId) &&
fileData.issueDate &&
fileData.dueDate
? "default"
: "secondary"
}
>
{fileData.errors.length > 0
? "Has Errors"
: (fileData.clientId || globalClientId) &&
fileData.issueDate &&
fileData.dueDate
? "Ready"
: "Pending"}
</Badge>
</div>
</div>
@@ -600,25 +712,31 @@ export function CSVImportPage() {
{/* Batch Actions */}
{files.length > 0 && (
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm">
<CardContent>
<div className="flex flex-col gap-4">
{isProcessing && (
<div className="w-full flex flex-col gap-2">
<span className="text-xs text-gray-500">Uploading invoices...</span>
<Progress value={Math.round((progressCount / totalFiles) * 100)} />
<div className="flex w-full flex-col gap-2">
<span className="text-xs text-gray-500">
Uploading invoices...
</span>
<Progress
value={Math.round((progressCount / totalFiles) * 100)}
/>
</div>
)}
<div className="flex justify-between items-center">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600">
{readyFiles} of {totalFiles} files ready for import
</div>
<Button
onClick={processBatch}
disabled={readyFiles === 0 || isProcessing}
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white"
className="bg-gradient-to-r from-emerald-600 to-teal-600 text-white hover:from-emerald-700 hover:to-teal-700"
>
{isProcessing ? "Processing..." : `Import ${readyFiles} Invoice${readyFiles !== 1 ? 's' : ''}`}
{isProcessing
? "Processing..."
: `Import ${readyFiles} Invoice${readyFiles !== 1 ? "s" : ""}`}
</Button>
</div>
</div>
@@ -628,11 +746,12 @@ export function CSVImportPage() {
{/* Preview Modal */}
<Dialog open={previewModalOpen} onOpenChange={setPreviewModalOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col bg-white/95 backdrop-blur-sm border-0 shadow-2xl">
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col border-0 bg-white/95 shadow-2xl backdrop-blur-sm">
<DialogHeader className="flex-shrink-0">
<DialogTitle className="text-xl font-bold text-gray-800 flex items-center gap-2">
<DialogTitle className="flex items-center gap-2 text-xl font-bold text-gray-800">
<FileText className="h-5 w-5 text-emerald-600" />
{selectedFileIndex !== null && files[selectedFileIndex]?.file.name}
{selectedFileIndex !== null &&
files[selectedFileIndex]?.file.name}
</DialogTitle>
<DialogDescription className="text-gray-600">
Preview of parsed CSV data
@@ -640,49 +759,90 @@ export function CSVImportPage() {
</DialogHeader>
{selectedFileIndex !== null && files[selectedFileIndex] && (
<div className="flex-1 flex flex-col min-h-0 space-y-4">
<div className="flex-shrink-0 grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="flex min-h-0 flex-1 flex-col space-y-4">
<div className="grid flex-shrink-0 grid-cols-1 gap-4 md:grid-cols-3">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-emerald-600" />
<span className="text-sm text-gray-600">{files[selectedFileIndex].parsedItems.length} items</span>
<span className="text-sm text-gray-600">
{files[selectedFileIndex].parsedItems.length} items
</span>
</div>
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-emerald-600" />
<span className="text-sm text-gray-600">
{files[selectedFileIndex].parsedItems.reduce((sum, item) => sum + item.hours, 0).toFixed(1)} total hours
{files[selectedFileIndex].parsedItems
.reduce((sum, item) => sum + item.hours, 0)
.toFixed(1)}{" "}
total hours
</span>
</div>
<div className="flex items-center gap-2">
<DollarSign className="h-4 w-4 text-emerald-600" />
<span className="text-sm text-gray-600 font-medium">
{files[selectedFileIndex].parsedItems.reduce((sum, item) => sum + item.amount, 0).toLocaleString('en-US', { style: 'currency', currency: 'USD' })}
<span className="text-sm font-medium text-gray-600">
{files[selectedFileIndex].parsedItems
.reduce((sum, item) => sum + item.amount, 0)
.toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}
</span>
</div>
</div>
<div className="flex-1 min-h-0 overflow-hidden">
<div className="min-h-0 flex-1 overflow-hidden">
<div className="max-h-96 overflow-y-auto">
<div className="overflow-x-auto">
<table className="w-full text-sm min-w-[600px]">
<thead className="bg-gray-50 sticky top-0">
<table className="w-full min-w-[600px] text-sm">
<thead className="sticky top-0 bg-gray-50">
<tr>
<th className="text-left p-2 font-medium text-gray-700 whitespace-nowrap">Date</th>
<th className="text-left p-2 font-medium text-gray-700">Description</th>
<th className="text-right p-2 font-medium text-gray-700 whitespace-nowrap">Hours</th>
<th className="text-right p-2 font-medium text-gray-700 whitespace-nowrap">Rate</th>
<th className="text-right p-2 font-medium text-gray-700 whitespace-nowrap">Amount</th>
<th className="p-2 text-left font-medium whitespace-nowrap text-gray-700">
Date
</th>
<th className="p-2 text-left font-medium text-gray-700">
Description
</th>
<th className="p-2 text-right font-medium whitespace-nowrap text-gray-700">
Hours
</th>
<th className="p-2 text-right font-medium whitespace-nowrap text-gray-700">
Rate
</th>
<th className="p-2 text-right font-medium whitespace-nowrap text-gray-700">
Amount
</th>
</tr>
</thead>
<tbody>
{files[selectedFileIndex].parsedItems.map((item, index) => (
<tr key={index} className="border-b border-gray-100">
<td className="p-2 text-gray-600 whitespace-nowrap">{item.date.toLocaleDateString()}</td>
<td className="p-2 text-gray-600 max-w-xs truncate">{item.description}</td>
<td className="p-2 text-gray-600 text-right whitespace-nowrap">{item.hours}</td>
<td className="p-2 text-gray-600 text-right whitespace-nowrap">{item.rate.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}</td>
<td className="p-2 text-gray-600 text-right font-medium whitespace-nowrap">{item.amount.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}</td>
</tr>
))}
{files[selectedFileIndex].parsedItems.map(
(item, index) => (
<tr
key={index}
className="border-b border-gray-100"
>
<td className="p-2 whitespace-nowrap text-gray-600">
{item.date.toLocaleDateString()}
</td>
<td className="max-w-xs truncate p-2 text-gray-600">
{item.description}
</td>
<td className="p-2 text-right whitespace-nowrap text-gray-600">
{item.hours}
</td>
<td className="p-2 text-right whitespace-nowrap text-gray-600">
{item.rate.toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}
</td>
<td className="p-2 text-right font-medium whitespace-nowrap text-gray-600">
{item.amount.toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}
</td>
</tr>
),
)}
</tbody>
</table>
</div>
@@ -703,4 +863,4 @@ export function CSVImportPage() {
</Dialog>
</div>
);
}
}

View File

@@ -1,285 +0,0 @@
"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 space-y-8 p-8">
{/* Header */}
<div className="space-y-4 text-center">
<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="border-input placeholder:text-muted-foreground focus-visible:ring-ring dark:bg-input/30 flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-sm shadow-xs transition-colors focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
>
<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="rounded-md bg-gray-50 p-3 dark:bg-gray-700">
<p className="text-sm text-gray-700 dark:text-gray-300">
Light Background
</p>
</div>
<div className="rounded-md bg-gray-100 p-3 dark:bg-gray-600">
<p className="text-sm text-gray-700 dark:text-gray-300">
Medium Background
</p>
</div>
<div className="rounded-md border border-gray-200 bg-white p-3 dark:border-gray-600 dark:bg-gray-800">
<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: &quot;media&quot;
</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
CSS Variables:
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
oklch() color space
</p>
</div>
</div>
</CardContent>
</Card>
{/* Instructions */}
<Card className="border-blue-200 dark:border-blue-800 dark:bg-gray-800">
<CardHeader>
<CardTitle className="text-blue-700 dark:text-blue-300">
Testing Instructions
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-blue-600 dark:text-blue-400">
<p>
Change your system theme between light and dark to test automatic
switching
</p>
<p> All UI elements should adapt colors automatically</p>
<p> Text should remain readable in both modes</p>
<p> Icons and buttons should have appropriate contrast</p>
<p> Form elements should be clearly visible and functional</p>
</CardContent>
</Card>
</div>
);
}

View File

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

View File

@@ -15,6 +15,7 @@ import React from "react";
import { api } from "~/trpc/react";
import { format } from "date-fns";
import { Skeleton } from "~/components/ui/skeleton";
import { getRouteLabel, capitalize } from "~/lib/pluralize";
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(
@@ -22,68 +23,126 @@ function isUUID(str: string) {
);
}
// Special segment labels
const SPECIAL_SEGMENTS: Record<string, string> = {
new: "New",
edit: "Edit",
import: "Import",
export: "Export",
dashboard: "Dashboard",
};
export function DashboardBreadcrumbs() {
const pathname = usePathname();
const segments = pathname.split("/").filter(Boolean);
// Find clientId if present
let clientId: string | undefined = undefined;
if (segments[1] === "clients" && segments[2] && isUUID(segments[2])) {
clientId = segments[2];
}
// Determine resource type and ID from path
const resourceType = segments[1]; // e.g., 'clients', 'invoices', 'businesses'
const resourceId =
segments[2] && isUUID(segments[2]) ? segments[2] : undefined;
const action = segments[3]; // e.g., 'edit'
// Fetch client data if needed
const { data: client, isLoading: clientLoading } =
api.clients.getById.useQuery(
{ id: clientId ?? "" },
{ enabled: !!clientId },
{ id: resourceId ?? "" },
{ enabled: resourceType === "clients" && !!resourceId },
);
// Find invoiceId if present
let invoiceId: string | undefined = undefined;
if (segments[1] === "invoices" && segments[2] && isUUID(segments[2])) {
invoiceId = segments[2];
}
// Fetch invoice data if needed
const { data: invoice, isLoading: invoiceLoading } =
api.invoices.getById.useQuery(
{ id: invoiceId ?? "" },
{ enabled: !!invoiceId },
{ id: resourceId ?? "" },
{ enabled: resourceType === "invoices" && !!resourceId },
);
// Fetch business data if needed
const { data: business, isLoading: businessLoading } =
api.businesses.getById.useQuery(
{ id: resourceId ?? "" },
{ enabled: resourceType === "businesses" && !!resourceId },
);
// Generate breadcrumb items based on pathname
const breadcrumbs = React.useMemo(() => {
const items = [];
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
const path = `/${segments.slice(0, i + 1).join("/")}`;
// Skip dashboard segment as it's always shown as root
if (segment === "dashboard") continue;
let label: string | React.ReactElement = segment ?? "";
if (segment === "clients") label = "Clients";
if (isUUID(segment ?? "") && clientLoading)
label = <Skeleton className="inline-block h-5 w-24 align-middle" />;
else if (isUUID(segment ?? "") && client) label = client.name ?? "";
if (isUUID(segment ?? "") && invoiceLoading)
label = <Skeleton className="inline-block h-5 w-24 align-middle" />;
else if (isUUID(segment ?? "") && invoice) {
const issueDate = new Date(invoice.issueDate);
label = format(issueDate, "MMM dd, yyyy");
let label: string | React.ReactElement = "";
let shouldShow = true;
// Handle UUID segments
if (segment && isUUID(segment)) {
// Determine which resource we're looking at
const prevSegment = segments[i - 1];
if (prevSegment === "clients") {
if (clientLoading) {
label = <Skeleton className="inline-block h-5 w-24 align-middle" />;
} else if (client) {
label = client.name;
}
} else if (prevSegment === "invoices") {
if (invoiceLoading) {
label = <Skeleton className="inline-block h-5 w-24 align-middle" />;
} else if (invoice) {
// You can customize this - show invoice number or date
label =
invoice.invoiceNumber ||
format(new Date(invoice.issueDate), "MMM dd, yyyy");
}
} else if (prevSegment === "businesses") {
if (businessLoading) {
label = <Skeleton className="inline-block h-5 w-24 align-middle" />;
} else if (business) {
label = business.name;
}
}
}
// Handle action segments (edit, new, etc.)
else if (segment && SPECIAL_SEGMENTS[segment]) {
// Don't show 'edit' as the last breadcrumb when we have the resource name
if (segment === "edit" && i === segments.length - 1 && resourceId) {
shouldShow = false;
} else {
label = SPECIAL_SEGMENTS[segment];
}
}
// Handle resource segments (clients, invoices, etc.)
else if (segment) {
// Use plural form for list pages, singular when there's a specific ID
const nextSegment = segments[i + 1];
const isListPage =
!nextSegment || (!isUUID(nextSegment) && nextSegment !== "new");
label = getRouteLabel(segment, isListPage);
}
if (shouldShow && label) {
items.push({
label,
href: path,
isLast: i === segments.length - 1,
});
}
if (segment === "invoices") label = "Invoices";
if (segment === "new") label = "New";
// Only show 'Edit' if not the last segment
if (segment === "edit" && i !== segments.length - 1) label = "Edit";
// Don't show 'edit' as the last breadcrumb, just show the client name
if (segment === "edit" && i === segments.length - 1 && client) continue;
if (segment === "import") label = "Import";
items.push({
label,
href: path,
isLast:
i === segments.length - 1 ||
(segment === "edit" && i === segments.length - 1 && client),
});
}
return items;
}, [segments, client, invoice, clientLoading, invoiceLoading]);
}, [
segments,
client,
invoice,
business,
clientLoading,
invoiceLoading,
businessLoading,
resourceId,
]);
if (breadcrumbs.length === 0) return null;
@@ -100,8 +159,8 @@ export function DashboardBreadcrumbs() {
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
{breadcrumbs.map((crumb) => (
<React.Fragment key={crumb.href}>
{breadcrumbs.map((crumb, index) => (
<React.Fragment key={`${crumb.href}-${index}`}>
<BreadcrumbSeparator>
<ChevronRight className="h-3 w-3 sm:h-4 sm:w-4" />
</BreadcrumbSeparator>

View File

@@ -42,23 +42,18 @@ const STATUS_OPTIONS = [
{
value: "draft",
label: "Draft",
color: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300",
},
{
value: "sent",
label: "Sent",
color: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400",
},
{
value: "paid",
label: "Paid",
color:
"bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400",
},
{
value: "overdue",
label: "Overdue",
color: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400",
},
] as const;
@@ -438,26 +433,20 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
<CardContent className="space-y-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:gap-6 xl:grid-cols-4">
<div className="space-y-2">
<Label
htmlFor="invoiceNumber"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
<Label htmlFor="invoiceNumber" className="text-sm font-medium">
Invoice Number
</Label>
<Input
id="invoiceNumber"
value={formData.invoiceNumber}
className="h-10 border-gray-200 bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
className="bg-muted"
placeholder="Auto-generated"
readOnly
/>
</div>
<div className="space-y-2">
<Label
htmlFor="businessId"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
<Label htmlFor="businessId" className="text-sm font-medium">
Business *
</Label>
<SearchableSelect
@@ -478,10 +467,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</div>
<div className="space-y-2">
<Label
htmlFor="clientId"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
<Label htmlFor="clientId" className="text-sm font-medium">
Client *
</Label>
<SearchableSelect
@@ -502,10 +488,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</div>
<div className="space-y-2">
<Label
htmlFor="status"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
<Label htmlFor="status" className="text-sm font-medium">
Status
</Label>
<Select
@@ -517,7 +500,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
}))
}
>
<SelectTrigger className="h-10 border-gray-200 bg-gray-50 dark:border-gray-600 dark:bg-gray-700">
<SelectTrigger className="h-10">
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
@@ -530,10 +513,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</div>
<div className="space-y-2">
<Label
htmlFor="issueDate"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
<Label htmlFor="issueDate" className="text-sm font-medium">
Issue Date *
</Label>
<DatePicker
@@ -547,10 +527,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</div>
<div className="space-y-2">
<Label
htmlFor="dueDate"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
<Label htmlFor="dueDate" className="text-sm font-medium">
Due Date *
</Label>
<DatePicker
@@ -564,10 +541,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</div>
<div className="space-y-2">
<Label
htmlFor="defaultRate"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
<Label htmlFor="defaultRate" className="text-sm font-medium">
Default Rate ($/hr)
</Label>
<div className="flex gap-2">
@@ -580,14 +554,14 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
setDefaultRate(parseFloat(e.target.value) || 0)
}
placeholder="0.00"
className="h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
className=""
/>
<Button
type="button"
onClick={applyDefaultRate}
variant="outline"
size="sm"
className="h-10 border-emerald-200 text-emerald-700 hover:bg-emerald-50 dark:border-emerald-800 dark:text-emerald-400 dark:hover:bg-emerald-900/20"
className="border-primary text-primary hover:bg-primary/10"
>
Apply
</Button>
@@ -595,10 +569,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</div>
<div className="space-y-2">
<Label
htmlFor="taxRate"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
<Label htmlFor="taxRate" className="text-sm font-medium">
Tax Rate (%)
</Label>
<Input
@@ -615,18 +586,18 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
}))
}
placeholder="0.00"
className="h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
className=""
/>
</div>
</div>
{selectedBusiness && (
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-4 dark:border-emerald-800 dark:bg-emerald-900/20">
<div className="mb-2 flex items-center gap-2 text-emerald-700 dark:text-emerald-400">
<div className="mb-2 flex items-center gap-2 text-green-600">
<Building className="h-4 w-4" />
<span className="font-medium">Business Information</span>
</div>
<div className="text-sm text-gray-700 dark:text-gray-300">
<div className="text-muted-foreground text-sm">
<p className="font-medium">{selectedBusiness.name}</p>
{selectedBusiness.email && <p>{selectedBusiness.email}</p>}
{selectedBusiness.phone && <p>{selectedBusiness.phone}</p>}
@@ -652,11 +623,11 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
{selectedClient && (
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-4 dark:border-emerald-800 dark:bg-emerald-900/20">
<div className="mb-2 flex items-center gap-2 text-emerald-700 dark:text-emerald-400">
<div className="mb-2 flex items-center gap-2 text-green-600">
<User className="h-4 w-4" />
<span className="font-medium">Client Information</span>
</div>
<div className="text-sm text-gray-700 dark:text-gray-300">
<div className="text-muted-foreground text-sm">
<p className="font-medium">{selectedClient.name}</p>
{selectedClient.email && <p>{selectedClient.email}</p>}
{selectedClient.phone && <p>{selectedClient.phone}</p>}
@@ -665,10 +636,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
)}
<div className="space-y-2">
<Label
htmlFor="notes"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
<Label htmlFor="notes" className="text-sm font-medium">
Notes
</Label>
<textarea
@@ -705,7 +673,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</CardHeader>
<CardContent className="space-y-4">
{/* Items Table Header */}
<div className="grid grid-cols-12 items-center gap-2 rounded-lg bg-gray-50 px-4 py-3 text-sm font-medium text-gray-700 dark:bg-gray-700 dark:text-gray-300">
<div className="bg-muted text-muted-foreground grid grid-cols-12 items-center gap-2 rounded-lg px-4 py-3 text-sm font-medium">
<div className="col-span-1 text-center"></div>
<div className="col-span-2">Date</div>
<div className="col-span-4">Description</div>
@@ -761,7 +729,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</div>
)}
</div>
<div className="text-lg font-medium text-gray-700 dark:text-gray-300">
<div className="text-foreground text-lg font-medium">
Total Amount
</div>
<div className="text-3xl font-bold text-emerald-600 dark:text-emerald-400">
@@ -801,7 +769,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
type="button"
variant="outline"
onClick={() => router.push("/dashboard/invoices")}
className="border-gray-300 font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800"
className="font-medium"
>
Cancel
</Button>
@@ -812,7 +780,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
>
{loading ? (
<>
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
<div className="border-primary-foreground mr-2 h-4 w-4 animate-spin rounded-full border-2 border-t-transparent" />
{invoiceId ? "Updating..." : "Creating..."}
</>
) : (

View File

@@ -3,27 +3,36 @@
import { useState } from "react";
import Link from "next/link";
import { api } from "~/trpc/react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import {
Card,
CardContent,
CardDescription,
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 { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { StatusBadge, type StatusType } from "~/components/ui/status-badge";
import { toast } from "sonner";
import { FileText, Calendar, DollarSign, Edit, Trash2, Eye, Plus, User } from "lucide-react";
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",
};
const statusLabels = {
draft: "Draft",
sent: "Sent",
paid: "Paid",
overdue: "Overdue",
};
import {
FileText,
Calendar,
DollarSign,
Edit,
Trash2,
Eye,
Plus,
User,
} from "lucide-react";
export function InvoiceList() {
const [searchTerm, setSearchTerm] = useState("");
@@ -43,10 +52,14 @@ export function InvoiceList() {
},
});
const filteredInvoices = invoices?.filter(invoice =>
invoice.invoiceNumber.toLowerCase().includes(searchTerm.toLowerCase()) ||
invoice.client.name.toLowerCase().includes(searchTerm.toLowerCase())
) || [];
const filteredInvoices =
invoices?.filter(
(invoice) =>
invoice.invoiceNumber
.toLowerCase()
.includes(searchTerm.toLowerCase()) ||
invoice.client.name.toLowerCase().includes(searchTerm.toLowerCase()),
) || [];
const handleDelete = (invoiceId: string) => {
setInvoiceToDelete(invoiceId);
@@ -64,9 +77,9 @@ export function InvoiceList() {
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount);
};
@@ -76,12 +89,12 @@ export function InvoiceList() {
{[...Array(3)].map((_, i) => (
<Card key={i}>
<CardHeader>
<div className="h-4 bg-muted rounded animate-pulse" />
<div className="bg-muted h-4 animate-pulse rounded" />
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="h-3 bg-muted rounded animate-pulse" />
<div className="h-3 bg-muted rounded w-2/3 animate-pulse" />
<div className="bg-muted h-3 animate-pulse rounded" />
<div className="bg-muted h-3 w-2/3 animate-pulse rounded" />
</div>
</CardContent>
</Card>
@@ -158,26 +171,25 @@ export function InvoiceList() {
</div>
</CardTitle>
<div className="flex items-center justify-between">
<span className={`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>
<StatusBadge status={invoice.status as StatusType} />
<span className="text-lg font-bold text-green-600">
{formatCurrency(invoice.totalAmount)}
</span>
</div>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center text-sm text-muted-foreground">
<div className="text-muted-foreground flex items-center text-sm">
<User className="mr-2 h-4 w-4" />
{invoice.client.name}
</div>
<div className="flex items-center text-sm text-muted-foreground">
<div className="text-muted-foreground flex items-center text-sm">
<Calendar className="mr-2 h-4 w-4" />
Due: {formatDate(invoice.dueDate)}
</div>
<div className="flex items-center text-sm text-muted-foreground">
<div className="text-muted-foreground flex items-center text-sm">
<FileText className="mr-2 h-4 w-4" />
{invoice.items.length} item{invoice.items.length !== 1 ? 's' : ''}
{invoice.items.length} item
{invoice.items.length !== 1 ? "s" : ""}
</div>
</CardContent>
</Card>
@@ -189,11 +201,15 @@ export function InvoiceList() {
<DialogHeader>
<DialogTitle>Delete Invoice</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
>
Cancel
</Button>
<Button variant="destructive" onClick={confirmDelete}>
@@ -204,4 +220,4 @@ export function InvoiceList() {
</Dialog>
</div>
);
}
}

View File

@@ -1,10 +1,12 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { api } from "~/trpc/react";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { StatusBadge, type StatusType } from "~/components/ui/status-badge";
import { Separator } from "~/components/ui/separator";
import {
Dialog,
@@ -15,7 +17,6 @@ import {
DialogTitle,
} from "~/components/ui/dialog";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import { format } from "date-fns";
import {
Calendar,
@@ -41,28 +42,11 @@ interface InvoiceViewProps {
invoiceId: string;
}
const statusConfig = {
draft: {
label: "Draft",
color: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300",
icon: FileText,
},
sent: {
label: "Sent",
color: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400",
icon: Send,
},
paid: {
label: "Paid",
color:
"bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400",
icon: DollarSign,
},
overdue: {
label: "Overdue",
color: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400",
icon: AlertCircle,
},
const statusIconConfig = {
draft: FileText,
sent: Send,
paid: DollarSign,
overdue: AlertCircle,
} as const;
export function InvoiceView({ invoiceId }: InvoiceViewProps) {
@@ -168,7 +152,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
}
const StatusIcon =
statusConfig[invoice.status as keyof typeof statusConfig].icon;
statusIconConfig[invoice.status as keyof typeof statusIconConfig];
return (
<div className="space-y-6">
@@ -227,22 +211,20 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
</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`}
<StatusBadge
status={invoice.status as StatusType}
className="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>
</StatusBadge>
<div className="text-3xl font-bold text-emerald-600 dark:text-emerald-400">
{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"
variant="brand"
className="transform-none"
>
{isExportingPDF ? (
<>

View File

@@ -0,0 +1,79 @@
import React from "react";
interface PageHeaderProps {
title: string;
description?: string;
children?: React.ReactNode; // For action buttons or other header content
className?: string;
variant?: "default" | "gradient" | "large" | "large-gradient";
titleClassName?: string;
}
export function PageHeader({
title,
description,
children,
className = "",
variant = "default",
titleClassName,
}: PageHeaderProps) {
const getTitleClasses = () => {
const baseClasses = "font-bold";
switch (variant) {
case "gradient":
return `${baseClasses} text-3xl bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent`;
case "large":
return `${baseClasses} text-4xl text-foreground`;
case "large-gradient":
return `${baseClasses} text-4xl bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent`;
default:
return `${baseClasses} text-3xl text-foreground`;
}
};
const getDescriptionSpacing = () => {
return variant === "large" || variant === "large-gradient"
? "mt-2"
: "mt-1";
};
return (
<div className={`mb-8 ${className}`}>
<div className="flex items-start justify-between gap-4">
<h1 className={titleClassName ?? getTitleClasses()}>{title}</h1>
{children && (
<div className="flex flex-shrink-0 gap-2 sm:gap-3 [&>*]:h-8 [&>*]:px-2 [&>*]:text-sm sm:[&>*]:h-10 sm:[&>*]:px-4 sm:[&>*]:text-base [&>*>span]:hidden sm:[&>*>span]:inline [&>*>svg]:mr-0 sm:[&>*>svg]:mr-2">
{children}
</div>
)}
</div>
{description && (
<p
className={`text-muted-foreground ${getDescriptionSpacing()} text-lg`}
>
{description}
</p>
)}
</div>
);
}
// Convenience wrapper for dashboard page with larger gradient title
export function DashboardPageHeader({
title,
description,
children,
className = "",
}: Omit<PageHeaderProps, "variant">) {
return (
<PageHeader
title={title}
description={description}
variant="large-gradient"
className={className}
>
{children}
</PageHeader>
);
}

View File

@@ -0,0 +1,230 @@
"use client";
import { MapPin } from "lucide-react";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { SearchableSelect } from "~/components/ui/select";
import {
US_STATES,
ALL_COUNTRIES,
POPULAR_COUNTRIES,
formatPostalCode,
PLACEHOLDERS,
} from "~/lib/form-constants";
interface AddressFormProps {
addressLine1: string;
addressLine2: string;
city: string;
state: string;
postalCode: string;
country: string;
onChange: (field: string, value: string) => void;
errors?: {
addressLine1?: string;
addressLine2?: string;
city?: string;
state?: string;
postalCode?: string;
country?: string;
};
required?: boolean;
className?: string;
}
export function AddressForm({
addressLine1,
addressLine2,
city,
state,
postalCode,
country,
onChange,
errors = {},
required = false,
className = "",
}: AddressFormProps) {
const handlePostalCodeChange = (value: string) => {
const formatted = formatPostalCode(value, country || "US");
onChange("postalCode", formatted);
};
// Combine popular and all countries, removing duplicates
const countryOptions = [
{ value: "__placeholder__", label: "Select a country", disabled: true },
{ value: "divider-popular", label: "Popular Countries", disabled: true },
...POPULAR_COUNTRIES,
{ value: "divider-all", label: "All Countries", disabled: true },
...ALL_COUNTRIES.filter(
(c) => !POPULAR_COUNTRIES.some((p) => p.value === c.value),
),
];
const stateOptions = [
{ value: "__placeholder__", label: "Select a state", disabled: true },
...US_STATES,
];
return (
<div className={`space-y-4 ${className}`}>
<div className="flex items-center gap-2 text-sm font-medium">
<MapPin className="text-muted-foreground h-4 w-4" />
<span>Address Information</span>
</div>
<div className="grid gap-4">
{/* Address Line 1 */}
<div className="space-y-2">
<Label htmlFor="addressLine1">
Address Line 1
{required && <span className="text-destructive ml-1">*</span>}
</Label>
<Input
id="addressLine1"
value={addressLine1}
onChange={(e) => onChange("addressLine1", e.target.value)}
placeholder={PLACEHOLDERS.addressLine1}
className={errors.addressLine1 ? "border-destructive" : ""}
/>
{errors.addressLine1 && (
<p className="text-destructive text-sm">{errors.addressLine1}</p>
)}
</div>
{/* Address Line 2 */}
<div className="space-y-2">
<Label htmlFor="addressLine2">
Address Line 2
<span className="text-muted-foreground ml-1 text-xs">
(Optional)
</span>
</Label>
<Input
id="addressLine2"
value={addressLine2}
onChange={(e) => onChange("addressLine2", e.target.value)}
placeholder={PLACEHOLDERS.addressLine2}
/>
</div>
{/* City and State/Province */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="city">
City{required && <span className="text-destructive ml-1">*</span>}
</Label>
<Input
id="city"
value={city}
onChange={(e) => onChange("city", e.target.value)}
placeholder={PLACEHOLDERS.city}
className={errors.city ? "border-destructive" : ""}
/>
{errors.city && (
<p className="text-destructive text-sm">{errors.city}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="state">
{country === "United States" ? "State" : "State/Province"}
{required && country === "United States" && (
<span className="text-destructive ml-1">*</span>
)}
</Label>
{country === "United States" ? (
<SearchableSelect
id="state"
options={stateOptions}
value={state || ""}
onValueChange={(value) => onChange("state", value)}
placeholder="Select a state"
className={errors.state ? "border-destructive" : ""}
/>
) : (
<Input
id="state"
value={state}
onChange={(e) => onChange("state", e.target.value)}
placeholder="State/Province"
className={errors.state ? "border-destructive" : ""}
/>
)}
{errors.state && (
<p className="text-destructive text-sm">{errors.state}</p>
)}
</div>
</div>
{/* Postal Code and Country */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="postalCode">
{country === "United States" ? "ZIP Code" : "Postal Code"}
{required && <span className="text-destructive ml-1">*</span>}
</Label>
<Input
id="postalCode"
value={postalCode}
onChange={(e) => handlePostalCodeChange(e.target.value)}
placeholder={
country === "United States" ? "12345" : PLACEHOLDERS.postalCode
}
className={errors.postalCode ? "border-destructive" : ""}
maxLength={
country === "United States"
? 10
: country === "Canada"
? 7
: undefined
}
/>
{errors.postalCode && (
<p className="text-destructive text-sm">{errors.postalCode}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="country">
Country
{required && <span className="text-destructive ml-1">*</span>}
</Label>
<SearchableSelect
id="country"
options={countryOptions}
value={country || ""}
onValueChange={(value) => {
// Don't save the placeholder value
if (value !== "__placeholder__") {
onChange("country", value);
// Reset state when country changes from United States
if (value !== "United States" && state.length === 2) {
onChange("state", "");
}
}
}}
placeholder="Select a country"
className={errors.country ? "border-destructive" : ""}
renderOption={(option) => {
if (option.value?.startsWith("divider-")) {
return (
<div className="text-muted-foreground px-2 py-1 text-xs font-semibold">
{option.label}
</div>
);
}
return option.label;
}}
isOptionDisabled={(option) =>
option.disabled || option.value?.startsWith("divider-")
}
/>
{errors.country && (
<p className="text-destructive text-sm">{errors.country}</p>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,8 +1,8 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
@@ -17,13 +17,17 @@ const badgeVariants = cva(
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
success: "border-transparent bg-status-success [a&]:hover:opacity-90",
warning: "border-transparent bg-status-warning [a&]:hover:opacity-90",
error: "border-transparent bg-status-error [a&]:hover:opacity-90",
info: "border-transparent bg-status-info [a&]:hover:opacity-90",
},
},
defaultVariants: {
variant: "default",
},
}
)
},
);
function Badge({
className,
@@ -32,7 +36,7 @@ function Badge({
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
const Comp = asChild ? Slot : "span";
return (
<Comp
@@ -40,7 +44,7 @@ function Badge({
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
);
}
export { Badge, badgeVariants }
export { Badge, badgeVariants };

View File

@@ -1,8 +1,8 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
@@ -11,10 +11,12 @@ const buttonVariants = cva(
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
brand:
"bg-brand-gradient text-white shadow-lg hover:bg-brand-gradient hover:shadow-xl font-medium",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
"border border-border/40 bg-background/60 shadow-sm backdrop-blur-sm hover:bg-accent/50 hover:text-accent-foreground hover:border-border/60 transition-all duration-200",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
@@ -32,8 +34,8 @@ const buttonVariants = cva(
variant: "default",
size: "default",
},
}
)
},
);
function Button({
className,
@@ -43,9 +45,9 @@ function Button({
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button"
const Comp = asChild ? Slot : "button";
return (
<Comp
@@ -53,7 +55,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
);
}
export { Button, buttonVariants }
export { Button, buttonVariants };

View File

@@ -1,18 +1,18 @@
import * as React from "react"
import * as React from "react";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
"bg-background/60 text-card-foreground border-border/40 flex flex-col gap-6 rounded-2xl border py-6 shadow-lg backdrop-blur-xl backdrop-saturate-150",
className,
)}
{...props}
/>
)
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -21,21 +21,21 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
className,
)}
{...props}
/>
)
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
@@ -45,7 +45,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
@@ -54,11 +54,11 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
className,
)}
{...props}
/>
)
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
@@ -68,7 +68,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
className={cn("px-6", className)}
{...props}
/>
)
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -78,7 +78,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
);
}
export {
@@ -89,4 +89,4 @@ export {
CardAction,
CardDescription,
CardContent,
}
};

View File

@@ -0,0 +1,33 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -0,0 +1,563 @@
"use client";
import * as React from "react";
import type {
ColumnDef,
ColumnFiltersState,
SortingState,
VisibilityState,
} from "@tanstack/react-table";
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import {
ArrowUpDown,
ChevronDown,
Search,
Filter,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
X,
} from "lucide-react";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import { Card, CardContent } from "~/components/ui/card";
import { cn } from "~/lib/utils";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
searchKey?: string;
searchPlaceholder?: string;
showColumnVisibility?: boolean;
showPagination?: boolean;
showSearch?: boolean;
pageSize?: number;
className?: string;
title?: string;
description?: string;
actions?: React.ReactNode;
filterableColumns?: {
id: string;
title: string;
options: { label: string; value: string }[];
}[];
}
export function DataTable<TData, TValue>({
columns,
data,
searchKey,
searchPlaceholder = "Search...",
showColumnVisibility = true,
showPagination = true,
showSearch = true,
pageSize = 10,
className,
title,
description,
actions,
filterableColumns = [],
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[],
);
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({});
const [rowSelection, setRowSelection] = React.useState({});
const [globalFilter, setGlobalFilter] = React.useState("");
// Create responsive columns that properly hide on mobile
const responsiveColumns = React.useMemo(() => {
return columns.map((column) => ({
...column,
// Add a meta property to control responsive visibility
meta: {
...((column as any).meta || {}),
headerClassName: (column as any).meta?.headerClassName || "",
cellClassName: (column as any).meta?.cellClassName || "",
},
}));
}, [columns]);
const table = useReactTable({
data,
columns: responsiveColumns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
onGlobalFilterChange: setGlobalFilter,
globalFilterFn: "includesString",
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
globalFilter,
},
initialState: {
pagination: {
pageSize: pageSize,
},
},
});
const pageSizeOptions = [5, 10, 20, 30, 50, 100];
return (
<div className={cn("space-y-4", className)}>
{/* Header Section */}
{(title ?? description) && (
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
{title && (
<h3 className="text-foreground text-lg font-semibold">{title}</h3>
)}
{description && (
<p className="text-muted-foreground mt-1 text-sm">
{description}
</p>
)}
</div>
{actions && (
<div className="flex flex-shrink-0 items-center gap-2">
{actions}
</div>
)}
</div>
)}
{/* Filter Bar Card */}
{(showSearch || filterableColumns.length > 0 || showColumnVisibility) && (
<Card className="border-0 py-2 shadow-sm">
<CardContent className="px-3 py-0">
<div className="flex items-center gap-2">
{showSearch && (
<div className="relative min-w-0 flex-1">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder={searchPlaceholder}
value={globalFilter ?? ""}
onChange={(event) => setGlobalFilter(event.target.value)}
className="h-9 w-full pr-3 pl-9"
/>
</div>
)}
{filterableColumns.map((column) => (
<Select
key={column.id}
value={
(table.getColumn(column.id)?.getFilterValue() as string) ??
"all"
}
onValueChange={(value) =>
table
.getColumn(column.id)
?.setFilterValue(value === "all" ? "" : value)
}
>
<SelectTrigger className="h-9 w-9 p-0 sm:w-[180px] sm:px-3 [&>svg]:hidden sm:[&>svg]:inline-flex">
<div className="flex w-full items-center justify-center">
<Filter className="h-4 w-4 sm:hidden" />
<span className="hidden sm:inline">
<SelectValue placeholder={column.title} />
</span>
</div>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All {column.title}</SelectItem>
{column.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
))}
{filterableColumns.length > 0 && (
<Button
variant="outline"
size="sm"
className="h-9 w-9 p-0 sm:w-auto sm:px-4"
onClick={() => {
table.resetColumnFilters();
setGlobalFilter("");
}}
>
<X className="h-4 w-4 sm:hidden" />
<span className="hidden sm:flex sm:items-center">
<Filter className="mr-2 h-3.5 w-3.5" />
Clear filters
</span>
</Button>
)}
{showColumnVisibility && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="hidden h-9 sm:flex"
>
Columns <ChevronDown className="ml-2 h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[150px]">
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{column.id}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</CardContent>
</Card>
)}
{/* Table Content Card */}
<Card className="overflow-hidden border-0 p-0 shadow-sm">
<div className="w-full overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow
key={headerGroup.id}
className="bg-muted/50 hover:bg-muted/50"
>
{headerGroup.headers.map((header) => {
const meta = header.column.columnDef.meta as any;
return (
<TableHead
key={header.id}
className={cn(
"text-muted-foreground h-9 px-3 text-left align-middle text-xs font-medium sm:h-10 sm:px-4 sm:text-sm [&:has([role=checkbox])]:pr-3",
meta?.headerClassName,
)}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="hover:bg-muted/20 data-[state=selected]:bg-muted/50 border-b transition-colors"
>
{row.getVisibleCells().map((cell) => {
const meta = cell.column.columnDef.meta as any;
return (
<TableCell
key={cell.id}
className={cn(
"px-3 py-1.5 align-middle text-xs sm:px-4 sm:py-2 sm:text-sm [&:has([role=checkbox])]:pr-3",
meta?.cellClassName,
)}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
);
})}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
<p className="text-muted-foreground">No results found</p>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</Card>
{/* Pagination Bar Card */}
{showPagination && (
<Card className="border-0 py-2 shadow-sm">
<CardContent className="px-3 py-0">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<p className="text-muted-foreground hidden text-xs sm:inline sm:text-sm">
{table.getFilteredRowModel().rows.length === 0
? "No entries"
: `Showing ${
table.getState().pagination.pageIndex *
table.getState().pagination.pageSize +
1
} to ${Math.min(
(table.getState().pagination.pageIndex + 1) *
table.getState().pagination.pageSize,
table.getFilteredRowModel().rows.length,
)} of ${table.getFilteredRowModel().rows.length} entries`}
</p>
<p className="text-muted-foreground text-xs sm:hidden">
{table.getFilteredRowModel().rows.length === 0
? "0"
: `${
table.getState().pagination.pageIndex *
table.getState().pagination.pageSize +
1
}-${Math.min(
(table.getState().pagination.pageIndex + 1) *
table.getState().pagination.pageSize,
table.getFilteredRowModel().rows.length,
)} of ${table.getFilteredRowModel().rows.length}`}
</p>
<Select
value={table.getState().pagination.pageSize.toString()}
onValueChange={(value) => {
table.setPageSize(Number(value));
}}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{pageSizeOptions.map((size) => (
<SelectItem key={size} value={size.toString()}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<ChevronsLeft className="h-4 w-4" />
<span className="sr-only">First page</span>
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className="h-4 w-4" />
<span className="sr-only">Previous page</span>
</Button>
<div className="flex items-center gap-1 px-2">
<span className="text-muted-foreground text-xs sm:text-sm">
Page{" "}
<span className="text-foreground font-medium">
{table.getState().pagination.pageIndex + 1}
</span>{" "}
of{" "}
<span className="text-foreground font-medium">
{table.getPageCount() || 1}
</span>
</span>
</div>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<ChevronRight className="h-4 w-4" />
<span className="sr-only">Next page</span>
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<ChevronsRight className="h-4 w-4" />
<span className="sr-only">Last page</span>
</Button>
</div>
</div>
</CardContent>
</Card>
)}
</div>
);
}
// Helper component for sortable column headers
export function DataTableColumnHeader<TData, TValue>({
column,
title,
className,
}: {
column: any;
title: string;
className?: string;
}) {
if (!column.getCanSort()) {
return <div className={cn("text-xs sm:text-sm", className)}>{title}</div>;
}
return (
<Button
variant="ghost"
size="sm"
className={cn(
"data-[state=open]:bg-accent -ml-2 h-8 px-2 text-xs font-medium hover:bg-transparent sm:text-sm",
className,
)}
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
<span className="mr-2">{title}</span>
{column.getIsSorted() === "desc" ? (
<ArrowUpDown className="h-3 w-3 rotate-180 sm:h-3.5 sm:w-3.5" />
) : column.getIsSorted() === "asc" ? (
<ArrowUpDown className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
) : (
<ArrowUpDown className="text-muted-foreground/50 h-3 w-3 sm:h-3.5 sm:w-3.5" />
)}
</Button>
);
}
// Export skeleton component for loading states
export function DataTableSkeleton({
columns = 5,
rows = 5,
}: {
columns?: number;
rows?: number;
}) {
return (
<div className="space-y-4">
{/* Filter bar skeleton */}
<Card className="border-0 py-2 shadow-sm">
<CardContent className="px-3 py-0">
<div className="flex items-center gap-2">
<div className="bg-muted/30 h-9 w-full flex-1 animate-pulse rounded-md sm:max-w-sm"></div>
<div className="bg-muted/30 h-9 w-24 animate-pulse rounded-md"></div>
</div>
</CardContent>
</Card>
{/* Table skeleton */}
<Card className="overflow-hidden border-0 p-0 shadow-sm">
<div className="w-full overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50">
{Array.from({ length: columns }).map((_, i) => (
<TableHead
key={i}
className="h-9 px-3 text-left align-middle sm:h-10 sm:px-4"
>
<div className="bg-muted/30 h-4 w-16 animate-pulse rounded sm:w-20"></div>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: rows }).map((_, i) => (
<TableRow key={i} className="border-b">
{Array.from({ length: columns }).map((_, j) => (
<TableCell
key={j}
className="px-3 py-1.5 align-middle sm:px-4 sm:py-2"
>
<div className="bg-muted/30 h-4 w-full animate-pulse rounded"></div>
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
</Card>
{/* Pagination skeleton */}
<Card className="border-0 py-2 shadow-sm">
<CardContent className="px-3 py-0">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<div className="bg-muted/30 h-4 w-20 animate-pulse rounded text-xs sm:w-32 sm:text-sm"></div>
<div className="bg-muted/30 h-8 w-[70px] animate-pulse rounded"></div>
</div>
<div className="flex items-center gap-1">
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="bg-muted/30 h-8 w-8 animate-pulse rounded"
></div>
))}
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,28 +1,28 @@
"use client"
"use client";
import { format } from "date-fns"
import { Calendar as CalendarIcon } from "lucide-react"
import * as React from "react"
import { format } from "date-fns";
import { Calendar as CalendarIcon } from "lucide-react";
import * as React from "react";
import { Button } from "~/components/ui/button"
import { Calendar } from "~/components/ui/calendar"
import { Label } from "~/components/ui/label"
import { Button } from "~/components/ui/button";
import { Calendar } from "~/components/ui/calendar";
import { Label } from "~/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover"
import { cn } from "~/lib/utils"
} from "~/components/ui/popover";
import { cn } from "~/lib/utils";
interface DatePickerProps {
date?: Date
onDateChange: (date: Date | undefined) => void
label?: string
placeholder?: string
className?: string
disabled?: boolean
required?: boolean
id?: string
date?: Date;
onDateChange: (date: Date | undefined) => void;
label?: string;
placeholder?: string;
className?: string;
disabled?: boolean;
required?: boolean;
id?: string;
}
export function DatePicker({
@@ -33,16 +33,16 @@ export function DatePicker({
className,
disabled = false,
required = false,
id
id,
}: DatePickerProps) {
const [open, setOpen] = React.useState(false)
const [open, setOpen] = React.useState(false);
return (
<div className={cn("flex flex-col gap-2", className)}>
{label && (
<Label htmlFor={id} className="text-sm font-medium text-gray-700">
<Label htmlFor={id} className="text-sm font-medium">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
{required && <span className="text-destructive ml-1">*</span>}
</Label>
)}
<Popover open={open} onOpenChange={setOpen}>
@@ -52,12 +52,12 @@ export function DatePicker({
id={id}
disabled={disabled}
className={cn(
"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"
"h-10 w-full justify-between text-sm font-normal",
!date && "text-muted-foreground",
)}
>
{date ? format(date, "PPP") : placeholder}
<CalendarIcon className="h-4 w-4 text-gray-400" />
<CalendarIcon className="text-muted-foreground h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
@@ -66,12 +66,12 @@ export function DatePicker({
selected={date}
captionLayout="dropdown"
onSelect={(selectedDate: Date | undefined) => {
onDateChange(selectedDate)
setOpen(false)
onDateChange(selectedDate);
setOpen(false);
}}
/>
</PopoverContent>
</Popover>
</div>
)
);
}

View File

@@ -1,15 +1,15 @@
"use client"
"use client";
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
@@ -17,7 +17,7 @@ function DropdownMenuPortal({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
);
}
function DropdownMenuTrigger({
@@ -28,7 +28,7 @@ function DropdownMenuTrigger({
data-slot="dropdown-menu-trigger"
{...props}
/>
)
);
}
function DropdownMenuContent({
@@ -42,13 +42,13 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
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 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
"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 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border-0 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
);
}
function DropdownMenuGroup({
@@ -56,7 +56,7 @@ function DropdownMenuGroup({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
);
}
function DropdownMenuItem({
@@ -65,8 +65,8 @@ function DropdownMenuItem({
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
@@ -75,11 +75,11 @@ function DropdownMenuItem({
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
className,
)}
{...props}
/>
)
);
}
function DropdownMenuCheckboxItem({
@@ -93,7 +93,7 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 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",
className
className,
)}
checked={checked}
{...props}
@@ -105,7 +105,7 @@ function DropdownMenuCheckboxItem({
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
);
}
function DropdownMenuRadioGroup({
@@ -116,7 +116,7 @@ function DropdownMenuRadioGroup({
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
);
}
function DropdownMenuRadioItem({
@@ -129,7 +129,7 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 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",
className
className,
)}
{...props}
>
@@ -140,7 +140,7 @@ function DropdownMenuRadioItem({
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
);
}
function DropdownMenuLabel({
@@ -148,7 +148,7 @@ function DropdownMenuLabel({
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
@@ -156,11 +156,11 @@ function DropdownMenuLabel({
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
className,
)}
{...props}
/>
)
);
}
function DropdownMenuSeparator({
@@ -173,7 +173,7 @@ function DropdownMenuSeparator({
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
);
}
function DropdownMenuShortcut({
@@ -185,17 +185,17 @@ function DropdownMenuShortcut({
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
className,
)}
{...props}
/>
)
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
@@ -204,7 +204,7 @@ function DropdownMenuSubTrigger({
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
@@ -212,14 +212,14 @@ function DropdownMenuSubTrigger({
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
);
}
function DropdownMenuSubContent({
@@ -230,12 +230,12 @@ function DropdownMenuSubContent({
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-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 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
"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 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border-0 shadow-lg",
className,
)}
{...props}
/>
)
);
}
export {
@@ -254,4 +254,4 @@ export {
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
};

View File

@@ -0,0 +1,105 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { cn } from "~/lib/utils";
interface FloatingActionBarProps {
/** Ref to the element that triggers visibility when scrolled out of view */
triggerRef: React.RefObject<HTMLElement | null>;
/** Title text displayed on the left */
title: string;
/** Action buttons to display on the right */
children: React.ReactNode;
/** Additional className for styling */
className?: string;
/** Whether to show the floating bar (for manual control) */
show?: boolean;
/** Callback when visibility changes */
onVisibilityChange?: (visible: boolean) => void;
}
export function FloatingActionBar({
triggerRef,
title,
children,
className,
show,
onVisibilityChange,
}: FloatingActionBarProps) {
const [isVisible, setIsVisible] = useState(false);
const floatingRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// If show prop is provided, use it instead of auto-detection
if (show !== undefined) {
setIsVisible(show);
onVisibilityChange?.(show);
return;
}
const handleScroll = () => {
if (!triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
const isInView = rect.top < window.innerHeight && rect.bottom >= 0;
// Show floating bar when trigger element is out of view
const shouldShow = !isInView;
if (shouldShow !== isVisible) {
setIsVisible(shouldShow);
onVisibilityChange?.(shouldShow);
}
};
// Use ResizeObserver and IntersectionObserver for better detection
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (entry) {
const shouldShow = !entry.isIntersecting;
if (shouldShow !== isVisible) {
setIsVisible(shouldShow);
onVisibilityChange?.(shouldShow);
}
}
},
{
// Trigger when element is completely out of view
threshold: 0,
rootMargin: "0px 0px -100% 0px",
},
);
// Start observing when trigger element is available
if (triggerRef.current) {
observer.observe(triggerRef.current);
}
// Also add scroll listener as fallback
window.addEventListener("scroll", handleScroll, { passive: true });
// Check initial state
handleScroll();
return () => {
observer.disconnect();
window.removeEventListener("scroll", handleScroll);
};
}, [triggerRef, isVisible, show, onVisibilityChange]);
if (!isVisible) return null;
return (
<div
ref={floatingRef}
className={cn(
"border-border/40 bg-background/60 animate-in slide-in-from-bottom-4 fixed right-3 bottom-3 left-3 z-20 flex items-center justify-between rounded-2xl border p-4 shadow-lg backdrop-blur-xl backdrop-saturate-150 duration-300 md:right-3 md:left-[279px]",
className,
)}
>
<p className="text-muted-foreground text-sm">{title}</p>
<div className="flex items-center gap-3">{children}</div>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import * as React from "react"
import * as React from "react";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
@@ -8,14 +8,15 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground bg-background/50 text-foreground border-border/40 flex h-10 w-full min-w-0 rounded-md border px-3 py-2 text-sm shadow-sm backdrop-blur-sm transition-all duration-200 outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
"focus-visible:border-ring focus-visible:bg-background/80 focus-visible:ring-ring/20 focus-visible:ring-[3px]",
"hover:border-border/60 hover:bg-background/60",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
className,
)}
{...props}
/>
)
);
}
export { Input }
export { Input };

View File

@@ -0,0 +1,179 @@
"use client";
import * as React from "react";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { cn } from "~/lib/utils";
import { Minus, Plus } from "lucide-react";
interface NumberInputProps {
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
placeholder?: string;
disabled?: boolean;
className?: string;
prefix?: string;
suffix?: string;
id?: string;
name?: string;
"aria-label"?: string;
}
export function NumberInput({
value,
onChange,
min = 0,
max,
step = 1,
placeholder = "0",
disabled = false,
className,
prefix,
suffix,
id,
name,
"aria-label": ariaLabel,
}: NumberInputProps) {
const [inputValue, setInputValue] = React.useState(value.toString());
// Update input when external value changes
React.useEffect(() => {
setInputValue(value.toString());
}, [value]);
const handleIncrement = () => {
const newValue = Math.min(value + step, max ?? Infinity);
onChange(newValue);
};
const handleDecrement = () => {
const newValue = Math.max(value - step, min);
onChange(newValue);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const inputVal = e.target.value;
setInputValue(inputVal);
// Allow empty input for better UX
if (inputVal === "") {
onChange(0);
return;
}
const numValue = parseFloat(inputVal);
if (!isNaN(numValue)) {
const clampedValue = Math.max(min, Math.min(numValue, max ?? Infinity));
onChange(clampedValue);
}
};
const handleInputBlur = () => {
// Ensure the input shows the actual value on blur
setInputValue(value.toString());
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowUp" && canIncrement) {
e.preventDefault();
handleIncrement();
} else if (e.key === "ArrowDown" && canDecrement) {
e.preventDefault();
handleDecrement();
}
};
const canDecrement = value > min;
const canIncrement = !max || value < max;
return (
<div
className={cn("relative flex items-center", className)}
role="group"
aria-label={
ariaLabel || "Number input with increment and decrement buttons"
}
>
{/* Prefix */}
{prefix && (
<div className="text-muted-foreground pointer-events-none absolute left-10 z-10 flex items-center text-sm">
{prefix}
</div>
)}
{/* Decrement Button */}
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled || !canDecrement}
onClick={handleDecrement}
className={cn(
"h-8 w-8 rounded-r-none border-r-0 p-0 transition-all duration-150",
"hover:border-emerald-200 hover:bg-emerald-50 hover:text-emerald-700",
"dark:hover:border-emerald-700 dark:hover:bg-emerald-900/30",
"focus:z-10 focus:ring-2 focus:ring-emerald-500/20",
!canDecrement && "cursor-not-allowed opacity-40",
)}
aria-label="Decrease value"
tabIndex={disabled ? -1 : 0}
>
<Minus className="h-3 w-3" />
</Button>
{/* Input */}
<Input
id={id}
name={name}
type="number"
value={inputValue}
onChange={handleInputChange}
onBlur={handleInputBlur}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
step={step}
min={min}
max={max}
aria-label={ariaLabel}
className={cn(
"h-8 rounded-none border-x-0 text-center font-mono focus:z-10",
"focus:border-emerald-300 focus:ring-2 focus:ring-emerald-500/20",
"dark:focus:border-emerald-600",
prefix && "pl-12",
suffix && "pr-12",
)}
/>
{/* Increment Button */}
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled || !canIncrement}
onClick={handleIncrement}
className={cn(
"h-8 w-8 rounded-l-none border-l-0 p-0 transition-all duration-150",
"hover:border-emerald-200 hover:bg-emerald-50 hover:text-emerald-700",
"dark:hover:border-emerald-700 dark:hover:bg-emerald-900/30",
"focus:z-10 focus:ring-2 focus:ring-emerald-500/20",
!canIncrement && "cursor-not-allowed opacity-40",
)}
aria-label="Increase value"
tabIndex={disabled ? -1 : 0}
>
<Plus className="h-3 w-3" />
</Button>
{/* Suffix */}
{suffix && (
<div className="text-muted-foreground pointer-events-none absolute right-10 z-10 flex items-center text-sm">
{suffix}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,148 @@
import * as React from "react";
import { cn } from "~/lib/utils";
interface PageLayoutProps {
children: React.ReactNode;
className?: string;
}
export function PageLayout({ children, className }: PageLayoutProps) {
return (
<div className={cn("min-h-screen", className)}>
{children}
</div>
);
}
interface PageContentProps {
children: React.ReactNode;
className?: string;
spacing?: "default" | "compact" | "large";
}
export function PageContent({
children,
className,
spacing = "default"
}: PageContentProps) {
const spacingClasses = {
default: "space-y-8",
compact: "space-y-4",
large: "space-y-12"
};
return (
<div className={cn(spacingClasses[spacing], className)}>
{children}
</div>
);
}
interface PageSectionProps {
children: React.ReactNode;
className?: string;
title?: string;
description?: string;
actions?: React.ReactNode;
}
export function PageSection({
children,
className,
title,
description,
actions
}: PageSectionProps) {
return (
<section className={cn("space-y-4", className)}>
{(title ?? description ?? actions) && (
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
{title && (
<h2 className="text-xl font-semibold text-foreground">{title}</h2>
)}
{description && (
<p className="text-sm text-muted-foreground mt-1">{description}</p>
)}
</div>
{actions && (
<div className="flex flex-shrink-0 gap-3">{actions}</div>
)}
</div>
)}
{children}
</section>
);
}
interface PageGridProps {
children: React.ReactNode;
className?: string;
columns?: 1 | 2 | 3 | 4;
gap?: "default" | "compact" | "large";
}
export function PageGrid({
children,
className,
columns = 3,
gap = "default"
}: PageGridProps) {
const columnClasses = {
1: "grid-cols-1",
2: "grid-cols-1 md:grid-cols-2",
3: "grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
4: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4"
};
const gapClasses = {
default: "gap-4",
compact: "gap-2",
large: "gap-6"
};
return (
<div className={cn(
"grid",
columnClasses[columns],
gapClasses[gap],
className
)}>
{children}
</div>
);
}
// Empty state component for consistent empty states across pages
interface EmptyStateProps {
icon?: React.ReactNode;
title: string;
description?: string;
action?: React.ReactNode;
className?: string;
}
export function EmptyState({
icon,
title,
description,
action,
className
}: EmptyStateProps) {
return (
<div className={cn("py-12 text-center", className)}>
{icon && (
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted/50">
{icon}
</div>
)}
<h3 className="mb-2 text-lg font-semibold">{title}</h3>
{description && (
<p className="text-muted-foreground mb-4 max-w-sm mx-auto">
{description}
</p>
)}
{action && <div className="mt-4">{action}</div>}
</div>
);
}

View File

@@ -0,0 +1,112 @@
import * as React from "react";
import { Card, CardContent } from "~/components/ui/card";
import { cn } from "~/lib/utils";
import type { LucideIcon } from "lucide-react";
interface QuickActionCardProps {
title: string;
description?: string;
icon: LucideIcon;
variant?: "default" | "success" | "info" | "warning" | "purple";
className?: string;
onClick?: () => void;
children?: React.ReactNode;
}
const variantStyles = {
default: {
icon: "text-foreground",
background: "bg-muted/50",
hoverBackground: "group-hover:bg-muted/70",
},
success: {
icon: "text-status-success",
background: "bg-status-success-muted",
hoverBackground: "group-hover:bg-status-success-muted/70",
},
info: {
icon: "text-status-info",
background: "bg-status-info-muted",
hoverBackground: "group-hover:bg-status-info-muted/70",
},
warning: {
icon: "text-status-warning",
background: "bg-status-warning-muted",
hoverBackground: "group-hover:bg-status-warning-muted/70",
},
purple: {
icon: "text-purple-600",
background: "bg-purple-100 dark:bg-purple-900/30",
hoverBackground:
"group-hover:bg-purple-200 dark:group-hover:bg-purple-900/50",
},
};
export function QuickActionCard({
title,
description,
icon: Icon,
variant = "default",
className,
onClick,
children,
}: QuickActionCardProps) {
const styles = variantStyles[variant];
const content = (
<CardContent className="p-6 text-center">
<div
className={cn(
"mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full transition-colors",
styles.background,
styles.hoverBackground,
)}
>
<Icon className={cn("h-6 w-6", styles.icon)} />
</div>
<h3 className="font-semibold">{title}</h3>
{description && (
<p className="text-muted-foreground mt-1 text-sm">{description}</p>
)}
</CardContent>
);
if (children) {
return (
<Card
className={cn(
"group cursor-pointer border-0 shadow-md transition-all hover:scale-[1.02] hover:shadow-lg",
className,
)}
>
{children}
</Card>
);
}
return (
<Card
className={cn(
"group cursor-pointer border-0 shadow-md transition-all hover:scale-[1.02] hover:shadow-lg",
className,
)}
onClick={onClick}
>
{content}
</Card>
);
}
export function QuickActionCardSkeleton() {
return (
<Card className="border-0 shadow-md">
<CardContent className="p-6">
<div className="animate-pulse">
<div className="bg-muted mx-auto mb-3 h-12 w-12 rounded-full"></div>
<div className="bg-muted mx-auto mb-2 h-4 w-2/3 rounded"></div>
<div className="bg-muted mx-auto h-3 w-1/2 rounded"></div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -42,7 +42,7 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
"data-[placeholder]:text-muted-foreground flex h-10 w-full items-center justify-between gap-2 rounded-md border border-gray-200 bg-gray-50 px-3 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-emerald-500 focus-visible:ring-[3px] focus-visible:ring-emerald-500 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-700 dark:text-white",
"data-[placeholder]:text-muted-foreground border-input bg-background text-foreground focus-visible:border-ring focus-visible:ring-ring/50 flex h-10 w-full items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
@@ -66,7 +66,7 @@ function SelectContent({
<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",
"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-0 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,
@@ -210,7 +210,7 @@ function SelectContentWithSearch({
<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",
"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-0 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,
@@ -231,7 +231,7 @@ function SelectContentWithSearch({
{...props}
>
{onSearchChange && (
<div className="border-border flex items-center border-b px-3 py-2">
<div className="border-border/20 flex items-center border-b px-3 py-2">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<input
ref={searchInputRef}
@@ -282,10 +282,21 @@ interface SearchableSelectProps {
value?: string;
onValueChange?: (value: string) => void;
placeholder?: string;
options: { value: string; label: string }[];
options: { value: string; label: string; disabled?: boolean }[];
searchPlaceholder?: string;
className?: string;
disabled?: boolean;
renderOption?: (option: {
value: string;
label: string;
disabled?: boolean;
}) => React.ReactNode;
isOptionDisabled?: (option: {
value: string;
label: string;
disabled?: boolean;
}) => boolean;
id?: string;
}
function SearchableSelect({
@@ -296,15 +307,21 @@ function SearchableSelect({
searchPlaceholder = "Search...",
className,
disabled,
renderOption,
isOptionDisabled,
id,
}: 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()),
);
return options.filter((option) => {
// Don't filter out dividers, disabled options, or placeholder
if (option.value?.startsWith("divider-")) return true;
if (option.value === "__placeholder__") return true;
return option.label.toLowerCase().includes(searchValue.toLowerCase());
});
}, [options, searchValue]);
// Convert empty string to placeholder value for display
@@ -327,7 +344,7 @@ function SearchableSelect({
open={isOpen}
onOpenChange={setIsOpen}
>
<SelectTrigger className={cn("w-full", className)}>
<SelectTrigger className={cn("w-full", className)} id={id}>
<SelectValue
placeholder={placeholder}
// Always show placeholder if nothing is selected
@@ -341,11 +358,34 @@ function SearchableSelect({
isOpen={isOpen}
filteredOptions={filteredOptions}
>
{filteredOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
{filteredOptions.map((option) => {
const isDisabled = isOptionDisabled
? isOptionDisabled(option)
: option.disabled;
if (renderOption && option.value?.startsWith("divider-")) {
return (
<div key={option.value} className="pointer-events-none">
{renderOption(option)}
</div>
);
}
// Skip rendering items with empty string values
if (option.value === "") {
return null;
}
return (
<SelectItem
key={option.value}
value={option.value}
disabled={isDisabled}
>
{renderOption ? renderOption(option) : option.label}
</SelectItem>
);
})}
</SelectContentWithSearch>
</Select>
);

View File

@@ -1,13 +1,11 @@
import { cn } from "~/lib/utils";
import { Card, CardContent, CardHeader } from "~/components/ui/card";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn(
"bg-muted animate-pulse rounded-md dark:bg-gray-700",
className,
)}
className={cn("bg-muted/30 animate-pulse rounded-md", className)}
{...props}
/>
);
@@ -20,14 +18,14 @@ export function DashboardStatsSkeleton() {
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="rounded-xl border-0 bg-white/80 p-6 shadow-xl backdrop-blur-sm dark:border-gray-700 dark:bg-gray-800/80"
className="border-border/40 bg-background/60 rounded-2xl border p-6 shadow-lg backdrop-blur-xl backdrop-saturate-150"
>
<div className="mb-4 flex items-center justify-between">
<Skeleton className="h-4 w-24 dark:bg-gray-600" />
<Skeleton className="h-8 w-8 rounded-lg dark:bg-gray-600" />
<Skeleton className="bg-muted/20 h-4 w-24" />
<Skeleton className="bg-muted/20 h-8 w-8 rounded-lg" />
</div>
<Skeleton className="mb-2 h-8 w-16 dark:bg-gray-600" />
<Skeleton className="h-3 w-32 dark:bg-gray-600" />
<Skeleton className="bg-muted/20 mb-2 h-8 w-16" />
<Skeleton className="bg-muted/20 h-3 w-32" />
</div>
))}
</div>
@@ -40,16 +38,16 @@ export function DashboardCardsSkeleton() {
{Array.from({ length: 2 }).map((_, i) => (
<div
key={i}
className="rounded-xl border-0 bg-white/80 p-6 shadow-xl backdrop-blur-sm dark:border-gray-700 dark:bg-gray-800/80"
className="border-border/40 bg-background/60 rounded-2xl border p-6 shadow-lg backdrop-blur-xl backdrop-saturate-150"
>
<div className="mb-4 flex items-center gap-2">
<Skeleton className="h-8 w-8 rounded-lg dark:bg-gray-600" />
<Skeleton className="h-6 w-32 dark:bg-gray-600" />
<Skeleton className="bg-muted/20 h-8 w-8 rounded-lg" />
<Skeleton className="bg-muted/20 h-6 w-32" />
</div>
<Skeleton className="mb-4 h-4 w-full dark:bg-gray-600" />
<Skeleton className="bg-muted/20 mb-4 h-4 w-full" />
<div className="flex gap-3">
<Skeleton className="h-10 w-24 dark:bg-gray-600" />
<Skeleton className="h-10 w-32 dark:bg-gray-600" />
<Skeleton className="bg-muted/20 h-10 w-24" />
<Skeleton className="bg-muted/20 h-10 w-32" />
</div>
</div>
))}
@@ -59,65 +57,124 @@ export function DashboardCardsSkeleton() {
export function DashboardActivitySkeleton() {
return (
<div className="rounded-xl border-0 bg-white/80 p-6 shadow-xl backdrop-blur-sm dark:border-gray-700 dark:bg-gray-800/80">
<Skeleton className="mb-6 h-6 w-32 dark:bg-gray-600" />
<div className="border-border/40 bg-background/60 rounded-2xl border p-6 shadow-lg backdrop-blur-xl backdrop-saturate-150">
<Skeleton className="bg-muted/20 mb-6 h-6 w-32" />
<div className="py-12 text-center">
<Skeleton className="mx-auto mb-4 h-20 w-20 rounded-full dark:bg-gray-600" />
<Skeleton className="mx-auto mb-2 h-6 w-48 dark:bg-gray-600" />
<Skeleton className="mx-auto h-4 w-64 dark:bg-gray-600" />
<Skeleton className="bg-muted/20 mx-auto mb-4 h-20 w-20 rounded-full" />
<Skeleton className="bg-muted/20 mx-auto mb-2 h-6 w-48" />
<Skeleton className="bg-muted/20 mx-auto h-4 w-64" />
</div>
</div>
);
}
// Table skeleton components
export function TableSkeleton({ rows = 5 }: { rows?: number }) {
export function TableSkeleton({ rows = 8 }: { rows?: number }) {
return (
<div className="space-y-4">
{/* Search and filters */}
<div className="flex flex-col gap-4 sm:flex-row">
<Skeleton className="h-10 w-64 dark:bg-gray-600" />
<div className="flex gap-2">
<Skeleton className="h-10 w-24 dark:bg-gray-600" />
<Skeleton className="h-10 w-24 dark:bg-gray-600" />
<div className="w-full">
{/* Controls - matches universal table controls */}
<div className="border-border/40 bg-background/60 mb-4 flex flex-wrap items-center gap-3 rounded-2xl border p-4 shadow-lg backdrop-blur-xl backdrop-saturate-150">
{/* Left side - View controls and filters */}
<div className="flex items-center gap-2">
<Skeleton className="bg-muted/20 h-10 w-10" />{" "}
{/* Table view button */}
<Skeleton className="bg-muted/20 h-10 w-10" />{" "}
{/* Grid view button */}
<Skeleton className="bg-muted/20 h-10 w-10" /> {/* Filter button */}
</div>
{/* Right side - Search and batch actions */}
<div className="ml-auto flex flex-shrink-0 items-center gap-2">
<Skeleton className="bg-muted/20 h-10 w-48 sm:w-64" />{" "}
{/* Search input */}
<Skeleton className="bg-muted/20 h-10 w-10" /> {/* Search button */}
</div>
</div>
{/* Table */}
<div className="rounded-lg border dark:border-gray-700 dark:bg-gray-800/90">
<div className="border-b p-4 dark:border-gray-700">
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-32 dark:bg-gray-600" />
<div className="flex gap-2">
<Skeleton className="h-8 w-8 dark:bg-gray-600" />
<Skeleton className="h-8 w-8 dark:bg-gray-600" />
{/* Table - matches universal table structure */}
<div className="bg-background/60 border-border/40 overflow-hidden rounded-2xl border shadow-lg backdrop-blur-xl backdrop-saturate-150">
<div className="w-full">
{/* Table header */}
<div className="border-border/40 border-b">
<div className="flex items-center px-4 py-4">
<div className="w-12 px-4">
<Skeleton className="bg-muted/20 h-4 w-4" /> {/* Checkbox */}
</div>
<div className="flex-1 px-4">
<Skeleton className="bg-muted/20 h-4 w-16" /> {/* Header 1 */}
</div>
<div className="w-32 px-4">
<Skeleton className="bg-muted/20 h-4 w-20" /> {/* Header 2 */}
</div>
<div className="w-32 px-4">
<Skeleton className="bg-muted/20 h-4 w-16" /> {/* Header 3 */}
</div>
<div className="w-32 px-4">
<Skeleton className="bg-muted/20 h-4 w-20" /> {/* Header 4 */}
</div>
<div className="w-8 px-4">
<Skeleton className="bg-muted/20 h-4 w-4" /> {/* Actions */}
</div>
</div>
</div>
</div>
<div className="p-4">
<div className="space-y-3">
{/* Table body */}
<div>
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton className="h-4 w-4 dark:bg-gray-600" />
<Skeleton className="h-4 flex-1 dark:bg-gray-600" />
<Skeleton className="h-4 w-24 dark:bg-gray-600" />
<Skeleton className="h-4 w-24 dark:bg-gray-600" />
<Skeleton className="h-4 w-20 dark:bg-gray-600" />
<Skeleton className="h-8 w-16 dark:bg-gray-600" />
<div
key={i}
className="border-border/40 border-b last:border-b-0"
>
<div className="hover:bg-accent/30 flex items-center px-4 py-4 transition-colors">
<div className="w-12 px-4">
<Skeleton className="bg-muted/20 h-4 w-4" />{" "}
{/* Checkbox */}
</div>
<div className="flex-1 px-4">
<Skeleton className="bg-muted/20 h-4 w-full max-w-48" />{" "}
{/* Main content */}
</div>
<div className="w-32 px-4">
<Skeleton className="bg-muted/20 h-4 w-24" />{" "}
{/* Column 2 */}
</div>
<div className="w-32 px-4">
<Skeleton className="bg-muted/20 h-4 w-20" />{" "}
{/* Column 3 */}
</div>
<div className="w-32 px-4">
<Skeleton className="bg-muted/20 h-4 w-16" />{" "}
{/* Column 4 */}
</div>
<div className="w-8 px-4">
<Skeleton className="bg-muted/20 h-8 w-8 rounded" />{" "}
{/* Actions button */}
</div>
</div>
</div>
))}
</div>
</div>
</div>
{/* Pagination */}
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-32 dark:bg-gray-600" />
<div className="flex gap-2">
<Skeleton className="h-8 w-8 dark:bg-gray-600" />
<Skeleton className="h-8 w-8 dark:bg-gray-600" />
<Skeleton className="h-8 w-8 dark:bg-gray-600" />
{/* Pagination - matches universal table pagination */}
<div className="border-border/40 bg-background/60 mt-4 mb-4 flex flex-wrap items-center justify-between gap-3 rounded-2xl border p-4 shadow-lg backdrop-blur-xl backdrop-saturate-150">
{/* Left side - Page info and items per page */}
<div className="flex items-center gap-2">
<Skeleton className="bg-muted/20 h-4 w-40" /> {/* Page info text */}
<Skeleton className="bg-muted/20 h-8 w-20" />{" "}
{/* Items per page select */}
</div>
{/* Right side - Pagination controls */}
<div className="flex items-center gap-1">
<Skeleton className="bg-muted/20 h-8 w-20" /> {/* Previous button */}
<div className="flex items-center gap-1">
<Skeleton className="bg-muted/20 h-8 w-8" /> {/* Page 1 */}
<Skeleton className="bg-muted/20 h-8 w-8" /> {/* Page 2 */}
<Skeleton className="bg-muted/20 h-8 w-8" /> {/* Page 3 */}
</div>
<Skeleton className="bg-muted/20 h-8 w-16" /> {/* Next button */}
</div>
</div>
</div>
@@ -127,36 +184,115 @@ export function TableSkeleton({ rows = 5 }: { rows?: number }) {
// Form skeleton components
export function FormSkeleton() {
return (
<div className="space-y-6">
<div className="mx-auto max-w-6xl pb-24">
<div className="space-y-4">
<div>
<Skeleton className="mb-2 h-4 w-20 dark:bg-gray-600" />
<Skeleton className="h-10 w-full dark:bg-gray-600" />
</div>
<div>
<Skeleton className="mb-2 h-4 w-24 dark:bg-gray-600" />
<Skeleton className="h-10 w-full dark:bg-gray-600" />
</div>
<div>
<Skeleton className="mb-2 h-4 w-16 dark:bg-gray-600" />
<Skeleton className="h-10 w-full dark:bg-gray-600" />
</div>
{/* Basic Information Card */}
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<Skeleton className="bg-muted/20 h-10 w-10 rounded-lg" />
<div className="space-y-2">
<Skeleton className="bg-muted/20 h-6 w-40" />
<Skeleton className="bg-muted/20 h-4 w-56" />
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Skeleton className="bg-muted/20 h-4 w-24" />
<Skeleton className="bg-muted/20 h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="bg-muted/20 h-4 w-20" />
<Skeleton className="bg-muted/20 h-10 w-full" />
</div>
</div>
</CardContent>
</Card>
{/* Contact Information Card */}
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<Skeleton className="bg-muted/20 h-10 w-10 rounded-lg" />
<div className="space-y-2">
<Skeleton className="bg-muted/20 h-6 w-44" />
<Skeleton className="bg-muted/20 h-4 w-48" />
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Skeleton className="bg-muted/20 h-4 w-16" />
<Skeleton className="bg-muted/20 h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="bg-muted/20 h-4 w-16" />
<Skeleton className="bg-muted/20 h-10 w-full" />
</div>
</div>
<div className="space-y-2">
<Skeleton className="bg-muted/20 h-4 w-20" />
<Skeleton className="bg-muted/20 h-10 w-full" />
</div>
</CardContent>
</Card>
{/* Address Card */}
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<Skeleton className="bg-muted/20 h-10 w-10 rounded-lg" />
<div className="space-y-2">
<Skeleton className="bg-muted/20 h-6 w-20" />
<Skeleton className="bg-muted/20 h-4 w-40" />
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-4">
<div className="space-y-2">
<Skeleton className="bg-muted/20 h-4 w-28" />
<Skeleton className="bg-muted/20 h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="bg-muted/20 h-4 w-28" />
<Skeleton className="bg-muted/20 h-10 w-full" />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Skeleton className="bg-muted/20 h-4 w-12" />
<Skeleton className="bg-muted/20 h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="bg-muted/20 h-4 w-16" />
<Skeleton className="bg-muted/20 h-10 w-full" />
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Skeleton className="bg-muted/20 h-4 w-20" />
<Skeleton className="bg-muted/20 h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="bg-muted/20 h-4 w-20" />
<Skeleton className="bg-muted/20 h-10 w-full" />
</div>
</div>
</div>
</CardContent>
</Card>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Skeleton className="mb-2 h-4 w-20 dark:bg-gray-600" />
<Skeleton className="h-10 w-full dark:bg-gray-600" />
{/* Form Actions - styled like data table footer */}
<div className="border-border/40 bg-background/60 fixed right-3 bottom-3 left-3 z-20 flex items-center justify-between rounded-2xl border p-4 shadow-lg backdrop-blur-xl backdrop-saturate-150 md:right-3 md:left-[279px]">
<Skeleton className="bg-muted/20 h-4 w-40" />
<div className="flex items-center gap-3">
<Skeleton className="bg-muted/20 h-10 w-24" />
<Skeleton className="bg-muted/20 h-10 w-32" />
</div>
<div>
<Skeleton className="mb-2 h-4 w-16 dark:bg-gray-600" />
<Skeleton className="h-10 w-full dark:bg-gray-600" />
</div>
</div>
<div className="flex gap-3">
<Skeleton className="h-10 w-24 dark:bg-gray-600" />
<Skeleton className="h-10 w-24 dark:bg-gray-600" />
</div>
</div>
);
@@ -169,41 +305,41 @@ export function InvoiceViewSkeleton() {
{/* Header */}
<div className="flex items-start justify-between">
<div className="space-y-2">
<Skeleton className="h-8 w-48 dark:bg-gray-600" />
<Skeleton className="h-4 w-64 dark:bg-gray-600" />
<Skeleton className="bg-muted/20 h-8 w-48" />
<Skeleton className="bg-muted/20 h-4 w-64" />
</div>
<Skeleton className="h-10 w-32 dark:bg-gray-600" />
<Skeleton className="bg-muted/20 h-10 w-32" />
</div>
{/* Client info */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="space-y-3">
<Skeleton className="h-5 w-24 dark:bg-gray-600" />
<Skeleton className="h-4 w-full dark:bg-gray-600" />
<Skeleton className="h-4 w-3/4 dark:bg-gray-600" />
<Skeleton className="h-4 w-1/2 dark:bg-gray-600" />
<Skeleton className="bg-muted/20 h-5 w-24" />
<Skeleton className="bg-muted/20 h-4 w-full" />
<Skeleton className="bg-muted/20 h-4 w-3/4" />
<Skeleton className="bg-muted/20 h-4 w-1/2" />
</div>
<div className="space-y-3">
<Skeleton className="h-5 w-24 dark:bg-gray-600" />
<Skeleton className="h-4 w-full dark:bg-gray-600" />
<Skeleton className="h-4 w-3/4 dark:bg-gray-600" />
<Skeleton className="bg-muted/20 h-5 w-24" />
<Skeleton className="bg-muted/20 h-4 w-full" />
<Skeleton className="bg-muted/20 h-4 w-3/4" />
</div>
</div>
{/* Items table */}
<div className="rounded-lg border dark:border-gray-700 dark:bg-gray-800/90">
<div className="border-b p-4 dark:border-gray-700">
<Skeleton className="h-5 w-32 dark:bg-gray-600" />
<div className="border-border bg-card rounded-lg border">
<div className="border-border border-b p-4">
<Skeleton className="bg-muted/20 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 dark:bg-gray-600" />
<Skeleton className="h-4 flex-1 dark:bg-gray-600" />
<Skeleton className="h-4 w-16 dark:bg-gray-600" />
<Skeleton className="h-4 w-20 dark:bg-gray-600" />
<Skeleton className="h-4 w-24 dark:bg-gray-600" />
<Skeleton className="bg-muted/20 h-4 w-20" />
<Skeleton className="bg-muted/20 h-4 flex-1" />
<Skeleton className="bg-muted/20 h-4 w-16" />
<Skeleton className="bg-muted/20 h-4 w-20" />
<Skeleton className="bg-muted/20 h-4 w-24" />
</div>
))}
</div>
@@ -213,8 +349,8 @@ export function InvoiceViewSkeleton() {
{/* Total */}
<div className="flex justify-end">
<div className="space-y-2">
<Skeleton className="h-6 w-32 dark:bg-gray-600" />
<Skeleton className="h-8 w-40 dark:bg-gray-600" />
<Skeleton className="bg-muted/20 h-6 w-32" />
<Skeleton className="bg-muted/20 h-8 w-40" />
</div>
</div>
</div>

View File

@@ -0,0 +1,107 @@
import * as React from "react";
import { Card, CardContent } from "~/components/ui/card";
import { cn } from "~/lib/utils";
import type { LucideIcon } from "lucide-react";
interface StatsCardProps {
title: string;
value: string | number;
description?: string;
icon?: LucideIcon;
trend?: {
value: number;
isPositive: boolean;
};
variant?: "default" | "success" | "warning" | "error" | "info";
className?: string;
}
const variantStyles = {
default: {
icon: "text-foreground",
background: "bg-muted/50",
},
success: {
icon: "text-status-success",
background: "bg-status-success-muted",
},
warning: {
icon: "text-status-warning",
background: "bg-status-warning-muted",
},
error: {
icon: "text-status-error",
background: "bg-status-error-muted",
},
info: {
icon: "text-status-info",
background: "bg-status-info-muted",
},
};
export function StatsCard({
title,
value,
description,
icon: Icon,
trend,
variant = "default",
className,
}: StatsCardProps) {
const styles = variantStyles[variant];
return (
<Card
className={cn(
"border-0 shadow-md transition-shadow hover:shadow-lg",
className,
)}
>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-2">
<p className="text-muted-foreground text-sm font-medium">{title}</p>
<div className="flex items-baseline gap-2">
<p className="text-2xl font-bold">{value}</p>
{trend && (
<span
className={cn(
"text-sm font-medium",
trend.isPositive
? "text-status-success"
: "text-status-error",
)}
>
{trend.isPositive ? "+" : ""}
{trend.value}%
</span>
)}
</div>
{description && (
<p className="text-muted-foreground text-xs">{description}</p>
)}
</div>
{Icon && (
<div className={cn("rounded-full p-3", styles.background)}>
<Icon className={cn("h-6 w-6", styles.icon)} />
</div>
)}
</div>
</CardContent>
</Card>
);
}
export function StatsCardSkeleton() {
return (
<Card className="border-0 shadow-md">
<CardContent className="p-6">
<div className="animate-pulse">
<div className="bg-muted mb-2 h-4 w-1/2 rounded"></div>
<div className="bg-muted mb-2 h-8 w-3/4 rounded"></div>
<div className="bg-muted h-3 w-1/3 rounded"></div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,45 @@
import * as React from "react";
import { Badge, type badgeVariants } from "./badge";
import { type VariantProps } from "class-variance-authority";
type StatusType = "draft" | "sent" | "paid" | "overdue" | "success" | "warning" | "error" | "info";
interface StatusBadgeProps extends Omit<React.ComponentProps<typeof Badge>, "variant"> {
status: StatusType;
children?: React.ReactNode;
}
const statusVariantMap: Record<StatusType, VariantProps<typeof badgeVariants>["variant"]> = {
draft: "secondary",
sent: "info",
paid: "success",
overdue: "error",
success: "success",
warning: "warning",
error: "error",
info: "info",
};
const statusLabelMap: Record<StatusType, string> = {
draft: "Draft",
sent: "Sent",
paid: "Paid",
overdue: "Overdue",
success: "Success",
warning: "Warning",
error: "Error",
info: "Info",
};
export function StatusBadge({ status, children, ...props }: StatusBadgeProps) {
const variant = statusVariantMap[status];
const label = children || statusLabelMap[status];
return (
<Badge variant={variant} {...props}>
{label}
</Badge>
);
}
export { type StatusType };

View File

@@ -0,0 +1,31 @@
"use client";
import * as React from "react";
import * as SwitchPrimitive from "@radix-ui/react-switch";
import { cn } from "~/lib/utils";
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-emerald-600",
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=checked]:bg-white data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitive.Root>
);
}
export { Switch };

View File

@@ -1,8 +1,8 @@
"use client"
"use client";
import * as React from "react"
import * as React from "react";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
@@ -16,7 +16,7 @@ function Table({ className, ...props }: React.ComponentProps<"table">) {
{...props}
/>
</div>
)
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
@@ -26,7 +26,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
);
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
@@ -36,7 +36,7 @@ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
);
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
@@ -45,11 +45,11 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
className,
)}
{...props}
/>
)
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
@@ -58,11 +58,11 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
className,
)}
{...props}
/>
)
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
@@ -70,12 +70,12 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
"text-foreground h-9 px-3 text-left align-middle text-xs font-medium sm:h-10 sm:px-4 sm:text-sm [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
)
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
@@ -83,12 +83,12 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
"px-3 py-1.5 align-middle text-xs sm:px-4 sm:py-2 sm:text-sm [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
)
);
}
function TableCaption({
@@ -101,7 +101,7 @@ function TableCaption({
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
);
}
export {
@@ -113,4 +113,4 @@ export {
TableRow,
TableCell,
TableCaption,
}
};

View File

@@ -1,18 +1,18 @@
import * as React from "react"
import * as React from "react";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive bg-background text-foreground flex field-sizing-content min-h-16 w-full resize-y rounded-md border px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
)
);
}
export { Textarea }
export { Textarea };

File diff suppressed because it is too large Load Diff