Update date picker, mobile styling

This commit is contained in:
2025-07-16 03:27:56 -04:00
parent 76711d2c10
commit c6fa9c4ac1
41 changed files with 3522 additions and 1431 deletions

View File

@@ -10,7 +10,12 @@ interface AddressAutocompleteProps {
placeholder?: string;
}
export function AddressAutocomplete({ value, onChange, onSelect, placeholder }: AddressAutocompleteProps) {
export function AddressAutocomplete({
value,
onChange,
onSelect,
placeholder,
}: AddressAutocompleteProps) {
const [suggestions, setSuggestions] = useState<any[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -20,7 +25,9 @@ export function AddressAutocomplete({ value, onChange, onSelect, placeholder }:
setSuggestions([]);
return;
}
const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}`);
const res = await fetch(
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}`,
);
const data = await res.json();
setSuggestions(data);
};
@@ -50,12 +57,12 @@ export function AddressAutocomplete({ value, onChange, onSelect, placeholder }:
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
/>
{showSuggestions && suggestions.length > 0 && (
<Card className="absolute z-10 mt-1 w-full max-h-60 overflow-auto shadow-lg border bg-white">
<Card className="card-primary absolute z-10 mt-1 max-h-60 w-full overflow-auto">
<ul>
{suggestions.map((s, i) => (
<li
key={s.place_id}
className="px-4 py-2 cursor-pointer hover:bg-muted text-sm"
className="hover:bg-muted cursor-pointer px-4 py-2 text-sm"
onMouseDown={() => handleSelect(s.display_name)}
>
{s.display_name}
@@ -66,4 +73,4 @@ export function AddressAutocomplete({ value, onChange, onSelect, placeholder }:
)}
</div>
);
}
}

View File

@@ -438,9 +438,9 @@ export function CSVImportPage() {
return (
<div className="space-y-6">
{/* Global Client Selection */}
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm">
<Card className="card-primary">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-emerald-800">
<CardTitle className="card-title-primary">
<Users className="h-5 w-5" />
Default Client
</CardTitle>
@@ -460,7 +460,7 @@ export function CSVImportPage() {
applyGlobalClient(newClientId);
}
}}
className="h-12 w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-gray-700 focus:border-emerald-500 focus:ring-emerald-500"
className="h-12 w-full rounded-md border px-3 py-2"
disabled={loadingClients}
>
<option value="">No default client (select individually)</option>
@@ -470,7 +470,7 @@ export function CSVImportPage() {
</option>
))}
</select>
<p className="text-xs text-gray-500">
<p className="text-muted text-xs">
This client will be automatically selected for all uploaded files.
You can still change individual files below.
</p>
@@ -479,9 +479,9 @@ export function CSVImportPage() {
</Card>
{/* File Upload Area */}
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm">
<Card className="card-primary">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-emerald-800">
<CardTitle className="card-title-primary">
<Upload className="h-5 w-5" />
Upload CSV Files
</CardTitle>
@@ -500,32 +500,32 @@ export function CSVImportPage() {
{totalFiles > 0 && (
<div className="grid grid-cols-2 gap-4 rounded-lg bg-emerald-50/50 p-4 md:grid-cols-4">
<div className="text-center">
<div className="text-2xl font-bold text-emerald-600">
<div className="text-icon-emerald text-2xl font-bold">
{totalFiles}
</div>
<div className="text-sm text-gray-600">Files</div>
<div className="text-xs text-gray-500">of 50 max</div>
<div className="text-secondary text-sm">Files</div>
<div className="text-muted text-xs">of 50 max</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-emerald-600">
<div className="text-icon-emerald text-2xl font-bold">
{totalItems}
</div>
<div className="text-sm text-gray-600">Total Items</div>
<div className="text-secondary text-sm">Total Items</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-emerald-600">
<div className="text-icon-emerald text-2xl font-bold">
{totalAmount.toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}
</div>
<div className="text-sm text-gray-600">Total Amount</div>
<div className="text-secondary text-sm">Total Amount</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-emerald-600">
<div className="text-icon-emerald text-2xl font-bold">
{readyFiles}/{totalFiles}
</div>
<div className="text-sm text-gray-600">Ready</div>
<div className="text-secondary text-sm">Ready</div>
</div>
</div>
)}
@@ -534,9 +534,9 @@ export function CSVImportPage() {
{/* File List */}
{files.length > 0 && (
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm">
<Card className="card-primary">
<CardHeader>
<CardTitle className="text-emerald-800">Uploaded Files</CardTitle>
<CardTitle className="text-brand-light">Uploaded Files</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
@@ -547,12 +547,12 @@ export function CSVImportPage() {
>
<div className="mb-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<FileText className="h-5 w-5 text-emerald-600" />
<FileText className="text-icon-emerald h-5 w-5" />
<div>
<h3 className="truncate font-medium text-gray-900">
<h3 className="text-accent truncate font-medium">
{fileData.file.name}
</h3>
<p className="text-sm text-gray-500">
<p className="text-muted text-sm">
{fileData.parsedItems.length} items {" "}
{fileData.parsedItems
.reduce((sum, item) => sum + item.hours, 0)
@@ -574,7 +574,7 @@ export function CSVImportPage() {
variant="outline"
size="sm"
onClick={() => removeFile(index)}
className="text-red-600 hover:text-red-700"
className="text-icon-red hover:text-error"
>
<Trash2 className="mr-1 h-4 w-4" />
Remove
@@ -584,7 +584,7 @@ export function CSVImportPage() {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-2">
<Label className="text-xs font-medium text-gray-700">
<Label className="text-secondary text-xs font-medium">
Invoice Number
</Label>
<Input
@@ -614,7 +614,7 @@ export function CSVImportPage() {
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-gray-700">
<Label className="text-secondary text-xs font-medium">
Issue Date
</Label>
<DatePicker
@@ -628,7 +628,7 @@ export function CSVImportPage() {
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-gray-700">
<Label className="text-secondary text-xs font-medium">
Due Date
</Label>
<DatePicker
@@ -646,18 +646,18 @@ export function CSVImportPage() {
{fileData.errors.length > 0 && (
<div className="mt-4 rounded-lg border border-red-200 bg-red-50 p-3">
<div className="mb-2 flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-red-600" />
<span className="text-sm font-medium text-red-800">
<AlertCircle className="text-icon-red h-4 w-4" />
<span className="text-error text-sm font-medium">
Issues Found
</span>
</div>
<ul className="space-y-1 text-sm text-red-700">
<ul className="text-error space-y-1 text-sm">
{fileData.errors.map((error, errorIndex) => (
<li
key={errorIndex}
className="flex items-start gap-2"
>
<span className="text-red-600"></span>
<span className="text-icon-red"></span>
<span>{error}</span>
</li>
))}
@@ -677,7 +677,7 @@ export function CSVImportPage() {
</div>
<div className="flex items-center gap-2">
{fileData.errors.length > 0 && (
<Badge variant="destructive" className="text-xs">
<Badge className="badge-error text-xs">
{fileData.errors.length} Error
{fileData.errors.length !== 1 ? "s" : ""}
</Badge>
@@ -712,7 +712,7 @@ export function CSVImportPage() {
{/* Batch Actions */}
{files.length > 0 && (
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm">
<Card className="card-primary">
<CardContent>
<div className="flex flex-col gap-4">
{isProcessing && (
@@ -732,7 +732,7 @@ export function CSVImportPage() {
<Button
onClick={processBatch}
disabled={readyFiles === 0 || isProcessing}
className="bg-gradient-to-r from-emerald-600 to-teal-600 text-white hover:from-emerald-700 hover:to-teal-700"
className="btn-brand-primary"
>
{isProcessing
? "Processing..."
@@ -746,7 +746,7 @@ export function CSVImportPage() {
{/* Preview Modal */}
<Dialog open={previewModalOpen} onOpenChange={setPreviewModalOpen}>
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col border-0 bg-white/95 shadow-2xl backdrop-blur-sm">
<DialogContent className="card-primary flex max-h-[90vh] max-w-4xl flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2 text-xl font-bold text-gray-800">
<FileText className="h-5 w-5 text-emerald-600" />
@@ -834,7 +834,7 @@ export function CSVImportPage() {
currency: "USD",
})}
</td>
<td className="p-2 text-right font-medium whitespace-nowrap text-gray-600">
<td className="text-secondary p-2 text-right font-medium whitespace-nowrap">
{item.amount.toLocaleString("en-US", {
style: "currency",
currency: "USD",

View File

@@ -3,13 +3,35 @@
import { useState } from "react";
import Link from "next/link";
import { api } from "~/trpc/react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { toast } from "sonner";
import { Mail, Phone, MapPin, Edit, Trash2, Eye, Plus, Search } from "lucide-react";
import {
Mail,
Phone,
MapPin,
Edit,
Trash2,
Eye,
Plus,
Search,
} from "lucide-react";
export function ClientList() {
const [searchTerm, setSearchTerm] = useState("");
@@ -29,10 +51,12 @@ export function ClientList() {
},
});
const filteredClients = clients?.filter(client =>
client.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
client.email?.toLowerCase().includes(searchTerm.toLowerCase())
) ?? [];
const filteredClients =
clients?.filter(
(client) =>
client.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
client.email?.toLowerCase().includes(searchTerm.toLowerCase()),
) ?? [];
const handleDelete = (clientId: string) => {
setClientToDelete(clientId);
@@ -49,14 +73,14 @@ export function ClientList() {
return (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{[...Array(3)].map((_, i: number) => (
<Card key={i} className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
<Card key={i} className="card-primary">
<CardHeader>
<div className="h-4 bg-gray-200 rounded animate-pulse" />
<div className="h-4 animate-pulse rounded bg-gray-200" />
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="h-3 bg-gray-200 rounded animate-pulse" />
<div className="h-3 bg-gray-200 rounded w-2/3 animate-pulse" />
<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>
@@ -67,9 +91,9 @@ export function ClientList() {
if (!clients || clients.length === 0) {
return (
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
<Card className="card-primary">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">
<CardTitle className="text-brand-gradient text-2xl font-bold">
No Clients Yet
</CardTitle>
<CardDescription className="text-lg">
@@ -78,9 +102,7 @@ export function ClientList() {
</CardHeader>
<CardContent className="text-center">
<Link href="/dashboard/clients/new">
<Button
className="w-full h-12 bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
>
<Button variant="brand" className="h-12 w-full">
<Plus className="mr-2 h-4 w-4" />
Add Your First Client
</Button>
@@ -92,24 +114,24 @@ export function ClientList() {
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
<div className="flex-1 relative">
<Label htmlFor="search" className="sr-only">Search clients</Label>
<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="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<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="pl-10 h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
className="h-12 pl-10"
/>
</div>
</div>
<Link href="/dashboard/clients/new">
<Button
className="w-full sm:w-auto h-12 bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
>
<Button variant="brand" className="h-12 w-full sm:w-auto">
<Plus className="mr-2 h-4 w-4" />
Add Client
</Button>
@@ -118,20 +140,23 @@ export function ClientList() {
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{filteredClients.map((client) => (
<Card key={client.id} className="shadow-xl border-0 bg-white/80 backdrop-blur-sm hover:shadow-2xl transition-all duration-300 group">
<Card
key={client.id}
className="group card-primary transition-all duration-300 hover:shadow-lg"
>
<CardHeader>
<CardTitle className="flex items-center justify-between text-lg">
<span className="font-semibold text-gray-800 group-hover:text-emerald-600 transition-colors">
<span className="text-accent group-hover:text-icon-emerald font-semibold transition-colors">
{client.name}
</span>
<div className="flex space-x-1 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="flex space-x-1 opacity-0 transition-opacity group-hover:opacity-100">
<Link href={`/clients/${client.id}`}>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 hover:bg-emerald-100">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<Eye className="h-4 w-4" />
</Button>
</Link>
<Link href={`/clients/${client.id}/edit`}>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 hover:bg-emerald-100">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<Edit className="h-4 w-4" />
</Button>
</Link>
@@ -139,7 +164,7 @@ export function ClientList() {
variant="ghost"
size="sm"
onClick={() => handleDelete(client.id)}
className="h-8 w-8 p-0 hover:bg-red-100 hover:text-red-600"
className="hover:bg-error-subtle hover:text-icon-red h-8 w-8 p-0"
>
<Trash2 className="h-4 w-4" />
</Button>
@@ -148,32 +173,34 @@ export function ClientList() {
</CardHeader>
<CardContent className="space-y-3">
{client.email && (
<div className="flex items-center text-sm text-gray-600">
<div className="p-1.5 bg-emerald-100 rounded mr-3">
<Mail className="h-3 w-3 text-emerald-600" />
<div className="text-secondary flex items-center text-sm">
<div className="bg-brand-muted mr-3 rounded p-1.5">
<Mail className="text-icon-emerald h-3 w-3" />
</div>
{client.email}
</div>
)}
{client.phone && (
<div className="flex items-center text-sm text-gray-600">
<div className="p-1.5 bg-blue-100 rounded mr-3">
<Phone className="h-3 w-3 text-blue-600" />
<div className="text-secondary flex items-center text-sm">
<div className="bg-brand-muted-blue mr-3 rounded p-1.5">
<Phone className="text-icon-blue h-3 w-3" />
</div>
{client.phone}
</div>
)}
{(client.addressLine1 ?? client.city ?? client.state) && (
<div className="flex items-start text-sm text-gray-600">
<div className="p-1.5 bg-teal-100 rounded mr-3 mt-0.5 flex-shrink-0">
<MapPin className="h-3 w-3 text-teal-600" />
<div className="text-secondary flex items-start text-sm">
<div className="bg-brand-muted-teal mt-0.5 mr-3 flex-shrink-0 rounded p-1.5">
<MapPin className="text-icon-teal 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(", ")}
{[client.city, client.state, client.postalCode]
.filter(Boolean)
.join(", ")}
</div>
)}
{client.country && <div>{client.country}</div>}
@@ -186,23 +213,26 @@ export function ClientList() {
</div>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent className="bg-white/95 backdrop-blur-sm border-0 shadow-2xl">
<DialogContent className="card-primary">
<DialogHeader>
<DialogTitle className="text-xl font-bold text-gray-800">Delete Client</DialogTitle>
<DialogDescription className="text-gray-600">
Are you sure you want to delete this client? This action cannot be undone.
<DialogTitle className="text-accent text-xl font-bold">
Delete Client
</DialogTitle>
<DialogDescription className="text-secondary">
Are you sure you want to delete this client? This action cannot be
undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
className="border-gray-300 text-gray-700 hover:bg-gray-50"
className="text-secondary"
>
Cancel
</Button>
<Button
variant="destructive"
<Button
variant="destructive"
onClick={confirmDelete}
className="bg-red-600 hover:bg-red-700"
>
@@ -213,4 +243,4 @@ export function ClientList() {
</Dialog>
</div>
);
}
}

View File

@@ -6,19 +6,20 @@ import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { Skeleton } from "~/components/ui/skeleton";
import {
FileText,
Clock,
Plus,
Edit,
import {
FileText,
Clock,
Plus,
Edit,
Eye,
DollarSign,
User,
Calendar
Calendar,
} from "lucide-react";
export function CurrentOpenInvoiceCard() {
const { data: currentInvoice, isLoading } = api.invoices.getCurrentOpen.useQuery();
const { data: currentInvoice, isLoading } =
api.invoices.getCurrentOpen.useQuery();
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-US", {
@@ -36,10 +37,10 @@ export function CurrentOpenInvoiceCard() {
if (isLoading) {
return (
<Card className="border-0 shadow-sm">
<Card className="card-primary">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<FileText className="h-5 w-5 text-emerald-600" />
<CardTitle className="card-title-secondary">
<FileText className="text-icon-emerald h-5 w-5" />
Current Open Invoice
</CardTitle>
</CardHeader>
@@ -57,20 +58,21 @@ export function CurrentOpenInvoiceCard() {
if (!currentInvoice) {
return (
<Card className="border-0 shadow-sm">
<Card className="card-primary">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<FileText className="h-5 w-5 text-emerald-600" />
<CardTitle className="card-title-secondary">
<FileText className="text-icon-emerald h-5 w-5" />
Current Open Invoice
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-center py-6">
<FileText className="mx-auto mb-3 h-8 w-8 text-muted-foreground" />
<p className="text-muted-foreground text-sm mb-4">
No open invoice found. Create a new invoice to start tracking your time.
<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 className="bg-gradient-to-r from-emerald-600 to-teal-600 text-white hover:from-emerald-700 hover:to-teal-700">
<Button asChild variant="brand">
<Link href="/dashboard/invoices/new">
<Plus className="mr-2 h-4 w-4" />
Create New Invoice
@@ -82,14 +84,15 @@ export function CurrentOpenInvoiceCard() {
);
}
const totalHours = currentInvoice.items?.reduce((sum, item) => sum + item.hours, 0) ?? 0;
const totalHours =
currentInvoice.items?.reduce((sum, item) => sum + item.hours, 0) ?? 0;
const totalAmount = currentInvoice.totalAmount;
return (
<Card className="border-0 shadow-sm">
<Card className="card-primary">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<FileText className="h-5 w-5 text-emerald-600" />
<CardTitle className="card-title-secondary">
<FileText className="text-icon-emerald h-5 w-5" />
Current Open Invoice
</CardTitle>
</CardHeader>
@@ -97,15 +100,13 @@ export function CurrentOpenInvoiceCard() {
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs">
<Badge className="badge-secondary text-xs">
{currentInvoice.invoiceNumber}
</Badge>
<Badge variant="outline" className="text-xs">
Draft
</Badge>
<Badge className="badge-outline text-xs">Draft</Badge>
</div>
<div className="text-right">
<p className="text-sm font-medium text-emerald-600">
<p className="text-icon-emerald text-sm font-medium">
{formatCurrency(totalAmount)}
</p>
</div>
@@ -113,19 +114,21 @@ export function CurrentOpenInvoiceCard() {
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<User className="h-3 w-3 text-muted-foreground" />
<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="h-3 w-3 text-muted-foreground" />
<Calendar className="text-muted-foreground h-3 w-3" />
<span className="text-muted-foreground">Due:</span>
<span className="font-medium">{formatDate(currentInvoice.dueDate)}</span>
<span className="font-medium">
{formatDate(currentInvoice.dueDate)}
</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Clock className="h-3 w-3 text-muted-foreground" />
<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>
@@ -139,7 +142,7 @@ export function CurrentOpenInvoiceCard() {
View
</Link>
</Button>
<Button asChild size="sm" className="flex-1 bg-gradient-to-r from-emerald-600 to-teal-600 text-white hover:from-emerald-700 hover:to-teal-700">
<Button asChild variant="brand" size="sm" className="flex-1">
<Link href={`/dashboard/invoices/${currentInvoice.id}/edit`}>
<Edit className="mr-2 h-3 w-3" />
Continue
@@ -149,4 +152,4 @@ export function CurrentOpenInvoiceCard() {
</CardContent>
</Card>
);
}
}

View File

@@ -106,10 +106,10 @@ export function DataTable<TData, TValue>({
const checkMobile = () => {
setIsMobile(window.innerWidth < 640); // sm breakpoint
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
}, []);
// Create responsive columns that properly hide on mobile
@@ -118,9 +118,23 @@ export function DataTable<TData, TValue>({
...column,
// Add a meta property to control responsive visibility
meta: {
...((column as ColumnDef<TData, TValue> & { meta?: { headerClassName?: string; cellClassName?: string } }).meta ?? {}),
headerClassName: (column as ColumnDef<TData, TValue> & { meta?: { headerClassName?: string; cellClassName?: string } }).meta?.headerClassName ?? "",
cellClassName: (column as ColumnDef<TData, TValue> & { meta?: { headerClassName?: string; cellClassName?: string } }).meta?.cellClassName ?? "",
...((
column as ColumnDef<TData, TValue> & {
meta?: { headerClassName?: string; cellClassName?: string };
}
).meta ?? {}),
headerClassName:
(
column as ColumnDef<TData, TValue> & {
meta?: { headerClassName?: string; cellClassName?: string };
}
).meta?.headerClassName ?? "",
cellClassName:
(
column as ColumnDef<TData, TValue> & {
meta?: { headerClassName?: string; cellClassName?: string };
}
).meta?.cellClassName ?? "",
},
}));
}, [columns]);
@@ -163,15 +177,16 @@ export function DataTable<TData, TValue>({
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"]');
const isActionButton =
target.closest('[data-action-button="true"]') ??
target.closest("button") ??
target.closest("a") ??
target.closest('[role="button"]');
if (isActionButton) {
return;
}
onRowClick?.(row);
};
@@ -200,7 +215,7 @@ export function DataTable<TData, TValue>({
{/* Filter Bar Card */}
{(showSearch || filterableColumns.length > 0 || showColumnVisibility) && (
<Card className="border-0 py-2 shadow-sm">
<Card className="card-primary py-2">
<CardContent className="px-3 py-0">
<div className="flex items-center gap-2">
{showSearch && (
@@ -300,7 +315,7 @@ export function DataTable<TData, TValue>({
)}
{/* Table Content Card */}
<Card className="overflow-hidden border-0 p-0 shadow-sm">
<Card className="card-primary overflow-hidden p-0">
<div className="w-full overflow-x-auto">
<Table>
<TableHeader>
@@ -310,7 +325,9 @@ export function DataTable<TData, TValue>({
className="bg-muted/50 hover:bg-muted/50"
>
{headerGroup.headers.map((header) => {
const meta = header.column.columnDef.meta as { headerClassName?: string; cellClassName?: string } | undefined;
const meta = header.column.columnDef.meta as
| { headerClassName?: string; cellClassName?: string }
| undefined;
return (
<TableHead
key={header.id}
@@ -339,12 +356,16 @@ export function DataTable<TData, TValue>({
data-state={row.getIsSelected() && "selected"}
className={cn(
"hover:bg-muted/20 data-[state=selected]:bg-muted/50 border-b transition-colors",
onRowClick && "cursor-pointer"
onRowClick && "cursor-pointer",
)}
onClick={(event) => onRowClick && handleRowClick(row.original, event)}
onClick={(event) =>
onRowClick && handleRowClick(row.original, event)
}
>
{row.getVisibleCells().map((cell) => {
const meta = cell.column.columnDef.meta as { headerClassName?: string; cellClassName?: string } | undefined;
const meta = cell.column.columnDef.meta as
| { headerClassName?: string; cellClassName?: string }
| undefined;
return (
<TableCell
key={cell.id}
@@ -379,7 +400,7 @@ export function DataTable<TData, TValue>({
{/* Pagination Bar Card */}
{showPagination && (
<Card className="border-0 py-2 shadow-sm">
<Card className="card-primary py-2">
<CardContent className="px-3 py-0">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
@@ -540,7 +561,7 @@ export function DataTableSkeleton({
return (
<div className="space-y-4">
{/* Filter bar skeleton */}
<Card className="border-0 py-2 shadow-sm">
<Card className="card-primary py-2">
<CardContent className="px-3 py-0">
<div className="flex items-center gap-2">
<div className="bg-muted/30 h-9 w-full flex-1 animate-pulse rounded-md sm:max-w-sm"></div>
@@ -550,7 +571,7 @@ export function DataTableSkeleton({
</Card>
{/* Table skeleton */}
<Card className="overflow-hidden border-0 p-0 shadow-sm">
<Card className="card-primary overflow-hidden p-0">
<div className="w-full overflow-x-auto">
<Table>
<TableHeader>
@@ -562,16 +583,16 @@ export function DataTableSkeleton({
<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 sm:table-cell h-12 px-3 text-left align-middle sm:h-14 sm:px-4">
<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 sm:table-cell h-12 px-3 text-left align-middle sm:h-14 sm:px-4">
<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 lg:table-cell h-12 px-3 text-left align-middle sm:h-14 sm:px-4">
<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>
@@ -588,11 +609,11 @@ export function DataTableSkeleton({
<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 sm:table-cell px-3 py-3 align-middle sm:px-4 sm:py-4">
<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 sm:table-cell px-3 py-3 align-middle sm:px-4 sm:py-4">
<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 */}
@@ -600,7 +621,7 @@ export function DataTableSkeleton({
<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 lg:table-cell px-3 py-3 align-middle sm:px-4 sm:py-4">
<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>
@@ -611,7 +632,7 @@ export function DataTableSkeleton({
</Card>
{/* Pagination skeleton */}
<Card className="border-0 py-2 shadow-sm">
<Card className="card-primary py-2">
<CardContent className="px-3 py-0">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">

View File

@@ -21,14 +21,11 @@ import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
import { Trash2, GripVertical, CalendarIcon } from "lucide-react";
import { format } from "date-fns";
import { Calendar } from "~/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
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;
@@ -50,6 +47,10 @@ function SortableItem({
index,
onItemChange,
onRemove,
onMoveUp,
onMoveDown,
canMoveUp,
canMoveDown,
}: {
item: InvoiceItem;
index: number;
@@ -59,6 +60,10 @@ function SortableItem({
value: string | number | Date,
) => void;
onRemove: (index: number) => void;
onMoveUp: (index: number) => void;
onMoveDown: (index: number) => void;
canMoveUp: boolean;
canMoveDown: boolean;
}) {
const {
attributes,
@@ -82,101 +87,193 @@ function SortableItem({
<div
ref={setNodeRef}
style={style}
className={`grid grid-cols-12 items-center gap-2 rounded-lg border border-gray-200 p-4 transition-colors hover:border-emerald-300 dark:border-gray-700 dark:hover:border-emerald-500 ${
className={`card-secondary rounded-lg transition-colors ${
isDragging ? "opacity-50 shadow-lg" : ""
}`}
>
{/* Drag Handle */}
<div className="col-span-1 flex h-10 items-center justify-center">
<button
type="button"
{...attributes}
{...listeners}
className="cursor-grab rounded p-2 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 active:cursor-grabbing dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-400"
>
<GripVertical className="h-4 w-4" />
</button>
</div>
{/* 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">
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="h-10 w-full justify-between border-gray-200 text-sm font-normal focus:border-emerald-500 focus:ring-emerald-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600"
>
{item.date ? format(item.date, "MMM dd") : "Date"}
<CalendarIcon className="h-4 w-4 text-gray-400 dark:text-gray-500" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
<Calendar
mode="single"
selected={item.date}
captionLayout="dropdown"
onSelect={(selectedDate: Date | undefined) => {
handleItemChange("date", selectedDate ?? new Date());
}}
/>
</PopoverContent>
</Popover>
</div>
{/* 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-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</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">
<Input
type="number"
step="0.25"
min="0"
value={item.hours}
onChange={(e) => handleItemChange("hours", e.target.value)}
placeholder="0"
className="h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</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">
<Input
type="number"
step="0.01"
min="0"
value={item.rate}
onChange={(e) => handleItemChange("rate", e.target.value)}
placeholder="0.00"
className="h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</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="flex h-10 items-center rounded-md border border-gray-200 bg-gray-50 px-3 font-medium text-gray-700 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
${item.amount.toFixed(2)}
{/* Amount */}
<div className="col-span-1">
<div className="bg-muted/30 flex h-9 items-center rounded-md border px-3 font-medium text-emerald-600">
${item.amount.toFixed(2)}
</div>
</div>
{/* Remove Button */}
<div className="col-span-1">
<Button
type="button"
onClick={() => onRemove(index)}
variant="ghost"
size="sm"
className="h-9 w-9 p-0 text-red-600 hover:bg-red-50 hover:text-red-700"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
{/* Remove Button */}
<div className="col-span-1">
<Button
type="button"
onClick={() => onRemove(index)}
variant="outline"
size="sm"
className="h-10 w-10 border-red-200 p-0 text-red-700 hover:bg-red-50 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-900/20"
>
<Trash2 className="h-4 w-4" />
</Button>
{/* 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="h-6 w-6 p-0 text-red-600 hover:bg-red-50 hover:text-red-700"
>
<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 rounded-md border p-3">
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-sm">Total Amount:</span>
<span className="font-mono text-lg font-bold text-emerald-600">
${item.amount.toFixed(2)}
</span>
</div>
</div>
</div>
</div>
);
@@ -244,6 +341,20 @@ export function EditableInvoiceItems({
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 (
@@ -251,28 +362,42 @@ export function EditableInvoiceItems({
{items.map((item, _index) => (
<div
key={item.id}
className="grid animate-pulse grid-cols-12 items-center gap-2 rounded-lg border border-gray-200 p-4"
className="card-secondary animate-pulse rounded-lg p-4"
>
<div className="col-span-1 flex h-10 items-center justify-center">
<div className="h-4 w-4 rounded bg-gray-300"></div>
{/* 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>
<div className="col-span-2">
<div className="h-10 rounded bg-gray-300"></div>
</div>
<div className="col-span-4">
<div className="h-10 rounded bg-gray-300"></div>
</div>
<div className="col-span-1">
<div className="h-10 rounded bg-gray-300"></div>
</div>
<div className="col-span-2">
<div className="h-10 rounded bg-gray-300"></div>
</div>
<div className="col-span-1">
<div className="h-10 rounded bg-gray-300"></div>
</div>
<div className="col-span-1">
<div className="h-10 w-10 rounded bg-gray-300"></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>
))}
@@ -281,27 +406,44 @@ export function EditableInvoiceItems({
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={items.map((item) => item.id)}
strategy={verticalListSortingStrategy}
<>
{/* 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}
>
<div className="space-y-3">
{items.map((item, index) => (
<SortableItem
key={item.id}
item={item}
index={index}
onItemChange={handleItemChange}
onRemove={onRemoveItem}
/>
))}
</div>
</SortableContext>
</DndContext>
<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>
</>
);
}

View File

@@ -172,7 +172,7 @@ export function InvoiceList() {
</CardTitle>
<div className="flex items-center justify-between">
<StatusBadge status={invoice.status as StatusType} />
<span className="text-lg font-bold text-green-600">
<span className="text-icon-green text-lg font-bold">
{formatCurrency(invoice.totalAmount)}
</span>
</div>

View File

@@ -136,11 +136,11 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
if (!invoice) {
return (
<div className="py-12 text-center">
<FileText className="mx-auto mb-4 h-12 w-12 text-gray-400" />
<h3 className="mb-2 text-lg font-medium text-gray-900 dark:text-white">
<FileText className="text-muted mx-auto mb-4 h-12 w-12" />
<h3 className="text-accent mb-2 text-lg font-medium">
Invoice not found
</h3>
<p className="mb-4 text-gray-500 dark:text-gray-400">
<p className="text-muted mb-4">
The invoice you&apos;re looking for doesn&apos;t exist or has been
deleted.
</p>
@@ -160,7 +160,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
{isOverdue && (
<Card className="border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-900/20">
<CardContent className="p-4">
<div className="flex items-center gap-2 text-red-700 dark:text-red-400">
<div className="text-error flex items-center gap-2">
<AlertCircle className="h-5 w-5" />
<span className="font-medium">This invoice is overdue</span>
</div>
@@ -172,7 +172,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
{/* Main Content */}
<div className="space-y-6 lg:col-span-2">
{/* Invoice Header Card */}
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
<Card className="card-primary">
<CardContent>
<div className="flex items-start justify-between">
<div className="space-y-4">
@@ -244,7 +244,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
</Card>
{/* Client Information */}
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
<Card className="card-primary">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-emerald-700 dark:text-emerald-400">
<User className="h-5 w-5" />
@@ -307,7 +307,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
</Card>
{/* Invoice Items */}
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
<Card className="card-primary">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-emerald-700 dark:text-emerald-400">
<Clock className="h-5 w-5" />
@@ -367,7 +367,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
{/* Notes */}
{invoice.notes && (
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
<Card className="card-primary">
<CardHeader>
<CardTitle className="text-emerald-700 dark:text-emerald-400">
Notes
@@ -385,7 +385,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
{/* Sidebar */}
<div className="space-y-6">
{/* Status Actions */}
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
<Card className="card-primary">
<CardHeader>
<CardTitle className="text-emerald-700 dark:text-emerald-400">
Status Actions
@@ -437,7 +437,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
</Card>
{/* Invoice Summary */}
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
<Card className="card-primary">
<CardHeader>
<CardTitle className="text-emerald-700 dark:text-emerald-400">
Summary
@@ -476,7 +476,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
</Card>
{/* Danger Zone */}
<Card className="border-0 border-red-200 bg-white/80 shadow-xl backdrop-blur-sm dark:border-red-800 dark:bg-gray-800/80">
<Card className="card-primary border-red-200 dark:border-red-800">
<CardHeader>
<CardTitle className="text-red-700 dark:text-red-400">
Danger Zone
@@ -498,7 +498,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent className="border-0 bg-white/95 shadow-2xl backdrop-blur-sm dark:bg-gray-800/95">
<DialogContent className="card-primary">
<DialogHeader>
<DialogTitle className="text-xl font-bold text-gray-800 dark:text-white">
Delete Invoice

View File

@@ -94,7 +94,7 @@ export function StatsCard({
export function StatsCardSkeleton() {
return (
<Card className="border-0 shadow-md">
<Card className="card-primary">
<CardContent className="p-6">
<div className="animate-pulse">
<div className="bg-muted mb-2 h-4 w-1/2 rounded"></div>

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import { Badge, type badgeVariants } from "~/components/ui/badge";
import { type VariantProps } from "class-variance-authority";
import { Badge } from "~/components/ui/badge";
import { cn } from "~/lib/utils";
type StatusType =
| "draft"
@@ -18,18 +18,15 @@ interface StatusBadgeProps
children?: React.ReactNode;
}
const statusVariantMap: Record<
StatusType,
VariantProps<typeof badgeVariants>["variant"]
> = {
draft: "outline-draft",
sent: "outline-sent",
paid: "outline-paid",
overdue: "outline-overdue",
success: "success",
warning: "warning",
error: "error",
info: "info",
const statusClassMap: Record<StatusType, string> = {
draft: "status-badge-draft",
sent: "status-badge-sent",
paid: "status-badge-paid",
overdue: "status-badge-overdue",
success: "badge-success",
warning: "badge-warning",
error: "badge-error",
info: "badge-features",
};
const statusLabelMap: Record<StatusType, string> = {
@@ -43,12 +40,17 @@ const statusLabelMap: Record<StatusType, string> = {
info: "Info",
};
export function StatusBadge({ status, children, ...props }: StatusBadgeProps) {
const variant = statusVariantMap[status];
export function StatusBadge({
status,
children,
className,
...props
}: StatusBadgeProps) {
const statusClass = statusClassMap[status];
const label = children || statusLabelMap[status];
return (
<Badge variant={variant} {...props}>
<Badge className={cn(statusClass, className)} {...props}>
{label}
</Badge>
);

View File

@@ -255,11 +255,11 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
{/* Main Form Container - styled like data table */}
<div className="space-y-4">
{/* Basic Information */}
<Card>
<Card className="card-primary">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-r from-emerald-600/10 to-teal-600/10">
<Building className="h-5 w-5 text-emerald-700 dark:text-emerald-400" />
<div className="bg-brand-muted flex h-10 w-10 items-center justify-center rounded-lg">
<Building className="text-brand-light h-5 w-5" />
</div>
<div>
<CardTitle>Basic Information</CardTitle>
@@ -376,12 +376,12 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
</Card>
{/* Address */}
<Card>
<Card className="card-primary">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-r from-emerald-600/10 to-teal-600/10">
<div className="bg-brand-muted flex h-10 w-10 items-center justify-center rounded-lg">
<svg
className="h-5 w-5 text-emerald-700 dark:text-emerald-400"
className="text-brand-light h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -424,11 +424,11 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
</Card>
{/* Settings */}
<Card>
<Card className="card-primary">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-r from-emerald-600/10 to-teal-600/10">
<Star className="h-5 w-5 text-emerald-700 dark:text-emerald-400" />
<div className="bg-brand-muted flex h-10 w-10 items-center justify-center rounded-lg">
<Star className="text-brand-light h-5 w-5" />
</div>
<div>
<CardTitle>Settings</CardTitle>
@@ -439,7 +439,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="border-border/40 flex items-center justify-between rounded-xl border bg-gradient-to-r from-emerald-600/5 to-teal-600/5 p-4">
<div className="bg-brand-muted border-border/40 flex items-center justify-between rounded-xl border p-4">
<div className="space-y-0.5">
<Label htmlFor="isDefault" className="text-base font-medium">
Default Business
@@ -485,7 +485,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
<Button
type="submit"
disabled={isSubmitting || !isDirty}
className="bg-gradient-to-r from-emerald-600 to-teal-600 shadow-md transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-lg"
className="btn-brand-primary shadow-md"
>
{isSubmitting ? (
<>
@@ -525,18 +525,22 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
<Button
onClick={handleSubmit}
disabled={isSubmitting || !isDirty}
className="bg-gradient-to-r from-emerald-600 to-teal-600 shadow-md transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-lg"
className="btn-brand-primary shadow-md"
size="sm"
>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin sm:mr-2" />
<span className="hidden sm:inline">{mode === "create" ? "Creating..." : "Saving..."}</span>
<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 Business" : "Save Changes"}</span>
<span className="hidden sm:inline">
{mode === "create" ? "Create Business" : "Save Changes"}
</span>
</>
)}
</Button>

View File

@@ -221,7 +221,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
{/* Main Form Container - styled like data table */}
<div className="space-y-4">
{/* Basic Information */}
<Card>
<Card className="card-primary">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-r from-emerald-600/10 to-teal-600/10">
@@ -300,7 +300,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
</Card>
{/* Address */}
<Card>
<Card className="card-primary">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-r from-emerald-600/10 to-teal-600/10">
@@ -348,7 +348,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
</Card>
{/* Billing Information */}
<Card>
<Card className="card-primary">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-r from-emerald-600/10 to-teal-600/10">

View File

@@ -59,7 +59,7 @@ function InvoiceFormSkeleton() {
</div>
{/* Invoice Details Card */}
<Card className="shadow-sm">
<Card className="card-primary">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<div className="bg-muted/30 h-5 w-5 animate-pulse rounded"></div>
@@ -118,7 +118,7 @@ function InvoiceFormSkeleton() {
</Card>
{/* Invoice Items Card */}
<Card className="shadow-sm">
<Card className="card-primary">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<div className="bg-muted/30 h-5 w-5 animate-pulse rounded"></div>
@@ -172,7 +172,7 @@ function InvoiceFormSkeleton() {
{/* Right Column - Summary */}
<div className="space-y-6">
<Card className="sticky top-6 shadow-sm">
<Card className="card-primary sticky top-6">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<div className="bg-muted/30 h-5 w-5 animate-pulse rounded"></div>
@@ -493,7 +493,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</TabsList>
<TabsContent value="invoice-details">
{/* Invoice Details */}
<Card className="shadow-sm">
<Card className="card-primary">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
@@ -554,6 +554,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
issueDate: date ?? new Date(),
}))
}
className="w-full"
/>
</div>
<div className="space-y-2">
@@ -566,6 +567,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
dueDate: date ?? new Date(),
}))
}
className="w-full"
/>
</div>
</div>
@@ -681,7 +683,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</Card>
</TabsContent>
<TabsContent value="invoice-items">
<Card className="shadow-sm">
<Card className="card-primary">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<DollarSign className="h-5 w-5" />
@@ -705,7 +707,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
{/* Right Column - Summary (Always Visible) */}
<div className="space-y-6">
<Card className="sticky top-6 shadow-sm">
<Card className="card-primary sticky top-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Check className="h-5 w-5" />

View File

@@ -2,6 +2,7 @@
import * as React from "react";
import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { DatePicker } from "~/components/ui/date-picker";
@@ -62,28 +63,27 @@ function LineItemRow({
onUpdate,
}: LineItemRowProps) {
return (
<>
{/* Desktop Layout - Table Row */}
<tr className="group hover:bg-muted/20 hidden transition-colors lg:table-row">
<div className="card-secondary hidden rounded-lg p-4 md:block">
<div className="flex items-start gap-3">
{/* Drag Handle */}
<td className="w-6 p-2 text-center align-top">
<GripVertical className="text-muted-foreground mt-1 h-4 w-4 cursor-grab" />
</td>
<div className="mt-1 flex items-center justify-center">
<GripVertical className="text-muted-foreground h-4 w-4 cursor-grab" />
</div>
{/* Main Content */}
<td className="p-2" colSpan={5}>
<div className="flex-1 space-y-3">
{/* Description */}
<div className="mb-3">
<div>
<Input
value={item.description}
onChange={(e) => onUpdate(index, "description", e.target.value)}
placeholder="Describe the work performed..."
className="w-full border-0 bg-transparent py-0 pr-0 pl-2 text-sm font-medium focus-visible:ring-0"
className="w-full text-sm font-medium"
/>
</div>
{/* Controls Row */}
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center gap-3">
{/* Date */}
<DatePicker
date={item.date}
@@ -91,7 +91,7 @@ function LineItemRow({
onUpdate(index, "date", date ?? new Date())
}
size="sm"
className="h-9 w-28"
className="h-9 w-36"
/>
{/* Hours */}
@@ -101,7 +101,7 @@ function LineItemRow({
min={0}
step={0.25}
width="auto"
className="h-9 w-28"
className="h-9 w-32"
/>
{/* Rate */}
@@ -112,7 +112,7 @@ function LineItemRow({
step={1}
prefix="$"
width="auto"
className="h-9 w-28"
className="h-9 w-32"
/>
{/* Amount */}
@@ -138,80 +138,9 @@ function LineItemRow({
<Trash2 className="h-4 w-4" />
</Button>
</div>
</td>
</tr>
{/* Tablet Layout - Condensed Row */}
<tr className="group hover:bg-muted/20 hidden transition-colors md:table-row lg:hidden">
{/* Drag Handle */}
<td className="w-6 p-2 text-center align-top">
<GripVertical className="text-muted-foreground mt-1 h-4 w-4 cursor-grab" />
</td>
{/* Main Content - Description on top, inputs below */}
<td className="p-3" colSpan={6}>
{/* Description */}
<div className="mb-3">
<Input
value={item.description}
onChange={(e) => onUpdate(index, "description", e.target.value)}
placeholder="Describe the work performed..."
className="w-full pl-3 text-sm font-medium"
/>
</div>
{/* Controls Row - Date/Hours/Rate break to separate rows on smaller screens */}
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<DatePicker
date={item.date}
onDateChange={(date) =>
onUpdate(index, "date", date ?? new Date())
}
size="sm"
className="h-9 w-full sm:w-28"
/>
<NumberInput
value={item.hours}
onChange={(value) => onUpdate(index, "hours", value)}
min={0}
step={0.25}
width="full"
className="h-9 w-1/2 sm:w-28"
/>
<NumberInput
value={item.rate}
onChange={(value) => onUpdate(index, "rate", value)}
min={0}
step={1}
prefix="$"
width="full"
className="h-9 w-1/2 sm:w-28"
/>
{/* Amount and Actions - inline with controls on larger screens */}
<div className="mt-3 flex items-center justify-between sm:mt-0 sm:ml-auto sm:gap-3">
<span className="text-primary font-semibold">
${(item.hours * item.rate).toFixed(2)}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onRemove(index)}
className={cn(
"text-muted-foreground h-8 w-8 p-0 transition-colors hover:text-red-500",
!canRemove && "cursor-not-allowed opacity-50",
)}
disabled={!canRemove}
aria-label="Remove item"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</td>
</tr>
</>
</div>
</div>
</div>
);
}
@@ -227,55 +156,57 @@ function MobileLineItem({
isLast,
}: LineItemRowProps) {
return (
<div className="bg-card space-y-3 rounded-lg border p-4 md:hidden">
{/* Description */}
<div className="space-y-1">
<Label className="text-muted-foreground text-xs">Description</Label>
<Input
value={item.description}
onChange={(e) => onUpdate(index, "description", e.target.value)}
placeholder="Describe the work performed..."
className="pl-3 text-sm"
/>
</div>
{/* Date */}
<div className="space-y-1">
<Label className="text-muted-foreground text-xs">Date</Label>
<DatePicker
date={item.date}
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
size="sm"
/>
</div>
{/* Hours and Rate in a row */}
<div className="grid grid-cols-2 gap-3">
<div className="card-secondary space-y-3 rounded-lg md:hidden">
<div className="space-y-3 p-4">
{/* Description */}
<div className="space-y-1">
<Label className="text-muted-foreground text-xs">Hours</Label>
<NumberInput
value={item.hours}
onChange={(value) => onUpdate(index, "hours", value)}
min={0}
step={0.25}
width="full"
<Label className="text-muted-foreground text-xs">Description</Label>
<Input
value={item.description}
onChange={(e) => onUpdate(index, "description", e.target.value)}
placeholder="Describe the work performed..."
className="pl-3 text-sm"
/>
</div>
{/* Date */}
<div className="space-y-1">
<Label className="text-muted-foreground text-xs">Rate</Label>
<NumberInput
value={item.rate}
onChange={(value) => onUpdate(index, "rate", value)}
min={0}
step={1}
prefix="$"
width="full"
<Label className="text-muted-foreground text-xs">Date</Label>
<DatePicker
date={item.date}
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
size="sm"
/>
</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={(value) => onUpdate(index, "hours", value)}
min={0}
step={0.25}
width="full"
/>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground text-xs">Rate</Label>
<NumberInput
value={item.rate}
onChange={(value) => onUpdate(index, "rate", value)}
min={0}
step={1}
prefix="$"
width="full"
/>
</div>
</div>
</div>
{/* Bottom section with controls, item name, and total */}
<div className="flex items-center justify-between border-t pt-2">
<div className="flex items-center justify-between rounded-b-lg border-t border-slate-400/60 bg-slate-200/30 px-4 py-2 dark:border-slate-500/60 dark:bg-slate-700/30">
<div className="flex items-center gap-2">
<Button
type="button"
@@ -355,71 +286,36 @@ export function InvoiceLineItems({
return (
<div className={cn("space-y-2", className)}>
{/* Desktop and Tablet Table */}
<div className="hidden md:block">
<div className="overflow-hidden rounded-lg border">
<table className="w-full">
{/* Desktop Header */}
<thead className="bg-muted/30 hidden lg:table-header-group">
<tr>
<th className="w-6 p-2"></th>
<th
className="text-muted-foreground p-2 text-left text-xs font-medium"
colSpan={5}
>
Invoice Items
</th>
</tr>
</thead>
{/* Tablet Header */}
<thead className="bg-muted/30 md:table-header-group lg:hidden">
<tr>
<th className="w-6 p-2"></th>
<th
className="text-muted-foreground p-2 text-left text-xs font-medium"
colSpan={6}
>
Invoice Items
</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((item, index) => (
<LineItemRow
key={item.id}
item={item}
index={index}
canRemove={canRemoveItems}
onRemove={onRemoveItem}
onUpdate={onUpdateItem}
onMoveUp={onMoveUp}
onMoveDown={onMoveDown}
isFirst={index === 0}
isLast={index === items.length - 1}
/>
))}
</tbody>
</table>
</div>
</div>
{/* Mobile Cards */}
<div className="space-y-2 md:hidden">
{/* Desktop and Mobile Cards */}
<div className="space-y-2">
{items.map((item, index) => (
<MobileLineItem
key={item.id}
item={item}
index={index}
canRemove={canRemoveItems}
onRemove={onRemoveItem}
onUpdate={onUpdateItem}
onMoveUp={onMoveUp}
onMoveDown={onMoveDown}
isFirst={index === 0}
isLast={index === items.length - 1}
/>
<React.Fragment key={item.id}>
{/* Desktop/Tablet Card */}
<LineItemRow
item={item}
index={index}
canRemove={canRemoveItems}
onRemove={onRemoveItem}
onUpdate={onUpdateItem}
onMoveUp={onMoveUp}
onMoveDown={onMoveDown}
isFirst={index === 0}
isLast={index === items.length - 1}
/>
{/* Mobile Card */}
<MobileLineItem
item={item}
index={index}
canRemove={canRemoveItems}
onRemove={onRemoveItem}
onUpdate={onUpdateItem}
onMoveUp={onMoveUp}
onMoveDown={onMoveDown}
isFirst={index === 0}
isLast={index === items.length - 1}
/>
</React.Fragment>
))}
</div>

View File

@@ -97,19 +97,11 @@ export function FloatingActionBar({
if (!isVisible) return null;
return (
<div
ref={floatingRef}
className={cn(
"border-border/40 bg-background/60 animate-in slide-in-from-bottom-4 sticky bottom-4 z-20 flex items-center justify-between rounded-2xl border p-4 shadow-lg backdrop-blur-xl backdrop-saturate-150 duration-300",
className,
)}
>
<div className="flex-1">
{leftContent || (
<p className="text-muted-foreground text-sm">{title}</p>
)}
<div ref={floatingRef} className={cn("floating-action-bar", className)}>
<div className="floating-action-bar-content">
{leftContent || <p className="floating-action-bar-title">{title}</p>}
</div>
<div className="flex items-center gap-2 sm:gap-3">{children}</div>
<div className="floating-action-bar-actions">{children}</div>
</div>
);
}

View File

@@ -22,11 +22,11 @@ export function PageHeader({
switch (variant) {
case "gradient":
return `${baseClasses} text-3xl bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent`;
return `${baseClasses} text-3xl text-brand-gradient`;
case "large":
return `${baseClasses} text-4xl text-foreground`;
case "large-gradient":
return `${baseClasses} text-4xl bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent`;
return `${baseClasses} text-4xl text-brand-gradient`;
default:
return `${baseClasses} text-3xl text-foreground`;
}

View File

@@ -99,7 +99,7 @@ export function QuickActionCard({
export function QuickActionCardSkeleton() {
return (
<Card className="border-0 shadow-md">
<Card className="card-primary">
<CardContent className="p-6">
<div className="animate-pulse">
<div className="bg-muted mx-auto mb-3 h-12 w-12 rounded-full"></div>

View File

@@ -10,22 +10,26 @@ const badgeVariants = cva(
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
"border-slate-300 bg-slate-200 text-slate-800 shadow-sm dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
"border-slate-300 bg-slate-200/80 text-slate-700 shadow-sm dark:border-slate-600 dark:bg-slate-700/80 dark:text-slate-300",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
"border-2 border-slate-300 bg-transparent text-slate-700 dark:border-slate-600 dark:text-slate-300",
success: "border-transparent bg-status-success [a&]:hover:opacity-90",
warning: "border-transparent bg-status-warning [a&]:hover:opacity-90",
error: "border-transparent bg-status-error [a&]:hover:opacity-90",
info: "border-transparent bg-status-info [a&]:hover:opacity-90",
// Outlined variants for status badges
"outline-draft": "border-gray-400 text-gray-600 dark:border-gray-500 dark:text-gray-300 bg-transparent",
"outline-sent": "border-blue-400 text-blue-600 dark:border-blue-500 dark:text-blue-300 bg-transparent",
"outline-paid": "border-green-400 text-green-600 dark:border-green-500 dark:text-green-300 bg-transparent",
"outline-overdue": "border-red-400 text-red-600 dark:border-red-500 dark:text-red-300 bg-transparent",
"outline-draft":
"border-gray-400 text-gray-600 dark:border-gray-500 dark:text-gray-300 bg-transparent",
"outline-sent":
"border-blue-400 text-blue-600 dark:border-blue-500 dark:text-blue-300 bg-transparent",
"outline-paid":
"border-green-400 text-green-600 dark:border-green-500 dark:text-green-300 bg-transparent",
"outline-overdue":
"border-red-400 text-red-600 dark:border-red-500 dark:text-red-300 bg-transparent",
},
},
defaultVariants: {

View File

@@ -1,15 +1,22 @@
"use client"
"use client";
import * as React from "react"
import * as React from "react";
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
} from "lucide-react";
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
import { cn } from "~/lib/utils"
import { Button, buttonVariants } from "~/components/ui/button"
import { cn } from "~/lib/utils";
import { Button, buttonVariants } from "~/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
function Calendar({
className,
@@ -19,11 +26,33 @@ function Calendar({
buttonVariant = "ghost",
formatters,
components,
month,
onMonthChange,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
}) {
const defaultClassNames = getDefaultClassNames()
const defaultClassNames = getDefaultClassNames();
const months = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
const currentYear = month?.getFullYear() || new Date().getFullYear();
const currentMonth = month?.getMonth() || new Date().getMonth();
const years = Array.from({ length: 11 }, (_, i) => currentYear - 5 + i);
return (
<DayPicker
@@ -32,94 +61,82 @@ function Calendar({
"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
className,
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
month={month}
onMonthChange={onMonthChange}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
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
defaultClassNames.nav,
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
defaultClassNames.button_previous,
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
defaultClassNames.button_next,
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
defaultClassNames.month_caption,
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
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
"relative has-focus:border-ring border border-input shadow-sm has-focus:ring-ring/50 has-focus:ring-2 rounded-md h-8",
defaultClassNames.dropdown_root,
),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown
"absolute bg-transparent inset-0 w-full h-full opacity-0 cursor-pointer",
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
"select-none font-medium text-sm hidden",
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
"text-muted-foreground 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
defaultClassNames.week_number_header,
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
defaultClassNames.week_number,
),
day: cn(
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
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
"relative w-full h-full p-0 text-center group/day aspect-square select-none",
defaultClassNames.day,
),
range_start: cn("", defaultClassNames.range_start),
range_middle: cn("", defaultClassNames.range_middle),
range_end: cn("", defaultClassNames.range_end),
today: cn("font-semibold", defaultClassNames.today),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
defaultClassNames.outside,
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
defaultClassNames.disabled,
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
@@ -133,13 +150,13 @@ function Calendar({
className={cn(className)}
{...props}
/>
)
);
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
);
}
if (orientation === "right") {
@@ -148,14 +165,67 @@ function Calendar({
className={cn("size-4", className)}
{...props}
/>
)
);
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
);
},
DayButton: CalendarDayButton,
MonthCaption: ({ calendarMonth }) => {
if (captionLayout !== "dropdown") {
return null;
}
return (
<div className="calendar-custom-header flex items-center justify-center gap-2 py-2">
<Select
value={currentMonth.toString()}
onValueChange={(value) => {
const newDate = new Date(currentYear, parseInt(value), 1);
onMonthChange?.(newDate);
}}
>
<SelectTrigger
size="sm"
className="w-auto px-2 text-sm font-semibold"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{months.map((monthName, index) => (
<SelectItem key={index} value={index.toString()}>
{monthName}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={currentYear.toString()}
onValueChange={(value) => {
const newDate = new Date(parseInt(value), currentMonth, 1);
onMonthChange?.(newDate);
}}
>
<SelectTrigger
size="sm"
className="w-auto px-2 text-sm font-semibold"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{years.map((year) => (
<SelectItem key={year} value={year.toString()}>
{year}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
},
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
@@ -163,13 +233,13 @@ function Calendar({
{children}
</div>
</td>
)
);
},
...components,
}}
{...props}
/>
)
);
}
function CalendarDayButton({
@@ -178,12 +248,12 @@ function CalendarDayButton({
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null)
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<Button
@@ -201,13 +271,14 @@ function CalendarDayButton({
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
"hover:bg-accent hover:text-accent-foreground flex aspect-square size-auto h-8 w-full min-w-8 items-center justify-center rounded-md border-0 text-sm leading-none font-normal shadow-none",
modifiers.selected && "bg-primary text-primary-foreground",
modifiers.today && !modifiers.selected && "bg-accent font-semibold",
className,
)}
{...props}
/>
)
);
}
export { Calendar, CalendarDayButton }
export { Calendar, CalendarDayButton };

View File

@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card"
className={cn(
"bg-background/60 text-card-foreground border-border/40 flex flex-col gap-6 rounded-2xl border py-6 shadow-lg backdrop-blur-xl backdrop-saturate-150",
"bg-background/60 text-card-foreground border-border/40 flex flex-col gap-2 rounded-2xl border py-2 shadow-lg backdrop-blur-xl backdrop-saturate-150",
className,
)}
{...props}
@@ -20,7 +20,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 p-3 px-5 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
@@ -65,7 +65,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
className={cn("px-5 pb-3", className)}
{...props}
/>
);
@@ -75,7 +75,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
className={cn("flex items-center px-6 py-6 [.border-t]:pt-6", className)}
{...props}
/>
);

View File

@@ -1,11 +1,12 @@
"use client";
import { format } from "date-fns";
import { Calendar as CalendarIcon } from "lucide-react";
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,
@@ -13,6 +14,18 @@ import {
} from "~/components/ui/popover";
import { cn } from "~/lib/utils";
function formatDate(date: Date | undefined) {
if (!date) {
return "";
}
return date.toLocaleDateString("en-US", {
day: "2-digit",
month: "long",
year: "numeric",
});
}
interface DatePickerProps {
date?: Date;
onDateChange: (date: Date | undefined) => void;
@@ -26,13 +39,15 @@ interface DatePickerProps {
export function DatePicker({
date,
onDateChange,
placeholder = "Select date",
placeholder = "Tomorrow or next week",
className,
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",
@@ -40,42 +55,68 @@ export function DatePicker({
lg: "h-10 text-sm",
};
const formatDate = (date: Date) => {
if (size === "sm") {
return format(date, "MMM dd");
}
return format(date, "PPP");
};
const inputWidthClass = className?.includes("w-full")
? "w-full"
: className?.includes("w-32") ||
className?.includes("w-28") ||
className?.includes("w-36")
? className
: "w-full md:w-32 md:min-w-32";
React.useEffect(() => {
setValue(formatDate(date));
setMonth(date);
}, [date]);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
id={id}
disabled={disabled}
className={cn(
"w-full justify-between font-normal",
sizeClasses[size],
!date && "text-muted-foreground",
className,
)}
>
{date ? formatDate(date) : placeholder}
<CalendarIcon className="text-muted-foreground h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
<Calendar
mode="single"
selected={date}
captionLayout="dropdown"
onSelect={(selectedDate: Date | undefined) => {
onDateChange(selectedDate);
setOpen(false);
}}
/>
</PopoverContent>
</Popover>
<div className={cn("relative flex gap-2", inputWidthClass, className)}>
<Input
id={id}
value={value}
placeholder={placeholder}
disabled={disabled}
className={cn("bg-background pr-10", sizeClasses[size], "w-full")}
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="absolute top-1/2 right-2 size-6 -translate-y-1/2"
>
<CalendarIcon className="size-3.5" />
<span className="sr-only">Select date</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden 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>
);
}

View File

@@ -71,12 +71,12 @@ export function NumberInput({
onChange(Math.max(min, (value || 0) - step));
};
const widthClass = width === "full" ? "w-full" : "w-24";
const widthClass = width === "full" ? "w-full" : "w-24 min-w-24";
return (
<div
className={cn(
"border-input bg-background ring-offset-background flex h-9 items-center justify-center rounded-md border px-2 text-sm",
"bg-background flex h-9 items-center justify-center rounded-md text-sm shadow-none",
widthClass,
disabled && "cursor-not-allowed opacity-50",
className,
@@ -103,7 +103,7 @@ export function NumberInput({
onBlur={handleBlur}
placeholder={placeholder}
disabled={disabled}
className="w-16 border-0 bg-transparent text-center outline-none focus-visible:ring-0"
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>