2026-04-10 08:22:12 +00:00
import { DeesElement , customElement , html , css , cssManager , state , type TemplateResult } from '../plugins.js' ;
import { deesCatalog } from '../plugins.js' ;
import { appState , type IAppState } from '../state/appstate.js' ;
import { viewHostCss } from './shared/index.js' ;
2026-04-14 18:52:13 +00:00
import type { IIncomingNumberConfig , ISipRoute } from '../../ts/config.ts' ;
2026-04-10 08:22:12 +00:00
const { DeesModal , DeesToast } = deesCatalog ;
2026-04-14 18:52:13 +00:00
const CUSTOM_REGEX_KEY = '__custom_regex__' ;
const CUSTOM_COUNTRY_CODE_KEY = '__custom_country_code__' ;
function clone < T > ( value : T ) : T {
return JSON . parse ( JSON . stringify ( value ) ) ;
}
function createRoute ( ) : ISipRoute {
return {
id : ` route- ${ Date . now ( ) } ` ,
name : '' ,
priority : 0 ,
enabled : true ,
match : { direction : 'outbound' } ,
action : { } ,
2026-04-10 08:22:12 +00:00
} ;
2026-04-14 18:52:13 +00:00
}
function createIncomingNumber ( ) : IIncomingNumberConfig {
return {
id : ` incoming- ${ Date . now ( ) } ` ,
label : '' ,
mode : 'single' ,
countryCode : '+49' ,
areaCode : '' ,
localNumber : '' ,
2026-04-10 08:22:12 +00:00
} ;
}
2026-04-14 18:52:13 +00:00
function normalizeCountryCode ( value? : string ) : string {
const trimmed = ( value || '' ) . trim ( ) ;
if ( ! trimmed ) return '' ;
const digits = trimmed . replace ( /\D/g , '' ) ;
return digits ? ` + ${ digits } ` : '' ;
}
function normalizeNumberPart ( value? : string ) : string {
return ( value || '' ) . replace ( /\D/g , '' ) ;
}
function escapeRegex ( value : string ) : string {
return value . replace ( /[.*+?^${}()|[\]\\]/g , '\\$&' ) ;
}
function anyDigits ( length : number ) : string {
if ( length <= 0 ) return '' ;
if ( length === 1 ) return '\\d' ;
return ` \\ d{ ${ length } } ` ;
}
function digitClass ( start : number , end : number ) : string {
if ( start === end ) return String ( start ) ;
if ( end === start + 1 ) return ` [ ${ start } ${ end } ] ` ;
return ` [ ${ start } - ${ end } ] ` ;
}
function unique ( values : string [ ] ) : string [ ] {
return Array . from ( new Set ( values ) ) ;
}
function buildRangeAlternatives ( min : string , max : string ) : string [ ] {
if ( min . length !== max . length || ! /^\d*$/ . test ( min ) || ! /^\d*$/ . test ( max ) ) {
return [ ] ;
}
if ( min . length === 0 ) {
return [ '' ] ;
}
if ( min === max ) {
return [ min ] ;
}
if ( /^0+$/ . test ( min ) && /^9+$/ . test ( max ) ) {
return [ anyDigits ( min . length ) ] ;
}
let index = 0 ;
while ( index < min . length && min [ index ] === max [ index ] ) {
index += 1 ;
}
const prefix = min . slice ( 0 , index ) ;
const lowDigit = Number ( min [ index ] ) ;
const highDigit = Number ( max [ index ] ) ;
const restLength = min . length - index - 1 ;
const values : string [ ] = [ ] ;
values . push (
. . . buildRangeAlternatives ( min . slice ( index + 1 ) , '9' . repeat ( restLength ) ) . map (
( suffix ) = > ` ${ prefix } ${ lowDigit } ${ suffix } ` ,
) ,
) ;
if ( highDigit - lowDigit > 1 ) {
values . push ( ` ${ prefix } ${ digitClass ( lowDigit + 1 , highDigit - 1 ) } ${ anyDigits ( restLength ) } ` ) ;
}
values . push (
. . . buildRangeAlternatives ( '0' . repeat ( restLength ) , max . slice ( index + 1 ) ) . map (
( suffix ) = > ` ${ prefix } ${ highDigit } ${ suffix } ` ,
) ,
) ;
return unique ( values ) ;
}
function buildRangePattern ( min : string , max : string ) : string {
const alternatives = buildRangeAlternatives ( min , max ) . filter ( Boolean ) ;
if ( ! alternatives . length ) return '' ;
if ( alternatives . length === 1 ) return alternatives [ 0 ] ;
return ` (?: ${ alternatives . join ( '|' ) } ) ` ;
}
function validateLocalRange ( start? : string , end? : string ) : string | null {
const normalizedStart = normalizeNumberPart ( start ) ;
const normalizedEnd = normalizeNumberPart ( end ) ;
if ( ! normalizedStart || ! normalizedEnd ) {
return 'Range start and end are required' ;
}
if ( normalizedStart . length !== normalizedEnd . length ) {
return 'Range start and end must have the same length' ;
}
if ( normalizedStart > normalizedEnd ) {
return 'Range start must be less than or equal to range end' ;
}
return null ;
}
function getIncomingNumberPattern ( entry : IIncomingNumberConfig ) : string {
if ( entry . mode === 'regex' ) {
return ( entry . pattern || '' ) . trim ( ) ;
}
const countryCode = normalizeCountryCode ( entry . countryCode ) ;
const areaCode = normalizeNumberPart ( entry . areaCode ) ;
const localNumber = normalizeNumberPart ( entry . localNumber ) ;
if ( ! countryCode || ! areaCode || ! localNumber ) {
return '' ;
}
let localPattern = escapeRegex ( localNumber ) ;
if ( entry . mode === 'range' ) {
const rangeEnd = normalizeNumberPart ( entry . rangeEnd ) ;
if ( ! rangeEnd || validateLocalRange ( localNumber , rangeEnd ) ) {
return '' ;
}
localPattern = buildRangePattern ( localNumber , rangeEnd ) ;
}
const countryDigits = countryCode . slice ( 1 ) ;
const nationalArea = areaCode . startsWith ( '0' ) ? areaCode : ` 0 ${ areaCode } ` ;
const internationalArea = areaCode . replace ( /^0+/ , '' ) || areaCode ;
return ` /^(?: \\ + ${ countryDigits } ${ internationalArea } | ${ nationalArea } ) ${ localPattern } $ / ` ;
}
function describeIncomingNumber ( entry : IIncomingNumberConfig ) : string {
if ( entry . mode === 'regex' ) {
return ( entry . pattern || '' ) . trim ( ) || '(regex missing)' ;
}
const countryCode = normalizeCountryCode ( entry . countryCode ) || '+?' ;
const areaCode = normalizeNumberPart ( entry . areaCode ) || '?' ;
const localNumber = normalizeNumberPart ( entry . localNumber ) || '?' ;
if ( entry . mode === 'range' ) {
const rangeEnd = normalizeNumberPart ( entry . rangeEnd ) || '?' ;
return ` ${ countryCode } / ${ areaCode } / ${ localNumber } - ${ rangeEnd } ` ;
}
return ` ${ countryCode } / ${ areaCode } / ${ localNumber } ` ;
}
function describeRouteAction ( route : ISipRoute ) : string {
const action = route . action ;
if ( route . match . direction === 'outbound' ) {
const parts : string [ ] = [ ] ;
if ( action . provider ) parts . push ( ` -> ${ action . provider } ` ) ;
if ( action . failoverProviders ? . length ) parts . push ( ` (failover: ${ action . failoverProviders . join ( ', ' ) } ) ` ) ;
if ( action . stripPrefix ) parts . push ( ` strip: ${ action . stripPrefix } ` ) ;
if ( action . prependPrefix ) parts . push ( ` prepend: ${ action . prependPrefix } ` ) ;
return parts . join ( ' ' ) ;
}
const parts : string [ ] = [ ] ;
if ( action . ivrMenuId ) {
parts . push ( ` ivr: ${ action . ivrMenuId } ` ) ;
} else if ( action . targets ? . length ) {
parts . push ( ` ring: ${ action . targets . join ( ', ' ) } ` ) ;
} else {
parts . push ( 'ring: all devices' ) ;
}
if ( action . ringBrowsers ) parts . push ( '+ browsers' ) ;
if ( action . voicemailBox ) parts . push ( ` vm: ${ action . voicemailBox } ` ) ;
if ( action . noAnswerTimeout ) parts . push ( ` timeout: ${ action . noAnswerTimeout } s ` ) ;
return parts . join ( ' ' ) ;
}
2026-04-10 08:22:12 +00:00
@customElement ( 'sipproxy-view-routes' )
export class SipproxyViewRoutes extends DeesElement {
@state ( ) accessor appData : IAppState = appState . getState ( ) ;
@state ( ) accessor config : any = null ;
public static styles = [
cssManager . defaultStyles ,
viewHostCss ,
css `
. view - section { margin - bottom : 24px ; }
` ,
] ;
2026-04-14 16:35:54 +00:00
async connectedCallback ( ) : Promise < void > {
await super . connectedCallback ( ) ;
2026-04-14 18:52:13 +00:00
appState . subscribe ( ( state ) = > { this . appData = state ; } ) ;
2026-04-14 16:35:54 +00:00
await this . loadConfig ( ) ;
2026-04-10 08:22:12 +00:00
}
2026-04-14 18:52:13 +00:00
private async loadConfig ( ) : Promise < void > {
2026-04-10 08:22:12 +00:00
try {
this . config = await appState . apiGetConfig ( ) ;
} catch {
2026-04-14 18:52:13 +00:00
// Show empty state.
2026-04-10 08:22:12 +00:00
}
}
2026-04-14 18:52:13 +00:00
private getRoutes ( ) : ISipRoute [ ] {
return this . config ? . routing ? . routes || [ ] ;
}
private getIncomingNumbers ( ) : IIncomingNumberConfig [ ] {
return this . config ? . incomingNumbers || [ ] ;
}
private getCountryCodeOptions ( extraCode? : string ) : Array < { option : string ; key : string } > {
const codes = new Set < string > ( [ '+49' ] ) ;
for ( const entry of this . getIncomingNumbers ( ) ) {
const countryCode = normalizeCountryCode ( entry . countryCode ) ;
if ( countryCode ) codes . add ( countryCode ) ;
}
const normalizedExtra = normalizeCountryCode ( extraCode ) ;
if ( normalizedExtra ) codes . add ( normalizedExtra ) ;
return [
. . . Array . from ( codes ) . sort ( ( a , b ) = > a . localeCompare ( b ) ) . map ( ( code ) = > ( { option : code , key : code } ) ) ,
{ option : 'Custom' , key : CUSTOM_COUNTRY_CODE_KEY } ,
] ;
}
private getProviderLabel ( providerId? : string ) : string {
if ( ! providerId ) return '(any)' ;
const provider = ( this . config ? . providers || [ ] ) . find ( ( item : any ) = > item . id === providerId ) ;
return provider ? . displayName || providerId ;
}
private findIncomingNumberForRoute ( route : ISipRoute ) : IIncomingNumberConfig | undefined {
if ( route . match . direction !== 'inbound' || ! route . match . numberPattern ) {
return undefined ;
}
return this . getIncomingNumbers ( ) . find ( ( entry ) = > {
if ( getIncomingNumberPattern ( entry ) !== route . match . numberPattern ) {
return false ;
}
if ( entry . providerId && route . match . sourceProvider && entry . providerId !== route . match . sourceProvider ) {
return false ;
}
return true ;
} ) ;
}
private countRoutesUsingIncomingNumber ( entry : IIncomingNumberConfig ) : number {
const pattern = getIncomingNumberPattern ( entry ) ;
return this . getRoutes ( ) . filter ( ( route ) = > {
if ( route . match . direction !== 'inbound' || route . match . numberPattern !== pattern ) {
return false ;
}
if ( entry . providerId && route . match . sourceProvider && entry . providerId !== route . match . sourceProvider ) {
return false ;
}
return true ;
} ) . length ;
}
private async saveRoutes ( routes : ISipRoute [ ] ) : Promise < boolean > {
const result = await appState . apiSaveConfig ( { routing : { routes } } ) ;
if ( ! result . ok ) return false ;
await this . loadConfig ( ) ;
return true ;
}
private async saveIncomingNumbers ( incomingNumbers : IIncomingNumberConfig [ ] ) : Promise < boolean > {
const result = await appState . apiSaveConfig ( { incomingNumbers } ) ;
if ( ! result . ok ) return false ;
await this . loadConfig ( ) ;
return true ;
}
2026-04-10 08:22:12 +00:00
public render ( ) : TemplateResult {
2026-04-14 18:52:13 +00:00
const routes = this . getRoutes ( ) ;
const incomingNumbers = [ . . . this . getIncomingNumbers ( ) ] . sort ( ( a , b ) = > a . label . localeCompare ( b . label ) ) ;
const sortedRoutes = [ . . . routes ] . sort ( ( a , b ) = > b . priority - a . priority ) ;
2026-04-10 08:22:12 +00:00
const tiles : any [ ] = [
{
2026-04-14 18:52:13 +00:00
id : 'incoming-numbers' ,
title : 'Incoming Numbers' ,
value : incomingNumbers.length ,
type : 'number' ,
icon : 'lucide:phoneIncoming' ,
description : 'Managed DIDs and regexes' ,
} ,
{
id : 'total-routes' ,
2026-04-10 08:22:12 +00:00
title : 'Total Routes' ,
value : routes.length ,
type : 'number' ,
icon : 'lucide:route' ,
2026-04-14 18:52:13 +00:00
description : ` ${ routes . filter ( ( route ) = > route . enabled ) . length } active ` ,
2026-04-10 08:22:12 +00:00
} ,
{
2026-04-14 18:52:13 +00:00
id : 'inbound-routes' ,
title : 'Inbound Routes' ,
value : routes.filter ( ( route ) = > route . match . direction === 'inbound' ) . length ,
2026-04-10 08:22:12 +00:00
type : 'number' ,
2026-04-14 18:52:13 +00:00
icon : 'lucide:phoneCall' ,
description : 'Incoming call routing rules' ,
2026-04-10 08:22:12 +00:00
} ,
{
2026-04-14 18:52:13 +00:00
id : 'outbound-routes' ,
title : 'Outbound Routes' ,
value : routes.filter ( ( route ) = > route . match . direction === 'outbound' ) . length ,
2026-04-10 08:22:12 +00:00
type : 'number' ,
icon : 'lucide:phoneOutgoing' ,
2026-04-14 18:52:13 +00:00
description : 'Outgoing call routing rules' ,
2026-04-10 08:22:12 +00:00
} ,
] ;
return html `
< div class = "view-section" >
< dees - statsgrid .tiles = $ { tiles } .minTileWidth = $ { 220 } .gap = $ { 16 } > < / d e e s - s t a t s g r i d >
< / div >
2026-04-14 18:52:13 +00:00
< div class = "view-section" >
< dees - table
heading1 = "Incoming Numbers"
heading2 = "${incomingNumbers.length} managed"
dataName = "incomingNumbers"
. data = $ { incomingNumbers }
. rowKey = $ { 'id' }
. columns = $ { this . getIncomingNumberColumns ( ) }
. dataActions = $ { this . getIncomingNumberActions ( ) }
> < / d e e s - t a b l e >
< / div >
2026-04-10 08:22:12 +00:00
< div class = "view-section" >
< dees - table
heading1 = "Call Routes"
heading2 = "${routes.length} configured"
dataName = "routes"
2026-04-14 18:52:13 +00:00
. data = $ { sortedRoutes }
2026-04-10 08:22:12 +00:00
. rowKey = $ { 'id' }
2026-04-14 18:52:13 +00:00
. columns = $ { this . getRouteColumns ( ) }
. dataActions = $ { this . getRouteActions ( ) }
2026-04-10 08:22:12 +00:00
> < / d e e s - t a b l e >
< / div >
` ;
}
2026-04-14 18:52:13 +00:00
private getIncomingNumberColumns() {
return [
{
key : 'label' ,
header : 'Label' ,
sortable : true ,
} ,
{
key : 'providerId' ,
header : 'Provider' ,
renderer : ( _value : string | undefined , row : IIncomingNumberConfig ) = >
html ` <span> ${ this . getProviderLabel ( row . providerId ) } </span> ` ,
} ,
{
key : 'mode' ,
header : 'Type' ,
renderer : ( value : string ) = > {
const label = value === 'regex' ? 'regex' : value === 'range' ? 'range' : 'number' ;
const color = value === 'regex' ? '#f59e0b' : value === 'range' ? '#60a5fa' : '#4ade80' ;
const bg = value === 'regex' ? '#422006' : value === 'range' ? '#1e3a5f' : '#1a3c2a' ;
return html ` <span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:.7rem;font-weight:600;text-transform:uppercase;background: ${ bg } ;color: ${ color } "> ${ label } </span> ` ;
} ,
} ,
{
key : 'match' ,
header : 'Definition' ,
renderer : ( _value : unknown , row : IIncomingNumberConfig ) = >
html ` <span style="font-family:'JetBrains Mono',monospace;font-size:.82rem"> ${ describeIncomingNumber ( row ) } </span> ` ,
} ,
{
key : 'pattern' ,
header : 'Generated Pattern' ,
renderer : ( _value : unknown , row : IIncomingNumberConfig ) = >
html ` <span style="font-family:'JetBrains Mono',monospace;font-size:.78rem;color:#94a3b8"> ${ getIncomingNumberPattern ( row ) || '(incomplete)' } </span> ` ,
} ,
{
key : 'usage' ,
header : 'Used By' ,
renderer : ( _value : unknown , row : IIncomingNumberConfig ) = > {
const count = this . countRoutesUsingIncomingNumber ( row ) ;
return html ` <span style="font-weight:600;color: ${ count > 0 ? '#e2e8f0' : '#64748b' } "> ${ count } route ${ count === 1 ? '' : 's' } </span> ` ;
} ,
} ,
] ;
}
private getIncomingNumberActions() {
return [
{
name : 'Add Number' ,
iconName : 'lucide:plus' as any ,
type : [ 'header' ] as any ,
actionFunc : async ( ) = > {
await this . openIncomingNumberEditor ( null ) ;
} ,
} ,
{
name : 'Edit' ,
iconName : 'lucide:pencil' as any ,
type : [ 'inRow' ] as any ,
actionFunc : async ( { item } : { item : IIncomingNumberConfig } ) = > {
await this . openIncomingNumberEditor ( item ) ;
} ,
} ,
{
name : 'Delete' ,
iconName : 'lucide:trash2' as any ,
type : [ 'inRow' ] as any ,
actionFunc : async ( { item } : { item : IIncomingNumberConfig } ) = > {
const incomingNumbers = this . getIncomingNumbers ( ) . filter ( ( entry ) = > entry . id !== item . id ) ;
if ( await this . saveIncomingNumbers ( incomingNumbers ) ) {
DeesToast . success ( 'Incoming number deleted' ) ;
} else {
DeesToast . error ( 'Failed to delete incoming number' ) ;
}
} ,
} ,
] ;
}
private getRouteColumns() {
2026-04-10 08:22:12 +00:00
return [
{
key : 'priority' ,
header : 'Priority' ,
sortable : true ,
2026-04-14 18:52:13 +00:00
renderer : ( value : number ) = > html ` <span style="font-weight:600;color:#94a3b8"> ${ value } </span> ` ,
2026-04-10 08:22:12 +00:00
} ,
{
key : 'name' ,
header : 'Name' ,
sortable : true ,
} ,
{
key : 'match' ,
header : 'Direction' ,
2026-04-14 18:52:13 +00:00
renderer : ( _value : unknown , row : ISipRoute ) = > {
const direction = row . match . direction ;
const color = direction === 'inbound' ? '#60a5fa' : '#4ade80' ;
const bg = direction === 'inbound' ? '#1e3a5f' : '#1a3c2a' ;
return html ` <span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:.7rem;font-weight:600;text-transform:uppercase;background: ${ bg } ;color: ${ color } "> ${ direction } </span> ` ;
2026-04-10 08:22:12 +00:00
} ,
} ,
{
key : 'match' ,
header : 'Match' ,
2026-04-14 18:52:13 +00:00
renderer : ( _value : unknown , row : ISipRoute ) = > {
2026-04-10 08:22:12 +00:00
const parts : string [ ] = [ ] ;
2026-04-14 18:52:13 +00:00
if ( row . match . sourceProvider ) parts . push ( ` provider: ${ row . match . sourceProvider } ` ) ;
const incomingNumber = this . findIncomingNumberForRoute ( row ) ;
if ( incomingNumber ) {
parts . push ( ` did: ${ incomingNumber . label } ` ) ;
} else if ( row . match . numberPattern ) {
parts . push ( ` number: ${ row . match . numberPattern } ` ) ;
}
if ( row . match . callerPattern ) parts . push ( ` caller: ${ row . match . callerPattern } ` ) ;
2026-04-10 08:22:12 +00:00
if ( ! parts . length ) return html ` <span style="color:#64748b;font-style:italic">catch-all</span> ` ;
return html ` <span style="font-family:'JetBrains Mono',monospace;font-size:.82rem"> ${ parts . join ( ', ' ) } </span> ` ;
} ,
} ,
{
key : 'action' ,
header : 'Action' ,
2026-04-14 18:52:13 +00:00
renderer : ( _value : unknown , row : ISipRoute ) = >
html ` <span style="font-family:'JetBrains Mono',monospace;font-size:.82rem"> ${ describeRouteAction ( row ) } </span> ` ,
2026-04-10 08:22:12 +00:00
} ,
{
key : 'enabled' ,
header : 'Status' ,
2026-04-14 18:52:13 +00:00
renderer : ( value : boolean ) = > {
const color = value ? '#4ade80' : '#71717a' ;
const bg = value ? '#1a3c2a' : '#3f3f46' ;
return html ` <span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:.7rem;font-weight:600;text-transform:uppercase;background: ${ bg } ;color: ${ color } "> ${ value ? 'Active' : 'Disabled' } </span> ` ;
2026-04-10 08:22:12 +00:00
} ,
} ,
] ;
}
2026-04-14 18:52:13 +00:00
private getRouteActions() {
2026-04-10 08:22:12 +00:00
return [
{
2026-04-14 18:52:13 +00:00
name : 'Add Route' ,
2026-04-10 08:22:12 +00:00
iconName : 'lucide:plus' as any ,
type : [ 'header' ] as any ,
actionFunc : async ( ) = > {
await this . openRouteEditor ( null ) ;
} ,
} ,
{
name : 'Edit' ,
iconName : 'lucide:pencil' as any ,
type : [ 'inRow' ] as any ,
actionFunc : async ( { item } : { item : ISipRoute } ) = > {
await this . openRouteEditor ( item ) ;
} ,
} ,
{
name : 'Toggle' ,
iconName : 'lucide:toggleLeft' as any ,
type : [ 'inRow' ] as any ,
actionFunc : async ( { item } : { item : ISipRoute } ) = > {
2026-04-14 18:52:13 +00:00
const routes = this . getRoutes ( ) . map ( ( route ) = >
route . id === item . id ? { . . . route , enabled : ! route . enabled } : route ,
2026-04-10 08:22:12 +00:00
) ;
2026-04-14 18:52:13 +00:00
if ( await this . saveRoutes ( routes ) ) {
2026-04-10 08:22:12 +00:00
DeesToast . success ( item . enabled ? 'Route disabled' : 'Route enabled' ) ;
2026-04-14 18:52:13 +00:00
} else {
DeesToast . error ( 'Failed to update route' ) ;
2026-04-10 08:22:12 +00:00
}
} ,
} ,
{
name : 'Delete' ,
iconName : 'lucide:trash2' as any ,
type : [ 'inRow' ] as any ,
actionFunc : async ( { item } : { item : ISipRoute } ) = > {
2026-04-14 18:52:13 +00:00
const routes = this . getRoutes ( ) . filter ( ( route ) = > route . id !== item . id ) ;
if ( await this . saveRoutes ( routes ) ) {
2026-04-10 08:22:12 +00:00
DeesToast . success ( 'Route deleted' ) ;
2026-04-14 18:52:13 +00:00
} else {
DeesToast . error ( 'Failed to delete route' ) ;
2026-04-10 08:22:12 +00:00
}
} ,
} ,
] ;
}
2026-04-14 18:52:13 +00:00
private async openIncomingNumberEditor ( existing : IIncomingNumberConfig | null ) : Promise < void > {
const providers = this . config ? . providers || [ ] ;
const formData = existing ? clone ( existing ) : createIncomingNumber ( ) ;
const countryCodeOptions = this . getCountryCodeOptions ( formData . countryCode ) ;
let definitionType : 'number' | 'regex' = formData . mode === 'regex' ? 'regex' : 'number' ;
let selectedCountryCode = countryCodeOptions . some ( ( option ) = > option . key === normalizeCountryCode ( formData . countryCode ) )
? normalizeCountryCode ( formData . countryCode )
: CUSTOM_COUNTRY_CODE_KEY ;
let customCountryCode = selectedCountryCode === CUSTOM_COUNTRY_CODE_KEY
? normalizeCountryCode ( formData . countryCode )
: '' ;
let modalRef : any ;
const applySelectedCountryCode = ( ) = > {
formData . countryCode = selectedCountryCode === CUSTOM_COUNTRY_CODE_KEY
? normalizeCountryCode ( customCountryCode )
: selectedCountryCode ;
} ;
const rerender = ( ) = > {
if ( ! modalRef ) return ;
modalRef . content = renderContent ( ) ;
modalRef . requestUpdate ( ) ;
} ;
const renderContent = ( ) = > {
applySelectedCountryCode ( ) ;
const generatedPattern = getIncomingNumberPattern ( formData ) ;
return html `
< div style = "display:flex;flex-direction:column;gap:12px;padding:4px 0;" >
< dees - input - text
. key = $ { 'label' } . label = $ { 'Label' } . value = $ { formData . label }
@input = $ { ( e : Event ) = > { formData . label = ( e . target as any ) . value ; } }
> < / d e e s - i n p u t - t e x t >
< dees - input - dropdown
. key = $ { 'providerId' } . label = $ { 'Provider' }
. selectedOption = $ { formData . providerId
? { option : this.getProviderLabel ( formData . providerId ) , key : formData.providerId }
: { option : '(any)' , key : '' } }
. options = $ { [
{ option : '(any)' , key : '' } ,
. . . providers . map ( ( provider : any ) = > ( { option : provider.displayName || provider . id , key : provider.id } ) ) ,
] }
@selectedOption = $ { ( e : CustomEvent ) = > { formData . providerId = e . detail . key || undefined ; } }
> < / d e e s - i n p u t - d r o p d o w n >
< dees - input - dropdown
. key = $ { 'definitionType' } . label = $ { 'Definition Type' }
. selectedOption = $ { definitionType === 'regex'
? { option : 'Custom regex' , key : 'regex' }
: { option : 'Phone number' , key : 'number' } }
. options = $ { [
{ option : 'Custom regex' , key : 'regex' } ,
{ option : 'Phone number' , key : 'number' } ,
] }
@selectedOption = $ { ( e : CustomEvent ) = > {
definitionType = e . detail . key ;
formData . mode = definitionType === 'regex' ? 'regex' : formData . mode === 'range' ? 'range' : 'single' ;
if ( definitionType === 'number' && ! formData . countryCode ) {
formData . countryCode = '+49' ;
selectedCountryCode = '+49' ;
}
rerender ( ) ;
} }
> < / d e e s - i n p u t - d r o p d o w n >
$ { definitionType === 'regex' ? html `
< dees - input - text
. key = $ { 'pattern' }
. label = $ { 'Number Pattern' }
. description = $ { 'Free-form pattern. Use this only when the structured number fields are not enough.' }
. value = $ { formData . pattern || '' }
@input = $ { ( e : Event ) = > { formData . pattern = ( e . target as any ) . value || undefined ; } }
> < / d e e s - i n p u t - t e x t >
` : html `
< dees - input - dropdown
. key = $ { 'countryCode' } . label = $ { 'Country Code' }
. selectedOption = $ { countryCodeOptions . find ( ( option ) = > option . key === selectedCountryCode ) || countryCodeOptions [ 0 ] }
. options = $ { countryCodeOptions }
@selectedOption = $ { ( e : CustomEvent ) = > {
selectedCountryCode = e . detail . key || '+49' ;
rerender ( ) ;
} }
> < / d e e s - i n p u t - d r o p d o w n >
$ { selectedCountryCode === CUSTOM_COUNTRY_CODE_KEY ? html `
< dees - input - text
. key = $ { 'customCountryCode' } . label = $ { 'Custom Country Code' }
. description = $ { 'Example: +49' }
. value = $ { customCountryCode }
@input = $ { ( e : Event ) = > {
customCountryCode = ( e . target as any ) . value || '' ;
formData . countryCode = normalizeCountryCode ( customCountryCode ) ;
} }
> < / d e e s - i n p u t - t e x t >
` : ''}
< dees - input - text
. key = $ { 'areaCode' } . label = $ { 'Area Code' }
. description = $ { 'Example: 421 or 0421' }
. value = $ { formData . areaCode || '' }
@input = $ { ( e : Event ) = > { formData . areaCode = ( e . target as any ) . value || undefined ; } }
> < / d e e s - i n p u t - t e x t >
< dees - input - text
. key = $ { 'localNumber' } . label = $ { formData . mode === 'range' ? 'Number Start' : 'Number' }
. description = $ { 'Example: 219694' }
. value = $ { formData . localNumber || '' }
@input = $ { ( e : Event ) = > { formData . localNumber = ( e . target as any ) . value || undefined ; } }
> < / d e e s - i n p u t - t e x t >
< dees - input - checkbox
. key = $ { 'rangeEnabled' } . label = $ { 'Use range' } . value = $ { formData . mode === 'range' }
@newValue = $ { ( e : CustomEvent ) = > {
formData . mode = e . detail ? 'range' : 'single' ;
rerender ( ) ;
} }
> < / d e e s - i n p u t - c h e c k b o x >
$ { formData . mode === 'range' ? html `
< dees - input - text
. key = $ { 'rangeEnd' } . label = $ { 'Number End' }
. description = $ { 'Range applies to the local number part only' }
. value = $ { formData . rangeEnd || '' }
@input = $ { ( e : Event ) = > { formData . rangeEnd = ( e . target as any ) . value || undefined ; } }
> < / d e e s - i n p u t - t e x t >
` : ''}
< div style = "padding:10px 12px;border:1px solid #334155;border-radius:8px;background:#0f172a;" >
< div style = "font-size:.72rem;color:#94a3b8;text-transform:uppercase;letter-spacing:.04em;margin-bottom:6px;font-weight:600;" > Generated Regex < / div >
< div style = "font-family:'JetBrains Mono',monospace;font-size:.8rem;color:#e2e8f0;word-break:break-all;" > $ { generatedPattern || '(complete the fields to generate a pattern)' } < / div >
< / div >
` }
< / div >
` ;
} ;
modalRef = await DeesModal . createAndShow ( {
heading : existing ? ` Edit Incoming Number: ${ existing . label } ` : 'New Incoming Number' ,
2026-04-10 08:22:12 +00:00
width : 'small' ,
showCloseButton : true ,
2026-04-14 18:52:13 +00:00
content : renderContent ( ) ,
menuOptions : [
{
name : 'Cancel' ,
iconName : 'lucide:x' ,
action : async ( modal : any ) = > { modal . destroy ( ) ; } ,
} ,
{
name : 'Save' ,
iconName : 'lucide:check' ,
action : async ( modal : any ) = > {
const next = clone ( formData ) ;
next . label = next . label . trim ( ) ;
if ( ! next . label ) {
DeesToast . error ( 'Label is required' ) ;
return ;
}
if ( definitionType === 'regex' ) {
next . mode = 'regex' ;
next . pattern = ( next . pattern || '' ) . trim ( ) ;
if ( ! next . pattern ) {
DeesToast . error ( 'Number pattern is required for custom regex' ) ;
return ;
}
delete next . countryCode ;
delete next . areaCode ;
delete next . localNumber ;
delete next . rangeEnd ;
delete next . number ;
delete next . rangeStart ;
} else {
next . countryCode = selectedCountryCode === CUSTOM_COUNTRY_CODE_KEY
? normalizeCountryCode ( customCountryCode )
: selectedCountryCode ;
next . areaCode = normalizeNumberPart ( next . areaCode ) ;
next . localNumber = normalizeNumberPart ( next . localNumber ) ;
next . rangeEnd = normalizeNumberPart ( next . rangeEnd ) ;
next . mode = next . mode === 'range' ? 'range' : 'single' ;
if ( ! next . countryCode || ! next . areaCode || ! next . localNumber ) {
DeesToast . error ( 'Country code, area code, and number are required' ) ;
return ;
}
if ( next . mode === 'range' ) {
const validationError = validateLocalRange ( next . localNumber , next . rangeEnd ) ;
if ( validationError ) {
DeesToast . error ( validationError ) ;
return ;
}
} else {
delete next . rangeEnd ;
}
delete next . pattern ;
delete next . number ;
delete next . rangeStart ;
}
if ( ! next . providerId ) delete next . providerId ;
const incomingNumbers = [ . . . this . getIncomingNumbers ( ) ] ;
const index = incomingNumbers . findIndex ( ( entry ) = > entry . id === next . id ) ;
if ( index >= 0 ) incomingNumbers [ index ] = next ;
else incomingNumbers . push ( next ) ;
if ( await this . saveIncomingNumbers ( incomingNumbers ) ) {
modal . destroy ( ) ;
DeesToast . success ( existing ? 'Incoming number updated' : 'Incoming number created' ) ;
} else {
DeesToast . error ( 'Failed to save incoming number' ) ;
}
} ,
} ,
] ,
} ) ;
}
private async openRouteEditor ( existing : ISipRoute | null ) : Promise < void > {
const providers = this . config ? . providers || [ ] ;
const devices = this . config ? . devices || [ ] ;
const voiceboxes = this . config ? . voiceboxes || [ ] ;
const ivrMenus = this . config ? . ivr ? . menus || [ ] ;
const incomingNumbers = this . getIncomingNumbers ( ) ;
const formData = existing ? clone ( existing ) : createRoute ( ) ;
let selectedIncomingNumberId = this . findIncomingNumberForRoute ( formData ) ? . id || CUSTOM_REGEX_KEY ;
let modalRef : any ;
const rerender = ( ) = > {
if ( ! modalRef ) return ;
modalRef . content = renderContent ( ) ;
modalRef . requestUpdate ( ) ;
} ;
const renderContent = ( ) = > {
const incomingNumberOptions = [
{ option : 'Custom regex' , key : CUSTOM_REGEX_KEY } ,
. . . incomingNumbers . map ( ( entry ) = > ( {
option : ` ${ entry . label } | ${ describeIncomingNumber ( entry ) } ` ,
key : entry.id ,
} ) ) ,
] ;
return html `
2026-04-10 08:22:12 +00:00
< div style = "display:flex;flex-direction:column;gap:12px;padding:4px 0;" >
< dees - input - text
. key = $ { 'name' } . label = $ { 'Route Name' } . value = $ { formData . name }
@input = $ { ( e : Event ) = > { formData . name = ( e . target as any ) . value ; } }
> < / d e e s - i n p u t - t e x t >
< dees - input - dropdown
. key = $ { 'direction' } . label = $ { 'Direction' }
. selectedOption = $ { formData . match . direction === 'inbound'
? { option : 'inbound' , key : 'inbound' }
: { option : 'outbound' , key : 'outbound' } }
. options = $ { [
{ option : 'inbound' , key : 'inbound' } ,
{ option : 'outbound' , key : 'outbound' } ,
] }
2026-04-14 18:52:13 +00:00
@selectedOption = $ { ( e : CustomEvent ) = > {
formData . match . direction = e . detail . key ;
rerender ( ) ;
} }
2026-04-10 08:22:12 +00:00
> < / d e e s - i n p u t - d r o p d o w n >
< dees - input - text
. key = $ { 'priority' } . label = $ { 'Priority (higher = matched first)' }
. value = $ { String ( formData . priority ) }
@input = $ { ( e : Event ) = > { formData . priority = parseInt ( ( e . target as any ) . value , 10 ) || 0 ; } }
> < / d e e s - i n p u t - t e x t >
< dees - input - checkbox
. key = $ { 'enabled' } . label = $ { 'Enabled' } . value = $ { formData . enabled }
@newValue = $ { ( e : CustomEvent ) = > { formData . enabled = e . detail ; } }
> < / d e e s - i n p u t - c h e c k b o x >
< div style = "margin-top:8px;padding-top:12px;border-top:1px solid #334155;" >
< div style = "font-size:.75rem;color:#94a3b8;text-transform:uppercase;letter-spacing:.04em;margin-bottom:8px;font-weight:600;" > Match Criteria < / div >
< / div >
2026-04-14 18:52:13 +00:00
$ { formData . match . direction === 'inbound' ? html `
< dees - input - dropdown
. key = $ { 'sourceProvider' } . label = $ { 'Source Provider' }
. selectedOption = $ { formData . match . sourceProvider
? { option : this.getProviderLabel ( formData . match . sourceProvider ) , key : formData.match.sourceProvider }
: { option : '(any)' , key : '' } }
. options = $ { [
{ option : '(any)' , key : '' } ,
. . . providers . map ( ( provider : any ) = > ( { option : provider.displayName || provider . id , key : provider.id } ) ) ,
] }
@selectedOption = $ { ( e : CustomEvent ) = > { formData . match . sourceProvider = e . detail . key || undefined ; } }
> < / d e e s - i n p u t - d r o p d o w n >
2026-04-10 08:22:12 +00:00
2026-04-14 18:52:13 +00:00
< dees - input - dropdown
. key = $ { 'incomingNumberId' } . label = $ { 'DID' }
. selectedOption = $ { incomingNumberOptions . find ( ( option ) = > option . key === selectedIncomingNumberId ) || incomingNumberOptions [ 0 ] }
. options = $ { incomingNumberOptions }
@selectedOption = $ { ( e : CustomEvent ) = > {
selectedIncomingNumberId = e . detail . key || CUSTOM_REGEX_KEY ;
const selectedIncomingNumber = incomingNumbers . find ( ( entry ) = > entry . id === selectedIncomingNumberId ) ;
if ( selectedIncomingNumber ) {
formData . match . numberPattern = getIncomingNumberPattern ( selectedIncomingNumber ) || undefined ;
if ( selectedIncomingNumber . providerId ) {
formData . match . sourceProvider = selectedIncomingNumber . providerId ;
}
}
rerender ( ) ;
} }
> < / d e e s - i n p u t - d r o p d o w n >
2026-04-10 08:22:12 +00:00
2026-04-14 18:52:13 +00:00
$ { selectedIncomingNumberId === CUSTOM_REGEX_KEY ? html `
< dees - input - text
. key = $ { 'numberPattern' }
. label = $ { 'Number Pattern' }
. description = $ { 'Free-form inbound number pattern used only for Custom regex routes' }
. value = $ { formData . match . numberPattern || '' }
@input = $ { ( e : Event ) = > { formData . match . numberPattern = ( e . target as any ) . value || undefined ; } }
> < / d e e s - i n p u t - t e x t >
` : html `
< div style = "padding:10px 12px;border:1px solid #334155;border-radius:8px;background:#0f172a;" >
< div style = "font-size:.72rem;color:#94a3b8;text-transform:uppercase;letter-spacing:.04em;margin-bottom:6px;font-weight:600;" > Selected Pattern < / div >
< div style = "font-family:'JetBrains Mono',monospace;font-size:.8rem;color:#e2e8f0;word-break:break-all;" > $ { formData . match . numberPattern || '(not set)' } < / div >
< / div >
` }
< dees - input - text
. key = $ { 'callerPattern' }
. label = $ { 'Caller Pattern' }
. description = $ { 'Optional caller-ID filter' }
. value = $ { formData . match . callerPattern || '' }
@input = $ { ( e : Event ) = > { formData . match . callerPattern = ( e . target as any ) . value || undefined ; } }
> < / d e e s - i n p u t - t e x t >
` : html `
< dees - input - text
. key = $ { 'numberPattern' }
. label = $ { 'Number Pattern' }
. description = $ { 'Outbound dialed-number match. Exact, prefix*, range, or /regex/' }
. value = $ { formData . match . numberPattern || '' }
@input = $ { ( e : Event ) = > { formData . match . numberPattern = ( e . target as any ) . value || undefined ; } }
> < / d e e s - i n p u t - t e x t >
` }
2026-04-10 08:22:12 +00:00
< div style = "margin-top:8px;padding-top:12px;border-top:1px solid #334155;" >
< div style = "font-size:.75rem;color:#94a3b8;text-transform:uppercase;letter-spacing:.04em;margin-bottom:8px;font-weight:600;" > Action < / div >
< / div >
2026-04-14 18:52:13 +00:00
$ { formData . match . direction === 'inbound' ? html `
< dees - input - text
. key = $ { 'targets' }
. label = $ { 'Ring Devices (comma-separated IDs)' }
. description = $ { 'Leave empty to ring all devices' }
. value = $ { ( formData . action . targets || [ ] ) . join ( ', ' ) }
@input = $ { ( e : Event ) = > {
const value = ( e . target as any ) . value . trim ( ) ;
formData . action . targets = value ? value . split ( ',' ) . map ( ( item : string ) = > item . trim ( ) ) . filter ( Boolean ) : undefined ;
} }
> < / d e e s - i n p u t - t e x t >
2026-04-10 08:22:12 +00:00
2026-04-14 18:52:13 +00:00
< dees - input - checkbox
. key = $ { 'ringBrowsers' } . label = $ { 'Ring browser clients' }
. value = $ { formData . action . ringBrowsers ? ? false }
@newValue = $ { ( e : CustomEvent ) = > { formData . action . ringBrowsers = e . detail ; } }
> < / d e e s - i n p u t - c h e c k b o x >
2026-04-10 08:22:12 +00:00
2026-04-14 18:52:13 +00:00
< dees - input - dropdown
. key = $ { 'voicemailBox' } . label = $ { 'Voicemail Box (fallback)' }
. selectedOption = $ { formData . action . voicemailBox
? { option : formData.action.voicemailBox , key : formData.action.voicemailBox }
: { option : '(none)' , key : '' } }
. options = $ { [
{ option : '(none)' , key : '' } ,
. . . voiceboxes . map ( ( voicebox : any ) = > ( { option : voicebox.id , key : voicebox.id } ) ) ,
] }
@selectedOption = $ { ( e : CustomEvent ) = > { formData . action . voicemailBox = e . detail . key || undefined ; } }
> < / d e e s - i n p u t - d r o p d o w n >
2026-04-10 08:22:12 +00:00
2026-04-14 18:52:13 +00:00
< dees - input - dropdown
. key = $ { 'ivrMenuId' } . label = $ { 'IVR Menu' }
. selectedOption = $ { formData . action . ivrMenuId
? { option : formData.action.ivrMenuId , key : formData.action.ivrMenuId }
: { option : '(none)' , key : '' } }
. options = $ { [
{ option : '(none)' , key : '' } ,
. . . ivrMenus . map ( ( menu : any ) = > ( { option : menu.name || menu . id , key : menu.id } ) ) ,
] }
@selectedOption = $ { ( e : CustomEvent ) = > { formData . action . ivrMenuId = e . detail . key || undefined ; } }
> < / d e e s - i n p u t - d r o p d o w n >
` : html `
< dees - input - dropdown
. key = $ { 'provider' } . label = $ { 'Outbound Provider' }
. selectedOption = $ { formData . action . provider
? { option : this.getProviderLabel ( formData . action . provider ) , key : formData.action.provider }
: { option : '(none)' , key : '' } }
. options = $ { [
{ option : '(none)' , key : '' } ,
. . . providers . map ( ( provider : any ) = > ( { option : provider.displayName || provider . id , key : provider.id } ) ) ,
] }
@selectedOption = $ { ( e : CustomEvent ) = > { formData . action . provider = e . detail . key || undefined ; } }
> < / d e e s - i n p u t - d r o p d o w n >
2026-04-14 16:35:54 +00:00
2026-04-14 18:52:13 +00:00
< dees - input - text
. key = $ { 'stripPrefix' } . label = $ { 'Strip Prefix' }
. value = $ { formData . action . stripPrefix || '' }
@input = $ { ( e : Event ) = > { formData . action . stripPrefix = ( e . target as any ) . value || undefined ; } }
> < / d e e s - i n p u t - t e x t >
2026-04-14 16:35:54 +00:00
2026-04-14 18:52:13 +00:00
< dees - input - text
. key = $ { 'prependPrefix' } . label = $ { 'Prepend Prefix' }
. value = $ { formData . action . prependPrefix || '' }
@input = $ { ( e : Event ) = > { formData . action . prependPrefix = ( e . target as any ) . value || undefined ; } }
> < / d e e s - i n p u t - t e x t >
` }
2026-04-10 08:22:12 +00:00
2026-04-14 18:52:13 +00:00
$ { formData . match . direction === 'inbound' && devices . length ? html `
< div style = "font-size:.78rem;color:#94a3b8;" > Known devices : $ { devices . map ( ( device : any ) = > device . id ) . join ( ', ' ) } < / div >
` : ''}
2026-04-10 08:22:12 +00:00
< / div >
2026-04-14 18:52:13 +00:00
` ;
} ;
modalRef = await DeesModal . createAndShow ( {
heading : existing ? ` Edit Route: ${ existing . name } ` : 'New Route' ,
width : 'small' ,
showCloseButton : true ,
content : renderContent ( ) ,
2026-04-10 08:22:12 +00:00
menuOptions : [
{
name : 'Cancel' ,
iconName : 'lucide:x' ,
2026-04-14 18:52:13 +00:00
action : async ( modal : any ) = > { modal . destroy ( ) ; } ,
2026-04-10 08:22:12 +00:00
} ,
{
name : 'Save' ,
iconName : 'lucide:check' ,
2026-04-14 18:52:13 +00:00
action : async ( modal : any ) = > {
const next = clone ( formData ) ;
next . name = next . name . trim ( ) ;
if ( ! next . name ) {
2026-04-10 08:22:12 +00:00
DeesToast . error ( 'Route name is required' ) ;
return ;
}
2026-04-14 18:52:13 +00:00
const selectedIncomingNumber = incomingNumbers . find ( ( entry ) = > entry . id === selectedIncomingNumberId ) ;
if ( next . match . direction === 'inbound' && selectedIncomingNumber ) {
next . match . numberPattern = getIncomingNumberPattern ( selectedIncomingNumber ) || undefined ;
if ( selectedIncomingNumber . providerId ) {
next . match . sourceProvider = selectedIncomingNumber . providerId ;
}
2026-04-10 08:22:12 +00:00
}
2026-04-14 18:52:13 +00:00
if ( ! next . match . numberPattern ) delete next . match . numberPattern ;
if ( ! next . match . callerPattern ) delete next . match . callerPattern ;
if ( ! next . match . sourceProvider ) delete next . match . sourceProvider ;
if ( ! next . match . sourceDevice ) delete next . match . sourceDevice ;
if ( ! next . action . provider ) delete next . action . provider ;
if ( ! next . action . stripPrefix ) delete next . action . stripPrefix ;
if ( ! next . action . prependPrefix ) delete next . action . prependPrefix ;
if ( ! next . action . targets ? . length ) delete next . action . targets ;
if ( ! next . action . ringBrowsers ) delete next . action . ringBrowsers ;
if ( ! next . action . voicemailBox ) delete next . action . voicemailBox ;
if ( ! next . action . ivrMenuId ) delete next . action . ivrMenuId ;
if ( ! next . action . noAnswerTimeout ) delete next . action . noAnswerTimeout ;
const routes = [ . . . this . getRoutes ( ) ] ;
const index = routes . findIndex ( ( route ) = > route . id === next . id ) ;
if ( index >= 0 ) routes [ index ] = next ;
else routes . push ( next ) ;
if ( await this . saveRoutes ( routes ) ) {
modal . destroy ( ) ;
2026-04-10 08:22:12 +00:00
DeesToast . success ( existing ? 'Route updated' : 'Route created' ) ;
} else {
DeesToast . error ( 'Failed to save route' ) ;
}
} ,
} ,
] ,
} ) ;
}
}