- ContactsPage: drop whiteSpace:nowrap on the action cell; wrap the Edit/History/Delete/⋮ buttons in a flex container with flex-wrap so they reflow instead of forcing the table wider than the viewport; allow the name/number/email cell to break long strings. - index.css: td word-break:break-word + table max-width:100% as a guard. - Sync the UI repo (ContactsPage/ContactHistory/MessagesPage implicit-any annotations that unblock npm run build) into the frontend/ mirror.
209 lines
9.2 KiB
TypeScript
209 lines
9.2 KiB
TypeScript
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { api } from "../lib/api";
|
|
import { useState } from "react";
|
|
|
|
export default function MessagesPage() {
|
|
const [q, setQ] = useState("");
|
|
const [dateFrom, setDateFrom] = useState("");
|
|
const [dateTo, setDateTo] = useState("");
|
|
const [unreadOnly, setUnreadOnly] = useState(false);
|
|
const [expanded, setExpanded] = useState<number | null>(null);
|
|
const [addingId, setAddingId] = useState<number | null>(null);
|
|
const [addName, setAddName] = useState("");
|
|
const [addEmail, setAddEmail] = useState("");
|
|
const qc = useQueryClient();
|
|
|
|
const { data, isLoading, error } = useQuery({
|
|
queryKey: ["messages", q, dateFrom, dateTo, unreadOnly],
|
|
queryFn: () => api.messages({ q, date_from: dateFrom, date_to: dateTo, unread: unreadOnly ? "1" : "" }),
|
|
});
|
|
|
|
const toggleRead = useMutation({
|
|
mutationFn: (id: number) => api.toggleRead(id),
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["messages"] }),
|
|
});
|
|
const deleteMsg = useMutation({
|
|
mutationFn: (id: number) => api.deleteMessage(id),
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["messages"] }),
|
|
});
|
|
const addContact = useMutation({
|
|
mutationFn: (data: { name: string; number?: string; email?: string | null }) => api.createContact(data),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ["contacts"] });
|
|
qc.invalidateQueries({ queryKey: ["messages"] });
|
|
setAddingId(null);
|
|
setAddName("");
|
|
setAddEmail("");
|
|
},
|
|
});
|
|
|
|
const onSubmit = (e: React.FormEvent) => e.preventDefault();
|
|
|
|
const sanitiseNumber = (raw?: string) => {
|
|
if (!raw) return "";
|
|
const bracket = raw.match(/<([^>]+)>/);
|
|
if (bracket) return bracket[1].trim();
|
|
return raw.replace(/[^\d+]/g, "").trim();
|
|
};
|
|
|
|
const startAdd = (m: { id: number; contact_name?: string; callerid?: string }) => {
|
|
setAddName(m.contact_name || "");
|
|
setAddEmail("");
|
|
setAddingId(m.id);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<form onSubmit={onSubmit} className="card" style={{ padding: "12px 16px" }}>
|
|
<div className="row" style={{ alignItems: "center", gap: 8 }}>
|
|
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search caller, transcript..." style={{ flex: 1, minWidth: 180 }} />
|
|
<input type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)} style={{ width: "auto" }} />
|
|
<input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)} style={{ width: "auto" }} />
|
|
<label style={{ fontWeight: 400, margin: 0, display: "flex", alignItems: "center", gap: 5 }}>
|
|
<input type="checkbox" checked={unreadOnly} onChange={(e) => setUnreadOnly(e.target.checked)} /> unread
|
|
</label>
|
|
<button type="submit" className="primary">Filter</button>
|
|
{(q || dateFrom || dateTo || unreadOnly) && <a className="btn" href="/">Clear all</a>}
|
|
</div>
|
|
</form>
|
|
|
|
{isLoading && <div className="card empty">Loading...</div>}
|
|
{error && <div className="card err">{(error as Error).message}</div>}
|
|
|
|
{data?.messages?.map((m: any) => {
|
|
const whoRaw = m.contact_name || m.callerid || "Unknown caller";
|
|
const num = sanitiseNumber(m.callerid);
|
|
const showAdd = !m.contact_name && num;
|
|
return (
|
|
<div key={m.id} className={"card" + (m.is_read ? "" : " unread")}>
|
|
<div className="who" style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8 }}>
|
|
{m.contact_name && num ? (
|
|
<a href={"/contacts/history/" + encodeURIComponent(num.replace(/^\+/, ""))} style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{whoRaw}</a>
|
|
) : num ? (
|
|
<span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{whoRaw}</span>
|
|
) : (
|
|
<span>{whoRaw}</span>
|
|
)}
|
|
<span style={{ position: "relative", display: "inline-block", flexShrink: 0 }}>
|
|
<MsgContactMenu number={num} name={m.contact_name} callerid={m.callerid} />
|
|
</span>
|
|
</div>
|
|
<div className="meta">
|
|
{m.time}{m.duration_fmt ? ` · ${m.duration_fmt}` : ""}
|
|
</div>
|
|
<div className="sum">{m.summary || "(no speech detected)"}</div>
|
|
{m.transcript && (
|
|
<details style={{ marginTop: 8 }}>
|
|
<summary>Full transcript</summary>
|
|
<div className="tr">{m.transcript}</div>
|
|
</details>
|
|
)}
|
|
{m.tags?.length ? (
|
|
<div style={{ marginTop: 8 }}>
|
|
{m.tags.map((t: string) => <span key={t} className="tag">{t}</span>)}
|
|
</div>
|
|
) : null}
|
|
{m.numbers?.length ? (
|
|
<div className="meta" style={{ marginTop: 6 }}>
|
|
📞 Callback:{" "}
|
|
{m.numbers.map((n: string) => {
|
|
const digits = n.replace(/[^\d+]/g, "");
|
|
return <a key={n} href={"tel:" + digits}>{n}</a>;
|
|
}).reduce((prev: any, curr: any, i: number) => i === 0 ? [curr] : [...prev, " · ", curr], [])}
|
|
</div>
|
|
) : null}
|
|
{m.has_audio && <audio controls preload="none" src={"/audio/" + m.id} />}
|
|
<div className="row">
|
|
<button onClick={() => toggleRead.mutate(m.id)}>
|
|
{m.is_read ? "Mark unread" : "Mark read"}
|
|
</button>
|
|
{m.has_audio && <a className="btn" href={"/audio/" + m.id + "?dl=1"}>Download</a>}
|
|
<button className="danger" onClick={() => deleteMsg.mutate(m.id)}>Delete</button>
|
|
{showAdd && (
|
|
<button className="add-btn" onClick={() => startAdd(m)}>+ Add to contacts</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{data?.messages?.length === 0 && (
|
|
<div className="card empty">No voicemails match your filters.</div>
|
|
)}
|
|
|
|
{addingId !== null && (
|
|
<div className="modal-bg open" onClick={() => setAddingId(null)}>
|
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
|
<h3>Add contact</h3>
|
|
{(() => { const target = data?.messages?.find((x: any) => x.id === addingId); const num = sanitiseNumber(target?.callerid); return num ? <div style={{ marginBottom: 10, fontFamily: "var(--font-mono)", fontSize: 13, color: "var(--text-secondary)" }}>Number: {num}</div> : null; })()}
|
|
<form onSubmit={(e) => { e.preventDefault(); if (!addName.trim()) return; const target = data?.messages?.find((x: any) => x.id === addingId); const targetNum = sanitiseNumber(target?.callerid); addContact.mutate({ name: addName.trim(), number: targetNum || undefined, email: addEmail.trim() || null }); }}>
|
|
<label>Name</label>
|
|
<input value={addName} onChange={(e) => setAddName(e.target.value)} required />
|
|
<label>Email</label>
|
|
<input type="email" value={addEmail} onChange={(e) => setAddEmail(e.target.value)} />
|
|
<div className="row">
|
|
<button type="button" className="btn" onClick={() => setAddingId(null)}>Cancel</button>
|
|
<button type="submit" className="primary" disabled={addContact.isPending}>Save</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function MsgContactMenu({ number, name, callerid }: { number?: string; name?: string; callerid?: string }) {
|
|
const [open, setOpen] = useState(false);
|
|
const digits = (number || "").replace(/[^\d+]/g, "");
|
|
const lookup = encodeURIComponent(digits.startsWith("+44") ? "0" + digits.slice(3) : digits);
|
|
return (
|
|
<>
|
|
<button className="btn" style={{ padding: "4px 8px", lineHeight: 1 }} onClick={() => setOpen(!open)}>⋮</button>
|
|
{open && (
|
|
<>
|
|
<span
|
|
className="modal-bg open"
|
|
style={{ position: "fixed", inset: 0, zIndex: 200 }}
|
|
onClick={() => setOpen(false)}
|
|
/>
|
|
<div
|
|
className="modal"
|
|
style={{
|
|
position: "absolute",
|
|
right: 0,
|
|
top: "100%",
|
|
marginTop: 4,
|
|
zIndex: 201,
|
|
padding: "6px 0",
|
|
minWidth: 180,
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{digits && (
|
|
<a
|
|
className="btn"
|
|
href={"tel:" + digits}
|
|
style={{ display: "block", borderRadius: 0, border: "none", borderBottom: "1px solid var(--border)", width: "100%", justifyContent: "flex-start" }}
|
|
onClick={() => setOpen(false)}
|
|
>
|
|
📞 Call back
|
|
</a>
|
|
)}
|
|
<a
|
|
className="btn"
|
|
href={"https://www.google.com/search?q=uk+number+" + lookup}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
style={{ display: "block", borderRadius: 0, border: "none", width: "100%", justifyContent: "flex-start" }}
|
|
onClick={() => setOpen(false)}
|
|
>
|
|
🔍 Lookup number
|
|
</a>
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|