mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-02-05 08:16:31 -05:00
feat: polish invoice editor and viewer UI with custom NumberInput
component - Create custom NumberInput component with increment/decrement buttons - Add 0.25 step increments for hours and rates in invoice forms - Implement emerald-themed styling with hover states and accessibility - Add keyboard navigation (arrow keys) and proper ARIA support - Condense invoice editor tax/totals section into efficient grid layout - Update client dropdown to single-line format (name + email) - Add fixed footer with floating action bar pattern matching business forms - Redesign invoice viewer with better space utilization and visual hierarchy - Maintain professional appearance and consistent design system - Fix Next.js 15 params Promise handling across all invoice pages - Resolve TypeScript compilation errors and type-only imports
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: "media"
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
CSS Variables:
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
oklch() color space
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Instructions */}
|
||||
<Card className="border-blue-200 dark:border-blue-800 dark:bg-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-blue-700 dark:text-blue-300">
|
||||
Testing Instructions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-blue-600 dark:text-blue-400">
|
||||
<p>
|
||||
• Change your system theme between light and dark to test automatic
|
||||
switching
|
||||
</p>
|
||||
<p>• All UI elements should adapt colors automatically</p>
|
||||
<p>• Text should remain readable in both modes</p>
|
||||
<p>• Icons and buttons should have appropriate contrast</p>
|
||||
<p>• Form elements should be clearly visible and functional</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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..."}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
79
src/components/page-header.tsx
Normal file
79
src/components/page-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
230
src/components/ui/address-form.tsx
Normal file
230
src/components/ui/address-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
33
src/components/ui/collapsible.tsx
Normal file
33
src/components/ui/collapsible.tsx
Normal 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 }
|
||||
563
src/components/ui/data-table.tsx
Normal file
563
src/components/ui/data-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
105
src/components/ui/floating-action-bar.tsx
Normal file
105
src/components/ui/floating-action-bar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
179
src/components/ui/number-input.tsx
Normal file
179
src/components/ui/number-input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
148
src/components/ui/page-layout.tsx
Normal file
148
src/components/ui/page-layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
src/components/ui/quick-action-card.tsx
Normal file
112
src/components/ui/quick-action-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
107
src/components/ui/stats-card.tsx
Normal file
107
src/components/ui/stats-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
src/components/ui/status-badge.tsx
Normal file
45
src/components/ui/status-badge.tsx
Normal 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 };
|
||||
31
src/components/ui/switch.tsx
Normal file
31
src/components/ui/switch.tsx
Normal 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 };
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user