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:
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user