From c0279c40951c553eed399ea107326dbfe0fe5402 Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Tue, 15 Jul 2025 03:04:10 -0400 Subject: [PATCH] feat: enhance dashboard layout and UI components for improved responsiveness - Introduce new 'xs' screen size in Tailwind configuration for better mobile support - Update dashboard layout to use a cosmic gradient background for a modern look - Refactor Quick Actions component for improved styling and layout consistency - Add Current Open Invoice Card for quick access to ongoing invoices - Standardize button sizes across various components for a cohesive user experience - Implement responsive design adjustments in invoice forms and data tables This update enhances the overall user experience by improving responsiveness and visual appeal across the dashboard and related components. --- src/app/dashboard/invoices/[id]/edit/page.tsx | 19 ++- .../_components/invoices-data-table.tsx | 90 +++++------ src/app/dashboard/invoices/new/page.tsx | 19 ++- src/app/dashboard/layout.tsx | 8 +- src/app/dashboard/page.tsx | 78 +++++---- .../data/current-open-invoice-card.tsx | 152 ++++++++++++++++++ src/components/data/data-table.tsx | 6 +- src/components/data/status-badge.tsx | 8 +- src/components/forms/business-form.tsx | 14 +- src/components/forms/client-form.tsx | 14 +- src/components/layout/floating-action-bar.tsx | 4 +- src/components/layout/navbar.tsx | 21 +++ src/components/ui/badge.tsx | 5 + src/server/api/routers/invoices.ts | 30 ++++ src/styles/globals.css | 101 ++++++++++-- tailwind.config.ts | 3 + 16 files changed, 432 insertions(+), 140 deletions(-) create mode 100644 src/components/data/current-open-invoice-card.tsx diff --git a/src/app/dashboard/invoices/[id]/edit/page.tsx b/src/app/dashboard/invoices/[id]/edit/page.tsx index 13f5e14..d19cc93 100644 --- a/src/app/dashboard/invoices/[id]/edit/page.tsx +++ b/src/app/dashboard/invoices/[id]/edit/page.tsx @@ -722,9 +722,10 @@ function InvoiceEditor({ invoiceId }: { invoiceId: string }) { variant="outline" disabled={isLoading} className="border-border/40 hover:bg-accent/50" + size="sm" > - - Cancel + + Cancel diff --git a/src/app/dashboard/invoices/_components/invoices-data-table.tsx b/src/app/dashboard/invoices/_components/invoices-data-table.tsx index 4242442..228e041 100644 --- a/src/app/dashboard/invoices/_components/invoices-data-table.tsx +++ b/src/app/dashboard/invoices/_components/invoices-data-table.tsx @@ -79,28 +79,6 @@ const formatCurrency = (amount: number) => { }; const columns: ColumnDef[] = [ - { - accessorKey: "invoiceNumber", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const invoice = row.original; - return ( -
-
- -
-
-

{invoice.invoiceNumber}

-

- {invoice.items?.length || 0} items -

-
-
- ); - }, - }, { accessorKey: "client.name", header: ({ column }) => ( @@ -109,10 +87,10 @@ const columns: ColumnDef[] = [ cell: ({ row }) => { const invoice = row.original; return ( -
-

{invoice.client?.name || "—"}

-

- {invoice.client?.email || "—"} +

+

{invoice.client?.name ?? "—"}

+

+ {invoice.invoiceNumber}

); @@ -121,33 +99,18 @@ const columns: ColumnDef[] = [ { accessorKey: "issueDate", header: ({ column }) => ( - - ), - cell: ({ row }) => formatDate(row.getValue("issueDate")), - meta: { - headerClassName: "hidden md:table-cell", - cellClassName: "hidden md:table-cell", - }, - }, - { - accessorKey: "dueDate", - header: ({ column }) => ( - - ), - cell: ({ row }) => formatDate(row.getValue("dueDate")), - meta: { - headerClassName: "hidden lg:table-cell", - cellClassName: "hidden lg:table-cell", - }, - }, - { - accessorKey: "totalAmount", - header: ({ column }) => ( - + ), cell: ({ row }) => { - const amount = row.getValue("totalAmount") as number; - return

{formatCurrency(amount)}

; + const date = row.getValue("issueDate") as Date; + return ( +
+

{formatDate(date)}

+

+ Due {formatDate(new Date(row.original.dueDate))} +

+
+ ); }, }, { @@ -164,6 +127,31 @@ const columns: ColumnDef[] = [ const status = getStatusType(invoice); return value.includes(status); }, + meta: { + headerClassName: "hidden xs:table-cell", + cellClassName: "hidden xs:table-cell", + }, + }, + { + accessorKey: "totalAmount", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const amount = row.getValue("totalAmount") as number; + return ( +
+

{formatCurrency(amount)}

+

+ {row.original.items?.length ?? 0} items +

+
+ ); + }, + meta: { + headerClassName: "hidden xs:table-cell", + cellClassName: "hidden xs:table-cell", + }, }, { id: "actions", diff --git a/src/app/dashboard/invoices/new/page.tsx b/src/app/dashboard/invoices/new/page.tsx index 16d0ded..056adbe 100644 --- a/src/app/dashboard/invoices/new/page.tsx +++ b/src/app/dashboard/invoices/new/page.tsx @@ -704,9 +704,10 @@ export default function NewInvoicePage() { variant="outline" disabled={isLoading} className="border-border/40 hover:bg-accent/50" + size="sm" > - - Cancel + + Cancel
diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index b3f6596..8caef90 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -8,23 +8,23 @@ export default function DashboardLayout({ children: React.ReactNode; }) { return ( - <> +
{/* Mobile layout - no left margin */} -
+
{children}
{/* Desktop layout - with sidebar margin */} -
+
{children}
- +
); } diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 81325ab..06e77d3 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -6,6 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { StatusBadge, type StatusType } from "~/components/data/status-badge"; import { DataTableSkeleton } from "~/components/data/data-table"; +import { CurrentOpenInvoiceCard } from "~/components/data/current-open-invoice-card"; import { auth } from "~/server/auth"; import Link from "next/link"; import { @@ -98,39 +99,43 @@ async function DashboardStats() { // Quick Actions Component function QuickActions() { return ( - - -
- - - -
+ + + + + Quick Actions + + + + + + ); @@ -245,10 +250,13 @@ export default async function DashboardPage() { - +
+ + +
- }> + }> diff --git a/src/components/data/current-open-invoice-card.tsx b/src/components/data/current-open-invoice-card.tsx new file mode 100644 index 0000000..db54574 --- /dev/null +++ b/src/components/data/current-open-invoice-card.tsx @@ -0,0 +1,152 @@ +"use client"; + +import Link from "next/link"; +import { api } from "~/trpc/react"; +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; +import { Button } from "~/components/ui/button"; +import { Badge } from "~/components/ui/badge"; +import { Skeleton } from "~/components/ui/skeleton"; +import { + FileText, + Clock, + Plus, + Edit, + Eye, + DollarSign, + User, + Calendar +} from "lucide-react"; + +export function CurrentOpenInvoiceCard() { + const { data: currentInvoice, isLoading } = api.invoices.getCurrentOpen.useQuery(); + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(amount); + }; + + const formatDate = (date: Date) => { + return new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + }).format(new Date(date)); + }; + + if (isLoading) { + return ( + + + + + Current Open Invoice + + + + + +
+ + +
+
+
+ ); + } + + if (!currentInvoice) { + return ( + + + + + Current Open Invoice + + + +
+ +

+ No open invoice found. Create a new invoice to start tracking your time. +

+ +
+
+
+ ); + } + + const totalHours = currentInvoice.items?.reduce((sum, item) => sum + item.hours, 0) ?? 0; + const totalAmount = currentInvoice.totalAmount; + + return ( + + + + + Current Open Invoice + + + +
+
+
+ + {currentInvoice.invoiceNumber} + + + Draft + +
+
+

+ {formatCurrency(totalAmount)} +

+
+
+ +
+
+ + Client: + {currentInvoice.client?.name} +
+ +
+ + Due: + {formatDate(currentInvoice.dueDate)} +
+ +
+ + Hours: + {totalHours.toFixed(1)}h +
+
+
+ +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/data/data-table.tsx b/src/components/data/data-table.tsx index 0489ef2..c2ce85a 100644 --- a/src/components/data/data-table.tsx +++ b/src/components/data/data-table.tsx @@ -410,11 +410,11 @@ export function DataTable({
- Page{" "} + Page {table.getState().pagination.pageIndex + 1} - {" "} - of{" "} + + of {table.getPageCount() || 1} diff --git a/src/components/data/status-badge.tsx b/src/components/data/status-badge.tsx index 110adf4..286a434 100644 --- a/src/components/data/status-badge.tsx +++ b/src/components/data/status-badge.tsx @@ -22,10 +22,10 @@ const statusVariantMap: Record< StatusType, VariantProps["variant"] > = { - draft: "secondary", - sent: "info", - paid: "success", - overdue: "error", + draft: "outline-draft", + sent: "outline-sent", + paid: "outline-paid", + overdue: "outline-overdue", success: "success", warning: "warning", error: "error", diff --git a/src/components/forms/business-form.tsx b/src/components/forms/business-form.tsx index 01c0b90..a082425 100644 --- a/src/components/forms/business-form.tsx +++ b/src/components/forms/business-form.tsx @@ -517,24 +517,26 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) { onClick={handleCancel} disabled={isSubmitting} className="border-border/40 hover:bg-accent/50" + size="sm" > - - Cancel + + Cancel diff --git a/src/components/forms/client-form.tsx b/src/components/forms/client-form.tsx index 59cfcf8..b00beda 100644 --- a/src/components/forms/client-form.tsx +++ b/src/components/forms/client-form.tsx @@ -397,24 +397,26 @@ export function ClientForm({ clientId, mode }: ClientFormProps) { onClick={handleCancel} disabled={isSubmitting} className="border-border/40 hover:bg-accent/50" + size="sm" > - - Cancel + + Cancel diff --git a/src/components/layout/floating-action-bar.tsx b/src/components/layout/floating-action-bar.tsx index 3f252f2..332faa6 100644 --- a/src/components/layout/floating-action-bar.tsx +++ b/src/components/layout/floating-action-bar.tsx @@ -99,7 +99,9 @@ export function FloatingActionBar({ )} >

{title}

-
{children}
+
+ {children} +
); } diff --git a/src/components/layout/navbar.tsx b/src/components/layout/navbar.tsx index e7ac67b..3a9047f 100644 --- a/src/components/layout/navbar.tsx +++ b/src/components/layout/navbar.tsx @@ -6,10 +6,15 @@ import { Button } from "~/components/ui/button"; import { Skeleton } from "~/components/ui/skeleton"; import { Logo } from "~/components/branding/logo"; import { SidebarTrigger } from "~/components/navigation/sidebar-trigger"; +import { api } from "~/trpc/react"; +import { FileText, Edit } from "lucide-react"; export function Navbar() { const { data: session, status } = useSession(); const [isMobileNavOpen, setIsMobileNavOpen] = useState(false); + + // Get current open invoice for quick access + const { data: currentInvoice } = api.invoices.getCurrentOpen.useQuery(); return (
@@ -25,6 +30,22 @@ export function Navbar() {
+ {/* Quick access to current open invoice */} + {session?.user && currentInvoice && ( + + )} + {status === "loading" ? ( <> diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index b9e732a..0052b9f 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -21,6 +21,11 @@ const badgeVariants = cva( 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", }, }, defaultVariants: { diff --git a/src/server/api/routers/invoices.ts b/src/server/api/routers/invoices.ts index 575013f..32b00d6 100644 --- a/src/server/api/routers/invoices.ts +++ b/src/server/api/routers/invoices.ts @@ -58,6 +58,36 @@ export const invoicesRouter = createTRPCRouter({ } }), + getCurrentOpen: protectedProcedure.query(async ({ ctx }) => { + try { + // Get the most recent draft invoice + const currentInvoice = await ctx.db.query.invoices.findFirst({ + where: eq(invoices.createdById, ctx.session.user.id), + with: { + business: true, + client: true, + items: { + orderBy: (items, { asc }) => [asc(items.position)], + }, + }, + orderBy: (invoices, { desc }) => [desc(invoices.createdAt)], + }); + + // Return null if no draft invoice exists + if (!currentInvoice || currentInvoice.status !== "draft") { + return null; + } + + return currentInvoice; + } catch (error) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to fetch current open invoice", + cause: error, + }); + } + }), + getById: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { diff --git a/src/styles/globals.css b/src/styles/globals.css index 75260e5..aa3f9fa 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -107,19 +107,19 @@ @media (prefers-color-scheme: dark) { :root { - --background: oklch(0.145 0.02 160); + --background: oklch(0 0 0); --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0.02 160); + --card: oklch(0.25 0.08 170); --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0.02 160); + --popover: oklch(0.25 0.08 170); --popover-foreground: oklch(0.985 0 0); --primary: oklch(0.922 0 0); --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0.015 160); + --secondary: oklch(0.30 0.05 170); --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0.015 160); + --muted: oklch(0.30 0.05 170); --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0.015 160); + --accent: oklch(0.30 0.05 170); --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); --destructive-foreground: oklch(0.985 0 0); @@ -133,7 +133,7 @@ --chart-5: oklch(0.645 0.246 16.439); --sidebar: oklch(0.205 0.02 160); --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary: oklch(0.696 0.17 162.48); --sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-accent: oklch(0.269 0.015 160); --sidebar-accent-foreground: oklch(0.985 0 0); @@ -376,8 +376,8 @@ .bg-gradient-auth { background: linear-gradient( 135deg, - oklch(0.145 0 0) 0%, - oklch(0.185 0 0) 100% + oklch(0.12 0.05 280) 0%, + oklch(0.16 0.03 260) 100% ); } } @@ -390,9 +390,11 @@ .bg-gradient-dashboard { background: linear-gradient( 135deg, - oklch(0.145 0 0) 0%, - oklch(0.185 0 0) 40%, - oklch(0.205 0 0) 100% + oklch(0.15 0.06 175) 0%, + oklch(0.18 0.08 180) 25%, + oklch(0.22 0.10 185) 50%, + oklch(0.26 0.12 190) 75%, + oklch(0.30 0.14 195) 100% ); } } @@ -414,12 +416,83 @@ ); } + /* New colorful background patterns for dark mode */ + .bg-cosmic-gradient { + background: linear-gradient(135deg, #f0fdf4 0%, #ecfdf5 50%, #d1fae5 100%); + } + + @media (prefers-color-scheme: dark) { + .bg-cosmic-gradient { + background: linear-gradient( + 135deg, + oklch(0.18 0.07 170) 0%, + oklch(0.22 0.09 175) 20%, + oklch(0.26 0.11 180) 40%, + oklch(0.30 0.13 185) 60%, + oklch(0.34 0.15 190) 80%, + oklch(0.38 0.17 195) 100% + ); + } + } + + .bg-aurora-gradient { + background: linear-gradient(45deg, #f0fdf4 0%, #ecfdf5 100%); + } + + @media (prefers-color-scheme: dark) { + .bg-aurora-gradient { + background: linear-gradient( + 45deg, + oklch(0.12 0.06 300) 0%, + oklch(0.14 0.05 270) 25%, + oklch(0.16 0.04 240) 50%, + oklch(0.15 0.03 210) 75%, + oklch(0.13 0.04 280) 100% + ); + } + } + + .bg-nebula-overlay::before { + content: ""; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 0; + pointer-events: none; + background: transparent; + } + + @media (prefers-color-scheme: dark) { + .bg-nebula-overlay::before { + background: + radial-gradient( + circle at 20% 20%, + oklch(0.25 0.08 300 / 0.15) 0%, + transparent 50% + ), + radial-gradient( + circle at 80% 80%, + oklch(0.22 0.06 240 / 0.12) 0%, + transparent 50% + ), + radial-gradient( + circle at 60% 10%, + oklch(0.18 0.04 270 / 0.08) 0%, + transparent 40% + ); + } + } + @media (prefers-color-scheme: dark) { .bg-radial-overlay::before { background: radial-gradient( ellipse at 80% 0%, - oklch(0.696 0.17 162.48 / 0.15) 0%, - transparent 60% + oklch(0.5 0.12 185 / 0.12) 0%, + oklch(0.4 0.10 190 / 0.08) 30%, + oklch(0.3 0.08 195 / 0.04) 60%, + transparent 80% ); } } diff --git a/tailwind.config.ts b/tailwind.config.ts index 3e9cb7e..b78d1b8 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -10,6 +10,9 @@ export default { ], theme: { extend: { + screens: { + xs: "475px", + }, fontFamily: { sans: [ "var(--font-geist-sans)",