{% extends "console/layout.html" %}
{% block title %}Extensions · {{ site_name | default('RustPBX') }}{% endblock %}
{% block content %}
<div class="p-4 sm:p-6" x-data='extensionsPage({
basePath: {{ (base_path | default("/console")) | tojson }},
filters: {{ filters | default({}) | tojson }},
createUrl: {{ create_url | tojson }}
})' x-init="init()">
<div class="mx-auto max-w-7xl space-y-6">
<div class="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div class="space-y-0.5">
<p class="text-[11px] font-semibold uppercase tracking-wide text-sky-600">{{ "extension.directory_management" | t }}</p>
<h1 class="text-2xl font-semibold text-slate-900">{{ "extension.title" | t }}</h1>
<p class="text-sm text-slate-500 sm:max-w-xl">{{ "extension.review_search" | t }}</p>
</div>
<div class="flex gap-3">
<a :href="createUrl"
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">
<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="M10 4v12m6-6H4" />
</svg>
{{ "extension.new_extension" | t }}
</a>
</div>
</div>
<template x-if="flash">
<div class="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700"
x-text="flash"></div>
</template>
<template x-if="error">
<div class="rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700" x-text="error">
</div>
</template>
<section class="rounded-xl bg-white p-4 sm:p-5 lg:p-6 shadow-sm ring-1 ring-black/5">
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div class="flex flex-wrap items-center gap-2">
<button type="button"
class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold transition"
:class="statusFilter === '' ? 'border-sky-200 bg-sky-100 text-sky-700' : 'border-slate-200 text-slate-600 hover:border-sky-300 hover:text-sky-700'"
@click="selectStatusTab('')">
{{ "extension.all_statuses_tab" | t }}
</button>
<template x-for="status in statusTabs()" :key="status.value">
<button type="button"
class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold transition"
:class="statusFilter === status.value ? 'border-sky-200 bg-sky-100 text-sky-700' : 'border-slate-200 text-slate-600 hover:border-sky-300 hover:text-sky-700'"
@click="selectStatusTab(status.value)">
<span x-text="status.label"></span>
</button>
</template>
</div>
<div
class="flex w-full flex-col gap-3 lg:w-auto lg:flex-row lg:items-center lg:justify-end lg:gap-3">
<label class="relative flex-1 lg:min-w-[18rem]" aria-label="{{ "extension.search_extensions" | 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"
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="{{ "extension.search_placeholder" | t }}" x-model="keyword"
@input.debounce.400ms="applyFilters()">
</label>
<div class="flex flex-wrap items-center gap-2">
<div class="relative">
<select aria-label="{{ "extension.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="">{{ "extension.add_filter" | t }}</option>
<template x-for="option in filterOptions" :key="option.key">
<option :value="option.key" :disabled="activeFilters.includes(option.key)"
x-text="option.label"></option>
</template>
</select>
</div>
<div class="relative">
<select aria-label="{{ "extension.sort_extensions" | 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>
</div>
<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="resetFilters()">
<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>
{{ "extension.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-[18rem]">
<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-2">
<template x-if="filter === 'departments'">
<div class="flex flex-wrap gap-2">
<template x-if="!departments.length">
<span
class="rounded-full border border-slate-200 px-3 py-1 text-xs text-slate-400">{{ "extension.no_departments_configured" | t }}</span>
</template>
<template x-for="dept in departments" :key="dept.id">
<label
class="inline-flex items-center gap-2 rounded-full 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-400"
:value="String(dept.id)" x-model="selectedDepartments"
@change="applyFilters()">
<span x-text="dept.name"></span>
</label>
</template>
</div>
</template>
<template x-if="filter === 'callForwarding'">
<select x-model="callForwardingFilter" @change="applyFilters()"
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">
<option value="any">Any</option>
<option value="enabled">Enabled</option>
<option value="disabled">Disabled</option>
</select>
</template>
<template x-if="filter === 'login'">
<select x-model="loginFilter" @change="applyFilters()"
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">
<option value="any">Any</option>
<option value="allowed">Allowed</option>
<option value="disabled">Disabled</option>
</select>
</template>
<template x-if="filter === 'createdRange'">
<div class="grid gap-2 sm:grid-cols-2">
<input type="datetime-local" x-model="createdFrom" @change="applyFilters()"
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">
<input type="datetime-local" x-model="createdTo" @change="applyFilters()"
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">
</div>
</template>
<template x-if="filter === 'registeredRange'">
<div class="grid gap-2 sm:grid-cols-2">
<input type="datetime-local" x-model="registeredFrom" @change="applyFilters()"
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">
<input type="datetime-local" x-model="registeredTo" @change="applyFilters()"
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">
</div>
</template>
</div>
</div>
</template>
</div>
</div>
</section>
<section class="overflow-hidden rounded-xl bg-white ring-1 ring-black/5">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-slate-200 text-left text-sm">
<thead class="bg-slate-50 text-xs font-semibold uppercase tracking-wide text-slate-500">
<tr>
<th scope="col" class="px-4 py-2">{{ "extension.extension_col" | t }}</th>
<th scope="col" class="px-4 py-2">{{ "extension.departments_col" | t }}</th>
<th scope="col" class="px-4 py-2">{{ "common.status" | t }}</th>
<th scope="col" class="px-4 py-2">{{ "extension.login_col" | t }}</th>
<th scope="col" class="px-4 py-2">{{ "extension.public_access_col" | t }}</th>
<th scope="col" class="px-4 py-2">{{ "extension.forwarding_col" | t }}</th>
<th scope="col" class="px-4 py-2">{{ "extension.created_col" | t }}</th>
<th scope="col" class="px-4 py-2">{{ "extension.registered_col" | t }}</th>
<th scope="col" class="px-4 py-2">{{ "common.actions" | t }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 bg-white text-sm text-slate-600">
<template x-if="loading">
<tr>
<td colspan="9" class="px-4 py-6 text-center text-sm text-slate-500">
<div class="inline-flex items-center gap-2">
<svg class="h-4 w-4 animate-spin text-slate-400" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 3v3m6.364 1.636-2.121 2.121M21 12h-3m-1.636 6.364-2.121-2.121M12 21v-3m-6.364-1.636 2.121-2.121M3 12h3m1.636-6.364 2.121 2.121" />
</svg>
Loading extensions…
</div>
</td>
</tr>
</template>
<template x-if="!loading && !extensions.length">
<tr>
<td colspan="9" class="px-4 py-6 text-center text-sm text-slate-500">
No extensions found for the current filters.
</td>
</tr>
</template>
<template x-for="extension in extensions" :key="extension.id">
<tr class="hover:bg-slate-50">
<td class="whitespace-nowrap px-4 py-2">
<div class="font-medium text-slate-900"
x-text="formatDefault(extension.display_name, '—')"></div>
<div class="text-xs text-slate-400">Ext <span x-text="extension.extension"></span>
</div>
<template x-if="extension.email">
<div class="text-xs text-slate-400" x-text="extension.email"></div>
</template>
</td>
<td class="whitespace-nowrap px-4 py-2">
<template x-if="resolveDepartments(extension).length">
<div class="flex flex-wrap gap-1">
<template x-for="dept in resolveDepartments(extension)" :key="dept">
<span
class="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-700 ring-1 ring-slate-200"
x-text="dept"></span>
</template>
</div>
</template>
<template x-if="!resolveDepartments(extension).length">
<span class="text-slate-400">—</span>
</template>
</td>
<td class="whitespace-nowrap px-4 py-2">
<span
class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold"
:class="statusMeta(extension.status).badge">
<span class="h-1.5 w-1.5 rounded-full"
:class="statusMeta(extension.status).dot"></span>
<span x-text="statusMeta(extension.status).label"></span>
</span>
</td>
<td class="whitespace-nowrap px-4 py-2">
<span
class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium ring-1"
:class="loginAllowed(extension) ? 'bg-emerald-50 text-emerald-600 ring-emerald-100' : 'bg-rose-50 text-rose-600 ring-rose-100'">
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="none" stroke="currentColor"
stroke-width="1.8">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 10l4 4 6-8"
x-show="loginAllowed(extension)"></path>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6l8 8M6 14l8-8"
x-show="!loginAllowed(extension)"></path>
</svg>
<span x-text="loginAllowed(extension) ? 'Enabled' : 'Disabled'"></span>
</span>
</td>
<td class="whitespace-nowrap px-4 py-2">
<span
class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium ring-1"
:class="publicCallsAllowed(extension) ? 'bg-sky-50 text-sky-600 ring-sky-100' : 'bg-slate-100 text-slate-600 ring-slate-200'">
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="none" stroke="currentColor"
stroke-width="1.8">
<path stroke-linecap="round" stroke-linejoin="round"
d="M4.75 3.75h2.5l1 3-1.5.75a7 7 0 0 0 4.5 4.5l.75-1.5 3 1v2.5a1 1 0 0 1-1 1c-6.351 0-11.5-5.149-11.5-11.5a1 1 0 0 1 1-1Z" />
</svg>
<span x-text="publicCallsAllowed(extension) ? 'Allowed' : 'Restricted'"></span>
</span>
</td>
<td class="whitespace-nowrap px-4 py-2">
<span
class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium ring-1"
:class="forwardingLabel(extension) === 'None' ? 'bg-slate-100 text-slate-600 ring-slate-200' : 'bg-sky-50 text-sky-600 ring-sky-100'">
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="none" stroke="currentColor"
stroke-width="1.8">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 5l6 5-6 5" />
</svg>
<span x-text="forwardingLabel(extension)"></span>
</span>
<template x-if="forwardingDestination(extension)">
<div class="mt-1 max-w-[12rem] truncate text-xs text-slate-400"
:title="forwardingDestination(extension)"
x-text="forwardingDestination(extension)"></div>
</template>
</td>
<td class="whitespace-nowrap px-4 py-2 text-slate-500"
x-text="formatDate(extension.created_at)"></td>
<td class="whitespace-nowrap px-4 py-2 text-slate-500">
<template x-if="registrationError(extension)">
<div
class="rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-600">
<div class="font-semibold">Locator error</div>
<div x-text="registrationError(extension)"></div>
</div>
</template>
<template x-if="!registrationError(extension) && hasActiveRegistration(extension)">
<div class="flex items-start gap-2">
<span
class="mt-0.5 inline-flex h-4 w-4 items-center justify-center rounded-full bg-emerald-100 text-emerald-600 ring-1 ring-emerald-200">
<svg class="h-3 w-3" viewBox="0 0 20 20" fill="none"
stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round"
d="M5 10l3 3 7-7" />
</svg>
</span>
<div class="leading-tight">
<div class="max-w-[14rem] truncate text-xs font-semibold text-slate-700"
:title="registrationDevice(extension)"
x-text="registrationDevice(extension)"></div>
<div class="text-xs text-slate-400"
x-text="registrationStatus(extension)"></div>
</div>
</div>
</template>
<template x-if="!registrationError(extension) && !hasActiveRegistration(extension)">
<div class="flex items-start gap-2 text-slate-400">
<span
class="mt-0.5 inline-flex h-4 w-4 items-center justify-center rounded-full bg-slate-100 text-slate-500 ring-1 ring-slate-200">
<svg class="h-3 w-3" viewBox="0 0 20 20" fill="none"
stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round"
d="M6 6l8 8M6 14l8-8" />
</svg>
</span>
<div class="leading-tight">
<div class="text-xs font-semibold"
x-text="registrationStatus(extension)"></div>
</div>
</div>
</template>
</td>
<td class="px-4 py-2">
<div class="flex items-center gap-2">
<a :href="detailUrl(extension.id)" title="Edit extension"
class="inline-flex items-center justify-center rounded-full border border-slate-200 p-2 text-slate-500 transition hover:border-sky-300 hover:text-sky-600">
<span class="sr-only">Edit</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="M13.5 3.5l3 3-8 8H5.5v-3l8-8z" />
</svg>
</a>
<button type="button" title="Toggle login"
class="inline-flex items-center justify-center rounded-full border border-slate-200 p-2 text-slate-500 transition hover:border-amber-300 hover:text-amber-600"
:disabled="processing.toggle === extension.id"
@click="toggleLogin(extension)">
<span class="sr-only">Toggle login</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="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z" />
</svg>
</button>
<button type="button" title="Delete extension"
class="inline-flex items-center justify-center rounded-full border border-slate-200 p-2 text-slate-500 transition hover:border-rose-300 hover:text-rose-600"
:disabled="processing.delete === extension.id"
@click="confirmDelete(extension)">
<span class="sr-only">Delete</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 7h8m-7 0v8m3-8v8m3-8v8M4 7h12M8 4h4a1 1 0 011 1v2H7V5a1 1 0 011-1z" />
</svg>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</section>
<section
class="flex flex-col gap-3 rounded-xl border border-slate-100 bg-white px-4 py-4 text-xs text-slate-500 sm:flex-row sm:items-center sm:justify-between"
x-show="pagination">
<div>
Showing
<span class="font-semibold text-slate-700" x-text="pagination?.showing_from ?? 0"></span>
–
<span class="font-semibold text-slate-700" x-text="pagination?.showing_to ?? 0"></span>
of
<span class="font-semibold text-slate-700" x-text="pagination?.total_items ?? 0"></span>
extensions
</div>
<div class="flex items-center gap-2">
<label class="hidden md:inline-flex items-center gap-1 text-slate-400">
Per page
<select
class="rounded-md border border-slate-200 px-2 py-1 text-xs text-slate-600 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200"
@change="perPage = parseInt($event.target.value, 10) || 20; applyFilters();" :value="perPage">
<option value="10">10</option>
<option value="20">20</option>
<option value="50">50</option>
</select>
</label>
<button type="button"
class="inline-flex items-center gap-1 rounded-full border border-slate-200 px-3 py-1 text-xs font-semibold hover:border-sky-300 hover:text-sky-700"
:class="{ 'pointer-events-none opacity-40': !(pagination?.has_prev) }" @click="prevPage()">
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 5l-4 5 4 5" />
</svg>
Prev
</button>
<span class="rounded-full border border-slate-200 px-3 py-1 text-xs font-semibold text-slate-600">
Page <span x-text="pagination?.current_page ?? page"></span> /
<span x-text="pagination?.total_pages ?? 1"></span>
</span>
<button type="button"
class="inline-flex items-center gap-1 rounded-full border border-slate-200 px-3 py-1 text-xs font-semibold hover:border-sky-300 hover:text-sky-700"
:class="{ 'pointer-events-none opacity-40': !(pagination?.has_next) }" @click="nextPage()">
Next
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 5l4 5-4 5" />
</svg>
</button>
</div>
</section>
</div>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('extensionsPage', (options) => {
const normalizeLocator = (raw) => {
if (!raw || typeof raw !== 'object') {
return {
available: false,
query_uri: null,
realm: null,
total: 0,
records: [],
error: null,
};
}
return {
available: !!raw.available,
query_uri: raw.query_uri || null,
realm: raw.realm || null,
total: Number(raw.total) || 0,
records: Array.isArray(raw.records) ? raw.records : [],
error: raw.error || null,
};
};
return {
basePath: options.basePath || '/console',
listEndpoint: `${options.basePath || '/console'}/extensions`,
createUrl: options.createUrl || `${options.basePath || '/console'}/extensions/new`,
filtersRaw: options.filters || {},
departments: [],
statuses: [],
extensions: [],
pagination: null,
loading: false,
error: null,
flash: null,
keyword: '',
selectedDepartments: [],
callForwardingFilter: 'any',
statusFilter: '',
loginFilter: 'any',
createdFrom: '',
createdTo: '',
registeredFrom: '',
registeredTo: '',
sortOrder: '',
activeFilters: [],
filterOptions: [
{ key: 'departments', label: 'Departments' },
{ key: 'callForwarding', label: 'Call forwarding' },
{ key: 'login', label: 'Login capability' },
{ key: 'createdRange', label: 'Created between' },
{ key: 'registeredRange', label: 'Registered between' },
],
sortOptions: [
{ value: 'created_at_desc', label: 'Created · Newest first' },
{ value: 'created_at_asc', label: 'Created · Oldest first' },
{ value: 'extension_asc', label: 'Extension · A → Z' },
{ value: 'extension_desc', label: 'Extension · Z → A' },
{ value: 'display_name_asc', label: 'Name · A → Z' },
{ value: 'display_name_desc', label: 'Name · Z → A' },
{ value: 'status_asc', label: 'Status · A → Z' },
{ value: 'status_desc', label: 'Status · Z → A' },
{ value: 'registered_at_desc', label: 'Registered · Latest first' },
{ value: 'registered_at_asc', label: 'Registered · Oldest first' },
],
page: 1,
perPage: 20,
processing: {
toggle: null,
delete: null,
},
init() {
this.departments = (this.filtersRaw.departments || []).map((item) => ({
id: item.id,
name: item.name || item.display_name || item.label || `Department #${item.id}`,
}));
this.statuses = (this.filtersRaw.statuses || [])
.map((value) => (typeof value === 'string' ? value : String(value || '')))
.map((value) => value.trim())
.filter((value, index, self) => value.length > 0 && self.indexOf(value) === index)
.sort((a, b) => a.localeCompare(b));
this.sortOrder = this.defaultSort();
this.syncActiveFilters();
this.fetchExtensions();
},
resetFilters() {
this.keyword = '';
this.selectedDepartments = [];
this.callForwardingFilter = 'any';
this.statusFilter = '';
this.loginFilter = 'any';
this.createdFrom = '';
this.createdTo = '';
this.registeredFrom = '';
this.registeredTo = '';
this.sortOrder = this.defaultSort();
this.activeFilters = [];
this.applyFilters();
},
buildParams() {
let department_ids = (this.selectedDepartments || [])
.map((id) => parseInt(id, 10))
.filter((id) => Number.isFinite(id));
const callForwarding = this.callForwardingFilter === 'enabled'
? true
: this.callForwardingFilter === 'disabled'
? false
: undefined;
const loginAllowed = this.loginFilter === 'allowed'
? true
: this.loginFilter === 'disabled'
? false
: undefined;
return {
page: this.page,
per_page: this.perPage,
filters: {
q: this.keyword.trim() || undefined,
department_ids,
call_forwarding_enabled: callForwarding,
status: this.statusFilter || undefined,
login_allowed: loginAllowed,
created_at_from: this.createdFrom || undefined,
created_at_to: this.createdTo || undefined,
registered_at_from: this.registeredFrom || undefined,
registered_at_to: this.registeredTo || undefined,
},
sort: this.sortOrder || undefined,
}
},
async fetchExtensions() {
this.loading = true;
this.error = null;
try {
const params = this.buildParams();
const response = await fetch(`${this.listEndpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(params),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data?.message || 'Failed to load extensions');
}
const items = Array.isArray(data?.items) ? data.items : [];
const normalizedExtensions = items.map((item) => {
const base = (item && typeof item === 'object' ? item.extension : null) || {};
const departments = Array.isArray(item?.departments) ? item.departments : [];
const registrations = normalizeLocator(item?.registrations);
return {
...base,
departments,
registrations,
};
});
const perPageRaw = Number(data?.per_page);
const currentPageRaw = Number(data?.page);
const totalItemsRaw = Number(data?.total_items);
const totalPagesRaw = Number(data?.total_pages);
const perPage = Number.isFinite(perPageRaw) && perPageRaw > 0 ? perPageRaw : this.perPage;
const totalItems = Number.isFinite(totalItemsRaw) && totalItemsRaw >= 0 ? totalItemsRaw : normalizedExtensions.length;
const inferredTotalPages = Math.max(Math.ceil(totalItems / perPage), 1);
const totalPages = Number.isFinite(totalPagesRaw) && totalPagesRaw >= 1
? Math.max(Math.min(totalPagesRaw, inferredTotalPages || 1), 1)
: inferredTotalPages;
const currentPage = Number.isFinite(currentPageRaw) && currentPageRaw > 0
? Math.min(currentPageRaw, totalPages)
: Math.min(this.page || 1, totalPages);
const resultsCount = normalizedExtensions.length;
const showingFrom = resultsCount ? ((currentPage - 1) * perPage) + 1 : 0;
const showingTo = resultsCount ? Math.min(showingFrom + resultsCount - 1, totalItems) : 0;
this.extensions = normalizedExtensions;
this.pagination = {
current_page: currentPage,
per_page: perPage,
total_items: totalItems,
total_pages: totalPages,
has_prev: currentPage > 1,
has_next: currentPage < totalPages,
prev_page: currentPage > 1 ? currentPage - 1 : null,
next_page: currentPage < totalPages ? currentPage + 1 : null,
showing_from: showingFrom,
showing_to: showingTo,
};
this.perPage = perPage;
this.page = currentPage;
this.flash = null;
} catch (err) {
console.error(err);
this.error = err.message || 'Unable to load extensions';
} finally {
this.loading = false;
}
},
applyFilters() {
this.page = 1;
this.fetchExtensions();
},
applySorting() {
this.page = 1;
this.fetchExtensions();
},
goToPage(target) {
if (!this.pagination) {
return;
}
const totalPages = this.pagination.total_pages || 1;
const page = Math.min(Math.max(target, 1), totalPages);
if (page === this.page) {
return;
}
this.page = page;
this.fetchExtensions();
},
prevPage() {
if (this.pagination?.has_prev) {
this.goToPage((this.pagination?.prev_page) || (this.page - 1));
}
},
nextPage() {
if (this.pagination?.has_next) {
this.goToPage((this.pagination?.next_page) || (this.page + 1));
}
},
formatDefault(value, fallback = '—') {
if (value === undefined || value === null || value === '') {
return fallback;
}
return value;
},
formatDate(value) {
if (!value) {
return '—';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString();
},
loginAllowed(extension) {
return !(extension?.login_disabled);
},
publicCallsAllowed(extension) {
return !!(extension?.allow_guest_calls);
},
forwardingLabel(extension) {
const mode = (extension?.call_forwarding_mode || 'none').toLowerCase();
switch (mode) {
case 'always':
return 'Always';
case 'when_busy':
return 'When busy';
case 'when_not_answered':
return 'When not answered';
default:
return 'None';
}
},
forwardingDestination(extension) {
return extension?.call_forwarding_destination || '';
},
resolveDepartments(extension) {
const raw = extension?.departments || extension?.metadata?.departments || [];
if (!Array.isArray(raw)) {
return [];
}
return raw
.map((entry) => {
if (typeof entry === 'string') {
return entry;
}
if (entry && typeof entry === 'object') {
return entry.name || entry.display_name || entry.label || entry.title || '';
}
return '';
})
.filter(Boolean);
},
statusMeta(status) {
const normalized = (status || '').toLowerCase();
if (normalized === 'online') {
return {
label: 'Online',
badge: 'bg-emerald-50 text-emerald-600 ring-1 ring-emerald-100',
dot: 'bg-emerald-400',
};
}
if (normalized === 'ringing' || normalized === 'idle') {
return {
label: status || 'Idle',
badge: 'bg-amber-50 text-amber-600 ring-1 ring-amber-100',
dot: 'bg-amber-400',
};
}
return {
label: status || 'Unknown',
badge: 'bg-slate-100 text-slate-600 ring-1 ring-slate-200',
dot: 'bg-slate-400',
};
},
statusLabel(value) {
const normalized = (value || '').trim();
if (!normalized) {
return 'Unknown';
}
return normalized
.split(/[_\s]+/)
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
.join(' ');
},
statusTabs() {
return this.statuses.map((value) => ({
value,
label: this.statusLabel(value),
}));
},
selectStatusTab(value) {
if (this.statusFilter === value) {
return;
}
this.statusFilter = value;
this.applyFilters();
},
filterOptionLabel(key) {
const found = this.filterOptions.find((item) => item.key === key);
return found ? found.label : key;
},
formatFilterValue(key) {
switch (key) {
case 'departments': {
if (!this.selectedDepartments.length) {
return 'Any department';
}
const names = this.selectedDepartments
.map((id) => this.departments.find((dept) => String(dept.id) === String(id)))
.filter(Boolean)
.map((dept) => dept.name);
return names.length ? names.join(', ') : `${this.selectedDepartments.length} selected`;
}
case 'callForwarding': {
if (this.callForwardingFilter === 'enabled') {
return 'Enabled';
}
if (this.callForwardingFilter === 'disabled') {
return 'Disabled';
}
return 'Any state';
}
case 'login': {
if (this.loginFilter === 'allowed') {
return 'Allowed';
}
if (this.loginFilter === 'disabled') {
return 'Disabled';
}
return 'Any state';
}
case 'createdRange': {
return this.formatDateRange(this.createdFrom, this.createdTo);
}
case 'registeredRange': {
return this.formatDateRange(this.registeredFrom, this.registeredTo);
}
default:
return '';
}
},
formatDateRange(from, to) {
if (!from && !to) {
return 'Any time';
}
if (from && to) {
return `${this.formatDateInput(from)} → ${this.formatDateInput(to)}`;
}
if (from) {
return `From ${this.formatDateInput(from)}`;
}
return `Until ${this.formatDateInput(to)}`;
},
formatDateInput(value) {
if (!value) {
return '—';
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return value;
}
return parsed.toLocaleString();
},
addFilter(event) {
const key = event?.target?.value;
if (!key) {
return;
}
if (!this.activeFilters.includes(key)) {
this.activeFilters.push(key);
}
if (event && event.target) {
event.target.value = '';
}
},
removeFilter(key) {
this.activeFilters = this.activeFilters.filter((item) => item !== key);
switch (key) {
case 'departments':
this.selectedDepartments = [];
break;
case 'callForwarding':
this.callForwardingFilter = 'any';
break;
case 'login':
this.loginFilter = 'any';
break;
case 'createdRange':
this.createdFrom = '';
this.createdTo = '';
break;
case 'registeredRange':
this.registeredFrom = '';
this.registeredTo = '';
break;
default:
break;
}
this.applyFilters();
},
syncActiveFilters() {
const defaults = [];
if (this.selectedDepartments.length) {
defaults.push('departments');
}
if (this.callForwardingFilter !== 'any') {
defaults.push('callForwarding');
}
if (this.loginFilter !== 'any') {
defaults.push('login');
}
if (this.createdFrom || this.createdTo) {
defaults.push('createdRange');
}
if (this.registeredFrom || this.registeredTo) {
defaults.push('registeredRange');
}
this.activeFilters = defaults;
},
defaultSort() {
return 'created_at_desc';
},
detailUrl(id) {
return `${this.basePath}/extensions/${id}`;
},
async toggleLogin(extension) {
if (!extension) {
return;
}
this.processing.toggle = extension.id;
this.error = null;
try {
const nextDisabled = this.loginAllowed(extension);
const body = new URLSearchParams();
body.set('login_disabled', nextDisabled ? 'true' : 'false');
const response = await fetch(this.detailUrl(extension.id), {
method: 'PATCH',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
},
body: body.toString(),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data?.message || 'Failed to update extension');
}
extension.login_disabled = nextDisabled;
this.flash = nextDisabled ? 'Login disabled for this extension.' : 'Login enabled for this extension.';
} catch (err) {
console.error(err);
this.error = err.message || 'Failed to toggle login state';
this.fetchExtensions();
} finally {
this.processing.toggle = null;
}
},
confirmDelete(extension) {
if (!extension) {
return;
}
window.dispatchEvent(new CustomEvent('console:confirm', {
detail: {
title: 'Delete extension',
message: `Delete extension ${extension.extension}? This action cannot be undone.`,
confirmLabel: 'Delete',
cancelLabel: 'Cancel',
destructive: true,
onConfirm: () => this.deleteExtension(extension),
},
}));
},
async deleteExtension(extension) {
this.processing.delete = extension.id;
this.error = null;
try {
const response = await fetch(this.detailUrl(extension.id), {
method: 'DELETE',
headers: {
'Accept': 'application/json',
},
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data?.message || 'Failed to delete extension');
}
this.flash = 'Extension deleted successfully.';
await this.fetchExtensions();
} catch (err) {
console.error(err);
this.error = err.message || 'Failed to delete extension';
} finally {
this.processing.delete = null;
}
},
remainingTtl(record) {
if (!record || typeof record !== 'object') {
return 0;
}
const expires = Number(record.expires) || 0;
const age = Number(record.age_seconds) || 0;
return Math.max(expires - age, 0);
},
formatDuration(value) {
const seconds = Math.max(Number(value) || 0, 0);
if (seconds < 1) {
return '0s';
}
if (seconds < 60) {
return `${Math.round(seconds)}s`;
}
const minutes = Math.floor(seconds / 60);
const remSeconds = Math.round(seconds % 60);
if (minutes < 60) {
return remSeconds ? `${minutes}m ${remSeconds}s` : `${minutes}m`;
}
const hours = Math.floor(minutes / 60);
const remMinutes = minutes % 60;
if (hours < 24) {
return remMinutes ? `${hours}h ${remMinutes}m` : `${hours}h`;
}
const days = Math.floor(hours / 24);
const remHours = hours % 24;
return remHours ? `${days}d ${remHours}h` : `${days}d`;
},
formatRelativeSeconds(value) {
const seconds = Number(value);
if (!Number.isFinite(seconds) || seconds <= 0) {
return 'moments ago';
}
if (seconds < 60) {
return `${Math.round(seconds)}s ago`;
}
if (seconds < 3600) {
const minutes = Math.floor(seconds / 60);
const rem = Math.round(seconds % 60);
return rem ? `${minutes}m ${rem}s ago` : `${minutes}m ago`;
}
if (seconds < 86400) {
const hours = Math.floor(seconds / 3600);
const remMinutes = Math.floor((seconds % 3600) / 60);
return remMinutes ? `${hours}h ${remMinutes}m ago` : `${hours}h ago`;
}
const days = Math.floor(seconds / 86400);
const remHours = Math.floor((seconds % 86400) / 3600);
return remHours ? `${days}d ${remHours}h ago` : `${days}d ago`;
},
primaryRegistration(extension) {
const info = extension?.registrations;
if (!info || !info.available) {
return null;
}
if (!Array.isArray(info.records) || !info.records.length) {
return null;
}
return info.records[0];
},
hasActiveRegistration(extension) {
return !!this.primaryRegistration(extension);
},
registrationDevice(extension) {
const record = this.primaryRegistration(extension);
if (!record) {
return 'Unknown device';
}
if (record.user_agent) {
return record.user_agent;
}
if (record.contact) {
return record.contact;
}
if (record.destination) {
return record.destination;
}
if (record.registered_aor) {
return record.registered_aor;
}
return record.aor || 'Unknown device';
},
registrationStatus(extension) {
const record = this.primaryRegistration(extension);
if (!record) {
if (extension?.registered_at) {
return `Last seen ${this.formatDate(extension.registered_at)}`;
}
return 'Awaiting registration';
}
const ttl = this.formatDuration(this.remainingTtl(record));
const seen = this.formatRelativeSeconds(record.age_seconds);
return `${seen} • TTL ${ttl}`;
},
registrationError(extension) {
const info = extension?.registrations;
return info?.error || null;
},
};
});
});
</script>
{% endblock %}