16 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 03-agent-portal-shell | 03 | execute | 3 |
|
|
true |
|
|
Purpose: These are the two main entry points of the portal (agent lands on dashboard post-login; clients is the client management hub). Seed data is needed so the dashboard table is not empty on first load. Output: Working /portal/dashboard with filterable documents table; working /portal/clients with card grid and create modal; extended seed.ts.
<execution_context> @/Users/ccopeland/.claude/get-shit-done/workflows/execute-plan.md @/Users/ccopeland/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/STATE.md @.planning/phases/03-agent-portal-shell/03-CONTEXT.md @.planning/phases/03-agent-portal-shell/03-RESEARCH.md @.planning/phases/03-agent-portal-shell/03-02-SUMMARY.mdFrom src/app/portal/_components/DocumentsTable.tsx:
type DocumentRow = {
id: string;
name: string;
clientName: string | null;
status: "Draft" | "Sent" | "Viewed" | "Signed";
sentAt: Date | null;
clientId: string;
};
type Props = { rows: DocumentRow[]; showClientColumn?: boolean };
export function DocumentsTable({ rows, showClientColumn = true }: Props): JSX.Element
From src/app/portal/_components/StatusBadge.tsx:
export function StatusBadge({ status }: { status: "Draft" | "Sent" | "Viewed" | "Signed" }): JSX.Element
From src/lib/actions/clients.ts:
export async function createClient(
_prevState: { error?: string } | null,
formData: FormData
): Promise<{ error?: string; success?: boolean }>
From src/lib/db/schema.ts (Plan 01):
export const clients // id, name, email, createdAt, updatedAt
export const documents // id, name, clientId, status, sentAt, createdAt
export const documentStatusEnum // "Draft" | "Sent" | "Viewed" | "Signed"
Drizzle query patterns (from RESEARCH.md):
import { db } from "@/lib/db";
import { documents, clients } from "@/lib/db/schema";
import { eq, desc, sql } from "drizzle-orm";
// Dashboard: all documents with client name
const rows = await db
.select({
id: documents.id,
name: documents.name,
status: documents.status,
sentAt: documents.sentAt,
clientName: clients.name,
clientId: documents.clientId,
})
.from(documents)
.leftJoin(clients, eq(documents.clientId, clients.id))
.orderBy(desc(documents.createdAt));
// Clients with document count + last activity
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));
Next.js 16 searchParams (server component):
// searchParams is a Promise in Next.js 16
export default async function DashboardPage({
searchParams,
}: {
searchParams: Promise<{ status?: string; sort?: string }>;
}) {
const { status } = await searchParams;
// filter rows by status if provided
}
Data fetching:
- Accept
searchParams: Promise<{ status?: string }>as a prop (Next.js 16 — must await) - Await searchParams to get
{ status } - Query all documents with LEFT JOIN to clients (use the Drizzle pattern from the interfaces block above)
- If
statusparam is set and is one of ["Draft", "Sent", "Viewed", "Signed"], filter the rows in JavaScript after fetch (simplest approach — the data set is tiny in Phase 3):rows.filter(r => r.status === status) - Sort by
documents.createdAtDESC in the query
Page layout:
<h1>with "Dashboard" heading- Filter bar: a
<StatusFilterBar>— inline a simple client component OR use a plain<form>approach:- Since filter state lives in the URL, use a plain
<select>that triggers navigation via a client component wrapper. Create a small inline"use client"componentDashboardFiltersin the SAME FILE (below the default export) that usesuseRouter+useSearchParamsto push?status=Xon change:"use client"; function DashboardFilters({ currentStatus }: { currentStatus?: string }) { const router = useRouter(); return ( <select value={currentStatus ?? ""} onChange={(e) => router.push(e.target.value ? `?status=${e.target.value}` : "/portal/dashboard")} className="border border-gray-200 rounded-lg px-3 py-1.5 text-sm" > <option value="">All statuses</option> <option value="Draft">Draft</option> <option value="Sent">Sent</option> <option value="Viewed">Viewed</option> <option value="Signed">Signed</option> </select> ); } - Import
useRouteranduseSearchParamsfromnext/navigationin the inline client component
- Since filter state lives in the URL, use a plain
- Below the filter bar:
<DocumentsTable rows={filteredRows} showClientColumn={true} /> - Page heading area should also show the agent's first name: extract from session via
await auth()(already done in layout, but page can also call it — session is cached by next-auth in the same request)
Styling:
- Use
bg-[var(--cream)]page background (inherited from layout) - Section heading
text-[var(--navy)] text-2xl font-semibold mb-6 - Filter bar:
flex items-center gap-4 mb-6cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 Dashboard page renders DocumentsTable with seeded rows. Filter select changes URL to ?status=X. TypeScript compiles cleanly.
Render a white card with rounded-xl shadow-sm:
- Client name:
text-[var(--navy)] font-semibold text-base - Email:
text-gray-500 text-sm - Row: "Documents: {docCount}" (small, gray)
- Row: "Last activity: {lastActivity formatted}" or "No activity yet" if null — use
toLocaleDateStringfor date - Wrap entire card in a Next.js
<Link href={"/portal/clients/" + id}>— clicking the card navigates to the profile page - Card styling:
bg-white rounded-xl shadow-sm p-5 hover:shadow-md transition-shadow
src/app/portal/_components/ClientModal.tsx ("use client"):
Props: { isOpen: boolean; onClose: () => void; mode?: "create" | "edit"; clientId?: string; defaultName?: string; defaultEmail?: string }
- If
mode === "edit"andclientIdexists, bindupdateClientto the id:const boundAction = updateClient.bind(null, clientId)— importupdateClientfrom@/lib/actions/clients - If
mode === "create", usecreateClientdirectly const [state, formAction, pending] = useActionState(mode === "edit" ? boundAction : createClient, null)— importuseActionStatefrom'react'NOT'react-dom'useEffect: ifstate?.success→ callonClose()- Modal overlay:
fixed inset-0 z-50 flex items-center justify-center bg-black/40 - Modal card:
bg-white rounded-xl shadow-xl p-6 w-full max-w-md - Two inputs: name (defaultValue={defaultName}), email (type="email", defaultValue={defaultEmail})
- Error display if
state?.error - Cancel + Submit buttons (Submit shows "Saving..." when pending, disabled when pending)
- Title: "Add Client" for create mode, "Edit Client" for edit mode
src/app/portal/(protected)/clients/page.tsx (async server component with a "use client" wrapper for modal state):
Since this page needs both server data AND client modal state, use a common pattern:
clients/page.tsxis a server component that fetches data and renders<ClientsPageClient clients={clientRows} />- Inline a
ClientsPageClient"use client" component below the default export in the same file, OR extract it to_components/ClientsPageClient.tsx
Either approach: the client wrapper holds const [isOpen, setIsOpen] = useState(false).
Server part fetches clients with docCount + lastActivity using the Drizzle query from interfaces block.
Client part renders:
- Page heading + "+ Add Client" button (gold background:
bg-[var(--gold)] text-white px-4 py-2 rounded-lg text-sm font-medium) - If no clients: empty state —
<p>No clients yet.</p>+<button onClick={() => setIsOpen(true)}>+ Add your first client</button>per CONTEXT.md decision - If clients exist:
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">with<ClientCard>for each <ClientModal isOpen={isOpen} onClose={() => setIsOpen(false)} mode="create" />
PITFALL: Do NOT use onMouseEnter/onMouseLeave on server component props. ClientCard should use Tailwind hover: classes (it's a server component — no event handlers).
cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1
ClientCard exported. ClientModal exported with create/edit mode support. Clients page renders grid + empty state + "+ Add Client" button. TypeScript compiles cleanly.
2 clients:
await db.insert(clients).values([
{ name: "Sarah Johnson", email: "sarah.j@example.com" },
{ name: "Mike Torres", email: "m.torres@example.com" },
]).onConflictDoNothing();
4 placeholder documents (use the client IDs just inserted — query them back after insert):
const [sarah, mike] = await db.select({ id: clients.id })
.from(clients)
.where(inArray(clients.email, ["sarah.j@example.com", "m.torres@example.com"]))
.orderBy(clients.createdAt);
// Handle case where seeded clients not found (e.g., already exist from prior seed run)
if (sarah && mike) {
await db.insert(documents).values([
{ name: "Purchase Agreement - 842 Maple Dr", clientId: sarah.id, status: "Signed", sentAt: new Date("2026-02-15") },
{ name: "Seller Disclosure - 842 Maple Dr", clientId: sarah.id, status: "Signed", sentAt: new Date("2026-02-14") },
{ name: "Buyer Rep Agreement", clientId: mike.id, status: "Sent", sentAt: new Date("2026-03-10") },
{ name: "Purchase Agreement - 1205 Oak Ave", clientId: mike.id, status: "Draft", sentAt: null },
]).onConflictDoNothing();
}
Import clients, documents from @/lib/db/schema (or relative path — match the existing import style in seed.ts).
Import inArray from drizzle-orm if needed.
Then run the seed:
cd teressa-copeland-homes && npm run db:seed
Verify it completes without error. cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run db:seed 2>&1 | tail -20 seed.ts runs without error. Database contains 2 client rows and 4 document rows. The clients page shows populated cards and the dashboard shows the 4 placeholder documents.
1. `npx tsc --noEmit` produces zero errors 2. `npm run db:seed` completes without error 3. `src/app/portal/(protected)/dashboard/page.tsx` exists and imports DocumentsTable 4. `src/app/portal/(protected)/clients/page.tsx` exists with card grid and modal trigger 5. `src/app/portal/_components/ClientCard.tsx` exported with Link to /portal/clients/[id] 6. `src/app/portal/_components/ClientModal.tsx` exported with useActionState from 'react'<success_criteria>
- Dashboard page renders document table with filter dropdown that uses URL search params
- Clients page renders a card grid with name, email, doc count, last activity on each card
- "+ Add Client" button opens modal; modal submits createClient action and closes on success
- Empty state shows friendly message with CTA when no clients exist
- Seed produces 2 clients and 4 placeholder documents
- TypeScript compiles cleanly </success_criteria>