Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
202 lines
7.7 KiB
TypeScript
202 lines
7.7 KiB
TypeScript
import { createFileRoute, Link } from "@tanstack/react-router";
|
|
import { useEffect, 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 { supabase } from "@/integrations/supabase/client";
|
|
import { useAuth } from "@/lib/auth";
|
|
import { Loader2, Send, ExternalLink } from "lucide-react";
|
|
import { formatDateTime } from "@/lib/format";
|
|
import { toast } from "sonner";
|
|
|
|
export const Route = createFileRoute("/status/")({
|
|
component: () => (
|
|
<ProtectedLayout>
|
|
<StatusUpdatesPage />
|
|
</ProtectedLayout>
|
|
),
|
|
});
|
|
|
|
function nowLocal() {
|
|
const d = new Date();
|
|
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
|
|
return d.toISOString().slice(0, 16);
|
|
}
|
|
|
|
function StatusUpdatesPage() {
|
|
const { user } = useAuth();
|
|
const [cases, setCases] = useState<any[]>([]);
|
|
const [recent, setRecent] = useState<any[]>([]);
|
|
const [caseId, setCaseId] = useState<string>("");
|
|
const [title, setTitle] = useState(nowLocal());
|
|
const [body, setBody] = useState("");
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const loadCases = async () => {
|
|
const { data, error } = await supabase
|
|
.from("cases")
|
|
.select("id, case_number, title, client:clients(name)")
|
|
.is("archived_at", null)
|
|
.order("opened_at", { ascending: false })
|
|
.limit(500);
|
|
if (error) toast.error("Could not load cases", { description: error.message });
|
|
setCases(data ?? []);
|
|
};
|
|
|
|
const loadRecent = async () => {
|
|
const { data, error } = await supabase
|
|
.from("status_updates")
|
|
.select("*, case:cases(id, case_number, title, client:clients(name))")
|
|
.order("created_at", { ascending: false })
|
|
.limit(25);
|
|
if (error) { toast.error("Could not load updates", { description: error.message }); setLoading(false); return; }
|
|
const ids = Array.from(new Set((data ?? []).map((d: any) => d.created_by).filter(Boolean)));
|
|
let profMap: Record<string, any> = {};
|
|
if (ids.length) {
|
|
const { data: profs } = await supabase.from("profiles").select("id, full_name, email").in("id", ids);
|
|
profMap = Object.fromEntries((profs ?? []).map((p: any) => [p.id, p]));
|
|
}
|
|
setRecent((data ?? []).map((u: any) => ({ ...u, user: u.created_by ? profMap[u.created_by] : null })));
|
|
setLoading(false);
|
|
};
|
|
|
|
useEffect(() => { loadCases(); loadRecent(); }, []);
|
|
|
|
const selectedCase = useMemo(() => cases.find((c) => c.id === caseId), [cases, caseId]);
|
|
|
|
const submit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!caseId) { toast.error("Select a case"); return; }
|
|
if (!title.trim() || !body.trim()) { toast.error("Date/time and details required"); return; }
|
|
setSubmitting(true);
|
|
const { error } = await supabase.from("status_updates").insert({
|
|
case_id: caseId,
|
|
title: title.trim(),
|
|
body: body.trim(),
|
|
created_by: user?.id,
|
|
});
|
|
setSubmitting(false);
|
|
if (error) { toast.error(error.message); return; }
|
|
toast.success("Update logged");
|
|
setBody("");
|
|
setTitle(nowLocal());
|
|
loadRecent();
|
|
};
|
|
|
|
return (
|
|
<PageContainer>
|
|
<PageHeader
|
|
title="Status Updates"
|
|
description="Quickly log a status update against any case without leaving this page."
|
|
/>
|
|
|
|
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)]">
|
|
{/* Form */}
|
|
<Card className="border-border/60">
|
|
<CardContent className="p-5">
|
|
<form onSubmit={submit} className="space-y-4">
|
|
<div className="space-y-1.5">
|
|
<Label>Case</Label>
|
|
<Select value={caseId} onValueChange={setCaseId}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select a case…" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{cases.map((c) => (
|
|
<SelectItem key={c.id} value={c.id}>
|
|
{c.case_number} — {c.title}
|
|
{c.client?.name ? ` · ${c.client.name}` : ""}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{selectedCase && (
|
|
<Link
|
|
to="/cases/$caseId"
|
|
params={{ caseId: selectedCase.id }}
|
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-primary mt-1"
|
|
>
|
|
Open case <ExternalLink className="h-3 w-3" />
|
|
</Link>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label>Date & time</Label>
|
|
<Input type="datetime-local" value={title} onChange={(e) => setTitle(e.target.value)} required />
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label>Details</Label>
|
|
<Textarea
|
|
rows={6}
|
|
value={body}
|
|
onChange={(e) => setBody(e.target.value)}
|
|
required
|
|
maxLength={5000}
|
|
placeholder="What happened?"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex justify-end">
|
|
<Button type="submit" disabled={submitting || !caseId}>
|
|
{submitting ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Send className="h-4 w-4 mr-2" />}
|
|
Log update
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Recent */}
|
|
<div className="space-y-3">
|
|
<div className="text-xs uppercase tracking-wider text-muted-foreground px-1">
|
|
Recent updates (last 25)
|
|
</div>
|
|
{loading && (
|
|
<Card className="border-border/60"><CardContent className="p-6 text-center text-sm text-muted-foreground">Loading…</CardContent></Card>
|
|
)}
|
|
{!loading && recent.length === 0 && (
|
|
<Card className="border-border/60"><CardContent className="p-6 text-center text-sm text-muted-foreground">No updates yet.</CardContent></Card>
|
|
)}
|
|
{recent.map((u) => (
|
|
<Card key={u.id} className="border-border/60">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-start justify-between gap-3 mb-1">
|
|
<div className="min-w-0">
|
|
<div className="font-serif text-base">{formatDateTime(u.title)}</div>
|
|
<div className="text-xs text-muted-foreground truncate">
|
|
{u.case ? (
|
|
<Link to="/cases/$caseId" params={{ caseId: u.case.id }} className="hover:text-primary">
|
|
{u.case.case_number} — {u.case.title}
|
|
{u.case.client?.name ? ` · ${u.case.client.name}` : ""}
|
|
</Link>
|
|
) : "—"}
|
|
</div>
|
|
</div>
|
|
<div className="text-[11px] text-muted-foreground whitespace-nowrap">
|
|
{u.user?.full_name || u.user?.email || "Unknown"} · {formatDateTime(u.created_at)}
|
|
</div>
|
|
</div>
|
|
<p className="text-sm whitespace-pre-wrap mt-2">{u.body}</p>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</PageContainer>
|
|
);
|
|
}
|