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
This commit is contained in:
Chandler Copeland
2026-03-19 16:47:28 -06:00
parent e55d7a1de5
commit df1924acc4
4 changed files with 230 additions and 0 deletions

View File

@@ -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<number>`count(${documents.id})::int`,
lastActivity: sql<Date | null>`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 <ClientsPageClient clients={clientRows} />;
}

View File

@@ -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 (
<Link href={"/portal/clients/" + id}>
<div className="bg-white rounded-xl shadow-sm p-5 hover:shadow-md transition-shadow cursor-pointer">
<p className="text-[var(--navy)] font-semibold text-base">{name}</p>
<p className="text-gray-500 text-sm mt-1">{email}</p>
<p className="text-gray-400 text-xs mt-3">
Documents: {docCount}
</p>
<p className="text-gray-400 text-xs mt-1">
Last activity:{" "}
{lastActivity
? lastActivity.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
: "No activity yet"}
</p>
</div>
</Link>
);
}

View File

@@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-xl p-6 w-full max-w-md">
<h2 className="text-[var(--navy)] text-lg font-semibold mb-4">
{title}
</h2>
<form action={formAction} className="space-y-4">
<div>
<label
htmlFor="name"
className="block text-sm font-medium text-gray-700 mb-1"
>
Name
</label>
<input
id="name"
name="name"
type="text"
defaultValue={defaultName}
required
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--gold)]"
/>
</div>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 mb-1"
>
Email
</label>
<input
id="email"
name="email"
type="email"
defaultValue={defaultEmail}
required
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--gold)]"
/>
</div>
{state?.error && (
<p className="text-red-500 text-sm">{state.error}</p>
)}
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm text-gray-600 border border-gray-200 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
disabled={pending}
className="px-4 py-2 text-sm text-white bg-[var(--gold)] rounded-lg hover:opacity-90 disabled:opacity-50"
>
{pending ? "Saving..." : "Save"}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -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 (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-[var(--navy)] text-2xl font-semibold">Clients</h1>
<button
onClick={() => setIsOpen(true)}
className="bg-[var(--gold)] text-white px-4 py-2 rounded-lg text-sm font-medium hover:opacity-90"
>
+ Add Client
</button>
</div>
{clients.length === 0 ? (
<div className="text-center py-16">
<p className="text-gray-500 mb-4">No clients yet.</p>
<button
onClick={() => setIsOpen(true)}
className="bg-[var(--gold)] text-white px-4 py-2 rounded-lg text-sm font-medium hover:opacity-90"
>
+ Add your first client
</button>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{clients.map((client) => (
<ClientCard
key={client.id}
id={client.id}
name={client.name}
email={client.email}
docCount={client.docCount}
lastActivity={client.lastActivity}
/>
))}
</div>
)}
<ClientModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
mode="create"
/>
</div>
);
}