339 lines
16 KiB
Markdown
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>
|