Update date picker, mobile styling
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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're looking for doesn'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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user