From df1924acc4532df818176916330d3dfc5094e6ee Mon Sep 17 00:00:00 2001 From: Chandler Copeland Date: Thu, 19 Mar 2026 16:47:28 -0600 Subject: [PATCH] feat(03-03): add Clients page with card grid and create modal - ClientCard.tsx: server component with name, email, doc count, last activity; wrapped in Link to /portal/clients/[id] - ClientModal.tsx: use client component with useActionState from react; supports create/edit modes via bind pattern; closes on success - ClientsPageClient.tsx: use client wrapper holding isOpen modal state, renders card grid or empty state CTA - clients/page.tsx: async server component fetching clients with docCount + lastActivity via Drizzle LEFT JOIN + GROUP BY --- .../app/portal/(protected)/clients/page.tsx | 22 ++++ .../src/app/portal/_components/ClientCard.tsx | 39 +++++++ .../app/portal/_components/ClientModal.tsx | 102 ++++++++++++++++++ .../portal/_components/ClientsPageClient.tsx | 67 ++++++++++++ 4 files changed, 230 insertions(+) create mode 100644 teressa-copeland-homes/src/app/portal/(protected)/clients/page.tsx create mode 100644 teressa-copeland-homes/src/app/portal/_components/ClientCard.tsx create mode 100644 teressa-copeland-homes/src/app/portal/_components/ClientModal.tsx create mode 100644 teressa-copeland-homes/src/app/portal/_components/ClientsPageClient.tsx diff --git a/teressa-copeland-homes/src/app/portal/(protected)/clients/page.tsx b/teressa-copeland-homes/src/app/portal/(protected)/clients/page.tsx new file mode 100644 index 0000000..b738470 --- /dev/null +++ b/teressa-copeland-homes/src/app/portal/(protected)/clients/page.tsx @@ -0,0 +1,22 @@ +import { db } from "@/lib/db"; +import { clients, documents } from "@/lib/db/schema"; +import { eq, desc, sql } from "drizzle-orm"; +import { ClientsPageClient } from "../../_components/ClientsPageClient"; + +export default async function ClientsPage() { + const clientRows = await db + .select({ + id: clients.id, + name: clients.name, + email: clients.email, + createdAt: clients.createdAt, + docCount: sql`count(${documents.id})::int`, + lastActivity: sql`max(${documents.sentAt})`, + }) + .from(clients) + .leftJoin(documents, eq(documents.clientId, clients.id)) + .groupBy(clients.id, clients.name, clients.email, clients.createdAt) + .orderBy(desc(clients.createdAt)); + + return ; +} diff --git a/teressa-copeland-homes/src/app/portal/_components/ClientCard.tsx b/teressa-copeland-homes/src/app/portal/_components/ClientCard.tsx new file mode 100644 index 0000000..a8cac93 --- /dev/null +++ b/teressa-copeland-homes/src/app/portal/_components/ClientCard.tsx @@ -0,0 +1,39 @@ +import Link from "next/link"; + +type ClientCardProps = { + id: string; + name: string; + email: string; + docCount: number; + lastActivity: Date | null; +}; + +export function ClientCard({ + id, + name, + email, + docCount, + lastActivity, +}: ClientCardProps) { + return ( + +
+

{name}

+

{email}

+

+ Documents: {docCount} +

+

+ Last activity:{" "} + {lastActivity + ? lastActivity.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }) + : "No activity yet"} +

+
+ + ); +} diff --git a/teressa-copeland-homes/src/app/portal/_components/ClientModal.tsx b/teressa-copeland-homes/src/app/portal/_components/ClientModal.tsx new file mode 100644 index 0000000..5bf894b --- /dev/null +++ b/teressa-copeland-homes/src/app/portal/_components/ClientModal.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useActionState, useEffect } from "react"; +import { createClient, updateClient } from "@/lib/actions/clients"; + +type ClientModalProps = { + isOpen: boolean; + onClose: () => void; + mode?: "create" | "edit"; + clientId?: string; + defaultName?: string; + defaultEmail?: string; +}; + +export function ClientModal({ + isOpen, + onClose, + mode = "create", + clientId, + defaultName, + defaultEmail, +}: ClientModalProps) { + const boundAction = + mode === "edit" && clientId + ? updateClient.bind(null, clientId) + : createClient; + + const [state, formAction, pending] = useActionState(boundAction, null); + + useEffect(() => { + if (state?.success) { + onClose(); + } + }, [state, onClose]); + + if (!isOpen) return null; + + const title = mode === "edit" ? "Edit Client" : "Add Client"; + + return ( +
+
+

+ {title} +

+
+
+ + +
+
+ + +
+ {state?.error && ( +

{state.error}

+ )} +
+ + +
+
+
+
+ ); +} diff --git a/teressa-copeland-homes/src/app/portal/_components/ClientsPageClient.tsx b/teressa-copeland-homes/src/app/portal/_components/ClientsPageClient.tsx new file mode 100644 index 0000000..b7b278d --- /dev/null +++ b/teressa-copeland-homes/src/app/portal/_components/ClientsPageClient.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useState } from "react"; +import { ClientCard } from "./ClientCard"; +import { ClientModal } from "./ClientModal"; + +type ClientRow = { + id: string; + name: string; + email: string; + docCount: number; + lastActivity: Date | null; + createdAt: Date; +}; + +type ClientsPageClientProps = { + clients: ClientRow[]; +}; + +export function ClientsPageClient({ clients }: ClientsPageClientProps) { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+
+

Clients

+ +
+ + {clients.length === 0 ? ( +
+

No clients yet.

+ +
+ ) : ( +
+ {clients.map((client) => ( + + ))} +
+ )} + + setIsOpen(false)} + mode="create" + /> +
+ ); +}