Added litigation overview
X-Lovable-Edit-ID: edt-8351170c-d6dd-4396-a752-6df441f86dc9 Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
import { useState } from "react";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Pencil, Save, X, Calendar, Gavel, Scale, FileSearch, Users, AlertCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { formatCurrency, formatDate } from "@/lib/format";
|
||||
|
||||
interface Props {
|
||||
caseRecord: any;
|
||||
canManage: boolean;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
const FIELDS: Array<{ key: string; label: string; type?: string; full?: boolean }> = [
|
||||
{ key: "case_caption", label: "Case caption", full: true },
|
||||
{ key: "court_case_number", label: "Court case #" },
|
||||
{ key: "litigation_stage", label: "Litigation stage" },
|
||||
{ key: "court", label: "Court" },
|
||||
{ key: "jurisdiction", label: "Jurisdiction" },
|
||||
{ key: "judge", label: "Judge" },
|
||||
{ key: "filing_date", label: "Filing date", type: "date" },
|
||||
{ key: "next_hearing_date", label: "Next hearing", type: "date" },
|
||||
{ key: "statute_of_limitations", label: "Statute of limitations", type: "date" },
|
||||
{ key: "claim_amount", label: "Claim amount", type: "number" },
|
||||
{ key: "settlement_amount", label: "Settlement amount", type: "number" },
|
||||
];
|
||||
|
||||
const OPPOSING: Array<{ key: string; label: string }> = [
|
||||
{ key: "opposing_party", label: "Opposing party" },
|
||||
{ key: "opposing_counsel", label: "Opposing counsel" },
|
||||
{ key: "opposing_counsel_firm", label: "Counsel firm" },
|
||||
{ key: "opposing_counsel_email", label: "Counsel email" },
|
||||
{ key: "opposing_counsel_phone", label: "Counsel phone" },
|
||||
];
|
||||
|
||||
export function CaseLitigationTab({ caseRecord, canManage, onSaved }: Props) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState<any>(() => ({ ...caseRecord }));
|
||||
const [keyDates, setKeyDates] = useState<Array<{ date: string; label: string }>>(
|
||||
Array.isArray(caseRecord.key_dates) ? caseRecord.key_dates : [],
|
||||
);
|
||||
|
||||
const startEdit = () => {
|
||||
setForm({ ...caseRecord });
|
||||
setKeyDates(Array.isArray(caseRecord.key_dates) ? caseRecord.key_dates : []);
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
const payload: any = { key_dates: keyDates.filter((k) => k.date || k.label) };
|
||||
const all: Array<{ key: string; type?: string }> = [
|
||||
...FIELDS,
|
||||
...OPPOSING,
|
||||
{ key: "case_summary", type: "text" },
|
||||
{ key: "next_hearing_notes", type: "text" },
|
||||
];
|
||||
all.forEach((f) => {
|
||||
const v = form[f.key];
|
||||
if (f.type === "number") payload[f.key] = v === "" || v == null ? null : Number(v);
|
||||
else payload[f.key] = v === "" ? null : v ?? null;
|
||||
});
|
||||
const { error } = await supabase.from("cases").update(payload).eq("id", caseRecord.id);
|
||||
setSaving(false);
|
||||
if (error) {
|
||||
toast.error("Could not save", { description: error.message });
|
||||
return;
|
||||
}
|
||||
toast.success("Litigation details updated");
|
||||
setEditing(false);
|
||||
onSaved();
|
||||
};
|
||||
|
||||
const renderValue = (key: string, type?: string) => {
|
||||
const v = caseRecord[key];
|
||||
if (v == null || v === "") return <span className="text-muted-foreground">—</span>;
|
||||
if (type === "date") return formatDate(v);
|
||||
if (type === "number") return formatCurrency(Number(v));
|
||||
return String(v);
|
||||
};
|
||||
|
||||
const updateKeyDate = (i: number, patch: Partial<{ date: string; label: string }>) => {
|
||||
setKeyDates((prev) => prev.map((k, idx) => (idx === i ? { ...k, ...patch } : k)));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-serif text-xl">Litigation overview</h2>
|
||||
<p className="text-xs text-muted-foreground">Court details, opposing parties, and key dates.</p>
|
||||
</div>
|
||||
{canManage && !editing && (
|
||||
<Button variant="outline" size="sm" onClick={startEdit}>
|
||||
<Pencil className="h-3.5 w-3.5 mr-1.5" /> Edit
|
||||
</Button>
|
||||
)}
|
||||
{editing && (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={cancel} disabled={saving}>
|
||||
<X className="h-3.5 w-3.5 mr-1.5" /> Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={save} disabled={saving}>
|
||||
<Save className="h-3.5 w-3.5 mr-1.5" /> {saving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card className="border-border/60">
|
||||
<CardContent className="p-5 space-y-5">
|
||||
<Section icon={<FileSearch className="h-4 w-4" />} title="Case summary">
|
||||
{editing ? (
|
||||
<Textarea
|
||||
rows={5}
|
||||
value={form.case_summary ?? ""}
|
||||
onChange={(e) => setForm({ ...form, case_summary: e.target.value })}
|
||||
placeholder="Facts, claims, theory of the case, posture…"
|
||||
/>
|
||||
) : caseRecord.case_summary ? (
|
||||
<p className="text-sm whitespace-pre-wrap">{caseRecord.case_summary}</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No summary yet.</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Section icon={<Gavel className="h-4 w-4" />} title="Court & filing">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{FIELDS.map((f) => (
|
||||
<Field key={f.key} field={f} editing={editing} form={form} setForm={setForm} renderValue={renderValue} />
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Section icon={<Users className="h-4 w-4" />} title="Opposing party & counsel">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{OPPOSING.map((f) => (
|
||||
<Field key={f.key} field={f} editing={editing} form={form} setForm={setForm} renderValue={renderValue} />
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Section icon={<AlertCircle className="h-4 w-4" />} title="Next hearing notes">
|
||||
{editing ? (
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={form.next_hearing_notes ?? ""}
|
||||
onChange={(e) => setForm({ ...form, next_hearing_notes: e.target.value })}
|
||||
placeholder="What's on calendar, prep needs, location…"
|
||||
/>
|
||||
) : caseRecord.next_hearing_notes ? (
|
||||
<p className="text-sm whitespace-pre-wrap">{caseRecord.next_hearing_notes}</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">—</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Section icon={<Calendar className="h-4 w-4" />} title="Key dates timeline">
|
||||
{editing ? (
|
||||
<div className="space-y-2">
|
||||
{keyDates.map((k, i) => (
|
||||
<div key={i} className="flex gap-2 items-center">
|
||||
<Input
|
||||
type="date"
|
||||
value={k.date ?? ""}
|
||||
onChange={(e) => updateKeyDate(i, { date: e.target.value })}
|
||||
className="w-[170px]"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Event description"
|
||||
value={k.label ?? ""}
|
||||
onChange={(e) => updateKeyDate(i, { label: e.target.value })}
|
||||
/>
|
||||
<Button variant="ghost" size="icon" onClick={() => setKeyDates((p) => p.filter((_, idx) => idx !== i))}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button variant="outline" size="sm" onClick={() => setKeyDates((p) => [...p, { date: "", label: "" }])}>
|
||||
Add date
|
||||
</Button>
|
||||
</div>
|
||||
) : keyDates.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No key dates recorded.</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{[...keyDates]
|
||||
.sort((a, b) => (a.date || "").localeCompare(b.date || ""))
|
||||
.map((k, i) => (
|
||||
<li key={i} className="flex gap-3 text-sm">
|
||||
<Scale className="h-3.5 w-3.5 mt-0.5 text-muted-foreground" />
|
||||
<span className="font-mono text-xs text-muted-foreground w-24 shrink-0">
|
||||
{k.date ? formatDate(k.date) : "—"}
|
||||
</span>
|
||||
<span>{k.label || "—"}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ icon, title, children }: { icon: React.ReactNode; title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs uppercase tracking-wider text-muted-foreground">
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
field,
|
||||
editing,
|
||||
form,
|
||||
setForm,
|
||||
renderValue,
|
||||
}: {
|
||||
field: { key: string; label: string; type?: string };
|
||||
editing: boolean;
|
||||
form: any;
|
||||
setForm: (v: any) => void;
|
||||
renderValue: (key: string, type?: string) => React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">{field.label}</Label>
|
||||
{editing ? (
|
||||
<Input
|
||||
type={field.type ?? "text"}
|
||||
step={field.type === "number" ? "0.01" : undefined}
|
||||
value={form[field.key] ?? ""}
|
||||
onChange={(e) => setForm({ ...form, [field.key]: e.target.value })}
|
||||
className="mt-1"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm mt-1">{renderValue(field.key, field.type)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,49 +17,106 @@ export type Database = {
|
||||
cases: {
|
||||
Row: {
|
||||
assigned_attorney_id: string | null
|
||||
case_caption: string | null
|
||||
case_number: string
|
||||
case_summary: string | null
|
||||
claim_amount: number | null
|
||||
client_id: string
|
||||
closed_at: string | null
|
||||
court: string | null
|
||||
court_case_number: string | null
|
||||
created_at: string
|
||||
created_by: string | null
|
||||
default_hourly_rate: number | null
|
||||
description: string | null
|
||||
filing_date: string | null
|
||||
id: string
|
||||
judge: string | null
|
||||
jurisdiction: string | null
|
||||
key_dates: Json
|
||||
litigation_stage: string | null
|
||||
next_hearing_date: string | null
|
||||
next_hearing_notes: string | null
|
||||
opened_at: string
|
||||
opposing_counsel: string | null
|
||||
opposing_counsel_email: string | null
|
||||
opposing_counsel_firm: string | null
|
||||
opposing_counsel_phone: string | null
|
||||
opposing_party: string | null
|
||||
practice_area: string | null
|
||||
settlement_amount: number | null
|
||||
status: Database["public"]["Enums"]["case_status"]
|
||||
statute_of_limitations: string | null
|
||||
title: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
assigned_attorney_id?: string | null
|
||||
case_caption?: string | null
|
||||
case_number: string
|
||||
case_summary?: string | null
|
||||
claim_amount?: number | null
|
||||
client_id: string
|
||||
closed_at?: string | null
|
||||
court?: string | null
|
||||
court_case_number?: string | null
|
||||
created_at?: string
|
||||
created_by?: string | null
|
||||
default_hourly_rate?: number | null
|
||||
description?: string | null
|
||||
filing_date?: string | null
|
||||
id?: string
|
||||
judge?: string | null
|
||||
jurisdiction?: string | null
|
||||
key_dates?: Json
|
||||
litigation_stage?: string | null
|
||||
next_hearing_date?: string | null
|
||||
next_hearing_notes?: string | null
|
||||
opened_at?: string
|
||||
opposing_counsel?: string | null
|
||||
opposing_counsel_email?: string | null
|
||||
opposing_counsel_firm?: string | null
|
||||
opposing_counsel_phone?: string | null
|
||||
opposing_party?: string | null
|
||||
practice_area?: string | null
|
||||
settlement_amount?: number | null
|
||||
status?: Database["public"]["Enums"]["case_status"]
|
||||
statute_of_limitations?: string | null
|
||||
title: string
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
assigned_attorney_id?: string | null
|
||||
case_caption?: string | null
|
||||
case_number?: string
|
||||
case_summary?: string | null
|
||||
claim_amount?: number | null
|
||||
client_id?: string
|
||||
closed_at?: string | null
|
||||
court?: string | null
|
||||
court_case_number?: string | null
|
||||
created_at?: string
|
||||
created_by?: string | null
|
||||
default_hourly_rate?: number | null
|
||||
description?: string | null
|
||||
filing_date?: string | null
|
||||
id?: string
|
||||
judge?: string | null
|
||||
jurisdiction?: string | null
|
||||
key_dates?: Json
|
||||
litigation_stage?: string | null
|
||||
next_hearing_date?: string | null
|
||||
next_hearing_notes?: string | null
|
||||
opened_at?: string
|
||||
opposing_counsel?: string | null
|
||||
opposing_counsel_email?: string | null
|
||||
opposing_counsel_firm?: string | null
|
||||
opposing_counsel_phone?: string | null
|
||||
opposing_party?: string | null
|
||||
practice_area?: string | null
|
||||
settlement_amount?: number | null
|
||||
status?: Database["public"]["Enums"]["case_status"]
|
||||
statute_of_limitations?: string | null
|
||||
title?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
@@ -8,13 +8,14 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { ArrowLeft, FileText, Clock, DollarSign, Activity, Receipt } from "lucide-react";
|
||||
import { ArrowLeft, FileText, Clock, DollarSign, Activity, Receipt, Scale } from "lucide-react";
|
||||
import { formatCurrency, formatDate, statusBadgeClass } from "@/lib/format";
|
||||
import { CaseDocumentsTab } from "@/components/cases/documents-tab";
|
||||
import { CaseTimeTab } from "@/components/cases/time-tab";
|
||||
import { CaseExpensesTab } from "@/components/cases/expenses-tab";
|
||||
import { CaseStatusTab } from "@/components/cases/status-tab";
|
||||
import { CaseInvoicesTab } from "@/components/cases/invoices-tab";
|
||||
import { CaseLitigationTab } from "@/components/cases/litigation-tab";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
@@ -135,14 +136,16 @@ function CaseDetail() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Tabs defaultValue="overview">
|
||||
<Tabs defaultValue="litigation">
|
||||
<TabsList className="mb-4 flex-wrap h-auto">
|
||||
<TabsTrigger value="litigation"><Scale className="h-3.5 w-3.5 mr-1.5" />Litigation</TabsTrigger>
|
||||
<TabsTrigger value="overview"><Activity className="h-3.5 w-3.5 mr-1.5" />Status log</TabsTrigger>
|
||||
<TabsTrigger value="documents"><FileText className="h-3.5 w-3.5 mr-1.5" />Documents</TabsTrigger>
|
||||
<TabsTrigger value="time"><Clock className="h-3.5 w-3.5 mr-1.5" />Time</TabsTrigger>
|
||||
<TabsTrigger value="expenses"><DollarSign className="h-3.5 w-3.5 mr-1.5" />Expenses</TabsTrigger>
|
||||
<TabsTrigger value="invoices"><Receipt className="h-3.5 w-3.5 mr-1.5" />Invoices</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="litigation"><CaseLitigationTab caseRecord={data} canManage={canManage} onSaved={load} /></TabsContent>
|
||||
<TabsContent value="overview"><CaseStatusTab caseId={caseId} /></TabsContent>
|
||||
<TabsContent value="documents"><CaseDocumentsTab caseId={caseId} /></TabsContent>
|
||||
<TabsContent value="time"><CaseTimeTab caseRecord={data} /></TabsContent>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
ALTER TABLE public.cases
|
||||
ADD COLUMN IF NOT EXISTS case_summary text,
|
||||
ADD COLUMN IF NOT EXISTS opposing_party text,
|
||||
ADD COLUMN IF NOT EXISTS opposing_counsel text,
|
||||
ADD COLUMN IF NOT EXISTS opposing_counsel_firm text,
|
||||
ADD COLUMN IF NOT EXISTS opposing_counsel_email text,
|
||||
ADD COLUMN IF NOT EXISTS opposing_counsel_phone text,
|
||||
ADD COLUMN IF NOT EXISTS court text,
|
||||
ADD COLUMN IF NOT EXISTS jurisdiction text,
|
||||
ADD COLUMN IF NOT EXISTS judge text,
|
||||
ADD COLUMN IF NOT EXISTS court_case_number text,
|
||||
ADD COLUMN IF NOT EXISTS case_caption text,
|
||||
ADD COLUMN IF NOT EXISTS filing_date date,
|
||||
ADD COLUMN IF NOT EXISTS next_hearing_date date,
|
||||
ADD COLUMN IF NOT EXISTS next_hearing_notes text,
|
||||
ADD COLUMN IF NOT EXISTS statute_of_limitations date,
|
||||
ADD COLUMN IF NOT EXISTS claim_amount numeric,
|
||||
ADD COLUMN IF NOT EXISTS settlement_amount numeric,
|
||||
ADD COLUMN IF NOT EXISTS litigation_stage text,
|
||||
ADD COLUMN IF NOT EXISTS key_dates jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||
Reference in New Issue
Block a user