feat(03-04): add client profile page with edit/delete and documents table
- Create ConfirmDialog component with overlay, title, message, cancel/confirm buttons
- Create ClientProfilePage server component (awaits params Promise — Next.js 16)
- Create ClientProfileClient client component with edit modal and delete confirmation
- Documents section uses DocumentsTable with showClientColumn={false}
- deleteClient called directly from async event handler in client component
This commit is contained in:
@@ -0,0 +1,32 @@
|
|||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { clients, documents } from "@/lib/db/schema";
|
||||||
|
import { eq, desc, sql } from "drizzle-orm";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { ClientProfileClient } from "../../../_components/ClientProfileClient";
|
||||||
|
|
||||||
|
export default async function ClientProfilePage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const [client] = await db.select().from(clients).where(eq(clients.id, id));
|
||||||
|
if (!client) notFound();
|
||||||
|
|
||||||
|
const docs = await db
|
||||||
|
.select({
|
||||||
|
id: documents.id,
|
||||||
|
name: documents.name,
|
||||||
|
status: documents.status,
|
||||||
|
sentAt: documents.sentAt,
|
||||||
|
clientId: documents.clientId,
|
||||||
|
clientName: sql<string>`${clients.name}`,
|
||||||
|
})
|
||||||
|
.from(documents)
|
||||||
|
.leftJoin(clients, eq(documents.clientId, clients.id))
|
||||||
|
.where(eq(documents.clientId, id))
|
||||||
|
.orderBy(desc(documents.createdAt));
|
||||||
|
|
||||||
|
return <ClientProfileClient client={client} docs={docs} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ClientModal } from "./ClientModal";
|
||||||
|
import { ConfirmDialog } from "./ConfirmDialog";
|
||||||
|
import { DocumentsTable } from "./DocumentsTable";
|
||||||
|
import { deleteClient } from "@/lib/actions/clients";
|
||||||
|
|
||||||
|
type DocumentRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
clientName: string | null;
|
||||||
|
status: "Draft" | "Sent" | "Viewed" | "Signed";
|
||||||
|
sentAt: Date | null;
|
||||||
|
clientId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
client: { id: string; name: string; email: string };
|
||||||
|
docs: DocumentRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ClientProfileClient({ client, docs }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||||
|
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
await deleteClient(client.id);
|
||||||
|
router.push("/portal/clients");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header card */}
|
||||||
|
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||||
|
<div className="mb-4">
|
||||||
|
<Link
|
||||||
|
href="/portal/clients"
|
||||||
|
className="text-[var(--gold)] text-sm hover:opacity-80 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">←</span> Back to Clients
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-[var(--navy)] text-2xl font-semibold">
|
||||||
|
{client.name}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 text-sm mt-1">{client.email}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsEditOpen(true)}
|
||||||
|
className="border border-[var(--navy)] text-[var(--navy)] px-4 py-2 rounded-lg text-sm hover:bg-[var(--navy)] hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsDeleteOpen(true)}
|
||||||
|
className="border border-red-300 text-red-600 px-4 py-2 rounded-lg text-sm hover:bg-red-50 transition-colors"
|
||||||
|
>
|
||||||
|
Delete Client
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Documents card */}
|
||||||
|
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||||
|
<h2 className="text-[var(--navy)] text-lg font-semibold mb-4">
|
||||||
|
Documents
|
||||||
|
</h2>
|
||||||
|
{docs.length === 0 ? (
|
||||||
|
<p className="text-gray-500 text-sm">No documents yet.</p>
|
||||||
|
) : (
|
||||||
|
<DocumentsTable rows={docs} showClientColumn={false} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit modal */}
|
||||||
|
<ClientModal
|
||||||
|
isOpen={isEditOpen}
|
||||||
|
onClose={() => setIsEditOpen(false)}
|
||||||
|
mode="edit"
|
||||||
|
clientId={client.id}
|
||||||
|
defaultName={client.name}
|
||||||
|
defaultEmail={client.email}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={isDeleteOpen}
|
||||||
|
title="Delete client?"
|
||||||
|
message={
|
||||||
|
"Delete " +
|
||||||
|
client.name +
|
||||||
|
"? This will also delete all associated documents. This cannot be undone."
|
||||||
|
}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
onCancel={() => setIsDeleteOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
confirmLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ConfirmDialog({
|
||||||
|
isOpen,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
confirmLabel = "Delete",
|
||||||
|
}: Props) {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
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-2">{title}</h2>
|
||||||
|
<p className="text-gray-600 text-sm mb-6">{message}</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-4 py-2 text-sm text-gray-600 border border-gray-200 rounded-lg hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="px-4 py-2 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700"
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user