diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index cc2e251..9701a8f 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -14,6 +14,7 @@ import { LayoutDashboard, Settings, Wallet, + FileSignature, } from "lucide-react"; import { cn } from "@/lib/utils"; import type { ReactNode } from "react"; @@ -30,6 +31,7 @@ const NAV: NavItem[] = [ { to: "/clients", label: "Clients", icon: Users }, { to: "/cases", label: "Cases", icon: Briefcase }, { to: "/collections", label: "Collections", icon: Wallet }, + { to: "/documents", label: "Documents", icon: FileSignature }, { to: "/invoices", label: "Invoices", icon: Receipt }, { to: "/admin/users", label: "Users", icon: ShieldCheck, adminOnly: true }, { to: "/settings", label: "Settings", icon: Settings, adminOnly: true }, diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index b27c8fb..688fae1 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -14,6 +14,7 @@ import { Route as SettingsRouteImport } from './routes/settings' import { Route as LoginRouteImport } from './routes/login' import { Route as IndexRouteImport } from './routes/index' import { Route as SettingsIndexRouteImport } from './routes/settings.index' +import { Route as DocumentsIndexRouteImport } from './routes/documents.index' import { Route as CollectionsIndexRouteImport } from './routes/collections.index' import { Route as ClientsIndexRouteImport } from './routes/clients.index' import { Route as CasesIndexRouteImport } from './routes/cases.index' @@ -24,6 +25,10 @@ import { Route as ClientsClientIdRouteImport } from './routes/clients.$clientId' import { Route as CasesNewRouteImport } from './routes/cases.new' import { Route as CasesCaseIdRouteImport } from './routes/cases.$caseId' import { Route as AdminUsersRouteImport } from './routes/admin.users' +import { Route as DocumentsTemplatesIndexRouteImport } from './routes/documents.templates.index' +import { Route as DocumentsTemplatesNewRouteImport } from './routes/documents.templates.new' +import { Route as DocumentsTemplatesTemplateIdRouteImport } from './routes/documents.templates.$templateId' +import { Route as DocumentsPleadingNewRouteImport } from './routes/documents.pleading.new' const SetupRoute = SetupRouteImport.update({ id: '/setup', @@ -50,6 +55,11 @@ const SettingsIndexRoute = SettingsIndexRouteImport.update({ path: '/', getParentRoute: () => SettingsRoute, } as any) +const DocumentsIndexRoute = DocumentsIndexRouteImport.update({ + id: '/documents/', + path: '/documents/', + getParentRoute: () => rootRouteImport, +} as any) const CollectionsIndexRoute = CollectionsIndexRouteImport.update({ id: '/collections/', path: '/collections/', @@ -100,6 +110,27 @@ const AdminUsersRoute = AdminUsersRouteImport.update({ path: '/admin/users', getParentRoute: () => rootRouteImport, } as any) +const DocumentsTemplatesIndexRoute = DocumentsTemplatesIndexRouteImport.update({ + id: '/documents/templates/', + path: '/documents/templates/', + getParentRoute: () => rootRouteImport, +} as any) +const DocumentsTemplatesNewRoute = DocumentsTemplatesNewRouteImport.update({ + id: '/documents/templates/new', + path: '/documents/templates/new', + getParentRoute: () => rootRouteImport, +} as any) +const DocumentsTemplatesTemplateIdRoute = + DocumentsTemplatesTemplateIdRouteImport.update({ + id: '/documents/templates/$templateId', + path: '/documents/templates/$templateId', + getParentRoute: () => rootRouteImport, + } as any) +const DocumentsPleadingNewRoute = DocumentsPleadingNewRouteImport.update({ + id: '/documents/pleading/new', + path: '/documents/pleading/new', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -116,7 +147,12 @@ export interface FileRoutesByFullPath { '/cases/': typeof CasesIndexRoute '/clients/': typeof ClientsIndexRoute '/collections/': typeof CollectionsIndexRoute + '/documents/': typeof DocumentsIndexRoute '/settings/': typeof SettingsIndexRoute + '/documents/pleading/new': typeof DocumentsPleadingNewRoute + '/documents/templates/$templateId': typeof DocumentsTemplatesTemplateIdRoute + '/documents/templates/new': typeof DocumentsTemplatesNewRoute + '/documents/templates/': typeof DocumentsTemplatesIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -132,7 +168,12 @@ export interface FileRoutesByTo { '/cases': typeof CasesIndexRoute '/clients': typeof ClientsIndexRoute '/collections': typeof CollectionsIndexRoute + '/documents': typeof DocumentsIndexRoute '/settings': typeof SettingsIndexRoute + '/documents/pleading/new': typeof DocumentsPleadingNewRoute + '/documents/templates/$templateId': typeof DocumentsTemplatesTemplateIdRoute + '/documents/templates/new': typeof DocumentsTemplatesNewRoute + '/documents/templates': typeof DocumentsTemplatesIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -150,7 +191,12 @@ export interface FileRoutesById { '/cases/': typeof CasesIndexRoute '/clients/': typeof ClientsIndexRoute '/collections/': typeof CollectionsIndexRoute + '/documents/': typeof DocumentsIndexRoute '/settings/': typeof SettingsIndexRoute + '/documents/pleading/new': typeof DocumentsPleadingNewRoute + '/documents/templates/$templateId': typeof DocumentsTemplatesTemplateIdRoute + '/documents/templates/new': typeof DocumentsTemplatesNewRoute + '/documents/templates/': typeof DocumentsTemplatesIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -169,7 +215,12 @@ export interface FileRouteTypes { | '/cases/' | '/clients/' | '/collections/' + | '/documents/' | '/settings/' + | '/documents/pleading/new' + | '/documents/templates/$templateId' + | '/documents/templates/new' + | '/documents/templates/' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -185,7 +236,12 @@ export interface FileRouteTypes { | '/cases' | '/clients' | '/collections' + | '/documents' | '/settings' + | '/documents/pleading/new' + | '/documents/templates/$templateId' + | '/documents/templates/new' + | '/documents/templates' id: | '__root__' | '/' @@ -202,7 +258,12 @@ export interface FileRouteTypes { | '/cases/' | '/clients/' | '/collections/' + | '/documents/' | '/settings/' + | '/documents/pleading/new' + | '/documents/templates/$templateId' + | '/documents/templates/new' + | '/documents/templates/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -218,6 +279,11 @@ export interface RootRouteChildren { CasesIndexRoute: typeof CasesIndexRoute ClientsIndexRoute: typeof ClientsIndexRoute CollectionsIndexRoute: typeof CollectionsIndexRoute + DocumentsIndexRoute: typeof DocumentsIndexRoute + DocumentsPleadingNewRoute: typeof DocumentsPleadingNewRoute + DocumentsTemplatesTemplateIdRoute: typeof DocumentsTemplatesTemplateIdRoute + DocumentsTemplatesNewRoute: typeof DocumentsTemplatesNewRoute + DocumentsTemplatesIndexRoute: typeof DocumentsTemplatesIndexRoute } declare module '@tanstack/react-router' { @@ -257,6 +323,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsIndexRouteImport parentRoute: typeof SettingsRoute } + '/documents/': { + id: '/documents/' + path: '/documents' + fullPath: '/documents/' + preLoaderRoute: typeof DocumentsIndexRouteImport + parentRoute: typeof rootRouteImport + } '/collections/': { id: '/collections/' path: '/collections' @@ -327,6 +400,34 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminUsersRouteImport parentRoute: typeof rootRouteImport } + '/documents/templates/': { + id: '/documents/templates/' + path: '/documents/templates' + fullPath: '/documents/templates/' + preLoaderRoute: typeof DocumentsTemplatesIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/documents/templates/new': { + id: '/documents/templates/new' + path: '/documents/templates/new' + fullPath: '/documents/templates/new' + preLoaderRoute: typeof DocumentsTemplatesNewRouteImport + parentRoute: typeof rootRouteImport + } + '/documents/templates/$templateId': { + id: '/documents/templates/$templateId' + path: '/documents/templates/$templateId' + fullPath: '/documents/templates/$templateId' + preLoaderRoute: typeof DocumentsTemplatesTemplateIdRouteImport + parentRoute: typeof rootRouteImport + } + '/documents/pleading/new': { + id: '/documents/pleading/new' + path: '/documents/pleading/new' + fullPath: '/documents/pleading/new' + preLoaderRoute: typeof DocumentsPleadingNewRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -359,7 +460,21 @@ const rootRouteChildren: RootRouteChildren = { CasesIndexRoute: CasesIndexRoute, ClientsIndexRoute: ClientsIndexRoute, CollectionsIndexRoute: CollectionsIndexRoute, + DocumentsIndexRoute: DocumentsIndexRoute, + DocumentsPleadingNewRoute: DocumentsPleadingNewRoute, + DocumentsTemplatesTemplateIdRoute: DocumentsTemplatesTemplateIdRoute, + DocumentsTemplatesNewRoute: DocumentsTemplatesNewRoute, + DocumentsTemplatesIndexRoute: DocumentsTemplatesIndexRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/src/routes/documents.index.tsx b/src/routes/documents.index.tsx new file mode 100644 index 0000000..ed2a423 --- /dev/null +++ b/src/routes/documents.index.tsx @@ -0,0 +1,143 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { ProtectedLayout } from "@/components/protected-layout"; +import { PageContainer, PageHeader } from "@/components/app-shell"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { supabase } from "@/integrations/supabase/client"; +import { formatDate } from "@/lib/format"; +import { FilePlus2, FileText, Gavel, LayoutTemplate, Plus, Download, Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +export const Route = createFileRoute("/documents/")({ + component: DocumentsIndex, +}); + +function DocumentsIndex() { + const [generated, setGenerated] = useState([]); + const [templates, setTemplates] = useState([]); + + const load = async () => { + const [g, t] = await Promise.all([ + supabase.from("generated_documents").select("*").order("created_at", { ascending: false }).limit(20), + supabase.from("document_templates").select("*").order("name"), + ]); + setGenerated(g.data ?? []); + setTemplates(t.data ?? []); + }; + + useEffect(() => { load(); }, []); + + const removeGenerated = async (row: any) => { + if (!confirm(`Delete ${row.name}?`)) return; + if (row.storage_path) await supabase.storage.from("generated-documents").remove([row.storage_path]); + const { error } = await supabase.from("generated_documents").delete().eq("id", row.id); + if (error) toast.error(error.message); else { toast.success("Deleted"); load(); } + }; + + const downloadGenerated = async (row: any) => { + if (!row.storage_path) { toast.error("No file stored for this document"); return; } + const { data, error } = await supabase.storage.from("generated-documents").createSignedUrl(row.storage_path, 60); + if (error) { toast.error(error.message); return; } + window.open(data.signedUrl, "_blank"); + }; + + return ( + + + + + + + } + /> + +
+ + + +
+
+
Florida Pleading
+
Court caption header in Bookman Old Style 12pt.
+
+
+
+ + + + +
+
+
New template
+
Define custom merge fields and reuse them.
+
+
+
+ + + + +
+
+
Template library
+
{templates.length} template{templates.length === 1 ? "" : "s"} available.
+
+
+
+ +
+ + + + Recent documents + + + {generated.length === 0 ? ( +
No documents yet. Create a pleading or use a template.
+ ) : ( + + + + + + + + + + + {generated.map((d) => ( + + + + + + + ))} + +
NameTypeCreatedActions
{d.name}
{d.kind}{formatDate(d.created_at)} + {d.storage_path && ( + + )} + +
+ )} +
+
+
+
+ ); +} diff --git a/src/routes/documents.pleading.new.tsx b/src/routes/documents.pleading.new.tsx new file mode 100644 index 0000000..1156e11 --- /dev/null +++ b/src/routes/documents.pleading.new.tsx @@ -0,0 +1,220 @@ +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useMemo, useState } from "react"; +import { ProtectedLayout } from "@/components/protected-layout"; +import { PageContainer, PageHeader } from "@/components/app-shell"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from "@/components/ui/select"; +import { FL_CIRCUITS } from "@/lib/florida"; +import { Packer } from "docx"; +import { buildPleadingDoc, downloadPleading } from "@/lib/docx-pleading"; +import { supabase } from "@/integrations/supabase/client"; +import { useAuth } from "@/lib/auth"; +import { toast } from "sonner"; +import { Download, Save, Loader2 } from "lucide-react"; + +export const Route = createFileRoute("/documents/pleading/new")({ + component: PleadingNewPage, +}); + +function PleadingNewPage() { + const { user } = useAuth(); + const navigate = useNavigate(); + const [courtType, setCourtType] = useState<"CIRCUIT" | "COUNTY">("CIRCUIT"); + const [circuit, setCircuit] = useState("ELEVENTH"); + const [county, setCounty] = useState("Miami-Dade"); + const [plaintiffs, setPlaintiffs] = useState(""); + const [defendants, setDefendants] = useState(""); + const [caseNumber, setCaseNumber] = useState(""); + const [title, setTitle] = useState(""); + const [body, setBody] = useState(""); + const [docName, setDocName] = useState("Pleading"); + const [saving, setSaving] = useState(false); + + const counties = useMemo(() => { + const c = FL_CIRCUITS.find((x) => x.value === circuit); + return c?.counties ?? []; + }, [circuit]); + + // Reset county when circuit changes if current county isn't valid + const handleCircuitChange = (v: string) => { + setCircuit(v); + const c = FL_CIRCUITS.find((x) => x.value === v); + if (c && !c.counties.includes(county)) setCounty(c.counties[0]); + }; + + const input = { + courtType, circuit, county, + plaintiffs, defendants, caseNumber, + title, body, + }; + + const headerLine1 = `IN THE ${courtType} COURT OF THE ${circuit} JUDICIAL CIRCUIT,`; + const headerLine2 = `IN AND FOR ${county.toUpperCase()} COUNTY, FLORIDA`; + + const onDownload = async () => { + await downloadPleading(input, docName || "Pleading"); + }; + + const onSave = async () => { + setSaving(true); + try { + const doc = buildPleadingDoc(input); + const blob = await Packer.toBlob(doc); + const safe = (docName || "Pleading").replace(/[^a-zA-Z0-9._-]/g, "_"); + const path = `${user?.id}/${Date.now()}-${safe}.docx`; + const { error: upErr } = await supabase.storage.from("generated-documents").upload(path, blob, { + contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + }); + if (upErr) throw upErr; + const { error: insErr } = await supabase.from("generated_documents").insert({ + name: docName || "Pleading", + kind: "pleading", + payload: input, + storage_path: path, + created_by: user?.id, + }); + if (insErr) throw insErr; + toast.success("Pleading saved"); + navigate({ to: "/documents" }); + } catch (e: any) { + toast.error(e.message ?? "Save failed"); + } finally { + setSaving(false); + } + }; + + return ( + + + + + + + } + /> + +
+ {/* Form */} + + +
+ + setDocName(e.target.value)} placeholder="e.g. Complaint - Smith v. Jones" /> +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +