This commit is contained in:
parent
c949d189b6
commit
3b29b9dca2
|
@ -12,6 +12,7 @@ import { ActiveCalls } from './active-calls';
|
|||
import { SipCredentialsCard } from './SipCredentialsCard';
|
||||
import { QuickActionsCard } from './QuickActionsCard';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { VoicemailPanel } from './voicemail';
|
||||
|
||||
function OverviewCard({ details, deviceStatus, loading }) {
|
||||
const Stat = ({ icon, label, value, loading }) => (
|
||||
|
@ -229,6 +230,7 @@ export default function DashboardClient() {
|
|||
<main className="lg:col-span-2 space-y-8">
|
||||
<ActiveCalls />
|
||||
<RegisteredDevices />
|
||||
<VoicemailPanel />
|
||||
<ApiKeys />
|
||||
</main>
|
||||
|
||||
|
|
439
src/components/dashboard/voicemail.jsx
Normal file
439
src/components/dashboard/voicemail.jsx
Normal file
|
@ -0,0 +1,439 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
|
||||
import { RefreshCw, Inbox, Folder, Download, Play, Pause, Loader2, PhoneIncoming, PhoneCall, Trash2 } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
const FOLDERS = ['INBOX', 'Family', 'Friends', 'Old', 'Work', 'Urgent'];
|
||||
const FRIENDLY_FOLDER = (f) => (f === 'INBOX' ? 'New' : f);
|
||||
|
||||
function formatDuration(sec) {
|
||||
if (isNaN(sec)) return '0:00';
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.floor(sec % 60).toString().padStart(2, '0');
|
||||
return `${m}:${s}`;
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes && bytes !== 0) return '—';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
function formatVoicemailDate(dateStr) {
|
||||
try {
|
||||
return format(new Date(dateStr), "MMM d, yyyy 'at' h:mm a");
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
export function VoicemailPanel({ extensionId: extProp }) {
|
||||
const { token } = useAuth();
|
||||
const [extensionId, setExtensionId] = useState(extProp || null);
|
||||
const [loadingExt, setLoadingExt] = useState(!extProp);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [voicemails, setVoicemails] = useState([]);
|
||||
const [activeFolder, setActiveFolder] = useState('INBOX');
|
||||
const [search, setSearch] = useState('');
|
||||
const [expandedId, setExpandedId] = useState(null);
|
||||
const [audioMap, setAudioMap] = useState({}); // messageId -> { url, loading, error }
|
||||
const [movingId, setMovingId] = useState(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [vmToDelete, setVmToDelete] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (extProp || !token) return;
|
||||
const load = async () => {
|
||||
setLoadingExt(true);
|
||||
try {
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/extensions/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setExtensionId(data.extensionId);
|
||||
}
|
||||
} catch (e) {
|
||||
// silent
|
||||
} finally {
|
||||
setLoadingExt(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, [token, extProp]);
|
||||
|
||||
const fetchVoicemails = useCallback(async () => {
|
||||
if (!token || !extensionId) return;
|
||||
setRefreshing(true);
|
||||
if (voicemails.length === 0) setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/extensions/${extensionId}/voicemails`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setVoicemails(Array.isArray(data) ? data : []);
|
||||
} else {
|
||||
setVoicemails([]);
|
||||
}
|
||||
} catch (e) {
|
||||
setVoicemails([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [token, extensionId, voicemails.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (extensionId) fetchVoicemails();
|
||||
}, [extensionId, fetchVoicemails]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return voicemails
|
||||
.filter(v => v.folder === activeFolder)
|
||||
.filter(v => {
|
||||
if (!search) return true;
|
||||
const t = search.toLowerCase();
|
||||
return (
|
||||
v.callerIdName?.toLowerCase().includes(t) ||
|
||||
v.callerIdNum?.toLowerCase().includes(t) ||
|
||||
v.messageId?.toLowerCase().includes(t)
|
||||
);
|
||||
})
|
||||
.sort((a, b) => new Date(b.date) - new Date(a.date));
|
||||
}, [voicemails, activeFolder, search]);
|
||||
|
||||
const toggleExpand = async (vm) => {
|
||||
const isOpening = expandedId !== vm.messageId;
|
||||
setExpandedId(isOpening ? vm.messageId : null);
|
||||
if (isOpening && vm.hasAudio && !audioMap[vm.messageId]?.url) {
|
||||
setAudioMap(m => ({ ...m, [vm.messageId]: { url: null, loading: true } }));
|
||||
try {
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/extensions/${extensionId}/voicemails/${vm.messageId}/download`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!res.ok) throw new Error('Download failed');
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
setAudioMap(m => ({ ...m, [vm.messageId]: { url, loading: false } }));
|
||||
} catch {
|
||||
setAudioMap(m => ({ ...m, [vm.messageId]: { url: null, loading: false, error: true } }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const moveVoicemail = async (vm, target) => {
|
||||
if (target === vm.folder) return;
|
||||
setMovingId(vm.messageId);
|
||||
try {
|
||||
await fetch(`${process.env.NEXT_PUBLIC_API_URL}/extensions/${extensionId}/voicemails/${vm.messageId}/move`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ targetFolder: target })
|
||||
});
|
||||
setVoicemails(list => list.map(x => x.messageId === vm.messageId ? { ...x, folder: target } : x));
|
||||
if (target !== activeFolder) {
|
||||
setExpandedId(null);
|
||||
}
|
||||
} catch {
|
||||
// silent fail
|
||||
} finally {
|
||||
setMovingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadVoicemail = async (vm) => {
|
||||
if (!vm.hasAudio) return;
|
||||
try {
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/extensions/${extensionId}/voicemails/${vm.messageId}/download`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${vm.messageId}.wav`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!vmToDelete || !token || !extensionId) return;
|
||||
try {
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/extensions/${extensionId}/voicemails/${vmToDelete.messageId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (res.ok) {
|
||||
setVoicemails(list => list.filter(v => v.messageId !== vmToDelete.messageId));
|
||||
if (expandedId === vmToDelete.messageId) setExpandedId(null);
|
||||
}
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setVmToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const loadingState = loading || loadingExt;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="bg-gray-950/50 border-gray-800">
|
||||
<CardHeader className="gap-2">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<CardTitle>Voicemail (VVM)</CardTitle>
|
||||
<CardDescription>
|
||||
{extensionId ? `Extension ${extensionId} visual voicemail` : 'Loading extension...'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={fetchVoicemails}
|
||||
disabled={loadingState || refreshing || !extensionId}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{FOLDERS.map(folder => {
|
||||
const count = voicemails.filter(v => v.folder === folder).length;
|
||||
const active = folder === activeFolder;
|
||||
return (
|
||||
<Button
|
||||
key={folder}
|
||||
variant={active ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => { setActiveFolder(folder); setExpandedId(null); }}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{folder === 'INBOX' ? <Inbox className="h-3.5 w-3.5" /> : <Folder className="h-3.5 w-3.5" />}
|
||||
<span>{FRIENDLY_FOLDER(folder)}</span>
|
||||
<Badge variant="outline" className="text-[10px] px-1 py-0 leading-none border-gray-600">
|
||||
{count}
|
||||
</Badge>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Input
|
||||
placeholder="Search caller, number or ID..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="bg-gray-900 border-gray-700"
|
||||
disabled={loadingState}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{loadingState ? (
|
||||
<div className="space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
No voicemails in {FRIENDLY_FOLDER(activeFolder)}{search && ' matching your search'}.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-800">
|
||||
{filtered.map(vm => {
|
||||
const expanded = expandedId === vm.messageId;
|
||||
const audioInfo = audioMap[vm.messageId];
|
||||
return (
|
||||
<div key={vm.messageId} className="py-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
|
||||
<button
|
||||
onClick={() => toggleExpand(vm)}
|
||||
className="flex-1 text-left group focus:outline-none"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
{/* Caller block */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="p-2 rounded-md bg-gray-800 group-hover:bg-gray-700 transition-colors">
|
||||
{vm.callerIdNum ? <PhoneIncoming className="h-4 w-4 text-blue-400" /> : <PhoneCall className="h-4 w-4 text-blue-400" />}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-white truncate">
|
||||
{vm.callerIdName || 'Unknown Caller'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 font-mono truncate">
|
||||
{vm.callerIdNum || 'No Number'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Meta (desktop) */}
|
||||
<div className="hidden sm:flex items-center gap-6 text-xs sm:text-sm">
|
||||
<div className="text-gray-300">{formatDuration(vm.duration)}</div>
|
||||
<div className="text-gray-400 hidden md:block">{formatBytes(vm.fileSize)}</div>
|
||||
<div className="text-gray-500 hidden lg:block truncate max-w-[180px]">
|
||||
{formatVoicemailDate(vm.date)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Meta (mobile compact) */}
|
||||
<div className="flex sm:hidden w-full justify-start gap-4 text-[11px] text-gray-400">
|
||||
<span>{formatDuration(vm.duration)}</span>
|
||||
<span>{formatBytes(vm.fileSize)}</span>
|
||||
<span className="truncate">{formatVoicemailDate(vm.date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => toggleExpand(vm)}
|
||||
title="Play"
|
||||
disabled={!vm.hasAudio}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
{expanded && audioInfo?.url
|
||||
? <Pause className="h-4 w-4" />
|
||||
: <Play className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => downloadVoicemail(vm)}
|
||||
disabled={!vm.hasAudio}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
<Select
|
||||
onValueChange={(val) => moveVoicemail(vm, val)}
|
||||
disabled={movingId === vm.messageId}
|
||||
>
|
||||
<SelectTrigger className="w-[78px] h-8 bg-gray-900 border-gray-700 text-xs">
|
||||
<SelectValue placeholder="Move" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FOLDERS.filter(f => f !== vm.folder).map(f => (
|
||||
<SelectItem key={f} value={f} className="text-xs">
|
||||
{FRIENDLY_FOLDER(f)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => setVmToDelete(vm)}
|
||||
className="h-8 w-8 text-red-500/80 hover:text-red-500 hover:bg-red-900/20"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{movingId === vm.messageId && (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="mt-3 pl-0 sm:pl-14 space-y-3">
|
||||
{/* Audio / status */}
|
||||
{vm.hasAudio ? (
|
||||
audioInfo?.loading ? (
|
||||
<div className="text-sm text-gray-400 flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading audio...
|
||||
</div>
|
||||
) : audioInfo?.error ? (
|
||||
<div className="text-sm text-red-400">
|
||||
Failed to load audio.
|
||||
</div>
|
||||
) : audioInfo?.url ? (
|
||||
<audio
|
||||
controls
|
||||
autoPlay
|
||||
src={audioInfo.url}
|
||||
className="w-full"
|
||||
/>
|
||||
) : null
|
||||
) : (
|
||||
<div className="text-sm text-gray-500">
|
||||
No audio available for this voicemail.
|
||||
</div>
|
||||
)}
|
||||
{/* Detail chips */}
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-400">
|
||||
<span>ID: {vm.messageId}</span>
|
||||
<span>Orig: {vm.originalMessageId}</span>
|
||||
<span>Folder: {FRIENDLY_FOLDER(vm.folder)}</span>
|
||||
<span className="sm:hidden">Size: {formatBytes(vm.fileSize)}</span>
|
||||
<span className="md:hidden">At: {formatVoicemailDate(vm.date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AlertDialog open={!!vmToDelete} onOpenChange={(open) => !open && setVmToDelete(null)}>
|
||||
<AlertDialogContent className="bg-gray-950 border-gray-800 text-white">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete voicemail?</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-gray-400">
|
||||
This permanently deletes the voicemail from{' '}
|
||||
<span className="text-white font-medium">
|
||||
{vmToDelete?.callerIdName || 'Unknown Caller'}
|
||||
</span>{' '}
|
||||
({vmToDelete?.callerIdNum || 'No Number'}). This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="border-gray-700 hover:bg-gray-800">
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-red-600 hover:bg-red-700 text-white"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default VoicemailPanel;
|
Loading…
Reference in a new issue