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