Generische Unterstützung für CTI: Click-to-dial
authorMoritz Bunkus <m.bunkus@linet-services.de>
Tue, 24 Jun 2014 11:15:43 +0000 (13:15 +0200)
committerMoritz Bunkus <m.bunkus@linet-services.de>
Tue, 24 Jun 2014 15:11:03 +0000 (17:11 +0200)
19 files changed:
SL/CTI.pm [new file with mode: 0644]
SL/Controller/CTI.pm [new file with mode: 0644]
bin/mozilla/ct.pl
config/kivitendo.conf.default
css/kivitendo/main.css
css/lx-office-erp/main.css
image/icons/16x16/Program--Internal Phone List.png [new symlink]
image/icons/16x16/phone.png [new file with mode: 0644]
js/kivi.CustomerVendor.js
locale/de/all
menus/erp.ini
t/cti/call_link.t [new file with mode: 0644]
t/cti/sanitize_number.t [new file with mode: 0644]
templates/webpages/admin/edit_user.html
templates/webpages/cti/calling.html [new file with mode: 0644]
templates/webpages/cti/list_internal_extensions.html [new file with mode: 0644]
templates/webpages/customer_vendor/form.html
templates/webpages/customer_vendor/tabs/contacts.html
templates/webpages/customer_vendor/tabs/shipto.html

diff --git a/SL/CTI.pm b/SL/CTI.pm
new file mode 100644 (file)
index 0000000..8605ff1
--- /dev/null
+++ b/SL/CTI.pm
@@ -0,0 +1,52 @@
+package SL::CTI;
+
+use strict;
+
+use String::ShellQuote;
+
+use SL::MoreCommon qw(uri_encode);
+
+sub call {
+  my ($class, %params) = @_;
+
+  my $config           = $::lx_office_conf{cti}  || {};
+  my $command          = $config->{dial_command} || die $::locale->text('Dial command missing in kivitendo configuration\'s [cti] section');
+  my $external_prefix  = $params{internal} ? '' : ($config->{external_prefix} // '');
+
+  my %command_args     = (
+    phone_extension    => $::myconfig{phone_extension} || die($::locale->text('Phone extension missing in user configuration')),
+    phone_password     => $::myconfig{phone_password}  || die($::locale->text('Phone password missing in user configuration')),
+    number             => $external_prefix . $class->sanitize_number(%params),
+  );
+
+  foreach my $key (keys %command_args) {
+    my $value = shell_quote($command_args{$key});
+    $command  =~ s{<\% ${key} \%>}{$value}gx;
+  }
+
+  return `$command`;
+}
+
+sub call_link {
+  my ($class, %params) = @_;
+
+  return "controller.pl?action=CTI/call&number=" . uri_encode($class->sanitize_number(number => $params{number})) . ($params{internal} ? '&internal=1' : '');
+}
+
+sub sanitize_number {
+  my ($class, %params) = @_;
+
+  my $config           = $::lx_office_conf{cti} || {};
+  my $idp              = $config->{international_dialing_prefix} // '00';
+
+  my $number           = $params{number} // '';
+  $number              =~ s/[^0-9+\.-]//g;                                     # delete unsupported characters
+  my $countrycode      = $number =~ s/^(?: $idp | \+ ) ( \d{2} )//x ? $1 : ''; # TODO: countrycodes can have more or less than 2 digits
+  $number              =~ s/^0//x if $countrycode;                             # kill non standard optional zero after global identifier
+
+  return '' unless $number;
+
+  return ($countrycode ? $idp . $countrycode : '') . $number;
+}
+
+1;
diff --git a/SL/Controller/CTI.pm b/SL/Controller/CTI.pm
new file mode 100644 (file)
index 0000000..24e3a84
--- /dev/null
@@ -0,0 +1,66 @@
+package SL::Controller::CTI;
+
+use strict;
+
+use SL::CTI;
+use SL::DB::AuthUserConfig;
+use SL::Helper::Flash;
+use SL::Locale::String;
+
+use parent qw(SL::Controller::Base);
+
+use Rose::Object::MakeMethods::Generic (
+  'scalar --get_set_init' => [ qw(internal_extensions) ],
+);
+
+sub action_call {
+  my ($self) = @_;
+
+  eval {
+    my $res = SL::CTI->call(number => $::form->{number}, internal => $::form->{internal});
+    flash('info', t8('Calling #1 now', $::form->{number}));
+    1;
+  } or do {
+    flash('error', $@);
+  };
+
+  $self->render('cti/calling');
+}
+
+sub action_list_internal_extensions {
+  my ($self) = @_;
+
+  $self->render('cti/list_internal_extensions', title => t8('Internal Phone List'));
+}
+
+#
+# filters
+#
+
+sub init_internal_extensions {
+  my ($self) = @_;
+
+  my $user_configs = SL::DB::Manager::AuthUserConfig->get_all(
+    where => [
+      cfg_key      => 'phone_extension',
+      '!cfg_value' => undef,
+      '!cfg_value' => '',
+    ],
+    with_objects => [ qw(user) ],
+  );
+
+  my %users;
+  foreach my $config (@{ $user_configs }) {
+    $users{$config->user_id} ||= {
+      name            => $config->user->get_config_value('name') || $config->user->login,
+      phone_extension => $config->cfg_value,
+      call_link       => SL::CTI->call_link(number => $config->cfg_value, internal => 1),
+    };
+  }
+
+  return [
+    sort { lc($a->{name}) cmp lc($b->{name}) } values %users
+  ];
+}
+
+1;
index 0793a44..b5b83c1 100644 (file)
@@ -48,6 +48,7 @@
 use POSIX qw(strftime);
 
 use SL::CT;
+use SL::CTI;
 use SL::CVar;
 use SL::Request qw(flatten);
 use SL::DB::Business;
@@ -268,6 +269,11 @@ sub list_names {
     my $column                = $ref->{formtype} eq 'invoice' ? 'invnumber' : $ref->{formtype} eq 'order' ? 'ordnumber' : 'quonumber';
     $row->{$column}->{data}   = $ref->{$column};
 
+    if (my $number = SL::CTI->sanitize_number(number => $ref->{phone})) {
+      $row->{phone}->{link}       = SL::CTI->call_link(number => $number);
+      $row->{phone}->{link_class} = 'cti_call_action';
+    }
+
     $report->add_data($row);
   }
 
@@ -392,6 +398,13 @@ sub list_contacts {
       $row->{$_}->{link} = 'mailto:' . E($ref->{$_}) if $ref->{$_};
     }
 
+    for (qw(cp_phone1 cp_phone2 cp_mobile1)) {
+      next unless my $number = SL::CTI->sanitize_number(number => $ref->{$_});
+
+      $row->{$_}->{link}       = SL::CTI->call_link(number => $number);
+      $row->{$_}->{link_class} = 'cti_call_action';
+    }
+
     $report->add_data($row);
   }
 
index e6849a5..25a8d4c 100644 (file)
@@ -314,3 +314,24 @@ auto_reload_resources = 0
 
 # If set to 1 each exception will include a full stack backtrace.
 backtrace_on_die = 0
+
+[cti]
+# If you want phone numbers to be clickable then this must be set to a
+# command that does the actually dialing. Within this command three
+# variables are replaced before it is executed:
+#
+# 1. <%phone_extension%> and <%phone_password%> are taken from the user
+#    configuration (changeable in the admin interface).
+# 2. <%number%> is the number to dial. It has already been sanitized
+#    and formatted correctly regarding e.g. the international dialing
+#    prefix.
+#
+# The following is an example that works with the OpenUC telephony
+# server:
+# dial_command = curl --insecure -X PUT https://<%phone_extension%>:<%phone_password%>@IP.AD.DR.ESS:8443/sipxconfig/rest/my/call/<%number%>
+dial_command =
+# If you need to dial something before the actual number then set
+# external_prefix to it.
+external_prefix = 0
+# The prefix for international calls (numbers starting with +).
+international_dialing_prefix = 00
index 3b85993..6fa432d 100644 (file)
@@ -443,3 +443,15 @@ div.ppp_line span.ppp_block_sellprice {
 span.toggle_selected {
   font-weight: bold;
 }
+
+/* CTI */
+a.cti_call_action {
+  display: inline-block;
+  padding-left: 18px;
+  height: 16px;
+  position: relative;
+  top: 2px;
+  vertical-align: center;
+  background-image: url(../../image/icons/16x16/phone.png);
+  background-repeat: no-repeat;
+}
index f42c872..ab71226 100644 (file)
@@ -494,3 +494,15 @@ div.ppp_line span.ppp_block_sellprice {
 span.toggle_selected {
   font-weight: bold;
 }
+
+/* CTI */
+a.cti_call_action {
+  display: inline-block;
+  padding-left: 18px;
+  height: 16px;
+  position: relative;
+  top: 2px;
+  vertical-align: center;
+  background-image: url(../../image/icons/16x16/phone.png);
+  background-repeat: no-repeat;
+}
diff --git a/image/icons/16x16/Program--Internal Phone List.png b/image/icons/16x16/Program--Internal Phone List.png
new file mode 120000 (symlink)
index 0000000..282275d
--- /dev/null
@@ -0,0 +1 @@
+phone.png
\ No newline at end of file
diff --git a/image/icons/16x16/phone.png b/image/icons/16x16/phone.png
new file mode 100644 (file)
index 0000000..f189191
Binary files /dev/null and b/image/icons/16x16/phone.png differ
index 59c0735..bfef730 100644 (file)
@@ -160,4 +160,44 @@ namespace('kivi.CustomerVendor', function(ns) {
     var url = "common.pl?INPUT_ENCODING=UTF-8&action=show_history&longdescription=&input_name="+ encodeURIComponent(id);
     window.open(url, "_new_generic", parm);
   };
+
+  this.update_dial_action = function($input) {
+    var $action = $('#' + $input.prop('id') + '-dial-action');
+
+    if (!$action)
+      return true;
+
+    var number = $input.val().replace(/\s+/g, '');
+    if (number == '')
+      $action.hide();
+    else
+      $action.prop('href', 'controller.pl?action=CTI/call&number=' + encodeURIComponent(number)).show();
+
+    return true;
+  };
+
+  this.init_dial_action = function(input) {
+    if ($('#_cti_enabled').val() != 1)
+      return false;
+
+    var $input    = $(input);
+    var action_id = $input.prop('id') + '-dial-action';
+
+    if (!$('#' + action_id).size()) {
+      var $action = $('<a href="" id="' + action_id + '" class="cti_call_action" target="_blank" tabindex="-1"></a>');
+      $input.wrap('<span nobr></span>').after($action);
+
+      $input.change(function() { kivi.CustomerVendor.update_dial_action($input); });
+    }
+
+    kivi.CustomerVendor.update_dial_action($input);
+
+    return true;
+  };
 });
+
+function local_reinit_widgets() {
+  $('#cv_phone,#shipto_shiptophone,#contact_cp_phone1,#contact_cp_phone2,#contact_cp_mobile1,#contact_cp_mobile2').each(function(idx, elt) {
+    kivi.CustomerVendor.init_dial_action($(elt));
+  });
+}
index 191f9f6..4d6c3c4 100755 (executable)
@@ -408,7 +408,9 @@ $self->{texts} = {
   'CSV import: parts and services' => 'CSV-Import: Waren und Dienstleistungen',
   'CSV import: projects'        => 'CSV-Import: Projekte',
   'CSV import: shipping addresses' => 'CSV-Import: Lieferadressen',
+  'CTI settings'                => 'CTI-Einstellungen',
   'Calculate'                   => 'Berechnen',
+  'Calling #1 now'              => 'Wähle jetzt #1',
   'Can not create that quantity with current stock' => 'Diese Anzahl kann mit dem gegenwärtigen Lagerbestand nicht hergestellt werden.',
   'Cancel'                      => 'Abbrechen',
   'Cancel Accounts Payables Transaction' => 'Kreditorenbuchung stornieren',
@@ -803,6 +805,7 @@ $self->{texts} = {
   'Destination warehouse and bin' => 'Ziellager und -lagerplatz',
   'Detail view'                 => 'Detailanzeige',
   'Details (one letter abbreviation)' => 'D',
+  'Dial command missing in kivitendo configuration\'s [cti] section' => 'Wählbefehl fehlt im Abschnitt [cti] der kivitendo-Konfiguration',
   'Difference'                  => 'Differenz',
   'Dimensions'                  => 'Abmessungen',
   'Directory'                   => 'Verzeichnis',
@@ -1265,6 +1268,7 @@ $self->{texts} = {
   'Interest'                    => 'Zinsen',
   'Interest Rate'               => 'Zinssatz',
   'Internal Notes'              => 'Interne Bemerkungen',
+  'Internal Phone List'         => 'Interne Telefonliste',
   'Internal comment'            => 'Interne Bemerkungen',
   'Internet'                    => 'Internet',
   'Introduction of clients'     => 'Einführung von Mandanten',
@@ -1535,6 +1539,7 @@ $self->{texts} = {
   'No file has been uploaded yet.' => 'Es wurde noch keine Datei hochgeladen.',
   'No function blocks have been created yet.' => 'Es wurden noch keine Funktionsblöcke angelegt.',
   'No groups have been created yet.' => 'Es wurden noch keine Gruppen angelegt.',
+  'No internal phone extensions have been configured yet.' => 'Es wurden noch keine internen Durchwahlen konfiguriert.',
   'No or an unknown authenticantion module specified in "config/kivitendo.conf".' => 'Es wurde kein oder ein unbekanntes Authentifizierungsmodul in "config/kivitendo.conf" angegeben.',
   'No part was found matching the search parameters.' => 'Es wurde kein Artikel gefunden, auf den die Suchparameter zutreffen.',
   'No payment term has been created yet.' => 'Es wurden noch keine Zahlungsbedingungen angelegt.',
@@ -1733,6 +1738,10 @@ $self->{texts} = {
   'Person'                      => 'Person',
   'Personal settings'           => 'Pers&ouml;nliche Einstellungen',
   'Phone'                       => 'Telefon',
+  'Phone extension'             => 'Durchwahl',
+  'Phone extension missing in user configuration' => 'Durchwahl fehlt in der Benutzerkonfiguration',
+  'Phone password'              => 'Telefonpasswort',
+  'Phone password missing in user configuration' => 'Telefonpasswort fehlt in der Benutzerkonfiguration',
   'Phone1'                      => 'Telefon 1 ',
   'Phone2'                      => 'Telefon 2',
   'Pick List'                   => 'Sammelliste',
index 637ac64..3297621 100644 (file)
@@ -787,6 +787,10 @@ action=Employee/list
 module=am.pl
 action=config
 
+[Program--Internal Phone List]
+module=controller.pl
+action=CTI/list_internal_extensions
+
 [Program--Version]
 module=login.pl
 action=company_logo
diff --git a/t/cti/call_link.t b/t/cti/call_link.t
new file mode 100644 (file)
index 0000000..ce76239
--- /dev/null
@@ -0,0 +1,22 @@
+use Test::More tests => 9;
+
+use strict;
+use lib 't';
+use utf8;
+
+use_ok 'SL::CTI';
+
+{
+  no warnings 'once';
+  $::lx_office_conf{cti}->{international_dialing_prefix} = '00';
+}
+
+is SL::CTI->call_link(number => '0371 5347 620'),        'controller.pl?action=CTI/call&number=03715347620';
+is SL::CTI->call_link(number => '0049(0)421-22232 22'),  'controller.pl?action=CTI/call&number=0049421-2223222';
+is SL::CTI->call_link(number => '+49(0)421-22232 22'),   'controller.pl?action=CTI/call&number=0049421-2223222';
+is SL::CTI->call_link(number => 'Tel: +49 40 809064 0'), 'controller.pl?action=CTI/call&number=0049408090640';
+
+is SL::CTI->call_link(number => '0371 5347 620',        internal => 1), 'controller.pl?action=CTI/call&number=03715347620&internal=1';
+is SL::CTI->call_link(number => '0049(0)421-22232 22',  internal => 1), 'controller.pl?action=CTI/call&number=0049421-2223222&internal=1';
+is SL::CTI->call_link(number => '+49(0)421-22232 22',   internal => 1), 'controller.pl?action=CTI/call&number=0049421-2223222&internal=1';
+is SL::CTI->call_link(number => 'Tel: +49 40 809064 0', internal => 1), 'controller.pl?action=CTI/call&number=0049408090640&internal=1';
diff --git a/t/cti/sanitize_number.t b/t/cti/sanitize_number.t
new file mode 100644 (file)
index 0000000..639e716
--- /dev/null
@@ -0,0 +1,17 @@
+use Test::More tests => 5;
+
+use strict;
+use lib 't';
+use utf8;
+
+use_ok 'SL::CTI';
+
+{
+  no warnings 'once';
+  $::lx_office_conf{cti}->{international_dialing_prefix} = '00';
+}
+
+is SL::CTI->sanitize_number(number => '0371 5347 620'),        '03715347620';
+is SL::CTI->sanitize_number(number => '0049(0)421-22232 22'),  '0049421-2223222';
+is SL::CTI->sanitize_number(number => '+49(0)421-22232 22'),   '0049421-2223222';
+is SL::CTI->sanitize_number(number => 'Tel: +49 40 809064 0'), '0049408090640';
index 94db96b..3a8f129 100644 (file)
   </tr>
  </table>
 
+ <h2>[%- LxERP.t8("CTI settings") %]</h2>
+
+ <table>
+  <tr>
+   <th align="right">[% LxERP.t8("Phone extension") %]</th>
+   <td>[% L.input_tag("user.config_values.phone_extension", props.phone_extension) %]</td>
+  </tr>
+
+  <tr>
+   <th align="right">[% LxERP.t8("Phone password") %]</th>
+   <td>[% L.input_tag("user.config_values.phone_password", props.phone_password) %]</td>
+  </tr>
+ </table>
+
  <h2>[%- LxERP.t8("Access to clients") %]</h2>
 
 [% IF SELF.all_clients.size %]
diff --git a/templates/webpages/cti/calling.html b/templates/webpages/cti/calling.html
new file mode 100644 (file)
index 0000000..60bbcd7
--- /dev/null
@@ -0,0 +1,4 @@
+<body>
+[% PROCESS 'common/flash.html' %]
+</body>
+</html>
diff --git a/templates/webpages/cti/list_internal_extensions.html b/templates/webpages/cti/list_internal_extensions.html
new file mode 100644 (file)
index 0000000..1a5dfaf
--- /dev/null
@@ -0,0 +1,25 @@
+[%- USE HTML %][%- USE LxERP -%]
+<body>
+
+<h1>[% HTML.escape(title) %]</h1>
+
+[% IF !SELF.internal_extensions.size %]
+ <p>[% LxERP.t8("No internal phone extensions have been configured yet.") %]</p>
+
+[% ELSE %]
+ <table>
+  <tr class="listheading">
+   <th>[% LxERP.t8("Name") %]</th>
+   <th>[% LxERP.t8("Phone extension") %]</th>
+  </tr>
+
+  [%- FOREACH extension = SELF.internal_extensions %]
+   <tr class="listrow">
+    <td>[% HTML.escape(extension.name) %]</td>
+    <td><a href="[% HTML.escape(extension.call_link) %]" class="cti_call_action">[% HTML.escape(extension.phone_extension) %]</a></td>
+   </tr>
+  [%- END %]
+ </table>
+[% END %]
+</body>
+</html>
index ad2da05..e56b3e5 100644 (file)
@@ -2,6 +2,8 @@
 [%- USE LxERP %]
 [%- USE L %]
 
+[% L.hidden_tag('_cti_enabled', !!LXCONFIG.cti.dial_command) %]
+
 [% cv_cvars = SELF.cv.cvars_by_config %]
 
 <form method="post" action="controller.pl">
index aca56cd..8d03d8b 100644 (file)
@@ -18,7 +18,7 @@
             empty_title = LxERP.t8('New contact'),
             value_key = 'cp_id',
             title_key = 'full_name',
-            onchange = "kivi.CustomerVendor.selectContact({onFormSet: function(){contactsMapWidget.testInputs();}});",
+            onchange = "kivi.CustomerVendor.selectContact({onFormSet: function(){ contactsMapWidget.testInputs(); local_reinit_widgets(); }});",
           )
         %]
       </td>
index 8f49977..ded32f3 100644 (file)
@@ -16,7 +16,7 @@
              title_key = 'displayable_id',
              with_empty = 1,
              empty_title = LxERP.t8('New shipto'),
-             onchange = "kivi.CustomerVendor.selectShipto({onFormSet: function(){shiptoMapWidget.testInputs();}});",
+             onchange = "kivi.CustomerVendor.selectShipto({onFormSet: function(){ shiptoMapWidget.testInputs(); local_reinit_widgets(); }});",
            )
         %]
       </td>