Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-17 02:46:25 +00:00
co-authored by renee-png
parent 09351957de
commit 38c6f72bd2
2 changed files with 728 additions and 0 deletions
+322
View File
@@ -0,0 +1,322 @@
import { useCallback, useEffect, useState } from "react";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/lib/auth";
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 { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Loader2, Phone, PhoneIncoming, PhoneOutgoing, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { formatDateTime } from "@/lib/format";
type Direction = "inbound" | "outbound";
interface CallLog {
id: string;
case_id: string;
contact_id: string | null;
call_date: string;
direction: Direction;
duration_minutes: number | null;
caller_name: string | null;
caller_phone: string | null;
subject: string;
notes: string | null;
follow_up_required: boolean;
follow_up_date: string | null;
billable: boolean;
created_by: string | null;
created_at: string;
}
export function CaseCallLogsTab({ caseId }: { caseId: string }) {
const { user, isAdmin } = useAuth();
const [logs, setLogs] = useState<CallLog[]>([]);
const [loading, setLoading] = useState(true);
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<CallLog | null>(null);
const load = useCallback(async () => {
setLoading(true);
const { data, error } = await supabase
.from("call_logs")
.select("*")
.eq("case_id", caseId)
.order("call_date", { ascending: false });
if (error) toast.error("Failed to load call logs", { description: error.message });
setLogs((data ?? []) as CallLog[]);
setLoading(false);
}, [caseId]);
useEffect(() => { load(); }, [load]);
const remove = async (id: string) => {
if (!confirm("Delete this call log?")) return;
const { error } = await supabase.from("call_logs").delete().eq("id", id);
if (error) toast.error(error.message);
else { toast.success("Deleted"); load(); }
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Phone className="h-4 w-4 text-primary" />
<h3 className="font-medium">Call log</h3>
{logs.length > 0 && <Badge variant="outline" className="text-[10px]">{logs.length}</Badge>}
</div>
<Button size="sm" onClick={() => { setEditing(null); setOpen(true); }}>
<Plus className="h-3.5 w-3.5 mr-1.5" /> Log call
</Button>
</div>
{loading ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : logs.length === 0 ? (
<Card className="border-dashed border-border/60">
<CardContent className="p-6 text-center text-sm text-muted-foreground">
No calls logged yet.
</CardContent>
</Card>
) : (
<div className="space-y-2">
{logs.map((c) => {
const canEdit = isAdmin || c.created_by === user?.id;
const Icon = c.direction === "inbound" ? PhoneIncoming : PhoneOutgoing;
return (
<Card key={c.id} className="border-border/60 hover:border-border transition-colors">
<CardContent className="p-3">
<div className="flex items-start gap-3">
<div className={`rounded-full p-2 mt-0.5 ${c.direction === "inbound" ? "bg-success/10 text-success" : "bg-primary/10 text-primary"}`}>
<Icon className="h-3.5 w-3.5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<button
className="font-medium text-sm text-left hover:underline"
onClick={() => canEdit && (setEditing(c), setOpen(true))}
>
{c.subject || "(no subject)"}
</button>
{c.billable && <Badge variant="outline" className="text-[10px]">Billable</Badge>}
{c.follow_up_required && (
<Badge variant="outline" className="text-[10px] bg-warning/15 text-warning-foreground border-warning/40">
Follow-up{c.follow_up_date ? ` ${c.follow_up_date}` : ""}
</Badge>
)}
</div>
<div className="text-xs text-muted-foreground mt-0.5">
{formatDateTime(c.call_date)}
{c.caller_name && ` · ${c.caller_name}`}
{c.caller_phone && ` · ${c.caller_phone}`}
{c.duration_minutes != null && ` · ${c.duration_minutes} min`}
</div>
{c.notes && (
<p className="text-sm mt-2 whitespace-pre-wrap">{c.notes}</p>
)}
</div>
{canEdit && (
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => remove(c.id)}>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
)}
</div>
</CardContent>
</Card>
);
})}
</div>
)}
<CallLogDialog
open={open}
onOpenChange={setOpen}
caseId={caseId}
userId={user?.id}
editing={editing}
onSaved={load}
/>
</div>
);
}
function CallLogDialog({
open,
onOpenChange,
caseId,
userId,
editing,
onSaved,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
caseId: string;
userId?: string;
editing: CallLog | null;
onSaved: () => void;
}) {
const [date, setDate] = useState("");
const [direction, setDirection] = useState<Direction>("outbound");
const [duration, setDuration] = useState("");
const [callerName, setCallerName] = useState("");
const [callerPhone, setCallerPhone] = useState("");
const [subject, setSubject] = useState("");
const [notes, setNotes] = useState("");
const [followUp, setFollowUp] = useState(false);
const [followUpDate, setFollowUpDate] = useState("");
const [billable, setBillable] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
if (editing) {
// datetime-local needs YYYY-MM-DDTHH:MM
const d = new Date(editing.call_date);
const pad = (n: number) => String(n).padStart(2, "0");
const local = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
setDate(local);
setDirection(editing.direction);
setDuration(editing.duration_minutes?.toString() ?? "");
setCallerName(editing.caller_name ?? "");
setCallerPhone(editing.caller_phone ?? "");
setSubject(editing.subject ?? "");
setNotes(editing.notes ?? "");
setFollowUp(editing.follow_up_required);
setFollowUpDate(editing.follow_up_date ?? "");
setBillable(editing.billable);
} else {
const now = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
setDate(`${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`);
setDirection("outbound");
setDuration("");
setCallerName("");
setCallerPhone("");
setSubject("");
setNotes("");
setFollowUp(false);
setFollowUpDate("");
setBillable(false);
}
}, [open, editing]);
const submit = async () => {
if (!subject.trim()) { toast.error("Subject required"); return; }
setSaving(true);
const payload = {
case_id: caseId,
call_date: new Date(date).toISOString(),
direction,
duration_minutes: duration ? parseInt(duration) : null,
caller_name: callerName || null,
caller_phone: callerPhone || null,
subject,
notes: notes || null,
follow_up_required: followUp,
follow_up_date: followUp && followUpDate ? followUpDate : null,
billable,
};
const { error } = editing
? await supabase.from("call_logs").update(payload).eq("id", editing.id)
: await supabase.from("call_logs").insert({ ...payload, created_by: userId });
setSaving(false);
if (error) toast.error(error.message);
else {
toast.success(editing ? "Updated" : "Logged");
onOpenChange(false);
onSaved();
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editing ? "Edit call log" : "Log a call"}</DialogTitle>
<DialogDescription>Track inbound and outbound calls related to this case.</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs">Date / time</Label>
<Input type="datetime-local" value={date} onChange={(e) => setDate(e.target.value)} />
</div>
<div>
<Label className="text-xs">Direction</Label>
<Select value={direction} onValueChange={(v) => setDirection(v as Direction)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="outbound">Outbound</SelectItem>
<SelectItem value="inbound">Inbound</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="text-xs">Subject</Label>
<Input value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="What was the call about?" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs">Caller name</Label>
<Input value={callerName} onChange={(e) => setCallerName(e.target.value)} />
</div>
<div>
<Label className="text-xs">Caller phone</Label>
<Input value={callerPhone} onChange={(e) => setCallerPhone(e.target.value)} />
</div>
</div>
<div>
<Label className="text-xs">Duration (minutes)</Label>
<Input type="number" min="0" value={duration} onChange={(e) => setDuration(e.target.value)} />
</div>
<div>
<Label className="text-xs">Notes</Label>
<Textarea rows={4} value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
<div className="flex items-center gap-6 pt-1">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={billable} onCheckedChange={(c) => setBillable(!!c)} />
Billable
</label>
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={followUp} onCheckedChange={(c) => setFollowUp(!!c)} />
Follow-up needed
</label>
</div>
{followUp && (
<div>
<Label className="text-xs">Follow-up date</Label>
<Input type="date" value={followUpDate} onChange={(e) => setFollowUpDate(e.target.value)} />
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={submit} disabled={saving}>
{saving && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
{editing ? "Save" : "Log call"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}