Revision 6c630204
Von Sven Schöling vor mehr als 9 Jahren hinzugefügt
SL/Controller/TopQuickSearch.pm | ||
---|---|---|
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
|
SL/Controller/TopQuickSearch/Assembly.pm | ||
---|---|---|
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;
|
SL/Controller/TopQuickSearch/Base.pm | ||
---|---|---|
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
|
SL/Controller/TopQuickSearch/Contact.pm | ||
---|---|---|
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;
|
SL/Controller/TopQuickSearch/GLTransaction.pm | ||
---|---|---|
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;
|
SL/Layout/Top.pm | ||
---|---|---|
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;
|
js/kivi.QuickSearch.js | ||
---|---|---|
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') })
|
||
})
|
||
})
|
templates/webpages/menu/header.html | ||
---|---|---|
<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">
|
Auch abrufbar als: Unified diff
TopQuickSearch: erste version