diff --git a/teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx b/teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx new file mode 100644 index 0000000..3344c37 --- /dev/null +++ b/teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx @@ -0,0 +1,66 @@ +'use client'; +import { useState } from 'react'; +import { Document, Page, pdfjs } from 'react-pdf'; +import 'react-pdf/dist/Page/AnnotationLayer.css'; +import 'react-pdf/dist/Page/TextLayer.css'; + +// Worker setup — must use import.meta.url for local/Docker environments (no CDN) +pdfjs.GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/build/pdf.worker.min.mjs', + import.meta.url, +).toString(); + +export function PdfViewer({ docId }: { docId: string }) { + const [numPages, setNumPages] = useState(0); + const [pageNumber, setPageNumber] = useState(1); + const [scale, setScale] = useState(1.0); + + return ( +
+
+ + {pageNumber} / {numPages || '?'} + + + + + Download + +
+ + setNumPages(numPages)} + className="shadow-lg" + > + + +
+ ); +} diff --git a/teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/page.tsx b/teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/page.tsx new file mode 100644 index 0000000..09a9f22 --- /dev/null +++ b/teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/page.tsx @@ -0,0 +1,43 @@ +import { auth } from '@/lib/auth'; +import { redirect } from 'next/navigation'; +import { db } from '@/lib/db'; +import { documents, clients } from '@/lib/db/schema'; +import { eq } from 'drizzle-orm'; +import Link from 'next/link'; +import { PdfViewer } from './_components/PdfViewer'; + +export default async function DocumentPage({ + params, +}: { + params: Promise<{ docId: string }>; +}) { + const session = await auth(); + if (!session) redirect('/login'); + + const { docId } = await params; + + const doc = await db.query.documents.findFirst({ + where: eq(documents.id, docId), + with: { client: true }, + }); + + if (!doc) redirect('/portal/dashboard'); + + return ( +
+
+
+ + ← Back to {doc.client?.name ?? 'Client'} + +

{doc.name}

+

{doc.client?.name}

+
+
+ +
+ ); +} diff --git a/teressa-copeland-homes/src/app/portal/_components/AddDocumentModal.tsx b/teressa-copeland-homes/src/app/portal/_components/AddDocumentModal.tsx new file mode 100644 index 0000000..2936313 --- /dev/null +++ b/teressa-copeland-homes/src/app/portal/_components/AddDocumentModal.tsx @@ -0,0 +1,129 @@ +'use client'; +import { useState, useEffect, useTransition } from 'react'; +import { useRouter } from 'next/navigation'; + +type FormTemplate = { id: string; name: string; filename: string }; + +export function AddDocumentModal({ clientId, onClose }: { clientId: string; onClose: () => void }) { + const [templates, setTemplates] = useState([]); + const [query, setQuery] = useState(''); + const [docName, setDocName] = useState(''); + const [selectedTemplate, setSelectedTemplate] = useState(null); + const [customFile, setCustomFile] = useState(null); + const [isPending, startTransition] = useTransition(); + const [saving, setSaving] = useState(false); + const router = useRouter(); + + useEffect(() => { + fetch('/api/forms-library') + .then(r => r.json()) + .then(setTemplates) + .catch(console.error); + }, []); + + const filtered = templates.filter(t => + t.name.toLowerCase().includes(query.toLowerCase()) + ); + + const handleSelectTemplate = (t: FormTemplate) => { + setSelectedTemplate(t); + setCustomFile(null); + setDocName(t.name); + }; + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] ?? null; + setCustomFile(file); + setSelectedTemplate(null); + if (file) setDocName(file.name.replace(/\.pdf$/i, '')); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!docName.trim() || (!selectedTemplate && !customFile)) return; + + setSaving(true); + try { + if (customFile) { + const fd = new FormData(); + fd.append('clientId', clientId); + fd.append('name', docName.trim()); + fd.append('file', customFile); + await fetch('/api/documents', { method: 'POST', body: fd }); + } else { + await fetch('/api/documents', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clientId, name: docName.trim(), formTemplateId: selectedTemplate!.id }), + }); + } + startTransition(() => router.refresh()); + onClose(); + } catch (err) { + console.error(err); + } finally { + setSaving(false); + } + }; + + return ( +
+
+

Add Document

+ + setQuery(e.target.value)} + className="w-full border rounded px-3 py-2 mb-3 text-sm" + /> + +
    + {filtered.length === 0 && ( +
  • No forms found
  • + )} + {filtered.map(t => ( +
  • handleSelectTemplate(t)} + className={`px-3 py-2 text-sm cursor-pointer hover:bg-gray-100 ${selectedTemplate?.id === t.id ? 'bg-blue-50 font-medium' : ''}`} + > + {t.name} +
  • + ))} +
+ +
+ + +
+ +
+ + setDocName(e.target.value)} + required + className="w-full border rounded px-3 py-2 mb-4 text-sm" + placeholder="e.g. 123 Main St Purchase Agreement" + /> + +
+ + +
+
+
+
+ ); +} diff --git a/teressa-copeland-homes/src/app/portal/_components/ClientProfileClient.tsx b/teressa-copeland-homes/src/app/portal/_components/ClientProfileClient.tsx index 5f750ba..9035189 100644 --- a/teressa-copeland-homes/src/app/portal/_components/ClientProfileClient.tsx +++ b/teressa-copeland-homes/src/app/portal/_components/ClientProfileClient.tsx @@ -6,6 +6,7 @@ import Link from "next/link"; import { ClientModal } from "./ClientModal"; import { ConfirmDialog } from "./ConfirmDialog"; import { DocumentsTable } from "./DocumentsTable"; +import { AddDocumentModal } from "./AddDocumentModal"; import { deleteClient } from "@/lib/actions/clients"; type DocumentRow = { @@ -26,6 +27,7 @@ export function ClientProfileClient({ client, docs }: Props) { const router = useRouter(); const [isEditOpen, setIsEditOpen] = useState(false); const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const [isAddDocOpen, setIsAddDocOpen] = useState(false); async function handleDelete() { await deleteClient(client.id); @@ -35,34 +37,30 @@ export function ClientProfileClient({ client, docs }: Props) { return (
{/* Header card */} -
-
- +
+
+ Back to Clients
-
+
-

- {client.name} -

-

{client.email}

+

{client.name}

+

{client.email}

-
+
@@ -71,36 +69,33 @@ export function ClientProfileClient({ client, docs }: Props) {
{/* Documents card */} -
-

- Documents -

+
+
+

Documents

+ +
{docs.length === 0 ? ( -

No documents yet.

+
No documents yet.
) : ( )}
- {/* Edit modal */} - setIsEditOpen(false)} - mode="edit" - clientId={client.id} - defaultName={client.name} - defaultEmail={client.email} - /> - - {/* Delete confirmation */} + setIsEditOpen(false)} mode="edit" clientId={client.id} defaultName={client.name} defaultEmail={client.email} /> + {isAddDocOpen && ( + setIsAddDocOpen(false)} /> + )} setIsDeleteOpen(false)} /> diff --git a/teressa-copeland-homes/src/app/portal/_components/DocumentsTable.tsx b/teressa-copeland-homes/src/app/portal/_components/DocumentsTable.tsx index 3101051..b0be365 100644 --- a/teressa-copeland-homes/src/app/portal/_components/DocumentsTable.tsx +++ b/teressa-copeland-homes/src/app/portal/_components/DocumentsTable.tsx @@ -1,3 +1,4 @@ +import Link from "next/link"; import { StatusBadge } from "./StatusBadge"; type DocumentRow = { @@ -12,45 +13,55 @@ type DocumentRow = { type Props = { rows: DocumentRow[]; showClientColumn?: boolean }; export function DocumentsTable({ rows, showClientColumn = true }: Props) { + if (rows.length === 0) { + return ( +
+ No documents found. +
+ ); + } + return ( - +
- - + {showClientColumn && ( - )} - - {rows.map((row) => ( - - + + {showClientColumn && ( - + )} - - diff --git a/teressa-copeland-homes/src/lib/db/schema.ts b/teressa-copeland-homes/src/lib/db/schema.ts index 95ef1c0..3711a3f 100644 --- a/teressa-copeland-homes/src/lib/db/schema.ts +++ b/teressa-copeland-homes/src/lib/db/schema.ts @@ -1,4 +1,5 @@ import { pgEnum, pgTable, text, timestamp } from "drizzle-orm/pg-core"; +import { relations } from "drizzle-orm"; export const users = pgTable("users", { id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()), @@ -42,3 +43,11 @@ export const documents = pgTable("documents", { formTemplateId: text("form_template_id").references(() => formTemplates.id), filePath: text("file_path"), }); + +export const documentsRelations = relations(documents, ({ one }) => ({ + client: one(clients, { fields: [documents.clientId], references: [clients.id] }), +})); + +export const clientsRelations = relations(clients, ({ many }) => ({ + documents: many(documents), +}));
+
Document Name + Client + Status + Date Sent
{row.name}
+ + {row.name} + + - {row.clientName ?? "—"} - {row.clientName ?? "—"} + + {row.sentAt - ? row.sentAt.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - }) + ? new Date(row.sentAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) : "—"}