From d7177aa1a4ad46d00dbea4489e3388d0ba1cc772 Mon Sep 17 00:00:00 2001 From: Bill Erickson <berick@esilibrary.com> Date: Tue, 10 Jul 2012 11:07:53 -0400 Subject: [PATCH] ACQ invoice inline lineitem search and add The Invoice UI is how composed of two tabs, the main invoice tab and a new Search tab. The search tab consists of a subset of the Acquisitions unified search interface. The goal is to allow users to search for lineitems to invoice. Search results may be added directly to the growing invoice. A number of small usability features are included. Features ~~~~~~~~ * Option (default) to limit searches to invoiceable items. ** These are lineitems that are not cancelled, have at least one invoiceable copy, linked to a PO whose provider matches that of the current invoice, and are not already linked to the current invoice. * Search defaults to last-run search (on workstation). * New Lineitem Detail filter options * Sort searches by lineitem number (default) and title. * There is a new Expected Cost field which includes both the total invoiced cost plus the anticipated cost of lineitems as they are added. * New Price per Copy field * Lineitem count field * Show / Hide Invoice details button. Details are displayed by default, but hidden when the user enters the search tab. From there it remains hidden until manually shown (or a new invoice is opened). * A new "Save & Clear" button which saves the current invoice then clears the invoice display to create a new invoice. * Provider, shipper, and receiver fields are auto-populated from the first-added invoice data (when not already set). * Totals are now read-only, since they are derived from existing data (and are informational only). Signed-off-by: Bill Erickson <berick@esilibrary.com> Signed-off-by: Lebbeous Fogle-Weekley <lebbeous@esilibrary.com> --- .../perlmods/lib/OpenILS/Application/Acq/Search.pm | 46 ++- Open-ILS/src/templates/acq/invoice/view.tt2 | 405 +++++++++++++++------ Open-ILS/web/css/skin/default/acq.css | 2 +- Open-ILS/web/js/ui/default/acq/invoice/common.js | 26 +- Open-ILS/web/js/ui/default/acq/invoice/view.js | 309 ++++++++++++++-- Open-ILS/web/js/ui/default/acq/search/unified.js | 176 +++++---- docs/RELEASE_NOTES_NEXT/acq-invoice-li-search.txt | 22 ++ 7 files changed, 771 insertions(+), 215 deletions(-) create mode 100644 docs/RELEASE_NOTES_NEXT/acq-invoice-li-search.txt diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Acq/Search.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Acq/Search.pm index cbf4c73c5c..da594a2355 100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Acq/Search.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Acq/Search.pm @@ -229,7 +229,7 @@ sub prepare_terms { my $conj = $is_and ? "-and" : "-or"; my $outer_clause = {}; - foreach my $class (qw/acqpo acqpl acqinv jub/) { + foreach my $class (qw/acqpo acqpl acqinv jub acqlid acqlisum acqlisumi/) { next if not exists $terms->{$class}; $outer_clause->{$conj} = [] unless $outer_clause->{$conj}; @@ -243,7 +243,12 @@ sub prepare_terms { } elsif ($between and could_be_range($v)) { $term_clause = {$k => {"between" => $v}}; } elsif (check_1d_max($v)) { - $v = castdate($v, $gte, $lte) if $castdate; + if ($castdate) { + $v = castdate($v, $gte, $lte) if $castdate; + } elsif ($gte or $lte) { + my $op = $gte ? '>=' : '<='; + $v = {$op => $v}; + } $term_clause = {$k => $v}; } else { next; @@ -339,6 +344,11 @@ sub build_from_clause_and_joins { } else { $graft_map{$class} = $query->{from}{$core}{$class} ||= {}; $graft_map{$class}{type} = $join_type; + + # without this, the SQL attempts to join on + # jub.order_summary, which is a virtual field. + $graft_map{$class}{field} = 'lineitem' + if $class eq 'acqlisum' or $class eq 'acqlisumi'; } } @@ -415,6 +425,7 @@ sub unified_search { $hint => [{"column" => "id", "transform" => "distinct"}] }; + my $attr_from_filter; if ($options->{"order_by"}) { # What's the point of this block? When using ORDER BY in conjuction # with SELECT DISTINCT, the fields present in ORDER BY have to also @@ -432,9 +443,30 @@ sub unified_search { q/order_by clause must be of the long form, like: "order_by": [{"class": "foo", "field": "bar", "direction": "asc"}]/ ); + } else { + + # we can't combine distinct(id) with another select column, + # since the non-distinct column may arbitrarily (via hash keys) + # sort to the front of the final SQL, which PG will complain about. + $select_clause = { $hint => ["id"] }; $select_clause->{$class} ||= []; - push @{$select_clause->{$class}}, $field; + push @{$select_clause->{$class}}, + {column => $field, transform => 'first', aggregate => 1}; + + # when sorting by LI attr values, we have to limit + # to a specific type of attr value to sort on. + if ($class eq 'acqlia') { + $attr_from_filter = { + "fkey" => "id", + "filter" => { + "attr_type" => "lineitem_marc_attr_definition", + "attr_name" => $options->{"order_by_attr"} || "title" + }, + "type" => "left", + "field" =>"lineitem" + }; + } } } @@ -469,6 +501,14 @@ q/order_by clause must be of the long form, like: return new OpenILS::Event("BAD_PARAMS", "desc" => "No usable terms"); } + + # if ordering by acqlia, insert the from clause + # filter to limit to one type of attr. + if ($attr_from_filter) { + $query->{from}->{jub} = {} unless $query->{from}->{jub}; + $query->{from}->{jub}->{acqlia} = $attr_from_filter; + } + my $results = $e->json_query($query) or return $e->die_event; my @id_list = map { $_->{"id"} } (grep { $_->{"id"} } @$results); diff --git a/Open-ILS/src/templates/acq/invoice/view.tt2 b/Open-ILS/src/templates/acq/invoice/view.tt2 index 7e4d48931b..6ed29f1596 100644 --- a/Open-ILS/src/templates/acq/invoice/view.tt2 +++ b/Open-ILS/src/templates/acq/invoice/view.tt2 @@ -1,122 +1,307 @@ [% WRAPPER 'base.tt2' %] [% ctx.page_title = 'Invoicing' %] -<script type="text/javascript">var invoiceId = '[% ctx.page_args.0 %]';</script> <div dojoType="dijit.layout.ContentPane" style="height:100%"> <div dojoType="dijit.layout.ContentPane" layoutAlign="client" class='oils-header-panel'> <div> Invoice </div> - <div id="acq-view-invoice-receive" class="hidden"><button id="acq-view-invoice-receive-link">Receive Items</button></div> + <div id="acq-view-invoice-receive" class="hidden"> + <button id="acq-view-invoice-receive-link">Receive Items</button> + </div> </div> <div dojoType="dijit.layout.ContentPane" layoutAlign="client"> - <div id='acq-view-invoice-div'></div> - </div> + <style> + #acq-invoice-num-summary-table td { + padding-right: 8px; + padding-top: 5px; + padding-bottom: 5px; + border-bottom: 2px solid #888; + } + #acq-invoice-num-summary-table td[id] { + font-weight:bold; + } + </style> + <table id='acq-invoice-num-summary-table'> + <tr><td>[% l("Lineitems: " ) %]</td><td id='acq-invoice-summary-count'>0</td></tr> + <tr> + <td>[% l("Expected Cost: " ) %]</td> + <td id='acq-invoice-summary-cost'>0.00</td> + </tr> + </table> + <br/> + <div> <div dojoType="dijit.layout.ContentPane" layoutAlign="client"> - <table class='oils-acq-invoice-table'> - <thead/> - <tbody id='acq-invoice-entry-header' class='hidden'> - <tr> - <td colspan='0'> - <h3>Bibliographic Items</h3> - </td> - </tr> - </tbody> - <!-- acq.invoice_entry --> - <thead id='acq-invoice-entry-thead' class='hidden'> - <tr> - <th colspan='2'>Title Details</th> - <th class='acq-invoice-center-col'># Invoiced / # Paid</th> - <th class='acq-invoice-center-col'>Billed</th> - <th class='acq-invoice-paid-col'>Paid</th> - <th class='acq-invoice-center-col hide-complete'>Detach</th> - </tr> - </thead> - <tbody id='acq-invoice-entry-tbody' class='hidden'> - <tr id='acq-invoice-entry-template' class='acq-invoice-row'> - <td colspan='2'> - <div name='title_details'></div> - <div name='note'></div> - </td> - <td class='acq-invoice-center-col'> - <span name='inv_item_count'></span> / <span name='phys_item_count'></span> - </td> - <td class='acq-invoice-billed-col'><div name='cost_billed'></div></td> - <td class='acq-invoice-paid-col'><div name='amount_paid'></div></td> - <td class='acq-invoice-center-col hide-complete'><a href='javascript:void(0);' name='detach'>Detach</a></td> - </tr> - </tbody> - <tbody> - <tr> - <td style='margin-top:15px;' colspan='0'> - <h3>Direct Charges, Taxes, Fees, etc.</h3> - </td> - </tr> - </tbody> - <!-- acq.invoice_item --> - <thead> - <tr> - <th>Charge Type</th> - <th class='acq-invoice-center-col'>Fund</th> - <th>Title/Description</th> - <th class='acq-invoice-center-col'>Billed</th> - <th class='acq-invoice-paid-col'>Paid</th> - <th class='acq-invoice-center-col hide-complete'>Delete</th> - </tr> - </thead> - <tbody id='acq-invoice-item-tbody'> - <tr id='acq-invoice-item-template' class='acq-invoice-row acq-invoice-item-row'> - <td><div name='inv_item_type'></div></td> - <td class='acq-invoice-center-col'><div name='fund'></div></td> - <td><div name='title'></div></td> - <td class='acq-invoice-center-col acq-invoice-billed-col'><div name='cost_billed'></div></td> - <td class='acq-invoice-paid-col'><div name='amount_paid'></div></td> - <td class='acq-invoice-center-col hide-complete'><a href='javascript:void(0);' name='delete'>Delete</a></td> - </tr> - </tbody> - <tbody class='hide-complete'> - <tr> - <td colspan='0'> - <a href='javascript:void(0);' id='acq-invoice-new-item'>Add Charge...</a> - </td> - </tr> - </tbody> - <tbody> - <tr> - <td style='margin-top:15px;' colspan='0'> - <h3> </h3> - </td> - </tr> - </tbody> - <thead> - <tr> - <th colspan='3'/> - <th class='acq-invoice-center-col acq-invoice-billed-col'>Total</th> - <th class='acq-invoice-paid-col'>Total</th> - <th class='acq-invoice-center-col acq-invoice-balance-col'>Balance</th> - </tr> - </thead> - <tbody> - <tr> - <td colspan='3' style='text-align:right;'> - <button jsId='invoiceSaveButton' class='hide-complete' - dojoType='dijit.form.Button' onclick='saveChanges();'>Save</button> - <button jsId='invoiceProrateButton' class='hide-complete' - dojoType='dijit.form.Button' onclick='saveChanges(true);'>Save & Prorate</button> - <button jsId='invoiceCloseButton' class='hide-complete' - dojoType='dijit.form.Button' onclick='saveChanges(false, true);'>Save & Close</button> - <span class='hidden' id='acq-invoice-reopen-button-wrapper'> - <button jsId='invoiceReopenButton' - dojoType='dijit.form.Button' onclick='saveChanges(false, false, true);'>Reopen Invoice</button> - </span> - </td> - <td class='acq-invoice-center-col'><div jsId='totalInvoicedBox' dojoType='dijit.form.CurrencyTextBox' style='width:9em;'></div></td> - <td class='acq-invoice-paid-col'><div jsId='totalPaidBox' dojoType='dijit.form.CurrencyTextBox' style='width:9em;'></div></td> - <td class='acq-invoice-center-col'><div jsId='balanceOwedBox' dojoType='dijit.form.CurrencyTextBox' style='width:9em;'></div></td> - </tr> - </tbody> - </table> + <div id='acq-invoice-summary'> + <button id='acq-invoice-summary-toggle-off'>[% l('Hide Details') %]</button> + <div id='acq-view-invoice-div'></div> + </div> + <div id='acq-invoice-summary-small'> + <button id='acq-invoice-summary-toggle-on'>[% l('Show Details') %]</button> + <span style='font-weight:bold; font-size:120%' id='acq-invoice-summary-name'></span> + <br/> + <br/> + </div> </div> + + <div dojoType="dijit.layout.ContentPane" layoutAlign="client"> + + + <div dojoType="dijit.layout.TabContainer" style="width: 96%; height: 100%;"> + <div dojoType="dijit.layout.ContentPane" + class='oils-acq-detail-content-pane' title="Invoice" selected='true' style='height:600px'> + + <script type='dojo/connect' event='onShow'> + // the table is left at display=none on subsequent tab views + dojo.byId('oils-acq-invoice-table').style.display = 'table' + </script> + + <table id='oils-acq-invoice-table' class='oils-acq-invoice-table'> + <thead/> + <tbody id='acq-invoice-entry-header' class='hidden'> + <tr> + <td colspan='0'> + <h3> + [% l('Bibliographic Items') %] + </h3> + </td> + </tr> + </tbody> + <!-- acq.invoice_entry --> + <thead id='acq-invoice-entry-thead' class='hidden'> + <tr> + <th colspan='2'>Title Details</th> + <th class='acq-invoice-center-col'># Invoiced / # Paid</th> + <th class='acq-invoice-center-col'>Billed</th> + <th class='acq-invoice-paid-per-copy-col'>Per Copy</th> + <th class='acq-invoice-paid-col'>Paid</th> + <th class='acq-invoice-center-col hide-complete'>Detach</th> + </tr> + </thead> + <tbody id='acq-invoice-entry-tbody' class='hidden'> + <tr id='acq-invoice-entry-template' class='acq-invoice-row'> + <td colspan='2'> + <div name='title_details'></div> + <div name='note'></div> + </td> + <td class='acq-invoice-center-col'> + <span name='inv_item_count'></span> / <span name='phys_item_count'></span> + </td> + <td class='acq-invoice-billed-col'><div name='cost_billed'></div></td> + <td><div name='amount_paid_per_copy'>0.00</div></td> + <td class='acq-invoice-paid-col'><div name='amount_paid'></div></td> + <td class='acq-invoice-center-col hide-complete'><a href='javascript:void(0);' name='detach'>Detach</a></td> + </tr> + </tbody> + <tbody> + <tr> + <td style='margin-top:15px;' colspan='0'> + <h3>Direct Charges, Taxes, Fees, etc.</h3> + </td> + </tr> + </tbody> + <!-- acq.invoice_item --> + <thead> + <tr> + <th>Charge Type</th> + <th class='acq-invoice-center-col'>Fund</th> + <th>Title/Description</th> + <th class='acq-invoice-center-col'>Billed</th> + <th/> + <th class='acq-invoice-paid-col'>Paid</th> + <th class='acq-invoice-center-col hide-complete'>Delete</th> + </tr> + </thead> + <tbody id='acq-invoice-item-tbody'> + <tr id='acq-invoice-item-template' class='acq-invoice-row acq-invoice-item-row'> + <td><div name='inv_item_type'></div></td> + <td class='acq-invoice-center-col'><div name='fund'></div></td> + <td><div name='title'></div></td> + <td class='acq-invoice-center-col acq-invoice-billed-col'><div name='cost_billed'></div></td> + <td/> + <td class='acq-invoice-paid-col'><div name='amount_paid'></div></td> + <td class='acq-invoice-center-col hide-complete'><a href='javascript:void(0);' name='delete'>Delete</a></td> + </tr> + </tbody> + <tbody class='hide-complete'> + <tr> + <td colspan='0'> + <a href='javascript:void(0);' id='acq-invoice-new-item'>Add Charge...</a> + </td> + </tr> + </tbody> + <tbody> + <tr> + <td style='margin-top:15px;' colspan='0'> + <h3> </h3> + </td> + </tr> + </tbody> + <thead> + <tr> + <th colspan='3'/> + <th class='acq-invoice-center-col acq-invoice-billed-col'>Total</th> + <th/> + <th class='acq-invoice-paid-col'>Total</th> + <th class='acq-invoice-center-col acq-invoice-balance-col'>Balance</th> + </tr> + </thead> + <tbody> + <tr> + <td colspan='3' style='text-align:right;'> + <button jsId='invoiceSaveButton' class='hide-complete' + dojoType='dijit.form.Button' onclick='saveChanges();'>[% l('Save') %]</button> + <button jsId='invoiceSaveButton' class='hide-complete' + dojoType='dijit.form.Button' onclick='saveChanges({clear:true});'>[% l('Save & Clear') %]</button> + <button jsId='invoiceProrateButton' class='hide-complete' + dojoType='dijit.form.Button' onclick='saveChanges({prorate:true});'>[% l('Prorate') %]</button> + <button jsId='invoiceCloseButton' class='hide-complete' + dojoType='dijit.form.Button' onclick='saveChanges({close:true});'>[% l('Close') %]</button> + <span class='hidden' id='acq-invoice-reopen-button-wrapper'> + <button jsId='invoiceReopenButton' + dojoType='dijit.form.Button' onclick='saveChanges({reopen:true});'>[% l('Reopen') %]</button> + </span> + </td> + <td class='acq-invoice-center-col'><div id='acq-total-invoiced-box'></div></td> + <td/> + <td class='acq-invoice-paid-col'><div id='acq-total-paid-box'></div></td> + <td class='acq-invoice-center-col'><div id='acq-total-balance-box'></div></td> + </tr> + </tbody> + </table> + </div> <!-- tab 1 --> + + <div dojoType="dijit.layout.ContentPane" + class='oils-acq-detail-content-pane' title="Search"> + + <script type='dojo/connect' event='onShow'> + // hide summary info when opening the search tab + dojo.byId('acq-invoice-summary-toggle-off').onclick(); + renderUnifiedSearch(); + </script> + + + <!-- slim, inline unified search UI --> + <div id='oils-acq-invoice-search' _class='hidden'> + + <div id="acq-unified-form"> + <div> + <label for="acq-unified-result-type">[% l('Search for') %]</label> + <select id="acq-unified-result-type" disabled='disabled'> + <option value="lineitem">line items</option> + </select> + <label for="acq-unified-conjunction">matching</label> + <select id="acq-unified-conjunction"> + <option value="and">all</option> + <option value="or">any</option> + </select> + <label for="acq-unified-conjunction"> + of the following terms: + </label> + </div> + <div id="acq-unified-terms"> + <table id="acq-unified-terms-table"> + <tbody id="acq-unified-terms-tbody"> + <tr id="acq-unified-terms-row-tmpl" + class="acq-unified-terms-row"> + <td name="selector" + class="acq-unified-terms-selector"></td> + <td name="match" + class="acq-unified-terms-match"> + <select> + <option value="">is</option> + <option value="__not">is NOT</option> + <option value="__fuzzy" disabled="disabled"> + contains + </option> + <option value="__not,__fuzzy" + disabled="disabled"> + does NOT contain + </option> + <option value="__lte" disabled="disabled"> + is on or BEFORE + </option> + <option value="__gte" disabled="disabled"> + is on or AFTER + </option> + <option value="__in" disabled="disabled"> + matches a term from a file + </option> + </select> + </td> + <td name="widget" + class="acq-unified-terms-widget"></td> + <td name="remove" + class="acq-unified-terms-remove"></td> + </tr> + </tbody> + </table> + </div> + <div id="acq-unified-add-term"> + <button onclick="termManager.addRow()">Add Search Term</button> + </div> + <table width='100%'><tr> + <td align='left'> + <button onclick="performSearch(0)">[% l('Search') %]</button> + <button onclick='addSelectedToInvoice()'> + [% l('Add Selected Items to Invoice') %] + </button> + <span id='acq-inv-search-prev'> + <a href='javascript:performSearch(-1)'>[% l('Previous') %]</a> + </span> + <span> + <a href='javascript:performSearch(1)'>[% l('Next') %]</a> + </span> + </td> + <td align='right'> + <input type='checkbox' id='acq-invoice-search-sort-title'/> + [% l('Sort by title') %] + <span style='padding-left:8px;'> + <input type='checkbox' id='acq-invoice-search-limit-invoiceable' checked='checked'/> + [% l('Limit to Invoiceable Items') %] + </td> + </tr></table> + </div> <!-- end search form --> + <div id='acq-unified-results-no_results'> + <b>[% l('No Results') %]</b> + </div> + <div id='acq-unified-results-lineitem'> + <style> + #acq-invoice-search-results-tbody { width: 100%; } + #acq-invoice-search-results-tbody td { + padding: 5px; + border-bottom: 1px solid #888; + } + .search-resutls-select-td { + padding-right: 8px; border-right: 2px solid #888; + } + .search-results-content-td { + padding-left: 8px; border-left: 2px solid #888; + } + .search-results-already-invoiced { + background-color: #E99; + } + </style> + <table> + <tbody id='acq-invoice-search-results-tbody'> + <tr id='acq-invoice-search-results-tr'> + <td class='search-resutls-select-td'> + <input type='checkbox' name='search-results-checkbox'/> + </td> + <td class='search-results-content-td'> + <div name='search-results-content-div'> + <img src='[% ctx.media_prefix %]/opac/images/progressbar_green.gif'/> + </div> + </td> + </tr> + </tbody> + </table> + </div> + </div> + </div> <!-- tab 2 --> + </div> <!-- end tabcontainer --> + </div> <!-- end contentpane --> + </div> <div dojoType='openils.widget.ProgressDialog' jsId='progressDialog'></div> <div jsId='extraItemsDialog' dojoType="dijit.Dialog" title="Extra Items"> @@ -132,6 +317,12 @@ <button dojoType='dijit.form.Button' jsId='extraCopiesGo'>Add New Items</button> </div> </div> +<script type="text/javascript"> + var invoiceId = '[% ctx.page_args.0 %]'; + window.unifiedSearchExternalMode = true; +</script> +<script type="text/javascript" src="[% ctx.media_prefix %]/js/ui/default/acq/common/base64.js"></script> +<script type="text/javascript" src="[% ctx.media_prefix %]/js/ui/default/acq/search/unified.js"></script> <script type="text/javascript" src='[% ctx.media_prefix %]/js/ui/default/acq/invoice/common.js'> </script> <script type="text/javascript" src='[% ctx.media_prefix %]/js/ui/default/acq/invoice/view.js'> </script> [% END %] diff --git a/Open-ILS/web/css/skin/default/acq.css b/Open-ILS/web/css/skin/default/acq.css index 6d03933675..07fccb8865 100644 --- a/Open-ILS/web/css/skin/default/acq.css +++ b/Open-ILS/web/css/skin/default/acq.css @@ -211,7 +211,7 @@ span[name="notes_alert_flag"] {color: #c00;font-weight: bold;font-size: 110%;mar .acq-inoice-item-extra-info { padding-left: 10px; } .acq-inoice-item-info { font-weight: bold; } .acq-invoice-row td { border-bottom: 1px solid #e0e0e0; } -.acq-invoice-invalid-amount input { color: red; font-weight: bold; } +.acq-invoice-invalid-amount { color: red; font-weight: bold; } .acq-link-invoice-dialog td,.acq-link-invoice-dialog th {padding-top: 10px;} .acq-invoice-paid-col {background : #E0E0E0; text-align: center;} .acq-invoice-center-col { text-align: center; } diff --git a/Open-ILS/web/js/ui/default/acq/invoice/common.js b/Open-ILS/web/js/ui/default/acq/invoice/common.js index 03fade4c8e..0980fca978 100644 --- a/Open-ILS/web/js/ui/default/acq/invoice/common.js +++ b/Open-ILS/web/js/ui/default/acq/invoice/common.js @@ -4,18 +4,26 @@ dojo.require('openils.widget.EditPane'); function drawInvoicePane(parentNode, inv, args) { args = args || {}; + var pane; var override = {}; if(!inv) { override = { recv_date : {widgetValue : dojo.date.stamp.toISOString(new Date())}, - receiver : {widgetValue : openils.User.user.ws_ou()}, + //receiver : {widgetValue : openils.User.user.ws_ou()}, recv_method : {widgetValue : 'PPR'} }; } dojo.mixin(override, { - provider : { dijitArgs : { store_options : { base_filter : { active :"t" } } } }, + provider : { + dijitArgs : { + store_options : { base_filter : { active :"t" } }, + onChange : function(val) { + pane.setFieldValue('shipper', val); + } + } + }, shipper : { dijitArgs : { store_options : { base_filter : { active :"t" } } } } }); @@ -23,7 +31,19 @@ function drawInvoicePane(parentNode, inv, args) { override[field] = {widgetValue : args[field]}; } - var pane = new openils.widget.EditPane({ + // push the name of the invoice into the name display field after update + override.inv_ident = dojo.mixin( + override.inv_ident, + {dijitArgs : {onChange : + function(newVal) { + if (dojo.byId('acq-invoice-summary-name')) + dojo.byId('acq-invoice-summary-name').innerHTML = newVal; + } + }} + ); + + + pane = new openils.widget.EditPane({ fmObject : inv, paneStackCount : 2, fmClass : 'acqinv', diff --git a/Open-ILS/web/js/ui/default/acq/invoice/view.js b/Open-ILS/web/js/ui/default/acq/invoice/view.js index 2e8f499eeb..4f1fa607c4 100644 --- a/Open-ILS/web/js/ui/default/acq/invoice/view.js +++ b/Open-ILS/web/js/ui/default/acq/invoice/view.js @@ -1,6 +1,8 @@ dojo.require('dojo.date.locale'); dojo.require('dojo.date.stamp'); +dojo.require('dojo.cookie'); dojo.require('dijit.form.CheckBox'); +dojo.require('dijit.form.Button'); dojo.require('dijit.form.CurrencyTextBox'); dojo.require('dijit.form.NumberTextBox'); dojo.require('openils.User'); @@ -36,6 +38,9 @@ var extraCopies = {}; var extraCopiesFund; var widgetRegistry = {acqie : {}, acqii : {}}; var focusLineitem; +var searchInitDone = false; +var termManager; +var resultManager; function nodeByName(name, context) { return dojo.query('[name='+name+']', context)[0]; @@ -53,12 +58,30 @@ function init() { focusLineitem = new openils.CGI().param('focus_li'); + totalInvoicedBox = dojo.byId('acq-total-invoiced-box'); + totalPaidBox = dojo.byId('acq-total-paid-box'); + balanceOwedBox = dojo.byId('acq-total-balance-box'); + itemTypes = pcrud.retrieveAll('aiit'); + dojo.byId('acq-invoice-summary-toggle-off').onclick = function() { + openils.Util.hide(dojo.byId('acq-invoice-summary')); + openils.Util.show(dojo.byId('acq-invoice-summary-small')); + }; + + dojo.byId('acq-invoice-summary-toggle-on').onclick = function() { + openils.Util.show(dojo.byId('acq-invoice-summary')); + openils.Util.hide(dojo.byId('acq-invoice-summary-small')); + } + if(cgi.param('create')) { renderInvoice(); + // show summary info by default for new invoices + dojo.byId('acq-invoice-summary-toggle-on').onclick(); + } else { + dojo.byId('acq-invoice-summary-toggle-off').onclick(); fieldmapper.standardRequest( ['open-ils.acq', 'open-ils.acq.invoice.retrieve.authoritative'], { @@ -117,16 +140,20 @@ function renderInvoice() { ); } + // display items and entries in ID order + // which effectively equates to add order. + function idsort(a, b) { return a.id() < b.id() ? -1 : 1 } + if(invoice) { dojo.forEach( - invoice.items(), + invoice.items().sort(idsort), function(item) { addInvoiceItem(item); } ); dojo.forEach( - invoice.entries(), + invoice.entries().sort(idsort), function(entry) { addInvoiceEntry(entry); } @@ -137,10 +164,10 @@ function renderInvoice() { if(attachPo.length) doAttachPo(0); } -function doAttachLi() { +function doAttachLi(skipInit) { //var invoiceArgs = {provider : lineitem.provider(), shipper : lineitem.provider()}; - if(cgi.param('create')) { + if(cgi.param('create') && !skipInit) { // use the first LI in the list to determine the default provider fieldmapper.standardRequest( @@ -223,6 +250,172 @@ function doAttachPo(idx) { ); } +function performSearch(pageDir) { + clearSearchResTable(); + var searchObject = termManager.buildSearchObject(); + dojo.cookie('invs', base64Encode(searchObject)); + dojo.cookie('invc', dojo.byId("acq-unified-conjunction").getValue()); + + if (pageDir == 0) { // new search + resultsLoader.displayOffset = 0; + } else { + resultsLoader.displayOffset += pageDir * resultsLoader.displayLimit; + } + + if (resultsLoader.displayOffset == 0) { + openils.Util.hide('acq-inv-search-prev'); + } else { + openils.Util.show('acq-inv-search-prev', 'inline'); + } + + if (dojo.byId('acq-invoice-search-limit-invoiceable').checked) { + if (!searchObject.jub) + searchObject.jub = []; + + // exclude lineitems that are "cancelled" (sidebar: 'Mericans spell it 'canceled') + searchObject.jub.push({state : 'cancelled', '__not' : true}); + + // exclude lineitems already linked to this invoice + if (invoice && invoice.id() > 0) { + if (!searchObject.acqinv) + searchObject.acqinv = []; + searchObject.acqinv.push({id : invoice.id(), '__not' : true}); + } + + // limit to lineitems that have invoiceable copies + searchObject.acqlisumi = [{item_count : 1, '_gte' : true}]; + + // limit to provider if a provider is selected + var provider = invoicePane.getFieldValue('provider'); + if (provider) { + if (!searchObject.jub.filter(function(i) { return i.provider != null }).length) + searchObject.jub.push({provider : provider}); + } + } + + if (dojo.byId('acq-invoice-search-sort-title').checked) { + uriManager.order_by = + [ {"class": "acqlia", "field":"attr_value", "transform":"first"} ]; + } + + resultsLoader.lastSearch = searchObject; + resultManager.go(searchObject) + console.log('Lineitem Search: ' + js2JSON(searchObject)); + focusLastSearchInput(); +} + + +function renderUnifiedSearch() { + + if (!searchInitDone) { + + searchInitDone = true; + termManager = new TermManager(); + resultManager = new ResultManager(); + resultsLoader = new searchResultsLoader(); + uriManager = new URIManager(); + + // define custom lineitem result handler + resultManager.result_types = { + "lineitem": { + "search_options": { "id_list": true }, + "revealer": function() { }, + "finisher": function() { + resultsLoader.batch_length = resultManager.count_results; + }, + "adder": function(li) { + resultsLoader.addLineitem(li); + }, + "interface": resultsLoader + }, + "no_results": { + "revealer": function() { } + } + }; + + var searchObject = dojo.cookie('invs'); + console.log('loaded ' + searchObject); + if (searchObject) { + // if there is a search object cookie, populate the search form + termManager.reflect(base64Decode(searchObject)); + dojo.byId("acq-unified-conjunction").setValue(dojo.cookie('invc')); + } else { + console.log('adding row'); + termManager.addRow(); + } + } + + dojo.addClass(dojo.byId('oils-acq-invoice-table'), 'hidden'); + dojo.removeClass(dojo.byId('oils-acq-invoice-search'), 'hidden'); + focusLastSearchInput(); +} + +function focusLastSearchInput() { + // TODO: see about making this better and moving it into search/unified.js + var wnodes = dojo.query('[name=widget]'); + var inputNode = wnodes.item(wnodes.length - 1).firstChild; + if (inputNode) { + try { + inputNode.select(); + } catch(E) { + inputNode.focus(); + } + } +} + +var resultsTbody, resultsRow; +function searchResultsLoader() { + this.displayOffset = 0; + this.displayLimit = 10; + + if (!resultsTbody) { + resultsTbody = dojo.byId('acq-invoice-search-results-tbody'); + resultsRow = resultsTbody.removeChild(dojo.byId('acq-invoice-search-results-tr')); + } + + this.addLineitem = function(li_id) { + console.log('Adding search result lineitem ' + li_id); + var row = resultsRow.cloneNode(true); + resultsTbody.appendChild(row); + var checkbox = dojo.query('[name=search-results-checkbox]', row)[0]; + checkbox.setAttribute('lineitem', li_id); + + // this lineitem is already part of the invoice + if (dojo.query('[entry_lineitem_row=' + li_id + ']')[0]) { + checkbox.disabled = true; + dojo.addClass(checkbox.parentNode, 'search-results-already-invoiced'); + } + + openils.acq.Lineitem.fetchAndRender( + li_id, {}, + function(li, html) { + dojo.query('[name=search-results-content-div]', row)[0].innerHTML = html; + } + ); + } +} + +function addSelectedToInvoice() { + var inputs = dojo.query('[name=search-results-checkbox]'); + attachLi = []; + dojo.forEach(inputs, + function(checkbox) { + if (checkbox.checked) { + attachLi.push(checkbox.getAttribute('lineitem')); + checkbox.disabled = true; + checkbox.checked = false; + dojo.addClass(checkbox.parentNode, 'search-results-already-invoiced'); + } + } + ); + doAttachLi(true); +} + +function clearSearchResTable() { + while (resultsTbody.childNodes[0]) + resultsTbody.removeChild(resultsTbody.childNodes[0]); +} + function updateTotalCost() { var totalCost = 0; @@ -232,7 +425,7 @@ function updateTotalCost() { for(var id in widgetRegistry.acqie) if(!widgetRegistry.acqie[id]._object.isdeleted()) totalCost += Number(widgetRegistry.acqie[id].cost_billed.getFormattedValue()); - totalInvoicedBox.attr('value', totalCost); + totalInvoicedBox.innerHTML = totalCost.toFixed(2); totalPaid = 0; for(var id in widgetRegistry.acqii) @@ -241,27 +434,27 @@ function updateTotalCost() { for(var id in widgetRegistry.acqie) if(!widgetRegistry.acqie[id]._object.isdeleted()) totalPaid += Number(widgetRegistry.acqie[id].amount_paid.getFormattedValue()); - totalPaidBox.attr('value', totalPaid); + totalPaidBox.innerHTML = totalPaid.toFixed(2); var buttonsDisabled = false; if(totalPaid > totalCost || totalPaid < 0) { - openils.Util.addCSSClass(totalPaidBox.domNode, 'acq-invoice-invalid-amount'); + openils.Util.addCSSClass(totalPaidBox, 'acq-invoice-invalid-amount'); invoiceSaveButton.attr('disabled', true); invoiceProrateButton.attr('disabled', true); buttonsDisabled = true; } else { - openils.Util.removeCSSClass(totalPaidBox.domNode, 'acq-invoice-invalid-amount'); + openils.Util.removeCSSClass(totalPaidBox, 'acq-invoice-invalid-amount'); invoiceSaveButton.attr('disabled', false); invoiceProrateButton.attr('disabled', false); } if(totalCost < 0) { - openils.Util.addCSSClass(totalInvoicedBox.domNode, 'acq-invoice-invalid-amount'); + openils.Util.addCSSClass(totalInvoicedBox, 'acq-invoice-invalid-amount'); invoiceSaveButton.attr('disabled', true); invoiceProrateButton.attr('disabled', true); } else { - openils.Util.removeCSSClass(totalInvoicedBox.domNode, 'acq-invoice-invalid-amount'); + openils.Util.removeCSSClass(totalInvoicedBox, 'acq-invoice-invalid-amount'); if(!buttonsDisabled) { invoiceSaveButton.attr('disabled', false); invoiceProrateButton.attr('disabled', false); @@ -274,7 +467,9 @@ function updateTotalCost() { invoiceCloseButton.attr('disabled', true); } - balanceOwedBox.attr('value', (totalCost - totalPaid)); + balanceOwedBox.innerHTML = (totalCost - totalPaid).toFixed(2); + + updateExpectedCost(); } @@ -316,6 +511,7 @@ function addInvoiceItem(item) { } else if(field == 'cost_billed' || field == 'amount_paid') { args = {required : true, style : 'width: 8em'}; } + registerWidget( item, field, @@ -481,12 +677,39 @@ function focusLi() { } +// expected cost is totalCostInvoiced + totalCostNotYetInvoiced +function updateExpectedCost() { + + var cost = Number(totalInvoicedBox.innerHTML || 0); + + // for any LI's that are not yet billed (i.e. filled in) + // use the total expected cost for that lineitem. + for(var id in widgetRegistry.acqie) { + var entry = widgetRegistry.acqie[id]._object; + if(!entry.isdeleted()) { + if (Number(widgetRegistry.acqie[id].cost_billed.getFormattedValue()) == 0) { + var li = entry.lineitem(); + cost += + Number(li.order_summary().estimated_amount()) - + Number(li.order_summary().paid_amount()); + } + } + } + + dojo.byId('acq-invoice-summary-cost').innerHTML = cost.toFixed(2); +} + +var invoicEntryWidgets = {}; function addInvoiceEntry(entry) { + console.log('Adding new entry for lineitem ' + entry.lineitem()); openils.Util.removeCSSClass(dojo.byId('acq-invoice-entry-header'), 'hidden'); openils.Util.removeCSSClass(dojo.byId('acq-invoice-entry-thead'), 'hidden'); openils.Util.removeCSSClass(dojo.byId('acq-invoice-entry-tbody'), 'hidden'); + dojo.byId('acq-invoice-summary-count').innerHTML = + Number(dojo.byId('acq-invoice-summary-count').innerHTML) + 1; + entryTbody = dojo.byId('acq-invoice-entry-tbody'); if(entryTemplate == null) { entryTemplate = entryTbody.removeChild(dojo.byId('acq-invoice-entry-template')); @@ -498,6 +721,7 @@ function addInvoiceEntry(entry) { var row = entryTemplate.cloneNode(true); row.setAttribute('lineitem', entry.lineitem()); + row.setAttribute('entry_lineitem_row', entry.lineitem()); openils.acq.Lineitem.fetchAndRender( entry.lineitem(), {}, @@ -511,6 +735,14 @@ function addInvoiceEntry(entry) { updateReceiveLink(li); + // set some default values if otherwise unset + if (!invoicePane.getFieldValue('receiver')) { + invoicePane.setFieldValue('receiver', li.purchase_order().ordering_agency()); + } + if (!invoicePane.getFieldValue('provider')) { + invoicePane.setFieldValue('provider', li.purchase_order().provider()); + } + dojo.forEach( ['inv_item_count', 'phys_item_count', 'cost_billed', 'amount_paid'], function(field) { @@ -536,6 +768,7 @@ function addInvoiceEntry(entry) { parentNode : nodeByName(field, row) }), function(w) { + if(field == 'phys_item_count') { dojo.connect(w, 'onChange', function() { @@ -548,12 +781,20 @@ function addInvoiceEntry(entry) { } } ) - } - } + } // if + + if(field == 'inv_item_count' || field == 'cost_billed') { + setPerCopyPrice(row, entry); + // update the per-copy count as invoice count and cost billed change + dojo.connect(w, 'onChange', function() { setPerCopyPrice(row, entry) } ); + } + + } // func ); } ); + updateTotalCost(); if (focusLineitem == li.id()) focusLi(); } @@ -586,7 +827,22 @@ function addInvoiceEntry(entry) { } entryTbody.appendChild(row); - updateTotalCost(); +} + +function setPerCopyPrice(row, entry) { + var inv_w = widgetRegistry.acqie[entry.id()].inv_item_count; + var bill_w = widgetRegistry.acqie[entry.id()].cost_billed; + + if (inv_w && bill_w) { + var invoiced = Number(inv_w.getFormattedValue()); + var billed = Number(bill_w.getFormattedValue()); + console.log(invoiced + ' : ' + billed); + if (invoiced > 0) { + nodeByName('amount_paid_per_copy', row).innerHTML = (billed / invoiced).toFixed(2); + } else { + nodeByName('amount_paid_per_copy', row).innerHTML = '0.00'; + } + } } function liMarcAttr(lineitem, name) { @@ -601,12 +857,9 @@ function liMarcAttr(lineitem, name) { return (attr) ? attr.attr_value() : ''; } -function saveChanges(doProrate, doClose, doReopen) { - createExtraCopies( - function() { - saveChangesPartTwo(doProrate, doClose, doReopen); - } - ); +function saveChanges(args) { + args = args || {}; + createExtraCopies(function() { saveChangesPartTwo(args); }); } // Define a helper function to 'unflesh' sub-objects from an fmclass object. @@ -621,10 +874,10 @@ function unflesh() { }); } -function saveChangesPartTwo(doProrate, doClose, doReopen) { - +function saveChangesPartTwo(args) { + args = args || {}; - if(doReopen) { + if(args.reopen) { invoice.complete('f'); } else { @@ -643,7 +896,7 @@ function saveChangesPartTwo(doProrate, doClose, doReopen) { return; } - if(doClose) + if(args.close) invoice.complete('t'); @@ -693,9 +946,13 @@ function saveChangesPartTwo(doProrate, doClose, doReopen) { progressDialog.hide(); var invoice = openils.Util.readResponse(r); if(invoice) { - if(doProrate) + if(args.prorate) return prorateInvoice(invoice); - location.href = oilsBasePath + '/acq/invoice/view/' + invoice.id(); + if (args.clear) { + location.href = oilsBasePath + '/acq/invoice/view?create=1'; + } else { + location.href = oilsBasePath + '/acq/invoice/view/' + invoice.id(); + } } } } diff --git a/Open-ILS/web/js/ui/default/acq/search/unified.js b/Open-ILS/web/js/ui/default/acq/search/unified.js index b7f0137836..2c1eff01bc 100644 --- a/Open-ILS/web/js/ui/default/acq/search/unified.js +++ b/Open-ILS/web/js/ui/default/acq/search/unified.js @@ -4,6 +4,7 @@ dojo.require("openils.widget.AutoGrid"); dojo.require("openils.widget.AutoWidget"); dojo.require("openils.widget.XULTermLoader"); dojo.require("openils.PermaCrud"); +dojo.require('dijit.layout.TabContainer'); if (!localeStrings) { /* we can do this because javascript doesn't have block scope */ @@ -251,7 +252,7 @@ function TermManager() { }; this.terms = {}; - ["jub", "acqpl", "acqpo", "acqinv"].forEach( + ["jub", "acqpl", "acqpo", "acqinv", "acqlid"].forEach( function(hint) { var o = {}; o.__label = fieldmapper.IDL.fmclasses[hint].label; @@ -621,84 +622,91 @@ function ResultManager(liPager, poGrid, plGrid, invGrid) { this.plCache = {}; this.invCache = {}; - this.result_types = { - "lineitem": { - "search_options": { - "flesh_attrs": true, - "flesh_cancel_reason": true, - "flesh_notes": true - }, - "revealer": function() { - self.liPager.show(); - progressDialog.show(true); - }, - "finisher": function() { - self.liPager.batch_length = self.count_results; - self.liPager.relabelControls(); - self.liPager.enableControls(true); - progressDialog.hide(); - }, - "adder": function(li) { - self.liPager.liTable.addLineitem(li); - }, - "interface": self.liPager - }, - "purchase_order": { - "search_options": { - "no_flesh_cancel_reason": true - }, - "revealer": function() { - self.poGrid.resetStore(); - self.poGrid.showLoadProgressIndicator(); - self.poCache = {}; - }, - "finisher": function() { - self.poGrid.hideLoadProgressIndicator(); - }, - "adder": function(po) { - self.poCache[po.id()] = po; - self.poGrid.store.newItem(acqpo.toStoreItem(po)); - }, - "interface": self.poGrid - }, - "picklist": { - "search_options": { - "flesh_lineitem_count": true, - "flesh_owner": true - }, - "revealer": function() { - self.plGrid.resetStore(); - self.plGrid.showLoadProgressIndicator(); - self.plCache = {}; - }, - "finisher": function() { - self.plGrid.hideLoadProgressIndicator(); - }, - "adder": function(pl) { - self.plCache[pl.id()] = pl; - self.plGrid.store.newItem(acqpl.toStoreItem(pl)); - }, - "interface": self.plGrid - }, - "invoice": { - "search_options": { - "no_flesh_misc": true + if (window.unifiedSearchExternalMode) { + + // external user will define result types and handlers + + } else { + + this.result_types = { + "lineitem": { + "search_options": { + "flesh_attrs": true, + "flesh_cancel_reason": true, + "flesh_notes": true + }, + "revealer": function() { + self.liPager.show(); + progressDialog.show(true); + }, + "finisher": function() { + self.liPager.batch_length = self.count_results; + self.liPager.relabelControls(); + self.liPager.enableControls(true); + progressDialog.hide(); + }, + "adder": function(li) { + self.liPager.liTable.addLineitem(li); + }, + "interface": self.liPager }, - "finisher": function() { - self.invGrid.hideLoadProgressIndicator(); + "purchase_order": { + "search_options": { + "no_flesh_cancel_reason": true + }, + "revealer": function() { + self.poGrid.resetStore(); + self.poGrid.showLoadProgressIndicator(); + self.poCache = {}; + }, + "finisher": function() { + self.poGrid.hideLoadProgressIndicator(); + }, + "adder": function(po) { + self.poCache[po.id()] = po; + self.poGrid.store.newItem(acqpo.toStoreItem(po)); + }, + "interface": self.poGrid }, - "revealer": function() { - self.invGrid.resetStore(); - self.invCache = {}; + "picklist": { + "search_options": { + "flesh_lineitem_count": true, + "flesh_owner": true + }, + "revealer": function() { + self.plGrid.resetStore(); + self.plGrid.showLoadProgressIndicator(); + self.plCache = {}; + }, + "finisher": function() { + self.plGrid.hideLoadProgressIndicator(); + }, + "adder": function(pl) { + self.plCache[pl.id()] = pl; + self.plGrid.store.newItem(acqpl.toStoreItem(pl)); + }, + "interface": self.plGrid }, - "adder": function(inv) { - self.invCache[inv.id()] = inv; - self.invGrid.store.newItem(acqinv.toStoreItem(inv)); + "invoice": { + "search_options": { + "no_flesh_misc": true + }, + "finisher": function() { + self.invGrid.hideLoadProgressIndicator(); + }, + "revealer": function() { + self.invGrid.resetStore(); + self.invCache = {}; + }, + "adder": function(inv) { + self.invCache[inv.id()] = inv; + self.invGrid.store.newItem(acqinv.toStoreItem(inv)); + }, + "interface": self.invGrid }, - "interface": self.invGrid - }, - "no_results": { - "revealer": function() { alert(localeStrings.NO_RESULTS); } + "no_results": { + "revealer": function() { alert(localeStrings.NO_RESULTS); } + } } }; @@ -788,10 +796,23 @@ function ResultManager(liPager, poGrid, plGrid, invGrid) { }; this.go = function(search_object) { + + if (window.unifiedSearchExternalMode) { + // assume for now that external mode implies inline results display + + uriManager = uriManager || new URIManager(); + uriManager.search_object = search_object; + uriManager.result_type = dojo.byId("acq-unified-result-type").getValue(); + uriManager.conjunction = dojo.byId("acq-unified-conjunction").getValue(); + this.search(uriManager, termManager); + + } else { + location.href = oilsBasePath + "/acq/search/unified?" + "so=" + base64Encode(search_object) + "&rt=" + dojo.byId("acq-unified-result-type").getValue() + "&c=" + dojo.byId("acq-unified-conjunction").getValue(); + } }; this.search = function(uriManager, termManager) { @@ -928,7 +949,12 @@ function URIManager() { /* onload */ openils.Util.addOnLoad( function() { + + // onload handled by external user + if (window.unifiedSearchExternalMode) return; + termManager = new TermManager(); + resultManager = new ResultManager( new LiTablePager(null, new AcqLiTable()), dijit.byId("acq-unified-po-grid"), diff --git a/docs/RELEASE_NOTES_NEXT/acq-invoice-li-search.txt b/docs/RELEASE_NOTES_NEXT/acq-invoice-li-search.txt new file mode 100644 index 0000000000..3ef304d075 --- /dev/null +++ b/docs/RELEASE_NOTES_NEXT/acq-invoice-li-search.txt @@ -0,0 +1,22 @@ +ACQ Invoice Inline Lineitem Search and Add +------------------------------------------ + +The Invoice UI is how composed of two tabs, the main invoice tab and a new Search tab. The search tab consists of a subset of the Acquisitions unified search interface. The goal is to allow users to search for lineitems to invoice. Search results may be added directly to the growing invoice. A number of small usability features are included. + +Features +~~~~~~~~ + + * Option (default) to limit searches to invoiceable items. + ** These are lineitems that are not cancelled, have at least one invoiceable copy, linked to a PO whose provider matches that of the current invoice, and are not already linked to the current invoice. + * Search defaults to last-run search (on workstation). + * New Lineitem Detail filter options + * Sort searches by lineitem number (default) and title. + * There is a new Expected Cost field which includes both the total invoiced cost plus the anticipated cost of lineitems as they are added. + * New Price per Copy field + * Lineitem count field + * Show / Hide Invoice details button. Details are displayed by default, but hidden when the user enters the search tab. From there it remains hidden until manually shown (or a new invoice is opened). + * A new "Save & Clear" button which saves the current invoice then clears the invoice display to create a new invoice. + * Provider, shipper, and receiver fields are auto-populated from the first-added invoice data (when not already set). + * Totals are now read-only, since they are derived from existing data (and are informational only). + + -- 2.11.0