{% extends "console/layout.html" %}
{% block title %}{{ "call_record.title" | t }} · {{site_name|default('RustPBX')}}{% endblock %}
{% block content %}
<div class="p-6">
<div class="mx-auto max-w-7xl space-y-6" x-data='callRecordsConsole({
basePath: {{ (base_path | default("/console")) | tojson }},
filter_options: {{ filter_options | default({}) | tojson }},
list_url: {{ list_url | default("/console/call-records") | tojson }},
page_size_options: {{ page_size_options | default([10, 25, 50]) | tojson }},
translations: {{ t | tojson }}
})' x-init="init()">
<header class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div class="space-y-1">
<p class="text-xs font-semibold uppercase tracking-wide text-sky-600">{{ "call_record.voice_analytics" | t }}</p>
<h1 class="text-2xl font-semibold text-slate-900">{{ "call_record.title" | t }}</h1>
</div>
<div class="flex flex-wrap items-center gap-3">
<button type="button"
class="inline-flex items-center gap-2 rounded-lg bg-sky-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-400 focus:ring-offset-2"
@click="exportCsv">
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 12l6 6 6-6" />
<path stroke-linecap="round" stroke-linejoin="round" d="M10 18V4" />
</svg>
{{ "call_record.export_csv" | t }}
</button>
</div>
</header>
<section class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-4">
<div class="relative flex items-center rounded-lg border border-slate-200 bg-white p-1"
x-data="{ open: false }">
<button type="button" @click="open = !open" @click.outside="open = false"
class="flex items-center gap-2 rounded-md px-2 py-1 text-xs font-medium text-slate-600 hover:bg-slate-50">
<span x-text="dateRangeLabel"></span>
<svg class="h-3 w-3 text-slate-400" fill="none" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div x-show="open" x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="transform opacity-0 scale-95"
x-transition:enter-end="transform opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
class="absolute left-0 top-full z-20 mt-1 w-48 rounded-lg border border-slate-100 bg-white py-1 shadow-lg ring-1 ring-black/5"
style="display: none;">
<template x-for="preset in datePresets" :key="preset.value">
<button type="button"
class="block w-full px-4 py-2 text-left text-xs text-slate-700 hover:bg-slate-50"
:class="dateRangeLabel === preset.value ? 'bg-slate-50 font-semibold text-sky-600' : ''"
@click="applyDatePreset(preset.value); open = false">
<span x-text="preset.label"></span>
</button>
</template>
</div>
<div x-show="dateRangeLabel === 'custom'"
class="flex items-center gap-1 border-l border-slate-200 pl-1 ml-1">
<input type="date" x-model="dateFrom"
class="rounded-md border-0 bg-transparent px-2 py-1 text-xs font-medium text-slate-600 focus:ring-2 focus:ring-sky-200"
aria-label="{{ "call_record.date_from" | t }}">
<span class="text-slate-400">-</span>
<input type="date" x-model="dateTo"
class="rounded-md border-0 bg-transparent px-2 py-1 text-xs font-medium text-slate-600 focus:ring-2 focus:ring-sky-200"
aria-label="{{ "call_record.date_to" | t }}">
</div>
</div>
<div
class="flex gap-1 rounded-lg border border-slate-200 p-1 text-xs font-medium text-slate-600">
<button type="button" class="rounded-md px-3 py-1 transition"
:class="statusFilter === 'any' ? 'bg-emerald-100 text-emerald-700' : 'hover:bg-slate-100'"
@click="statusFilter = 'any'">{{ "call_record.any_status" | t }}</button>
<template x-for="tab in statusTabs()" :key="`status-${tab.value}`">
<button type="button" class="rounded-md px-3 py-1 transition"
:class="statusFilter === tab.value ? 'bg-emerald-100 text-emerald-700' : 'hover:bg-slate-100'"
@click="statusFilter = tab.value">
<span x-text="tab.label"></span>
</button>
</template>
</div>
<div
class="flex gap-1 rounded-lg border border-slate-200 p-1 text-xs font-medium text-slate-600">
<button type="button" class="rounded-md px-3 py-1 transition"
:class="directionFilter === 'any' ? 'bg-sky-100 text-sky-700' : 'hover:bg-slate-100'"
@click="directionFilter = 'any'">{{ "call_record.any_direction" | t }}</button>
<template x-for="tab in directionTabs()" :key="`direction-${tab.value}`">
<button type="button" class="rounded-md px-3 py-1 transition"
:class="directionFilter === tab.value ? 'bg-sky-100 text-sky-700' : 'hover:bg-slate-100'"
@click="directionFilter = tab.value">
<span x-text="tab.label"></span>
</button>
</template>
</div>
</div>
<div class="flex flex-grow flex-wrap items-center justify-end gap-3">
<label class="relative flex-1 min-w-[18rem]" aria-label="{{ "call_record.search_call_records_label" | t }}">
<svg class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400"
viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6">
<path stroke-linecap="round" stroke-linejoin="round"
d="m17.5 17.5-3.65-3.65m0 0a5.5 5.5 0 1 0-7.778-7.778 5.5 5.5 0 0 0 7.778 7.778Z" />
</svg>
<input type="search" x-model.trim="search" @input.debounce.400ms="scheduleFetch()"
class="w-full rounded-lg border border-slate-200 px-3 py-2 pl-9 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200"
placeholder='{{ "call_record.search_call_records" | t }}'>
</label>
<div class="flex flex-wrap items-center gap-2">
<select aria-label="{{ "call_record.add_filter" | t }}"
class="rounded-lg border border-slate-200 px-3 py-2 text-xs font-semibold text-slate-600 transition hover:border-sky-300 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200"
@change="addFilter($event)">
<option value="">{{ "call_record.add_filter" | t }}</option>
<template x-for="option in filterSelectOptions" :key="option.key">
<option :value="option.key" :disabled="activeFilters.includes(option.key)"
x-text="option.label"></option>
</template>
</select>
<select aria-label="{{ "call_record.sort_call_records" | t }}" x-model="sortOrder" @change="applySorting()"
class="rounded-lg border border-slate-200 px-3 py-2 text-xs font-semibold text-slate-600 transition hover:border-sky-300 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200">
<template x-for="option in sortOptions" :key="option.value">
<option :value="option.value" x-text="option.label"></option>
</template>
</select>
<button type="button"
class="inline-flex items-center gap-2 rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:border-sky-300 hover:text-sky-700"
@click="clearFilters()">
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="none" stroke="currentColor"
stroke-width="1.6">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 3v14m-7-7h14" />
</svg>
{{ "call_record.clear_all" | t }}
</button>
</div>
</div>
</div>
<div class="flex flex-wrap gap-3" x-show="activeFilters.length" x-cloak>
<template x-for="filter in activeFilters" :key="filter">
<div
class="w-full rounded-lg border border-slate-200 bg-slate-50 p-3 text-xs text-slate-600 sm:w-auto sm:min-w-[20rem]">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-[11px] font-semibold uppercase tracking-wide text-slate-500"
x-text="filterOptionLabel(filter)"></div>
<div class="text-sm text-slate-700" x-text="formatFilterValue(filter)"></div>
</div>
<button type="button"
class="rounded-full p-1 text-slate-400 transition hover:bg-rose-50 hover:text-rose-600"
@click="removeFilter(filter)">
<span class="sr-only">Remove filter</span>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="none" stroke="currentColor"
stroke-width="1.6">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6l8 8M6 14l8-8" />
</svg>
</button>
</div>
<div class="mt-3 space-y-3">
<template x-if="filter === 'direction'">
<select x-model="directionFilter"
class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200">
<template x-for="option in filterOptions.direction" :key="option">
<option :value="option" x-text="directionLabel(option)"></option>
</template>
</select>
</template>
<template x-if="filter === 'date_range'">
<div class="grid gap-2 sm:grid-cols-2">
<label class="flex flex-col text-[11px] font-semibold text-slate-500">
<span>From</span>
<input type="date" x-model="dateFrom"
class="mt-1 rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200">
</label>
<label class="flex flex-col text-[11px] font-semibold text-slate-500">
<span>To</span>
<input type="date" x-model="dateTo"
class="mt-1 rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200">
</label>
</div>
</template>
<template x-if="filter === 'only_transcribed'">
<label
class="inline-flex items-center gap-2 rounded border border-slate-200 px-3 py-2 text-sm font-semibold text-slate-600 transition hover:border-sky-300 hover:text-sky-700">
<input type="checkbox"
class="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500"
x-model="onlyTranscribed">
Transcribed only
</label>
</template>
<template x-if="filter === 'departments'">
<div class="flex max-h-48 flex-col gap-2 overflow-y-auto pr-1">
<template x-if="!filterOptions.departments.length">
<span
class="rounded-full border border-dashed border-slate-200 px-3 py-1 text-xs text-slate-400">No
departments</span>
</template>
<template x-for="dept in filterOptions.departments" :key="dept.id">
<label
class="inline-flex items-center gap-2 rounded border border-slate-200 px-3 py-1 text-xs font-semibold text-slate-600 transition hover:border-sky-300 hover:text-sky-700">
<input type="checkbox"
class="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500"
:value="String(dept.id)" x-model="selectedDepartments">
<span x-text="dept.name"></span>
</label>
</template>
</div>
</template>
<template x-if="filter === 'sip_trunks'">
<div class="flex max-h-48 flex-col gap-2 overflow-y-auto pr-1">
<template x-if="!filterOptions.sip_trunks.length">
<span
class="rounded-full border border-dashed border-slate-200 px-3 py-1 text-xs text-slate-400">No
trunks</span>
</template>
<template x-for="trunk in filterOptions.sip_trunks" :key="trunk.id">
<label
class="inline-flex items-center gap-2 rounded border border-slate-200 px-3 py-1 text-xs font-semibold text-slate-600 transition hover:border-sky-300 hover:text-sky-700">
<input type="checkbox"
class="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500"
:value="String(trunk.id)" x-model="selectedSipTrunks">
<span x-text="trunk.display_name || trunk.name"></span>
</label>
</template>
</div>
</template>
<template x-if="filter === 'billing_status'">
<select x-model="billingStatusFilter"
class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200">
<template x-for="option in filterOptions.billing_status" :key="option">
<option :value="option" x-text="billingStatusLabel(option)"></option>
</template>
</select>
</template>
</div>
</div>
</template>
</div>
</div>
<template x-if="exportedAt">
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-xs text-emerald-700">
CSV exported <span class="font-semibold" x-text="exportedAt"></span>
</div>
</template>
<template x-if="error">
<div class="mt-4 rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-xs text-rose-700"
x-text="error"></div>
</template>
<div class="mt-4 flex items-center gap-2 text-xs text-slate-500" x-show="loading">
<svg class="h-4 w-4 animate-spin text-slate-400" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
fill="none"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
</svg>
<span>Loading records…</span>
</div>
<template x-if="!filteredRecords.length">
<div
class="mt-8 flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-slate-200 py-16 text-center text-sm text-slate-400">
<svg class="h-10 w-10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 6h13M3 12h18M8 18h13" />
</svg>
<div>No records match the current filters.</div>
</div>
</template>
<div class="mt-6 overflow-x-auto" x-show="filteredRecords.length">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
<tr>
<th scope="col" class="whitespace-nowrap px-4 py-2">Call</th>
<th scope="col" class="whitespace-nowrap px-4 py-2">Started</th>
<th scope="col" class="whitespace-nowrap px-4 py-2">Caller</th>
<th scope="col" class="whitespace-nowrap px-4 py-2">Callee</th>
<th scope="col" class="whitespace-nowrap px-4 py-2">Duration</th>
<th scope="col" class="whitespace-nowrap px-4 py-2">Status</th>
<th scope="col" class="whitespace-nowrap px-4 py-2">Tags</th>
<th scope="col" class="whitespace-nowrap px-4 py-2">Transcript</th>
<th scope="col"
class="sticky right-0 z-10 whitespace-nowrap border-l border-slate-200 bg-gradient-to-l from-white via-white to-slate-50 px-4 py-2 text-right shadow-[-8px_0_12px_-6px_rgba(15,23,42,0.12)]">
Actions
</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<template x-for="record in paginatedRecords" :key="record.id">
<tr class="group align-middle transition hover:bg-slate-50">
<td class="whitespace-nowrap px-4 py-2">
<div class="flex gap-x-2 items-center">
<a :href="recordDetailUrl(record)"
class="block font-semibold text-slate-900 transition hover:text-sky-700 hover:underline"
x-text="record.display_id || record.id"></a>
<div class="mt-1 flex flex-wrap items-center gap-2 text-[11px] text-slate-500">
<div class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 font-semibold"
:class="directionClasses(record.direction)">
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="none"
stroke="currentColor" stroke-width="1.6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M5 10h10M10 5l5 5-5 5" />
</svg>
<span x-text="directionLabel(record.direction)"></span>
</div>
<template x-if="record.sip_gateway">
<div class="inline-flex items-center gap-1">
<svg class="h-3 w-3" viewBox="0 0 20 20" fill="none"
stroke="currentColor" stroke-width="1.6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M4 4h12v12H4z" />
<path stroke-linecap="round" stroke-linejoin="round"
d="M8 4v12M12 8h4" />
</svg>
<span x-text="record.sip_gateway || '—'"></span>
</div>
</template>
</div>
</div>
<div class="mt-0.5 flex items-center gap-2 text-[11px] text-slate-500">
<span class="max-w-[180px] truncate font-mono" :title="record.call_id"
x-text="record.call_id || '—'"></span>
<template x-if="record.call_id">
<div class="flex items-center gap-1">
<button type="button"
class="inline-flex h-5 w-5 items-center justify-center rounded border border-slate-200 text-slate-400 transition hover:border-slate-300 hover:text-slate-600"
title="{{ "call_record.copy_call_id" | t }}" aria-label="{{ "call_record.copy_call_id" | t }}"
@click.stop="copyCallId(record.call_id)">
<svg class="h-3 w-3" viewBox="0 0 20 20" fill="none"
stroke="currentColor" stroke-width="1.6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M6 6h8v8H6z" />
<path stroke-linecap="round" stroke-linejoin="round"
d="M4 4h8" />
</svg>
</button>
<span class="text-[10px] font-semibold text-emerald-600"
x-show="copiedCallId === record.call_id"
x-transition.opacity>Copied</span>
</div>
</template>
</div>
</td>
<td class="px-4 py-2">
<div class="whitespace-nowrap text-[13px] leading-tight text-slate-600"
x-text="formatDateTime(record.started_at)">
</div>
</td>
<td class="px-4 py-2">
<div class="text-sm font-semibold text-slate-800" x-text="record.cnam || '—'"></div>
<div class="text-xs text-slate-500">
<span class="font-mono" x-text="record.from || '—'"></span>
</div>
</td>
<td class="px-4 py-2">
<div class="text-sm font-semibold text-slate-800"
x-text="record.agent || record.to || '—'"></div>
<div class="text-xs text-slate-500 flex flex-wrap gap-1">
<span class="font-mono" x-text="record.to || '—'"></span>
<span class="text-[11px] text-slate-400" x-show="record.queue"
x-text="record.queue"></span>
</div>
</td>
<td class="whitespace-nowrap px-4 py-2">
<div class="text-sm font-semibold text-slate-800"
x-text="formatDuration(record.duration_secs)"></div>
<div class="text-xs font-semibold" :class="qualityTone(record.quality?.mos)"
x-text="record.quality && record.quality.mos !== undefined && record.quality.mos !== null ? formatNumber(record.quality.mos) : '—'">
</div>
</td>
<td class="px-4 py-2">
<span
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold"
:class="statusClasses(record.status)">
<span class="h-1.5 w-1.5 rounded-full" :class="statusDot(record.status)"></span>
<span class="font-mono text-xs" x-text="statusCodeText(record)"></span>
</span>
</td>
<td class="px-4 py-2">
<div class="flex flex-wrap gap-1 text-[11px]">
<template x-for="tag in (Array.isArray(record.tags) ? record.tags : [])"
:key="record.id + tag">
<span
class="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2 py-0.5 text-slate-600">
<span x-text="tag"></span>
</span>
</template>
</div>
</td>
<td class="px-4 py-2">
<template
x-if="transcriptStatusIcon(record.transcript_status, record.has_transcript) !== 'pending'">
<div class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold"
:class="transcriptStatusClasses(record.transcript_status, record.has_transcript)"
:title="transcriptStatusLabel(record.transcript_status, record.has_transcript)">
<svg x-cloak
x-show="transcriptStatusIcon(record.transcript_status, record.has_transcript) === 'ready'"
class="h-3 w-3" viewBox="0 0 20 20" fill="none" stroke="currentColor"
stroke-width="1.6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M5 10l3 3 7-7" />
</svg>
<svg x-cloak
x-show="transcriptStatusIcon(record.transcript_status, record.has_transcript) === 'processing'"
class="h-3 w-3 animate-spin" viewBox="0 0 20 20" fill="none"
stroke="currentColor" stroke-width="1.5">
<circle class="opacity-25" cx="10" cy="10" r="7" stroke="currentColor"
stroke-width="1.5"></circle>
<path class="opacity-75" fill="currentColor"
d="M10 3a7 7 0 0 1 7 7h-1.8a5.2 5.2 0 0 0-5.2-5.2V3Z" />
</svg>
<svg x-cloak
x-show="transcriptStatusIcon(record.transcript_status, record.has_transcript) === 'failed'"
class="h-3 w-3" viewBox="0 0 20 20" fill="none" stroke="currentColor"
stroke-width="1.6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M6 6l8 8M6 14l8-8" />
</svg>
<span
x-text="transcriptStatusLabel(record.transcript_status, record.has_transcript)"></span>
</div>
</template>
</td>
<td
class="sticky right-0 z-[1] whitespace-nowrap border-l border-slate-200 bg-gradient-to-l from-white via-white to-slate-50 px-4 py-2 text-right text-xs font-semibold shadow-[-8px_0_12px_-6px_rgba(15,23,42,0.08)] group-hover:from-slate-50 group-hover:via-slate-50 group-hover:to-slate-100">
<div class="flex items-center justify-end gap-2">
<a :href="recordDetailUrl(record)" :title="t.action_view_detail || 'View detail'"
class="inline-flex h-8 w-8 items-center justify-center rounded-full border border-slate-200 text-slate-500 transition hover:border-sky-300 hover:text-sky-700">
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="none" stroke="currentColor"
stroke-width="1.6">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 5l6 5-6 5" />
</svg>
</a>
<template x-if="record.recording && record.recording.url">
<a :href="record.recording.url" target="_blank" rel="noopener"
:title="t.action_download_audio || 'Download audio'"
class="inline-flex h-8 w-8 items-center justify-center rounded-full border border-slate-200 text-slate-500 transition hover:border-sky-300 hover:text-sky-700">
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="none"
stroke="currentColor" stroke-width="1.6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M4 12l6 6 6-6" />
<path stroke-linecap="round" stroke-linejoin="round" d="M10 18V4" />
</svg>
</a>
</template>
<button type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-full border border-rose-200 text-rose-500 transition hover:border-rose-300 hover:text-rose-600 disabled:cursor-not-allowed disabled:opacity-60"
:disabled="deletingId === record.id" @click.stop="deleteRecord(record)"
:title="t.action_delete_record || 'Delete record'">
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="none" stroke="currentColor"
stroke-width="1.6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M6 6h8m-7 3v6m6-6v6M4 6h12" />
</svg>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
<div
class="flex flex-col gap-3 border-t border-slate-100 px-4 py-4 text-xs text-slate-500 sm:flex-row sm:items-center sm:justify-between">
<div>
<span x-text="filteredRecords.length ? pageRangeStart : 0"></span>
–
<span x-text="pageRangeEnd"></span>
<span x-text="t.records_of || 'of'"></span>
<span x-text="filteredRecords.length"></span>
<span x-text="t.records_label || 'records'"></span>
</div>
<div class="flex flex-wrap items-center gap-3">
<label class="flex items-center gap-2">
<span class="text-[11px] uppercase tracking-wide text-slate-400" x-text="t.per_page || 'Per page'"></span>
<select x-model.number="pageSize"
class="rounded-lg border border-slate-200 px-2 py-1 text-xs text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200">
<template x-for="option in pageSizeOptions" :key="option">
<option :value="option" x-text="option"></option>
</template>
</select>
</label>
<div
class="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white p-1 text-xs font-semibold text-slate-600">
<button type="button" class="rounded-full px-2 py-1 transition disabled:opacity-40"
:disabled="currentPage === 1" @click="prevPage" x-text="t.page_prev || 'Prev'">
</button>
<span class="px-2" x-text="`${currentPage} / ${totalPages}`"></span>
<button type="button" class="rounded-full px-2 py-1 transition disabled:opacity-40"
:disabled="currentPage === totalPages" @click="nextPage" x-text="t.page_next || 'Next'">
</button>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('callRecordsConsole', (payload) => ({
t: {},
basePath: '/console',
listEndpoint: '/console/call-records',
records: [],
summary: {
total: 0,
answered: 0,
missed: 0,
failed: 0,
transcribed: 0,
avg_duration: 0,
total_minutes: 0,
unique_dids: 0,
billing_billable_minutes: 0,
billing_rated_calls: 0,
billing_charged_calls: 0,
billing_included_calls: 0,
billing_zero_duration_calls: 0,
billing_unrated_calls: 0,
billing_revenue: [],
},
filterOptions: {
status: ['any'],
direction: ['any'],
departments: [],
sip_trunks: [],
tags: [],
billing_status: ['any'],
},
search: '',
statusFilter: 'any',
directionFilter: 'any',
dateRangeLabel: 'custom',
_updatingPreset: false,
datePresets: [
{ value: 'today', labelKey: 'preset_today' },
{ value: 'yesterday', labelKey: 'preset_yesterday' },
{ value: 'this_week', labelKey: 'preset_this_week' },
{ value: 'last_7_days', labelKey: 'preset_last_7_days' },
{ value: 'this_month', labelKey: 'preset_this_month' },
{ value: 'custom', labelKey: 'preset_custom' },
],
dateFrom: '',
dateTo: '',
onlyTranscribed: false,
selectedDepartments: [],
selectedSipTrunks: [],
selectedTags: [],
billingStatusFilter: 'any',
exportedAt: null,
loading: false,
error: null,
pageSizeOptions: [10, 25, 50],
pageSize: 10,
currentPage: 1,
totalPages: 1,
totalItems: 0,
debounceTimer: null,
copiedCallId: null,
copyTimer: null,
deletingId: null,
detailBase: '',
_sync: {
filters: false,
pageSize: false,
},
_activeRequest: null,
sortOrder: 'started_at_desc',
sortOptions: [
{ value: 'started_at_desc', labelKey: 'sort_started_newest' },
{ value: 'started_at_asc', labelKey: 'sort_started_oldest' },
{ value: 'duration_desc', labelKey: 'sort_duration_longest' },
{ value: 'duration_asc', labelKey: 'sort_duration_shortest' },
],
filterSelectOptions: [
{ key: 'only_transcribed', labelKey: 'filter_only_transcribed' },
{ key: 'departments', labelKey: 'filter_departments' },
{ key: 'sip_trunks', labelKey: 'filter_sip_trunks' },
],
activeFilters: [],
persistentFilters: [],
applyDatePreset(preset) {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const formatDate = (d) => {
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
let from = '';
let to = '';
let label = 'Custom';
switch (preset) {
case 'today':
from = formatDate(today);
to = formatDate(today);
label = 'today';
break;
case 'yesterday':
const y = new Date(today);
y.setDate(y.getDate() - 1);
from = formatDate(y);
to = formatDate(y);
label = 'yesterday';
break;
case 'this_week':
const day = today.getDay() || 7; const monday = new Date(today);
monday.setDate(today.getDate() - day + 1);
from = formatDate(monday);
to = formatDate(today);
label = 'this_week';
break;
case 'last_7_days':
const last7 = new Date(today);
last7.setDate(today.getDate() - 6);
from = formatDate(last7);
to = formatDate(today);
label = 'last_7_days';
break;
case 'this_month':
const firstDay = new Date(today.getFullYear(), today.getMonth(), 1);
from = formatDate(firstDay);
to = formatDate(today);
label = 'this_month';
break;
case 'custom':
label = 'custom';
from = this.dateFrom;
to = this.dateTo;
break;
}
this._updatingPreset = true;
this.dateFrom = from;
this.dateTo = to;
this.dateRangeLabel = label;
this.$nextTick(() => {
this._updatingPreset = false;
if (preset !== 'custom') {
this.scheduleFetch();
}
});
},
init() {
const data = typeof payload === 'string' ? JSON.parse(payload || '{}') : (payload || {});
if (data.translations && typeof data.translations === 'object') {
this.t = (data.translations || {}).call_record || {};
}
this.datePresets = this.datePresets.map(p => ({ ...p, label: this.t[p.labelKey] || p.labelKey }));
this.dateRangeLabel = this.t.preset_custom || 'Custom';
this.sortOptions = this.sortOptions.map(o => ({ ...o, label: this.t[o.labelKey] || o.labelKey }));
this.filterSelectOptions = this.filterSelectOptions.map(o => ({ ...o, label: this.t[o.labelKey] || o.labelKey }));
if (typeof data.basePath === 'string' && data.basePath) {
this.basePath = data.basePath;
}
if (typeof data.list_url === 'string' && data.list_url) {
this.listEndpoint = data.list_url;
} else {
const normalizedBase = this.basePath === '/'
? ''
: (this.basePath.endsWith('/')
? this.basePath.slice(0, -1)
: this.basePath);
this.listEndpoint = `${normalizedBase}/call-records` || '/call-records';
}
if (!this.listEndpoint.startsWith('/')) {
this.listEndpoint = `/${this.listEndpoint}`;
}
this.detailBase = this.normalizeBasePath(this.basePath);
this.sortOrder = this.defaultSort();
const options = (data.filter_options && typeof data.filter_options === 'object')
? data.filter_options
: {};
this.filterOptions = Object.assign({
status: ['any'],
direction: ['any'],
departments: [],
sip_trunks: [],
tags: [],
billing_status: ['any'],
}, options || {});
this.filterOptions.status = Array.isArray(this.filterOptions.status)
? this.filterOptions.status
: ['any'];
this.filterOptions.direction = Array.isArray(this.filterOptions.direction)
? this.filterOptions.direction
: ['any'];
this.filterOptions.departments = Array.isArray(this.filterOptions.departments)
? this.filterOptions.departments
: [];
this.filterOptions.sip_trunks = Array.isArray(this.filterOptions.sip_trunks)
? this.filterOptions.sip_trunks
: [];
this.filterOptions.tags = Array.isArray(this.filterOptions.tags)
? this.filterOptions.tags
: [];
this.filterOptions.billing_status = Array.isArray(this.filterOptions.billing_status)
? this.filterOptions.billing_status
: ['any'];
if (Array.isArray(data.page_size_options) && data.page_size_options.length) {
const normalized = data.page_size_options
.map((value) => Number(value))
.filter((value) => Number.isFinite(value) && value > 0);
if (normalized.length) {
this.pageSizeOptions = normalized;
}
}
const initialPerPage = Number(data.initial_per_page);
if (Number.isFinite(initialPerPage) && initialPerPage > 0) {
this.pageSize = initialPerPage;
} else if (this.pageSizeOptions.length) {
this.pageSize = this.pageSizeOptions[0];
}
if (!this.pageSizeOptions.includes(this.pageSize)) {
this.pageSizeOptions = [...this.pageSizeOptions, this.pageSize]
.filter((value, index, array) => array.indexOf(value) === index)
.sort((a, b) => a - b);
}
if (Array.isArray(data.records) && data.records.length) {
this.records = data.records;
}
this.summary = this.normalizeSummary(data.summary);
const totalItemsRaw = Number(data.total_items);
if (Number.isFinite(totalItemsRaw) && totalItemsRaw >= 0) {
this.totalItems = totalItemsRaw;
}
const totalPagesRaw = Number(data.total_pages);
if (Number.isFinite(totalPagesRaw) && totalPagesRaw >= 1) {
this.totalPages = totalPagesRaw;
}
const currentPageRaw = Number(data.page);
if (Number.isFinite(currentPageRaw) && currentPageRaw >= 1) {
this.currentPage = currentPageRaw;
}
if (!this.dateFrom) {
this.applyDatePreset('last_7_days');
}
const scheduleWatcher = () => {
if (this._sync.filters) return;
this.scheduleFetch();
};
const immediateWatcher = () => {
if (this._sync.filters) return;
this.applyFilters();
};
this.$watch('search', scheduleWatcher);
this.$watch('statusFilter', immediateWatcher);
this.$watch('directionFilter', immediateWatcher);
this.$watch('billingStatusFilter', immediateWatcher);
this.$watch('dateFrom', immediateWatcher);
this.$watch('dateTo', immediateWatcher);
this.$watch('onlyTranscribed', immediateWatcher);
this.$watch('selectedDepartments', immediateWatcher);
this.$watch('selectedSipTrunks', immediateWatcher);
this.$watch('selectedTags', immediateWatcher);
this.$watch('dateFrom', () => {
if (!this._updatingPreset) {
this.dateRangeLabel = 'custom';
}
});
this.$watch('dateTo', () => {
if (!this._updatingPreset) {
this.dateRangeLabel = 'custom';
}
});
this.$watch('pageSize', (value, oldValue) => {
if (this._sync.pageSize) {
return;
}
if (!Number.isFinite(value) || value <= 0 || value === oldValue) {
return;
}
this.applyPageSize(value);
});
this.syncActiveFilters();
if (!Array.isArray(data.records) || !data.records.length) {
this.fetchRecords({ page: this.currentPage });
}
},
normalizeBasePath(value) {
if (!value || value === '/') {
return '';
}
return value.endsWith('/') ? value.slice(0, -1) : value;
},
normalizeSummary(summary) {
const defaults = {
total: 0,
answered: 0,
missed: 0,
failed: 0,
transcribed: 0,
avg_duration: 0,
total_minutes: 0,
unique_dids: 0,
billing_billable_minutes: 0,
billing_rated_calls: 0,
billing_charged_calls: 0,
billing_included_calls: 0,
billing_zero_duration_calls: 0,
billing_unrated_calls: 0,
billing_revenue: [],
};
if (!summary || typeof summary !== 'object') {
return defaults;
}
return Object.assign({}, defaults, summary);
},
scheduleFetch() {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
this.debounceTimer = setTimeout(() => {
this.debounceTimer = null;
if (this._sync.filters) {
return;
}
this.currentPage = 1;
this.syncActiveFilters();
this.fetchRecords({ page: 1 });
}, 300);
},
applyFilters() {
this.currentPage = 1;
this.syncActiveFilters();
this.fetchRecords({ page: 1 });
},
applySorting() {
if (this._sync.filters) {
return;
}
this.currentPage = 1;
this.syncActiveFilters();
this.fetchRecords({ page: 1 });
},
applyPageSize(size) {
const perPage = Number(size);
if (!Number.isFinite(perPage) || perPage <= 0) {
return;
}
this.currentPage = 1;
this.syncActiveFilters();
this.fetchRecords({ page: 1, per_page: perPage });
},
buildRequestPayload(overrides = {}) {
const filters = {};
const query = (this.search || '').trim();
if (query) {
filters.q = query;
}
const status = (this.statusFilter || '').toLowerCase();
if (status && status !== 'any') {
filters.status = status;
}
const direction = (this.directionFilter || '').toLowerCase();
if (direction && direction !== 'any') {
filters.direction = direction;
}
const billingStatus = (this.billingStatusFilter || '').toLowerCase();
if (billingStatus && billingStatus !== 'any') {
filters.billing_status = billingStatus;
}
if (this.dateFrom) {
filters.date_from = this.dateFrom;
}
if (this.dateTo) {
filters.date_to = this.dateTo;
}
if (this.onlyTranscribed) {
filters.only_transcribed = true;
}
const deptIds = (Array.isArray(this.selectedDepartments) ? this.selectedDepartments : [])
.map((value) => parseInt(value, 10))
.filter((value) => Number.isInteger(value));
if (deptIds.length) {
filters.department_ids = deptIds;
}
const sipIds = (Array.isArray(this.selectedSipTrunks) ? this.selectedSipTrunks : [])
.map((value) => parseInt(value, 10))
.filter((value) => Number.isInteger(value));
if (sipIds.length) {
filters.sip_trunk_ids = sipIds;
}
const tags = (Array.isArray(this.selectedTags) ? this.selectedTags : [])
.map((value) => (value || '').toString().trim())
.filter((value) => value.length)
.filter((value, index, array) => array.indexOf(value) === index);
if (tags.length) {
filters.tags = tags;
}
const page = Number.isFinite(overrides.page) && overrides.page ? overrides.page : this.currentPage;
const perPage = Number.isFinite(overrides.per_page) && overrides.per_page > 0
? overrides.per_page
: this.pageSize;
const payload = {
page: Math.max(1, page),
per_page: Math.max(1, perPage),
};
if (Object.keys(filters).length) {
payload.filters = filters;
}
if (this.sortOrder) {
payload.sort = this.sortOrder;
}
return payload;
},
async fetchRecords(options = {}) {
const token = Symbol('call-records-request');
this._activeRequest = token;
this.loading = true;
this.error = null;
try {
const payload = this.buildRequestPayload(options);
const response = await fetch(this.listEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data?.message || this.t.load_error || 'Failed to load call records');
}
if (this._activeRequest !== token) {
return;
}
const items = Array.isArray(data?.items) ? data.items : [];
this.records = items;
const perPageRaw = Number(data?.per_page);
const totalItemsRaw = Number(data?.total_items);
const totalPagesRaw = Number(data?.total_pages);
const currentPageRaw = Number(data?.page);
const perPage = Number.isFinite(perPageRaw) && perPageRaw > 0
? perPageRaw
: payload.per_page;
if (this.pageSize !== perPage) {
this._sync.pageSize = true;
this.pageSize = perPage;
this.$nextTick(() => {
this._sync.pageSize = false;
});
}
const totalItems = Number.isFinite(totalItemsRaw) && totalItemsRaw >= 0
? totalItemsRaw
: items.length;
const inferredTotalPages = Math.max(Math.ceil(totalItems / (perPage || 1)), 1);
const totalPages = Number.isFinite(totalPagesRaw) && totalPagesRaw >= 1
? Math.max(totalPagesRaw, 1)
: inferredTotalPages;
const currentPage = Number.isFinite(currentPageRaw) && currentPageRaw >= 1
? Math.min(currentPageRaw, totalPages)
: Math.min(payload.page, totalPages);
this.currentPage = currentPage;
this.totalItems = totalItems;
this.totalPages = totalPages;
this.summary = this.normalizeSummary(data?.summary);
} catch (err) {
console.error(err);
if (this._activeRequest !== token) {
return;
}
this.error = err?.message || this.t.load_unable || 'Unable to load call records';
this.records = [];
this.summary = this.normalizeSummary(null);
this.totalItems = 0;
this.totalPages = 1;
this.currentPage = 1;
} finally {
if (this._activeRequest === token) {
this.loading = false;
this.syncActiveFilters();
}
}
},
setPage(page) {
const target = Math.min(
Math.max(Number(page) || 1, 1),
Math.max(this.totalPages || 1, 1),
);
if (target === this.currentPage) {
return;
}
this.currentPage = target;
this.fetchRecords({ page: target });
},
prevPage() {
if (this.currentPage > 1) {
this.setPage(this.currentPage - 1);
}
},
nextPage() {
if (this.currentPage < this.totalPages) {
this.setPage(this.currentPage + 1);
}
},
get filteredRecords() {
return Array.isArray(this.records) ? this.records : [];
},
get paginatedRecords() {
return this.filteredRecords;
},
get pageRangeStart() {
if (!this.filteredRecords.length) {
return 0;
}
return ((this.currentPage - 1) * this.pageSize) + 1;
},
get pageRangeEnd() {
if (!this.filteredRecords.length) {
return 0;
}
return Math.min(this.pageRangeStart + this.filteredRecords.length - 1, this.totalItems);
},
clearFilters() {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
this._sync.filters = true;
this.search = '';
this.statusFilter = 'any';
this.directionFilter = 'any';
this.billingStatusFilter = 'any';
this.dateFrom = '';
this.dateTo = '';
this.onlyTranscribed = false;
this.selectedDepartments = [];
this.selectedSipTrunks = [];
this.selectedTags = [];
this.sortOrder = this.defaultSort();
this.activeFilters = [];
this.persistentFilters = [];
this.$nextTick(() => {
this._sync.filters = false;
this.applyFilters();
});
},
async copyCallId(callId) {
if (!callId) {
return;
}
const text = String(callId);
let ok = await this.tryClipboardWrite(text);
if (!ok) {
ok = this.copyCallIdFallback(text);
}
if (ok) {
this.showCopyConfirmation(text);
}
},
async tryClipboardWrite(text) {
if (typeof navigator === 'undefined' || !navigator.clipboard || !navigator.clipboard.writeText) {
return false;
}
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
console.error('Clipboard API failed', err);
return false;
}
},
showCopyConfirmation(value) {
this.copiedCallId = value;
if (this.copyTimer) {
clearTimeout(this.copyTimer);
}
this.copyTimer = setTimeout(() => {
this.copiedCallId = null;
this.copyTimer = null;
}, 1500);
},
copyCallIdFallback(text) {
try {
const temp = document.createElement('textarea');
temp.value = text;
temp.setAttribute('readonly', '');
temp.style.position = 'fixed';
temp.style.opacity = '0';
document.body.appendChild(temp);
temp.select();
temp.setSelectionRange(0, temp.value.length);
const success = document.execCommand('copy');
document.body.removeChild(temp);
return success;
} catch (err) {
console.error('Copy fallback failed', err);
return false;
}
},
recordDetailUrl(record) {
if (record && record.detail_url) {
return record.detail_url;
}
const id = record && record.id;
if (!id) {
return '#';
}
const base = this.detailBase ? this.detailBase : '';
const path = `${base}/call-records/${encodeURIComponent(id)}`;
return path.startsWith('/') ? path : `/${path}`;
},
recordDeleteUrl(record) {
if (record && record.detail_url) {
return record.detail_url;
}
const id = record && record.id;
if (!id) {
return '#';
}
const base = this.detailBase ? this.detailBase : '';
const path = `${base}/call-records/${encodeURIComponent(id)}`;
return path.startsWith('/') ? path : `/${path}`;
},
async deleteRecord(record) {
if (!record || !record.id) {
return;
}
if (typeof window === 'undefined' || !window?.dispatchEvent) {
this.executeDelete(record);
return;
}
window.dispatchEvent(new CustomEvent('console:confirm', {
detail: {
title: this.t.delete_confirm_title || 'Delete call record',
message: this.t.delete_confirm_message || 'This will permanently remove the call record and associated metadata.',
confirmLabel: this.t.delete_confirm_label || 'Delete',
cancelLabel: this.t.delete_confirm_cancel || 'Cancel',
destructive: true,
onConfirm: () => this.executeDelete(record),
},
}));
},
async executeDelete(record) {
if (!record || !record.id) {
return;
}
this.deletingId = record.id;
this.error = null;
const isLastItemOnPage = this.records.length <= 1;
const targetPage = isLastItemOnPage && this.currentPage > 1
? this.currentPage - 1
: this.currentPage;
try {
const response = await fetch(this.recordDeleteUrl(record), {
method: 'DELETE',
headers: {
'Accept': 'application/json',
},
});
if (!response.ok) {
let message = this.t.delete_error || 'Failed to delete record';
try {
const data = await response.json();
if (data && data.message) {
message = data.message;
}
} catch (_) {
}
throw new Error(message);
}
this.records = this.records.filter((item) => item.id !== record.id);
this.currentPage = targetPage;
await this.fetchRecords({ page: targetPage });
} catch (err) {
console.error(err);
this.error = err && err.message ? err.message : (this.t.delete_error || 'Failed to delete record');
} finally {
this.deletingId = null;
}
},
async exportCsv() {
try {
this.exportedAt = null;
const desired = Math.max(this.totalItems || 0, this.pageSize || 1);
const payload = this.buildRequestPayload({
page: 1,
per_page: Math.min(Math.max(desired, 1000), 10000),
});
const response = await fetch(this.listEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data?.message || this.t.export_error || 'Failed to export CSV');
}
const rows = Array.isArray(data?.items) ? data.items : [];
if (!rows.length) {
this.exportedAt = this.t.export_no_data || 'No data to export';
return;
}
const csv = this.buildCsv(rows);
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `call-records-${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
this.exportedAt = new Date().toLocaleString();
} catch (err) {
console.error(err);
this.exportedAt = err?.message || this.t.export_failed || 'Export failed';
}
},
buildCsv(records) {
const header = [
this.t.csv_call_id || 'Call ID',
this.t.csv_display_id || 'Display ID',
this.t.csv_started_at || 'Started At',
this.t.csv_direction || 'Direction',
this.t.csv_status || 'Status',
this.t.csv_from || 'From',
this.t.csv_to || 'To',
this.t.csv_duration || 'Duration (s)',
this.t.csv_agent || 'Agent',
this.t.csv_queue || 'Queue',
this.t.csv_tags || 'Tags',
this.t.csv_transcript || 'Transcript',
this.t.csv_billing_status || 'Billing status',
this.t.csv_billing_total || 'Billing total',
];
return [header]
.concat(
records.map((record) => [
record?.id || '',
record?.display_id || '',
this.formatDateTime(record?.started_at),
this.directionLabel(record?.direction),
this.statusLabel(record?.status),
record?.from || '',
record?.to || '',
record?.duration_secs || 0,
record?.agent || '',
record?.queue || '',
(Array.isArray(record?.tags) ? record.tags : []).join('|'),
record?.has_transcript ? (this.t.csv_transcript_yes || 'Yes') : (this.t.csv_transcript_no || 'No'),
this.billingStatusLabel(record?.billing?.status),
this.formatBillingAmount(record?.billing),
])
)
.map((line) =>
line
.map((cell) => {
const value = cell === null || cell === undefined ? '' : String(cell);
const escaped = value.replace(/"/g, '""');
return `"${escaped}"`;
})
.join(',')
)
.join('\n');
},
statusTabs() {
const options = Array.isArray(this.filterOptions.status) ? this.filterOptions.status : [];
return options
.filter((value) => value !== 'any')
.map((value) => ({
value,
label: this.statusLabel(value),
}));
},
directionTabs() {
const options = Array.isArray(this.filterOptions.direction) ? this.filterOptions.direction : [];
return options
.filter((value) => value !== 'any')
.map((value) => ({
value,
label: this.directionLabel(value),
}));
},
filterOptionLabel(key) {
switch (key) {
case 'status':
return this.t.filter_status || 'Status';
case 'direction':
return this.t.filter_direction || 'Direction';
case 'date_range':
return this.t.filter_date_range || 'Date range';
case 'only_transcribed':
return this.t.filter_only_transcribed || 'Transcribed only';
case 'departments':
return this.t.filter_departments || 'Departments';
case 'sip_trunks':
return this.t.filter_sip_trunks || 'SIP trunks';
case 'billing_status':
return this.t.filter_billing_status || 'Billing status';
default:
return key;
}
},
formatFilterValue(key) {
switch (key) {
case 'status':
return this.statusLabel(this.statusFilter);
case 'direction':
return this.directionFilter && this.directionFilter !== 'any'
? this.directionLabel(this.directionFilter)
: '';
case 'date_range':
if (this.dateFrom && this.dateTo) {
return `${this.dateFrom} → ${this.dateTo}`;
}
if (this.dateFrom) {
return `${this.t.filter_from || 'From'} ${this.dateFrom}`;
}
if (this.dateTo) {
return `${this.t.filter_until || 'Until'} ${this.dateTo}`;
}
return '';
case 'only_transcribed':
return this.onlyTranscribed ? (this.t.filter_enabled || 'Enabled') : '';
case 'departments': {
const options = Array.isArray(this.filterOptions.departments)
? this.filterOptions.departments
: [];
const selected = Array.isArray(this.selectedDepartments)
? this.selectedDepartments
: [];
if (!selected.length) {
return '';
}
const names = selected
.map((id) => {
const numeric = Number(id);
const match = options.find((dept) => Number(dept.id) === numeric);
if (match) {
return match.name;
}
return Number.isFinite(numeric) ? `#${numeric}` : String(id);
})
.filter(Boolean);
return names.length ? names.join(', ') : '';
}
case 'sip_trunks': {
const options = Array.isArray(this.filterOptions.sip_trunks)
? this.filterOptions.sip_trunks
: [];
const selected = Array.isArray(this.selectedSipTrunks)
? this.selectedSipTrunks
: [];
if (!selected.length) {
return '';
}
const names = selected
.map((id) => {
const numeric = Number(id);
const match = options.find((trunk) => Number(trunk.id) === numeric);
if (!match) {
return Number.isFinite(numeric) ? `#${numeric}` : String(id);
}
return match.display_name || match.name || `#${numeric}`;
})
.filter(Boolean);
return names.length ? names.join(', ') : '';
}
case 'billing_status':
return this.billingStatusFilter && this.billingStatusFilter !== 'any'
? this.billingStatusLabel(this.billingStatusFilter)
: '';
default:
return '';
}
},
addFilter(event) {
const key = event?.target?.value;
if (!key) {
return;
}
if (!this.activeFilters.includes(key)) {
this.activeFilters = [...this.activeFilters, key];
}
if (this.shouldPersistFilter(key) && !this.persistentFilters.includes(key)) {
this.persistentFilters = [...this.persistentFilters, key];
}
if (event && event.target) {
event.target.value = '';
}
this.syncActiveFilters();
},
removeFilter(key) {
if (!key) {
return;
}
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
this._sync.filters = true;
switch (key) {
case 'status':
this.statusFilter = 'any';
break;
case 'direction':
this.directionFilter = 'any';
break;
case 'date_range':
this.dateFrom = '';
this.dateTo = '';
break;
case 'only_transcribed':
this.onlyTranscribed = false;
break;
case 'departments':
this.selectedDepartments = [];
break;
case 'sip_trunks':
this.selectedSipTrunks = [];
break;
case 'billing_status':
this.billingStatusFilter = 'any';
break;
default:
break;
}
this.activeFilters = this.activeFilters.filter((item) => item !== key);
if (this.shouldPersistFilter(key)) {
this.persistentFilters = this.persistentFilters.filter((item) => item !== key);
}
this.$nextTick(() => {
this._sync.filters = false;
this.syncActiveFilters();
this.applyFilters();
});
},
shouldPersistFilter(key) {
return ['direction', 'date_range', 'only_transcribed', 'departments', 'sip_trunks'].includes(key);
},
syncActiveFilters() {
const manualFilters = Array.isArray(this.activeFilters)
? [...this.activeFilters]
: [];
const statusActive = this.statusFilter && this.statusFilter !== 'any';
const directionActive = this.directionFilter && this.directionFilter !== 'any';
const billingStatusActive = this.billingStatusFilter && this.billingStatusFilter !== 'any';
const hasDate = Boolean(this.dateFrom) || Boolean(this.dateTo);
const departmentsSelected = Array.isArray(this.selectedDepartments) && this.selectedDepartments.length;
const sipSelected = Array.isArray(this.selectedSipTrunks) && this.selectedSipTrunks.length;
const next = [];
const addUnique = (key) => {
if (!next.includes(key)) {
next.push(key);
}
};
(this.persistentFilters || []).forEach((key) => addUnique(key));
if (statusActive) {
}
if (directionActive || this.persistentFilters.includes('direction')) {
}
if (billingStatusActive || manualFilters.includes('billing_status')) {
addUnique('billing_status');
}
if (hasDate || this.persistentFilters.includes('date_range')) {
}
if (this.onlyTranscribed || this.persistentFilters.includes('only_transcribed')) {
addUnique('only_transcribed');
}
if (departmentsSelected || this.persistentFilters.includes('departments')) {
addUnique('departments');
}
if (sipSelected || this.persistentFilters.includes('sip_trunks')) {
addUnique('sip_trunks');
}
this.activeFilters = next;
},
defaultSort() {
return 'started_at_desc';
},
statusLabel(value) {
switch ((value || '').toLowerCase()) {
case 'completed':
return this.t.status_completed || 'Completed';
case 'missed':
return this.t.status_missed || 'Missed';
case 'failed':
return this.t.status_failed || 'Failed';
case 'any':
return this.t.status_any || 'Any status';
default:
return value || (this.t.status_unknown || 'Unknown');
}
},
statusCodeText(record) {
if (record && record.status_code) {
return String(record.status_code);
}
return this.statusLabel(record && record.status ? record.status : '');
},
directionLabel(value) {
switch ((value || '').toLowerCase()) {
case 'inbound':
return this.t.direction_inbound || 'Inbound';
case 'outbound':
return this.t.direction_outbound || 'Outbound';
case 'internal':
return this.t.direction_internal || 'Internal';
case 'any':
return this.t.direction_any || 'Any direction';
default:
return value || (this.t.direction_unknown || 'Unknown');
}
},
billingStatusLabel(value) {
switch ((value || '').toLowerCase()) {
case 'charged':
return this.t.billing_charged || 'Charged';
case 'included':
return this.t.billing_included || 'Included';
case 'zero-duration':
return this.t.billing_zero_duration || 'Zero duration';
case 'unrated':
return this.t.billing_unrated || 'Unrated';
case 'any':
return this.t.billing_any || 'Any billing status';
default:
return value || (this.t.billing_unknown || 'Unknown');
}
},
transcriptStatusLabel(status, hasTranscript = false) {
const value = (status || '').toLowerCase();
if (value === 'completed' || hasTranscript) {
return this.t.transcript_ready || 'Ready';
}
if (value === 'processing') {
return this.t.transcript_processing || 'Processing';
}
if (value === 'failed') {
return this.t.transcript_failed || 'Failed';
}
return this.t.transcript_pending || 'Pending';
},
transcriptStatusClasses(status, hasTranscript = false) {
const value = (status || '').toLowerCase();
if (value === 'completed' || hasTranscript) {
return 'bg-emerald-50 text-emerald-600 ring-1 ring-emerald-200';
}
if (value === 'processing') {
return 'bg-amber-50 text-amber-600 ring-1 ring-amber-200';
}
if (value === 'failed') {
return 'bg-rose-50 text-rose-600 ring-1 ring-rose-200';
}
return 'bg-slate-100 text-slate-500 ring-1 ring-slate-200';
},
transcriptStatusIcon(status, hasTranscript = false) {
const value = (status || '').toLowerCase();
if (value === 'completed' || hasTranscript) {
return 'ready';
}
if (value === 'processing') {
return 'processing';
}
if (value === 'failed') {
return 'failed';
}
return 'pending';
},
statusClasses(status) {
switch ((status || '').toLowerCase()) {
case 'completed':
return 'bg-emerald-50 text-emerald-600 ring-1 ring-emerald-200';
case 'missed':
return 'bg-amber-50 text-amber-600 ring-1 ring-amber-200';
case 'failed':
return 'bg-rose-50 text-rose-600 ring-1 ring-rose-200';
default:
return 'bg-slate-100 text-slate-600 ring-1 ring-slate-200';
}
},
billingStatusClasses(status) {
switch ((status || '').toLowerCase()) {
case 'charged':
return 'bg-emerald-50 text-emerald-600 ring-1 ring-emerald-200';
case 'included':
return 'bg-sky-50 text-sky-600 ring-1 ring-sky-200';
case 'zero-duration':
return 'bg-slate-100 text-slate-600 ring-1 ring-slate-200';
case 'unrated':
return 'bg-amber-50 text-amber-600 ring-1 ring-amber-200';
default:
return 'bg-slate-100 text-slate-600 ring-1 ring-slate-200';
}
},
statusDot(status) {
switch ((status || '').toLowerCase()) {
case 'completed':
return 'bg-emerald-500';
case 'missed':
return 'bg-amber-500';
case 'failed':
return 'bg-rose-500';
default:
return 'bg-slate-400';
}
},
directionClasses(direction) {
switch ((direction || '').toLowerCase()) {
case 'inbound':
return 'border border-sky-100 bg-sky-50 text-sky-700';
case 'outbound':
return 'border border-emerald-100 bg-emerald-50 text-emerald-700';
case 'internal':
return 'border border-slate-200 bg-slate-50 text-slate-700';
default:
return 'border border-slate-200 bg-slate-50 text-slate-700';
}
},
formatCurrency(amount, currency) {
if (amount === null || amount === undefined) {
return '—';
}
const numeric = Number(amount);
if (!Number.isFinite(numeric)) {
return '—';
}
const symbol = (currency || 'USD').toString().trim().toUpperCase() || 'USD';
return `${symbol} ${numeric.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
},
formatBillingAmount(billing) {
if (!billing || typeof billing !== 'object') {
return '—';
}
const status = (billing.status || '').toLowerCase();
const amount = billing.amount || {};
const total = amount.total ?? amount.subtotal ?? null;
if (total !== null && total !== undefined) {
return this.formatCurrency(total, billing.currency || 'USD');
}
if (status === 'included') {
return this.t.csv_billing_included || 'Included';
}
if (status === 'zero-duration') {
return '0.00';
}
if (status === 'unrated') {
return this.t.csv_billing_unrated || 'Unrated';
}
return '—';
},
formatBillingMinutes(billing) {
if (!billing || typeof billing !== 'object') {
return '—';
}
const source = billing.billable_minutes !== undefined
? Number(billing.billable_minutes)
: (typeof billing.billable_secs === 'number'
? Number(billing.billable_secs) / 60.0
: NaN);
if (!Number.isFinite(source) || source <= 0) {
return '—';
}
return `${source.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})} min`;
},
billingRevenueEntries() {
const entries = Array.isArray(this.summary.billing_revenue)
? this.summary.billing_revenue
: [];
return entries
.map((entry) => ({
currency: (entry?.currency || 'USD').toString().trim().toUpperCase() || 'USD',
total: Number(entry?.total ?? 0),
}))
.filter((entry) => Number.isFinite(entry.total));
},
billingSummaryTotal() {
const entries = this.billingRevenueEntries();
if (!entries.length) {
return '—';
}
return entries
.map((entry) => this.formatCurrency(entry.total, entry.currency))
.join(' · ');
},
qualityTone(value) {
const mos = Number(value || 0);
if (mos >= 4.3) {
return 'text-emerald-600';
}
if (mos >= 3.8) {
return 'text-amber-600';
}
if (mos > 0) {
return 'text-rose-600';
}
return 'text-slate-500';
},
formatNumber(value) {
if (value === null || value === undefined || Number.isNaN(Number(value))) {
return '—';
}
return Number(value).toLocaleString(undefined, { maximumFractionDigits: 1 });
},
formatAvgDuration(value) {
if (!value) {
return '0s';
}
const seconds = Number(value);
if (Number.isNaN(seconds)) {
return '—';
}
if (seconds >= 3600) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.round((seconds % 3600) / 60);
return `${hours}h ${minutes}m`;
}
if (seconds >= 60) {
const minutes = Math.floor(seconds / 60);
const secs = Math.round(seconds % 60);
return `${minutes}m ${secs}s`;
}
return `${Math.round(seconds)}s`;
},
formatDuration(value) {
const seconds = Number(value || 0);
if (!seconds) {
return '00:00';
}
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
const pad = (num) => String(num).padStart(2, '0');
if (hours > 0) {
return `${pad(hours)}:${pad(minutes)}:${pad(secs)}`;
}
return `${pad(minutes)}:${pad(secs)}`;
},
formatDateTime(value) {
if (!value) {
return '—';
}
try {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString();
} catch (err) {
return value;
}
},
}));
});
</script>
{% endblock %}