Files
red/.planning/phases/03-agent-portal-shell/03-03-PLAN.md

339 lines
16 KiB
Markdown

---
phase: 03-agent-portal-shell
plan: 03
type: execute
wave: 3
depends_on: [03-01, 03-02]
files_modified:
- teressa-copeland-homes/src/app/portal/(protected)/dashboard/page.tsx
- teressa-copeland-homes/src/app/portal/(protected)/clients/page.tsx
- teressa-copeland-homes/src/app/portal/_components/ClientCard.tsx
- teressa-copeland-homes/src/app/portal/_components/ClientModal.tsx
- teressa-copeland-homes/scripts/seed.ts
autonomous: true
requirements: [CLIENT-01, CLIENT-02, DASH-01, DASH-02]
must_haves:
truths:
- "Agent visits /portal/clients and sees a card grid of clients — each card shows name, email, document count, and last activity date"
- "Agent clicks '+ Add Client' on the clients page, a modal opens, fills name + email, clicks submit — client appears in the grid"
- "Agent visits /portal/dashboard and sees a table of documents with Document Name, Client, Status badge, and Date Sent columns"
- "Dashboard filter dropdown (All / Draft / Sent / Viewed / Signed) filters the table; URL reflects the filter (?status=Signed)"
- "After running npm run db:seed, the database contains at least 2 clients and 4 placeholder documents"
artifacts:
- path: "teressa-copeland-homes/src/app/portal/(protected)/dashboard/page.tsx"
provides: "Dashboard with documents table, status filter, date sort"
- path: "teressa-copeland-homes/src/app/portal/(protected)/clients/page.tsx"
provides: "Clients card grid with '+ Add Client' modal trigger and empty state"
- path: "teressa-copeland-homes/src/app/portal/_components/ClientCard.tsx"
provides: "Individual client card with name, email, doc count, last activity"
- path: "teressa-copeland-homes/src/app/portal/_components/ClientModal.tsx"
provides: "Create/edit modal using useActionState with createClient action"
- path: "teressa-copeland-homes/scripts/seed.ts"
provides: "Seed data: 2 clients + 4 placeholder documents"
key_links:
- from: "clients/page.tsx"
to: "ClientModal"
via: "useState(isOpen) + <ClientModal isOpen={isOpen} onClose={() => setIsOpen(false)} />"
pattern: "ClientModal"
- from: "ClientModal"
to: "createClient server action"
via: "useActionState(createClient, null)"
pattern: "useActionState.*createClient"
- from: "dashboard/page.tsx"
to: "DocumentsTable"
via: "pass rows prop from Drizzle JOIN query"
pattern: "DocumentsTable"
- from: "dashboard/page.tsx searchParams"
to: "Drizzle WHERE clause"
via: "status filter applied in server component before passing to DocumentsTable"
pattern: "searchParams.*status"
---
<objective>
Build the Dashboard page and Clients list page — the two primary portal views. Also seed the database with placeholder data so the UI looks populated from the first run.
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.
</objective>
<execution_context>
@/Users/ccopeland/.claude/get-shit-done/workflows/execute-plan.md
@/Users/ccopeland/.claude/get-shit-done/templates/summary.md
</execution_context>
<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.md
<interfaces>
<!-- From Plan 02 — use these exactly -->
From src/app/portal/_components/DocumentsTable.tsx:
```typescript
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:
```typescript
export function StatusBadge({ status }: { status: "Draft" | "Sent" | "Viewed" | "Signed" }): JSX.Element
```
From src/lib/actions/clients.ts:
```typescript
export async function createClient(
_prevState: { error?: string } | null,
formData: FormData
): Promise<{ error?: string; success?: boolean }>
```
From src/lib/db/schema.ts (Plan 01):
```typescript
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):
```typescript
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):
```typescript
// 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
}
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Dashboard page with filterable documents table</name>
<files>teressa-copeland-homes/src/app/portal/(protected)/dashboard/page.tsx</files>
<action>
Create `src/app/portal/(protected)/dashboard/page.tsx` as an async server component.
**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 `status` param 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.createdAt` DESC 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"` component `DashboardFilters` in the SAME FILE (below the default export) that uses `useRouter` + `useSearchParams` to push `?status=X` on change:
```typescript
"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 `useRouter` and `useSearchParams` from `next/navigation` in the inline client component
- 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-6`
</action>
<verify>
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1</automated>
</verify>
<done>Dashboard page renders DocumentsTable with seeded rows. Filter select changes URL to ?status=X. TypeScript compiles cleanly.</done>
</task>
<task type="auto">
<name>Task 2: Clients list page with card grid and create modal</name>
<files>
teressa-copeland-homes/src/app/portal/(protected)/clients/page.tsx
teressa-copeland-homes/src/app/portal/_components/ClientCard.tsx
teressa-copeland-homes/src/app/portal/_components/ClientModal.tsx
</files>
<action>
**`src/app/portal/_components/ClientCard.tsx`** (server component):
Props: `{ id: string; name: string; email: string; docCount: number; lastActivity: Date | null }`
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 `toLocaleDateString` for 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"` and `clientId` exists, bind `updateClient` to the id: `const boundAction = updateClient.bind(null, clientId)` — import `updateClient` from `@/lib/actions/clients`
- If `mode === "create"`, use `createClient` directly
- `const [state, formAction, pending] = useActionState(mode === "edit" ? boundAction : createClient, null)` — import `useActionState` from `'react'` NOT `'react-dom'`
- `useEffect`: if `state?.success` → call `onClose()`
- 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.tsx` is 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).
</action>
<verify>
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1</automated>
</verify>
<done>ClientCard exported. ClientModal exported with create/edit mode support. Clients page renders grid + empty state + "+ Add Client" button. TypeScript compiles cleanly.</done>
</task>
<task type="auto">
<name>Task 3: Extend seed.ts with client and placeholder document rows</name>
<files>teressa-copeland-homes/scripts/seed.ts</files>
<action>
Read the existing `scripts/seed.ts` to understand its structure. It already seeds the admin user. Add, AFTER the existing user seed:
**2 clients:**
```typescript
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):
```typescript
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.
</action>
<verify>
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run db:seed 2>&1 | tail -20</automated>
</verify>
<done>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.</done>
</task>
</tasks>
<verification>
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'
</verification>
<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>
<output>
After completion, create `.planning/phases/03-agent-portal-shell/03-03-SUMMARY.md`
</output>