Added call log quick-add

X-Lovable-Edit-ID: edt-7807d7e4-efe4-44ff-be9f-9cf0802a43d1
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-19 18:56:45 +00:00
co-authored by renee-png
2 changed files with 298 additions and 0 deletions
+2
View File
@@ -29,6 +29,7 @@ import { cn } from "@/lib/utils";
import type { ReactNode } from "react";
import { HeaderTimer } from "@/components/timer/header-timer";
import { QuickAddTime, QuickAddExpense } from "@/components/quick-add/quick-add";
import { QuickAddCallLog } from "@/components/quick-add/quick-add-call-log";
import { DateCalculator } from "@/components/date-calculator";
import { NotificationBell } from "@/components/notifications/notification-bell";
import { JusticeIcon } from "@/components/justice-icon";
@@ -150,6 +151,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<div className="hidden md:flex items-center justify-end gap-2 px-6 py-2 border-b bg-card/50">
<QuickAddTime />
<QuickAddExpense />
<QuickAddCallLog />
<DateCalculator />
<HeaderTimer />
<NotificationBell />
@@ -0,0 +1,296 @@
import { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/lib/auth";
import { supabase } from "@/integrations/supabase/client";
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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { Phone, Loader2 } from "lucide-react";
import { toast } from "sonner";
interface ClientOpt {
id: string;
name: string;
}
interface CaseOpt {
id: string;
case_number: string;
title: string;
client_id: string | null;
}
interface ContactOpt {
id: string;
name: string;
phone: string | null;
}
export function QuickAddCallLog() {
const { user } = useAuth();
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [clients, setClients] = useState<ClientOpt[]>([]);
const [cases, setCases] = useState<CaseOpt[]>([]);
const [contacts, setContacts] = useState<ContactOpt[]>([]);
const [clientId, setClientId] = useState("");
const [caseId, setCaseId] = useState("");
const [contactId, setContactId] = useState("");
const [callerName, setCallerName] = useState("");
const [callerPhone, setCallerPhone] = useState("");
const [direction, setDirection] = useState<"inbound" | "outbound">("outbound");
const [subject, setSubject] = useState("");
const [notes, setNotes] = useState("");
const [duration, setDuration] = useState("");
useEffect(() => {
if (!open) return;
(async () => {
const [{ data: cs }, { data: ks }, { data: ct }] = await Promise.all([
supabase.from("clients").select("id, name").is("archived_at", null).order("name"),
supabase
.from("cases")
.select("id, case_number, title, client_id")
.is("archived_at", null)
.order("case_number", { ascending: false }),
supabase
.from("contacts")
.select("id, name, phone")
.is("archived_at", null)
.order("name"),
]);
setClients(cs ?? []);
setCases((ks ?? []) as CaseOpt[]);
setContacts((ct ?? []) as ContactOpt[]);
})();
}, [open]);
const filteredCases = useMemo(
() => (clientId ? cases.filter((k) => k.client_id === clientId) : cases),
[cases, clientId],
);
const reset = () => {
setClientId("");
setCaseId("");
setContactId("");
setCallerName("");
setCallerPhone("");
setDirection("outbound");
setSubject("");
setNotes("");
setDuration("");
};
const handleSave = async () => {
if (!user) return;
if (!subject.trim() && !notes.trim()) {
toast.error("Please enter a subject or notes");
return;
}
setSaving(true);
try {
// call_logs requires case_id (NOT NULL). If no case selected, we can't save to call_logs;
// fall back to creating a status update / comment? For now, require case OR refuse.
if (!caseId) {
toast.error("A case is required to save a call log. Pick a case (or create one first).");
setSaving(false);
return;
}
const { error } = await supabase.from("call_logs").insert({
case_id: caseId,
contact_id: contactId || null,
caller_name: callerName || null,
caller_phone: callerPhone || null,
direction,
subject: subject || "(no subject)",
notes: notes || null,
duration_minutes: duration ? Number(duration) : null,
call_date: new Date().toISOString(),
created_by: user.id,
} as never);
if (error) throw error;
toast.success("Call log saved");
reset();
setOpen(false);
} catch (e: any) {
toast.error(e.message ?? "Could not save call log");
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="outline"
size="icon"
aria-label="Log a call"
title="Log a call"
>
<Phone className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Log a call</DialogTitle>
<DialogDescription>Record an incoming or outgoing call.</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Direction</Label>
<Select value={direction} onValueChange={(v) => setDirection(v as "inbound" | "outbound")}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="outbound">Outgoing</SelectItem>
<SelectItem value="inbound">Incoming</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Duration (min)</Label>
<Input
type="number"
min="0"
value={duration}
onChange={(e) => setDuration(e.target.value)}
placeholder="optional"
/>
</div>
</div>
<div>
<Label>Client (optional)</Label>
<SearchableSelect
value={clientId}
onValueChange={(id) => {
setClientId(id);
setCaseId("");
}}
placeholder="Select client"
searchPlaceholder="Search clients…"
emptyText="No clients found."
options={[
{ value: "", label: "— None —", keywords: "none" },
...clients.map((c) => ({ value: c.id, label: c.name, keywords: c.name })),
]}
/>
</div>
<div>
<Label>Case (optional)</Label>
<SearchableSelect
value={caseId}
onValueChange={setCaseId}
placeholder="Select case"
searchPlaceholder="Search cases…"
emptyText="No cases found."
options={[
{ value: "", label: "— None —", keywords: "none" },
...filteredCases.map((k) => ({
value: k.id,
label: `${k.case_number} — ${k.title}`,
keywords: `${k.case_number} ${k.title}`,
})),
]}
/>
</div>
<div>
<Label>Contact (optional)</Label>
<SearchableSelect
value={contactId}
onValueChange={(id) => {
setContactId(id);
const c = contacts.find((x) => x.id === id);
if (c) {
if (!callerName) setCallerName(c.name);
if (!callerPhone && c.phone) setCallerPhone(c.phone);
}
}}
placeholder="Select contact or enter manually below"
searchPlaceholder="Search contacts…"
emptyText="No contacts found."
options={[
{ value: "", label: "— None —", keywords: "none" },
...contacts.map((c) => ({
value: c.id,
label: c.phone ? `${c.name} (${c.phone})` : c.name,
keywords: `${c.name} ${c.phone ?? ""}`,
})),
]}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Caller name</Label>
<Input
value={callerName}
onChange={(e) => setCallerName(e.target.value)}
placeholder="Manual entry"
/>
</div>
<div>
<Label>Caller phone</Label>
<Input
value={callerPhone}
onChange={(e) => setCallerPhone(e.target.value)}
placeholder="Manual entry"
/>
</div>
</div>
<div>
<Label>Subject</Label>
<Input
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Brief summary"
/>
</div>
<div>
<Label>Description / Notes</Label>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={4}
placeholder="What was discussed…"
/>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)} disabled={saving}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Save call log
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}