diff --git a/bun.lockb b/bun.lockb index 1ff96ff..a7d1590 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index bc9bfe9..ae673e7 100644 --- a/package.json +++ b/package.json @@ -46,11 +46,14 @@ "@tanstack/react-router": "^1.168.0", "@tanstack/react-start": "^1.167.14", "@tanstack/router-plugin": "^1.167.10", + "@types/file-saver": "^2.0.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", + "docx": "^9.6.1", "embla-carousel-react": "^8.6.0", + "file-saver": "^2.0.5", "input-otp": "^1.4.2", "lucide-react": "^0.575.0", "react": "^19.2.0", 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/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 8e7d9c6..9d6560c 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -446,6 +446,48 @@ export type Database = { }, ] } + document_templates: { + Row: { + body: string + created_at: string + created_by: string | null + description: string | null + fields: Json + font_family: string + font_size_pt: number + id: string + kind: string + name: string + updated_at: string + } + Insert: { + body?: string + created_at?: string + created_by?: string | null + description?: string | null + fields?: Json + font_family?: string + font_size_pt?: number + id?: string + kind?: string + name: string + updated_at?: string + } + Update: { + body?: string + created_at?: string + created_by?: string | null + description?: string | null + fields?: Json + font_family?: string + font_size_pt?: number + id?: string + kind?: string + name?: string + updated_at?: string + } + Relationships: [] + } documents: { Row: { case_id: string @@ -652,6 +694,53 @@ export type Database = { } Relationships: [] } + generated_documents: { + Row: { + body: string | null + created_at: string + created_by: string | null + id: string + kind: string + name: string + payload: Json + storage_path: string | null + template_id: string | null + updated_at: string + } + Insert: { + body?: string | null + created_at?: string + created_by?: string | null + id?: string + kind?: string + name: string + payload?: Json + storage_path?: string | null + template_id?: string | null + updated_at?: string + } + Update: { + body?: string | null + created_at?: string + created_by?: string | null + id?: string + kind?: string + name?: string + payload?: Json + storage_path?: string | null + template_id?: string | null + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "generated_documents_template_id_fkey" + columns: ["template_id"] + isOneToOne: false + referencedRelation: "document_templates" + referencedColumns: ["id"] + }, + ] + } homeowners: { Row: { address: string | null diff --git a/src/lib/docx-pleading.ts b/src/lib/docx-pleading.ts new file mode 100644 index 0000000..b4fd212 --- /dev/null +++ b/src/lib/docx-pleading.ts @@ -0,0 +1,194 @@ +import { + AlignmentType, + Document, + HeightRule, + Packer, + PageOrientation, + Paragraph, + Table, + TableCell, + TableRow, + TextRun, + WidthType, + BorderStyle, +} from "docx"; +import { saveAs } from "file-saver"; + +export interface PleadingInput { + courtType: "CIRCUIT" | "COUNTY"; + circuit: string; // e.g. "ELEVENTH" + county: string; // e.g. "Miami-Dade" + plaintiffs: string; // multi-line + defendants: string; // multi-line + caseNumber: string; + title?: string; + body?: string; +} + +const FONT = "Bookman Old Style"; +const SIZE = 24; // 12pt (docx uses half-points) + +const noBorder = { + top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + insideHorizontal: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + insideVertical: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, +}; + +const verticalLine = { + top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + right: { style: BorderStyle.SINGLE, size: 8, color: "000000" }, +}; + +function run(text: string, opts: { bold?: boolean } = {}) { + return new TextRun({ text, bold: opts.bold, font: FONT, size: SIZE }); +} + +function p(text: string, opts: { bold?: boolean; align?: (typeof AlignmentType)[keyof typeof AlignmentType] } = {}) { + return new Paragraph({ + alignment: opts.align, + children: [run(text, { bold: opts.bold })], + }); +} + +function emptyP() { + return new Paragraph({ children: [run("")] }); +} + +export function buildPleadingDoc(input: PleadingInput): Document { + const headerLine1 = `IN THE ${input.courtType} COURT OF THE ${input.circuit} JUDICIAL CIRCUIT,`; + const headerLine2 = `IN AND FOR ${input.county.toUpperCase()} COUNTY, FLORIDA`; + + const plaintiffLines = (input.plaintiffs || "").split("\n").filter((l) => l.trim().length > 0); + const defendantLines = (input.defendants || "").split("\n").filter((l) => l.trim().length > 0); + + // Left side of caption + const leftCells: Paragraph[] = []; + plaintiffLines.forEach((l) => leftCells.push(p(l))); + if (plaintiffLines.length === 0) leftCells.push(p("")); + leftCells.push(emptyP()); + leftCells.push(p("Plaintiff(s),")); + leftCells.push(emptyP()); + leftCells.push(p("v.")); + leftCells.push(emptyP()); + defendantLines.forEach((l) => leftCells.push(p(l))); + if (defendantLines.length === 0) leftCells.push(p("")); + leftCells.push(emptyP()); + leftCells.push(p("Defendant(s).")); + + const rightCells: Paragraph[] = [ + p(`CASE NO.: ${input.caseNumber || ""}`, { bold: true }), + ]; + + const captionTable = new Table({ + width: { size: 9360, type: WidthType.DXA }, + columnWidths: [5400, 3960], + rows: [ + new TableRow({ + height: { value: 400, rule: HeightRule.AUTO }, + children: [ + new TableCell({ + width: { size: 5400, type: WidthType.DXA }, + borders: verticalLine, + margins: { top: 80, bottom: 80, left: 0, right: 200 }, + children: leftCells, + }), + new TableCell({ + width: { size: 3960, type: WidthType.DXA }, + borders: noBorder, + margins: { top: 80, bottom: 80, left: 200, right: 0 }, + children: rightCells, + }), + ], + }), + ], + }); + + const bodyParagraphs: Paragraph[] = []; + if (input.title) { + bodyParagraphs.push(emptyP()); + bodyParagraphs.push(p(input.title.toUpperCase(), { bold: true, align: AlignmentType.CENTER })); + bodyParagraphs.push(emptyP()); + } + if (input.body) { + input.body.split("\n").forEach((line) => bodyParagraphs.push(p(line))); + } + + return new Document({ + styles: { + default: { document: { run: { font: FONT, size: SIZE } } }, + }, + sections: [ + { + properties: { + page: { + size: { width: 12240, height: 15840, orientation: PageOrientation.PORTRAIT }, + margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 }, + }, + }, + children: [ + p(headerLine1, { bold: true, align: AlignmentType.CENTER }), + p(headerLine2, { bold: true, align: AlignmentType.CENTER }), + emptyP(), + captionTable, + ...bodyParagraphs, + ], + }, + ], + }); +} + +export async function downloadPleading(input: PleadingInput, filename: string) { + const doc = buildPleadingDoc(input); + const blob = await Packer.toBlob(doc); + saveAs(blob, filename.endsWith(".docx") ? filename : `${filename}.docx`); +} + +// ---------- Generic template (custom fields) ---------- + +export interface GenericDocInput { + title?: string; + body: string; // Already merged (fields substituted) + fontFamily?: string; + fontSizePt?: number; +} + +export async function downloadGeneric(input: GenericDocInput, filename: string) { + const font = input.fontFamily || "Bookman Old Style"; + const size = (input.fontSizePt || 12) * 2; + + const mkP = (text: string, bold = false, center = false) => + new Paragraph({ + alignment: center ? AlignmentType.CENTER : undefined, + children: [new TextRun({ text, bold, font, size })], + }); + + const children: Paragraph[] = []; + if (input.title) { + children.push(mkP(input.title, true, true)); + children.push(new Paragraph({ children: [new TextRun({ text: "", font, size })] })); + } + input.body.split("\n").forEach((line) => children.push(mkP(line))); + + const doc = new Document({ + styles: { default: { document: { run: { font, size } } } }, + sections: [ + { + properties: { + page: { + size: { width: 12240, height: 15840 }, + margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 }, + }, + }, + children, + }, + ], + }); + + const blob = await Packer.toBlob(doc); + saveAs(blob, filename.endsWith(".docx") ? filename : `${filename}.docx`); +} diff --git a/src/lib/florida.ts b/src/lib/florida.ts new file mode 100644 index 0000000..a1b1f30 --- /dev/null +++ b/src/lib/florida.ts @@ -0,0 +1,27 @@ +// Florida judicial circuits (1st–20th) and the counties they cover. +export const FL_CIRCUITS: { value: string; label: string; counties: string[] }[] = [ + { value: "FIRST", label: "First", counties: ["Escambia", "Okaloosa", "Santa Rosa", "Walton"] }, + { value: "SECOND", label: "Second", counties: ["Franklin", "Gadsden", "Jefferson", "Leon", "Liberty", "Wakulla"] }, + { value: "THIRD", label: "Third", counties: ["Columbia", "Dixie", "Hamilton", "Lafayette", "Madison", "Suwannee", "Taylor"] }, + { value: "FOURTH", label: "Fourth", counties: ["Clay", "Duval", "Nassau"] }, + { value: "FIFTH", label: "Fifth", counties: ["Citrus", "Hernando", "Lake", "Marion", "Sumter"] }, + { value: "SIXTH", label: "Sixth", counties: ["Pasco", "Pinellas"] }, + { value: "SEVENTH", label: "Seventh", counties: ["Flagler", "Putnam", "St. Johns", "Volusia"] }, + { value: "EIGHTH", label: "Eighth", counties: ["Alachua", "Baker", "Bradford", "Gilchrist", "Levy", "Union"] }, + { value: "NINTH", label: "Ninth", counties: ["Orange", "Osceola"] }, + { value: "TENTH", label: "Tenth", counties: ["Hardee", "Highlands", "Polk"] }, + { value: "ELEVENTH", label: "Eleventh", counties: ["Miami-Dade"] }, + { value: "TWELFTH", label: "Twelfth", counties: ["DeSoto", "Manatee", "Sarasota"] }, + { value: "THIRTEENTH", label: "Thirteenth", counties: ["Hillsborough"] }, + { value: "FOURTEENTH", label: "Fourteenth", counties: ["Bay", "Calhoun", "Gulf", "Holmes", "Jackson", "Washington"] }, + { value: "FIFTEENTH", label: "Fifteenth", counties: ["Palm Beach"] }, + { value: "SIXTEENTH", label: "Sixteenth", counties: ["Monroe"] }, + { value: "SEVENTEENTH", label: "Seventeenth", counties: ["Broward"] }, + { value: "EIGHTEENTH", label: "Eighteenth", counties: ["Brevard", "Seminole"] }, + { value: "NINETEENTH", label: "Nineteenth", counties: ["Indian River", "Martin", "Okeechobee", "St. Lucie"] }, + { value: "TWENTIETH", label: "Twentieth", counties: ["Charlotte", "Collier", "Glades", "Hendry", "Lee"] }, +]; + +export const FL_COUNTIES: string[] = Array.from( + new Set(FL_CIRCUITS.flatMap((c) => c.counties)), +).sort(); diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index b27c8fb..febe8ae 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,6 +460,11 @@ 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) 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" /> +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +