VVM update (real)
All checks were successful
Deploy to Server / deploy (push) Successful in 2m9s

This commit is contained in:
rocord01 2025-08-10 01:39:04 -04:00
parent c949d189b6
commit 3b29b9dca2
2 changed files with 441 additions and 0 deletions

View file

@ -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>

View 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;