--- /dev/null
+package SL::Controller::TopQuickSearch;
+
+use strict;
+use parent qw(SL::Controller::Base);
+
+use SL::ClientJS;
+use SL::JSON;
+use SL::Locale::String qw(t8);
+
+use Rose::Object::MakeMethods::Generic (
+ 'scalar --get_set_init' => [ qw(module js) ],
+);
+
+my @available_modules = qw(
+ SL::Controller::TopQuickSearch::Assembly
+ SL::Controller::TopQuickSearch::Contact
+ SL::Controller::TopQuickSearch::GLTransaction
+);
+my %modules_by_name;
+
+sub action_query_autocomplete {
+ my ($self) = @_;
+
+ my $hashes = $self->module->query_autocomplete;
+
+ $self->render(\ SL::JSON::to_json($hashes), { layout => 0, type => 'json', process => 0 });
+}
+
+sub action_select_autocomplete {
+ my ($self) = @_;
+
+ my $redirect_url = $self->module->select_autocomplete;
+
+ $self->js->redirect_to($redirect_url)->render;
+}
+
+sub action_do_search {
+ my ($self) = @_;
+
+ my $redirect_url = $self->module->do_search;
+
+ if ($redirect_url) {
+ $self->js->redirect_to($redirect_url)
+ }
+
+ $self->js->render;
+}
+
+sub available_modules {
+ my ($self) = @_;
+
+ $self->require_modules;
+
+ map { $_->new } @available_modules;
+}
+
+sub active_modules {
+ grep {
+ $::auth->assert($_->auth, 1)
+ } $_[0]->available_modules
+}
+
+sub init_module {
+ my ($self) = @_;
+
+ $self->require_modules;
+
+ die t8('Need module') unless $::form->{module};
+
+ $::lxdebug->dump(0, "modules", \%modules_by_name);
+
+ die t8('Unknown module #1', $::form->{module}) unless my $class = $modules_by_name{$::form->{module}};
+
+ $::lxdebug->dump(0, "auth:", $class->auth);
+
+ $::auth->assert($class->auth);
+
+ return $class->new;
+}
+
+sub init_js {
+ SL::ClientJS->new(controller => $_[0])
+}
+
+sub require_modules {
+ my ($self) = @_;
+
+ if (!$self->{__modules_required}) {
+ for my $class (@available_modules) {
+ eval "require $class" or die $@;
+ $modules_by_name{ $class->name } = $class;
+ }
+ $self->{__modules_required} = 1;
+ }
+}
+
+1;
+
+__END__
+
+=encoding utf-8
+
+=head1 NAME
+
+SL::Controller::TopQuickSearch - Framework for pluggable quicksearch fields in the layout top header.
+
+=head1 SYNOPSIS
+
+use SL::Controller::TopQuickSearch;
+my $search = SL::Controller::TopQuickSearch->new;
+
+# in layout
+[%- FOREACH module = search.available_modules %]
+<input type='text' id='top-search-[% module.name %]'>
+[%- END %]
+
+=head1 DESCRIPTION
+
+This controller provides abstraction for different search plugins, and ensures
+that all follow a common useability scheme.
+
+Modules should be configurable, but currently are not. Diabling modules can be
+done by removing them from available_modules.
+
+=head1 BEHAVIOUR REQUIREMENTS
+
+=over 4
+
+=item *
+
+A single text input field with the html5 placeholder containing a small
+description of the target will be rendered from the plugin information.
+
+=item *
+
+On typing, the autocompletion must be enabled.
+
+=item *
+
+On C<Enter>, the search should redirect to an appropriate listing of matching
+results.
+
+If only one item matches the result, the plugin should instead redirect
+directly to the matched item.
+
+=item *
+
+Search terms should accept the broadest possible matching, and if possible with
+C<multi> parsing.
+
+=item *
+
+In case nothing is found, a visual indicator should be given, but no actual
+redirect should occur.
+
+=item *
+
+Each search must check rights and must not present a backdoor into data that
+the user should not see.
+
+=back
+
+=head1 INTERFACE
+
+Plugins need to provide:
+
+ - name
+ - localized description for config
+ - localized description for textfield
+ - autocomplete callback
+ - redirect callback
+
+the frontend will only generate urls of the forms:
+ action=TopQuickSearch/autocomplete&module=<module>&term=<term>
+ action=TopQuickSearch/search&module=<module>&term=<term>
+
+=head1 TODO
+
+ - filter available searches with auth
+ - toggling with cofiguration doesn't work yet
+
+=head1 BUGS
+
+None yet :)
+
+=head1 AUTHOR
+
+Sven Schöling E<lt>s.schoeling@linet-services.deE<gt>
+
+=cut
--- /dev/null
+package SL::Controller::TopQuickSearch::Assembly;
+
+use strict;
+use parent qw(Rose::Object);
+
+use SL::Locale::String qw(t8);
+use SL::DB::Part;
+use SL::Controller::Helper::GetModels;
+use SL::Controller::Base;
+
+use Rose::Object::MakeMethods::Generic (
+ 'scalar --get_set_init' => [ qw(parts models part) ],
+);
+
+sub auth { 'part_service_assembly_edit' }
+
+sub name { 'assembly' }
+
+sub description_config { t8('Assemblies') }
+
+sub description_field { t8('Assemblies') }
+
+sub query_autocomplete {
+ my ($self) = @_;
+
+ my $objects = $self->models->get;
+
+ [
+ map {
+ value => $_->displayable_name,
+ label => $_->displayable_name,
+ id => $_->id,
+ }, @$objects
+ ];
+}
+
+sub select_autocomplete {
+ redirect_to_part($::form->{id});
+}
+
+sub do_search {
+ my ($self) = @_;
+
+ my $objects = $self->models->get;
+
+ return !@$objects ? ()
+ : @$objects == 1 ? redirect_to_part($objects->[0]->id)
+ : redirect_to_search($::form->{term});
+}
+
+sub redirect_to_search {
+ SL::Controller::Base->new->url_for(
+ controller => 'ic.pl',
+ action => 'generate_report',
+ searchitems => 'assembly',
+ all => $_[0],
+ );
+}
+
+sub redirect_to_part {
+ SL::Controller::Base->new->url_for(
+ controller => 'ic.pl',
+ action => 'edit',
+ id => $_[0],
+ );
+}
+
+sub init_models {
+ my ($self) = @_;
+
+ SL::Controller::Helper::GetModels->new(
+ controller => $self,
+ model => 'Part',
+ source => {
+ filter => {
+ type => 'assembly',
+ 'all:substr:multi::ilike' => $::form->{term},
+ },
+ },
+ sorted => {
+ _default => {
+ by => 'partnumber',
+ dir => 1,
+ },
+ partnumber => t8('Partnumber'),
+ },
+ paginated => {
+ per_page => 10,
+ },
+ )
+}
+
+1;
--- /dev/null
+package SL::Controller::TopQuickSearch::Base;
+
+use strict;
+use parent qw(Rose::Object);
+
+sub auth { ... }
+
+sub name { ... }
+
+sub description_config { ... }
+
+sub description_field { ... }
+
+sub query_autocomplete { ... }
+
+sub select_autocomplete { ... }
+
+sub do_search { ... }
+
+1;
+
+__END__
+
+=encoding utf-8
+
+=head1 NAME
+
+SL::Controller::TopQuickSearch::Base - base interface class for quick search plugins
+
+=head1 DESCRIPTION
+
+see L<SL::Controller::TopQuickSearch>
+
+=head1 INTERFACE
+
+An implementation must provide these functions.
+
+=over 4
+
+=item C<auth>
+
+Must return a string used for access checks. Empty string or undef will mean
+unrestricted access.
+
+=item C<name>
+
+Internal name, must be plain ASCII.
+
+=item C<description_config>
+
+Localized name used in the configuration (NYI)
+
+=item C<description_field>
+
+Localized name used in the search field as hint. Should fit into an input of
+length 20.
+
+=item C<query_autocomplete>
+
+Needs to take C<term> from C<$::form> and must return an arrayref of JSON
+serializable matches fit for jquery autocomplete.
+
+=item C<select_autocomplete>
+
+Needs to take C<id> from C<$::form> and must return a redirect string to be
+used with C<SL::Controller::Base::redirect_to> pointing to a representation of
+the selected object.
+
+=item C<do_search>
+
+Needs to take C<term> from C<$::form> and must return a redirect string to be
+used with C<SL::Controller::Base::redirect_to> pointing to a representation of
+the search results. If the search will display only only one match, it should
+instead return the same result as if that object was selected directly using
+C<select_autocomplete>.
+
+=back
+
+=head1 BUGS
+
+None yet :)
+
+=head1 AUTHOR
+
+Sven Schöling E<lt>s.schoeling@linet-services.deE<gt>
+
+=cut
--- /dev/null
+package SL::Controller::TopQuickSearch::Contact;
+
+use strict;
+use parent qw(SL::Controller::TopQuickSearch::Base);
+
+use SL::Controller::CustomerVendor;
+use SL::DB::Vendor;
+use SL::DBUtils qw(selectfirst_array_query);
+use SL::Locale::String qw(t8);
+
+sub auth { 'customer_vendor_edit' }
+
+sub name { 'contact' }
+
+sub description_config { t8('Contact') }
+
+sub description_field { t8('Contacts') }
+
+sub query_autocomplete {
+ my ($self) = @_;
+
+ my $result = SL::DB::Manager::Contact->get_all(
+ query => [
+ or => [
+ cp_name => { ilike => "%$::form->{term}%" },
+ cp_givenname => { ilike => "%$::form->{term}%" },
+ cp_email => { ilike => "%$::form->{term}%" },
+ ],
+ cp_cv_id => [ \'SELECT id FROM customer UNION SELECT id FROM vendor' ],
+ ],
+ limit => 10,
+ sort_by => 'cp_name',
+ );
+
+ return [
+ map {
+ value => $_->full_name,
+ label => $_->full_name,
+ id => $_->cp_id,
+ }, @$result
+ ];
+}
+
+sub select_autocomplete {
+ my ($self) = @_;
+
+ my $contact = SL::DB::Manager::Contact->find_by(cp_id => $::form->{id});
+
+ SL::Controller::CustomerVendor->new->url_for(action => 'edit', id => $contact->cp_cv_id, db => db_for_contact($contact));
+}
+
+sub do_search {
+ my ($self) = @_;
+
+ my $results = $self->query_autocomplete;
+
+ if (@$results != 1) {
+ return SL::Controller::CustomerVendor->new->url_for(
+ controller => 'ct.pl',
+ action => 'list_contacts',
+ 'filter.status' => 'active',
+ search_term => $::form->{term},
+ );
+ } else {
+ $::form->{id} = $results->[0]{id};
+ return $self->select_autocomplete;
+ }
+}
+
+
+sub db_for_contact {
+ my ($contact) = @_;
+
+ my ($customer, $vendor) = selectfirst_array_query($::form, $::form->get_standard_dbh, <<SQL, ($contact->cp_cv_id)x2);
+ SELECT (SELECT COUNT(id) FROM customer WHERE id = ?), (SELECT COUNT(id) FROM vendor WHERE id = ?);
+SQL
+
+ die 'Contact is orphaned, cannot link to it' if !$customer && !$vendor;
+
+ $customer ? 'customer' : 'vendor';
+}
+
+# TODO: multi search
+
+1;
--- /dev/null
+package SL::Controller::TopQuickSearch::GLTransaction;
+
+use strict;
+use parent qw(Rose::Object);
+
+use SL::DB::GLTransaction;
+use SL::DB::Invoice;
+use SL::DB::PurchaseInvoice;
+use SL::DB::AccTransaction;
+use SL::Locale::String qw(t8);
+use List::Util qw(sum);
+
+sub auth { 'general_ledger' }
+
+sub name { 'gl_transction' }
+
+sub description_config { t8('GL search') }
+
+sub description_field { t8('GL search') }
+
+sub query_autocomplete {
+ my ($self, %params) = @_;
+
+ my $limit = $::form->{limit} || 40; # max number of results per type (AR/AP/GL)
+ my $term = $::form->{term} || '';
+
+ my $descriptionquery = { ilike => '%' . $term . '%' };
+ my $referencequery = { ilike => '%' . $term . '%' };
+ my $apinvnumberquery = { ilike => '%' . $term . '%' };
+ my $namequery = { ilike => '%' . $term . '%' };
+ my $arinvnumberquery = { ilike => '%' . $term };
+ # ar match is more restrictive. Left fuzzy beginning so it also matches "Storno zu $INVNUMBER"
+ # and numbers like 000123 if you only enter 123.
+ # When used in quicksearch short numbers like 1 or 11 won't match because of the
+ # ajax autocomplete minlimit of 3 characters
+
+ my (@glfilter, @arfilter, @apfilter);
+
+ push( @glfilter, (or => [ description => $descriptionquery, reference => $referencequery ] ) );
+ push( @arfilter, (or => [ invnumber => $arinvnumberquery, name => $namequery ] ) );
+ push( @apfilter, (or => [ invnumber => $apinvnumberquery, name => $namequery ] ) );
+
+ my $gls = SL::DB::Manager::GLTransaction->get_all( query => [ @glfilter ], limit => $limit, sort_by => 'transdate DESC');
+ my $ars = SL::DB::Manager::Invoice->get_all( query => [ @arfilter ], limit => $limit, sort_by => 'transdate DESC', with_objects => [ 'customer' ]);
+ my $aps = SL::DB::Manager::PurchaseInvoice->get_all(query => [ @apfilter ], limit => $limit, sort_by => 'transdate DESC', with_objects => [ 'vendor' ]);
+
+ # use the sum of all credit amounts as the "amount" of the gl transaction
+ foreach my $gl ( @$gls ) {
+ $gl->{'amount'} = sum map { $_->amount if $_->amount > 0 } @{$gl->transactions};
+ };
+
+ my $gldata = [
+ map(
+ {
+ {
+ transdate => DateTime->from_object(object => $_->transdate)->ymd(),
+ label => $_->abbreviation. ": " . $_->description . " " . $_->reference . " " . $::form->format_amount(\%::myconfig, $_->{'amount'},2). " (" . $_->transdate->to_lxoffice . ")" ,
+ id => 'gl.pl?action=edit&id=' . $_->id,
+ }
+ }
+ @{$gls}
+ ),
+ ];
+
+ my $ardata = [
+ map(
+ {
+ {
+ transdate => DateTime->from_object(object => $_->transdate)->ymd(),
+ label => $_->abbreviation . ": " . $_->invnumber . " " . $_->customer->name . " " . $::form->format_amount(\%::myconfig, $_->amount,2) . " (" . $_->transdate->to_lxoffice . ")" ,
+ id => ($_->invoice ? "is" : "ar" ) . '.pl?action=edit&id=' . $_->id,
+ }
+ }
+ @{$ars}
+ ),
+ ];
+
+ my $apdata = [
+ map(
+ {
+ {
+ transdate => DateTime->from_object(object => $_->transdate)->ymd(),
+ label => $_->abbreviation . ": " . $_->invnumber . " " . $_->vendor->name . " " . $::form->format_amount(\%::myconfig, $_->amount,2) . " (" . $_->transdate->to_lxoffice . ")" ,
+ value => "",
+ id => ($_->invoice ? "ir" : "ap" ) . '.pl?action=edit&id=' . $_->id,
+ }
+ }
+ @{$aps}
+ ),
+ ];
+
+ my $data;
+ push(@{$data},@{$gldata});
+ push(@{$data},@{$ardata});
+ push(@{$data},@{$apdata});
+
+ @$data = reverse sort { $a->{'transdate'} cmp $b->{'transdate'} } @$data;
+
+ $data;
+}
+
+sub select_autocomplete {
+ $::form->{id}
+}
+
+sub do_search {
+ my ($self) = @_;
+
+ my $results = $self->query_autocomplete;
+
+ return @$results == 1
+ ? $results->[0]{id}
+ : undef;
+}
+
+# TODO: result overview page
+
+1;
use strict;
use parent qw(SL::Layout::Base);
+use SL::Controller::TopQuickSearch;
+
sub pre_content {
my ($self) = @_;
now => DateTime->now_local,
is_fastcgi => $::dispatcher ? scalar($::dispatcher->interface_type =~ /fastcgi/i) : 0,
is_links => scalar($ENV{HTTP_USER_AGENT} =~ /links/i),
+ quick_search => SL::Controller::TopQuickSearch->new,
);
}
}
sub javascripts {
- ('jquery-ui.js', 'quicksearch_input.js') x!! $::auth->assert('customer_vendor_edit|part_service_assembly_edit', 1),
- ('jquery-ui.js', 'glquicksearch.js') x!! $::auth->assert('general_ledger', 1)
+ 'jquery-ui.js',
+ 'kivi.QuickSearch.js',
}
1;
--- /dev/null
+namespace('kivi', function(k){
+ k.QuickSearch = function($real, options) {
+ if ($real.data("quick_search"))
+ return $real.data("quick_search");
+
+ var KEY = {
+ ENTER: 13,
+ };
+ var o = $.extend({
+ limit: 20,
+ delay: 50,
+ }, options);
+
+ function send_query(action, term, id, success) {
+ var data = { module: o.module };
+ if (term != undefined) data.term = term;
+ if (id != undefined) data.id = id;
+ $.ajax($.extend(o, {
+ url: 'controller.pl?action=TopQuickSearch/' + action,
+ dataType: "json",
+ data: data,
+ success: success
+ }));
+ }
+
+ function submit_search(term) {
+ send_query('do_search', term, undefined, kivi.eval_json_result);
+ }
+
+ $real.autocomplete({
+ source: function(req, rsp) {
+ send_query('query_autocomplete', req.term, undefined, function (data){ rsp(data) });
+ },
+ select: function(event, ui) {
+ send_query('select_autocomplete', undefined, ui.item.id, kivi.eval_json_result);
+ },
+ });
+ $real.keydown(function(event){
+ if (event.which == KEY.ENTER) {
+ if ($real.val() != '') {
+ submit_search($real.val());
+ }
+ }
+ });
+
+ $real.data('quick_search', {});
+ }
+});
+
+$(function(){
+ $('input[id^=top-quick-search]').each(function(_,e){
+ kivi.QuickSearch($(e), { module: $(e).attr('module') })
+ })
+})
<span class="frame-header-element frame-header-left">
[<a href="controller.pl?action=LoginScreen/user_login" target="_blank" title="[% 'Open a further kivitendo window or tab' | $T8 %]">[% 'New window/tab' | $T8 %]</a>]
[<a href="JavaScript:top.print();" title="[% 'Hardcopy' | $T8 %]">[% 'Print' | $T8 %]</a>]
-[%- IF AUTH.assert('part_service_assembly_edit', 1) %]
- [<input name="frame_header_parts_search" id="frame_header_parts_search" placeholder="[% 'Search parts' | $T8 %]" size="14">]
-[%- END %]
-[%- IF AUTH.assert('customer_vendor_edit|customer_vendor_edit_all', 1) %]
- [<input name="frame_header_contact_search" id="frame_header_contact_search" placeholder="[% 'Search contacts' | $T8 %]" size="14">]
-[%- END %]
-[%- IF AUTH.assert('general_ledger', 1) %]
- [<input id="glquicksearch" name="glquicksearch" type="text" class="ui-widget" placeholder="[% 'GL search' | $T8 %]" maxlength="20">]
+
+[%- FOREACH search = quick_search.active_modules %]
+ [<input id="top-quick-search-[% search.name %]" module="[% search.name %]" placeholder="[% search.description_field %]" maxlength="20">]
[%- END %]
+
</span>
[%- END %]
<span class="frame-header-element frame-header-right">