Add 'apps/web/' from commit '1e7174fa604b11e7c3983cd8ad01c596f6e77e96'

git-subtree-dir: apps/web
git-subtree-mainline: 068a51b46b
git-subtree-split: 1e7174fa60
This commit is contained in:
2026-08-16 21:42:59 -04:00
350 changed files with 62192 additions and 0 deletions
+32
View File
@@ -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>
);
}
+122
View File
@@ -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>
);
}
+744
View File
@@ -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>
);
}
+105
View File
@@ -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>
&ldquo;{receipt.originalFilename}&rdquo; 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&apos;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&apos;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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.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>
);
}
+87
View File
@@ -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;
}
+279
View File
@@ -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 Services 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&apos;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&apos;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&apos;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&apos;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&apos;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}
/>
))}
</>
);
}
+157
View File
@@ -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,
};
+61
View File
@@ -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 };
+50
View File
@@ -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 };
+36
View File
@@ -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 };
+109
View File
@@ -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,
};
+57
View File
@@ -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 };
+223
View File
@@ -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 };
+92
View File
@@ -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,
};
+32
View File
@@ -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 };
+26
View File
@@ -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>;
}
+147
View File
@@ -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>
);
}
+143
View File
@@ -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>
);
}
+568
View File
@@ -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);
}
+23
View File
@@ -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 };
+24
View File
@@ -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,
};
+128
View File
@@ -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>
);
}
+48
View File
@@ -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 };
+31
View File
@@ -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 };
+409
View File
@@ -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,
};
+31
View File
@@ -0,0 +1,31 @@
"use client";
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "~/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className,
)}
{...props}
/>
),
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };
+139
View File
@@ -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,
};
+129
View File
@@ -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 };
+352
View File
@@ -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