Add 'apps/web/' from commit '1e7174fa604b11e7c3983cd8ad01c596f6e77e96'
git-subtree-dir: apps/web git-subtree-mainline:068a51b46bgit-subtree-split:1e7174fa60
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { authClient } from "~/lib/auth-client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function AuthRedirect() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
let isCurrent = true;
|
||||
|
||||
async function redirectAuthenticatedUser() {
|
||||
const { data: session } = await authClient.getSession().catch(() => ({
|
||||
data: null,
|
||||
}));
|
||||
|
||||
if (isCurrent && session?.user) {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
}
|
||||
|
||||
void redirectAuthenticatedUser();
|
||||
|
||||
return () => {
|
||||
isCurrent = false;
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
// This component doesn't render anything
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import Script from "next/script";
|
||||
import { env } from "~/env";
|
||||
|
||||
export function UmamiScript() {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || !env.NEXT_PUBLIC_UMAMI_SCRIPT_URL) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Script
|
||||
defer
|
||||
src={env.NEXT_PUBLIC_UMAMI_SCRIPT_URL}
|
||||
data-website-id={env.NEXT_PUBLIC_UMAMI_WEBSITE_ID}
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Logo } from "~/components/branding/logo";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
export function AuthPageShell({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-dashboard text-foreground flex min-h-screen flex-col px-5 py-6 sm:px-6 sm:py-8">
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto flex w-full max-w-md flex-1 flex-col justify-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-muted-foreground hover:text-foreground mb-6 inline-flex items-center gap-2 text-sm transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to home
|
||||
</Link>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthCard({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/50 bg-background/80 rounded-3xl border p-6 shadow-xl backdrop-blur-xl sm:p-8",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthCardHeader({
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-6 space-y-3">
|
||||
<Logo size="md" animated={false} />
|
||||
<div className="space-y-1">
|
||||
<h1 className="font-heading text-2xl font-semibold tracking-tight">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
import { useState, useRef } from "react";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Card } from "~/components/ui/card";
|
||||
|
||||
interface AddressAutocompleteProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSelect: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
interface NominatimResult {
|
||||
place_id: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
export function AddressAutocomplete({
|
||||
value,
|
||||
onChange,
|
||||
onSelect,
|
||||
placeholder,
|
||||
}: AddressAutocompleteProps) {
|
||||
const [suggestions, setSuggestions] = useState<NominatimResult[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const fetchSuggestions = async (query: string) => {
|
||||
if (!query) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
const res = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}`,
|
||||
);
|
||||
const data = (await res.json()) as NominatimResult[];
|
||||
setSuggestions(data);
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
onChange(val);
|
||||
setShowSuggestions(true);
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
void fetchSuggestions(val);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleSelect = (address: string) => {
|
||||
onSelect(address);
|
||||
setShowSuggestions(false);
|
||||
setSuggestions([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={value}
|
||||
onChange={handleInputChange}
|
||||
placeholder={placeholder ?? "Start typing address..."}
|
||||
autoComplete="off"
|
||||
onFocus={() => value && setShowSuggestions(true)}
|
||||
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
|
||||
/>
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<Card className="bg-card border-border absolute z-10 mt-1 max-h-60 w-full overflow-auto border">
|
||||
<ul>
|
||||
{suggestions.map((s) => (
|
||||
<li
|
||||
key={s.place_id}
|
||||
className="hover:bg-muted cursor-pointer px-4 py-2 text-sm"
|
||||
onMouseDown={() => handleSelect(s.display_name)}
|
||||
>
|
||||
{s.display_name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { brand, splitLogoText } from "~/lib/branding";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface LogoProps {
|
||||
className?: string;
|
||||
size?: "sm" | "md" | "lg" | "xl" | "icon";
|
||||
animated?: boolean;
|
||||
}
|
||||
|
||||
export function Logo({ className, size = "md", animated = true }: LogoProps) {
|
||||
const [logoPrefix, logoSuffix] = splitLogoText(brand.logoText);
|
||||
const sizeClasses = {
|
||||
sm: "text-base",
|
||||
md: "text-xl",
|
||||
lg: "text-3xl",
|
||||
xl: "text-5xl",
|
||||
icon: "text-2xl",
|
||||
};
|
||||
|
||||
if (!animated) {
|
||||
return (
|
||||
<LogoContent
|
||||
className={className}
|
||||
size={size}
|
||||
sizeClasses={sizeClasses}
|
||||
logoPrefix={logoPrefix}
|
||||
logoSuffix={logoSuffix}
|
||||
icon={brand.icon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.1, ease: "easeOut" }}
|
||||
className={cn(
|
||||
"flex items-center font-mono",
|
||||
sizeClasses[size],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<motion.span
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.02, duration: 0.05, ease: "easeOut" }}
|
||||
className="text-primary font-bold tracking-tight"
|
||||
>
|
||||
{brand.icon}
|
||||
</motion.span>
|
||||
{size !== "icon" && (
|
||||
<>
|
||||
<motion.span
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.03, duration: 0.05, ease: "easeOut" }}
|
||||
className="inline-block w-1"
|
||||
/>
|
||||
<motion.span
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.04, duration: 0.05, ease: "easeOut" }}
|
||||
className="text-foreground font-bold tracking-tight"
|
||||
>
|
||||
{logoPrefix}
|
||||
</motion.span>
|
||||
<motion.span
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.06, duration: 0.05, ease: "easeOut" }}
|
||||
className="text-foreground/70 font-bold tracking-tight"
|
||||
>
|
||||
{logoSuffix}
|
||||
</motion.span>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogoContent({
|
||||
className,
|
||||
size,
|
||||
sizeClasses,
|
||||
logoPrefix,
|
||||
logoSuffix,
|
||||
icon,
|
||||
}: {
|
||||
className?: string;
|
||||
size: "sm" | "md" | "lg" | "xl" | "icon";
|
||||
sizeClasses: Record<string, string>;
|
||||
logoPrefix: string;
|
||||
logoSuffix: string;
|
||||
icon: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center font-mono",
|
||||
sizeClasses[size],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="text-primary font-bold tracking-tight">{icon}</span>
|
||||
{size !== "icon" && (
|
||||
<>
|
||||
<span className="inline-block w-1" />
|
||||
<span className="text-foreground font-bold tracking-tight">
|
||||
{logoPrefix}
|
||||
</span>
|
||||
<span className="text-foreground/70 font-bold tracking-tight">
|
||||
{logoSuffix}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
import { ResponsiveContainer } from "recharts";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface ResponsiveChartProps {
|
||||
height?: number;
|
||||
className?: string;
|
||||
children: ReactElement;
|
||||
}
|
||||
|
||||
export function ResponsiveChart({
|
||||
height = 256,
|
||||
className,
|
||||
children,
|
||||
}: ResponsiveChartProps) {
|
||||
return (
|
||||
<div className={cn("w-full min-w-0", className)}>
|
||||
<ResponsiveContainer width="100%" height={height} minWidth={0}>
|
||||
{children}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** @deprecated Use InvoiceImportPage from ~/components/invoice-import-page */
|
||||
export { InvoiceImportPage as CSVImportPage } from "~/components/invoice-import-page";
|
||||
@@ -0,0 +1,246 @@
|
||||
"use client";
|
||||
|
||||
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 { 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 { toast } from "sonner";
|
||||
import {
|
||||
Mail,
|
||||
Phone,
|
||||
MapPin,
|
||||
Edit,
|
||||
Trash2,
|
||||
Eye,
|
||||
Plus,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
|
||||
export function ClientList() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [clientToDelete, setClientToDelete] = useState<string | null>(null);
|
||||
|
||||
const { data: clients, isLoading, refetch } = api.clients.getAll.useQuery();
|
||||
const deleteClient = api.clients.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Client deleted successfully");
|
||||
void refetch();
|
||||
setDeleteDialogOpen(false);
|
||||
setClientToDelete(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to delete client");
|
||||
},
|
||||
});
|
||||
|
||||
const filteredClients =
|
||||
clients?.filter(
|
||||
(client) =>
|
||||
client.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
client.email?.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
) ?? [];
|
||||
|
||||
const handleDelete = (clientId: string) => {
|
||||
setClientToDelete(clientId);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (clientToDelete) {
|
||||
deleteClient.mutate({ id: clientToDelete });
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 3 }, (_, i: number) => (
|
||||
<Card key={i} className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<div className="h-4 animate-pulse rounded bg-gray-200" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="h-3 animate-pulse rounded bg-gray-200" />
|
||||
<div className="h-3 w-2/3 animate-pulse rounded bg-gray-200" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!clients || clients.length === 0) {
|
||||
return (
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-primary text-2xl font-bold">
|
||||
No Clients Yet
|
||||
</CardTitle>
|
||||
<CardDescription className="text-lg">
|
||||
Get started by adding your first client
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center">
|
||||
<Link href="/dashboard/clients/new">
|
||||
<Button variant="default" className="h-12 w-full">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Your First Client
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col items-start gap-4 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Label htmlFor="search" className="sr-only">
|
||||
Search clients
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Search className="text-muted absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
|
||||
<Input
|
||||
id="search"
|
||||
placeholder="Search by name or email..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-12 pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/dashboard/clients/new">
|
||||
<Button variant="default" className="h-12 w-full sm:w-auto">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Client
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredClients.map((client) => (
|
||||
<Card
|
||||
key={client.id}
|
||||
className="group bg-card border-border border transition-all duration-300 hover:shadow-lg"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between text-lg">
|
||||
<span className="text-foreground group-hover:text-primary font-semibold transition-colors">
|
||||
{client.name}
|
||||
</span>
|
||||
<div className="flex space-x-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Link href={`/dashboard/clients/${client.id}`}>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/dashboard/clients/${client.id}/edit`}>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(client.id)}
|
||||
className="hover:bg-error-subtle hover:text-icon-red h-8 w-8 p-0"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{client.email && (
|
||||
<div className="text-muted-foreground flex items-center text-sm">
|
||||
<div className="bg-muted mr-3 rounded p-1.5">
|
||||
<Mail className="text-muted-foreground h-3 w-3" />
|
||||
</div>
|
||||
{client.email}
|
||||
</div>
|
||||
)}
|
||||
{client.phone && (
|
||||
<div className="text-muted-foreground flex items-center text-sm">
|
||||
<div className="bg-muted mr-3 rounded p-1.5">
|
||||
<Phone className="text-muted-foreground h-3 w-3" />
|
||||
</div>
|
||||
{client.phone}
|
||||
</div>
|
||||
)}
|
||||
{(client.addressLine1 ?? client.city ?? client.state) && (
|
||||
<div className="text-muted-foreground flex items-start text-sm">
|
||||
<div className="bg-muted mt-0.5 mr-3 flex-shrink-0 rounded p-1.5">
|
||||
<MapPin className="text-muted-foreground h-3 w-3" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
{client.addressLine1 && <div>{client.addressLine1}</div>}
|
||||
{client.addressLine2 && <div>{client.addressLine2}</div>}
|
||||
{(client.city ?? client.state ?? client.postalCode) && (
|
||||
<div>
|
||||
{[client.city, client.state, client.postalCode]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
{client.country && <div>{client.country}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent className="bg-card border-border border">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-foreground text-xl font-bold">
|
||||
Delete Client
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
Are you sure you want to delete this client? This action cannot be
|
||||
undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(false)}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmDelete}
|
||||
className="bg-destructive hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { Calendar, Clock, Edit, Eye, FileText, Plus, User } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
export function CurrentOpenInvoiceCard() {
|
||||
const { data: currentInvoice, isLoading } =
|
||||
api.invoices.getCurrentOpen.useQuery();
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(new Date(date));
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-foreground flex items-center gap-2">
|
||||
<FileText className="text-primary h-5 w-5" />
|
||||
Current Open Invoice
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentInvoice) {
|
||||
return (
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-foreground flex items-center gap-2">
|
||||
<FileText className="text-primary h-5 w-5" />
|
||||
Current Open Invoice
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="py-6 text-center">
|
||||
<FileText className="text-muted-foreground mx-auto mb-3 h-8 w-8" />
|
||||
<p className="text-muted-foreground mb-4 text-sm">
|
||||
No open invoice found. Create a new invoice to start tracking your
|
||||
time.
|
||||
</p>
|
||||
<Button asChild variant="default">
|
||||
<Link href="/dashboard/invoices/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create New Invoice
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const totalHours =
|
||||
currentInvoice.items?.reduce((sum, item) => sum + item.hours, 0) ?? 0;
|
||||
const totalAmount = currentInvoice.totalAmount;
|
||||
|
||||
return (
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-foreground flex items-center gap-2">
|
||||
<FileText className="text-primary h-5 w-5" />
|
||||
Current Open Invoice
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className="bg-secondary text-secondary-foreground text-xs">
|
||||
{currentInvoice.invoiceNumber}
|
||||
</Badge>
|
||||
<Badge className="border text-xs">Draft</Badge>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-primary text-sm font-medium">
|
||||
{formatCurrency(totalAmount)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<User className="text-muted-foreground h-3 w-3" />
|
||||
<span className="text-muted-foreground">Client:</span>
|
||||
<span className="font-medium">{currentInvoice.client?.name}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Calendar className="text-muted-foreground h-3 w-3" />
|
||||
<span className="text-muted-foreground">Due:</span>
|
||||
<span className="font-medium">
|
||||
{formatDate(currentInvoice.dueDate)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Clock className="text-muted-foreground h-3 w-3" />
|
||||
<span className="text-muted-foreground">Hours:</span>
|
||||
<span className="font-medium">{totalHours.toFixed(1)}h</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button asChild variant="outline" size="sm" className="flex-1">
|
||||
<Link href={`/dashboard/invoices/${currentInvoice.id}`}>
|
||||
<Eye className="mr-2 h-3 w-3" />
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="default" size="sm" className="flex-1">
|
||||
<Link href={`/dashboard/invoices/${currentInvoice.id}`}>
|
||||
<Edit className="mr-2 h-3 w-3" />
|
||||
Continue
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,744 @@
|
||||
"use client";
|
||||
|
||||
import type {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
RowData,
|
||||
SortingState,
|
||||
VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Filter,
|
||||
Search,
|
||||
SearchX,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { EmptyState } from "~/components/layout/page-layout";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card } from "~/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "~/components/ui/table";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
declare module "@tanstack/react-table" {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Generic names must match TanStack's declaration for module augmentation.
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
headerClassName?: string;
|
||||
cellClassName?: string;
|
||||
}
|
||||
}
|
||||
|
||||
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 }[];
|
||||
}[];
|
||||
onRowClick?: (row: TData) => void;
|
||||
/** Render bulk-action buttons when rows are selected. Receives selected rows and a clear function. */
|
||||
selectionActions?: (
|
||||
selectedRows: TData[],
|
||||
clearSelection: () => void,
|
||||
) => React.ReactNode;
|
||||
initialSorting?: SortingState;
|
||||
/** Shown when the dataset is empty (no rows in DB). */
|
||||
emptyTitle?: string;
|
||||
emptyDescription?: string;
|
||||
emptyIcon?: React.ReactNode;
|
||||
emptyAction?: React.ReactNode;
|
||||
/** Shown when filters/search hide all rows but data exists. */
|
||||
filteredEmptyTitle?: string;
|
||||
filteredEmptyDescription?: string;
|
||||
}
|
||||
|
||||
export interface DataTableEmptyStateProps {
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Centered empty state for data tables (reuses page EmptyState). */
|
||||
export function DataTableEmptyState({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
}: DataTableEmptyStateProps) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={icon}
|
||||
title={title}
|
||||
description={description}
|
||||
action={action}
|
||||
className={cn("py-16", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
searchKey: _searchKey,
|
||||
searchPlaceholder = "Search...",
|
||||
showColumnVisibility = true,
|
||||
showPagination = true,
|
||||
showSearch = true,
|
||||
pageSize = 10,
|
||||
className,
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
filterableColumns = [],
|
||||
onRowClick,
|
||||
selectionActions,
|
||||
initialSorting = [],
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
emptyIcon,
|
||||
emptyAction,
|
||||
filteredEmptyTitle = "No matches for your search",
|
||||
filteredEmptyDescription = "Try adjusting your search or filters.",
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = React.useState<SortingState>(initialSorting);
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
||||
[],
|
||||
);
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
React.useState<VisibilityState>({});
|
||||
const [rowSelection, setRowSelection] = React.useState({});
|
||||
const [globalFilter, setGlobalFilter] = React.useState("");
|
||||
const [searchInput, setSearchInput] = React.useState("");
|
||||
|
||||
// Mobile detection hook
|
||||
const [isMobile, setIsMobile] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 640); // sm breakpoint
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener("resize", checkMobile);
|
||||
return () => window.removeEventListener("resize", checkMobile);
|
||||
}, []);
|
||||
|
||||
// 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.meta ?? {}),
|
||||
headerClassName: column.meta?.headerClassName ?? "",
|
||||
cellClassName: column.meta?.cellClassName ?? "",
|
||||
},
|
||||
}));
|
||||
}, [columns]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
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: isMobile ? 5 : pageSize,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Update page size when mobile state changes
|
||||
React.useEffect(() => {
|
||||
table.setPageSize(isMobile ? 5 : pageSize);
|
||||
}, [isMobile, pageSize, table]);
|
||||
|
||||
// Debounce search input updates to the table's global filter
|
||||
React.useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
setGlobalFilter(searchInput);
|
||||
}, 300);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [searchInput]);
|
||||
|
||||
// Keep search input in sync when globalFilter is changed externally (e.g., "Clear filters")
|
||||
React.useEffect(() => {
|
||||
setSearchInput(globalFilter ?? "");
|
||||
}, [globalFilter]);
|
||||
|
||||
const pageSizeOptions = [5, 10, 20, 30, 50, 100];
|
||||
const filteredRowCount = table.getFilteredRowModel().rows.length;
|
||||
const isDatasetEmpty = data.length === 0;
|
||||
const isFilteredEmpty = !isDatasetEmpty && filteredRowCount === 0;
|
||||
|
||||
// Handle row click
|
||||
const handleRowClick = (row: TData, event: React.MouseEvent) => {
|
||||
// Don't trigger row click if clicking on action buttons or their children
|
||||
const target = event.target as HTMLElement;
|
||||
const isActionButton =
|
||||
target.closest('[data-action-button="true"]') ??
|
||||
target.closest("button") ??
|
||||
target.closest("a") ??
|
||||
target.closest('[role="button"]');
|
||||
|
||||
if (isActionButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
onRowClick?.(row);
|
||||
};
|
||||
|
||||
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="bg-card border-border border">
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
{showSearch && (
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Search className="text-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchInput ?? ""}
|
||||
onChange={(event) => setSearchInput(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="text-foreground h-4 w-4 sm:hidden" />
|
||||
<span className="hidden sm:inline">
|
||||
<SelectValue placeholder={column.title} />
|
||||
</span>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all" className="gap-0">
|
||||
All {column.title}
|
||||
</SelectItem>
|
||||
{column.options.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className="gap-0"
|
||||
>
|
||||
{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="text-foreground 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>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Selection Toolbar */}
|
||||
{selectionActions && table.getSelectedRowModel().rows.length > 0 && (
|
||||
<Card className="bg-primary/5 border-primary/20 border">
|
||||
<div className="flex items-center justify-between gap-3 px-3 py-2">
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
{table.getSelectedRowModel().rows.length} selected
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectionActions(
|
||||
table.getSelectedRowModel().rows.map((r) => r.original),
|
||||
() => table.resetRowSelection(),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Table Content Card */}
|
||||
<Card className="bg-card border-border overflow-hidden border p-0">
|
||||
<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;
|
||||
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={cn(
|
||||
"hover:bg-muted/20 data-[state=selected]:bg-muted/50 border-border/40 table-row border-b transition-colors",
|
||||
onRowClick && "cursor-pointer",
|
||||
)}
|
||||
onClick={(event) =>
|
||||
onRowClick && handleRowClick(row.original, event)
|
||||
}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const meta = cell.column.columnDef.meta;
|
||||
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 className="hover:bg-transparent">
|
||||
<TableCell colSpan={columns.length} className="p-0">
|
||||
{isDatasetEmpty && emptyTitle ? (
|
||||
<DataTableEmptyState
|
||||
icon={emptyIcon}
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
action={emptyAction}
|
||||
/>
|
||||
) : isFilteredEmpty ? (
|
||||
<DataTableEmptyState
|
||||
icon={<SearchX className="h-6 w-6" />}
|
||||
title={filteredEmptyTitle}
|
||||
description={filteredEmptyDescription}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground py-16 text-center text-sm">
|
||||
No results found
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Pagination Bar Card */}
|
||||
{showPagination && (
|
||||
<Card className="bg-card border-border border">
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-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-10 w-10 md:h-8 md: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-10 w-10 md:h-8 md: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">
|
||||
<span className="hidden sm:inline">Page </span>
|
||||
<span className="text-foreground font-medium">
|
||||
{table.getState().pagination.pageIndex + 1}
|
||||
</span>
|
||||
<span className="sm:inline"> of </span>
|
||||
<span className="text-foreground font-medium">
|
||||
{table.getPageCount() || 1}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-10 w-10 md:h-8 md: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-10 w-10 md:h-8 md: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>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper component for sortable column headers
|
||||
export function DataTableColumnHeader({
|
||||
column,
|
||||
title,
|
||||
className,
|
||||
}: {
|
||||
column: {
|
||||
getCanSort: () => boolean;
|
||||
getIsSorted: () => false | "asc" | "desc";
|
||||
toggleSorting: (isDesc: boolean) => void;
|
||||
};
|
||||
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: _columns = 5,
|
||||
rows = 5,
|
||||
}: {
|
||||
columns?: number;
|
||||
rows?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filter bar skeleton */}
|
||||
<Card className="bg-card border-border border">
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<div className="bg-muted/30 h-9 w-full flex-1 animate-pulse sm:max-w-sm"></div>
|
||||
<div className="bg-muted/30 h-9 w-24 animate-pulse"></div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Table skeleton */}
|
||||
<Card className="bg-card border-border overflow-hidden border p-0">
|
||||
<div className="w-full overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
||||
{/* Mobile: 3 columns, sm: 5 columns, lg: 6 columns */}
|
||||
<TableHead className="h-12 px-3 text-left align-middle sm:h-14 sm:px-4">
|
||||
<div className="bg-muted/30 h-4 w-16 animate-pulse rounded sm:w-24 lg:w-32"></div>
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-left align-middle sm:h-14 sm:px-4">
|
||||
<div className="bg-muted/30 h-4 w-14 animate-pulse rounded sm:w-20 lg:w-24"></div>
|
||||
</TableHead>
|
||||
<TableHead className="hidden h-12 px-3 text-left align-middle sm:table-cell sm:h-14 sm:px-4">
|
||||
<div className="bg-muted/30 h-4 w-14 animate-pulse rounded sm:w-20 lg:w-24"></div>
|
||||
</TableHead>
|
||||
<TableHead className="hidden h-12 px-3 text-left align-middle sm:table-cell sm:h-14 sm:px-4">
|
||||
<div className="bg-muted/30 h-4 w-16 animate-pulse rounded sm:w-20 lg:w-24"></div>
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-left align-middle sm:h-14 sm:px-4">
|
||||
<div className="bg-muted/30 h-4 w-10 animate-pulse rounded sm:w-12 lg:w-16"></div>
|
||||
</TableHead>
|
||||
<TableHead className="hidden h-12 px-3 text-left align-middle sm:h-14 sm:px-4 lg:table-cell">
|
||||
<div className="bg-muted/30 h-4 w-20 animate-pulse rounded"></div>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<TableRow key={i} className="border-b">
|
||||
{/* Client */}
|
||||
<TableCell className="px-3 py-3 align-middle sm:px-4 sm:py-4">
|
||||
<div className="bg-muted/30 h-4 w-16 animate-pulse rounded sm:w-24 lg:w-32"></div>
|
||||
</TableCell>
|
||||
{/* Date */}
|
||||
<TableCell className="px-3 py-3 align-middle sm:px-4 sm:py-4">
|
||||
<div className="bg-muted/30 h-4 w-14 animate-pulse rounded sm:w-20 lg:w-24"></div>
|
||||
</TableCell>
|
||||
{/* Status (sm+) */}
|
||||
<TableCell className="hidden px-3 py-3 align-middle sm:table-cell sm:px-4 sm:py-4">
|
||||
<div className="bg-muted/30 h-4 w-14 animate-pulse rounded sm:w-20 lg:w-24"></div>
|
||||
</TableCell>
|
||||
{/* Amount (sm+) */}
|
||||
<TableCell className="hidden px-3 py-3 align-middle sm:table-cell sm:px-4 sm:py-4">
|
||||
<div className="bg-muted/30 h-4 w-16 animate-pulse rounded sm:w-20 lg:w-24"></div>
|
||||
</TableCell>
|
||||
{/* Actions */}
|
||||
<TableCell className="px-3 py-3 align-middle sm:px-4 sm:py-4">
|
||||
<div className="bg-muted/30 h-4 w-10 animate-pulse rounded sm:w-12 lg:w-16"></div>
|
||||
</TableCell>
|
||||
{/* Extra (lg+) */}
|
||||
<TableCell className="hidden px-3 py-3 align-middle sm:px-4 sm:py-4 lg:table-cell">
|
||||
<div className="bg-muted/30 h-4 w-20 animate-pulse rounded"></div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Pagination skeleton */}
|
||||
<Card className="bg-card border-border border">
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-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>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { DatePicker } from "~/components/ui/date-picker";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { Trash2, GripVertical, ChevronUp, ChevronDown } from "lucide-react";
|
||||
|
||||
interface InvoiceItem {
|
||||
id: string;
|
||||
date: Date;
|
||||
description: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
interface EditableInvoiceItemsProps {
|
||||
items: InvoiceItem[];
|
||||
onItemsChange: (items: InvoiceItem[]) => void;
|
||||
onRemoveItem: (index: number) => void;
|
||||
}
|
||||
|
||||
function SortableItem({
|
||||
item,
|
||||
index,
|
||||
onItemChange,
|
||||
onRemove,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
canMoveUp,
|
||||
canMoveDown,
|
||||
}: {
|
||||
item: InvoiceItem;
|
||||
index: number;
|
||||
onItemChange: (
|
||||
index: number,
|
||||
field: string,
|
||||
value: string | number | Date,
|
||||
) => void;
|
||||
onRemove: (index: number) => void;
|
||||
onMoveUp: (index: number) => void;
|
||||
onMoveDown: (index: number) => void;
|
||||
canMoveUp: boolean;
|
||||
canMoveDown: boolean;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: item.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const handleItemChange = (field: string, value: string | number | Date) => {
|
||||
onItemChange(index, field, value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`card-secondary transition-colors ${
|
||||
isDragging ? "opacity-50 shadow-lg" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Desktop Layout - Hidden on Mobile */}
|
||||
<div className="hidden items-center gap-3 p-4 md:grid md:grid-cols-12">
|
||||
{/* Drag Handle */}
|
||||
<div className="col-span-1 flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="text-muted-foreground hover:bg-muted hover:text-foreground cursor-grab rounded p-2 transition-colors active:cursor-grabbing"
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div className="col-span-2">
|
||||
<DatePicker
|
||||
date={item.date}
|
||||
onDateChange={(date) =>
|
||||
handleItemChange("date", date ?? new Date())
|
||||
}
|
||||
size="sm"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="col-span-4">
|
||||
<Input
|
||||
value={item.description}
|
||||
onChange={(e) => handleItemChange("description", e.target.value)}
|
||||
placeholder="Work description"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hours */}
|
||||
<div className="col-span-1">
|
||||
<NumberInput
|
||||
value={item.hours}
|
||||
onChange={(value) => handleItemChange("hours", value)}
|
||||
min={0}
|
||||
step={0.25}
|
||||
placeholder="0"
|
||||
width="full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Rate */}
|
||||
<div className="col-span-2">
|
||||
<NumberInput
|
||||
value={item.rate}
|
||||
onChange={(value) => handleItemChange("rate", value)}
|
||||
min={0}
|
||||
step={0.01}
|
||||
placeholder="0.00"
|
||||
prefix="$"
|
||||
width="full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div className="col-span-1">
|
||||
<div className="bg-muted/30 text-primary flex h-9 items-center border px-3 font-medium">
|
||||
${item.amount.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remove Button */}
|
||||
<div className="col-span-1">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onRemove(index)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive/80 h-9 w-9 p-0"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Layout - Visible on Mobile Only */}
|
||||
<div className="space-y-4 p-4 md:hidden">
|
||||
{/* Header with Item Number and Controls */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
Item {index + 1}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onMoveUp(index)}
|
||||
disabled={!canMoveUp}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onMoveDown(index)}
|
||||
disabled={!canMoveDown}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onRemove(index)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive/80 h-6 w-6 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">Description</Label>
|
||||
<Textarea
|
||||
value={item.description}
|
||||
onChange={(e) => handleItemChange("description", e.target.value)}
|
||||
placeholder="Description of work..."
|
||||
className="min-h-[48px] resize-none text-sm"
|
||||
rows={1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">Date</Label>
|
||||
<DatePicker
|
||||
date={item.date}
|
||||
onDateChange={(date) =>
|
||||
handleItemChange("date", date ?? new Date())
|
||||
}
|
||||
size="sm"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hours and Rate */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">Hours</Label>
|
||||
<NumberInput
|
||||
value={item.hours}
|
||||
onChange={(value) => handleItemChange("hours", value)}
|
||||
min={0}
|
||||
step={0.25}
|
||||
placeholder="0"
|
||||
width="full"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">Rate</Label>
|
||||
<NumberInput
|
||||
value={item.rate}
|
||||
onChange={(value) => handleItemChange("rate", value)}
|
||||
min={0}
|
||||
step={0.01}
|
||||
placeholder="0.00"
|
||||
prefix="$"
|
||||
width="full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div className="bg-muted/20 border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-sm">Total Amount:</span>
|
||||
<span className="text-primary font-mono text-lg font-bold">
|
||||
${item.amount.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditableInvoiceItems({
|
||||
items,
|
||||
onItemsChange,
|
||||
onRemoveItem,
|
||||
}: EditableInvoiceItemsProps) {
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (active.id !== over?.id) {
|
||||
const oldIndex = items.findIndex((item) => item.id === active.id);
|
||||
const newIndex = items.findIndex((item) => item.id === over?.id);
|
||||
|
||||
const newItems = arrayMove(items, oldIndex, newIndex);
|
||||
onItemsChange(newItems);
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemChange = (
|
||||
index: number,
|
||||
field: string,
|
||||
value: string | number | Date,
|
||||
) => {
|
||||
const newItems = [...items];
|
||||
if (field === "hours" || field === "rate") {
|
||||
if (newItems[index]) {
|
||||
const numValue =
|
||||
typeof value === "string"
|
||||
? parseFloat(value)
|
||||
: typeof value === "number"
|
||||
? value
|
||||
: 0;
|
||||
newItems[index][field] = numValue || 0;
|
||||
newItems[index].amount = newItems[index].hours * newItems[index].rate;
|
||||
}
|
||||
} else if (field === "date") {
|
||||
if (newItems[index]) {
|
||||
const dateValue =
|
||||
value instanceof Date ? value : new Date(String(value));
|
||||
newItems[index].date = dateValue;
|
||||
}
|
||||
} else {
|
||||
if (newItems[index]) {
|
||||
const stringValue = typeof value === "string" ? value : String(value);
|
||||
newItems[index].description = stringValue;
|
||||
}
|
||||
}
|
||||
onItemsChange(newItems);
|
||||
};
|
||||
|
||||
const handleMoveUp = (index: number) => {
|
||||
if (index > 0) {
|
||||
const newItems = arrayMove(items, index, index - 1);
|
||||
onItemsChange(newItems);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveDown = (index: number) => {
|
||||
if (index < items.length - 1) {
|
||||
const newItems = arrayMove(items, index, index + 1);
|
||||
onItemsChange(newItems);
|
||||
}
|
||||
};
|
||||
|
||||
// Show skeleton loading on server-side
|
||||
if (!isClient) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{items.map((item, _index) => (
|
||||
<div key={item.id} className="card-secondary animate-pulse p-4">
|
||||
{/* Desktop Skeleton */}
|
||||
<div className="hidden grid-cols-12 gap-3 md:grid">
|
||||
<div className="col-span-1">
|
||||
<div className="bg-muted h-4 w-4 rounded"></div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div className="bg-muted h-9 rounded"></div>
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<div className="bg-muted h-9 rounded"></div>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<div className="bg-muted h-9 rounded"></div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div className="bg-muted h-9 rounded"></div>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<div className="bg-muted h-9 rounded"></div>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<div className="bg-muted h-9 w-9 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Mobile Skeleton */}
|
||||
<div className="space-y-3 md:hidden">
|
||||
<div className="bg-muted h-4 w-20 rounded"></div>
|
||||
<div className="bg-muted h-16 rounded"></div>
|
||||
<div className="bg-muted h-9 rounded"></div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-muted h-9 rounded"></div>
|
||||
<div className="bg-muted h-9 rounded"></div>
|
||||
</div>
|
||||
<div className="bg-muted h-12 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop Header Labels - Hidden on Mobile */}
|
||||
<div className="text-muted-foreground hidden items-center gap-3 px-4 pb-2 text-xs font-medium md:grid md:grid-cols-12">
|
||||
<div className="col-span-1"></div>
|
||||
<div className="col-span-2">Date</div>
|
||||
<div className="col-span-4">Description</div>
|
||||
<div className="col-span-1">Hours</div>
|
||||
<div className="col-span-2">Rate</div>
|
||||
<div className="col-span-1">Amount</div>
|
||||
<div className="col-span-1"></div>
|
||||
</div>
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={items.map((item) => item.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{items.map((item, index) => (
|
||||
<SortableItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={index}
|
||||
onItemChange={handleItemChange}
|
||||
onRemove={onRemoveItem}
|
||||
onMoveUp={handleMoveUp}
|
||||
onMoveDown={handleMoveDown}
|
||||
canMoveUp={index > 0}
|
||||
canMoveDown={index < items.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
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 { 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 { StatusBadge, type StatusType } from "~/components/data/status-badge";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
FileText,
|
||||
Calendar,
|
||||
Edit,
|
||||
Trash2,
|
||||
Eye,
|
||||
Plus,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
|
||||
export function InvoiceList() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [invoiceToDelete, setInvoiceToDelete] = useState<string | null>(null);
|
||||
|
||||
const { data: invoices, isLoading, refetch } = api.invoices.getAll.useQuery();
|
||||
const deleteInvoice = api.invoices.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Invoice deleted successfully");
|
||||
void refetch();
|
||||
setDeleteDialogOpen(false);
|
||||
setInvoiceToDelete(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message ?? "Failed to delete invoice");
|
||||
},
|
||||
});
|
||||
|
||||
const filteredInvoices =
|
||||
invoices?.filter(
|
||||
(invoice) =>
|
||||
invoice.invoiceNumber
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase()) ||
|
||||
invoice.client.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
) ?? [];
|
||||
|
||||
const handleDelete = (invoiceId: string) => {
|
||||
setInvoiceToDelete(invoiceId);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (invoiceToDelete) {
|
||||
deleteInvoice.mutate({ id: invoiceToDelete });
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Date(date).toLocaleDateString();
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<div className="bg-muted h-4 animate-pulse rounded" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="bg-muted h-3 animate-pulse rounded" />
|
||||
<div className="bg-muted h-3 w-2/3 animate-pulse rounded" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!invoices || invoices.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>No Invoices Yet</CardTitle>
|
||||
<CardDescription>
|
||||
Get started by creating your first invoice
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link href="/dashboard/invoices/new">
|
||||
<Button className="w-full">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Your First Invoice
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="search">Search invoices</Label>
|
||||
<Input
|
||||
id="search"
|
||||
placeholder="Search by invoice number or client..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Link href="/dashboard/invoices/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Invoice
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredInvoices.map((invoice) => (
|
||||
<Card key={invoice.id}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span className="truncate">{invoice.invoiceNumber}</span>
|
||||
<div className="flex space-x-1">
|
||||
<Link href={`/dashboard/invoices/${invoice.id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
{invoice.status === "draft" ? (
|
||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled
|
||||
title="Only draft invoices can be edited"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(invoice.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardTitle>
|
||||
<div className="flex items-center justify-between">
|
||||
<StatusBadge status={invoice.status as StatusType} />
|
||||
<span className="text-primary text-lg font-bold">
|
||||
{formatCurrency(invoice.totalAmount)}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="text-muted-foreground flex items-center text-sm">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
{invoice.client.name}
|
||||
</div>
|
||||
<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="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" : ""}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Invoice</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this invoice? This action cannot
|
||||
be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
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-primary",
|
||||
background: "bg-primary/10",
|
||||
},
|
||||
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-primary" : "text-destructive",
|
||||
)}
|
||||
>
|
||||
{trend.isPositive ? "+" : ""}
|
||||
{trend.value}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-xs">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{Icon && (
|
||||
<div className={cn("p-3", styles.background)}>
|
||||
<Icon className={cn("h-6 w-6", styles.icon)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsCardSkeleton() {
|
||||
return (
|
||||
<Card className="bg-card border-border border">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as React from "react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
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 statusClassMap: Record<StatusType, string> = {
|
||||
draft: "border-muted-foreground/40 bg-muted text-muted-foreground shadow-sm",
|
||||
sent: "border-primary/40 bg-primary/10 text-primary shadow-sm",
|
||||
paid: "border-primary/40 bg-primary/10 text-primary shadow-sm",
|
||||
overdue: "border-destructive/40 bg-destructive/10 text-destructive shadow-sm",
|
||||
success: "border-primary/40 bg-primary/10 text-primary shadow-sm",
|
||||
warning:
|
||||
"border-muted-foreground/40 bg-muted text-muted-foreground shadow-sm",
|
||||
error: "border-destructive/40 bg-destructive/10 text-destructive shadow-sm",
|
||||
info: "border-primary/40 bg-primary/10 text-primary shadow-sm",
|
||||
};
|
||||
|
||||
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,
|
||||
className,
|
||||
...props
|
||||
}: StatusBadgeProps) {
|
||||
const statusClass = statusClassMap[status];
|
||||
const label = children ?? statusLabelMap[status];
|
||||
|
||||
return (
|
||||
<Badge
|
||||
className={cn(
|
||||
statusClass,
|
||||
"transition-all duration-200 hover:scale-105",
|
||||
status === "sent" && "animate-pulse",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export { type StatusType };
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { FileText, Loader2, Paperclip } from "lucide-react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import { ExpenseReceiptItem } from "~/components/expenses/expense-receipt-item";
|
||||
import { ReceiptViewerDialog } from "~/components/expenses/receipt-viewer-dialog";
|
||||
import type { ReceiptViewerTarget } from "~/components/expenses/receipt-viewer-dialog";
|
||||
import { isImageReceipt, receiptUrl } from "~/components/expenses/receipt-utils";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface ReceiptPreview {
|
||||
id: string;
|
||||
mimeType: string;
|
||||
originalFilename: string;
|
||||
}
|
||||
|
||||
interface ExpenseReceiptIndicatorProps {
|
||||
expenseId: string;
|
||||
receiptCount: number;
|
||||
receiptPreview: ReceiptPreview | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ExpenseReceiptIndicator({
|
||||
expenseId,
|
||||
receiptCount,
|
||||
receiptPreview,
|
||||
className,
|
||||
}: ExpenseReceiptIndicatorProps) {
|
||||
const [listOpen, setListOpen] = useState(false);
|
||||
const [viewerReceipt, setViewerReceipt] = useState<ReceiptViewerTarget | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const { data: receipts = [], isLoading } = api.expenses.listReceipts.useQuery(
|
||||
{ expenseId },
|
||||
{ enabled: listOpen && receiptCount > 1 },
|
||||
);
|
||||
|
||||
if (receiptCount === 0) {
|
||||
return (
|
||||
<span className={cn("text-muted-foreground text-xs", className)}>—</span>
|
||||
);
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
if (receiptCount === 1 && receiptPreview) {
|
||||
setViewerReceipt({
|
||||
id: receiptPreview.id,
|
||||
originalFilename: receiptPreview.originalFilename,
|
||||
mimeType: receiptPreview.mimeType,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setListOpen(true);
|
||||
};
|
||||
|
||||
const previewIsImage =
|
||||
receiptPreview && isImageReceipt(receiptPreview.mimeType);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"hover:bg-muted h-auto gap-2 px-2 py-1.5 font-normal",
|
||||
className,
|
||||
)}
|
||||
title={
|
||||
receiptCount === 1
|
||||
? "View receipt"
|
||||
: `View ${receiptCount} receipts`
|
||||
}
|
||||
>
|
||||
{previewIsImage ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={receiptUrl(receiptPreview.id)}
|
||||
alt=""
|
||||
className="h-8 w-8 rounded object-cover ring-1 ring-black/5"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-muted flex h-8 w-8 items-center justify-center rounded ring-1 ring-black/5">
|
||||
<FileText className="text-muted-foreground h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
<span className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||
<Paperclip className="h-3 w-3" />
|
||||
{receiptCount}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<ReceiptViewerDialog
|
||||
receipt={viewerReceipt}
|
||||
open={!!viewerReceipt}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setViewerReceipt(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dialog open={listOpen} onOpenChange={setListOpen}>
|
||||
<DialogContent className="max-h-[85vh] max-w-lg overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Receipts ({receiptCount})</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading receipts…
|
||||
</div>
|
||||
) : (
|
||||
receipts.map((receipt) => (
|
||||
<ExpenseReceiptItem
|
||||
key={receipt.id}
|
||||
receipt={receipt}
|
||||
expenseId={expenseId}
|
||||
onView={(r) => {
|
||||
setListOpen(false);
|
||||
setViewerReceipt(r);
|
||||
}}
|
||||
compact
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ExternalLink, Eye, FileText, Loader2, Trash2 } from "lucide-react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "~/components/ui/alert-dialog";
|
||||
import {
|
||||
formatReceiptSize,
|
||||
isImageReceipt,
|
||||
receiptUrl,
|
||||
} from "~/components/expenses/receipt-utils";
|
||||
import type { ReceiptViewerTarget } from "~/components/expenses/receipt-viewer-dialog";
|
||||
|
||||
export interface ExpenseReceiptRecord {
|
||||
id: string;
|
||||
originalFilename: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
}
|
||||
|
||||
interface ExpenseReceiptItemProps {
|
||||
receipt: ExpenseReceiptRecord;
|
||||
expenseId: string;
|
||||
onView: (receipt: ReceiptViewerTarget) => void;
|
||||
compact?: boolean;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function ExpenseReceiptItem({
|
||||
receipt,
|
||||
expenseId,
|
||||
onView,
|
||||
compact = false,
|
||||
readOnly = false,
|
||||
}: ExpenseReceiptItemProps) {
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
|
||||
const deleteReceipt = api.expenses.deleteReceipt.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Receipt removed");
|
||||
void utils.expenses.listReceipts.invalidate({ expenseId });
|
||||
void utils.expenses.getAll.invalidate();
|
||||
setConfirmDelete(false);
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const url = receiptUrl(receipt.id);
|
||||
const isImage = isImageReceipt(receipt.mimeType);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={
|
||||
compact
|
||||
? "flex items-center gap-2 rounded-md border p-2"
|
||||
: "flex items-center gap-3 rounded-md border p-2 sm:p-3"
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onView(receipt)}
|
||||
className="hover:ring-primary/40 focus-visible:ring-ring shrink-0 overflow-hidden rounded transition hover:ring-2 focus-visible:ring-2 focus-visible:outline-none"
|
||||
aria-label={`View ${receipt.originalFilename}`}
|
||||
>
|
||||
{isImage ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
className={
|
||||
compact ? "h-10 w-10 object-cover" : "h-12 w-12 object-cover sm:h-14 sm:w-14"
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={
|
||||
compact
|
||||
? "bg-muted flex h-10 w-10 items-center justify-center"
|
||||
: "bg-muted flex h-12 w-12 items-center justify-center sm:h-14 sm:w-14"
|
||||
}
|
||||
>
|
||||
<FileText className="text-muted-foreground h-5 w-5 sm:h-6 sm:w-6" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{receipt.originalFilename}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{formatReceiptSize(receipt.sizeBytes)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => onView(receipt)}
|
||||
title="View receipt"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" asChild>
|
||||
<a href={url} target="_blank" rel="noreferrer" title="Open in new tab">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
{!readOnly && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive h-8 w-8 p-0"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
disabled={deleteReceipt.isPending}
|
||||
title="Delete receipt"
|
||||
>
|
||||
{deleteReceipt.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete receipt?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
“{receipt.originalFilename}” will be permanently
|
||||
removed. This cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteReceipt.isPending}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
disabled={deleteReceipt.isPending}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
deleteReceipt.mutate({ id: receipt.id });
|
||||
}}
|
||||
>
|
||||
{deleteReceipt.isPending ? "Deleting…" : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { Loader2, Paperclip } from "lucide-react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { FileUpload } from "~/components/forms/file-upload";
|
||||
import { ExpenseReceiptItem } from "~/components/expenses/expense-receipt-item";
|
||||
import { ReceiptViewerDialog } from "~/components/expenses/receipt-viewer-dialog";
|
||||
import type { ReceiptViewerTarget } from "~/components/expenses/receipt-viewer-dialog";
|
||||
import {
|
||||
fileToBase64,
|
||||
RECEIPT_ACCEPT,
|
||||
RECEIPT_MAX_SIZE,
|
||||
RECEIPT_UPLOAD_HINT,
|
||||
} from "~/components/expenses/receipt-utils";
|
||||
|
||||
interface ExpenseReceiptsPanelProps {
|
||||
expenseId: string | null;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function ExpenseReceiptsPanel({
|
||||
expenseId,
|
||||
readOnly = false,
|
||||
}: ExpenseReceiptsPanelProps) {
|
||||
const [viewerReceipt, setViewerReceipt] = useState<ReceiptViewerTarget | null>(
|
||||
null,
|
||||
);
|
||||
const [uploadKey, setUploadKey] = useState(0);
|
||||
const processedFileCountRef = useRef(0);
|
||||
|
||||
const utils = api.useUtils();
|
||||
const { data: receipts = [], isLoading } = api.expenses.listReceipts.useQuery(
|
||||
{ expenseId: expenseId! },
|
||||
{ enabled: !!expenseId },
|
||||
);
|
||||
|
||||
const uploadReceipt = api.expenses.uploadReceipt.useMutation({
|
||||
onSuccess: () => {
|
||||
if (expenseId) {
|
||||
void utils.expenses.listReceipts.invalidate({ expenseId });
|
||||
void utils.expenses.getAll.invalidate();
|
||||
}
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const handleFiles = async (files: File[]) => {
|
||||
if (!expenseId || files.length === 0) return;
|
||||
|
||||
const newFiles = files.slice(processedFileCountRef.current);
|
||||
processedFileCountRef.current = files.length;
|
||||
if (newFiles.length === 0) return;
|
||||
|
||||
let uploaded = 0;
|
||||
for (const file of newFiles) {
|
||||
try {
|
||||
const data = await fileToBase64(file);
|
||||
await uploadReceipt.mutateAsync({
|
||||
expenseId,
|
||||
filename: file.name,
|
||||
mimeType: file.type || "application/octet-stream",
|
||||
data,
|
||||
});
|
||||
uploaded++;
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Upload failed";
|
||||
toast.error(`${file.name}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (uploaded > 0) {
|
||||
toast.success(
|
||||
uploaded === 1 ? "Receipt uploaded" : `${uploaded} receipts uploaded`,
|
||||
);
|
||||
processedFileCountRef.current = 0;
|
||||
setUploadKey((k) => k + 1);
|
||||
}
|
||||
};
|
||||
|
||||
if (!expenseId) {
|
||||
return (
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Paperclip className="h-4 w-4" />
|
||||
Receipts
|
||||
</Label>
|
||||
<div className="bg-muted/40 text-muted-foreground rounded-md border border-dashed p-4 text-center text-sm">
|
||||
Save the expense first, then drag and drop receipts here.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 border-t pt-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Paperclip className="h-4 w-4" />
|
||||
Receipts
|
||||
{receipts.length > 0 && (
|
||||
<span className="text-muted-foreground text-xs font-normal">
|
||||
({receipts.length})
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
{uploadReceipt.isPending && (
|
||||
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Uploading…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 rounded-md border border-dashed p-6 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading receipts…
|
||||
</div>
|
||||
) : receipts.length === 0 ? (
|
||||
<div className="text-muted-foreground rounded-md border border-dashed p-4 text-center text-sm">
|
||||
{readOnly
|
||||
? "No receipts attached."
|
||||
: "No receipts yet. Drop images or PDFs below."}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{receipts.map((receipt) => (
|
||||
<ExpenseReceiptItem
|
||||
key={receipt.id}
|
||||
receipt={receipt}
|
||||
expenseId={expenseId}
|
||||
onView={setViewerReceipt}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!readOnly && (
|
||||
<FileUpload
|
||||
key={`${expenseId}-${uploadKey}`}
|
||||
onFilesSelected={(files) => void handleFiles(files)}
|
||||
accept={RECEIPT_ACCEPT}
|
||||
maxFiles={5}
|
||||
maxSize={RECEIPT_MAX_SIZE}
|
||||
disabled={uploadReceipt.isPending}
|
||||
placeholder="Drop receipts here or tap to browse"
|
||||
description={RECEIPT_UPLOAD_HINT}
|
||||
className="[&>div:first-child]:p-4 sm:[&>div:first-child]:p-6"
|
||||
/>
|
||||
)}
|
||||
|
||||
<ReceiptViewerDialog
|
||||
receipt={viewerReceipt}
|
||||
open={!!viewerReceipt}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setViewerReceipt(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export const RECEIPT_ACCEPT: Record<string, string[]> = {
|
||||
"image/*": [".png", ".jpg", ".jpeg", ".gif", ".webp", ".heic"],
|
||||
"application/pdf": [".pdf"],
|
||||
};
|
||||
|
||||
export const RECEIPT_MAX_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
export const RECEIPT_UPLOAD_HINT =
|
||||
"PNG, JPG, or PDF · up to 10MB each";
|
||||
|
||||
export function receiptUrl(receiptId: string) {
|
||||
return `/api/receipts/${receiptId}`;
|
||||
}
|
||||
|
||||
export function isImageReceipt(mimeType: string) {
|
||||
return mimeType.startsWith("image/");
|
||||
}
|
||||
|
||||
export function isPdfReceipt(mimeType: string) {
|
||||
return mimeType === "application/pdf";
|
||||
}
|
||||
|
||||
export function formatReceiptSize(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export async function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
const base64 = result.split(",")[1];
|
||||
if (!base64) {
|
||||
reject(new Error("Failed to read file"));
|
||||
return;
|
||||
}
|
||||
resolve(base64);
|
||||
};
|
||||
reader.onerror = () =>
|
||||
reject(reader.error instanceof Error ? reader.error : new Error("Failed to read file"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { ExternalLink, FileText } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import {
|
||||
isImageReceipt,
|
||||
isPdfReceipt,
|
||||
receiptUrl,
|
||||
} from "~/components/expenses/receipt-utils";
|
||||
|
||||
export interface ReceiptViewerTarget {
|
||||
id: string;
|
||||
originalFilename: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
interface ReceiptViewerDialogProps {
|
||||
receipt: ReceiptViewerTarget | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ReceiptViewerDialog({
|
||||
receipt,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ReceiptViewerDialogProps) {
|
||||
if (!receipt) return null;
|
||||
|
||||
const url = receiptUrl(receipt.id);
|
||||
const isImage = isImageReceipt(receipt.mimeType);
|
||||
const isPdf = isPdfReceipt(receipt.mimeType);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col gap-4">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle className="truncate pr-8">
|
||||
{receipt.originalFilename}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="bg-muted/30 min-h-[200px] flex-1 overflow-auto rounded-md border">
|
||||
{isImage ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={url}
|
||||
alt={receipt.originalFilename}
|
||||
className="mx-auto max-h-[min(70vh,720px)] w-full object-contain"
|
||||
/>
|
||||
) : isPdf ? (
|
||||
<iframe
|
||||
src={url}
|
||||
title={receipt.originalFilename}
|
||||
className="h-[min(70vh,720px)] w-full border-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex h-48 flex-col items-center justify-center gap-3 p-6 text-center text-sm">
|
||||
<FileText className="h-10 w-10" />
|
||||
<p>Preview not available for this file type.</p>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={url} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open file
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="shrink-0 sm:justify-between">
|
||||
<Button variant="outline" asChild>
|
||||
<a href={url} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open in new tab
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
"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
|
||||
key={`state-${state}`}
|
||||
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
|
||||
key={`country-${country}`}
|
||||
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 ?? false) ||
|
||||
(option.value?.startsWith("divider-") ?? false)
|
||||
}
|
||||
/>
|
||||
{errors.country && (
|
||||
<p className="text-destructive text-sm">{errors.country}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,587 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
UserPlus,
|
||||
Save,
|
||||
Loader2,
|
||||
ArrowLeft,
|
||||
DollarSign,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } 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 { Skeleton } from "~/components/ui/skeleton";
|
||||
import { AddressForm } from "~/components/forms/address-form";
|
||||
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import { DashboardPage, dashboardGapClass } from "~/components/layout/dashboard-page";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
import { api } from "~/trpc/react";
|
||||
import {
|
||||
formatPhoneNumber,
|
||||
isValidEmail,
|
||||
VALIDATION_MESSAGES,
|
||||
PLACEHOLDERS,
|
||||
} from "~/lib/form-constants";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { SUPPORTED_CURRENCIES } from "~/lib/currency";
|
||||
|
||||
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;
|
||||
defaultHourlyRate: number | null;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
name?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
addressLine1?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
postalCode?: string;
|
||||
country?: string;
|
||||
defaultHourlyRate?: string;
|
||||
}
|
||||
|
||||
const initialFormData: FormData = {
|
||||
name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
addressLine1: "",
|
||||
addressLine2: "",
|
||||
city: "",
|
||||
state: "",
|
||||
postalCode: "",
|
||||
country: "United States",
|
||||
defaultHourlyRate: null,
|
||||
currency: "USD",
|
||||
};
|
||||
|
||||
export function ClientForm({ clientId, mode }: ClientFormProps) {
|
||||
const router = useRouter();
|
||||
const [formData, setFormData] = useState<FormData>(initialFormData);
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
// Fetch client data if editing
|
||||
const { data: client, isLoading: isLoadingClient } =
|
||||
api.clients.getById.useQuery(
|
||||
{ id: clientId! },
|
||||
{
|
||||
enabled: mode === "edit" && !!clientId,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
const createClient = api.clients.create.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Client created successfully");
|
||||
router.push("/dashboard/entities?tab=clients");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to create client");
|
||||
},
|
||||
});
|
||||
|
||||
const updateClient = api.clients.update.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Client updated successfully");
|
||||
router.push("/dashboard/entities?tab=clients");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to update client");
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reset form when navigating to a different client.
|
||||
setInitialized(false);
|
||||
setIsDirty(false);
|
||||
setFormData(initialFormData);
|
||||
}, [clientId]);
|
||||
|
||||
// Load client data once when editing (avoid overwriting unsaved changes on refetch)
|
||||
useEffect(() => {
|
||||
if (client && mode === "edit" && !initialized) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded client data into the edit form.
|
||||
setFormData({
|
||||
name: client.name,
|
||||
email: client.email ?? "",
|
||||
phone: client.phone ?? "",
|
||||
addressLine1: client.addressLine1 ?? "",
|
||||
addressLine2: client.addressLine2 ?? "",
|
||||
city: client.city ?? "",
|
||||
state: client.state ?? "",
|
||||
postalCode: client.postalCode ?? "",
|
||||
country: client.country ?? "United States",
|
||||
defaultHourlyRate: client.defaultHourlyRate ?? null,
|
||||
currency: client.currency ?? "USD",
|
||||
});
|
||||
setInitialized(true);
|
||||
}
|
||||
}, [client, mode, initialized]);
|
||||
|
||||
const handleInputChange = (field: string, value: string | number | null) => {
|
||||
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();
|
||||
|
||||
if (!validateForm()) {
|
||||
toast.error("Please correct the errors in the form");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const apiData = {
|
||||
...formData,
|
||||
defaultHourlyRate: formData.defaultHourlyRate ?? undefined,
|
||||
};
|
||||
|
||||
if (mode === "create") {
|
||||
await createClient.mutateAsync(apiData);
|
||||
} else {
|
||||
await updateClient.mutateAsync({
|
||||
id: clientId!,
|
||||
...apiData,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
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/entities?tab=clients");
|
||||
};
|
||||
|
||||
if (mode === "edit" && isLoadingClient) {
|
||||
return (
|
||||
<DashboardPage className="pb-32">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardPage className="pb-32">
|
||||
<DashboardPageHeader
|
||||
title={mode === "edit" ? "Edit Client" : "Add Client"}
|
||||
description={
|
||||
mode === "edit"
|
||||
? "Update client information below"
|
||||
: "Enter client details below to add a new client."
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
form="client-form"
|
||||
disabled={isSubmitting}
|
||||
variant="default"
|
||||
className="shadow-md"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin sm:mr-2" />
|
||||
<span className="hidden sm:inline">
|
||||
{mode === "create" ? "Creating..." : "Saving..."}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">
|
||||
{mode === "create" ? "Create Client" : "Save Changes"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DashboardPageHeader>
|
||||
|
||||
<form
|
||||
id="client-form"
|
||||
onSubmit={handleSubmit}
|
||||
className={cn("flex flex-col", dashboardGapClass)}
|
||||
>
|
||||
{/* Main Form Container - styled like data table */}
|
||||
<div className="space-y-4">
|
||||
{/* Basic Information */}
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center">
|
||||
<UserPlus className="text-primary h-5 w-5" />
|
||||
</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">
|
||||
Client Name<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleInputChange("name", e.target.value)}
|
||||
placeholder={PLACEHOLDERS.name}
|
||||
className={`${errors.name ? "border-destructive" : ""}`}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-destructive text-sm">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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={PLACEHOLDERS.email}
|
||||
className={`${errors.email ? "border-destructive" : ""}`}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-destructive text-sm">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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={PLACEHOLDERS.phone}
|
||||
className={`${errors.phone ? "border-destructive" : ""}`}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{errors.phone && (
|
||||
<p className="text-destructive text-sm">{errors.phone}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Address */}
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center">
|
||||
<svg
|
||||
className="text-primary h-5 w-5"
|
||||
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>
|
||||
</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>
|
||||
|
||||
{/* Billing Information */}
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center">
|
||||
<DollarSign className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Billing Information</CardTitle>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
Default billing rates for this client
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="defaultHourlyRate"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Default Hourly Rate (Optional)
|
||||
</Label>
|
||||
<p className="text-muted-foreground mb-2 text-xs">
|
||||
This rate will be used as the default when creating new
|
||||
invoice items for this client.
|
||||
</p>
|
||||
<NumberInput
|
||||
value={formData.defaultHourlyRate ?? 0}
|
||||
onChange={(value) =>
|
||||
handleInputChange(
|
||||
"defaultHourlyRate",
|
||||
value === 0 ? null : value,
|
||||
)
|
||||
}
|
||||
min={0}
|
||||
step={1}
|
||||
prefix="$"
|
||||
width="full"
|
||||
disabled={isSubmitting}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
{errors.defaultHourlyRate && (
|
||||
<p className="text-destructive text-sm">
|
||||
{errors.defaultHourlyRate}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="currency" className="text-sm font-medium">
|
||||
Currency
|
||||
</Label>
|
||||
<p className="text-muted-foreground mb-2 text-xs">
|
||||
Default currency for invoices created for this client.
|
||||
</p>
|
||||
<Select
|
||||
value={formData.currency}
|
||||
onValueChange={(v) => handleInputChange("currency", v)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SUPPORTED_CURRENCIES.map((c) => (
|
||||
<SelectItem key={c.code} value={c.code}>
|
||||
{c.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</form>
|
||||
</DashboardPage>
|
||||
|
||||
<FloatingActionBar
|
||||
leftContent={
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-primary/10 p-2">
|
||||
<FileText className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{mode === "create"
|
||||
? "Creating a new client"
|
||||
: "Editing client details"}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{mode === "create"
|
||||
? "Complete the form to create your client"
|
||||
: "Update your client information"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={isSubmitting}
|
||||
className="border-border/40 hover:bg-accent/50"
|
||||
size="sm"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Cancel</span>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !isDirty}
|
||||
variant="default"
|
||||
size="sm"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin sm:mr-2" />
|
||||
<span className="hidden sm:inline">
|
||||
{mode === "create" ? "Creating..." : "Saving..."}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">
|
||||
{mode === "create" ? "Create Client" : "Save Changes"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</FloatingActionBar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
"use client";
|
||||
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { TextStyle } from "@tiptap/extension-text-style";
|
||||
import { Color } from "@tiptap/extension-color";
|
||||
import { TextAlign } from "@tiptap/extension-text-align";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Underline,
|
||||
List,
|
||||
ListOrdered,
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
Palette,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "~/components/ui/popover";
|
||||
|
||||
interface EmailComposerProps {
|
||||
subject: string;
|
||||
onSubjectChange: (subject: string) => void;
|
||||
content?: string;
|
||||
onContentChange?: (content: string) => void;
|
||||
customMessage?: string;
|
||||
onCustomMessageChange?: (customMessage: string) => void;
|
||||
fromEmail: string;
|
||||
toEmail: string;
|
||||
ccEmail?: string;
|
||||
onCcEmailChange?: (ccEmail: string) => void;
|
||||
bccEmail?: string;
|
||||
onBccEmailChange?: (bccEmail: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const MenuButton = ({
|
||||
onClick,
|
||||
isActive,
|
||||
children,
|
||||
title,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
isActive?: boolean;
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
}) => (
|
||||
<Button
|
||||
type="button"
|
||||
variant={isActive ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
|
||||
export function EmailComposer({
|
||||
subject,
|
||||
onSubjectChange,
|
||||
content: _content,
|
||||
onContentChange: _onContentChange,
|
||||
customMessage = "",
|
||||
onCustomMessageChange,
|
||||
fromEmail,
|
||||
toEmail,
|
||||
ccEmail = "",
|
||||
onCcEmailChange,
|
||||
bccEmail = "",
|
||||
onBccEmailChange,
|
||||
className,
|
||||
}: EmailComposerProps) {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
TextStyle,
|
||||
Color.configure({
|
||||
types: ["textStyle"],
|
||||
}),
|
||||
TextAlign.configure({
|
||||
types: ["heading", "paragraph"],
|
||||
}),
|
||||
],
|
||||
content: customMessage,
|
||||
immediatelyRender: false,
|
||||
onUpdate: ({ editor }) => {
|
||||
onCustomMessageChange?.(editor.isEmpty ? "" : editor.getHTML());
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
"prose prose-sm sm:prose lg:prose-lg xl:prose-2xl mx-auto focus:outline-none min-h-[120px] p-4 border bg-background",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Update editor content when customMessage prop changes
|
||||
useEffect(() => {
|
||||
if (editor && customMessage !== undefined) {
|
||||
const currentContent = editor.isEmpty ? "" : editor.getHTML();
|
||||
if (currentContent !== customMessage) {
|
||||
editor.commands.setContent(customMessage);
|
||||
}
|
||||
}
|
||||
}, [editor, customMessage]);
|
||||
|
||||
const colors = [
|
||||
"#000000",
|
||||
"#374151",
|
||||
"#DC2626",
|
||||
"#EA580C",
|
||||
"#D97706",
|
||||
"#65A30D",
|
||||
"#16A34A",
|
||||
"#0891B2",
|
||||
"#2563EB",
|
||||
"#7C3AED",
|
||||
"#C026D3",
|
||||
"#DC2626",
|
||||
];
|
||||
|
||||
if (!editor) {
|
||||
return (
|
||||
<div className="bg-muted flex h-[200px] items-center justify-center border">
|
||||
<div className="text-center">
|
||||
<div className="border-primary mx-auto mb-2 h-4 w-4 animate-spin border-2 border-t-transparent"></div>
|
||||
<p className="text-muted-foreground text-sm">Loading editor...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Email Headers */}
|
||||
<div className="bg-muted/20 space-y-4 border p-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="from-email" className="text-sm font-medium">
|
||||
From
|
||||
</Label>
|
||||
<Input
|
||||
id="from-email"
|
||||
value={fromEmail}
|
||||
disabled
|
||||
className="bg-muted text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="to-email" className="text-sm font-medium">
|
||||
To
|
||||
</Label>
|
||||
<Input
|
||||
id="to-email"
|
||||
value={toEmail}
|
||||
disabled
|
||||
className="bg-muted text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{(onCcEmailChange ?? onBccEmailChange) && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{onCcEmailChange && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cc-email" className="text-sm font-medium">
|
||||
CC
|
||||
</Label>
|
||||
<Input
|
||||
id="cc-email"
|
||||
value={ccEmail ?? ""}
|
||||
onChange={(e) => onCcEmailChange(e.target.value)}
|
||||
placeholder="CC email addresses..."
|
||||
className="bg-background"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{onBccEmailChange && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bcc-email" className="text-sm font-medium">
|
||||
BCC
|
||||
</Label>
|
||||
<Input
|
||||
id="bcc-email"
|
||||
value={bccEmail}
|
||||
onChange={(e) => onBccEmailChange(e.target.value)}
|
||||
placeholder="BCC email addresses..."
|
||||
className="bg-background"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subject" className="text-sm font-medium">
|
||||
Subject
|
||||
</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
value={subject}
|
||||
onChange={(e) => onSubjectChange(e.target.value)}
|
||||
placeholder="Enter email subject..."
|
||||
className="bg-background"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
{/* Custom Message Field with Rich Text Editor */}
|
||||
{onCustomMessageChange && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Email Note (Optional)</Label>
|
||||
<p className="text-muted-foreground mb-2 text-xs">
|
||||
This appears only in the email body and is not added to the
|
||||
invoice PDF.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Editor Toolbar */}
|
||||
<div className="bg-muted/20 flex flex-wrap items-center gap-1 border p-2">
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
isActive={editor.isActive("bold")}
|
||||
title="Bold"
|
||||
>
|
||||
<Bold className="h-4 w-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
isActive={editor.isActive("italic")}
|
||||
title="Italic"
|
||||
>
|
||||
<Italic className="h-4 w-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
isActive={editor.isActive("strike")}
|
||||
title="Strikethrough"
|
||||
>
|
||||
<Underline className="h-4 w-4" />
|
||||
</MenuButton>
|
||||
|
||||
<Separator orientation="vertical" className="mx-1 h-6" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().setTextAlign("left").run()}
|
||||
isActive={editor.isActive({ textAlign: "left" })}
|
||||
title="Align Left"
|
||||
>
|
||||
<AlignLeft className="h-4 w-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() =>
|
||||
editor.chain().focus().setTextAlign("center").run()
|
||||
}
|
||||
isActive={editor.isActive({ textAlign: "center" })}
|
||||
title="Align Center"
|
||||
>
|
||||
<AlignCenter className="h-4 w-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().setTextAlign("right").run()}
|
||||
isActive={editor.isActive({ textAlign: "right" })}
|
||||
title="Align Right"
|
||||
>
|
||||
<AlignRight className="h-4 w-4" />
|
||||
</MenuButton>
|
||||
|
||||
<Separator orientation="vertical" className="mx-1 h-6" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
isActive={editor.isActive("bulletList")}
|
||||
title="Bullet List"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
isActive={editor.isActive("orderedList")}
|
||||
title="Ordered List"
|
||||
>
|
||||
<ListOrdered className="h-4 w-4" />
|
||||
</MenuButton>
|
||||
|
||||
<Separator orientation="vertical" className="mx-1 h-6" />
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
title="Text Color"
|
||||
>
|
||||
<Palette className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-48 p-2">
|
||||
<div className="grid grid-cols-6 gap-1">
|
||||
{colors.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
className="h-6 w-6 rounded border border-gray-300 hover:scale-110"
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={() => {
|
||||
editor.chain().focus().setColor(color).run();
|
||||
}}
|
||||
title={color}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Rich Text Editor */}
|
||||
<div>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
|
||||
import { getAppUrl } from "~/lib/app-url";
|
||||
import { calculateLineItemAmount } from "~/lib/invoice-line-item";
|
||||
|
||||
interface EmailPreviewProps {
|
||||
subject: string;
|
||||
fromEmail: string;
|
||||
toEmail: string;
|
||||
ccEmail?: string;
|
||||
bccEmail?: string;
|
||||
content: string;
|
||||
customMessage?: string;
|
||||
invoice?: {
|
||||
invoiceNumber: string;
|
||||
issueDate: Date;
|
||||
dueDate: Date;
|
||||
taxRate: number;
|
||||
status?: string;
|
||||
totalAmount?: number;
|
||||
currency?: string | null;
|
||||
client?: {
|
||||
name: string;
|
||||
email: string | null;
|
||||
};
|
||||
business?: {
|
||||
id?: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
logoStorageKey?: string | null;
|
||||
logoMimeType?: string | null;
|
||||
};
|
||||
items?: Array<{
|
||||
id: string;
|
||||
date?: Date;
|
||||
description?: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
amount?: number;
|
||||
}>;
|
||||
};
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EmailPreview({
|
||||
subject,
|
||||
fromEmail,
|
||||
toEmail,
|
||||
ccEmail,
|
||||
bccEmail,
|
||||
content,
|
||||
customMessage,
|
||||
invoice,
|
||||
className,
|
||||
}: EmailPreviewProps) {
|
||||
// Calculate total from invoice items if available
|
||||
const calculateTotal = () => {
|
||||
if (!invoice?.items) return 0;
|
||||
const subtotal = invoice.items.reduce(
|
||||
(sum, item) => sum + calculateLineItemAmount(item.hours, item.rate),
|
||||
0,
|
||||
);
|
||||
const taxAmount = subtotal * (invoice.taxRate / 100);
|
||||
return subtotal + taxAmount;
|
||||
};
|
||||
|
||||
// Generate the branded email template if invoice is provided
|
||||
const emailTemplate = invoice
|
||||
? generateInvoiceEmailTemplate({
|
||||
invoice: {
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
issueDate: invoice.issueDate,
|
||||
dueDate: invoice.dueDate,
|
||||
status: invoice.status ?? "draft",
|
||||
totalAmount: invoice.totalAmount ?? calculateTotal(),
|
||||
taxRate: invoice.taxRate,
|
||||
currency: invoice.currency,
|
||||
client: {
|
||||
name: invoice.client?.name ?? "Client",
|
||||
email: invoice.client?.email ?? null,
|
||||
},
|
||||
business: invoice.business ?? null,
|
||||
items:
|
||||
invoice.items?.map((item) => ({
|
||||
date: item.date ?? new Date(),
|
||||
description: item.description ?? "Service",
|
||||
hours: item.hours,
|
||||
rate: item.rate,
|
||||
amount: item.amount ?? calculateLineItemAmount(item.hours, item.rate),
|
||||
})) ?? [],
|
||||
},
|
||||
customContent: content,
|
||||
customMessage: customMessage,
|
||||
userName: invoice.business?.name ?? "Your Business",
|
||||
userEmail: fromEmail,
|
||||
baseUrl: getAppUrl(),
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Email Headers */}
|
||||
<div className="bg-muted/20 mb-4 space-y-3 p-4">
|
||||
<div className="grid grid-cols-1 gap-3 text-sm md:grid-cols-3">
|
||||
<div>
|
||||
<span className="text-muted-foreground block text-xs font-medium">
|
||||
From:
|
||||
</span>
|
||||
<span className="font-mono text-sm break-all">{fromEmail}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground block text-xs font-medium">
|
||||
To:
|
||||
</span>
|
||||
<span className="font-mono text-sm break-all">{toEmail}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground block text-xs font-medium">
|
||||
Subject:
|
||||
</span>
|
||||
<span className="text-sm font-semibold break-words">
|
||||
{subject || "No subject"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{(ccEmail ?? bccEmail) && (
|
||||
<div className="grid grid-cols-1 gap-3 text-sm md:grid-cols-2">
|
||||
{ccEmail && (
|
||||
<div>
|
||||
<span className="text-muted-foreground block text-xs font-medium">
|
||||
CC:
|
||||
</span>
|
||||
<span className="font-mono text-sm break-all">{ccEmail}</span>
|
||||
</div>
|
||||
)}
|
||||
{bccEmail && (
|
||||
<div>
|
||||
<span className="text-muted-foreground block text-xs font-medium">
|
||||
BCC:
|
||||
</span>
|
||||
<span className="font-mono text-sm break-all">{bccEmail}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email Content */}
|
||||
{emailTemplate ? (
|
||||
<div className="border bg-gray-50 p-1 shadow-sm">
|
||||
<iframe
|
||||
srcDoc={emailTemplate.html}
|
||||
className="h-[700px] w-full rounded border-0"
|
||||
title="Email Preview"
|
||||
sandbox="allow-same-origin"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex min-h-[400px] items-center justify-center">
|
||||
<p className="text-center text-sm">
|
||||
Email preview will appear here...
|
||||
<br />
|
||||
<span className="text-xs">
|
||||
Professional beenvoice-branded template will be generated
|
||||
automatically
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Send, Loader2, Mail, MailCheck } from "lucide-react";
|
||||
|
||||
interface EnhancedSendInvoiceButtonProps {
|
||||
invoiceId: string;
|
||||
variant?: "default" | "outline" | "ghost" | "icon" | "secondary";
|
||||
className?: string;
|
||||
showResend?: boolean;
|
||||
size?: "default" | "sm" | "lg" | "icon";
|
||||
}
|
||||
|
||||
export function EnhancedSendInvoiceButton({
|
||||
invoiceId,
|
||||
variant = "outline",
|
||||
className,
|
||||
showResend = false,
|
||||
size = "default",
|
||||
}: EnhancedSendInvoiceButtonProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// Fetch invoice data
|
||||
const { data: invoiceData, isLoading: invoiceLoading } =
|
||||
api.invoices.getById.useQuery({
|
||||
id: invoiceId,
|
||||
});
|
||||
|
||||
// Check if client has email
|
||||
const hasClientEmail =
|
||||
invoiceData?.client?.email && invoiceData.client.email.trim() !== "";
|
||||
|
||||
const handleSendClick = () => {
|
||||
router.push(`/dashboard/invoices/${invoiceId}/send`);
|
||||
};
|
||||
|
||||
// Icon variant for compact display
|
||||
if (variant === "icon") {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={className}
|
||||
disabled={invoiceLoading || !hasClientEmail}
|
||||
onClick={handleSendClick}
|
||||
title={
|
||||
!hasClientEmail
|
||||
? "Client has no email address"
|
||||
: showResend
|
||||
? "Resend Email"
|
||||
: "Compose Email"
|
||||
}
|
||||
>
|
||||
{invoiceLoading ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin sm:h-4 sm:w-4" />
|
||||
) : hasClientEmail ? (
|
||||
<Send className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
) : (
|
||||
<Mail className="h-3 w-3 opacity-50 sm:h-4 sm:w-4" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={`shadow-sm ${className}`}
|
||||
disabled={invoiceLoading || !hasClientEmail}
|
||||
onClick={handleSendClick}
|
||||
data-testid="enhanced-send-invoice-button"
|
||||
>
|
||||
{invoiceLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<span>Loading...</span>
|
||||
</>
|
||||
) : !hasClientEmail ? (
|
||||
<>
|
||||
<Mail className="mr-2 h-4 w-4 opacity-50" />
|
||||
<span>No Email Address</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{invoiceData?.status === "sent" ? (
|
||||
<MailCheck className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
<span>{showResend ? "Resend Email" : "Compose Email"}</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useDropzone, type FileRejection } from "react-dropzone";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Upload, FileText, X, CheckCircle, AlertCircle } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
interface FileUploadProps {
|
||||
onFilesSelected: (files: File[]) => void;
|
||||
accept?: Record<string, string[]>;
|
||||
maxFiles?: number;
|
||||
maxSize?: number;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface FilePreviewProps {
|
||||
file: File;
|
||||
onRemove: () => void;
|
||||
status?: "success" | "error" | "pending";
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function FilePreview({
|
||||
file,
|
||||
onRemove,
|
||||
status = "pending",
|
||||
error,
|
||||
}: FilePreviewProps) {
|
||||
const getStatusIcon = () => {
|
||||
switch (status) {
|
||||
case "success":
|
||||
return <CheckCircle className="text-primary h-4 w-4" />;
|
||||
case "error":
|
||||
return <AlertCircle className="text-destructive h-4 w-4" />;
|
||||
default:
|
||||
return <FileText className="h-4 w-4 text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = () => {
|
||||
switch (status) {
|
||||
case "success":
|
||||
return "border-primary/20 bg-primary/10";
|
||||
case "error":
|
||||
return "border-destructive/20 bg-destructive/10";
|
||||
default:
|
||||
return "border-gray-200 bg-gray-50";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between border p-3",
|
||||
getStatusColor(),
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{getStatusIcon()}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground truncate text-sm font-medium">
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{(file.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
{error && <p className="text-destructive mt-1 text-xs">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRemove}
|
||||
className="h-6 w-6 p-0 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileUpload({
|
||||
onFilesSelected,
|
||||
accept,
|
||||
maxFiles = 10,
|
||||
maxSize = 10 * 1024 * 1024, // 10MB default
|
||||
className,
|
||||
disabled = false,
|
||||
placeholder = "Drag & drop files here, or click to select",
|
||||
description,
|
||||
}: FileUploadProps) {
|
||||
const [files, setFiles] = React.useState<File[]>([]);
|
||||
const [errors, setErrors] = React.useState<Record<string, string>>({});
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
|
||||
// Handle accepted files
|
||||
const newFiles = [...files, ...acceptedFiles];
|
||||
setFiles(newFiles);
|
||||
onFilesSelected(newFiles);
|
||||
|
||||
// Handle rejected files
|
||||
const newErrors: Record<string, string> = { ...errors };
|
||||
rejectedFiles.forEach(({ file, errors: fileErrors }) => {
|
||||
const errorMessage = fileErrors
|
||||
.map((error) => {
|
||||
if (error.code === "file-too-large") {
|
||||
return `File is too large. Max size is ${(maxSize / 1024 / 1024).toFixed(1)}MB`;
|
||||
}
|
||||
if (error.code === "file-invalid-type") {
|
||||
return "File type not supported";
|
||||
}
|
||||
if (error.code === "too-many-files") {
|
||||
return `Too many files. Max is ${maxFiles}`;
|
||||
}
|
||||
return error.message;
|
||||
})
|
||||
.join(", ");
|
||||
newErrors[file.name] = errorMessage;
|
||||
});
|
||||
setErrors(newErrors);
|
||||
},
|
||||
[files, onFilesSelected, errors, maxFiles, maxSize],
|
||||
);
|
||||
|
||||
const removeFile = (fileToRemove: File) => {
|
||||
const newFiles = files.filter((file) => file !== fileToRemove);
|
||||
setFiles(newFiles);
|
||||
onFilesSelected(newFiles);
|
||||
|
||||
const newErrors = { ...errors };
|
||||
delete newErrors[fileToRemove.name];
|
||||
setErrors(newErrors);
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive, isDragReject } =
|
||||
useDropzone({
|
||||
onDrop,
|
||||
accept,
|
||||
maxFiles,
|
||||
maxSize,
|
||||
disabled,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={cn(
|
||||
"cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors",
|
||||
"hover:border-primary/40 hover:bg-primary/10",
|
||||
isDragActive && "border-primary/40 bg-primary/10",
|
||||
isDragReject && "border-destructive/40 bg-destructive/10",
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
"bg-background/80 backdrop-blur-sm",
|
||||
)}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
"p-3 transition-colors",
|
||||
isDragActive ? "bg-primary/10" : "bg-muted",
|
||||
isDragReject && "bg-destructive/10",
|
||||
)}
|
||||
>
|
||||
<Upload
|
||||
className={cn(
|
||||
"h-6 w-6 transition-colors",
|
||||
isDragActive ? "text-primary" : "text-muted-foreground",
|
||||
isDragReject && "text-destructive",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p
|
||||
className={cn(
|
||||
"text-lg font-medium transition-colors",
|
||||
isDragActive ? "text-primary" : "text-foreground",
|
||||
isDragReject && "text-destructive",
|
||||
)}
|
||||
>
|
||||
{isDragActive
|
||||
? isDragReject
|
||||
? "File type not supported"
|
||||
: "Drop files here"
|
||||
: placeholder}
|
||||
</p>
|
||||
{description && (
|
||||
<p className="text-sm text-gray-500">{description}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-400">
|
||||
Max {maxFiles} file{maxFiles !== 1 ? "s" : ""} •{" "}
|
||||
{(maxSize / 1024 / 1024).toFixed(1)}MB each
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File List */}
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-gray-700">Selected Files</h4>
|
||||
<div className="max-h-60 space-y-2 overflow-y-auto">
|
||||
{files.map((file, index) => (
|
||||
<FilePreview
|
||||
key={`${file.name}-${index}`}
|
||||
file={file}
|
||||
onRemove={() => removeFile(file)}
|
||||
status={errors[file.name] ? "error" : "success"}
|
||||
error={errors[file.name]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Summary */}
|
||||
{Object.keys(errors).length > 0 && (
|
||||
<div className="border-destructive/20 bg-destructive/10 border p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<AlertCircle className="text-destructive h-4 w-4" />
|
||||
<span className="text-destructive text-sm font-medium">
|
||||
Upload Errors
|
||||
</span>
|
||||
</div>
|
||||
<ul className="text-destructive space-y-1 text-sm">
|
||||
{Object.entries(errors).map(([fileName, error]) => (
|
||||
<li key={fileName} className="flex items-start gap-2">
|
||||
<span className="text-destructive">•</span>
|
||||
<span>
|
||||
<strong>{fileName}:</strong> {error}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
format,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
eachDayOfInterval,
|
||||
isSameDay,
|
||||
subWeeks,
|
||||
addWeeks,
|
||||
subMonths,
|
||||
addMonths,
|
||||
} from "date-fns";
|
||||
import { Calendar } from "~/components/ui/calendar";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetFooter,
|
||||
} from "~/components/ui/sheet";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
Clock,
|
||||
Calendar as CalendarIcon,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { calculateLineItemAmount } from "~/lib/invoice-line-item";
|
||||
|
||||
interface InvoiceItem {
|
||||
id: string;
|
||||
date: Date;
|
||||
description: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
interface InvoiceCalendarViewProps {
|
||||
items: InvoiceItem[];
|
||||
onUpdateItem: (
|
||||
index: number,
|
||||
field: string,
|
||||
value: string | number | Date,
|
||||
) => void;
|
||||
onAddItem: (date?: Date) => void;
|
||||
onRemoveItem: (index: number) => void;
|
||||
className?: string;
|
||||
defaultHourlyRate: number | null;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function InvoiceCalendarView({
|
||||
items,
|
||||
onUpdateItem,
|
||||
onAddItem,
|
||||
onRemoveItem,
|
||||
className,
|
||||
defaultHourlyRate: _defaultHourlyRate,
|
||||
readOnly = false,
|
||||
}: InvoiceCalendarViewProps) {
|
||||
const [date, setDate] = React.useState<Date | undefined>(undefined); // Start unselected
|
||||
const [viewDate, setViewDate] = React.useState<Date>(new Date()); // Controls the view (month/week)
|
||||
const [view, setView] = React.useState<"month" | "week">("month");
|
||||
const [sheetOpen, setSheetOpen] = React.useState(false);
|
||||
// Derived state for selected date items - solves cursor jumping
|
||||
const selectedDateItems = React.useMemo(() => {
|
||||
if (!date) return [];
|
||||
return items
|
||||
.map((item, index) => ({ item, index }))
|
||||
.filter((wrapper) => {
|
||||
const itemDate = new Date(wrapper.item.date);
|
||||
return isSameDay(itemDate, date);
|
||||
});
|
||||
}, [items, date]);
|
||||
|
||||
// Helper to get items for any date (for calendar view)
|
||||
const getItemsForDate = React.useCallback(
|
||||
(targetDate: Date) => {
|
||||
return items
|
||||
.map((item, index) => ({ item, index }))
|
||||
.filter((wrapper) => {
|
||||
const itemDate = new Date(wrapper.item.date);
|
||||
return isSameDay(itemDate, targetDate);
|
||||
});
|
||||
},
|
||||
[items],
|
||||
);
|
||||
|
||||
const handleSelectDate = (newDate: Date | undefined) => {
|
||||
if (!newDate) return;
|
||||
setDate(newDate);
|
||||
setSheetOpen(true);
|
||||
};
|
||||
|
||||
const handleAddNewItem = () => {
|
||||
if (date) {
|
||||
onAddItem(date);
|
||||
}
|
||||
};
|
||||
|
||||
// Week View Logic - Uses viewDate
|
||||
const currentWeekStart = startOfWeek(viewDate);
|
||||
const currentWeekEnd = endOfWeek(viewDate);
|
||||
const weekDays = eachDayOfInterval({
|
||||
start: currentWeekStart,
|
||||
end: currentWeekEnd,
|
||||
});
|
||||
|
||||
const handleCloseSheet = (isOpen: boolean) => {
|
||||
setSheetOpen(isOpen);
|
||||
if (!isOpen) {
|
||||
setDate(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-full w-full flex-col gap-4", className)}>
|
||||
<div className="flex w-full items-center justify-between gap-4 px-4 pt-4">
|
||||
{/* Navigation Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
{view === "week" ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setViewDate((d) => subWeeks(d, 1))}
|
||||
className="h-8 w-8 rounded-lg"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="w-36 text-center text-sm font-medium">
|
||||
{`${format(currentWeekStart, "MMM d")} - ${format(currentWeekEnd, "MMM d")}`}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setViewDate((d) => addWeeks(d, 1))}
|
||||
className="h-8 w-8 rounded-lg"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setViewDate((d) => subMonths(d, 1))}
|
||||
className="h-8 w-8 rounded-lg"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="w-36 text-center text-sm font-medium">
|
||||
{format(viewDate, "MMMM yyyy")}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setViewDate((d) => addMonths(d, 1))}
|
||||
className="h-8 w-8 rounded-lg"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center space-x-2">
|
||||
{/* View Switcher */}
|
||||
<div className="bg-muted flex rounded-lg p-1 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("month")}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1.5 text-center font-medium transition-all",
|
||||
view === "month"
|
||||
? "bg-background text-foreground shadow"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
Month
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("week")}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1.5 text-center font-medium transition-all",
|
||||
view === "week"
|
||||
? "bg-background text-foreground shadow"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
Week
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex-1 overflow-hidden">
|
||||
{view === "month" ? (
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={handleSelectDate}
|
||||
month={viewDate}
|
||||
onMonthChange={setViewDate}
|
||||
className="w-full rounded-md border-0 p-0"
|
||||
classNames={{
|
||||
root: "w-full p-0",
|
||||
months: "flex flex-col w-full",
|
||||
month: "flex flex-col w-full space-y-4",
|
||||
|
||||
// Grid - Revert to Flex but Enforce 1/7th Width
|
||||
// table: "w-full border-collapse", // No table-fixed
|
||||
head_row: "flex w-full",
|
||||
row: "flex w-full mt-2",
|
||||
|
||||
// Cells & Headers: Explicit width 14.28%
|
||||
// Use calc(100%/7) via tailwind arbitrary or just flex bases.
|
||||
// Better: w-[14.28%] flex-none (approx 1/7)
|
||||
weekdays: "flex w-full border-b",
|
||||
weekday:
|
||||
"w-[14.285%] flex-none text-muted-foreground font-normal text-[0.8rem] text-center pb-4",
|
||||
|
||||
week: "flex w-full mt-2",
|
||||
cell: "w-[14.285%] flex-none h-20 sm:h-28 md:h-32 border-b p-0 relative focus-within:relative focus-within:z-20 text-center text-sm",
|
||||
|
||||
// Hide internal navigation & caption entirely
|
||||
nav: "hidden",
|
||||
caption: "hidden",
|
||||
|
||||
day: cn(
|
||||
"w-full h-full p-2 font-normal aria-selected:opacity-100 flex flex-col items-start justify-start gap-1 hover:bg-accent/50 hover:text-accent-foreground align-top transition-colors rounded-xl",
|
||||
),
|
||||
day_selected: "bg-primary/5 text-primary",
|
||||
day_today: "bg-accent/20",
|
||||
day_outside: "text-muted-foreground opacity-30",
|
||||
}}
|
||||
formatters={{
|
||||
formatMonthCaption: () => "", // Clear default caption text to prevent duplication
|
||||
}}
|
||||
components={{
|
||||
DayButton: (props) => {
|
||||
const { day, modifiers, className, ...buttonProps } = props;
|
||||
const DayDate = day.date;
|
||||
const dayItems = getItemsForDate(DayDate);
|
||||
// const totalHours = dayItems.reduce((acc, curr) => acc + curr.item.hours, 0); // Unused now
|
||||
|
||||
return (
|
||||
<button
|
||||
{...buttonProps}
|
||||
type="button"
|
||||
className={cn(
|
||||
"hover:border-border/50 hover:bg-secondary/30 relative flex h-full w-full flex-col items-start justify-between overflow-hidden rounded-xl border border-transparent p-2 text-left transition-all",
|
||||
// Selected State: Filled Box, No Outline
|
||||
modifiers.selected &&
|
||||
"bg-primary text-primary-foreground hover:bg-primary/90 scale-[0.98] transform shadow-md",
|
||||
modifiers.today &&
|
||||
!modifiers.selected &&
|
||||
"bg-accent/40 rounded-xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="z-10 text-sm font-medium">
|
||||
{DayDate.getDate()}
|
||||
</span>
|
||||
{dayItems.length > 0 && (
|
||||
<div className="mt-1 flex h-full w-full flex-col justify-end gap-1 overflow-hidden pb-1">
|
||||
<div className="mt-1 flex w-full flex-col gap-1">
|
||||
{dayItems.slice(0, 4).map((item, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={cn(
|
||||
"h-1 w-full rounded-full",
|
||||
modifiers.selected
|
||||
? "bg-primary-foreground/50"
|
||||
: "bg-primary/50",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
{dayItems.length > 4 && (
|
||||
<div
|
||||
className={cn(
|
||||
"h-1 w-1/3 rounded-full",
|
||||
modifiers.selected
|
||||
? "bg-primary-foreground/30"
|
||||
: "bg-muted-foreground/30",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex w-full gap-3 overflow-x-auto p-4 pb-6">
|
||||
{weekDays.map((day) => {
|
||||
const isSelected = date && isSameDay(day, date);
|
||||
const isToday = isSameDay(day, new Date());
|
||||
const dayItems = getItemsForDate(day);
|
||||
const totalHours = dayItems.reduce(
|
||||
(acc, curr) => acc + curr.item.hours,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={day.toString()}
|
||||
type="button"
|
||||
onClick={() => handleSelectDate(day)}
|
||||
className={cn(
|
||||
"hover:bg-accent/30 flex min-h-[260px] w-[120px] flex-shrink-0 flex-col rounded-3xl border p-3 text-left transition-all sm:w-auto sm:flex-1",
|
||||
isSelected
|
||||
? "ring-primary bg-primary/5 ring-2 ring-offset-2"
|
||||
: "bg-background/40",
|
||||
isToday && !isSelected ? "bg-accent/40" : "",
|
||||
)}
|
||||
>
|
||||
<div className="mb-4 flex w-full flex-col items-center border-b pb-4">
|
||||
<span className="text-muted-foreground text-xs font-bold uppercase">
|
||||
{format(day, "EEE")}
|
||||
</span>
|
||||
<span className="text-2xl font-light">
|
||||
{format(day, "d")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex-1 space-y-2 overflow-hidden">
|
||||
{dayItems.length > 0 ? (
|
||||
dayItems.map(({ item }, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-background rounded-xl border p-2 text-xs shadow-sm"
|
||||
>
|
||||
<div className="line-clamp-2 font-medium text-wrap break-words">
|
||||
{item.description || "No description"}
|
||||
</div>
|
||||
<div className="text-muted-foreground whitespace-nowrap">
|
||||
{item.hours}h
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-muted-foreground/20 flex h-full items-center justify-center">
|
||||
<Plus className="h-8 w-8" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{dayItems.length > 0 && (
|
||||
<div className="mt-auto w-full pt-2 text-center">
|
||||
<span className="text-sm font-semibold">
|
||||
{totalHours}h Total
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sheet for Day Details */}
|
||||
<Sheet open={sheetOpen} onOpenChange={handleCloseSheet}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex w-full max-w-full flex-col gap-0 p-0 sm:w-[400px] sm:max-w-[540px]"
|
||||
>
|
||||
<SheetHeader className="border-b p-6">
|
||||
<SheetTitle className="flex flex-wrap items-center gap-3 text-2xl">
|
||||
<div className="bg-primary/10 flex-shrink-0 rounded-full p-2.5">
|
||||
<CalendarIcon className="text-primary h-6 w-6" />
|
||||
</div>
|
||||
<span className="text-left break-words">
|
||||
{date ? format(date, "EEEE, MMMM do") : "Details"}
|
||||
</span>
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="space-y-6">
|
||||
{date && selectedDateItems.length === 0 ? (
|
||||
<div className="bg-secondary/20 border-border/60 flex flex-col items-center justify-center space-y-4 rounded-3xl border border-dashed py-16 text-center">
|
||||
<div className="bg-background rounded-full p-4 shadow-sm">
|
||||
<Clock className="text-muted-foreground/50 h-8 w-8" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-foreground text-lg font-semibold">
|
||||
No hours logged
|
||||
</p>
|
||||
<p className="text-muted-foreground/80 max-w-[200px] text-sm">
|
||||
There are no time entries recorded for this day yet.
|
||||
</p>
|
||||
</div>
|
||||
{!readOnly ? (
|
||||
<Button onClick={handleAddNewItem} className="mt-2" size="lg">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Log Time
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{selectedDateItems.map(({ item, index }) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="border-border bg-card group hover:border-primary/50 overflow-hidden rounded-lg border transition-colors"
|
||||
>
|
||||
<div className="space-y-3 p-4">
|
||||
{/* Description */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
Description
|
||||
</Label>
|
||||
<Input
|
||||
value={item.description}
|
||||
onChange={(e) =>
|
||||
onUpdateItem(index, "description", e.target.value)
|
||||
}
|
||||
placeholder="Describe the work performed..."
|
||||
className="pl-3 text-sm"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hours and Rate in a row */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
Hours
|
||||
</Label>
|
||||
<NumberInput
|
||||
value={item.hours}
|
||||
onChange={(v) => onUpdateItem(index, "hours", v)}
|
||||
step={0.25}
|
||||
min={0}
|
||||
width="full"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
Rate
|
||||
</Label>
|
||||
<NumberInput
|
||||
value={item.rate}
|
||||
onChange={(v) => onUpdateItem(index, "rate", v)}
|
||||
prefix="$"
|
||||
min={0}
|
||||
step={1}
|
||||
width="full"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom section with controls, item name, and total */}
|
||||
<div className="border-border bg-muted/50 flex items-center justify-between border-t px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{!readOnly ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRemoveItem(index)}
|
||||
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex-1 px-3 text-center">
|
||||
<span className="text-muted-foreground block text-sm font-medium">
|
||||
Item #{index + 1}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Total
|
||||
</span>
|
||||
<span className="text-primary text-lg font-bold">
|
||||
${calculateLineItemAmount(item.hours, item.rate).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!readOnly ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleAddNewItem}
|
||||
className="hover:bg-accent/50 hover:border-primary/50 text-muted-foreground hover:text-primary group w-full gap-2 rounded-xl border-dashed py-8 transition-all"
|
||||
>
|
||||
<div className="bg-muted group-hover:bg-primary/10 rounded-md p-1 transition-colors">
|
||||
<Plus className="h-4 w-4" />
|
||||
</div>
|
||||
<span>Add Another Entry</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SheetFooter className="bg-muted/10 mt-auto border-t p-6">
|
||||
<Button
|
||||
className="h-12 w-full rounded-xl text-base shadow-md sm:w-full"
|
||||
size="lg"
|
||||
onClick={() => handleCloseSheet(false)}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,960 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import {
|
||||
PageTabs,
|
||||
PageTabsContent,
|
||||
PageTabsList,
|
||||
PageTabsTrigger,
|
||||
pageTabsGridClass,
|
||||
} from "~/components/layout/page-tabs";
|
||||
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { DatePicker } from "~/components/ui/date-picker";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
import { InvoiceLineItems } from "./invoice-line-items";
|
||||
import { InvoiceCalendarView } from "./invoice-calendar-view";
|
||||
import { EmailPreview } from "./email-preview";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Save,
|
||||
Calendar as CalendarIcon,
|
||||
Tag,
|
||||
User,
|
||||
List,
|
||||
FileText,
|
||||
ChevronDown,
|
||||
Mail,
|
||||
} from "lucide-react";
|
||||
import { SUPPORTED_CURRENCIES } from "~/lib/currency";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import { STATUS_OPTIONS } from "./invoice/types";
|
||||
import type { InvoiceFormData, InvoiceItem } from "./invoice/types";
|
||||
import type { ParsedLineItem } from "~/lib/parse-line-item";
|
||||
import {
|
||||
applyBillingTypeChange,
|
||||
calculateLineItemAmount,
|
||||
getLineItemBillingType,
|
||||
} from "~/lib/invoice-line-item";
|
||||
import { InvoicePdfPreviewPanel } from "./invoice/invoice-pdf-preview-panel";
|
||||
|
||||
import { CountUp } from "~/components/ui/count-up";
|
||||
|
||||
interface InvoiceFormProps {
|
||||
invoiceId?: string;
|
||||
}
|
||||
|
||||
function InvoiceFormSkeleton() {
|
||||
return (
|
||||
<DashboardPage>
|
||||
<DashboardPageHeader
|
||||
title="Loading..."
|
||||
description="Loading invoice form"
|
||||
/>
|
||||
<div className="bg-muted h-10 w-full animate-pulse rounded-xl p-1" />
|
||||
<div className={cn(pageTabsGridClass, "lg:grid-cols-2")}>
|
||||
<div className="bg-muted h-[200px] animate-pulse rounded-xl" />
|
||||
<div className="bg-muted h-[200px] animate-pulse rounded-xl" />
|
||||
</div>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
|
||||
function getDefaultHourlyRate(value: unknown) {
|
||||
if (typeof value !== "object" || value === null) return null;
|
||||
|
||||
const rate = (value as { defaultHourlyRate?: unknown }).defaultHourlyRate;
|
||||
return typeof rate === "number" ? rate : null;
|
||||
}
|
||||
|
||||
function plainTextToHtml(value: string) {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\n/g, "<br>");
|
||||
}
|
||||
|
||||
function createDefaultInvoiceFormData(): InvoiceFormData {
|
||||
return {
|
||||
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
|
||||
invoicePrefix: "#",
|
||||
businessId: "",
|
||||
clientId: "",
|
||||
issueDate: new Date(),
|
||||
dueDate: new Date(),
|
||||
status: "draft",
|
||||
notes: "",
|
||||
emailMessage: "",
|
||||
taxRate: 0,
|
||||
currency: "USD",
|
||||
defaultHourlyRate: null,
|
||||
items: [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
date: new Date(),
|
||||
description: "",
|
||||
hours: 1,
|
||||
rate: 0,
|
||||
amount: 0,
|
||||
billingType: "hourly",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
const router = useRouter();
|
||||
const utils = api.useUtils();
|
||||
|
||||
// State
|
||||
const [formData, setFormData] = useState<InvoiceFormData>(() =>
|
||||
createDefaultInvoiceFormData(),
|
||||
);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState("details");
|
||||
const [previewTab, setPreviewTab] = useState("pdf");
|
||||
|
||||
// Queries (Same as before)
|
||||
const { data: clients, isLoading: loadingClients } =
|
||||
api.clients.getAll.useQuery();
|
||||
const { data: noteTemplates } = api.invoiceTemplates.getByType.useQuery({
|
||||
type: "notes",
|
||||
});
|
||||
const { data: businesses, isLoading: loadingBusinesses } =
|
||||
api.businesses.getAll.useQuery();
|
||||
const { data: existingInvoice, isLoading: loadingInvoice } =
|
||||
api.invoices.getById.useQuery(
|
||||
{ id: invoiceId! },
|
||||
{ enabled: !!invoiceId && invoiceId !== "new" },
|
||||
);
|
||||
|
||||
const deleteInvoice = api.invoices.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Invoice deleted");
|
||||
router.push("/dashboard/invoices");
|
||||
},
|
||||
onError: (e) => toast.error(e.message ?? "Failed to delete"),
|
||||
});
|
||||
|
||||
// Init Effects (Same as before)
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reset initialization state when the routed invoice changes.
|
||||
setInitialized(false);
|
||||
}, [invoiceId]);
|
||||
useEffect(() => {
|
||||
if (invoiceId && invoiceId !== "new" && existingInvoice && !initialized) {
|
||||
const mappedItems: InvoiceItem[] =
|
||||
existingInvoice.items?.map((item) => ({
|
||||
id: crypto.randomUUID(),
|
||||
date: new Date(item.date),
|
||||
description: item.description,
|
||||
hours: item.hours,
|
||||
rate: item.rate,
|
||||
amount: item.amount,
|
||||
billingType: getLineItemBillingType(item.hours),
|
||||
})) || [];
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded invoice data into the edit form.
|
||||
setFormData({
|
||||
invoiceNumber: existingInvoice.invoiceNumber,
|
||||
invoicePrefix: existingInvoice.invoicePrefix ?? "#",
|
||||
businessId: existingInvoice.businessId ?? "",
|
||||
clientId: existingInvoice.clientId,
|
||||
issueDate: new Date(existingInvoice.issueDate),
|
||||
dueDate: new Date(existingInvoice.dueDate),
|
||||
status: existingInvoice.status as "draft" | "sent" | "paid",
|
||||
notes: existingInvoice.notes ?? "",
|
||||
emailMessage: existingInvoice.emailMessage ?? "",
|
||||
taxRate: existingInvoice.taxRate,
|
||||
currency: existingInvoice.currency ?? "USD",
|
||||
defaultHourlyRate: existingInvoice.client?.defaultHourlyRate ?? null,
|
||||
items:
|
||||
mappedItems.length > 0
|
||||
? mappedItems
|
||||
: [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
date: new Date(),
|
||||
description: "",
|
||||
hours: 1,
|
||||
rate: 0,
|
||||
amount: 0,
|
||||
billingType: "hourly",
|
||||
},
|
||||
],
|
||||
});
|
||||
setInitialized(true);
|
||||
} else if (
|
||||
(!invoiceId || invoiceId === "new") &&
|
||||
businesses &&
|
||||
!initialized
|
||||
) {
|
||||
const defaultBusiness =
|
||||
businesses.find((b) => b.isDefault) ?? businesses[0];
|
||||
if (defaultBusiness)
|
||||
setFormData((prev) => ({ ...prev, businessId: defaultBusiness.id }));
|
||||
setInitialized(true);
|
||||
}
|
||||
}, [invoiceId, existingInvoice, businesses, initialized]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
invoiceId &&
|
||||
invoiceId !== "new" &&
|
||||
existingInvoice &&
|
||||
!loadingInvoice &&
|
||||
existingInvoice.status !== "draft"
|
||||
) {
|
||||
toast.error("Only draft invoices can be edited");
|
||||
router.replace(`/dashboard/invoices/${invoiceId}`);
|
||||
}
|
||||
}, [invoiceId, existingInvoice, loadingInvoice, router]);
|
||||
|
||||
const totals = React.useMemo(() => {
|
||||
const subtotal = formData.items.reduce(
|
||||
(sum, item) => sum + calculateLineItemAmount(item.hours, item.rate),
|
||||
0,
|
||||
);
|
||||
const taxAmount = (subtotal * formData.taxRate) / 100;
|
||||
const total = subtotal + taxAmount;
|
||||
return { subtotal, taxAmount, total };
|
||||
}, [formData.items, formData.taxRate]);
|
||||
const emailPreviewMessage = React.useMemo(
|
||||
() => plainTextToHtml(formData.emailMessage.trim()),
|
||||
[formData.emailMessage],
|
||||
);
|
||||
|
||||
const pdfPreviewInput = React.useMemo(
|
||||
() => ({
|
||||
invoiceNumber: formData.invoiceNumber,
|
||||
invoicePrefix: formData.invoicePrefix,
|
||||
businessId: formData.businessId || "",
|
||||
clientId: formData.clientId,
|
||||
issueDate: formData.issueDate,
|
||||
dueDate: formData.dueDate,
|
||||
status: formData.status,
|
||||
notes: formData.notes,
|
||||
emailMessage: formData.emailMessage,
|
||||
taxRate: formData.taxRate,
|
||||
currency: formData.currency,
|
||||
items: formData.items.map((item) => ({
|
||||
date: item.date,
|
||||
description: item.description || "Service",
|
||||
hours: item.hours,
|
||||
rate: item.rate,
|
||||
amount: calculateLineItemAmount(item.hours, item.rate),
|
||||
})),
|
||||
}),
|
||||
[formData],
|
||||
);
|
||||
|
||||
const selectedClient = React.useMemo(
|
||||
() => clients?.find((client) => client.id === formData.clientId),
|
||||
[clients, formData.clientId],
|
||||
);
|
||||
const selectedBusiness = React.useMemo(
|
||||
() =>
|
||||
businesses?.find((business) => business.id === formData.businessId) ??
|
||||
businesses?.find((business) => business.isDefault),
|
||||
[businesses, formData.businessId],
|
||||
);
|
||||
|
||||
// Handlers (addItem, updateItem etc. - same as before)
|
||||
const addItem = (date?: unknown) => {
|
||||
const validDate = date instanceof Date ? date : new Date();
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
items: [
|
||||
...prev.items,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
date: validDate,
|
||||
description: "",
|
||||
hours: 1,
|
||||
rate: prev.defaultHourlyRate ?? 0,
|
||||
amount: prev.defaultHourlyRate ?? 0,
|
||||
billingType: "hourly",
|
||||
},
|
||||
],
|
||||
}));
|
||||
};
|
||||
const addItemWithValues = (parsed: ParsedLineItem) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
items: [
|
||||
...prev.items,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
date: new Date(),
|
||||
description: parsed.description,
|
||||
hours: parsed.hours ?? 1,
|
||||
rate: parsed.rate ?? prev.defaultHourlyRate ?? 0,
|
||||
amount: calculateLineItemAmount(
|
||||
parsed.hours ?? 1,
|
||||
parsed.rate ?? prev.defaultHourlyRate ?? 0,
|
||||
),
|
||||
billingType: "hourly",
|
||||
},
|
||||
],
|
||||
}));
|
||||
};
|
||||
const removeItem = (idx: number) => {
|
||||
if (formData.items.length > 1)
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
items: prev.items.filter((_, i) => i !== idx),
|
||||
}));
|
||||
};
|
||||
const updateItem = (
|
||||
idx: number,
|
||||
field: string,
|
||||
value: string | number | Date,
|
||||
) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
items: prev.items.map((item, i) => {
|
||||
if (i !== idx) return item;
|
||||
|
||||
if (field === "billingType" && (value === "hourly" || value === "fixed")) {
|
||||
const next = applyBillingTypeChange(value, item);
|
||||
return {
|
||||
...item,
|
||||
...next,
|
||||
billingType: value,
|
||||
};
|
||||
}
|
||||
|
||||
const updated = { ...item, [field]: value };
|
||||
if (field === "hours" || field === "rate") {
|
||||
updated.amount = calculateLineItemAmount(updated.hours, updated.rate);
|
||||
updated.billingType = getLineItemBillingType(updated.hours);
|
||||
}
|
||||
return updated;
|
||||
}),
|
||||
}));
|
||||
};
|
||||
|
||||
const createInvoice = api.invoices.create.useMutation({
|
||||
onSuccess: (inv) => {
|
||||
toast.success("Created");
|
||||
void utils.invoices.getAll.invalidate();
|
||||
router.push(`/dashboard/invoices/${inv.id}`);
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
const updateInvoice = api.invoices.update.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Updated");
|
||||
if (invoiceId && invoiceId !== "new") {
|
||||
void utils.invoices.getById.invalidate({ id: invoiceId });
|
||||
}
|
||||
void utils.invoices.getAll.invalidate();
|
||||
router.push(
|
||||
invoiceId === "new"
|
||||
? "/dashboard/invoices"
|
||||
: `/dashboard/invoices/${invoiceId}`,
|
||||
);
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setLoading(true);
|
||||
if (!formData.clientId) {
|
||||
toast.error("Select Client");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const itemsToSave = formData.items.filter((item) => item.description?.trim());
|
||||
|
||||
let invalidItemIndex = -1;
|
||||
for (let i = 0; i < formData.items.length; i++) {
|
||||
const item = formData.items[i];
|
||||
const desc = item?.description?.trim() ?? "";
|
||||
if (!desc && ((item?.hours ?? 0) > 0 || (item?.rate ?? 0) > 0)) {
|
||||
invalidItemIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (invalidItemIndex !== -1) {
|
||||
toast.error(`Item #${invalidItemIndex + 1} is missing a description`);
|
||||
setLoading(false);
|
||||
setActiveTab("items"); // Switch to items tab
|
||||
|
||||
// Timeout to allow tab switch rendering
|
||||
setTimeout(() => {
|
||||
const element = document.getElementById(
|
||||
`invoice-item-${invalidItemIndex}`,
|
||||
);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
// Optional: Highlight effect
|
||||
element.classList.add("ring-2", "ring-destructive", "ring-offset-2");
|
||||
setTimeout(
|
||||
() =>
|
||||
element.classList.remove(
|
||||
"ring-2",
|
||||
"ring-destructive",
|
||||
"ring-offset-2",
|
||||
),
|
||||
2000,
|
||||
);
|
||||
}
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
invoiceNumber: formData.invoiceNumber,
|
||||
invoicePrefix: formData.invoicePrefix,
|
||||
businessId: formData.businessId || "",
|
||||
clientId: formData.clientId,
|
||||
issueDate: formData.issueDate,
|
||||
dueDate: formData.dueDate,
|
||||
status: formData.status,
|
||||
notes: formData.notes,
|
||||
emailMessage: formData.emailMessage,
|
||||
taxRate: formData.taxRate,
|
||||
currency: formData.currency,
|
||||
items: itemsToSave.map((i) => ({
|
||||
date: i.date,
|
||||
description: i.description,
|
||||
hours: i.hours,
|
||||
rate: i.rate,
|
||||
amount: calculateLineItemAmount(i.hours, i.rate),
|
||||
})),
|
||||
};
|
||||
if (invoiceId && invoiceId !== "new" && invoiceId !== undefined)
|
||||
await updateInvoice.mutateAsync({ id: invoiceId, ...payload });
|
||||
else await createInvoice.mutateAsync(payload);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateField = <K extends keyof InvoiceFormData>(
|
||||
field: K,
|
||||
value: InvoiceFormData[K],
|
||||
) => setFormData((p) => ({ ...p, [field]: value }));
|
||||
const handleDelete = () => setDeleteDialogOpen(true);
|
||||
const confirmDelete = () => {
|
||||
if (invoiceId) deleteInvoice.mutate({ id: invoiceId });
|
||||
};
|
||||
|
||||
if (
|
||||
!initialized ||
|
||||
loadingClients ||
|
||||
loadingBusinesses ||
|
||||
(invoiceId && invoiceId !== "new" && loadingInvoice) ||
|
||||
(invoiceId &&
|
||||
invoiceId !== "new" &&
|
||||
existingInvoice &&
|
||||
existingInvoice.status !== "draft")
|
||||
)
|
||||
return <InvoiceFormSkeleton />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardPage>
|
||||
<DashboardPageHeader
|
||||
title={invoiceId !== "new" ? "Edit Invoice" : "Create Invoice"}
|
||||
description="Manage your invoice"
|
||||
>
|
||||
{invoiceId !== "new" && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleDelete}
|
||||
className="text-destructive"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleSubmit} variant="secondary" disabled={loading}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{loading ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</DashboardPageHeader>
|
||||
|
||||
<PageTabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
|
||||
<PageTabsList>
|
||||
<PageTabsTrigger value="details">Details</PageTabsTrigger>
|
||||
<PageTabsTrigger value="items">Items</PageTabsTrigger>
|
||||
<PageTabsTrigger value="timesheet">Timesheet</PageTabsTrigger>
|
||||
<PageTabsTrigger value="preview">Preview</PageTabsTrigger>
|
||||
</PageTabsList>
|
||||
|
||||
{/* DETAILS TAB */}
|
||||
<PageTabsContent value="details">
|
||||
<div className={cn(pageTabsGridClass, "lg:grid-cols-2")}>
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex gap-2 text-base">
|
||||
<User className="h-4 w-4" /> Client Details
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Client</Label>
|
||||
<Select
|
||||
value={formData.clientId}
|
||||
onValueChange={(v) => {
|
||||
updateField("clientId", v);
|
||||
const selectedClient = clients?.find((c) => c.id === v);
|
||||
const currentBusiness = businesses?.find(
|
||||
(b) => b.id === formData.businessId,
|
||||
);
|
||||
const clientRate = getDefaultHourlyRate(selectedClient);
|
||||
const businessRate =
|
||||
getDefaultHourlyRate(currentBusiness);
|
||||
updateField(
|
||||
"defaultHourlyRate",
|
||||
clientRate ?? businessRate ?? 0,
|
||||
);
|
||||
// Auto-fill currency from client
|
||||
if (
|
||||
selectedClient &&
|
||||
"currency" in selectedClient &&
|
||||
selectedClient.currency
|
||||
) {
|
||||
updateField("currency", selectedClient.currency);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select Client" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients?.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Business</Label>
|
||||
<Select
|
||||
value={formData.businessId}
|
||||
onValueChange={(v) => updateField("businessId", v)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select Business" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{businesses?.map((b) => (
|
||||
<SelectItem key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex gap-2 text-base">
|
||||
<Tag className="h-4 w-4" /> Invoice Settings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Issue Date</Label>
|
||||
<DatePicker
|
||||
date={formData.issueDate}
|
||||
onDateChange={(d) =>
|
||||
updateField("issueDate", d ?? new Date())
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Due Date</Label>
|
||||
<DatePicker
|
||||
date={formData.dueDate}
|
||||
onDateChange={(d) =>
|
||||
updateField("dueDate", d ?? new Date())
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[96px_1fr] sm:gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Prefix</Label>
|
||||
<Input
|
||||
value={formData.invoicePrefix}
|
||||
onChange={(e) =>
|
||||
updateField("invoicePrefix", e.target.value)
|
||||
}
|
||||
placeholder="#"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Invoice Number</Label>
|
||||
<Input
|
||||
value={formData.invoiceNumber}
|
||||
onChange={(e) =>
|
||||
updateField("invoiceNumber", e.target.value)
|
||||
}
|
||||
placeholder="INV-20260428-000001"
|
||||
className="w-full font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Tax Rate</Label>
|
||||
<NumberInput
|
||||
value={formData.taxRate}
|
||||
onChange={(v) => updateField("taxRate", v)}
|
||||
suffix="%"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Hourly Rate</Label>
|
||||
<NumberInput
|
||||
value={formData.defaultHourlyRate ?? 0}
|
||||
onChange={(v) => updateField("defaultHourlyRate", v)}
|
||||
prefix="$"
|
||||
disabled={!formData.clientId}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Status</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(v: "draft" | "sent" | "paid") =>
|
||||
updateField("status", v)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Currency</Label>
|
||||
<Select
|
||||
value={formData.currency}
|
||||
onValueChange={(v) => updateField("currency", v)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SUPPORTED_CURRENCIES.map((c) => (
|
||||
<SelectItem key={c.code} value={c.code}>
|
||||
{c.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="h-fit">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between gap-2 text-base">
|
||||
<span className="flex items-center gap-2">
|
||||
<Mail className="h-4 w-4" /> Email Message
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Textarea
|
||||
value={formData.emailMessage}
|
||||
onChange={(e) => updateField("emailMessage", e.target.value)}
|
||||
placeholder="Add a note that appears only in the email body..."
|
||||
className="min-h-[140px]"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="h-fit">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between gap-2 text-base">
|
||||
<span className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" /> Invoice Notes
|
||||
</span>
|
||||
{noteTemplates && noteTemplates.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 text-xs"
|
||||
>
|
||||
Use template <ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
{noteTemplates.map((t) => (
|
||||
<DropdownMenuItem
|
||||
key={t.id}
|
||||
onClick={() => updateField("notes", t.content)}
|
||||
>
|
||||
{t.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Textarea
|
||||
value={formData.notes}
|
||||
onChange={(e) => updateField("notes", e.target.value)}
|
||||
placeholder="Add notes, payment terms, or other information for the invoice/PDF..."
|
||||
className="min-h-[140px]"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageTabsContent>
|
||||
|
||||
{/* ITEMS TAB */}
|
||||
<PageTabsContent value="items">
|
||||
<div className={cn(pageTabsGridClass, "md:grid-cols-3")}>
|
||||
<Card className="bg-primary/5 border-primary/20">
|
||||
<CardContent className="flex items-center justify-between p-4">
|
||||
<span className="text-muted-foreground">Total</span>
|
||||
<span className="font-mono text-2xl font-bold">
|
||||
<CountUp value={totals.total} prefix="$" />
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between p-4">
|
||||
<span className="text-muted-foreground">Subtotal</span>
|
||||
<span className="font-mono text-xl font-semibold">
|
||||
<CountUp value={totals.subtotal} prefix="$" />
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between p-4">
|
||||
<span className="text-muted-foreground">Hours</span>
|
||||
<span className="font-mono text-xl font-semibold">
|
||||
<CountUp
|
||||
value={formData.items.reduce(
|
||||
(s, i) => s + (i.hours > 0 ? i.hours : 0),
|
||||
0,
|
||||
)}
|
||||
suffix="h"
|
||||
/>
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex gap-2">
|
||||
<List className="h-5 w-5" /> Invoice Items
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<InvoiceLineItems
|
||||
items={formData.items}
|
||||
onAddItem={addItem}
|
||||
onRemoveItem={removeItem}
|
||||
onUpdateItem={updateItem}
|
||||
onAddItemWithValues={addItemWithValues}
|
||||
invoiceId={invoiceId && invoiceId !== "new" ? invoiceId : undefined}
|
||||
clientId={formData.clientId || undefined}
|
||||
defaultRate={formData.items[0]?.rate}
|
||||
readOnly={formData.status !== "draft"}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageTabsContent>
|
||||
|
||||
{/* TIMESHEET TAB */}
|
||||
<PageTabsContent value="timesheet">
|
||||
<Card className="min-h-[600px] w-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex gap-2">
|
||||
<CalendarIcon className="h-5 w-5" /> Timesheet
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0 sm:p-0">
|
||||
<InvoiceCalendarView
|
||||
items={formData.items}
|
||||
onAddItem={addItem}
|
||||
onRemoveItem={removeItem}
|
||||
onUpdateItem={updateItem}
|
||||
defaultHourlyRate={formData.defaultHourlyRate}
|
||||
readOnly={formData.status !== "draft"}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageTabsContent>
|
||||
|
||||
<PageTabsContent value="preview">
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center gap-3 space-y-0 pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<FileText className="h-4 w-4" />
|
||||
Preview
|
||||
</CardTitle>
|
||||
<div className="bg-muted flex rounded-lg p-1 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewTab("pdf")}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1.5 text-center font-medium transition-all",
|
||||
previewTab === "pdf"
|
||||
? "bg-background text-foreground shadow"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
PDF
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewTab("email")}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1.5 text-center font-medium transition-all",
|
||||
previewTab === "email"
|
||||
? "bg-background text-foreground shadow"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
Email
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{previewTab === "pdf" ? (
|
||||
<InvoicePdfPreviewPanel
|
||||
embedded
|
||||
input={pdfPreviewInput}
|
||||
enabled={activeTab === "preview" && previewTab === "pdf"}
|
||||
/>
|
||||
) : (
|
||||
<div className="border-t p-6">
|
||||
<EmailPreview
|
||||
subject={`Invoice ${formData.invoiceNumber} from ${
|
||||
selectedBusiness?.name ?? "Your Business"
|
||||
}`}
|
||||
fromEmail={selectedBusiness?.email ?? ""}
|
||||
toEmail={selectedClient?.email ?? ""}
|
||||
content=""
|
||||
customMessage={emailPreviewMessage}
|
||||
invoice={{
|
||||
invoiceNumber: formData.invoiceNumber,
|
||||
issueDate: formData.issueDate,
|
||||
dueDate: formData.dueDate,
|
||||
taxRate: formData.taxRate,
|
||||
status: formData.status,
|
||||
totalAmount: totals.total,
|
||||
currency: formData.currency,
|
||||
client: selectedClient
|
||||
? {
|
||||
name: selectedClient.name,
|
||||
email: selectedClient.email,
|
||||
}
|
||||
: undefined,
|
||||
business: selectedBusiness
|
||||
? {
|
||||
name: selectedBusiness.name,
|
||||
email: selectedBusiness.email,
|
||||
}
|
||||
: undefined,
|
||||
items: formData.items.map((item) => ({
|
||||
id: item.id,
|
||||
date: item.date,
|
||||
description: item.description,
|
||||
hours: item.hours,
|
||||
rate: item.rate,
|
||||
amount: calculateLineItemAmount(item.hours, item.rate),
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageTabsContent>
|
||||
</PageTabs>
|
||||
</DashboardPage>
|
||||
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete?</DialogTitle>
|
||||
<DialogDescription>Cannot be undone.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, Timer, Trash2, Zap } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { DatePicker } from "~/components/ui/date-picker";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
calculateLineItemAmount,
|
||||
getLineItemBillingType,
|
||||
type LineItemBillingType,
|
||||
} from "~/lib/invoice-line-item";
|
||||
import { parseLineItem, type ParsedLineItem } from "~/lib/parse-line-item";
|
||||
import {
|
||||
useLineItemSuggestions,
|
||||
type LineItemSuggestion,
|
||||
} from "~/hooks/use-line-item-suggestions";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
|
||||
interface InvoiceItem {
|
||||
id: string;
|
||||
date: Date;
|
||||
description: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
amount: number;
|
||||
billingType?: LineItemBillingType;
|
||||
}
|
||||
|
||||
interface InvoiceLineItemsProps {
|
||||
items: InvoiceItem[];
|
||||
onAddItem: () => void;
|
||||
onRemoveItem: (index: number) => void;
|
||||
onUpdateItem: (
|
||||
index: number,
|
||||
field: string,
|
||||
value: string | number | Date,
|
||||
) => void;
|
||||
onAddItemWithValues?: (parsed: ParsedLineItem) => void;
|
||||
invoiceId?: string;
|
||||
clientId?: string;
|
||||
defaultRate?: number;
|
||||
className?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
interface LineItemRowProps {
|
||||
item: InvoiceItem;
|
||||
index: number;
|
||||
canRemove: boolean;
|
||||
onRemove: (index: number) => void;
|
||||
onUpdate: (
|
||||
index: number,
|
||||
field: string,
|
||||
value: string | number | Date,
|
||||
) => void;
|
||||
suggestions: LineItemSuggestion[];
|
||||
onSelectSuggestion: (index: number, suggestion: LineItemSuggestion) => void;
|
||||
onDescriptionChange: (index: number, value: string) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
interface DescriptionAutocompleteProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSelect: (suggestion: LineItemSuggestion) => void;
|
||||
suggestions: LineItemSuggestion[];
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function DescriptionAutocomplete({
|
||||
value,
|
||||
onChange,
|
||||
onSelect,
|
||||
suggestions,
|
||||
placeholder,
|
||||
className,
|
||||
disabled,
|
||||
}: DescriptionAutocompleteProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const showDropdown = open && suggestions.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (!showDropdown) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.min(i + 1, suggestions.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.max(i - 1, -1));
|
||||
} else if (e.key === "Enter" && activeIndex >= 0) {
|
||||
e.preventDefault();
|
||||
const s = suggestions[activeIndex];
|
||||
if (s) { onSelect(s); setOpen(false); }
|
||||
} else if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full">
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => { onChange(e.target.value); setOpen(true); setActiveIndex(-1); }}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{showDropdown && (
|
||||
<div className="bg-popover text-popover-foreground border-border absolute top-full left-0 z-50 mt-1 w-full overflow-hidden rounded-md border shadow-md">
|
||||
{suggestions.map((s, i) => (
|
||||
<button
|
||||
key={s.description}
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect(s);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"hover:bg-accent hover:text-accent-foreground flex w-full items-center justify-between px-3 py-2 text-left text-sm",
|
||||
i === activeIndex && "bg-accent text-accent-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="truncate font-medium">{s.description}</span>
|
||||
<span className="text-muted-foreground ml-3 shrink-0 font-mono text-xs">
|
||||
{s.hours}h · ${s.rate}/hr
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const LINE_ITEM_GRID =
|
||||
"grid-cols-[minmax(11.5rem,auto)_minmax(160px,1fr)_76px_96px_108px_88px_28px]";
|
||||
|
||||
const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
||||
({ item, index, canRemove, onRemove, onUpdate, suggestions, onSelectSuggestion, onDescriptionChange, readOnly }, ref) => {
|
||||
const billingType = item.billingType ?? getLineItemBillingType(item.hours);
|
||||
const isFixed = billingType === "fixed";
|
||||
const lineTotal = calculateLineItemAmount(item.hours, item.rate);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group hover:bg-muted/30 hidden min-h-11 items-center gap-1.5 border-b px-2 py-1.5 transition-colors md:grid",
|
||||
LINE_ITEM_GRID,
|
||||
)}
|
||||
>
|
||||
<DatePicker
|
||||
date={item.date}
|
||||
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
|
||||
size="sm"
|
||||
className="w-full"
|
||||
inputClassName="h-8 text-xs"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
|
||||
<DescriptionAutocomplete
|
||||
value={item.description}
|
||||
onChange={(v) => onDescriptionChange(index, v)}
|
||||
onSelect={(s) => onSelectSuggestion(index, s)}
|
||||
suggestions={suggestions}
|
||||
placeholder="Description"
|
||||
className="h-8 w-full text-sm"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
|
||||
<Select
|
||||
value={billingType}
|
||||
onValueChange={(value: LineItemBillingType) =>
|
||||
onUpdate(index, "billingType", value)
|
||||
}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-full px-2 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="hourly">Hourly</SelectItem>
|
||||
<SelectItem value="fixed">Fixed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{isFixed ? (
|
||||
<span className="text-muted-foreground text-center text-xs">—</span>
|
||||
) : (
|
||||
<NumberInput
|
||||
value={item.hours}
|
||||
onChange={(value) => onUpdate(index, "hours", value)}
|
||||
min={0}
|
||||
step={0.25}
|
||||
width="full"
|
||||
className="h-8 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-10 [&_input]:text-xs"
|
||||
suffix="h"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
)}
|
||||
|
||||
<NumberInput
|
||||
value={item.rate}
|
||||
onChange={(value) => onUpdate(index, "rate", value)}
|
||||
min={0}
|
||||
step={1}
|
||||
prefix="$"
|
||||
width="full"
|
||||
className="h-8 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-12 [&_input]:text-xs"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
|
||||
<div className="text-primary text-right font-mono text-sm font-semibold tabular-nums">
|
||||
${lineTotal.toFixed(2)}
|
||||
</div>
|
||||
|
||||
{!readOnly ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRemove(index)}
|
||||
className="text-muted-foreground hover:text-destructive h-7 w-7 p-0"
|
||||
disabled={!canRemove}
|
||||
aria-label="Remove item"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
LineItemCard.displayName = "LineItemCard";
|
||||
|
||||
function MobileLineItem({
|
||||
item,
|
||||
index,
|
||||
canRemove,
|
||||
onRemove,
|
||||
onUpdate,
|
||||
suggestions,
|
||||
onSelectSuggestion,
|
||||
onDescriptionChange,
|
||||
readOnly,
|
||||
}: LineItemRowProps) {
|
||||
const billingType = item.billingType ?? getLineItemBillingType(item.hours);
|
||||
const isFixed = billingType === "fixed";
|
||||
const lineTotal = calculateLineItemAmount(item.hours, item.rate);
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`invoice-item-${index}-mobile`}
|
||||
className="border-border space-y-1.5 border-b px-3 py-2 md:hidden"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground w-5 shrink-0 text-center text-xs font-semibold">
|
||||
{index + 1}
|
||||
</span>
|
||||
<DescriptionAutocomplete
|
||||
value={item.description}
|
||||
onChange={(v) => onDescriptionChange(index, v)}
|
||||
onSelect={(s) => onSelectSuggestion(index, s)}
|
||||
suggestions={suggestions}
|
||||
placeholder="Description"
|
||||
className="h-8 flex-1 text-sm"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 pl-7">
|
||||
<DatePicker
|
||||
date={item.date}
|
||||
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
|
||||
size="sm"
|
||||
className="w-auto shrink-0"
|
||||
inputClassName="h-8 px-2 text-xs"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<Select
|
||||
value={billingType}
|
||||
onValueChange={(value: LineItemBillingType) =>
|
||||
onUpdate(index, "billingType", value)
|
||||
}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[76px] shrink-0 px-2 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="hourly">Hourly</SelectItem>
|
||||
<SelectItem value="fixed">Fixed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!isFixed ? (
|
||||
<NumberInput
|
||||
value={item.hours}
|
||||
onChange={(value) => onUpdate(index, "hours", value)}
|
||||
min={0}
|
||||
step={0.25}
|
||||
width="full"
|
||||
className="h-8 w-[88px] shrink-0 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-8 [&_input]:text-xs"
|
||||
suffix="h"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
) : null}
|
||||
<NumberInput
|
||||
value={item.rate}
|
||||
onChange={(value) => onUpdate(index, "rate", value)}
|
||||
min={0}
|
||||
step={1}
|
||||
prefix="$"
|
||||
width="full"
|
||||
className="h-8 w-[84px] shrink-0 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-10 [&_input]:text-xs"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<span className="text-primary ml-auto font-mono text-sm font-semibold tabular-nums">
|
||||
${lineTotal.toFixed(2)}
|
||||
</span>
|
||||
{!readOnly ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRemove(index)}
|
||||
className="text-muted-foreground hover:text-destructive h-7 w-7 shrink-0 p-0"
|
||||
disabled={!canRemove}
|
||||
aria-label="Remove item"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NLQuickAdd({ onAdd }: { onAdd: (parsed: ParsedLineItem) => void }) {
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === "Enter" && value.trim()) {
|
||||
e.preventDefault();
|
||||
onAdd(parseLineItem(value));
|
||||
setValue("");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<Zap className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder='Quick add: "3hrs web design @120" — press Enter'
|
||||
className="h-8 border-dashed text-sm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InvoiceLineItems({
|
||||
items,
|
||||
onAddItem,
|
||||
onRemoveItem,
|
||||
onUpdateItem,
|
||||
onAddItemWithValues,
|
||||
invoiceId,
|
||||
clientId,
|
||||
defaultRate: _defaultRate,
|
||||
className,
|
||||
readOnly = false,
|
||||
}: InvoiceLineItemsProps) {
|
||||
const canRemoveItems = items.length > 1;
|
||||
const { search } = useLineItemSuggestions();
|
||||
const [queriedIndex, setQueriedIndex] = useState<number | null>(null);
|
||||
const [suggestions, setSuggestions] = useState<LineItemSuggestion[]>([]);
|
||||
|
||||
function handleDescriptionChange(index: number, value: string) {
|
||||
onUpdateItem(index, "description", value);
|
||||
setQueriedIndex(index);
|
||||
setSuggestions(search(value));
|
||||
}
|
||||
|
||||
function handleSelectSuggestion(index: number, s: LineItemSuggestion) {
|
||||
onUpdateItem(index, "description", s.description);
|
||||
onUpdateItem(index, "hours", s.hours);
|
||||
onUpdateItem(index, "rate", s.rate);
|
||||
onUpdateItem(index, "billingType", "hourly");
|
||||
setSuggestions([]);
|
||||
setQueriedIndex(null);
|
||||
}
|
||||
|
||||
function getSuggestionsForIndex(index: number): LineItemSuggestion[] {
|
||||
return queriedIndex === index ? suggestions : [];
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
{readOnly ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Line items are locked after an invoice is sent. Revert to draft to edit entries.
|
||||
</p>
|
||||
) : null}
|
||||
<AnimatePresence>
|
||||
<div className="space-y-0 md:overflow-hidden md:rounded-lg md:border">
|
||||
<div className={cn("bg-muted/60 text-muted-foreground hidden gap-1.5 border-b px-2 py-1.5 text-[11px] font-semibold tracking-wide uppercase md:grid", LINE_ITEM_GRID)}>
|
||||
<span>Date</span>
|
||||
<span>Description</span>
|
||||
<span className="text-center">Type</span>
|
||||
<span className="text-center">Hours</span>
|
||||
<span className="text-center">Rate</span>
|
||||
<span className="text-right">Amount</span>
|
||||
<span />
|
||||
</div>
|
||||
{items.map((item, index) => (
|
||||
<React.Fragment key={item.id}>
|
||||
{/* Desktop/Tablet Card */}
|
||||
<motion.div
|
||||
layout
|
||||
id={`invoice-item-${index}`}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<LineItemCard
|
||||
item={item}
|
||||
index={index}
|
||||
canRemove={canRemoveItems}
|
||||
onRemove={onRemoveItem}
|
||||
onUpdate={onUpdateItem}
|
||||
suggestions={getSuggestionsForIndex(index)}
|
||||
onSelectSuggestion={handleSelectSuggestion}
|
||||
onDescriptionChange={handleDescriptionChange}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Mobile Card */}
|
||||
<MobileLineItem
|
||||
item={item}
|
||||
index={index}
|
||||
canRemove={canRemoveItems}
|
||||
onRemove={onRemoveItem}
|
||||
onUpdate={onUpdateItem}
|
||||
suggestions={getSuggestionsForIndex(index)}
|
||||
onSelectSuggestion={handleSelectSuggestion}
|
||||
onDescriptionChange={handleDescriptionChange}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
{invoiceId && (
|
||||
<div className="border-t p-3 space-y-2">
|
||||
<p className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
|
||||
<Timer className="h-3.5 w-3.5" /> Time clock
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Track time on the dedicated time clock — entries sync across devices and
|
||||
bill directly to an invoice.
|
||||
</p>
|
||||
<Button variant="outline" size="sm" className="w-full" asChild>
|
||||
<Link
|
||||
href={`/dashboard/time-clock?invoiceId=${invoiceId}${
|
||||
clientId ? `&clientId=${clientId}` : ""
|
||||
}`}
|
||||
>
|
||||
Open time clock
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{onAddItemWithValues && !readOnly ? (
|
||||
<NLQuickAdd onAdd={onAddItemWithValues} />
|
||||
) : null}
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
|
||||
{!readOnly ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onAddItem}
|
||||
className="border-border text-muted-foreground hover:text-primary hover:bg-accent/50 hover:border-primary/50 mt-2 w-full border-dashed py-3 transition-all"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Line Item
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { DatePicker } from "~/components/ui/date-picker";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { STATUS_OPTIONS } from "./types";
|
||||
import type { InvoiceFormData, ClientType, BusinessType } from "./types";
|
||||
|
||||
interface InvoiceMetaSidebarProps {
|
||||
formData: InvoiceFormData;
|
||||
updateField: <K extends keyof InvoiceFormData>(
|
||||
field: K,
|
||||
value: InvoiceFormData[K],
|
||||
) => void;
|
||||
clients: ClientType[] | undefined;
|
||||
businesses: BusinessType[] | undefined;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InvoiceMetaSidebar({
|
||||
formData,
|
||||
updateField,
|
||||
clients,
|
||||
businesses,
|
||||
className,
|
||||
}: InvoiceMetaSidebarProps) {
|
||||
return (
|
||||
<div className={cn("flex h-full flex-col gap-6 p-4", className)}>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
|
||||
Invoice Details
|
||||
</h3>
|
||||
|
||||
{/* Status */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="status" className="text-xs">
|
||||
Status
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(value: "draft" | "sent" | "paid") =>
|
||||
updateField("status", value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="bg-background/50">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Invoice Number */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="invoiceNumber" className="text-xs">
|
||||
Invoice Number
|
||||
</Label>
|
||||
<Input
|
||||
id="invoiceNumber"
|
||||
value={formData.invoiceNumber}
|
||||
placeholder="INV-..."
|
||||
disabled
|
||||
className="bg-muted/50 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
|
||||
Involved Parties
|
||||
</h3>
|
||||
|
||||
{/* From (Business) */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="business" className="text-xs">
|
||||
From (Business)
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.businessId}
|
||||
onValueChange={(value) => updateField("businessId", value)}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label="From Business"
|
||||
className="bg-background/50 text-sm"
|
||||
>
|
||||
<span className="truncate">
|
||||
<SelectValue placeholder="Select business" />
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{businesses?.map((business) => (
|
||||
<SelectItem key={business.id} value={business.id}>
|
||||
{business.name}
|
||||
{business.nickname ? ` (${business.nickname})` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Bill To (Client) */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="client" className="text-xs">
|
||||
Bill To (Client)
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.clientId}
|
||||
onValueChange={(value) => updateField("clientId", value)}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label="Bill To Client"
|
||||
className="bg-background/50 text-sm"
|
||||
>
|
||||
<span className="truncate">
|
||||
<SelectValue placeholder="Select client" />
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients?.map((client) => (
|
||||
<SelectItem key={client.id} value={client.id}>
|
||||
{client.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
|
||||
Dates
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Issued</Label>
|
||||
<DatePicker
|
||||
date={formData.issueDate}
|
||||
onDateChange={(date) =>
|
||||
updateField("issueDate", date ?? new Date())
|
||||
}
|
||||
className="bg-background/50 w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Due</Label>
|
||||
<DatePicker
|
||||
date={formData.dueDate}
|
||||
onDateChange={(date) =>
|
||||
updateField("dueDate", date ?? new Date())
|
||||
}
|
||||
className="bg-background/50 w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
|
||||
Config
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Tax Rate</Label>
|
||||
<NumberInput
|
||||
value={formData.taxRate}
|
||||
onChange={(v) => updateField("taxRate", v)}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
suffix="%"
|
||||
className="bg-background/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Hourly Rate</Label>
|
||||
<NumberInput
|
||||
value={formData.defaultHourlyRate ?? 0}
|
||||
onChange={(v) => updateField("defaultHourlyRate", v)}
|
||||
min={0}
|
||||
prefix="$"
|
||||
placeholder={!formData.clientId ? "Select client" : "Rate"}
|
||||
disabled={!formData.clientId}
|
||||
className={cn(
|
||||
"bg-background/50",
|
||||
!formData.clientId && "opacity-50",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Label className="text-xs">Notes</Label>
|
||||
<Textarea
|
||||
value={formData.notes}
|
||||
onChange={(e) => updateField("notes", e.target.value)}
|
||||
placeholder="Notes for client..."
|
||||
className="bg-background/50 h-24 resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { FileText, Loader2 } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
export type InvoicePdfPreviewInput = {
|
||||
invoiceNumber: string;
|
||||
invoicePrefix: string;
|
||||
businessId: string;
|
||||
clientId: string;
|
||||
issueDate: Date;
|
||||
dueDate: Date;
|
||||
status: "draft" | "sent" | "paid";
|
||||
notes: string;
|
||||
emailMessage: string;
|
||||
taxRate: number;
|
||||
currency: string;
|
||||
items: Array<{
|
||||
date: Date;
|
||||
description: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
function canPreview(input: InvoicePdfPreviewInput | null): input is InvoicePdfPreviewInput {
|
||||
if (!input?.clientId) return false;
|
||||
if (input.items.length === 0) return false;
|
||||
return input.items.every((item) => item.description.trim().length > 0);
|
||||
}
|
||||
|
||||
type InvoicePdfPreviewPanelProps = {
|
||||
input: InvoicePdfPreviewInput | null;
|
||||
enabled?: boolean;
|
||||
className?: string;
|
||||
heightClassName?: string;
|
||||
/** Renders only the preview body (no card/header) for embedding in a parent pane. */
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
export function InvoicePdfPreviewPanel({
|
||||
input,
|
||||
enabled = true,
|
||||
className,
|
||||
heightClassName = "h-[min(80vh,760px)]",
|
||||
embedded = false,
|
||||
}: InvoicePdfPreviewPanelProps) {
|
||||
const previewReady = canPreview(input);
|
||||
|
||||
const { data: pdfPreview, isFetching, error, refetch } =
|
||||
api.invoices.previewPdf.useQuery(input!, {
|
||||
enabled: enabled && previewReady,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 5_000,
|
||||
});
|
||||
|
||||
const previewBody = (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-muted/20 overflow-hidden border-t",
|
||||
heightClassName,
|
||||
)}
|
||||
>
|
||||
{!previewReady ? (
|
||||
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
|
||||
Select a client and add descriptions for all line items to generate the
|
||||
PDF preview.
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<p className="text-destructive text-sm">{error.message}</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void refetch()}>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
) : isFetching && !pdfPreview ? (
|
||||
<div className="text-muted-foreground flex h-full items-center justify-center gap-2 p-6 text-center text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Generating preview…
|
||||
</div>
|
||||
) : pdfPreview ? (
|
||||
<iframe
|
||||
title="Invoice PDF preview"
|
||||
src={`data:${pdfPreview.contentType};base64,${pdfPreview.base64}`}
|
||||
className="h-full w-full border-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
|
||||
PDF preview will appear here.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (embedded) {
|
||||
return <div className={cn("overflow-hidden", className)}>{previewBody}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={cn("overflow-hidden", className)}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<FileText className="h-4 w-4" />
|
||||
PDF preview
|
||||
{isFetching ? <Loader2 className="text-muted-foreground h-3.5 w-3.5 animate-spin" /> : null}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">{previewBody}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { List, Calendar as CalendarIcon } from "lucide-react";
|
||||
import { InvoiceLineItems } from "../invoice-line-items";
|
||||
import { InvoiceCalendarView } from "../invoice-calendar-view";
|
||||
import type { InvoiceFormData } from "./types";
|
||||
|
||||
interface InvoiceWorkspaceProps {
|
||||
formData: InvoiceFormData;
|
||||
viewMode: "list" | "calendar";
|
||||
setViewMode: (mode: "list" | "calendar") => void;
|
||||
addItem: (date?: Date) => void;
|
||||
removeItem: (index: number) => void;
|
||||
updateItem: (
|
||||
index: number,
|
||||
field: string,
|
||||
value: string | number | Date,
|
||||
) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InvoiceWorkspace({
|
||||
formData,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
addItem,
|
||||
removeItem,
|
||||
updateItem,
|
||||
className,
|
||||
}: InvoiceWorkspaceProps) {
|
||||
return (
|
||||
<div className={cn("flex h-full flex-col", className)}>
|
||||
{/* Workspace Header / View Toggle */}
|
||||
<div className="bg-background/50 sticky top-0 z-10 flex items-center justify-between border-b p-4 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold tracking-tight">
|
||||
{viewMode === "list" ? "Line Items" : "Timesheet"}
|
||||
</h2>
|
||||
<div className="text-muted-foreground ml-2 text-sm">
|
||||
{formData.items.length}{" "}
|
||||
{formData.items.length === 1 ? "entry" : "entries"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-secondary/50 flex items-center rounded-lg p-1">
|
||||
<Button
|
||||
variant={viewMode === "list" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setViewMode("list")}
|
||||
className="h-8 gap-2 text-xs"
|
||||
>
|
||||
<List className="h-3.5 w-3.5" />
|
||||
List
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "calendar" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setViewMode("calendar")}
|
||||
className="h-8 gap-2 text-xs"
|
||||
>
|
||||
<CalendarIcon className="h-3.5 w-3.5" />
|
||||
Calendar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workspace Content */}
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<div className="absolute inset-0 overflow-y-auto p-6 md:p-8">
|
||||
{viewMode === "list" ? (
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
<div className="bg-background/40 rounded-xl border border-white/10 p-1 backdrop-blur-md">
|
||||
<InvoiceLineItems
|
||||
items={formData.items}
|
||||
onAddItem={() => addItem()}
|
||||
onRemoveItem={removeItem}
|
||||
onUpdateItem={updateItem}
|
||||
className="p-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full">
|
||||
<InvoiceCalendarView
|
||||
items={formData.items}
|
||||
onAddItem={addItem}
|
||||
onRemoveItem={removeItem}
|
||||
onUpdateItem={updateItem}
|
||||
defaultHourlyRate={formData.defaultHourlyRate}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { type RouterOutputs } from "~/trpc/react";
|
||||
|
||||
export type ClientType = RouterOutputs["clients"]["getAll"][number];
|
||||
export type BusinessType = RouterOutputs["businesses"]["getAll"][number];
|
||||
|
||||
import type { LineItemBillingType } from "~/lib/invoice-line-item";
|
||||
|
||||
export interface InvoiceItem {
|
||||
id: string;
|
||||
date: Date;
|
||||
description: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
amount: number;
|
||||
billingType: LineItemBillingType;
|
||||
}
|
||||
|
||||
export interface InvoiceFormData {
|
||||
invoiceNumber: string;
|
||||
invoicePrefix: string;
|
||||
businessId: string;
|
||||
clientId: string;
|
||||
issueDate: Date;
|
||||
dueDate: Date;
|
||||
status: "draft" | "sent" | "paid";
|
||||
notes: string;
|
||||
emailMessage: string;
|
||||
taxRate: number;
|
||||
currency: string;
|
||||
defaultHourlyRate: number | null;
|
||||
items: InvoiceItem[];
|
||||
}
|
||||
|
||||
export const STATUS_OPTIONS = [
|
||||
{ value: "draft", label: "Draft" },
|
||||
{ value: "sent", label: "Sent" },
|
||||
{ value: "paid", label: "Paid" },
|
||||
] as const;
|
||||
@@ -0,0 +1,680 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
Building2,
|
||||
DollarSign,
|
||||
Eye,
|
||||
FileJson,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
Trash2,
|
||||
Upload,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { FileUpload } from "~/components/forms/file-upload";
|
||||
import {
|
||||
dashboardGapClass,
|
||||
dashboardGridClass,
|
||||
dashboardStatGridClass,
|
||||
} from "~/components/layout/dashboard-page";
|
||||
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 { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import {
|
||||
detectImportFormat,
|
||||
parseInvoiceCSV,
|
||||
parseInvoiceJSON,
|
||||
type ImportFormat,
|
||||
type ImportInvoice,
|
||||
} from "~/lib/invoice-import";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
interface StagedInvoice extends ImportInvoice {
|
||||
id: string;
|
||||
clientId: string;
|
||||
format: ImportFormat;
|
||||
}
|
||||
|
||||
const NONE = "__none__";
|
||||
|
||||
function newId() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
export function InvoiceImportPage() {
|
||||
const [invoices, setInvoices] = useState<StagedInvoice[]>([]);
|
||||
const [globalClientId, setGlobalClientId] = useState("");
|
||||
const [globalBusinessId, setGlobalBusinessId] = useState("");
|
||||
const [previewId, setPreviewId] = useState<string | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const { data: clients, isLoading: loadingClients } =
|
||||
api.clients.getAll.useQuery();
|
||||
const { data: businesses, isLoading: loadingBusinesses } =
|
||||
api.businesses.getAll.useQuery();
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const bulkImport = api.invoices.bulkImport.useMutation({
|
||||
onSuccess: (result) => {
|
||||
void utils.invoices.getAll.invalidate();
|
||||
if (result.clientsCreated > 0) {
|
||||
void utils.clients.getAll.invalidate();
|
||||
}
|
||||
const parts = [
|
||||
`${result.invoicesCreated} invoice${result.invoicesCreated !== 1 ? "s" : ""} created`,
|
||||
];
|
||||
if (result.clientsCreated > 0) {
|
||||
parts.push(
|
||||
`${result.clientsCreated} client${result.clientsCreated !== 1 ? "s" : ""} created`,
|
||||
);
|
||||
}
|
||||
toast.success(parts.join(", "));
|
||||
if (result.errors.length > 0) {
|
||||
toast.warning(
|
||||
`${result.errors.length} invoice${result.errors.length !== 1 ? "s" : ""} skipped:\n${result.errors.slice(0, 3).join("\n")}${result.errors.length > 3 ? "\n..." : ""}`,
|
||||
);
|
||||
}
|
||||
setInvoices([]);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Import failed");
|
||||
},
|
||||
});
|
||||
|
||||
const applyGlobalClient = (clientId: string) => {
|
||||
setInvoices((prev) =>
|
||||
prev.map((inv) => ({
|
||||
...inv,
|
||||
clientId: inv.clientId || clientId,
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
const handleFileSelect = async (selectedFiles: File[]) => {
|
||||
for (const file of selectedFiles) {
|
||||
const format = detectImportFormat(file.name);
|
||||
const text = await file.text();
|
||||
|
||||
if (format === "json") {
|
||||
const parsed = parseInvoiceJSON(text);
|
||||
const staged: StagedInvoice[] = parsed.map((inv) => ({
|
||||
...inv,
|
||||
id: newId(),
|
||||
clientId: globalClientId,
|
||||
format: "json" as const,
|
||||
sourceFile: file.name,
|
||||
}));
|
||||
setInvoices((prev) => [...prev, ...staged]);
|
||||
|
||||
const errorCount = staged.filter((s) => s.errors.length > 0).length;
|
||||
if (errorCount > 0) {
|
||||
toast.error(
|
||||
`${file.name}: ${errorCount} invoice${errorCount !== 1 ? "s" : ""} with validation issues`,
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
`Parsed ${staged.length} invoice${staged.length !== 1 ? "s" : ""} from ${file.name}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const parsed = parseInvoiceCSV(text, file.name);
|
||||
const staged: StagedInvoice = {
|
||||
...parsed,
|
||||
id: newId(),
|
||||
clientId: globalClientId,
|
||||
format: "csv",
|
||||
};
|
||||
setInvoices((prev) => [...prev, staged]);
|
||||
|
||||
if (parsed.errors.length > 0) {
|
||||
toast.error(
|
||||
`${file.name}: ${parsed.errors.length} issue${parsed.errors.length !== 1 ? "s" : ""}`,
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
`Parsed ${parsed.items.length} items from ${file.name}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeInvoice = (id: string) => {
|
||||
setInvoices((prev) => prev.filter((inv) => inv.id !== id));
|
||||
};
|
||||
|
||||
const updateInvoice = (id: string, updates: Partial<StagedInvoice>) => {
|
||||
setInvoices((prev) =>
|
||||
prev.map((inv) => {
|
||||
if (inv.id !== id) return inv;
|
||||
const updated = { ...inv, ...updates };
|
||||
if (updates.issueDate !== undefined && !updates.dueDate) {
|
||||
const due = new Date(updated.issueDate ?? new Date());
|
||||
due.setDate(due.getDate() + 30);
|
||||
updated.dueDate = due;
|
||||
}
|
||||
return updated;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const isReady = (inv: StagedInvoice) =>
|
||||
inv.errors.length === 0 &&
|
||||
inv.items.length > 0 &&
|
||||
!!(inv.clientId || globalClientId || inv.client?.name) &&
|
||||
!!inv.issueDate &&
|
||||
!!inv.dueDate;
|
||||
|
||||
const readyCount = invoices.filter(isReady).length;
|
||||
|
||||
const validateBeforeImport = (): string[] => {
|
||||
const errors: string[] = [];
|
||||
if (!globalBusinessId && (!businesses || businesses.length === 0)) {
|
||||
errors.push("Create a business in Settings before importing");
|
||||
}
|
||||
invoices.forEach((inv) => {
|
||||
if (inv.errors.length > 0) {
|
||||
errors.push(`${inv.name}: ${inv.errors.join("; ")}`);
|
||||
}
|
||||
if (inv.items.length === 0) {
|
||||
errors.push(`${inv.name}: no line items`);
|
||||
}
|
||||
if (!inv.clientId && !globalClientId && !inv.client?.name) {
|
||||
errors.push(`${inv.name}: client required`);
|
||||
}
|
||||
if (!inv.issueDate) errors.push(`${inv.name}: issue date required`);
|
||||
if (!inv.dueDate) errors.push(`${inv.name}: due date required`);
|
||||
});
|
||||
return errors;
|
||||
};
|
||||
|
||||
const processImport = async () => {
|
||||
const errors = validateBeforeImport();
|
||||
if (errors.length > 0) {
|
||||
toast.error(`Fix these issues first:\n${errors.slice(0, 5).join("\n")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const readyInvoices = invoices.filter(isReady);
|
||||
if (readyInvoices.length === 0) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await bulkImport.mutateAsync({
|
||||
defaultClientId: globalClientId || undefined,
|
||||
defaultBusinessId: globalBusinessId || undefined,
|
||||
invoices: readyInvoices.map((inv) => ({
|
||||
name: inv.name,
|
||||
issueDate: inv.issueDate,
|
||||
dueDate: inv.dueDate,
|
||||
clientId: inv.clientId || globalClientId || undefined,
|
||||
client: inv.client,
|
||||
items: inv.items.map((item) => ({
|
||||
date: item.date,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
rate: item.rate,
|
||||
})),
|
||||
sourceFile: inv.sourceFile,
|
||||
})),
|
||||
});
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const previewInvoice = previewId
|
||||
? invoices.find((i) => i.id === previewId)
|
||||
: null;
|
||||
|
||||
const totalItems = invoices.reduce((sum, inv) => sum + inv.items.length, 0);
|
||||
const totalAmount = invoices.reduce(
|
||||
(sum, inv) =>
|
||||
sum + inv.items.reduce((s, item) => s + item.quantity * item.rate, 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col", dashboardGapClass)}>
|
||||
{/* Upload — primary action */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Upload className="text-primary h-5 w-5" />
|
||||
Upload files
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<FileUpload
|
||||
onFilesSelected={handleFileSelect}
|
||||
accept={{
|
||||
"text/csv": [".csv"],
|
||||
"application/json": [".json"],
|
||||
}}
|
||||
maxFiles={50}
|
||||
maxSize={10 * 1024 * 1024}
|
||||
placeholder="Drag & drop CSV or JSON files here, or click to select"
|
||||
description="CSV: one file = one invoice. JSON: multiple invoices per file."
|
||||
/>
|
||||
|
||||
{invoices.length > 0 && (
|
||||
<div className={cn("bg-primary/10 p-4", dashboardStatGridClass)}>
|
||||
<SummaryStat label="Invoices" value={invoices.length} />
|
||||
<SummaryStat label="Line items" value={totalItems} />
|
||||
<SummaryStat
|
||||
label="Total amount"
|
||||
value={totalAmount.toLocaleString("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
})}
|
||||
/>
|
||||
<SummaryStat
|
||||
label="Ready"
|
||||
value={`${readyCount}/${invoices.length}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Defaults */}
|
||||
<div className={cn(dashboardGridClass, "lg:grid-cols-2")}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building2 className="text-primary h-5 w-5" />
|
||||
Default business
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="global-business" className="text-sm font-medium">
|
||||
Business for imported invoices
|
||||
</Label>
|
||||
<Select
|
||||
value={globalBusinessId || NONE}
|
||||
onValueChange={(value) =>
|
||||
setGlobalBusinessId(value === NONE ? "" : value)
|
||||
}
|
||||
disabled={loadingBusinesses}
|
||||
>
|
||||
<SelectTrigger id="global-business" className="h-11">
|
||||
<SelectValue placeholder="Use default business" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>Use default business</SelectItem>
|
||||
{businesses?.map((b) => (
|
||||
<SelectItem key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
{b.isDefault ? " (default)" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Required — your default business is used if none is selected.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="text-primary h-5 w-5" />
|
||||
Default client
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="global-client" className="text-sm font-medium">
|
||||
Client for CSV imports (optional)
|
||||
</Label>
|
||||
<Select
|
||||
value={globalClientId || NONE}
|
||||
onValueChange={(value) => {
|
||||
const id = value === NONE ? "" : value;
|
||||
setGlobalClientId(id);
|
||||
if (id) applyGlobalClient(id);
|
||||
}}
|
||||
disabled={loadingClients}
|
||||
>
|
||||
<SelectTrigger id="global-client" className="h-11">
|
||||
<SelectValue placeholder="No default client" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>
|
||||
No default (JSON client or per-invoice)
|
||||
</SelectItem>
|
||||
{clients?.map((client) => (
|
||||
<SelectItem key={client.id} value={client.id}>
|
||||
{client.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
CSV files need a client. JSON can include client details per
|
||||
invoice.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Staged invoices */}
|
||||
{invoices.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Preview</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{invoices.map((inv) => (
|
||||
<div
|
||||
key={inv.id}
|
||||
className="border-border bg-muted/20 space-y-4 rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{inv.format === "json" ? (
|
||||
<FileJson className="text-primary h-5 w-5 shrink-0" />
|
||||
) : (
|
||||
<FileSpreadsheet className="text-primary h-5 w-5 shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-foreground truncate font-medium">
|
||||
{inv.name}
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{inv.items.length} items
|
||||
{inv.sourceFile ? ` • ${inv.sourceFile}` : ""}
|
||||
{inv.client?.name ? ` • ${inv.client.name}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPreviewId(inv.id)}
|
||||
>
|
||||
<Eye className="mr-1 h-4 w-4" />
|
||||
Preview
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => removeInvoice(inv.id)}
|
||||
className="text-destructive hover:text-destructive/80"
|
||||
>
|
||||
<Trash2 className="mr-1 h-4 w-4" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground text-xs font-medium">
|
||||
Invoice title
|
||||
</Label>
|
||||
<Input
|
||||
value={inv.name}
|
||||
className="h-9 text-sm"
|
||||
onChange={(e) =>
|
||||
updateInvoice(inv.id, { name: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground text-xs font-medium">
|
||||
Client
|
||||
</Label>
|
||||
<Select
|
||||
value={inv.clientId || NONE}
|
||||
onValueChange={(value) =>
|
||||
updateInvoice(inv.id, {
|
||||
clientId: value === NONE ? "" : value,
|
||||
})
|
||||
}
|
||||
disabled={loadingClients}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
inv.client?.name
|
||||
? `Use JSON: ${inv.client.name}`
|
||||
: "Select client"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>
|
||||
{inv.client?.name
|
||||
? `Use JSON: ${inv.client.name}`
|
||||
: "Select client"}
|
||||
</SelectItem>
|
||||
{clients?.map((client) => (
|
||||
<SelectItem key={client.id} value={client.id}>
|
||||
{client.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground text-xs font-medium">
|
||||
Issue date
|
||||
</Label>
|
||||
<DatePicker
|
||||
date={inv.issueDate}
|
||||
onDateChange={(date) =>
|
||||
updateInvoice(inv.id, { issueDate: date })
|
||||
}
|
||||
placeholder="Issue date"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground text-xs font-medium">
|
||||
Due date
|
||||
</Label>
|
||||
<DatePicker
|
||||
date={inv.dueDate}
|
||||
onDateChange={(date) =>
|
||||
updateInvoice(inv.id, { dueDate: date })
|
||||
}
|
||||
placeholder="Due date"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{inv.errors.length > 0 && (
|
||||
<div className="border-destructive/20 bg-destructive/10 rounded-lg border p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<AlertCircle className="text-destructive h-4 w-4" />
|
||||
<span className="text-destructive text-sm font-medium">
|
||||
Issues
|
||||
</span>
|
||||
</div>
|
||||
<ul className="text-destructive space-y-1 text-sm">
|
||||
{inv.errors.map((err, i) => (
|
||||
<li key={i}>• {err}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Total:{" "}
|
||||
{inv.items
|
||||
.reduce((s, item) => s + item.quantity * item.rate, 0)
|
||||
.toLocaleString("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
})}
|
||||
</span>
|
||||
<Badge variant={isReady(inv) ? "default" : "secondary"}>
|
||||
{isReady(inv) ? "Ready" : "Pending"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{invoices.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<DollarSign className="text-primary h-5 w-5" />
|
||||
Import invoices
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-4">
|
||||
{isProcessing && (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Importing {readyCount} invoice
|
||||
{readyCount !== 1 ? "s" : ""}...
|
||||
</span>
|
||||
<Progress value={50} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{readyCount} of {invoices.length} ready • all imported as
|
||||
drafts
|
||||
</span>
|
||||
<Button
|
||||
onClick={processImport}
|
||||
disabled={readyCount === 0 || isProcessing}
|
||||
className="sm:shrink-0"
|
||||
>
|
||||
{isProcessing
|
||||
? "Importing..."
|
||||
: `Import ${readyCount} Invoice${readyCount !== 1 ? "s" : ""}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Dialog open={!!previewId} onOpenChange={() => setPreviewId(null)}>
|
||||
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileText className="text-primary h-5 w-5" />
|
||||
{previewInvoice?.name}
|
||||
</DialogTitle>
|
||||
<DialogDescription>Line item preview</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{previewInvoice && (
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead className="bg-muted/50 sticky top-0">
|
||||
<tr>
|
||||
<th className="text-muted-foreground p-2 text-left text-sm font-medium">
|
||||
Date
|
||||
</th>
|
||||
<th className="text-muted-foreground p-2 text-left text-sm font-medium">
|
||||
Description
|
||||
</th>
|
||||
<th className="text-muted-foreground p-2 text-right text-sm font-medium">
|
||||
Qty
|
||||
</th>
|
||||
<th className="text-muted-foreground p-2 text-right text-sm font-medium">
|
||||
Rate
|
||||
</th>
|
||||
<th className="text-muted-foreground p-2 text-right text-sm font-medium">
|
||||
Amount
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{previewInvoice.items.map((item, idx) => (
|
||||
<tr key={idx} className="border-border border-b">
|
||||
<td className="p-2 text-sm whitespace-nowrap">
|
||||
{item.date?.toLocaleDateString() ?? "—"}
|
||||
</td>
|
||||
<td className="max-w-xs truncate p-2 text-sm">
|
||||
{item.description}
|
||||
</td>
|
||||
<td className="p-2 text-right text-sm">{item.quantity}</td>
|
||||
<td className="p-2 text-right text-sm">
|
||||
{item.rate.toLocaleString("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
})}
|
||||
</td>
|
||||
<td className="p-2 text-right text-sm font-medium">
|
||||
{(item.quantity * item.rate).toLocaleString("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
})}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setPreviewId(null)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryStat({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
}) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="text-primary text-2xl font-bold">{value}</div>
|
||||
<div className="text-muted-foreground text-sm">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
// Sets data-color-mode / .dark on <html> from localStorage before paint, to
|
||||
// avoid a flash of the wrong theme. Rendered only during SSR (typeof window
|
||||
// check) and returns null on the client, so the <script> element never
|
||||
// enters the tree React reconciles during hydration — React 19 otherwise
|
||||
// warns "Encountered a script tag while rendering React component" for any
|
||||
// <script> it walks while hydrating, even one from next/script. Same fix
|
||||
// next-themes ships for its inline ThemeScript (shadcn-ui/ui#10238).
|
||||
const APPEARANCE_INIT_SOURCE = `
|
||||
try {
|
||||
var stored = JSON.parse(localStorage.getItem("bv.appearance") || "{}");
|
||||
var colorMode = stored.colorMode || "system";
|
||||
var root = document.documentElement;
|
||||
root.dataset.colorMode = colorMode;
|
||||
if (colorMode === "dark") root.classList.add("dark");
|
||||
} catch {}
|
||||
`;
|
||||
|
||||
export function AppearanceInitScript() {
|
||||
if (typeof window !== "undefined") return null;
|
||||
return (
|
||||
<script
|
||||
id="appearance-init"
|
||||
suppressHydrationWarning
|
||||
dangerouslySetInnerHTML={{ __html: APPEARANCE_INIT_SOURCE }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Grid + animated blob backdrop shared by the app shell and marketing pages. */
|
||||
export function BrandBackground() {
|
||||
return (
|
||||
<div className="brand-background pointer-events-none fixed inset-0 -z-10 flex items-center justify-center overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]" />
|
||||
<div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/40 blur-3xl dark:bg-neutral-500/30" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
/** Vertical rhythm for dashboard pages — use with shell `gap-5`. */
|
||||
export const dashboardGapClass = "gap-5 md:gap-6";
|
||||
|
||||
/** Standard grid gap for dashboard cards and sections. */
|
||||
export const dashboardGridClass =
|
||||
"grid gap-5 md:gap-6";
|
||||
|
||||
/** Summary stat cards (2-up mobile, 4-up desktop). */
|
||||
export const dashboardStatGridClass =
|
||||
"grid grid-cols-2 gap-4 sm:grid-cols-4";
|
||||
|
||||
export function DashboardPage({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"page-enter mx-auto flex w-full max-w-7xl flex-col",
|
||||
dashboardGapClass,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardGrid({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn(dashboardGridClass, className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardCardTitle({
|
||||
children,
|
||||
icon: Icon,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
}) {
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
{Icon ? <Icon className="text-muted-foreground h-4 w-4" /> : null}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Sidebar } from "~/components/layout/sidebar";
|
||||
import {
|
||||
SidebarProvider,
|
||||
useSidebar,
|
||||
} from "~/components/layout/sidebar-provider";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Menu } from "lucide-react";
|
||||
import { Logo } from "~/components/branding/logo";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
|
||||
import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget";
|
||||
import { OnboardingGuard } from "~/components/layout/onboarding-guard";
|
||||
|
||||
function DashboardContent({ children }: { children: React.ReactNode }) {
|
||||
const { isCollapsed } = useSidebar();
|
||||
const pathname = usePathname();
|
||||
const [isMobileOpen, setIsMobileOpen] = React.useState(false);
|
||||
const isOnboarding = pathname === "/dashboard/onboarding";
|
||||
|
||||
return (
|
||||
<div className="bg-dashboard relative flex min-h-screen">
|
||||
{!isOnboarding && (
|
||||
<div className="hidden md:block">
|
||||
<Sidebar />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"dashboard-mobile-header bg-background/80 border-border fixed top-0 right-0 left-0 z-50 flex min-h-16 items-center border-b px-3 backdrop-blur-md sm:px-4 md:hidden",
|
||||
isOnboarding && "hidden",
|
||||
)}
|
||||
>
|
||||
<Sheet open={isMobileOpen} onOpenChange={setIsMobileOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="bg-background h-10 w-10 shadow-sm"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
<span className="sr-only">Toggle menu</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<div className="ml-3 flex min-w-0 flex-1 items-center gap-2 sm:ml-4">
|
||||
<Logo size="sm" className="shrink-0" />
|
||||
<ActiveTimerWidget compact />
|
||||
</div>
|
||||
<SheetContent side="left" className="w-72 p-0">
|
||||
<div className="sr-only">
|
||||
<h2 id="mobile-nav-title">Navigation Menu</h2>
|
||||
</div>
|
||||
<Sidebar mobile onClose={() => setIsMobileOpen(false)} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
<main
|
||||
suppressHydrationWarning
|
||||
className={cn(
|
||||
"min-h-screen min-w-0 flex-1 transition-all duration-300 ease-in-out md:ml-0",
|
||||
!isOnboarding && (isCollapsed ? "md:ml-16" : "md:ml-64"),
|
||||
)}
|
||||
>
|
||||
{isOnboarding ? (
|
||||
<OnboardingGuard>{children}</OnboardingGuard>
|
||||
) : (
|
||||
<div className="dashboard-content-shell flex flex-col gap-5 md:gap-6">
|
||||
<OnboardingGuard>{children}</OnboardingGuard>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<DashboardContent>{children}</DashboardContent>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
interface DashboardUserContextValue {
|
||||
isAdmin: boolean;
|
||||
needsOnboarding: boolean;
|
||||
}
|
||||
|
||||
const DashboardUserContext = createContext<DashboardUserContextValue>({
|
||||
isAdmin: false,
|
||||
needsOnboarding: false,
|
||||
});
|
||||
|
||||
export function DashboardUserProvider({
|
||||
isAdmin,
|
||||
needsOnboarding,
|
||||
children,
|
||||
}: DashboardUserContextValue & { children: React.ReactNode }) {
|
||||
return (
|
||||
<DashboardUserContext.Provider value={{ isAdmin, needsOnboarding }}>
|
||||
{children}
|
||||
</DashboardUserContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDashboardUser() {
|
||||
return useContext(DashboardUserContext);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import { useSidebar } from "~/components/layout/sidebar-provider";
|
||||
|
||||
interface FloatingActionBarProps {
|
||||
leftContent?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FloatingActionBar({
|
||||
leftContent,
|
||||
children,
|
||||
className,
|
||||
}: FloatingActionBarProps) {
|
||||
const { isCollapsed } = useSidebar();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pb-safe-area-inset-bottom fixed right-0 bottom-4 left-0 z-50 transition-all duration-300 ease-in-out",
|
||||
isCollapsed ? "md:left-16" : "md:left-64",
|
||||
"animate-slide-in-bottom",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="w-full px-4 transition-transform duration-300">
|
||||
<Card className="hover-lift bg-card border-border border shadow-lg">
|
||||
<CardContent className="flex flex-col gap-3 p-3 sm:flex-row sm:items-center sm:justify-between sm:p-4">
|
||||
{leftContent && (
|
||||
<div className="text-card-foreground animate-fade-in flex flex-1 items-center gap-3">
|
||||
{leftContent}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="animate-fade-in animate-delay-100 flex items-center gap-2 sm:gap-3">
|
||||
{children}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
export function MotionBackground() {
|
||||
return (
|
||||
<div className="bg-background pointer-events-none fixed inset-0 -z-50 overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-[-50%] h-[200%] w-[200%]",
|
||||
"bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))]",
|
||||
"from-[oklch(var(--primary)/0.15)] via-transparent to-transparent",
|
||||
"animate-subtle-spin opacity-100",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-[-50%] h-[200%] w-[200%]",
|
||||
"bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))]",
|
||||
"from-[oklch(var(--accent)/0.15)] via-transparent to-transparent",
|
||||
"animate-subtle-wave opacity-100",
|
||||
)}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-[url('/noise.svg')] opacity-[0.02] mix-blend-overlay" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
import { authClient } from "~/lib/auth-client";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Logo } from "~/components/branding/logo";
|
||||
import { SidebarTrigger } from "~/components/navigation/sidebar-trigger";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
import { useAuthSession } from "~/hooks/use-auth-session";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface NavbarProps {
|
||||
allowRegistration?: boolean;
|
||||
}
|
||||
|
||||
export function Navbar({ allowRegistration = true }: NavbarProps) {
|
||||
const { data: session, isPending } = useAuthSession();
|
||||
const [isMobileNavOpen, setIsMobileNavOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
// Get current open invoice for quick access
|
||||
// const { data: currentInvoice } = api.invoices.getCurrentOpen.useQuery();
|
||||
|
||||
return (
|
||||
<header className="bg-navbar border-navbar-border text-navbar-foreground fixed top-0 right-0 left-0 z-30 border-b">
|
||||
<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
|
||||
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">
|
||||
{isPending ? (
|
||||
<>
|
||||
<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={async () => {
|
||||
await authClient.signOut();
|
||||
router.push("/");
|
||||
}}
|
||||
className="text-xs md:text-sm"
|
||||
>
|
||||
Sign Out
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/auth/signin">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs md:text-sm"
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
</Link>
|
||||
{allowRegistration && (
|
||||
<Link href="/auth/register">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="text-xs font-medium md:text-sm"
|
||||
>
|
||||
Register
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useDashboardUser } from "./dashboard-user-context";
|
||||
|
||||
export function OnboardingGuard({ children }: { children: React.ReactNode }) {
|
||||
const { needsOnboarding } = useDashboardUser();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const onOnboardingPage = pathname === "/dashboard/onboarding";
|
||||
|
||||
useEffect(() => {
|
||||
if (needsOnboarding && !onOnboardingPage) {
|
||||
router.replace("/dashboard/onboarding");
|
||||
}
|
||||
}, [needsOnboarding, onOnboardingPage, router]);
|
||||
|
||||
if (needsOnboarding && !onOnboardingPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import React from "react";
|
||||
import { DashboardBreadcrumbs } from "~/components/navigation/dashboard-breadcrumbs";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
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 text-foreground`;
|
||||
case "large":
|
||||
return `${baseClasses} text-4xl text-foreground`;
|
||||
case "large-gradient":
|
||||
return `${baseClasses} text-4xl text-foreground`;
|
||||
default:
|
||||
return `${baseClasses} text-3xl text-foreground`;
|
||||
}
|
||||
};
|
||||
|
||||
const getDescriptionSpacing = () => {
|
||||
return variant === "large" || variant === "large-gradient"
|
||||
? "mt-2"
|
||||
: "mt-1";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("animate-fade-in-down", className)}>
|
||||
{variant === "large-gradient" || variant === "gradient" ? (
|
||||
<div className="platform-header-surface bg-card text-card-foreground relative overflow-hidden rounded-xl border shadow-sm">
|
||||
<div className="platform-header-gradient from-primary/5 pointer-events-none absolute inset-0 bg-gradient-to-br via-transparent to-transparent" />
|
||||
<div className="platform-header-content relative p-6">
|
||||
<DashboardBreadcrumbs className="mb-4" />
|
||||
{/* UPDATED: flex-col on mobile to prevent squishing, row on sm+ */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h1 className={titleClassName ?? getTitleClasses()}>{title}</h1>
|
||||
{description && (
|
||||
<p
|
||||
className={`text-muted-foreground ${getDescriptionSpacing()} text-lg`}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{children && (
|
||||
<div className="flex w-full flex-shrink-0 gap-2 sm:w-auto sm:gap-3">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DashboardBreadcrumbs className="mb-2 sm:mb-4" />
|
||||
{/* UPDATED: flex-col on mobile to prevent squishing, row on sm+ */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="animate-fade-in-up space-y-1">
|
||||
<h1 className={titleClassName ?? getTitleClasses()}>{title}</h1>
|
||||
{description && (
|
||||
<p
|
||||
className={`animate-fade-in-up animate-delay-100 text-muted-foreground ${getDescriptionSpacing()} text-lg`}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{children && (
|
||||
<div className="animate-slide-in-right animate-delay-200 flex w-full flex-shrink-0 gap-2 sm:w-auto sm:gap-3">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</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="gradient"
|
||||
className={cn("mb-0", className)}
|
||||
titleClassName="font-heading text-2xl font-semibold tracking-tight sm:text-3xl"
|
||||
>
|
||||
{children}
|
||||
</PageHeader>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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-foreground text-xl font-semibold">{title}</h2>
|
||||
)}
|
||||
{description && (
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{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="bg-muted mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl p-3 [&_svg]:text-muted-foreground">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<h3 className="mb-2 text-lg font-semibold">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-muted-foreground mx-auto mb-4 max-w-sm text-sm">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "~/components/ui/tabs";
|
||||
import { dashboardGapClass, dashboardGridClass } from "~/components/layout/dashboard-page";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
/** Vertical rhythm inside tab panels — matches dashboard page sections. */
|
||||
export const pageTabsPanelClass = dashboardGapClass;
|
||||
|
||||
/** Grid for stacked cards inside a tab panel. */
|
||||
export const pageTabsGridClass = dashboardGridClass;
|
||||
|
||||
type PageTabsProps = React.ComponentPropsWithoutRef<typeof Tabs>;
|
||||
|
||||
export function PageTabs({ className, ...props }: PageTabsProps) {
|
||||
return (
|
||||
<Tabs
|
||||
className={cn("flex flex-col", pageTabsPanelClass, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type PageTabsListProps = React.ComponentPropsWithoutRef<typeof TabsList>;
|
||||
|
||||
export function PageTabsList({ className, ...props }: PageTabsListProps) {
|
||||
return (
|
||||
<div className="-mx-1 overflow-x-auto px-1 pb-0.5 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<TabsList
|
||||
className={cn(
|
||||
"bg-muted/50 border-border/60 inline-flex h-10 w-max min-w-full gap-0.5 rounded-xl border p-1 sm:min-w-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageTabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof TabsTrigger>) {
|
||||
return (
|
||||
<TabsTrigger
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background h-8 rounded-lg px-3.5 text-sm data-[state=active]:shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageTabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof TabsContent>) {
|
||||
return (
|
||||
<TabsContent
|
||||
className={cn(
|
||||
"mt-0 flex flex-col focus-visible:ring-0 focus-visible:outline-none",
|
||||
pageTabsPanelClass,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
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-primary",
|
||||
background: "bg-secondary",
|
||||
hoverBackground: "group-hover:bg-secondary/80",
|
||||
},
|
||||
};
|
||||
|
||||
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 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="bg-card border-border border">
|
||||
<CardContent className="p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="bg-muted mx-auto mb-3 h-12 w-12"></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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
interface SidebarContextType {
|
||||
isCollapsed: boolean;
|
||||
toggleCollapse: () => void;
|
||||
expand: () => void;
|
||||
collapse: () => void;
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export function SidebarProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isCollapsed, setIsCollapsed] = React.useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
const saved = localStorage.getItem("sidebar-collapsed");
|
||||
return saved ? (JSON.parse(saved) as boolean) : false;
|
||||
});
|
||||
|
||||
const toggleCollapse = React.useCallback(() => {
|
||||
setIsCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
localStorage.setItem("sidebar-collapsed", JSON.stringify(next));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const expand = React.useCallback(() => {
|
||||
setIsCollapsed(false);
|
||||
localStorage.setItem("sidebar-collapsed", JSON.stringify(false));
|
||||
}, []);
|
||||
|
||||
const collapse = React.useCallback(() => {
|
||||
setIsCollapsed(true);
|
||||
localStorage.setItem("sidebar-collapsed", JSON.stringify(true));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider
|
||||
value={{ isCollapsed, toggleCollapse, expand, collapse }}
|
||||
>
|
||||
{children}
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSidebar() {
|
||||
const context = React.useContext(SidebarContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { authClient } from "~/lib/auth-client";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { LogOut, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||
import { getNavigationForUser, isNavLinkActive } from "~/lib/navigation";
|
||||
import { useSidebar } from "./sidebar-provider";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Logo } from "~/components/branding/logo";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "~/components/ui/tooltip";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { getGravatarUrl } from "~/lib/gravatar";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
|
||||
import { useAuthSession } from "~/hooks/use-auth-session";
|
||||
import { useDashboardUser } from "~/components/layout/dashboard-user-context";
|
||||
import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget";
|
||||
|
||||
interface SidebarProps {
|
||||
mobile?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ mobile, onClose }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const { data: session, isPending } = useAuthSession();
|
||||
const { isAdmin } = useDashboardUser();
|
||||
const { isCollapsed, toggleCollapse } = useSidebar();
|
||||
const navSections = getNavigationForUser(isAdmin);
|
||||
|
||||
// If mobile, always expanded
|
||||
const collapsed = mobile ? false : isCollapsed;
|
||||
|
||||
const SidebarContent = (
|
||||
<div className="flex h-full flex-col justify-between">
|
||||
<div>
|
||||
{/* Header / Logo */}
|
||||
<div
|
||||
className={cn(
|
||||
"mb-2 flex h-14 items-center px-4",
|
||||
collapsed ? "justify-center px-2" : "justify-between",
|
||||
)}
|
||||
>
|
||||
{!collapsed && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Logo size="sm" />
|
||||
</div>
|
||||
)}
|
||||
{collapsed && <Logo size="icon" />}
|
||||
|
||||
{!mobile && !collapsed && (
|
||||
<div className="h-8 w-8" /> // Spacer to keep alignment if needed, or just remove
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav
|
||||
className={cn(
|
||||
"mt-4 flex flex-col gap-6 px-2",
|
||||
collapsed && "items-center",
|
||||
)}
|
||||
>
|
||||
{navSections.map((section) => (
|
||||
<div key={section.title}>
|
||||
{!collapsed && (
|
||||
<div className="text-muted-foreground/60 mb-2 px-2 text-xs font-semibold tracking-wider uppercase">
|
||||
{section.title}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
{section.links.map((link) => {
|
||||
const Icon = link.icon;
|
||||
const isActive = isNavLinkActive(pathname, link.href);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<TooltipProvider key={link.href} delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={link.href}
|
||||
data-active={isActive ? "true" : undefined}
|
||||
className={cn(
|
||||
"flex h-10 w-10 items-center justify-center rounded-md transition-colors",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
className="font-medium"
|
||||
>
|
||||
{link.name}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
data-active={isActive ? "true" : undefined}
|
||||
onClick={mobile ? onClose : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{link.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Footer / User */}
|
||||
<div className="mt-auto space-y-2 p-2">
|
||||
{!mobile && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex",
|
||||
collapsed ? "justify-center" : "justify-end px-2",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground h-8 w-8"
|
||||
onClick={toggleCollapse}
|
||||
>
|
||||
{collapsed ? (
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
) : (
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ActiveTimerWidget collapsed={collapsed} />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/50 border-t pt-4",
|
||||
collapsed ? "flex flex-col items-center gap-2" : "px-2",
|
||||
)}
|
||||
>
|
||||
{isPending ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3",
|
||||
collapsed ? "justify-center" : "px-2",
|
||||
)}
|
||||
>
|
||||
<Skeleton className="h-9 w-9 rounded-full" />
|
||||
{!collapsed && (
|
||||
<div className="flex-1 space-y-1">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-2 w-24" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : session?.user ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"w-full justify-start p-0 hover:bg-transparent",
|
||||
collapsed && "justify-center",
|
||||
)}
|
||||
>
|
||||
{/* FIXED: Changed div to span to prevent hydration error */}
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-3",
|
||||
collapsed ? "justify-center" : "w-full",
|
||||
)}
|
||||
>
|
||||
<Avatar className="border-border h-9 w-9 border">
|
||||
<AvatarImage
|
||||
src={getGravatarUrl(session.user.email)}
|
||||
alt={session.user.name ?? "User"}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{session.user.name?.[0] ?? "U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{!collapsed && (
|
||||
<span className="min-w-0 flex-1 text-left">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{session.user.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{session.user.email}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="right"
|
||||
align="end"
|
||||
className="bg-background/80 border-border/50 w-56 backdrop-blur-xl"
|
||||
sideOffset={10}
|
||||
>
|
||||
<DropdownMenuLabel>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm leading-none font-medium">
|
||||
{session.user.name}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs leading-none">
|
||||
{session.user.email}
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await authClient.signOut();
|
||||
window.location.href = "/";
|
||||
}}
|
||||
className="text-red-600 focus:bg-red-100/50 focus:text-red-600 dark:focus:bg-red-900/20"
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Sign Out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (mobile) {
|
||||
return <div className="bg-background h-full">{SidebarContent}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"border-border bg-background fixed top-0 bottom-0 left-0 z-30 hidden flex-col rounded-none border-r shadow-none transition-all duration-300 ease-in-out md:flex",
|
||||
isCollapsed ? "w-16" : "w-64",
|
||||
)}
|
||||
>
|
||||
{SidebarContent}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
export type LegalSection = {
|
||||
id: string;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
/** Body copy for legal pages — explicit styles (no typography plugin). */
|
||||
export function LegalParagraph({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
"text-muted-foreground text-[15px] leading-7",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function LegalSectionBody({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 [&_a]:text-foreground [&_a]:font-medium [&_a]:underline [&_a]:underline-offset-4">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LegalTableOfContents({ sections }: { sections: LegalSection[] }) {
|
||||
return (
|
||||
<nav aria-label="Table of contents" className="text-sm">
|
||||
<p className="text-foreground mb-3 font-medium">On this page</p>
|
||||
<ol className="space-y-2">
|
||||
{sections.map((section) => (
|
||||
<li key={section.id}>
|
||||
<Link
|
||||
href={`#${section.id}`}
|
||||
className="text-muted-foreground hover:text-foreground block leading-snug transition-colors"
|
||||
>
|
||||
{section.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function LegalSectionBlock({
|
||||
section,
|
||||
isLast,
|
||||
}: {
|
||||
section: LegalSection;
|
||||
isLast: boolean;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
id={section.id}
|
||||
className={cn("scroll-mt-28", !isLast && "border-border/50 border-b")}
|
||||
>
|
||||
<div className="px-6 pt-8 pb-4 sm:px-8">
|
||||
<h2 className="text-foreground text-lg font-semibold tracking-tight sm:text-xl">
|
||||
{section.title}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="px-6 pb-10 sm:px-8">
|
||||
<LegalSectionBody>{section.children}</LegalSectionBody>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function LegalDocument({ sections }: { sections: LegalSection[] }) {
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,13rem)_minmax(0,1fr)] lg:items-start lg:gap-8">
|
||||
<aside
|
||||
className={cn(
|
||||
"border-border/60 bg-card/70 rounded-2xl border p-4 backdrop-blur-sm lg:sticky lg:top-24",
|
||||
)}
|
||||
>
|
||||
<LegalTableOfContents sections={sections} />
|
||||
</aside>
|
||||
|
||||
<article
|
||||
className={cn(
|
||||
"border-border/60 bg-card/70 overflow-hidden rounded-3xl border shadow-xl backdrop-blur-sm",
|
||||
)}
|
||||
>
|
||||
{sections.map((section, index) => (
|
||||
<LegalSectionBlock
|
||||
key={section.id}
|
||||
section={section}
|
||||
isLast={index === sections.length - 1}
|
||||
/>
|
||||
))}
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
type LegalLinksProps = {
|
||||
className?: string;
|
||||
linkClassName?: string;
|
||||
};
|
||||
|
||||
export function LegalLinks({ className, linkClassName }: LegalLinksProps) {
|
||||
const linkStyles = cn(
|
||||
"text-foreground font-medium hover:underline",
|
||||
linkClassName,
|
||||
);
|
||||
|
||||
return (
|
||||
<span className={className}>
|
||||
<Link href="/terms" className={linkStyles}>
|
||||
Terms of Service
|
||||
</Link>
|
||||
{" and "}
|
||||
<Link href="/privacy" className={linkStyles}>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type LegalAgreementNoticeProps = {
|
||||
action: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function LegalAgreementNotice({
|
||||
action,
|
||||
className,
|
||||
}: LegalAgreementNoticeProps) {
|
||||
return (
|
||||
<p className={cn("text-muted-foreground text-center text-xs", className)}>
|
||||
By {action}, you agree to our <LegalLinks />.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
MarketingFooter,
|
||||
MarketingHeader,
|
||||
MarketingPageShell,
|
||||
marketingSurfaceClass,
|
||||
} from "~/components/marketing/marketing-chrome";
|
||||
import { LEGAL_LAST_UPDATED } from "~/lib/legal";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { env } from "~/env";
|
||||
|
||||
type LegalPageShellProps = {
|
||||
title: string;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function LegalPageShell({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: LegalPageShellProps) {
|
||||
const allowRegistration = env.DISABLE_SIGNUPS !== true;
|
||||
|
||||
return (
|
||||
<MarketingPageShell>
|
||||
<MarketingHeader allowRegistration={allowRegistration} />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
marketingSurfaceClass,
|
||||
"mb-8 space-y-3 px-6 py-8 sm:px-8 sm:py-10",
|
||||
)}
|
||||
>
|
||||
<h1 className="font-heading text-3xl font-semibold tracking-tight sm:text-4xl">
|
||||
{title}
|
||||
</h1>
|
||||
{description ? (
|
||||
<p className="text-muted-foreground max-w-3xl text-base leading-7">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Last updated {LEGAL_LAST_UPDATED}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<main>{children}</main>
|
||||
|
||||
<MarketingFooter />
|
||||
</MarketingPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { LEGAL_PRIVACY_EMAIL, LEGAL_WEBSITE } from "~/lib/legal";
|
||||
import { brand } from "~/lib/branding";
|
||||
import {
|
||||
LegalDocument,
|
||||
LegalParagraph,
|
||||
type LegalSection,
|
||||
} from "~/components/legal/legal-document";
|
||||
|
||||
const sections: LegalSection[] = [
|
||||
{
|
||||
id: "introduction",
|
||||
title: "Introduction",
|
||||
children: (
|
||||
<>
|
||||
<LegalParagraph>
|
||||
This Privacy Policy explains how {brand.name} collects, uses, and
|
||||
protects information when you use our invoicing platform, including
|
||||
the web app and mobile app (the “Service”).
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
If you have questions about this policy, email us at{" "}
|
||||
<a href={`mailto:${LEGAL_PRIVACY_EMAIL}`}>{LEGAL_PRIVACY_EMAIL}</a>.
|
||||
</LegalParagraph>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "information-we-collect",
|
||||
title: "Information we collect",
|
||||
children: (
|
||||
<>
|
||||
<LegalParagraph>
|
||||
When you create an account and use the Service, you provide
|
||||
information such as your name, email address, business details, client
|
||||
records, invoice content, and time entries. This is the data you enter
|
||||
to run your invoicing workflow.
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
You may also add payment instructions that appear on invoices, such as
|
||||
bank transfer details. We do not process card payments on your behalf.
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
We also collect some technical information automatically so the
|
||||
Service stays secure and reliable. This can include your IP address,
|
||||
device and browser or app details, log and diagnostic data, and
|
||||
session cookies that keep you signed in. Some deployments may use
|
||||
optional, privacy-focused analytics.
|
||||
</LegalParagraph>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "how-we-use-information",
|
||||
title: "How we use information",
|
||||
children: (
|
||||
<>
|
||||
<LegalParagraph>
|
||||
We use your information to provide and operate the Service,
|
||||
authenticate your account, send transactional messages such as
|
||||
password resets, respond to support requests, monitor security and
|
||||
performance, and meet legal obligations.
|
||||
</LegalParagraph>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "how-we-share",
|
||||
title: "How we share information",
|
||||
children: (
|
||||
<>
|
||||
<LegalParagraph>
|
||||
We do not sell your personal information. We share it only when needed
|
||||
to run the Service or when the law requires it.
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
We work with service providers that host our infrastructure, deliver
|
||||
transactional email, support single sign-on when enabled on your
|
||||
instance, and optionally provide privacy-focused analytics. These
|
||||
vendors may process your information only to perform services for us.
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
We may disclose information if we believe it is reasonably necessary
|
||||
to comply with law, respond to a valid legal request, or protect the
|
||||
security and integrity of the Service.
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
If we are involved in a merger, acquisition, or sale of assets, your
|
||||
information may be transferred as part of that transaction, subject to
|
||||
continued protection consistent with this policy.
|
||||
</LegalParagraph>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "security-retention",
|
||||
title: "Security and retention",
|
||||
children: (
|
||||
<>
|
||||
<LegalParagraph>
|
||||
We use reasonable safeguards to protect information, including
|
||||
encryption in transit, access controls, and secure authentication. No
|
||||
method of transmission or storage is completely secure.
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
We retain information for as long as you have an account or as needed
|
||||
to provide the Service. We may keep certain records longer when
|
||||
required by law or for legitimate purposes such as fraud prevention or
|
||||
dispute resolution.
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
You can permanently delete your account from Settings in the mobile
|
||||
app or web app. Account deletion removes your account record and
|
||||
associated Service data, including invoices, clients, businesses,
|
||||
expenses, time entries, uploaded files, access keys, and active
|
||||
sessions. The action cannot be undone. Limited information may be
|
||||
retained only when required by law, and residual copies may remain in
|
||||
secure backups until those backups are overwritten through our normal
|
||||
retention cycle.
|
||||
</LegalParagraph>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "your-rights",
|
||||
title: "Your rights",
|
||||
children: (
|
||||
<>
|
||||
<LegalParagraph>
|
||||
Depending on where you live, you may have the right to access,
|
||||
correct, delete, or export your personal information, or to object to
|
||||
or restrict certain processing.
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
You can delete your account directly in Settings. To exercise another
|
||||
privacy right, or if you cannot access your account, contact us at{" "}
|
||||
<a href={`mailto:${LEGAL_PRIVACY_EMAIL}`}>{LEGAL_PRIVACY_EMAIL}</a>.
|
||||
We will respond within a reasonable timeframe and as required by
|
||||
applicable law.
|
||||
</LegalParagraph>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "cookies",
|
||||
title: "Cookies",
|
||||
children: (
|
||||
<>
|
||||
<LegalParagraph>
|
||||
We use cookies and similar technologies to keep you signed in,
|
||||
remember preferences such as theme, and, when enabled on a deployment,
|
||||
measure usage with privacy-focused analytics.
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
You can control cookies through your browser settings. If you disable
|
||||
essential cookies, some parts of the Service may not work correctly.
|
||||
</LegalParagraph>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "other",
|
||||
title: "Other disclosures",
|
||||
children: (
|
||||
<>
|
||||
<LegalParagraph>
|
||||
The Service may link to third-party websites or integrate with
|
||||
services you configure, such as single sign-on. Those services have
|
||||
their own privacy policies, and we are not responsible for their
|
||||
practices.
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
The Service is not intended for children under 13. If you believe a
|
||||
child has provided us personal information, contact us and we will
|
||||
delete it.
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
Your information may be processed in countries other than your own.
|
||||
Where required, we use appropriate safeguards for international
|
||||
transfers.
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
We may update this policy from time to time. If we make material
|
||||
changes, we will post the updated policy on the Service and may notify
|
||||
you by email. Continued use after changes take effect means you accept
|
||||
the updated policy.
|
||||
</LegalParagraph>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "contact",
|
||||
title: "Contact",
|
||||
children: (
|
||||
<>
|
||||
<LegalParagraph>
|
||||
For privacy questions or requests, email{" "}
|
||||
<a href={`mailto:${LEGAL_PRIVACY_EMAIL}`}>{LEGAL_PRIVACY_EMAIL}</a> or
|
||||
visit{" "}
|
||||
<a href={LEGAL_WEBSITE} target="_blank" rel="noopener noreferrer">
|
||||
{LEGAL_WEBSITE.replace(/^https?:\/\//, "")}
|
||||
</a>
|
||||
.
|
||||
</LegalParagraph>
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export function PrivacyPolicyContent() {
|
||||
return <LegalDocument sections={sections} />;
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import {
|
||||
LEGAL_PRIVACY_EMAIL,
|
||||
LEGAL_TERMS_EMAIL,
|
||||
LEGAL_WEBSITE,
|
||||
} from "~/lib/legal";
|
||||
import { brand } from "~/lib/branding";
|
||||
import {
|
||||
LegalDocument,
|
||||
LegalParagraph,
|
||||
type LegalSection,
|
||||
} from "~/components/legal/legal-document";
|
||||
|
||||
const sections: LegalSection[] = [
|
||||
{
|
||||
id: "agreement",
|
||||
title: "Agreement to these terms",
|
||||
children: (
|
||||
<>
|
||||
<LegalParagraph>
|
||||
These Terms of Service (“Terms”) govern your use of the {brand.name}{" "}
|
||||
platform, including the web app and mobile app (the “Service”).
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
By accessing or using the Service, you agree to these Terms. If you do
|
||||
not agree, do not use the Service.
|
||||
</LegalParagraph>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "service",
|
||||
title: "The Service",
|
||||
children: (
|
||||
<>
|
||||
<LegalParagraph>
|
||||
{brand.name} helps you create and manage invoices, track clients and
|
||||
businesses, record billable time, and review basic financial summaries.
|
||||
You may use the official hosted Service or connect the mobile app to a
|
||||
self-hosted {brand.name} server you control.
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
The Service is a tool for your business records. We do not provide
|
||||
legal, tax, or accounting advice, and you are responsible for the
|
||||
accuracy of invoices and compliance with laws that apply to you.
|
||||
</LegalParagraph>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "accounts",
|
||||
title: "Accounts and security",
|
||||
children: (
|
||||
<>
|
||||
<LegalParagraph>
|
||||
When you create an account, you agree to provide accurate information
|
||||
and keep it up to date. You are responsible for activity under your
|
||||
account and for keeping your credentials secure.
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
Notify us promptly if you suspect unauthorized access to your account.
|
||||
We may suspend or restrict access if we believe your account is
|
||||
compromised or used in violation of these Terms.
|
||||
</LegalParagraph>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "acceptable-use",
|
||||
title: "Acceptable use",
|
||||
children: (
|
||||
<>
|
||||
<LegalParagraph>
|
||||
You agree to use the Service lawfully and only for its intended
|
||||
purpose. You may not use the Service to break the law, infringe
|
||||
others’ rights, transmit malware, attempt to gain unauthorized access,
|
||||
interfere with the Service’s operation, or harass or harm others.
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
You may not use the Service to send spam, publish false or misleading
|
||||
information, or scrape or overload our systems without permission.
|
||||
</LegalParagraph>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "data-privacy",
|
||||
title: "Your data and privacy",
|
||||
children: (
|
||||
<>
|
||||
<LegalParagraph>
|
||||
Your privacy matters to us. Our{" "}
|
||||
<Link href="/privacy">Privacy Policy</Link> explains how we collect
|
||||
and use information and is incorporated into these Terms.
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
You retain ownership of the content you enter into the Service, such as
|
||||
clients, invoices, and time entries. We do not sell your personal
|
||||
information. We may process your data as described in the Privacy
|
||||
Policy to provide and secure the Service.
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
You are responsible for maintaining your own backups of important
|
||||
business records. While we take reasonable steps to protect data, you
|
||||
should not rely on the Service as your only copy of critical
|
||||
information.
|
||||
</LegalParagraph>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "fees",
|
||||
title: "Fees",
|
||||
children: (
|
||||
<>
|
||||
<LegalParagraph>
|
||||
Access to the Service may be offered without charge today, but we
|
||||
reserve the right to introduce fees for certain features or hosted
|
||||
plans in the future. If we do, we will provide reasonable notice
|
||||
before any new fees apply to you.
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
The mobile app does not offer in-app purchases. If you run a
|
||||
self-hosted instance, you are responsible for the costs and
|
||||
administration of that environment.
|
||||
</LegalParagraph>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "intellectual-property",
|
||||
title: "Intellectual property",
|
||||
children: (
|
||||
<>
|
||||
<LegalParagraph>
|
||||
The Service, including its software, design, and branding, is owned by{" "}
|
||||
{brand.name} and its licensors and is protected by applicable
|
||||
intellectual property laws.
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
You may not copy, modify, or reverse engineer the Service except where
|
||||
the law expressly allows. Our name and marks may not be used without
|
||||
our prior written permission.
|
||||
</LegalParagraph>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "termination",
|
||||
title: "Termination",
|
||||
children: (
|
||||
<>
|
||||
<LegalParagraph>
|
||||
You may stop using the Service at any time. You may also contact us to
|
||||
request account deletion.
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
We may suspend or terminate your access if you violate these Terms, if
|
||||
required by law, or if we discontinue the Service. Upon termination,
|
||||
your right to use the Service ends immediately, subject to any legal
|
||||
obligations that require us to retain certain data.
|
||||
</LegalParagraph>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "disclaimers",
|
||||
title: "Disclaimers and limitation of liability",
|
||||
children: (
|
||||
<>
|
||||
<LegalParagraph>
|
||||
The Service is provided “as is” and “as available.” To the fullest
|
||||
extent permitted by law, we disclaim warranties of merchantability,
|
||||
fitness for a particular purpose, and non-infringement. We do not
|
||||
guarantee that the Service will be uninterrupted or error-free.
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
To the fullest extent permitted by law, {brand.name} and its
|
||||
affiliates will not be liable for indirect, incidental, special,
|
||||
consequential, or punitive damages, or for loss of profits, data, or
|
||||
goodwill, arising from your use of the Service.
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
Nothing in these Terms limits liability that cannot be limited under
|
||||
applicable law, including liability for fraud or for death or personal
|
||||
injury caused by negligence.
|
||||
</LegalParagraph>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "general",
|
||||
title: "General",
|
||||
children: (
|
||||
<>
|
||||
<LegalParagraph>
|
||||
These Terms are governed by the laws applicable where {brand.name}{" "}
|
||||
operates, without regard to conflict-of-law rules. If we do not
|
||||
enforce a provision, that does not waive our right to enforce it
|
||||
later.
|
||||
</LegalParagraph>
|
||||
<LegalParagraph>
|
||||
We may update these Terms from time to time. If we make material
|
||||
changes, we will post the updated Terms on the Service and may notify
|
||||
you by email. Continued use after changes take effect means you accept
|
||||
the revised Terms.
|
||||
</LegalParagraph>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "contact",
|
||||
title: "Contact",
|
||||
children: (
|
||||
<>
|
||||
<LegalParagraph>
|
||||
Questions about these Terms:{" "}
|
||||
<a href={`mailto:${LEGAL_TERMS_EMAIL}`}>{LEGAL_TERMS_EMAIL}</a>.
|
||||
Privacy questions:{" "}
|
||||
<a href={`mailto:${LEGAL_PRIVACY_EMAIL}`}>{LEGAL_PRIVACY_EMAIL}</a>.
|
||||
Website:{" "}
|
||||
<a href={LEGAL_WEBSITE} target="_blank" rel="noopener noreferrer">
|
||||
{LEGAL_WEBSITE.replace(/^https?:\/\//, "")}
|
||||
</a>
|
||||
.
|
||||
</LegalParagraph>
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export function TermsOfServiceContent() {
|
||||
return <LegalDocument sections={sections} />;
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import {
|
||||
BarChart3,
|
||||
Clock,
|
||||
FileText,
|
||||
LayoutDashboard,
|
||||
Receipt,
|
||||
Settings,
|
||||
Timer,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { BrowserFrame } from "~/components/marketing/browser-frame";
|
||||
import { getAppHost } from "~/lib/app-url";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const appHost = getAppHost();
|
||||
|
||||
function MockSidebar({ active }: { active: "dashboard" | "invoices" | "time" }) {
|
||||
const items = [
|
||||
{ id: "dashboard" as const, label: "Dashboard", icon: LayoutDashboard },
|
||||
{ id: "invoices" as const, label: "Invoices", icon: FileText },
|
||||
{ id: "time" as const, label: "Time clock", icon: Timer },
|
||||
{ id: "clients" as const, label: "Clients", icon: Users },
|
||||
{ id: "expenses" as const, label: "Expenses", icon: Receipt },
|
||||
{ id: "reports" as const, label: "Reports", icon: BarChart3 },
|
||||
];
|
||||
|
||||
return (
|
||||
<aside className="bg-card/90 hidden w-36 shrink-0 border-r p-3 sm:block">
|
||||
<div className="text-primary mb-4 font-mono text-xs font-bold">$ beenvoice</div>
|
||||
<nav className="space-y-0.5">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = item.id === active;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-lg px-2 py-1.5 text-[10px] font-medium",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="text-muted-foreground mt-6 flex items-center gap-2 px-2 text-[10px]">
|
||||
<Settings className="h-3 w-3" />
|
||||
Settings
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({
|
||||
children,
|
||||
tone,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
tone: "draft" | "sent" | "paid" | "overdue";
|
||||
}) {
|
||||
const tones = {
|
||||
draft: "bg-muted text-muted-foreground",
|
||||
sent: "bg-blue-500/10 text-blue-700 dark:text-blue-300",
|
||||
paid: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||||
overdue: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex rounded-full px-2 py-0.5 text-[9px] font-medium",
|
||||
tones[tone],
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function InvoicesScreenshot({ className }: { className?: string }) {
|
||||
const rows = [
|
||||
{ client: "Northwind Studio", id: "INV-1042", amount: "$1,850.00", status: "sent" as const },
|
||||
{ client: "Harbor & Co.", id: "INV-1041", amount: "$640.00", status: "paid" as const },
|
||||
{ client: "Lumen Creative", id: "INV-1040", amount: "$2,100.00", status: "draft" as const },
|
||||
{ client: "Field Notes Ltd", id: "INV-1039", amount: "$420.00", status: "overdue" as const },
|
||||
];
|
||||
|
||||
return (
|
||||
<BrowserFrame className={className} url={`${appHost}/dashboard/invoices`}>
|
||||
<div className="flex min-h-[280px] sm:min-h-[320px]">
|
||||
<MockSidebar active="invoices" />
|
||||
<div className="min-w-0 flex-1 p-4 sm:p-5">
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-heading text-sm font-semibold sm:text-base">
|
||||
Invoices
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-0.5 text-[10px] sm:text-xs">
|
||||
Draft, send, and track what you're owed.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-primary text-primary-foreground rounded-lg px-2.5 py-1 text-[10px] font-medium">
|
||||
New invoice
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-card overflow-hidden rounded-xl border">
|
||||
<div className="text-muted-foreground grid grid-cols-[1fr_auto_auto] gap-2 border-b px-3 py-2 text-[9px] font-medium uppercase tracking-wide sm:grid-cols-[1.2fr_0.8fr_auto_auto] sm:px-4">
|
||||
<span>Client</span>
|
||||
<span className="hidden sm:block">Invoice</span>
|
||||
<span>Amount</span>
|
||||
<span>Status</span>
|
||||
</div>
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className="grid grid-cols-[1fr_auto_auto] items-center gap-2 border-b px-3 py-2.5 text-[10px] last:border-0 sm:grid-cols-[1.2fr_0.8fr_auto_auto] sm:px-4 sm:text-xs"
|
||||
>
|
||||
<span className="truncate font-medium">{row.client}</span>
|
||||
<span className="text-muted-foreground hidden sm:block">{row.id}</span>
|
||||
<span className="tabular-nums">{row.amount}</span>
|
||||
<StatusBadge tone={row.status}>
|
||||
{row.status.charAt(0).toUpperCase() + row.status.slice(1)}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BrowserFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimeClockScreenshot({ className }: { className?: string }) {
|
||||
return (
|
||||
<BrowserFrame className={className} url={`${appHost}/dashboard/time-clock`}>
|
||||
<div className="flex min-h-[260px] sm:min-h-[300px]">
|
||||
<MockSidebar active="time" />
|
||||
<div className="min-w-0 flex-1 p-4 sm:p-5">
|
||||
<div className="mb-4">
|
||||
<h3 className="font-heading text-sm font-semibold sm:text-base">
|
||||
Time clock
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-0.5 text-[10px] sm:text-xs">
|
||||
Track billable hours and roll them into invoices.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="bg-card rounded-xl border p-4">
|
||||
<div className="text-muted-foreground mb-2 flex items-center gap-1.5 text-[10px] font-medium">
|
||||
<Timer className="h-3 w-3" />
|
||||
Active session
|
||||
</div>
|
||||
<div className="font-heading text-2xl font-semibold tabular-nums sm:text-3xl">
|
||||
02:14:38
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||
Brand refresh — Northwind Studio
|
||||
</p>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<div className="bg-primary text-primary-foreground rounded-lg px-3 py-1.5 text-[10px] font-medium">
|
||||
Stop
|
||||
</div>
|
||||
<div className="border-border rounded-lg border px-3 py-1.5 text-[10px] font-medium">
|
||||
Pause
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-card rounded-xl border p-4">
|
||||
<div className="text-muted-foreground mb-3 flex items-center gap-1.5 text-[10px] font-medium">
|
||||
<Clock className="h-3 w-3" />
|
||||
Recent entries
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
{[
|
||||
{ label: "Wireframes", time: "1h 20m", client: "Harbor & Co." },
|
||||
{ label: "Copy edits", time: "45m", client: "Lumen Creative" },
|
||||
{ label: "Kickoff call", time: "30m", client: "Field Notes Ltd" },
|
||||
].map((entry) => (
|
||||
<div
|
||||
key={entry.label}
|
||||
className="flex items-center justify-between gap-2 text-[10px] sm:text-xs"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{entry.label}</p>
|
||||
<p className="text-muted-foreground truncate">{entry.client}</p>
|
||||
</div>
|
||||
<span className="text-muted-foreground shrink-0 tabular-nums">
|
||||
{entry.time}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BrowserFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardScreenshot({ className }: { className?: string }) {
|
||||
return (
|
||||
<BrowserFrame className={className} url={`${appHost}/dashboard`}>
|
||||
<div className="flex min-h-[260px] sm:min-h-[300px]">
|
||||
<MockSidebar active="dashboard" />
|
||||
<div className="min-w-0 flex-1 p-4 sm:p-5">
|
||||
<div className="mb-4">
|
||||
<h3 className="font-heading text-sm font-semibold sm:text-base">
|
||||
Good afternoon
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-0.5 text-[10px] sm:text-xs">
|
||||
Here's what needs your attention.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="bg-card rounded-xl border p-4">
|
||||
<p className="text-muted-foreground text-[10px] font-medium uppercase tracking-wide">
|
||||
Awaiting payment
|
||||
</p>
|
||||
<p className="font-heading mt-2 text-lg font-semibold">3 invoices</p>
|
||||
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||
Follow up on sent invoices when you're ready.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-card rounded-xl border p-4">
|
||||
<p className="text-muted-foreground text-[10px] font-medium uppercase tracking-wide">
|
||||
Timer running
|
||||
</p>
|
||||
<p className="font-heading mt-2 text-lg font-semibold tabular-nums">
|
||||
02:14:38
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||
Northwind Studio · Brand refresh
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-card mt-3 rounded-xl border p-4">
|
||||
<p className="mb-3 text-[10px] font-medium">Recent activity</p>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
"Invoice INV-1042 sent to Northwind Studio",
|
||||
"Timer started for Brand refresh",
|
||||
"Client Harbor & Co. updated",
|
||||
].map((line) => (
|
||||
<div
|
||||
key={line}
|
||||
className="text-muted-foreground flex items-center gap-2 text-[10px] sm:text-xs"
|
||||
>
|
||||
<span className="bg-primary/60 h-1.5 w-1.5 shrink-0 rounded-full" />
|
||||
<span className="truncate">{line}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BrowserFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { getAppHost } from "~/lib/app-url";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
export function BrowserFrame({
|
||||
children,
|
||||
className,
|
||||
url = `${getAppHost()}/dashboard`,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
url?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/80 bg-card overflow-hidden rounded-2xl border shadow-2xl shadow-black/8 ring-1 ring-black/5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="bg-muted/50 flex items-center gap-3 border-b px-4 py-2.5">
|
||||
<div className="flex gap-1.5">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[#ff5f57]" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[#febc2e]" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[#28c840]" />
|
||||
</div>
|
||||
<div className="bg-background/70 text-muted-foreground mx-auto flex h-7 w-full max-w-sm items-center justify-center rounded-lg px-3 text-[11px] tracking-wide">
|
||||
{url}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-dashboard overflow-hidden">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowRight,
|
||||
Clock,
|
||||
FileText,
|
||||
Mail,
|
||||
Receipt,
|
||||
Repeat,
|
||||
Timer,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DashboardScreenshot,
|
||||
InvoicesScreenshot,
|
||||
TimeClockScreenshot,
|
||||
} from "~/components/marketing/app-screenshots";
|
||||
import {
|
||||
MarketingFooter,
|
||||
MarketingHeader,
|
||||
MarketingPageShell,
|
||||
marketingSurfaceClass,
|
||||
} from "~/components/marketing/marketing-chrome";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { brand } from "~/lib/branding";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: FileText,
|
||||
title: "Invoices that stay organized",
|
||||
description:
|
||||
"Draft line items, apply taxes, send a link, and export a polished PDF when you need a file.",
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: "Clients and businesses",
|
||||
description:
|
||||
"Keep the people and companies you bill in one place, with the details you reuse on every invoice.",
|
||||
},
|
||||
{
|
||||
icon: Timer,
|
||||
title: "Built-in time clock",
|
||||
description:
|
||||
"Track billable hours as you work, then pull them straight into an invoice without retyping.",
|
||||
},
|
||||
{
|
||||
icon: Repeat,
|
||||
title: "Recurring invoices",
|
||||
description:
|
||||
"Set up retainers and subscriptions once, then let beenvoice generate the next invoice on schedule.",
|
||||
},
|
||||
{
|
||||
icon: Receipt,
|
||||
title: "Expenses",
|
||||
description:
|
||||
"Log costs alongside your work so nothing gets lost before you bill it out.",
|
||||
},
|
||||
{
|
||||
icon: Mail,
|
||||
title: "Send and follow up",
|
||||
description:
|
||||
"Email invoices from the app and keep status visible from draft through paid.",
|
||||
},
|
||||
];
|
||||
|
||||
function FeatureRow({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
reverse = false,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
children: React.ReactNode;
|
||||
reverse?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid items-center gap-10 lg:grid-cols-2 lg:gap-16",
|
||||
reverse && "lg:[&>*:first-child]:order-2",
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-muted-foreground max-w-lg text-base leading-7">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LandingPage({ allowRegistration }: { allowRegistration: boolean }) {
|
||||
return (
|
||||
<MarketingPageShell>
|
||||
<MarketingHeader allowRegistration={allowRegistration} />
|
||||
|
||||
<section className="pb-16 sm:pb-20 lg:pb-24">
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<p className="text-primary mb-4 text-sm font-medium tracking-wide uppercase">
|
||||
Personal invoicing workspace
|
||||
</p>
|
||||
<h1 className="font-heading text-4xl leading-[1.1] font-bold tracking-tight sm:text-5xl lg:text-6xl">
|
||||
Run your freelance admin from one place.
|
||||
</h1>
|
||||
<p className="text-muted-foreground mx-auto mt-5 max-w-2xl text-base leading-7 sm:text-lg">
|
||||
{brand.name} helps you manage clients, track time, send invoices,
|
||||
and stay on top of getting paid — without the weight of a full
|
||||
accounting suite.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
|
||||
<Link href="/auth/signin">
|
||||
<Button size="lg" className="h-11 px-6">
|
||||
Open workspace
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
{allowRegistration && (
|
||||
<Link href="/auth/register">
|
||||
<Button variant="outline" size="lg" className="h-11 px-6">
|
||||
Create account
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto mt-14 max-w-5xl lg:mt-16">
|
||||
<InvoicesScreenshot className="w-full" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="border-border/50 border-t py-16 sm:py-20">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<h2 className="font-heading text-3xl font-semibold tracking-tight sm:text-4xl">
|
||||
Everything around getting paid, in one flow
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-4 text-base leading-7">
|
||||
{brand.tagline}. Built for one person doing real client work — not
|
||||
enterprise dashboards you'll never open.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{features.map((feature) => {
|
||||
const Icon = feature.icon;
|
||||
return (
|
||||
<div
|
||||
key={feature.title}
|
||||
className="bg-card/70 hover:bg-card/90 border-border/60 rounded-2xl border p-5 backdrop-blur-sm transition-colors"
|
||||
>
|
||||
<div className="bg-primary/10 text-primary mb-4 inline-flex rounded-xl p-2.5">
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold sm:text-base">
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-2 text-sm leading-6">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="border-border/50 space-y-20 border-t py-16 sm:space-y-24 sm:py-20">
|
||||
<FeatureRow
|
||||
title="See your week at a glance"
|
||||
description="Open the dashboard to check what's waiting on payment, whether a timer is still running, and what changed recently — without wading through reports."
|
||||
>
|
||||
<DashboardScreenshot />
|
||||
</FeatureRow>
|
||||
|
||||
<FeatureRow
|
||||
title="Track time where you already work"
|
||||
description="Start a timer for the client and project you're on. When the work is done, turn those hours into invoice line items in a few clicks."
|
||||
reverse
|
||||
>
|
||||
<TimeClockScreenshot />
|
||||
</FeatureRow>
|
||||
|
||||
<FeatureRow
|
||||
title="Invoices that look professional"
|
||||
description="Clean layouts, PDF export, and a shareable link for clients. Mark invoices sent or paid as your pipeline moves."
|
||||
>
|
||||
<InvoicesScreenshot />
|
||||
</FeatureRow>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div
|
||||
className={cn(
|
||||
marketingSurfaceClass,
|
||||
"bg-card/80 relative overflow-hidden px-6 py-10 text-center sm:px-10 sm:py-12",
|
||||
)}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="bg-primary/10 text-primary mx-auto mb-4 inline-flex rounded-full p-3">
|
||||
<Clock className="h-5 w-5" />
|
||||
</div>
|
||||
<h2 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||
Ready to simplify your invoicing?
|
||||
</h2>
|
||||
<p className="text-muted-foreground mx-auto mt-3 max-w-xl text-sm leading-6 sm:text-base">
|
||||
Sign in to your workspace or create an account to start with
|
||||
clients, invoices, and time tracking in minutes.
|
||||
</p>
|
||||
<div className="mt-6 flex flex-col items-center justify-center gap-3 sm:flex-row">
|
||||
<Link href="/auth/signin">
|
||||
<Button size="lg" className="h-11 px-6">
|
||||
Open workspace
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
{allowRegistration && (
|
||||
<Link href="/auth/register">
|
||||
<Button variant="outline" size="lg" className="h-11 px-6">
|
||||
Create account
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<MarketingFooter />
|
||||
</MarketingPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import Link from "next/link";
|
||||
import { Logo } from "~/components/branding/logo";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { brand } from "~/lib/branding";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
export const marketingSurfaceClass =
|
||||
"border-border/50 bg-background/80 rounded-3xl border shadow-xl backdrop-blur-xl";
|
||||
|
||||
export function MarketingPageShell({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-dashboard text-foreground relative min-h-screen">
|
||||
<div className="mx-auto w-full max-w-6xl px-5 pt-4 pb-6 sm:px-6 sm:pt-5 sm:pb-8 lg:px-8">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MarketingHeader({
|
||||
allowRegistration = true,
|
||||
sticky = true,
|
||||
}: {
|
||||
allowRegistration?: boolean;
|
||||
sticky?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
marketingSurfaceClass,
|
||||
"mb-8 flex items-center justify-between gap-4 px-4 py-3 sm:px-5",
|
||||
sticky && "sticky top-4 z-20 sm:top-5",
|
||||
)}
|
||||
>
|
||||
<Link href="/">
|
||||
<Logo animated={false} />
|
||||
</Link>
|
||||
<nav className="flex items-center gap-2">
|
||||
<Link href="/auth/signin">
|
||||
<Button variant="ghost" size="sm">
|
||||
Sign in
|
||||
</Button>
|
||||
</Link>
|
||||
{allowRegistration && (
|
||||
<Link href="/auth/register">
|
||||
<Button size="sm">Create account</Button>
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function MarketingFooter({ className }: { className?: string }) {
|
||||
return (
|
||||
<footer
|
||||
className={cn(
|
||||
marketingSurfaceClass,
|
||||
"text-muted-foreground mt-8 flex flex-col gap-3 px-4 py-4 text-sm sm:flex-row sm:items-center sm:justify-between sm:px-5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span>© 2026 {brand.name}</span>
|
||||
<div className="flex gap-5">
|
||||
<Link
|
||||
href="/privacy"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
<Link
|
||||
href="/terms"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Terms of Service
|
||||
</Link>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
export function Breadcrumbs() {
|
||||
const pathname = usePathname();
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const crumbs = [
|
||||
{ name: "Dashboard", href: "/dashboard" },
|
||||
...segments.slice(1).map((seg, i) => ({
|
||||
name: seg.charAt(0).toUpperCase() + seg.slice(1),
|
||||
href: "/dashboard/" + segments.slice(1, i + 2).join("/"),
|
||||
})),
|
||||
];
|
||||
return (
|
||||
<nav
|
||||
className="text-muted-foreground flex items-center text-sm"
|
||||
aria-label="Breadcrumb"
|
||||
>
|
||||
{crumbs.map((crumb, i) => (
|
||||
<span key={crumb.href} className="flex items-center">
|
||||
{i > 0 && <ChevronRight className="mx-2 h-4 w-4 text-gray-300" />}
|
||||
{i < crumbs.length - 1 ? (
|
||||
<Link href={crumb.href} className="text-gray-500 hover:underline">
|
||||
{crumb.name}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-medium text-gray-700">{crumb.name}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import React from "react";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "~/components/ui/breadcrumb";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
import { getRouteLabel } from "~/lib/pluralize";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
function isUUID(str: string) {
|
||||
return /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(
|
||||
str,
|
||||
);
|
||||
}
|
||||
|
||||
// Special segment labels
|
||||
const SPECIAL_SEGMENTS: Record<string, string> = {
|
||||
new: "New",
|
||||
edit: "Edit",
|
||||
import: "Import",
|
||||
export: "Export",
|
||||
dashboard: "Dashboard",
|
||||
entries: "All entries",
|
||||
"time-clock": "Time clock",
|
||||
};
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
export function DashboardBreadcrumbs({ className }: { className?: string }) {
|
||||
const pathname = usePathname();
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
|
||||
// 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: resourceId ?? "" },
|
||||
{ enabled: resourceType === "clients" && !!resourceId },
|
||||
);
|
||||
|
||||
// Fetch invoice data if needed
|
||||
const { data: invoice, isLoading: invoiceLoading } =
|
||||
api.invoices.getById.useQuery(
|
||||
{ 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 = "";
|
||||
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) {
|
||||
label = 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [
|
||||
segments,
|
||||
client,
|
||||
invoice,
|
||||
business,
|
||||
clientLoading,
|
||||
invoiceLoading,
|
||||
businessLoading,
|
||||
resourceId,
|
||||
]);
|
||||
|
||||
if (breadcrumbs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Breadcrumb className={cn("mb-4 sm:mb-6", className)}>
|
||||
<BreadcrumbList className="flex-nowrap overflow-hidden">
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="truncate text-sm sm:text-base dark:text-gray-300"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<React.Fragment key={`${crumb.href}-${index}`}>
|
||||
<BreadcrumbSeparator>
|
||||
<ChevronRight className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
</BreadcrumbSeparator>
|
||||
<BreadcrumbItem>
|
||||
{crumb.isLast ? (
|
||||
<BreadcrumbPage className="truncate text-sm sm:text-base dark:text-white">
|
||||
{crumb.label}
|
||||
</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink asChild>
|
||||
<Link
|
||||
href={crumb.href}
|
||||
className="truncate text-sm sm:text-base dark:text-gray-300"
|
||||
>
|
||||
{crumb.label}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const navigation = [
|
||||
{ name: "Dashboard", href: "/dashboard" },
|
||||
{ name: "Clients", href: "/dashboard/clients" },
|
||||
{ name: "Invoices", href: "/dashboard/invoices" },
|
||||
];
|
||||
|
||||
export function Navigation() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav className="flex space-x-2">
|
||||
{navigation.map((item) => (
|
||||
<Link key={item.name} href={item.href}>
|
||||
<Button
|
||||
variant={pathname === item.href ? "default" : "ghost"}
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
pathname === item.href
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
</Button>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { MenuIcon, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
import { useAuthSession } from "~/hooks/use-auth-session";
|
||||
import { getNavigationForUser, isNavLinkActive } from "~/lib/navigation";
|
||||
import { useDashboardUser } from "~/components/layout/dashboard-user-context";
|
||||
|
||||
interface SidebarTriggerProps {
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
|
||||
const pathname = usePathname();
|
||||
const { isPending } = useAuthSession();
|
||||
const { isAdmin } = useDashboardUser();
|
||||
const navSections = getNavigationForUser(isAdmin);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Toggle navigation"
|
||||
onClick={onToggle}
|
||||
className="h-8 w-8 md:hidden"
|
||||
>
|
||||
{isOpen ? <X className="h-4 w-4" /> : <MenuIcon className="h-4 w-4" />}
|
||||
</Button>
|
||||
|
||||
{/* Mobile dropdown navigation */}
|
||||
{isOpen && (
|
||||
<div className="bg-background border-border absolute top-full right-0 left-0 z-40 mt-1 border-t">
|
||||
{/* Navigation content */}
|
||||
<nav className="flex flex-col p-4">
|
||||
{navSections.map((section, sectionIndex) => (
|
||||
<div
|
||||
key={section.title}
|
||||
className={sectionIndex > 0 ? "mt-4" : ""}
|
||||
>
|
||||
{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">
|
||||
{isPending ? (
|
||||
<>
|
||||
{Array.from({ length: section.links.length }).map(
|
||||
(_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-3 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={
|
||||
isNavLinkActive(pathname, link.href) ? "page" : undefined
|
||||
}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 text-sm font-medium transition-colors ${
|
||||
isNavLinkActive(pathname, link.href)
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-muted"
|
||||
}`}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{link.name}
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import {
|
||||
AnimationPreferencesContext,
|
||||
DEFAULT_PREFERS_REDUCED,
|
||||
DEFAULT_SPEED,
|
||||
applyPreferencesToDOM,
|
||||
clampSpeed,
|
||||
readLocalStorage,
|
||||
writeLocalStorage,
|
||||
type AnimationPreferencesContextValue,
|
||||
type AnimationPreferencesProviderProps,
|
||||
} from "~/components/providers/animation-preferences-provider";
|
||||
|
||||
type PartialPrefs = {
|
||||
prefersReducedMotion?: boolean;
|
||||
animationSpeedMultiplier?: number;
|
||||
};
|
||||
|
||||
/** Dashboard animation preferences with tRPC sync. Must render inside TRPCReactProvider. */
|
||||
export function AnimationPreferencesProviderSynced({
|
||||
children,
|
||||
initial,
|
||||
autoSync = true,
|
||||
}: AnimationPreferencesProviderProps & { autoSync?: boolean }) {
|
||||
const updateMutation = api.settings.updateAnimationPreferences.useMutation();
|
||||
|
||||
const { data: serverPrefs } = api.settings.getAnimationPreferences.useQuery(
|
||||
undefined,
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
},
|
||||
);
|
||||
|
||||
const [prefersReducedMotion, setPrefersReducedMotion] = useState<boolean>(
|
||||
initial?.prefersReducedMotion ?? DEFAULT_PREFERS_REDUCED,
|
||||
);
|
||||
const [animationSpeedMultiplier, setAnimationSpeedMultiplier] =
|
||||
useState<number>(
|
||||
clampSpeed(initial?.animationSpeedMultiplier ?? DEFAULT_SPEED),
|
||||
);
|
||||
const [lastSyncedAt, setLastSyncedAt] = useState<number | null>(null);
|
||||
const pendingSyncRef = useRef<PartialPrefs | null>(null);
|
||||
const isHydratedRef = useRef(false);
|
||||
const serverHydratedRef = useRef(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const stored = readLocalStorage();
|
||||
|
||||
const systemReduced = window.matchMedia?.(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
).matches;
|
||||
|
||||
const finalPrefers =
|
||||
stored?.prefersReducedMotion ??
|
||||
initial?.prefersReducedMotion ??
|
||||
systemReduced ??
|
||||
DEFAULT_PREFERS_REDUCED;
|
||||
const finalSpeed = clampSpeed(
|
||||
stored?.animationSpeedMultiplier ??
|
||||
initial?.animationSpeedMultiplier ??
|
||||
DEFAULT_SPEED,
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setPrefersReducedMotion(finalPrefers);
|
||||
setAnimationSpeedMultiplier(finalSpeed);
|
||||
applyPreferencesToDOM({
|
||||
prefersReducedMotion: finalPrefers,
|
||||
animationSpeedMultiplier: finalSpeed,
|
||||
});
|
||||
isHydratedRef.current = true;
|
||||
}, [initial?.prefersReducedMotion, initial?.animationSpeedMultiplier]);
|
||||
|
||||
const performUpdate = useCallback(
|
||||
(patch: PartialPrefs, opts?: { sync?: boolean }) => {
|
||||
setIsUpdating(true);
|
||||
setPrefersReducedMotion((prev) => patch.prefersReducedMotion ?? prev);
|
||||
setAnimationSpeedMultiplier((prev) =>
|
||||
clampSpeed(patch.animationSpeedMultiplier ?? prev),
|
||||
);
|
||||
|
||||
const normalizedPatch: PartialPrefs = { ...patch };
|
||||
|
||||
if (
|
||||
normalizedPatch.prefersReducedMotion === true &&
|
||||
normalizedPatch.animationSpeedMultiplier === undefined &&
|
||||
animationSpeedMultiplier !== 1
|
||||
) {
|
||||
normalizedPatch.animationSpeedMultiplier = 1;
|
||||
}
|
||||
|
||||
const nextReduced =
|
||||
normalizedPatch.prefersReducedMotion ?? prefersReducedMotion;
|
||||
|
||||
let nextSpeed = clampSpeed(
|
||||
normalizedPatch.animationSpeedMultiplier ?? animationSpeedMultiplier,
|
||||
);
|
||||
|
||||
if (nextReduced && nextSpeed !== 1) {
|
||||
nextSpeed = 1;
|
||||
normalizedPatch.animationSpeedMultiplier ??= 1;
|
||||
}
|
||||
|
||||
const newPrefs = {
|
||||
prefersReducedMotion: nextReduced,
|
||||
animationSpeedMultiplier: nextSpeed,
|
||||
};
|
||||
|
||||
applyPreferencesToDOM(newPrefs);
|
||||
writeLocalStorage(newPrefs);
|
||||
|
||||
const shouldSync = opts?.sync ?? autoSync;
|
||||
|
||||
if (shouldSync && serverPrefs) {
|
||||
pendingSyncRef.current = {
|
||||
prefersReducedMotion: patch.prefersReducedMotion,
|
||||
animationSpeedMultiplier: patch.animationSpeedMultiplier,
|
||||
};
|
||||
updateMutation.mutate(
|
||||
{
|
||||
...(normalizedPatch.prefersReducedMotion !== undefined && {
|
||||
prefersReducedMotion: normalizedPatch.prefersReducedMotion,
|
||||
}),
|
||||
...(normalizedPatch.animationSpeedMultiplier !== undefined && {
|
||||
animationSpeedMultiplier: clampSpeed(
|
||||
normalizedPatch.animationSpeedMultiplier,
|
||||
),
|
||||
}),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setLastSyncedAt(Date.now());
|
||||
pendingSyncRef.current = null;
|
||||
setIsUpdating(false);
|
||||
},
|
||||
onError: () => {
|
||||
setIsUpdating(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
prefersReducedMotion,
|
||||
animationSpeedMultiplier,
|
||||
autoSync,
|
||||
updateMutation,
|
||||
serverPrefs,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isHydratedRef.current) return;
|
||||
if (serverHydratedRef.current) return;
|
||||
if (!serverPrefs) return;
|
||||
|
||||
const localIsDefault =
|
||||
prefersReducedMotion === DEFAULT_PREFERS_REDUCED &&
|
||||
animationSpeedMultiplier === DEFAULT_SPEED;
|
||||
|
||||
const differs =
|
||||
serverPrefs.prefersReducedMotion !== prefersReducedMotion ||
|
||||
serverPrefs.animationSpeedMultiplier !== animationSpeedMultiplier;
|
||||
|
||||
if (localIsDefault || differs) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- one-time server hydration after local storage
|
||||
performUpdate(
|
||||
{
|
||||
prefersReducedMotion: serverPrefs.prefersReducedMotion,
|
||||
animationSpeedMultiplier: serverPrefs.animationSpeedMultiplier,
|
||||
},
|
||||
{ sync: false },
|
||||
);
|
||||
}
|
||||
serverHydratedRef.current = true;
|
||||
// One-time hydration from server after local storage is read.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [serverPrefs]);
|
||||
|
||||
const updatePreferences = useCallback<
|
||||
AnimationPreferencesContextValue["updatePreferences"]
|
||||
>(
|
||||
(patch, opts) => {
|
||||
performUpdate(patch, opts);
|
||||
},
|
||||
[performUpdate],
|
||||
);
|
||||
|
||||
const handleSetReduced = useCallback(
|
||||
(val: boolean) => {
|
||||
updatePreferences({ prefersReducedMotion: val });
|
||||
},
|
||||
[updatePreferences],
|
||||
);
|
||||
|
||||
const handleSetSpeed = useCallback(
|
||||
(val: number) => {
|
||||
updatePreferences({ animationSpeedMultiplier: clampSpeed(val) });
|
||||
},
|
||||
[updatePreferences],
|
||||
);
|
||||
|
||||
const value: AnimationPreferencesContextValue = {
|
||||
prefersReducedMotion,
|
||||
animationSpeedMultiplier,
|
||||
updatePreferences,
|
||||
setPrefersReducedMotion: handleSetReduced,
|
||||
setAnimationSpeedMultiplier: handleSetSpeed,
|
||||
isUpdating: isUpdating || updateMutation.isPending,
|
||||
lastSyncedAt,
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimationPreferencesContext.Provider value={value}>
|
||||
{children}
|
||||
</AnimationPreferencesContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
type AnimationPreferences = {
|
||||
prefersReducedMotion: boolean;
|
||||
animationSpeedMultiplier: number;
|
||||
};
|
||||
|
||||
type PartialPrefs = Partial<AnimationPreferences>;
|
||||
|
||||
export interface AnimationPreferencesContextValue extends AnimationPreferences {
|
||||
updatePreferences: (patch: PartialPrefs, opts?: { sync?: boolean }) => void;
|
||||
setPrefersReducedMotion: (val: boolean) => void;
|
||||
setAnimationSpeedMultiplier: (val: number) => void;
|
||||
isUpdating: boolean;
|
||||
lastSyncedAt: number | null;
|
||||
}
|
||||
|
||||
export interface AnimationPreferencesProviderProps {
|
||||
children: React.ReactNode;
|
||||
initial?: PartialPrefs;
|
||||
}
|
||||
|
||||
export const STORAGE_KEY = "bv.animation.prefs";
|
||||
export const MIN_SPEED = 0.25;
|
||||
export const MAX_SPEED = 4;
|
||||
export const DEFAULT_SPEED = 1;
|
||||
export const DEFAULT_PREFERS_REDUCED = false;
|
||||
|
||||
export const AnimationPreferencesContext =
|
||||
createContext<AnimationPreferencesContextValue | null>(null);
|
||||
|
||||
export function clampSpeed(value: number): number {
|
||||
if (Number.isNaN(value)) return DEFAULT_SPEED;
|
||||
return Math.min(MAX_SPEED, Math.max(MIN_SPEED, value));
|
||||
}
|
||||
|
||||
export function readLocalStorage(): PartialPrefs | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as PartialPrefs;
|
||||
if (
|
||||
typeof parsed === "object" &&
|
||||
parsed !== null &&
|
||||
("prefersReducedMotion" in parsed || "animationSpeedMultiplier" in parsed)
|
||||
) {
|
||||
return {
|
||||
prefersReducedMotion:
|
||||
typeof parsed.prefersReducedMotion === "boolean"
|
||||
? parsed.prefersReducedMotion
|
||||
: undefined,
|
||||
animationSpeedMultiplier:
|
||||
typeof parsed.animationSpeedMultiplier === "number"
|
||||
? clampSpeed(parsed.animationSpeedMultiplier)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLocalStorage(prefs: AnimationPreferences) {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
prefersReducedMotion: prefs.prefersReducedMotion,
|
||||
animationSpeedMultiplier: prefs.animationSpeedMultiplier,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// Fail silently; storage may be unavailable
|
||||
}
|
||||
}
|
||||
|
||||
export function applyPreferencesToDOM(prefs: AnimationPreferences) {
|
||||
if (typeof document === "undefined") return;
|
||||
const root = document.documentElement;
|
||||
|
||||
if (prefs.prefersReducedMotion) {
|
||||
root.classList.add("user-reduce-motion");
|
||||
} else {
|
||||
root.classList.remove("user-reduce-motion");
|
||||
}
|
||||
|
||||
const multiplier = prefs.animationSpeedMultiplier || 1;
|
||||
|
||||
const fast = prefs.prefersReducedMotion
|
||||
? 0.01
|
||||
: parseFloat((0.15 / multiplier).toFixed(4));
|
||||
const normal = prefs.prefersReducedMotion
|
||||
? 0.01
|
||||
: parseFloat((0.3 / multiplier).toFixed(4));
|
||||
const slow = prefs.prefersReducedMotion
|
||||
? 0.01
|
||||
: parseFloat((0.5 / multiplier).toFixed(4));
|
||||
|
||||
root.style.setProperty("--animation-speed-fast", `${fast}s`);
|
||||
root.style.setProperty("--animation-speed-normal", `${normal}s`);
|
||||
root.style.setProperty("--animation-speed-slow", `${slow}s`);
|
||||
}
|
||||
|
||||
/** Local-only animation preferences for marketing and auth pages (no tRPC). */
|
||||
export function AnimationPreferencesProvider({
|
||||
children,
|
||||
initial,
|
||||
}: AnimationPreferencesProviderProps) {
|
||||
const [prefersReducedMotion, setPrefersReducedMotion] = useState<boolean>(
|
||||
initial?.prefersReducedMotion ?? DEFAULT_PREFERS_REDUCED,
|
||||
);
|
||||
const [animationSpeedMultiplier, setAnimationSpeedMultiplier] =
|
||||
useState<number>(
|
||||
clampSpeed(initial?.animationSpeedMultiplier ?? DEFAULT_SPEED),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const stored = readLocalStorage();
|
||||
const systemReduced = window.matchMedia?.(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
).matches;
|
||||
|
||||
const finalPrefers =
|
||||
stored?.prefersReducedMotion ??
|
||||
initial?.prefersReducedMotion ??
|
||||
systemReduced ??
|
||||
DEFAULT_PREFERS_REDUCED;
|
||||
const finalSpeed = clampSpeed(
|
||||
stored?.animationSpeedMultiplier ??
|
||||
initial?.animationSpeedMultiplier ??
|
||||
DEFAULT_SPEED,
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setPrefersReducedMotion(finalPrefers);
|
||||
setAnimationSpeedMultiplier(finalSpeed);
|
||||
applyPreferencesToDOM({
|
||||
prefersReducedMotion: finalPrefers,
|
||||
animationSpeedMultiplier: finalSpeed,
|
||||
});
|
||||
}, [initial?.prefersReducedMotion, initial?.animationSpeedMultiplier]);
|
||||
|
||||
const updatePreferences = useCallback<
|
||||
AnimationPreferencesContextValue["updatePreferences"]
|
||||
>((patch) => {
|
||||
const nextReduced = patch.prefersReducedMotion ?? prefersReducedMotion;
|
||||
let nextSpeed = clampSpeed(
|
||||
patch.animationSpeedMultiplier ?? animationSpeedMultiplier,
|
||||
);
|
||||
if (nextReduced && nextSpeed !== 1) {
|
||||
nextSpeed = 1;
|
||||
}
|
||||
|
||||
setPrefersReducedMotion(nextReduced);
|
||||
setAnimationSpeedMultiplier(nextSpeed);
|
||||
applyPreferencesToDOM({
|
||||
prefersReducedMotion: nextReduced,
|
||||
animationSpeedMultiplier: nextSpeed,
|
||||
});
|
||||
writeLocalStorage({
|
||||
prefersReducedMotion: nextReduced,
|
||||
animationSpeedMultiplier: nextSpeed,
|
||||
});
|
||||
}, [prefersReducedMotion, animationSpeedMultiplier]);
|
||||
|
||||
const value: AnimationPreferencesContextValue = {
|
||||
prefersReducedMotion,
|
||||
animationSpeedMultiplier,
|
||||
updatePreferences,
|
||||
setPrefersReducedMotion: (val) => updatePreferences({ prefersReducedMotion: val }),
|
||||
setAnimationSpeedMultiplier: (val) =>
|
||||
updatePreferences({ animationSpeedMultiplier: clampSpeed(val) }),
|
||||
isUpdating: false,
|
||||
lastSyncedAt: null,
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimationPreferencesContext.Provider value={value}>
|
||||
{children}
|
||||
</AnimationPreferencesContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAnimationPreferences(): AnimationPreferencesContextValue {
|
||||
const ctx = useContext(AnimationPreferencesContext);
|
||||
if (!ctx) {
|
||||
console.warn("useAnimationPreferences used without provider");
|
||||
return {
|
||||
prefersReducedMotion: false,
|
||||
animationSpeedMultiplier: 1,
|
||||
updatePreferences: () => {
|
||||
/* no-op fallback */
|
||||
},
|
||||
setPrefersReducedMotion: () => {
|
||||
/* no-op fallback */
|
||||
},
|
||||
setAnimationSpeedMultiplier: () => {
|
||||
/* no-op fallback */
|
||||
},
|
||||
isUpdating: false,
|
||||
lastSyncedAt: null,
|
||||
};
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function getInlineAnimationPrefsScript(): string {
|
||||
return `
|
||||
(function(){
|
||||
try {
|
||||
var STORAGE_KEY = '${STORAGE_KEY}';
|
||||
var raw = localStorage.getItem(STORAGE_KEY);
|
||||
var prefersReduced = false;
|
||||
var speed = 1;
|
||||
if (raw) {
|
||||
try {
|
||||
var parsed = JSON.parse(raw);
|
||||
if (typeof parsed.prefersReducedMotion === 'boolean') {
|
||||
prefersReduced = parsed.prefersReducedMotion;
|
||||
} else {
|
||||
prefersReduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
if (typeof parsed.animationSpeedMultiplier === 'number') {
|
||||
speed = parsed.animationSpeedMultiplier;
|
||||
if (isNaN(speed) || speed < ${MIN_SPEED} || speed > ${MAX_SPEED}) speed = 1;
|
||||
}
|
||||
} catch (_e) {}
|
||||
} else {
|
||||
prefersReduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
var root = document.documentElement;
|
||||
if (prefersReduced) root.classList.add('user-reduce-motion');
|
||||
function apply(fast, normal, slow){
|
||||
root.style.setProperty('--animation-speed-fast', fast + 's');
|
||||
root.style.setProperty('--animation-speed-normal', normal + 's');
|
||||
root.style.setProperty('--animation-speed-slow', slow + 's');
|
||||
}
|
||||
if (prefersReduced) {
|
||||
apply(0.01,0.01,0.01);
|
||||
} else {
|
||||
var fast = (0.15 / speed).toFixed(4);
|
||||
var normal = (0.30 / speed).toFixed(4);
|
||||
var slow = (0.50 / speed).toFixed(4);
|
||||
apply(fast, normal, slow);
|
||||
}
|
||||
} catch(_e){}
|
||||
})();`.trim();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { AnimationPreferencesProviderSynced } from "~/components/providers/animation-preferences-provider-synced";
|
||||
import { AppearanceProviderSynced } from "~/components/providers/appearance-provider-synced";
|
||||
import { TRPCReactProvider } from "~/trpc/react";
|
||||
|
||||
/** Full app providers for authenticated workspace routes. */
|
||||
export function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<TRPCReactProvider>
|
||||
<AppearanceProviderSynced>
|
||||
<AnimationPreferencesProviderSynced>
|
||||
{children}
|
||||
</AnimationPreferencesProviderSynced>
|
||||
</AppearanceProviderSynced>
|
||||
</TRPCReactProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { defaultColorMode, type ColorMode } from "~/lib/appearance";
|
||||
import { api } from "~/trpc/react";
|
||||
import {
|
||||
AppearanceContext,
|
||||
applyColorMode,
|
||||
defaultAppearance,
|
||||
readStoredColorMode,
|
||||
writeStoredColorMode,
|
||||
type AppearanceContextValue,
|
||||
type AppearancePatch,
|
||||
} from "~/components/providers/appearance-provider";
|
||||
|
||||
/** Dashboard appearance provider with per-user color mode sync. */
|
||||
export function AppearanceProviderSynced({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// Lazy initializer so the first render already matches what the inline
|
||||
// appearance-init script set on <html> — a separate mount effect here
|
||||
// would run one render behind it, transiently flashing (and persisting)
|
||||
// colorMode back to the default before the effect's own state update lands.
|
||||
const [colorMode, setColorMode] = useState<ColorMode>(
|
||||
() => readStoredColorMode() ?? defaultColorMode,
|
||||
);
|
||||
const serverHydratedRef = useRef(false);
|
||||
const utils = api.useUtils();
|
||||
const updateMutation = api.settings.updateColorMode.useMutation({
|
||||
onError: () => {
|
||||
const cached = utils.settings.getColorMode.getData();
|
||||
const fallback = cached?.colorMode ?? defaultColorMode;
|
||||
setColorMode(fallback);
|
||||
applyColorMode(fallback);
|
||||
writeStoredColorMode(fallback);
|
||||
},
|
||||
});
|
||||
|
||||
const { data: serverColorMode } = api.settings.getColorMode.useQuery(
|
||||
undefined,
|
||||
{
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 60_000,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverColorMode?.colorMode) return;
|
||||
if (serverHydratedRef.current) return;
|
||||
|
||||
|
||||
setColorMode(serverColorMode.colorMode);
|
||||
serverHydratedRef.current = true;
|
||||
}, [serverColorMode?.colorMode]);
|
||||
|
||||
useEffect(() => {
|
||||
applyColorMode(colorMode);
|
||||
writeStoredColorMode(colorMode);
|
||||
}, [colorMode]);
|
||||
|
||||
const updateAppearance = useCallback(
|
||||
(patch: AppearancePatch) => {
|
||||
if (!patch.colorMode) return;
|
||||
|
||||
setColorMode(patch.colorMode);
|
||||
applyColorMode(patch.colorMode);
|
||||
writeStoredColorMode(patch.colorMode);
|
||||
updateMutation.mutate({ colorMode: patch.colorMode });
|
||||
},
|
||||
[updateMutation],
|
||||
);
|
||||
|
||||
const value = useMemo<AppearanceContextValue>(
|
||||
() => ({
|
||||
...defaultAppearance,
|
||||
colorMode,
|
||||
updateAppearance,
|
||||
isUpdating: updateMutation.isPending,
|
||||
}),
|
||||
[colorMode, updateAppearance, updateMutation.isPending],
|
||||
);
|
||||
|
||||
return (
|
||||
<AppearanceContext.Provider value={value}>
|
||||
{children}
|
||||
</AppearanceContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { defaultColorMode, isColorMode, type ColorMode } from "~/lib/appearance";
|
||||
|
||||
export type AppearancePreferences = {
|
||||
colorMode: ColorMode;
|
||||
};
|
||||
|
||||
export type AppearancePatch = Partial<AppearancePreferences>;
|
||||
|
||||
export type AppearanceContextValue = AppearancePreferences & {
|
||||
updateAppearance: (patch: AppearancePatch) => void;
|
||||
isUpdating: boolean;
|
||||
};
|
||||
|
||||
export const STORAGE_KEY = "bv.appearance";
|
||||
|
||||
export const defaultAppearance: AppearancePreferences = {
|
||||
colorMode: defaultColorMode,
|
||||
};
|
||||
|
||||
export const AppearanceContext =
|
||||
createContext<AppearanceContextValue | null>(null);
|
||||
|
||||
export function readStoredColorMode(): ColorMode | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as { colorMode?: unknown };
|
||||
return isColorMode(parsed.colorMode) ? parsed.colorMode : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStoredColorMode(colorMode: ColorMode) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ colorMode }));
|
||||
} catch {
|
||||
// Storage can be unavailable in private browsing or locked-down contexts.
|
||||
}
|
||||
}
|
||||
|
||||
export function applyColorMode(colorMode: ColorMode) {
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
const root = document.documentElement;
|
||||
root.dataset.colorMode = colorMode;
|
||||
root.classList.toggle("dark", colorMode === "dark");
|
||||
}
|
||||
|
||||
/** Local-only appearance provider for marketing and auth pages (no tRPC). */
|
||||
export function AppearanceProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// Lazy initializer so the first render already matches what the inline
|
||||
// appearance-init script set on <html> — a separate mount effect here
|
||||
// would run one render behind it, transiently flashing (and persisting)
|
||||
// colorMode back to the default before the effect's own state update lands.
|
||||
const [colorMode, setColorMode] = useState<ColorMode>(
|
||||
() => readStoredColorMode() ?? defaultColorMode,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
applyColorMode(colorMode);
|
||||
writeStoredColorMode(colorMode);
|
||||
}, [colorMode]);
|
||||
|
||||
const updateAppearance = useCallback((patch: AppearancePatch) => {
|
||||
if (patch.colorMode) {
|
||||
setColorMode(patch.colorMode);
|
||||
applyColorMode(patch.colorMode);
|
||||
writeStoredColorMode(patch.colorMode);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AppearanceContextValue>(
|
||||
() => ({
|
||||
colorMode,
|
||||
updateAppearance,
|
||||
isUpdating: false,
|
||||
}),
|
||||
[colorMode, updateAppearance],
|
||||
);
|
||||
|
||||
return (
|
||||
<AppearanceContext.Provider value={value}>
|
||||
{children}
|
||||
</AppearanceContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAppearance() {
|
||||
const ctx = useContext(AppearanceContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useAppearance must be used within an AppearanceProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { AnimationPreferencesProvider } from "~/components/providers/animation-preferences-provider";
|
||||
import { AppearanceProvider } from "~/components/providers/appearance-provider";
|
||||
|
||||
/** Client providers for public/marketing pages — no tRPC or DB-backed settings sync. */
|
||||
export function MarketingProviders({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AppearanceProvider>
|
||||
<AnimationPreferencesProvider>{children}</AnimationPreferencesProvider>
|
||||
</AppearanceProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,730 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "~/components/ui/collapsible";
|
||||
import { ChevronDown, Clock, Play, Square } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
getLastTimeClockClientId,
|
||||
setLastTimeClockClientId,
|
||||
} from "~/lib/time-clock-prefs";
|
||||
import {
|
||||
describeClockOutOutcome,
|
||||
formatElapsedSeconds,
|
||||
formatRunningTimerLabel,
|
||||
resolveClockDescription,
|
||||
resolveEffectiveHourlyRate,
|
||||
startedAtFromMinutesAgo,
|
||||
} from "~/lib/time-clock";
|
||||
import { invoiceLabel } from "~/lib/time-entry-display";
|
||||
import { TimeEntryList } from "~/components/time-clock/time-entry-list";
|
||||
import { TimeEntryEditDialog } from "~/components/time-clock/time-entry-edit-dialog";
|
||||
|
||||
const FEATURED_CLIENT_COUNT = 4;
|
||||
|
||||
type StartMode = "now" | "pick" | "ago";
|
||||
|
||||
function toDatetimeLocalValue(value: Date | string) {
|
||||
const start = new Date(value);
|
||||
start.setMinutes(start.getMinutes() - start.getTimezoneOffset());
|
||||
return start.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function RunningTextFields({
|
||||
running,
|
||||
updateRunningPending,
|
||||
onDescriptionCommit,
|
||||
onStartedAtCommit,
|
||||
}: {
|
||||
running: { id: string; description: string | null; startedAt: Date };
|
||||
updateRunningPending: boolean;
|
||||
onDescriptionCommit: (description: string) => void;
|
||||
onStartedAtCommit: (startedAt: Date) => void;
|
||||
}) {
|
||||
const [title, setTitle] = useState(running.description ?? "");
|
||||
const [runningStartedAt, setRunningStartedAt] = useState(() =>
|
||||
toDatetimeLocalValue(running.startedAt),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clock-running-title">What are you working on?</Label>
|
||||
<Input
|
||||
id="clock-running-title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onBlur={() => onDescriptionCommit(title)}
|
||||
placeholder="What are you working on?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clock-running-start">Started at</Label>
|
||||
<Input
|
||||
id="clock-running-start"
|
||||
type="datetime-local"
|
||||
value={runningStartedAt}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setRunningStartedAt(value);
|
||||
if (!value) return;
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime()) || parsed > new Date()) return;
|
||||
onStartedAtCommit(parsed);
|
||||
}}
|
||||
disabled={updateRunningPending}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export type TimeClockPanelProps = {
|
||||
defaultClientId?: string;
|
||||
defaultInvoiceId?: string;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
function ClientChip({
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"rounded-full border px-3 py-1.5 text-sm font-medium transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-background hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimeClockPanel({
|
||||
defaultClientId = "",
|
||||
defaultInvoiceId = "",
|
||||
compact = false,
|
||||
}: TimeClockPanelProps) {
|
||||
const utils = api.useUtils();
|
||||
const { data: running, isLoading: runningLoading } = api.timeEntries.getRunning.useQuery(
|
||||
undefined,
|
||||
{ refetchInterval: 30_000 },
|
||||
);
|
||||
const { data: clients } = api.clients.getAll.useQuery();
|
||||
|
||||
const todayStart = useMemo(() => {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}, []);
|
||||
|
||||
const { data: todayEntries } = api.timeEntries.getAll.useQuery({
|
||||
from: todayStart,
|
||||
});
|
||||
|
||||
const [clientId, setClientId] = useState(() => {
|
||||
if (defaultClientId) return defaultClientId;
|
||||
return getLastTimeClockClientId() ?? "";
|
||||
});
|
||||
const [invoiceId, setInvoiceId] = useState(defaultInvoiceId);
|
||||
const [title, setTitle] = useState("");
|
||||
const [stopNote, setStopNote] = useState("");
|
||||
const [rate, setRate] = useState(0);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [showAllClients, setShowAllClients] = useState(false);
|
||||
const [optionsOpen, setOptionsOpen] = useState(false);
|
||||
const [startMode, setStartMode] = useState<StartMode>("now");
|
||||
const [pickedStart, setPickedStart] = useState("");
|
||||
const [minutesAgo, setMinutesAgo] = useState("30");
|
||||
const [editEntryId, setEditEntryId] = useState<string | null>(null);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const draftClientId = running ? (running.clientId ?? "") : clientId;
|
||||
const { data: billableInvoices } = api.invoices.getBillable.useQuery(
|
||||
draftClientId ? { clientId: draftClientId } : undefined,
|
||||
{ enabled: Boolean(draftClientId) },
|
||||
);
|
||||
|
||||
const selectedClient = useMemo(
|
||||
() => clients?.find((c) => c.id === clientId),
|
||||
[clients, clientId],
|
||||
);
|
||||
|
||||
const featuredClientIds = useMemo(() => {
|
||||
const ids: string[] = [];
|
||||
const last = getLastTimeClockClientId();
|
||||
if (last) ids.push(last);
|
||||
|
||||
if (running?.clientId && !ids.includes(running.clientId)) {
|
||||
ids.unshift(running.clientId);
|
||||
}
|
||||
|
||||
for (const entry of todayEntries ?? []) {
|
||||
if (entry.clientId && !ids.includes(entry.clientId)) {
|
||||
ids.push(entry.clientId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const client of clients ?? []) {
|
||||
if (!ids.includes(client.id)) ids.push(client.id);
|
||||
if (ids.length >= FEATURED_CLIENT_COUNT) break;
|
||||
}
|
||||
|
||||
return ids;
|
||||
}, [clients, todayEntries, running]);
|
||||
|
||||
const visibleClients = useMemo(() => {
|
||||
if (!clients?.length) return [];
|
||||
if (showAllClients) return clients;
|
||||
const featured = featuredClientIds
|
||||
.map((id) => clients.find((c) => c.id === id))
|
||||
.filter((c): c is NonNullable<typeof c> => Boolean(c));
|
||||
return featured.length > 0 ? featured : clients.slice(0, FEATURED_CLIENT_COUNT);
|
||||
}, [clients, featuredClientIds, showAllClients]);
|
||||
|
||||
const hiddenClientCount = Math.max(0, (clients?.length ?? 0) - visibleClients.length);
|
||||
|
||||
useEffect(() => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
if (!running) return;
|
||||
|
||||
const tick = () =>
|
||||
setElapsed(Math.floor((Date.now() - new Date(running.startedAt).getTime()) / 1000));
|
||||
tick();
|
||||
intervalRef.current = setInterval(tick, 1000);
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [running]);
|
||||
|
||||
const clockIn = api.timeEntries.clockIn.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Timer started");
|
||||
void utils.timeEntries.getRunning.invalidate();
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const updateRunning = api.timeEntries.updateRunning.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.timeEntries.getRunning.invalidate();
|
||||
void utils.invoices.getBillable.invalidate();
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
function handleRunningDescriptionCommit(nextTitle: string) {
|
||||
if (!running) return;
|
||||
const next = resolveClockDescription(nextTitle);
|
||||
if (next === (running.description ?? "")) return;
|
||||
updateRunning.mutate({ description: next });
|
||||
}
|
||||
|
||||
function handleRunningStartedAtCommit(parsed: Date) {
|
||||
if (!running) return;
|
||||
updateRunning.mutate({ startedAt: parsed });
|
||||
}
|
||||
|
||||
const clockOut = api.timeEntries.clockOut.useMutation({
|
||||
onSuccess: (data) => {
|
||||
const message = describeClockOutOutcome({
|
||||
outcome: data.outcome,
|
||||
hours: data.hours,
|
||||
rate: data.rate,
|
||||
invoice: data.invoice,
|
||||
});
|
||||
|
||||
if (data.outcome === "linked_to_invoice" && data.invoice) {
|
||||
toast.success("Time logged", {
|
||||
description: message,
|
||||
action: {
|
||||
label: "View invoice",
|
||||
onClick: () =>
|
||||
window.location.assign(`/dashboard/invoices/${data.invoice!.id}`),
|
||||
},
|
||||
});
|
||||
} else if (data.outcome === "saved_no_invoice" || data.outcome === "saved_no_client") {
|
||||
toast.warning("Time saved", { description: message });
|
||||
} else {
|
||||
toast.success(message);
|
||||
}
|
||||
|
||||
void utils.timeEntries.getRunning.invalidate();
|
||||
void utils.timeEntries.getAll.invalidate();
|
||||
void utils.invoices.getAll.invalidate();
|
||||
void utils.invoices.getBillable.invalidate();
|
||||
void utils.dashboard.getStats.invalidate();
|
||||
setTitle("");
|
||||
setStopNote("");
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
function handleClientChange(value: string) {
|
||||
if (running) {
|
||||
updateRunning.mutate({ clientId: value, invoiceId: "" });
|
||||
return;
|
||||
}
|
||||
|
||||
setClientId(value);
|
||||
setInvoiceId("");
|
||||
setLastTimeClockClientId(value);
|
||||
const client = clients?.find((c) => c.id === value);
|
||||
setRate(client?.defaultHourlyRate ?? 0);
|
||||
}
|
||||
|
||||
function handleInvoiceChange(value: string) {
|
||||
const next = value === "__none__" ? "" : value;
|
||||
if (running) {
|
||||
updateRunning.mutate({ invoiceId: next });
|
||||
return;
|
||||
}
|
||||
setInvoiceId(next);
|
||||
}
|
||||
|
||||
function resolveStartedAt(): Date | undefined {
|
||||
if (startMode === "now") return undefined;
|
||||
if (startMode === "pick") {
|
||||
if (!pickedStart) {
|
||||
toast.error("Choose a start date and time");
|
||||
return undefined;
|
||||
}
|
||||
const parsed = new Date(pickedStart);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
toast.error("Invalid start time");
|
||||
return undefined;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
const minutes = Number(minutesAgo);
|
||||
if (!Number.isFinite(minutes) || minutes < 1 || minutes > 24 * 60) {
|
||||
toast.error("Enter minutes between 1 and 1440");
|
||||
return undefined;
|
||||
}
|
||||
return startedAtFromMinutesAgo(minutes);
|
||||
}
|
||||
|
||||
function selectStartMode(mode: StartMode) {
|
||||
setStartMode(mode);
|
||||
if (mode === "pick" && !pickedStart) {
|
||||
const now = new Date();
|
||||
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
|
||||
setPickedStart(now.toISOString().slice(0, 16));
|
||||
}
|
||||
}
|
||||
|
||||
function handleStart() {
|
||||
const startedAt = resolveStartedAt();
|
||||
if (startMode !== "now" && !startedAt) return;
|
||||
|
||||
const description = resolveClockDescription(title);
|
||||
const effectiveRate = resolveEffectiveHourlyRate(rate, selectedClient);
|
||||
|
||||
if (clientId) setLastTimeClockClientId(clientId);
|
||||
|
||||
clockIn.mutate({
|
||||
description,
|
||||
clientId: clientId || "",
|
||||
invoiceId: invoiceId || undefined,
|
||||
rate: effectiveRate > 0 ? effectiveRate : undefined,
|
||||
startedAt,
|
||||
});
|
||||
}
|
||||
|
||||
if (runningLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="text-muted-foreground p-6 text-sm">Loading timer…</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const displayRate = running ? (running.rate ?? 0) : rate;
|
||||
const runningTitle = formatRunningTimerLabel(running?.description);
|
||||
const activeClientId = running ? (running.clientId ?? "") : clientId;
|
||||
const activeInvoiceId = running ? (running.invoiceId ?? "") : invoiceId;
|
||||
|
||||
return (
|
||||
<div className={compact ? "space-y-4" : "space-y-6"}>
|
||||
{running ? (
|
||||
<div className="border-primary/20 bg-primary/5 rounded-2xl border p-6 text-center shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-center gap-2">
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
|
||||
<span className="bg-primary relative inline-flex h-2.5 w-2.5 rounded-full" />
|
||||
</span>
|
||||
<span className="text-primary text-sm font-medium">Timer running</span>
|
||||
</div>
|
||||
<p className="text-primary font-mono text-5xl font-bold tracking-tight tabular-nums sm:text-6xl">
|
||||
{formatElapsedSeconds(elapsed)}
|
||||
</p>
|
||||
<p className="mt-3 text-lg font-medium">{runningTitle}</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{running.client?.name ?? "No client"}
|
||||
{running.invoice ? ` · ${invoiceLabel(running.invoice)}` : ""}
|
||||
{displayRate ? ` · $${displayRate}/hr` : ""}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
{!running ? <Clock className="h-4 w-4" /> : null}
|
||||
{running ? "Update & stop" : "Clock in"}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{!running ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clock-title" className="sr-only">
|
||||
What are you working on?
|
||||
</Label>
|
||||
<Input
|
||||
id="clock-title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="What are you working on?"
|
||||
className="h-12 border-0 bg-transparent px-0 text-lg font-medium shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Client</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{visibleClients.map((client) => (
|
||||
<ClientChip
|
||||
key={client.id}
|
||||
label={client.name}
|
||||
active={activeClientId === client.id}
|
||||
onClick={() => handleClientChange(client.id)}
|
||||
/>
|
||||
))}
|
||||
{!showAllClients && hiddenClientCount > 0 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
onClick={() => setShowAllClients(true)}
|
||||
>
|
||||
+{hiddenClientCount} more
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{(showAllClients || (clients?.length ?? 0) > FEATURED_CLIENT_COUNT) && (
|
||||
<Select value={clientId || undefined} onValueChange={handleClientChange}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Select client" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients?.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Invoice</Label>
|
||||
<Select
|
||||
value={invoiceId || "__none__"}
|
||||
onValueChange={handleInvoiceChange}
|
||||
disabled={!clientId}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
clientId ? "Draft invoice (optional)" : "Choose a client first"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">No invoice — save entry only</SelectItem>
|
||||
{billableInvoices?.map((inv) => (
|
||||
<SelectItem key={inv.id} value={inv.id}>
|
||||
{invoiceLabel(inv)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Collapsible open={optionsOpen} onOpenChange={setOptionsOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground h-auto w-full justify-between px-0 py-1 font-normal hover:bg-transparent"
|
||||
>
|
||||
Rate & start time
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 transition-transform",
|
||||
optionsOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 pt-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Hourly rate</Label>
|
||||
<NumberInput
|
||||
value={rate}
|
||||
onChange={setRate}
|
||||
min={0}
|
||||
step={0.01}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
{clientId && rate === 0 && selectedClient?.defaultHourlyRate ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Client default: ${selectedClient.defaultHourlyRate}/hr (used when left at zero).
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>When to start</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(
|
||||
[
|
||||
["now", "Now"],
|
||||
["pick", "Pick time"],
|
||||
["ago", "Time ago"],
|
||||
] as const
|
||||
).map(([mode, label]) => (
|
||||
<Button
|
||||
key={mode}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={startMode === mode ? "default" : "outline"}
|
||||
className="rounded-full"
|
||||
onClick={() => selectStartMode(mode)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{startMode === "pick" ? (
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={pickedStart}
|
||||
onChange={(e) => setPickedStart(e.target.value)}
|
||||
className="mt-2"
|
||||
/>
|
||||
) : null}
|
||||
{startMode === "ago" ? (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={1440}
|
||||
value={minutesAgo}
|
||||
onChange={(e) => setMinutesAgo(e.target.value)}
|
||||
className="w-24"
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">minutes ago</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RunningTextFields
|
||||
key={running.id}
|
||||
running={running}
|
||||
updateRunningPending={updateRunning.isPending}
|
||||
onDescriptionCommit={handleRunningDescriptionCommit}
|
||||
onStartedAtCommit={handleRunningStartedAtCommit}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Client</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{visibleClients.map((client) => (
|
||||
<ClientChip
|
||||
key={client.id}
|
||||
label={client.name}
|
||||
active={activeClientId === client.id}
|
||||
onClick={() => handleClientChange(client.id)}
|
||||
/>
|
||||
))}
|
||||
{!showAllClients && hiddenClientCount > 0 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
onClick={() => setShowAllClients(true)}
|
||||
>
|
||||
+{hiddenClientCount} more
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{(showAllClients || (clients?.length ?? 0) > FEATURED_CLIENT_COUNT) && (
|
||||
<Select
|
||||
value={activeClientId || undefined}
|
||||
onValueChange={handleClientChange}
|
||||
disabled={updateRunning.isPending}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Select client" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients?.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Invoice</Label>
|
||||
<Select
|
||||
value={activeInvoiceId || "__none__"}
|
||||
onValueChange={handleInvoiceChange}
|
||||
disabled={!activeClientId || updateRunning.isPending}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
activeClientId
|
||||
? "Draft invoice (optional)"
|
||||
: "Choose a client first"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">No invoice — save entry only</SelectItem>
|
||||
{billableInvoices?.map((inv) => (
|
||||
<SelectItem key={inv.id} value={inv.id}>
|
||||
{invoiceLabel(inv)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clock-stop-note">Note on stop (optional)</Label>
|
||||
<Input
|
||||
id="clock-stop-note"
|
||||
value={stopNote}
|
||||
onChange={(e) => setStopNote(e.target.value)}
|
||||
placeholder={
|
||||
running?.description?.trim()
|
||||
? running.description
|
||||
: "Update description when you stop"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{running ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
clockOut.mutate({
|
||||
description: stopNote.trim() || undefined,
|
||||
})
|
||||
}
|
||||
disabled={clockOut.isPending}
|
||||
>
|
||||
<Square className="mr-2 h-4 w-4" />
|
||||
{clockOut.isPending ? "Stopping…" : "Stop & save"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={handleStart}
|
||||
disabled={clockIn.isPending}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
{clockIn.isPending ? "Starting…" : "Start timer"}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{!compact ? (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">Today's entries</CardTitle>
|
||||
<Button variant="ghost" size="sm" className="h-8" asChild>
|
||||
<Link href="/dashboard/time-clock/entries">View all entries</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{todayEntries?.some((e) => e.endedAt) ? (
|
||||
<TimeEntryList
|
||||
entries={todayEntries}
|
||||
onEdit={(entry) => setEditEntryId(entry.id)}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||
No entries today.{" "}
|
||||
<Link
|
||||
href="/dashboard/time-clock/entries"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
View history
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<TimeEntryEditDialog
|
||||
entryId={editEntryId}
|
||||
open={editEntryId != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditEntryId(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { EmptyState } from "~/components/layout/page-layout";
|
||||
import { Clock, Play } from "lucide-react";
|
||||
import { groupEntriesByDate } from "~/lib/time-entry-display";
|
||||
import { TimeEntryRow } from "~/components/time-clock/time-entry-list";
|
||||
import { TimeEntryEditDialog } from "~/components/time-clock/time-entry-edit-dialog";
|
||||
import type { TimeEntryListItem } from "~/lib/time-entry-display";
|
||||
|
||||
export function TimeEntriesHistory() {
|
||||
const { data: entries, isLoading } = api.timeEntries.getAll.useQuery();
|
||||
const [editEntryId, setEditEntryId] = useState<string | null>(null);
|
||||
|
||||
const completedEntries = useMemo(
|
||||
() => (entries ?? []).filter((e) => e.endedAt),
|
||||
[entries],
|
||||
);
|
||||
|
||||
const grouped = useMemo(
|
||||
() => groupEntriesByDate(completedEntries),
|
||||
[completedEntries],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="text-muted-foreground p-6 text-sm">
|
||||
Loading entries…
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (completedEntries.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<EmptyState
|
||||
icon={<Clock className="h-6 w-6" />}
|
||||
title="No time entries yet"
|
||||
description="Start the timer to track billable hours. Completed entries will show up here."
|
||||
action={
|
||||
<Button asChild>
|
||||
<Link href="/dashboard/time-clock">
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Start timer
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
className="py-16"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
{grouped.map((group) => (
|
||||
<Card key={group.dateKey}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-muted-foreground text-sm font-medium">
|
||||
{group.label}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{group.entries.map((entry, index) => (
|
||||
<TimeEntryRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
isLast={index === group.entries.length - 1}
|
||||
onEdit={(item: TimeEntryListItem) => setEditEntryId(item.id)}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<TimeEntryEditDialog
|
||||
entryId={editEntryId}
|
||||
open={editEntryId != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditEntryId(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
import { invoiceLabel } from "~/lib/time-entry-display";
|
||||
import type { RouterOutputs } from "~/trpc/react";
|
||||
|
||||
type TimeEntry = RouterOutputs["timeEntries"]["getById"];
|
||||
|
||||
function toDatetimeLocalValue(value: Date | string) {
|
||||
const start = new Date(value);
|
||||
start.setMinutes(start.getMinutes() - start.getTimezoneOffset());
|
||||
return start.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
export type TimeEntryEditDialogProps = {
|
||||
entryId: string | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
type TimeEntryEditFormProps = {
|
||||
entry: TimeEntry;
|
||||
entryId: string;
|
||||
clients: RouterOutputs["clients"]["getAll"];
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function TimeEntryEditForm({
|
||||
entry,
|
||||
entryId,
|
||||
clients,
|
||||
onClose,
|
||||
}: TimeEntryEditFormProps) {
|
||||
const utils = api.useUtils();
|
||||
const [description, setDescription] = useState(entry.description ?? "");
|
||||
const [clientId, setClientId] = useState(entry.clientId ?? "");
|
||||
const [invoiceId, setInvoiceId] = useState(entry.invoiceId ?? "");
|
||||
const [rate, setRate] = useState(entry.rate ?? 0);
|
||||
const [startedAt, setStartedAt] = useState(() => toDatetimeLocalValue(entry.startedAt));
|
||||
const [endedAt, setEndedAt] = useState(() =>
|
||||
entry.endedAt ? toDatetimeLocalValue(entry.endedAt) : "",
|
||||
);
|
||||
|
||||
const { data: billableInvoices } = api.invoices.getBillable.useQuery(
|
||||
clientId ? { clientId } : undefined,
|
||||
{ enabled: Boolean(clientId) },
|
||||
);
|
||||
|
||||
const hoursPreview = useMemo(() => {
|
||||
if (!startedAt || !endedAt) return null;
|
||||
const start = new Date(startedAt);
|
||||
const end = new Date(endedAt);
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return null;
|
||||
return Math.max(0, (end.getTime() - start.getTime()) / 3_600_000);
|
||||
}, [endedAt, startedAt]);
|
||||
|
||||
const updateEntry = api.timeEntries.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast.success("Time entry updated");
|
||||
await Promise.all([
|
||||
utils.timeEntries.getAll.invalidate(),
|
||||
utils.timeEntries.getById.invalidate(),
|
||||
utils.invoices.getAll.invalidate(),
|
||||
utils.dashboard.getStats.invalidate(),
|
||||
]);
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const deleteEntry = api.timeEntries.delete.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast.success("Time entry deleted");
|
||||
await Promise.all([
|
||||
utils.timeEntries.getAll.invalidate(),
|
||||
utils.invoices.getAll.invalidate(),
|
||||
utils.dashboard.getStats.invalidate(),
|
||||
]);
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
function handleSave() {
|
||||
const start = new Date(startedAt);
|
||||
const end = endedAt ? new Date(endedAt) : undefined;
|
||||
if (Number.isNaN(start.getTime()) || (end && Number.isNaN(end.getTime()))) {
|
||||
toast.error("Invalid start or end time");
|
||||
return;
|
||||
}
|
||||
if (end && end <= start) {
|
||||
toast.error("End time must be after start time");
|
||||
return;
|
||||
}
|
||||
|
||||
updateEntry.mutate({
|
||||
id: entryId,
|
||||
description,
|
||||
clientId: clientId || "",
|
||||
invoiceId: invoiceId || "",
|
||||
rate,
|
||||
startedAt: start,
|
||||
endedAt: end,
|
||||
hours: hoursPreview ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="entry-description">Description</Label>
|
||||
<Input
|
||||
id="entry-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Client</Label>
|
||||
<Select
|
||||
value={clientId || "__none__"}
|
||||
onValueChange={(v) => {
|
||||
setClientId(v === "__none__" ? "" : v);
|
||||
setInvoiceId("");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No client" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">No client</SelectItem>
|
||||
{clients.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Invoice</Label>
|
||||
<Select
|
||||
value={invoiceId || "__none__"}
|
||||
onValueChange={(v) => setInvoiceId(v === "__none__" ? "" : v)}
|
||||
disabled={!clientId}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Not on invoice" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">Not on invoice</SelectItem>
|
||||
{billableInvoices?.map((inv) => (
|
||||
<SelectItem key={inv.id} value={inv.id}>
|
||||
{invoiceLabel(inv)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Hourly rate</Label>
|
||||
<NumberInput value={rate} onChange={setRate} min={0} step={0.01} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="entry-start">Started</Label>
|
||||
<Input
|
||||
id="entry-start"
|
||||
type="datetime-local"
|
||||
value={startedAt}
|
||||
onChange={(e) => setStartedAt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="entry-end">Ended</Label>
|
||||
<Input
|
||||
id="entry-end"
|
||||
type="datetime-local"
|
||||
value={endedAt}
|
||||
onChange={(e) => setEndedAt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hoursPreview != null ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Duration: {hoursPreview.toFixed(2)}h
|
||||
{rate > 0 ? ` · $${(hoursPreview * rate).toFixed(2)}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={deleteEntry.isPending}
|
||||
onClick={() => deleteEntry.mutate({ id: entryId })}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={handleSave} disabled={updateEntry.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimeEntryEditDialog({
|
||||
entryId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: TimeEntryEditDialogProps) {
|
||||
const entryQuery = api.timeEntries.getById.useQuery(
|
||||
{ id: entryId ?? "" },
|
||||
{ enabled: Boolean(entryId) && open },
|
||||
);
|
||||
const { data: clients = [] } = api.clients.getAll.useQuery(undefined, { enabled: open });
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit time entry</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{entryQuery.isLoading ? (
|
||||
<p className="text-muted-foreground text-sm">Loading…</p>
|
||||
) : entryQuery.data && entryId ? (
|
||||
<TimeEntryEditForm
|
||||
key={entryQuery.data.id}
|
||||
entry={entryQuery.data}
|
||||
entryId={entryId}
|
||||
clients={clients}
|
||||
onClose={() => onOpenChange(false)}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">Time entry not found.</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import Link from "next/link";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { formatRunningTimerLabel } from "~/lib/time-clock";
|
||||
import { entryHref, invoiceLabel, type TimeEntryListItem } from "~/lib/time-entry-display";
|
||||
|
||||
export function TimeEntryRow({
|
||||
entry,
|
||||
isLast,
|
||||
onEdit,
|
||||
}: {
|
||||
entry: TimeEntryListItem;
|
||||
isLast?: boolean;
|
||||
onEdit?: (entry: TimeEntryListItem) => void;
|
||||
}) {
|
||||
const href = onEdit ? null : entryHref(entry);
|
||||
const rowClassName = cn(
|
||||
"flex items-start justify-between gap-4 py-3",
|
||||
!isLast && "border-border border-b",
|
||||
);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{formatRunningTimerLabel(entry.description)}</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{entry.client?.name ?? "No client"}
|
||||
{entry.invoice
|
||||
? ` · ${invoiceLabel(entry.invoice)}`
|
||||
: entry.hours
|
||||
? " · not on invoice"
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right text-sm">
|
||||
<p className="font-mono font-semibold">{entry.hours ?? "—"}h</p>
|
||||
{entry.rate ? <p className="text-muted-foreground">${entry.rate}/hr</p> : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
rowClassName,
|
||||
"-mx-2 flex w-full cursor-pointer px-2 transition-colors hover:rounded-md hover:bg-muted/60",
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (onEdit) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(entry)}
|
||||
className={cn(
|
||||
rowClassName,
|
||||
"-mx-2 flex w-full cursor-pointer px-2 text-left transition-colors hover:rounded-md hover:bg-muted/60",
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={rowClassName}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimeEntryList({
|
||||
entries,
|
||||
onEdit,
|
||||
}: {
|
||||
entries: TimeEntryListItem[];
|
||||
onEdit?: (entry: TimeEntryListItem) => void;
|
||||
}) {
|
||||
const completed = entries.filter((e) => e.endedAt);
|
||||
|
||||
if (completed.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{completed.map((entry, index) => (
|
||||
<TimeEntryRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
isLast={index === completed.length - 1}
|
||||
onEdit={onEdit}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { buttonVariants } from "~/components/ui/button";
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
success:
|
||||
"border-success/50 text-success dark:border-success [&>svg]:text-success",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Alert.displayName = "Alert";
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 leading-none font-medium tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertTitle.displayName = "AlertTitle";
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDescription.displayName = "AlertDescription";
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-muted flex h-full w-full items-center justify-center rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex w-fit items-center rounded-md px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm border border-secondary/50 hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||
outline: "text-foreground border border-border", // Outline needs border
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof badgeVariants>) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,109 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
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";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-xl text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 button-hover",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-lg px-3 text-xs",
|
||||
lg: "h-10 rounded-xl px-8",
|
||||
icon: "h-9 w-9 rounded-full",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DayPicker,
|
||||
getDefaultClassNames,
|
||||
type DayButton,
|
||||
} from "react-day-picker";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Button, buttonVariants } from "~/components/ui/button";
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className,
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString("default", { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn(
|
||||
"flex gap-4 flex-col md:flex-row relative",
|
||||
defaultClassNames.months,
|
||||
),
|
||||
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
||||
defaultClassNames.nav,
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_previous,
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_next,
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
||||
defaultClassNames.month_caption,
|
||||
),
|
||||
dropdowns: cn(
|
||||
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
||||
defaultClassNames.dropdowns,
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
||||
defaultClassNames.dropdown_root,
|
||||
),
|
||||
dropdown: cn(
|
||||
"absolute bg-popover inset-0 opacity-0",
|
||||
defaultClassNames.dropdown,
|
||||
),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label,
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
||||
defaultClassNames.weekday,
|
||||
),
|
||||
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
"select-none w-(--cell-size)",
|
||||
defaultClassNames.week_number_header,
|
||||
),
|
||||
week_number: cn(
|
||||
"text-[0.8rem] select-none text-muted-foreground",
|
||||
defaultClassNames.week_number,
|
||||
),
|
||||
day: cn(
|
||||
"relative w-full h-full p-0 text-center group/day aspect-square select-none",
|
||||
props.mode !== "single" &&
|
||||
"[&:last-child[data-selected=true]_button]:rounded-r-md",
|
||||
props.mode !== "single" &&
|
||||
(props.showWeekNumber
|
||||
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
|
||||
: "[&:first-child[data-selected=true]_button]:rounded-l-md"),
|
||||
defaultClassNames.day,
|
||||
),
|
||||
range_start: cn(
|
||||
"rounded-l-md bg-accent",
|
||||
defaultClassNames.range_start,
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||
today: cn(
|
||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today,
|
||||
),
|
||||
outside: cn(
|
||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||
defaultClassNames.outside,
|
||||
),
|
||||
disabled: cn(
|
||||
"text-muted-foreground opacity-50",
|
||||
defaultClassNames.disabled,
|
||||
),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||
);
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null);
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus();
|
||||
}, [modifiers.focused]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton };
|
||||
@@ -0,0 +1,92 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-background/80 border-border/50 text-card-foreground flex flex-col overflow-hidden rounded-3xl border shadow-sm backdrop-blur-xl",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-5 pt-4 pb-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-5 pb-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 py-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="flex items-center justify-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useSpring, useTransform } from "framer-motion";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function CountUp({
|
||||
value,
|
||||
prefix = "",
|
||||
suffix = "",
|
||||
}: {
|
||||
value: number;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
}) {
|
||||
const spring = useSpring(value, { mass: 0.8, stiffness: 75, damping: 15 });
|
||||
const display = useTransform(
|
||||
spring,
|
||||
(current) => `${prefix}${current.toFixed(2)}${suffix}`,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
spring.set(value);
|
||||
}, [spring, value]);
|
||||
|
||||
return <motion.span>{display}</motion.span>;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { parseDate } from "chrono-node";
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Calendar } from "~/components/ui/calendar";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "~/components/ui/popover";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const DATE_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
};
|
||||
|
||||
function formatDate(date: Date | undefined) {
|
||||
if (!date) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return date.toLocaleDateString("en-US", DATE_FORMAT_OPTIONS);
|
||||
}
|
||||
|
||||
// Longest month name in en-US long format (September 30, 2026).
|
||||
const LONGEST_FORMATTED_DATE = formatDate(new Date(2026, 8, 30));
|
||||
|
||||
interface DatePickerProps {
|
||||
date?: Date;
|
||||
onDateChange: (date: Date | undefined) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
inputClassName?: string;
|
||||
}
|
||||
|
||||
export function DatePicker({
|
||||
date,
|
||||
onDateChange,
|
||||
placeholder = "Tomorrow or next week",
|
||||
className,
|
||||
inputClassName,
|
||||
disabled = false,
|
||||
id,
|
||||
size = "md",
|
||||
}: DatePickerProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [value, setValue] = React.useState(formatDate(date));
|
||||
const [month, setMonth] = React.useState<Date | undefined>(date);
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "h-9 text-xs",
|
||||
md: "h-9 text-sm",
|
||||
lg: "h-10 text-sm",
|
||||
};
|
||||
|
||||
const wantsFullWidth = className?.includes("w-full");
|
||||
|
||||
React.useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Keep text input and calendar month synchronized with the controlled date prop.
|
||||
setValue(formatDate(date));
|
||||
setMonth(date);
|
||||
}, [date]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative min-w-max",
|
||||
wantsFullWidth ? "w-full" : "w-auto shrink-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"invisible block whitespace-nowrap px-3 pr-10",
|
||||
sizeClasses[size],
|
||||
inputClassName,
|
||||
)}
|
||||
>
|
||||
{LONGEST_FORMATTED_DATE}
|
||||
</span>
|
||||
<Input
|
||||
id={id}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"bg-background absolute inset-0 w-full pr-10 tabular-nums",
|
||||
sizeClasses[size],
|
||||
inputClassName,
|
||||
)}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
const parsedDate = parseDate(e.target.value);
|
||||
if (parsedDate) {
|
||||
onDateChange(parsedDate);
|
||||
setMonth(parsedDate);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={disabled}
|
||||
className="text-primary/80 hover:text-primary absolute top-1/2 right-2 z-20 size-6 -translate-y-1/2 p-0 transition-colors"
|
||||
>
|
||||
<CalendarIcon className="size-4" />
|
||||
<span className="sr-only">Select date</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-auto overflow-hidden rounded-xl p-0"
|
||||
align="end"
|
||||
>
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
captionLayout="dropdown"
|
||||
month={month}
|
||||
onMonthChange={setMonth}
|
||||
onSelect={(selectedDate) => {
|
||||
onDateChange(selectedDate);
|
||||
setValue(formatDate(selectedDate));
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
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 border-0 shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-foreground-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 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,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-foreground-foreground relative flex cursor-default items-center gap-2 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,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-foreground-foreground relative flex cursor-default items-center gap-2 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,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-foreground-foreground data-[state=open]:bg-accent data-[state=open]:text-foreground-foreground flex cursor-default items-center px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<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 border-0 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useState } from "react";
|
||||
import Image, { type ImageProps } from "next/image";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
|
||||
interface ImageWithSkeletonProps extends ImageProps {
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
export function ImageWithSkeleton({
|
||||
className,
|
||||
containerClassName,
|
||||
alt,
|
||||
...props
|
||||
}: ImageWithSkeletonProps) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
return (
|
||||
<div className={cn("relative overflow-hidden", containerClassName)}>
|
||||
{isLoading && (
|
||||
<Skeleton className="absolute inset-0 h-full w-full animate-pulse" />
|
||||
)}
|
||||
<Image
|
||||
className={cn(
|
||||
"duration-700 ease-in-out",
|
||||
isLoading
|
||||
? "scale-110 blur-2xl grayscale"
|
||||
: "blur-0 scale-100 grayscale-0",
|
||||
className,
|
||||
)}
|
||||
onLoad={() => setIsLoading(false)}
|
||||
alt={alt}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { HexAlphaColorPicker, HexColorPicker } from "react-colorful";
|
||||
import { Loader2, PipetteIcon } from "lucide-react";
|
||||
import { z } from "zod";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "~/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import {
|
||||
hexToRgb,
|
||||
hexToRgba,
|
||||
hslToRgb,
|
||||
hslaToRgba,
|
||||
rgbToHex,
|
||||
rgbToHsl,
|
||||
rgbaToHex,
|
||||
rgbaToHsla,
|
||||
} from "~/lib/color-converter";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
EyeDropper?: new () => {
|
||||
open: () => Promise<{ sRGBHex: string }>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const colorSchema = z
|
||||
.string()
|
||||
.regex(
|
||||
/^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/,
|
||||
"Color must be a valid hex color (e.g., #FF0000 or #FF0000FF)",
|
||||
)
|
||||
.transform((val) => val.toUpperCase());
|
||||
|
||||
interface ColorPickerProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onBlur?: () => void;
|
||||
isLoading?: boolean;
|
||||
label: string;
|
||||
error?: string;
|
||||
className?: string;
|
||||
alpha?: boolean;
|
||||
}
|
||||
|
||||
interface ColorValues {
|
||||
hex: string;
|
||||
rgb: { r: number; g: number; b: number };
|
||||
hsl: { h: number; s: number; l: number };
|
||||
rgba?: { r: number; g: number; b: number; a: number };
|
||||
hsla?: { h: number; s: number; l: number; a: number };
|
||||
}
|
||||
|
||||
export function InputColor({
|
||||
value,
|
||||
onChange,
|
||||
onBlur = () => undefined,
|
||||
isLoading = false,
|
||||
label,
|
||||
error,
|
||||
className = "mt-6",
|
||||
alpha = false,
|
||||
}: ColorPickerProps) {
|
||||
const [colorFormat, setColorFormat] = useState(alpha ? "HEXA" : "HEX");
|
||||
const [colorValues, setColorValues] = useState<ColorValues>(() =>
|
||||
getColorValues(value, alpha),
|
||||
);
|
||||
const [hexInputValue, setHexInputValue] = useState(value);
|
||||
const [hexInputError, setHexInputError] = useState<string | null>(null);
|
||||
|
||||
const updateColorValues = (newColor: string) => {
|
||||
const nextValues = getColorValues(newColor, alpha);
|
||||
setColorValues(nextValues);
|
||||
setHexInputValue(newColor.toUpperCase());
|
||||
};
|
||||
|
||||
const handleColorChange = (newColor: string) => {
|
||||
updateColorValues(newColor);
|
||||
onChange(newColor.toUpperCase());
|
||||
};
|
||||
|
||||
const handleHexChange = (nextValue: string) => {
|
||||
let formattedValue = nextValue.toUpperCase();
|
||||
if (!formattedValue.startsWith("#")) {
|
||||
formattedValue = `#${formattedValue}`;
|
||||
}
|
||||
|
||||
const maxLength = alpha ? 9 : 7;
|
||||
if (
|
||||
formattedValue.length <= maxLength &&
|
||||
/^#[0-9A-Fa-f]*$/.test(formattedValue)
|
||||
) {
|
||||
setHexInputValue(formattedValue);
|
||||
onChange(formattedValue);
|
||||
updateColorValues(formattedValue);
|
||||
try {
|
||||
if (formattedValue.length === maxLength) {
|
||||
colorSchema.parse(formattedValue);
|
||||
setHexInputError(null);
|
||||
} else {
|
||||
setHexInputError("Enter a valid color");
|
||||
}
|
||||
} catch (validationError) {
|
||||
if (validationError instanceof z.ZodError) {
|
||||
setHexInputError("Enter a valid color");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRgbChange = (component: "r" | "g" | "b", nextValue: string) => {
|
||||
const numValue = Number.parseInt(nextValue) || 0;
|
||||
const clampedValue = Math.max(0, Math.min(255, numValue));
|
||||
const newRgb = { ...colorValues.rgb, [component]: clampedValue };
|
||||
const hex = rgbToHex(newRgb.r, newRgb.g, newRgb.b);
|
||||
const hsl = rgbToHsl(newRgb.r, newRgb.g, newRgb.b);
|
||||
|
||||
setColorValues({ ...colorValues, hex, rgb: newRgb, hsl });
|
||||
setHexInputValue(hex);
|
||||
onChange(hex);
|
||||
};
|
||||
|
||||
const handleRgbaChange = (
|
||||
component: "r" | "g" | "b" | "a",
|
||||
nextValue: string,
|
||||
) => {
|
||||
if (!alpha || !colorValues.rgba) return;
|
||||
|
||||
const numValue = Number.parseFloat(nextValue) || 0;
|
||||
const clampedValue =
|
||||
component === "a"
|
||||
? Math.max(0, Math.min(1, numValue))
|
||||
: Math.max(0, Math.min(255, Math.floor(numValue)));
|
||||
|
||||
const newRgba = { ...colorValues.rgba, [component]: clampedValue };
|
||||
const hex = rgbaToHex(newRgba.r, newRgba.g, newRgba.b, newRgba.a);
|
||||
const hsla = rgbaToHsla(newRgba.r, newRgba.g, newRgba.b, newRgba.a);
|
||||
|
||||
setColorValues({
|
||||
...colorValues,
|
||||
hex: hex.slice(0, 7),
|
||||
rgb: { r: newRgba.r, g: newRgba.g, b: newRgba.b },
|
||||
hsl: rgbToHsl(newRgba.r, newRgba.g, newRgba.b),
|
||||
rgba: newRgba,
|
||||
hsla,
|
||||
});
|
||||
setHexInputValue(hex);
|
||||
onChange(hex);
|
||||
};
|
||||
|
||||
const handleHslChange = (component: "h" | "s" | "l", nextValue: string) => {
|
||||
const numValue = Number.parseInt(nextValue) || 0;
|
||||
const clampedValue =
|
||||
component === "h"
|
||||
? Math.max(0, Math.min(360, numValue))
|
||||
: Math.max(0, Math.min(100, numValue));
|
||||
const newHsl = { ...colorValues.hsl, [component]: clampedValue };
|
||||
const rgb = hslToRgb(newHsl.h, newHsl.s, newHsl.l);
|
||||
const hex = rgbToHex(rgb.r, rgb.g, rgb.b);
|
||||
|
||||
setColorValues({ ...colorValues, hex, rgb, hsl: newHsl });
|
||||
setHexInputValue(hex);
|
||||
onChange(hex);
|
||||
};
|
||||
|
||||
const handleHslaChange = (
|
||||
component: "h" | "s" | "l" | "a",
|
||||
nextValue: string,
|
||||
) => {
|
||||
if (!alpha || !colorValues.hsla) return;
|
||||
|
||||
const numValue = Number.parseFloat(nextValue) || 0;
|
||||
const clampedValue =
|
||||
component === "a"
|
||||
? Math.max(0, Math.min(1, numValue))
|
||||
: component === "h"
|
||||
? Math.max(0, Math.min(360, numValue))
|
||||
: Math.max(0, Math.min(100, numValue));
|
||||
|
||||
const newHsla = { ...colorValues.hsla, [component]: clampedValue };
|
||||
const rgba = hslaToRgba(newHsla.h, newHsla.s, newHsla.l, newHsla.a);
|
||||
const hex = rgbaToHex(rgba.r, rgba.g, rgba.b, rgba.a);
|
||||
|
||||
setColorValues({
|
||||
...colorValues,
|
||||
hex: hex.slice(0, 7),
|
||||
rgb: { r: rgba.r, g: rgba.g, b: rgba.b },
|
||||
hsl: { h: newHsla.h, s: newHsla.s, l: newHsla.l },
|
||||
rgba,
|
||||
hsla: newHsla,
|
||||
});
|
||||
setHexInputValue(hex);
|
||||
onChange(hex);
|
||||
};
|
||||
|
||||
const handlePopoverChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
setColorFormat(alpha ? "HEXA" : "HEX");
|
||||
onBlur();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEyeDropper = async () => {
|
||||
const EyeDropper = window.EyeDropper;
|
||||
if (!EyeDropper) return;
|
||||
try {
|
||||
const eyeDropper = new EyeDropper();
|
||||
const result = await eyeDropper.open();
|
||||
const pickedColor = result.sRGBHex;
|
||||
updateColorValues(pickedColor);
|
||||
onChange(pickedColor);
|
||||
} catch {
|
||||
// User canceled the browser picker.
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Synchronize controlled color value into the picker fields.
|
||||
updateColorValues(value);
|
||||
setHexInputValue(value.toUpperCase());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- updateColorValues intentionally derives all picker state from value.
|
||||
}, [value]);
|
||||
|
||||
const getCurrentHexValue = () => {
|
||||
if (colorFormat === "HEX" || colorFormat === "HEXA") {
|
||||
return hexInputValue;
|
||||
}
|
||||
if (alpha && colorValues.rgba) {
|
||||
return rgbaToHex(
|
||||
colorValues.rgba.r,
|
||||
colorValues.rgba.g,
|
||||
colorValues.rgba.b,
|
||||
colorValues.rgba.a,
|
||||
);
|
||||
}
|
||||
return colorValues.hex;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn(className)}>
|
||||
<Label className="mb-3">{label}</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<Popover onOpenChange={handlePopoverChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
className="border-border relative h-12 w-12 overflow-hidden border shadow-none"
|
||||
size="icon"
|
||||
style={{ backgroundColor: hexInputValue }}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{alpha && colorValues.rgba && colorValues.rgba.a < 1 && (
|
||||
<span
|
||||
className="absolute inset-0 opacity-20"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(45deg, #ccc 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #ccc 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #ccc 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #ccc 75%)`,
|
||||
backgroundSize: "8px 8px",
|
||||
backgroundPosition: "0 0, 0 4px, 4px -4px, -4px 0px",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="sr-only">Open {label} picker</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-3" align="start">
|
||||
<div className="color-picker space-y-3">
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute -top-1.5 -left-1 z-10 flex h-7 w-7 items-center gap-1 bg-transparent hover:bg-transparent"
|
||||
onClick={handleEyeDropper}
|
||||
disabled={!isEyeDropperAvailable()}
|
||||
type="button"
|
||||
>
|
||||
<PipetteIcon className="h-3 w-3" />
|
||||
<span className="sr-only">Pick color from screen</span>
|
||||
</Button>
|
||||
{alpha ? (
|
||||
<HexAlphaColorPicker
|
||||
className="!aspect-square !h-[244.79px] !w-[244.79px]"
|
||||
color={value}
|
||||
onChange={handleColorChange}
|
||||
/>
|
||||
) : (
|
||||
<HexColorPicker
|
||||
className="!aspect-square !h-[244.79px] !w-[244.79px]"
|
||||
color={value}
|
||||
onChange={handleColorChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select value={colorFormat} onValueChange={setColorFormat}>
|
||||
<SelectTrigger className="!h-7 !w-[4.8rem] rounded-sm px-2 py-1 !text-sm">
|
||||
<SelectValue placeholder="Color" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="min-w-20">
|
||||
{alpha ? (
|
||||
<>
|
||||
<SelectItem value="HEXA" className="h-7 text-sm">
|
||||
HEXA
|
||||
</SelectItem>
|
||||
<SelectItem value="RGBA" className="h-7 text-sm">
|
||||
RGBA
|
||||
</SelectItem>
|
||||
<SelectItem value="HSLA" className="h-7 text-sm">
|
||||
HSLA
|
||||
</SelectItem>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SelectItem value="HEX" className="h-7 text-sm">
|
||||
HEX
|
||||
</SelectItem>
|
||||
<SelectItem value="RGB" className="h-7 text-sm">
|
||||
RGB
|
||||
</SelectItem>
|
||||
<SelectItem value="HSL" className="h-7 text-sm">
|
||||
HSL
|
||||
</SelectItem>
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ColorFormatFields
|
||||
alpha={alpha}
|
||||
colorFormat={colorFormat}
|
||||
colorValues={colorValues}
|
||||
currentHexValue={getCurrentHexValue()}
|
||||
handleHexChange={handleHexChange}
|
||||
handleHslChange={handleHslChange}
|
||||
handleHslaChange={handleHslaChange}
|
||||
handleRgbChange={handleRgbChange}
|
||||
handleRgbaChange={handleRgbaChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div className="relative flex-1 sm:flex-none">
|
||||
<Input
|
||||
placeholder={label}
|
||||
value={getCurrentHexValue()}
|
||||
onChange={(event) => handleHexChange(event.target.value)}
|
||||
onBlur={onBlur}
|
||||
className={cn("h-12 uppercase", error && "border-destructive")}
|
||||
/>
|
||||
{isLoading && (
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-4">
|
||||
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-destructive mt-1.5 text-sm">{error}</p>}
|
||||
{hexInputError && (
|
||||
<p className="text-destructive mt-1.5 text-sm">{hexInputError}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorFormatFields({
|
||||
alpha,
|
||||
colorFormat,
|
||||
colorValues,
|
||||
currentHexValue,
|
||||
handleHexChange,
|
||||
handleRgbChange,
|
||||
handleRgbaChange,
|
||||
handleHslChange,
|
||||
handleHslaChange,
|
||||
}: {
|
||||
alpha: boolean;
|
||||
colorFormat: string;
|
||||
colorValues: ColorValues;
|
||||
currentHexValue: string;
|
||||
handleHexChange: (value: string) => void;
|
||||
handleRgbChange: (component: "r" | "g" | "b", value: string) => void;
|
||||
handleRgbaChange: (component: "r" | "g" | "b" | "a", value: string) => void;
|
||||
handleHslChange: (component: "h" | "s" | "l", value: string) => void;
|
||||
handleHslaChange: (component: "h" | "s" | "l" | "a", value: string) => void;
|
||||
}) {
|
||||
if (colorFormat === "HEX" || colorFormat === "HEXA") {
|
||||
return (
|
||||
<Input
|
||||
className="h-7 w-[160px] rounded-sm text-sm"
|
||||
value={currentHexValue}
|
||||
onChange={(event) => handleHexChange(event.target.value)}
|
||||
placeholder={alpha ? "#FF0000FF" : "#FF0000"}
|
||||
maxLength={alpha ? 9 : 7}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (colorFormat === "RGB") {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
className="h-7 w-13 rounded-l-sm rounded-r-none text-center text-sm"
|
||||
value={colorValues.rgb.r}
|
||||
onChange={(event) => handleRgbChange("r", event.target.value)}
|
||||
placeholder="255"
|
||||
maxLength={3}
|
||||
/>
|
||||
<Input
|
||||
className="h-7 w-13 rounded-none border-x-0 text-center text-sm"
|
||||
value={colorValues.rgb.g}
|
||||
onChange={(event) => handleRgbChange("g", event.target.value)}
|
||||
placeholder="255"
|
||||
maxLength={3}
|
||||
/>
|
||||
<Input
|
||||
className="h-7 w-13 rounded-l-none rounded-r-sm text-center text-sm"
|
||||
value={colorValues.rgb.b}
|
||||
onChange={(event) => handleRgbChange("b", event.target.value)}
|
||||
placeholder="255"
|
||||
maxLength={3}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (colorFormat === "RGBA" && alpha && colorValues.rgba) {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
className="h-7 w-10 rounded-l-sm rounded-r-none px-1 text-center text-sm"
|
||||
value={colorValues.rgba.r}
|
||||
onChange={(event) => handleRgbaChange("r", event.target.value)}
|
||||
placeholder="255"
|
||||
maxLength={3}
|
||||
/>
|
||||
<Input
|
||||
className="h-7 w-10 rounded-none border-x-0 px-1 text-center text-sm"
|
||||
value={colorValues.rgba.g}
|
||||
onChange={(event) => handleRgbaChange("g", event.target.value)}
|
||||
placeholder="255"
|
||||
maxLength={3}
|
||||
/>
|
||||
<Input
|
||||
className="h-7 w-10 rounded-none border-x-0 px-1 text-center text-sm"
|
||||
value={colorValues.rgba.b}
|
||||
onChange={(event) => handleRgbaChange("b", event.target.value)}
|
||||
placeholder="255"
|
||||
maxLength={3}
|
||||
/>
|
||||
<Input
|
||||
className="h-7 w-10 rounded-l-none rounded-r-sm px-1 text-center text-sm"
|
||||
value={colorValues.rgba.a.toFixed(2)}
|
||||
onChange={(event) => handleRgbaChange("a", event.target.value)}
|
||||
placeholder="1.00"
|
||||
maxLength={4}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (colorFormat === "HSL") {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
className="h-7 w-13 rounded-l-sm rounded-r-none text-center text-sm"
|
||||
value={colorValues.hsl.h}
|
||||
onChange={(event) => handleHslChange("h", event.target.value)}
|
||||
placeholder="360"
|
||||
maxLength={3}
|
||||
/>
|
||||
<Input
|
||||
className="h-7 w-13 rounded-none border-x-0 text-center text-sm"
|
||||
value={colorValues.hsl.s}
|
||||
onChange={(event) => handleHslChange("s", event.target.value)}
|
||||
placeholder="100"
|
||||
maxLength={3}
|
||||
/>
|
||||
<Input
|
||||
className="h-7 w-13 rounded-l-none rounded-r-sm text-center text-sm"
|
||||
value={colorValues.hsl.l}
|
||||
onChange={(event) => handleHslChange("l", event.target.value)}
|
||||
placeholder="100"
|
||||
maxLength={3}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (colorFormat === "HSLA" && alpha && colorValues.hsla) {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
className="h-7 w-10 rounded-l-sm rounded-r-none px-1 text-center text-sm"
|
||||
value={colorValues.hsla.h}
|
||||
onChange={(event) => handleHslaChange("h", event.target.value)}
|
||||
placeholder="360"
|
||||
maxLength={3}
|
||||
/>
|
||||
<Input
|
||||
className="h-7 w-10 rounded-none border-x-0 px-1 text-center text-sm"
|
||||
value={colorValues.hsla.s}
|
||||
onChange={(event) => handleHslaChange("s", event.target.value)}
|
||||
placeholder="100"
|
||||
maxLength={3}
|
||||
/>
|
||||
<Input
|
||||
className="h-7 w-10 rounded-none border-x-0 px-1 text-center text-sm"
|
||||
value={colorValues.hsla.l}
|
||||
onChange={(event) => handleHslaChange("l", event.target.value)}
|
||||
placeholder="100"
|
||||
maxLength={3}
|
||||
/>
|
||||
<Input
|
||||
className="h-7 w-10 rounded-l-none rounded-r-sm px-1 text-center text-sm"
|
||||
value={colorValues.hsla.a.toFixed(2)}
|
||||
onChange={(event) => handleHslaChange("a", event.target.value)}
|
||||
placeholder="1.00"
|
||||
maxLength={4}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getColorValues(value: string, alpha: boolean): ColorValues {
|
||||
if (alpha) {
|
||||
const rgba = hexToRgba(value);
|
||||
const hsla = rgbaToHsla(rgba.r, rgba.g, rgba.b, rgba.a);
|
||||
return {
|
||||
hex: value.length === 9 ? value.slice(0, 7) : value,
|
||||
rgb: { r: rgba.r, g: rgba.g, b: rgba.b },
|
||||
hsl: rgbToHsl(rgba.r, rgba.g, rgba.b),
|
||||
rgba,
|
||||
hsla,
|
||||
};
|
||||
}
|
||||
|
||||
const rgb = hexToRgb(value);
|
||||
const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
|
||||
return {
|
||||
hex: value.toUpperCase(),
|
||||
rgb,
|
||||
hsl,
|
||||
};
|
||||
}
|
||||
|
||||
function isEyeDropperAvailable() {
|
||||
return typeof window !== "undefined" && Boolean(window.EyeDropper);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { cn } from "~/lib/utils";
|
||||
import * as React from "react";
|
||||
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,168 @@
|
||||
import * as React from "react";
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
function NavigationMenu({
|
||||
className,
|
||||
children,
|
||||
viewport = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
||||
viewport?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Root
|
||||
data-slot="navigation-menu"
|
||||
data-viewport={viewport}
|
||||
className={cn(
|
||||
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{viewport && <NavigationMenuViewport />}
|
||||
</NavigationMenuPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.List
|
||||
data-slot="navigation-menu-list"
|
||||
className={cn(
|
||||
"group flex flex-1 list-none items-center justify-center gap-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Item
|
||||
data-slot="navigation-menu-item"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
"group inline-flex h-9 w-max items-center justify-center bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-foreground-foreground focus:bg-accent focus:text-foreground-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-foreground-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1",
|
||||
);
|
||||
|
||||
function NavigationMenuTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
data-slot="navigation-menu-trigger"
|
||||
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}{" "}
|
||||
<ChevronDownIcon
|
||||
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Content
|
||||
data-slot="navigation-menu-content"
|
||||
className={cn(
|
||||
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
|
||||
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu: group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuViewport({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-full left-0 isolate z-50 flex justify-center",
|
||||
)}
|
||||
>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
data-slot="navigation-menu-viewport"
|
||||
className={cn(
|
||||
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuLink({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Link
|
||||
data-slot="navigation-menu-link"
|
||||
className={cn(
|
||||
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-foreground-foreground hover:bg-accent hover:text-foreground-foreground focus:bg-accent focus:text-foreground-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuIndicator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
data-slot="navigation-menu-indicator"
|
||||
className={cn(
|
||||
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
navigationMenuTriggerStyle,
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface NumberInputProps {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
width?: "auto" | "full";
|
||||
}
|
||||
|
||||
export function NumberInput({
|
||||
value,
|
||||
onChange,
|
||||
min = 0,
|
||||
max,
|
||||
step = 1,
|
||||
placeholder = "0",
|
||||
className,
|
||||
disabled = false,
|
||||
id,
|
||||
prefix,
|
||||
suffix,
|
||||
width = "auto",
|
||||
}: NumberInputProps) {
|
||||
const [displayValue, setDisplayValue] = React.useState(
|
||||
value ? value.toFixed(2) : "0.00",
|
||||
);
|
||||
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Only update display value if the input is NOT focused
|
||||
if (document.activeElement !== inputRef.current) {
|
||||
setDisplayValue(value ? value.toFixed(2) : "0.00");
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const inputValue = e.target.value;
|
||||
setDisplayValue(inputValue);
|
||||
|
||||
if (inputValue === "") {
|
||||
onChange(0);
|
||||
return;
|
||||
}
|
||||
const newValue = parseFloat(inputValue);
|
||||
if (!isNaN(newValue)) {
|
||||
onChange(Math.round(newValue * 100) / 100);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
const numValue = parseFloat(displayValue) || 0;
|
||||
const formattedValue = numValue.toFixed(2);
|
||||
setDisplayValue(formattedValue);
|
||||
onChange(numValue);
|
||||
};
|
||||
|
||||
const handleIncrement = () => {
|
||||
if (disabled) return;
|
||||
onChange((value || 0) + step);
|
||||
};
|
||||
|
||||
const handleDecrement = () => {
|
||||
if (disabled) return;
|
||||
onChange(Math.max(min, (value || 0) - step));
|
||||
};
|
||||
|
||||
const widthClass = width === "full" ? "w-full" : "w-24 min-w-24";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background border-input flex h-9 items-center justify-center rounded-md border text-sm shadow-none",
|
||||
widthClass,
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDecrement}
|
||||
disabled={disabled || value <= min}
|
||||
className="text-muted-foreground hover:text-foreground flex h-full w-8 items-center justify-center rounded-l-md disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
{prefix && (
|
||||
<span className="text-muted-foreground text-xs">{prefix}</span>
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={displayValue}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className="number-input-field w-full border-0 bg-transparent text-center ring-0 outline-none focus:border-transparent focus:ring-0 focus:outline-none focus-visible:ring-0"
|
||||
/>
|
||||
{suffix && (
|
||||
<span className="text-muted-foreground text-xs">{suffix}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleIncrement}
|
||||
disabled={disabled || (max !== undefined && value >= max)}
|
||||
className="text-muted-foreground hover:text-foreground flex h-full w-8 items-center justify-center rounded-r-md disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
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 w-72 origin-(--radix-popover-content-transform-origin) border p-4 shadow-md outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Progress };
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface ScrollAreaProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ScrollArea = React.forwardRef<HTMLDivElement, ScrollAreaProps>(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"scrollbar-thin scrollbar-track-transparent scrollbar-thumb-border hover:scrollbar-thumb-border/80 relative overflow-auto",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
);
|
||||
ScrollArea.displayName = "ScrollArea";
|
||||
|
||||
export { ScrollArea };
|
||||
@@ -0,0 +1,409 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"data-[placeholder]:text-muted-foreground border-input bg-background text-foreground focus-visible:border-ring focus-visible:ring-ring/50 relative flex h-10 w-full items-center justify-start gap-2 rounded-md border px-3 py-2 pr-8 text-left text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-left">{children}</span>
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-2 flex items-center">
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</span>
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto 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,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-foreground-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
// Enhanced SelectContent with search functionality
|
||||
function SelectContentWithSearch({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
searchPlaceholder = "Search...",
|
||||
onSearchChange,
|
||||
searchValue,
|
||||
isOpen,
|
||||
filteredOptions,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content> & {
|
||||
searchPlaceholder?: string;
|
||||
onSearchChange?: (value: string) => void;
|
||||
searchValue?: string;
|
||||
isOpen?: boolean;
|
||||
filteredOptions?: { value: string; label: string }[];
|
||||
}) {
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const wasOpen = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Only focus when dropdown transitions from closed to open
|
||||
if (isOpen && !wasOpen.current && searchInputRef.current) {
|
||||
searchInputRef.current.focus();
|
||||
}
|
||||
wasOpen.current = !!isOpen;
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-hidden 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,
|
||||
)}
|
||||
position={position}
|
||||
onEscapeKeyDown={(e) => {
|
||||
// Prevent escape from closing the dropdown when typing
|
||||
if (searchValue) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
onPointerDownOutside={(e) => {
|
||||
// Prevent closing when clicking inside the search input
|
||||
if (searchInputRef.current?.contains(e.target as Node)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{onSearchChange && (
|
||||
<div className="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}
|
||||
className="placeholder:text-muted-foreground text-foreground flex h-8 w-full border-0 bg-transparent py-2 text-sm outline-none focus:ring-0 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Prevent the dropdown from closing when typing
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
}
|
||||
// Prevent arrow keys from moving focus away from search
|
||||
if (
|
||||
["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(
|
||||
e.key,
|
||||
)
|
||||
) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
// Ensure the search input stays focused
|
||||
e.target.select();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport className="p-1">
|
||||
{filteredOptions?.length === 0 ? (
|
||||
<div className="text-muted-foreground px-3 py-2 text-sm select-none">
|
||||
No results found
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
// Searchable Select component
|
||||
interface SearchableSelectProps {
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
options: { value: string; label: string; 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({
|
||||
value,
|
||||
onValueChange,
|
||||
placeholder,
|
||||
options,
|
||||
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) => {
|
||||
// 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
|
||||
const displayValue = value === "" ? "__placeholder__" : value;
|
||||
|
||||
// Convert placeholder value back to empty string when selected
|
||||
const handleValueChange = (newValue: string) => {
|
||||
const actualValue = newValue === "__placeholder__" ? "" : newValue;
|
||||
onValueChange?.(actualValue);
|
||||
// Clear search when an option is selected
|
||||
setSearchValue("");
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={displayValue}
|
||||
onValueChange={handleValueChange}
|
||||
disabled={disabled}
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
>
|
||||
<SelectTrigger className={cn("w-full", className)} id={id}>
|
||||
<SelectValue
|
||||
placeholder={placeholder}
|
||||
// Always show placeholder if nothing is selected
|
||||
data-placeholder={displayValue === "__placeholder__"}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContentWithSearch
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={setSearchValue}
|
||||
isOpen={isOpen}
|
||||
filteredOptions={filteredOptions}
|
||||
>
|
||||
{filteredOptions.map((option) => {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectContentWithSearch,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SearchableSelect,
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto rounded-b-xl border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto rounded-t-xl border-t",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("bg-muted animate-pulse", className)} {...props} />;
|
||||
}
|
||||
|
||||
// Modern dashboard skeleton components
|
||||
export function DashboardStatsSkeleton() {
|
||||
return (
|
||||
<div className="mb-8 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="border border-gray-100 bg-white p-6 shadow-sm">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Skeleton className="h-9 w-9" />
|
||||
<Skeleton className="h-4 w-12" />
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton className="mb-2 h-8 w-20" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardCardsSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<div key={i} className="border border-gray-100 bg-white p-6 shadow-sm">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-5 rounded" />
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-20" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-9 flex-1" />
|
||||
<Skeleton className="h-9 flex-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardActivitySkeleton() {
|
||||
return (
|
||||
<div className="border border-gray-100 bg-white p-6 shadow-sm">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-5 rounded" />
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between border border-gray-100 p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-6 w-16" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-8 w-8 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardHeroSkeleton() {
|
||||
return (
|
||||
<div className="relative mb-8 overflow-hidden rounded-3xl bg-gradient-to-br from-gray-200 to-gray-300 p-8">
|
||||
<div className="relative z-10">
|
||||
<Skeleton className="mb-2 h-9 w-64" />
|
||||
<Skeleton className="h-6 w-80" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuickActionsSkeleton() {
|
||||
return (
|
||||
<div className="border border-gray-100 bg-white p-6 shadow-sm">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-5 rounded" />
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="border border-gray-200 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-5 w-5" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -0,0 +1,352 @@
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface SkeletonProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Skeleton({ className }: SkeletonProps) {
|
||||
return (
|
||||
<div className={cn("skeleton bg-muted animate-pulse rounded", className)} />
|
||||
);
|
||||
}
|
||||
|
||||
// Page Header Skeleton
|
||||
export function PageHeaderSkeleton() {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-5 w-96" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-10 w-24" />
|
||||
<Skeleton className="h-10 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Invoice Items Skeleton
|
||||
export function InvoiceItemsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="rounded-lg border p-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-6 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Recent Activity Skeleton
|
||||
export function RecentActivitySkeleton() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-muted/50 border-foreground/20 rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded" />
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-12 rounded-full" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Current Work Skeleton
|
||||
export function CurrentWorkSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-5" />
|
||||
<Skeleton className="h-6 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-20 rounded-full" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-8 w-24" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-8 flex-1" />
|
||||
<Skeleton className="h-8 flex-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Client Info Skeleton
|
||||
export function ClientInfoSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Skeleton className="h-8 w-8" />
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-4 w-12" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Skeleton className="h-8 w-8" />
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-4 w-12" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton className="mb-4 h-6 w-32" />
|
||||
<div className="flex items-start space-x-3">
|
||||
<Skeleton className="h-8 w-8" />
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-4 w-36" />
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Stats Summary Skeleton
|
||||
export function StatsSummarySkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2 text-center">
|
||||
<Skeleton className="mx-auto h-8 w-24" />
|
||||
<Skeleton className="mx-auto h-4 w-20" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-center">
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="mx-auto h-6 w-8" />
|
||||
<Skeleton className="mx-auto h-3 w-12" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="mx-auto h-6 w-8" />
|
||||
<Skeleton className="mx-auto h-3 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Recent Invoices Skeleton
|
||||
export function RecentInvoicesSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="card-secondary hover:bg-muted/50 border p-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-5 w-12 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Settings Form Skeleton
|
||||
export function SettingsFormSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-10 w-24" />
|
||||
<Skeleton className="h-10 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Data Stats Skeleton
|
||||
export function DataStatsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="bg-card border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Invoice Summary Skeleton
|
||||
export function InvoiceSummarySkeleton() {
|
||||
return (
|
||||
<div className="bg-muted/30 rounded-lg p-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
<div className="bg-border h-px" />
|
||||
<div className="flex justify-between">
|
||||
<Skeleton className="h-5 w-12" />
|
||||
<Skeleton className="h-5 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Actions Sidebar Skeleton
|
||||
export function ActionsSidebarSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Form Section Skeleton
|
||||
export function FormSectionSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Line Items Table Skeleton
|
||||
export function LineItemsTableSkeleton() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="grid grid-cols-5 gap-4 rounded border p-3">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Business Card Skeleton
|
||||
export function BusinessCardSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4 rounded-lg border p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Generic Card Grid Skeleton
|
||||
export function CardGridSkeleton({ count = 6 }: { count?: number }) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className="rounded-lg border p-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8" />
|
||||
<Skeleton className="h-5 w-24" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user