Merge branch 'master' of git.freeside.biz:/home/git/freeside
[freeside.git] / httemplate / misc / batch-cust_pay.html
1 <& /elements/header.html, {
2   title => 'Quick payment entry',
3   etc   => 'onload="preload()"'
4 } &>
5
6 <& /elements/error.html &>
7
8 <SCRIPT TYPE="text/javascript">
9 function warnUnload() {
10   if(document.getElementById("OneTrueTable").rows.length > 3 &&
11      !document.OneTrueForm.btnsubmit.disabled) {
12     return "The current batch will be lost.";
13   }
14   else {
15     return null;
16   }
17 }
18 window.onbeforeunload = warnUnload;
19
20 function add_row_callback(rownum, prefix) {
21   document.getElementById('enable_app'+rownum).disabled = true;
22 }
23
24 function custnum_update_callback(rownum, prefix) {
25   var custnum = document.getElementById('custnum'+rownum).value;
26   // if there is a custnum and more than one open invoice, enable
27   // (and check) the box
28   var show_applications = !(custnum > 0 && num_open_invoices[rownum] > 1);
29   var enable_app_checkbox = document.getElementById('enable_app'+rownum);
30   enable_app_checkbox.disabled = show_applications;
31
32 % if ( $use_discounts ) {
33   select_discount_term(rownum, prefix);
34 % }
35 }
36
37 function invnum_update_callback(rownum, prefix) {
38   custnum_update_callback(rownum, prefix);
39 }
40
41 function select_discount_term(row, prefix) {
42   var custnum_obj = document.getElementById('custnum'+prefix+row);
43   var select_obj = document.getElementById('discount_term'+prefix+row);
44
45   var value = '';
46   if (select_obj.type == 'hidden') {
47     value = select_obj.value;
48   }
49
50   var term_select = document.createElement('SELECT');
51   term_select.setAttribute('name', 'discount_term'+row);
52   term_select.setAttribute('id',   'discount_term'+row);
53   term_select.setAttribute('rownum', row);
54   term_select.style.display = '';
55   select_obj.parentNode.replaceChild(term_select, select_obj);
56   opt(term_select, '', '1 month');
57   
58   function select_discount_term_update(discount_terms) {
59
60     var termArray = eval('(' + discount_terms + ')');
61     for ( var t = 0; t < termArray.length; t++ ) {
62       opt(term_select, termArray[t][0], termArray[t][1]);
63       if (termArray[t][0] == value) {
64         term_select.selectedIndex = t+1;
65       }
66     }
67
68   }
69
70   discount_terms(custnum_obj.value, select_discount_term_update);
71
72 }
73
74 var invoices_for_row = new Object;
75
76 function update_invoices(rownum, invoices) {
77   invoices_for_row[rownum] = new Object;
78   // only called before create_application_row
79   for ( var i=0; i<invoices.length; i++ ) {
80     invoices_for_row[rownum][ invoices[i].invnum ] = invoices[i];
81   }
82 }
83
84 function toggle_application_row(ev, next) {
85   if (!next) next = function(){}; //optional continuation
86   var rownum = this.getAttribute('rownum');
87   if ( this.checked ) {
88     var custnum = document.getElementById('custnum'+rownum).value;
89     if (!custnum) return;
90     lock_payment_row(rownum, true);
91     custnum_search_open( custnum, 
92       function(returned) {
93         update_invoices(rownum, JSON.parse(returned));
94         create_application_row(rownum, 0);
95         next.call(this, rownum);
96       }
97     );
98   } else {
99     var row = document.getElementById('row'+rownum);
100     var table_rows = row.parentNode.rows;
101     for (i = row.sectionRowIndex; i < table_rows.count; i++) {
102       if ( table_rows[i].id.indexof('row'+rownum+'.') > -1 ) {
103         table_rows.removeChild(table_rows[i]);
104       } else {
105         break;
106       }
107     }
108     lock_payment_row(rownum, false);
109   }
110 }
111
112 function lock_payment_row(rownum, flag) {
113 % foreach (qw(invnum custnum customer)) {
114   obj = document.getElementById('<% $_ %>'+rownum);
115   obj.readOnly = flag;
116 % }
117   document.getElementById('enable_app'+rownum).disabled = flag;
118 }
119
120 function delete_application_row() {
121   var rownum = this.getAttribute('rownum');
122   var appnum = this.getAttribute('appnum');
123   var tr_app = document.getElementById('row'+rownum+'.'+appnum);
124   var select_invnum = document.getElementById('invnum'+rownum+'.'+appnum);
125   if ( select_invnum.value ) {
126     invoices_for_row[rownum][ select_invnum.value ] = select_invnum.curr_invoice;
127   }
128     
129   tr_app.parentNode.removeChild(tr_app);
130   if ( appnum > 0 ) {
131     document.getElementById('delete'+rownum+'.'+(appnum-1)).style.display = '';
132   }
133   else {
134     lock_payment_row(rownum, false);
135     document.getElementById('enable_app'+rownum).checked = false;
136   }
137 }
138
139 function amount_unapplied(rownum) {
140   var appnum = 0;
141   var total = 0;
142   var payment_amount = parseFloat(document.getElementById('paid'+rownum).value)
143                        || 0;
144   while (true) {
145     var input_amount = document.getElementById('amount'+rownum+'.'+appnum);
146     if ( input_amount ) {
147       total += parseFloat(input_amount.value || 0);
148       appnum++;
149     }
150     else {
151       return payment_amount - total;
152     }
153   }
154 }
155
156 var change_app_amount;
157
158 function choose_app_invnum() {
159   var rownum = this.getAttribute('rownum');
160   var appnum = this.getAttribute('appnum');
161   var last_invoice = this.curr_invoice;
162   if ( last_invoice ) {
163     invoices_for_row[rownum][ last_invoice['invnum'] ] = last_invoice;
164   }
165
166   if ( this.value ) {
167     var this_invoice = invoices_for_row[rownum][this.value];
168     this.curr_invoice = invoices_for_row[rownum][this.value];
169     var span_owed = document.getElementById('owed'+rownum+'.'+appnum);
170     span_owed.innerHTML = this_invoice['owed'] + '&nbsp;';
171     delete invoices_for_row[rownum][this.value];
172
173     var input_amount = document.getElementById('amount'+rownum+'.'+appnum);
174     if ( input_amount.value == '' ) {
175       input_amount.value = 
176         Math.max(
177           0, Math.min( amount_unapplied(rownum), this_invoice['owed'])
178         ).toFixed(2);
179       // trigger onchange
180       change_app_amount.call(input_amount);
181     }
182   }
183 }
184
185 function focus_app_invnum() {
186 % # invoice numbers just display as invoice numbers
187   var rownum = this.getAttribute('rownum');
188   var add_opt = function(obj, value) {
189     var o = document.createElement('OPTION');
190     o.text = value;
191     o.value = value;
192     obj.add(o);
193   }
194   this.options.length = 0;
195   var this_invoice = this.curr_invoice;
196   if ( this_invoice ) {
197     add_opt(this, this_invoice.invnum);
198   } else {
199     add_opt(this, '');
200   }
201   for ( var x in invoices_for_row[rownum] ) {
202     add_opt(this, invoices_for_row[rownum][x].invnum);
203   }
204 }
205
206 function change_app_amount() {
207   var rownum = this.getAttribute('rownum');
208   var appnum = this.getAttribute('appnum');
209 %# maybe some kind of warning if amount_unapplied < 0?
210 %# only spawn a new application row if there are open invoices left,
211 %# and this is the highest-numbered application row for the customer,
212 %# and the sum of the applied amounts is < the amount of the payment,
213   if ( Object.keys(invoices_for_row[rownum]).length > 0
214        && !document.getElementById( 'row'+rownum+'.'+(parseInt(appnum) + 1) )
215        && amount_unapplied(rownum) > 0 ) {
216
217     create_application_row(rownum, parseInt(appnum) + 1);
218   }
219 }
220
221 function create_application_row(rownum, appnum) {
222   var payment_row = document.getElementById('row'+rownum);
223   var tr_app = document.createElement('TR');
224   tr_app.setAttribute('rownum', rownum);
225   tr_app.setAttribute('appnum', appnum);
226   tr_app.setAttribute('id', 'row'+rownum+'.'+appnum);
227   
228   var td_invnum = document.createElement('TD');
229   td_invnum.setAttribute('colspan', 4);
230   td_invnum.style.textAlign = 'right';
231   td_invnum.appendChild(
232     document.createTextNode('<% mt('Apply to Invoice ') %>')
233   );
234   var select_invnum = document.createElement('SELECT');
235   select_invnum.setAttribute('rownum', rownum);
236   select_invnum.setAttribute('appnum', appnum);
237   select_invnum.setAttribute('id', 'invnum'+rownum+'.'+appnum);
238   select_invnum.setAttribute('name', 'invnum'+rownum+'.'+appnum);
239   select_invnum.style.textAlign = 'right';
240   select_invnum.style.width = '50px';
241   select_invnum.onchange = choose_app_invnum;
242   select_invnum.onfocus  = focus_app_invnum;
243   
244   td_invnum.appendChild(select_invnum);
245   tr_app.appendChild(td_invnum);
246
247   var td_owed = document.createElement('TD');
248   td_owed.style.textAlign= 'right';
249   var span_owed = document.createElement('SPAN');
250   span_owed.setAttribute('rownum', rownum);
251   span_owed.setAttribute('appnum', appnum);
252   span_owed.setAttribute('id', 'owed'+rownum+'.'+appnum);
253   td_owed.appendChild(span_owed);
254   tr_app.appendChild(td_owed);
255
256   var td_amount = document.createElement('TD');
257   td_amount.style.textAlign = 'right';
258   var input_amount = document.createElement('INPUT');
259   input_amount.size = 6;
260   input_amount.setAttribute('rownum', rownum);
261   input_amount.setAttribute('appnum', appnum);
262   input_amount.setAttribute('name', 'amount'+rownum+'.'+appnum);
263   input_amount.setAttribute('id', 'amount'+rownum+'.'+appnum);
264   input_amount.style.textAlign = 'right';
265   input_amount.onchange = change_app_amount;
266   td_amount.appendChild(input_amount);
267   tr_app.appendChild(td_amount);
268
269   var td_delete = document.createElement('TD');
270   td_delete.setAttribute('colspan', <% scalar(@fields)-2 %>);
271   var button_delete = document.createElement('INPUT');
272   button_delete.setAttribute('rownum', rownum);
273   button_delete.setAttribute('appnum', appnum);
274   button_delete.setAttribute('id', 'delete'+rownum+'.'+appnum);
275   button_delete.setAttribute('type', 'button');
276   button_delete.setAttribute('value', 'X');
277   button_delete.onclick = delete_application_row;
278   button_delete.style.color = '#ff0000';
279   button_delete.style.fontWeight = 'bold';
280   button_delete.style.paddingLeft = '2px';
281   button_delete.style.paddingRight = '2px';
282   td_delete.appendChild(button_delete);
283   tr_app.appendChild(td_delete);
284
285   var td_error = document.createElement('TD');
286   var span_error = document.createElement('SPAN');
287   span_error.setAttribute('rownum', rownum);
288   span_error.setAttribute('appnum', appnum);
289   span_error.setAttribute('id', 'error'+rownum+'.'+appnum);
290   span_error.style.color = '#ff0000';
291   td_error.appendChild(span_error);
292   tr_app.appendChild(td_error);
293
294   if ( appnum > 0 ) {
295     //remove delete button on the previous row
296     document.getElementById('delete'+rownum+'.'+(appnum-1)).style.display = 'none';
297   }
298   rownum++;
299   var next_row = document.getElementById('row'+rownum); // always exists
300   payment_row.parentNode.insertBefore(tr_app, next_row);
301
302 }
303
304 %# for error handling--ugly, but the alternative is translating the whole 
305 %# process of creating rows into Mason
306 var row_array = <% encode_json(\@rows) %>;
307 function preload() {
308   var rownum;
309   var appnum;
310   for (rownum=0; rownum < row_array.length; rownum++) {
311     if ( row_array[rownum].length ) {
312       var enable = document.getElementById('enable_app'+rownum);
313       enable.checked = true;
314       var preload_row = function(r) {//continuation from toggle_application_row
315         for (appnum=0; appnum < row_array[r].length; appnum++) {
316           this_app = row_array[r][appnum];
317           var x = r + '.' + appnum;
318           //set invnum
319           var select_invnum = document.getElementById('invnum'+x);
320           focus_app_invnum.call(select_invnum);
321           for (i=0; i<select_invnum.options.length; i++) {
322             if (select_invnum.options[i].value == this_app.invnum) {
323               select_invnum.selectedIndex = i;
324             }
325           }
326           choose_app_invnum.call(select_invnum);
327           //set amount
328           var input_amount = document.getElementById('amount'+x);
329           input_amount.value = this_app.amount;
330
331           //set error
332           var span_error = document.getElementById('error'+x);
333           span_error.innerHTML = this_app.error;
334           change_app_amount.call(input_amount); //creates next row
335         } //for appnum
336       }; //preload_row function
337       toggle_application_row.call(enable, null, preload_row);
338     } // if row_array[rownum].length
339   } //for rownum
340 }
341
342 </SCRIPT>
343
344 <% include('/elements/xmlhttp.html',
345               'url'  => $p. 'misc/xmlhttp-cust_main-discount_terms.cgi',
346               'subs' => [qw( discount_terms )],
347            )
348 %>
349
350 <FORM ACTION="process/batch-cust_pay.cgi" NAME="OneTrueForm" METHOD="POST" onsubmit="document.OneTrueForm.btnsubmit.disabled=true;window.onbeforeunload = null;">
351
352 <!-- <B>Batch</B> <INPUT TYPE="text" NAME="paybatch"><BR><BR> -->
353 <& /elements/xmlhttp.html,
354     url => $p.'misc/xmlhttp-cust_bill-search.html',
355     subs => ['custnum_search_open']
356 &>
357
358 <& /elements/customer-table.html,
359     name_singular => 'payment',
360     header  => \@header,
361     fields  => \@fields,
362     type    => \@types,
363     align   => \@align,
364     size    => \@sizes,
365     color   => \@colors,
366     param   => \%param,
367     footer  => \@footer,
368     footer_align => \@footer_align,
369     onchange => \@onchange,
370     custnum_update_callback => 'custnum_update_callback',
371     invnum_update_callback => 'invnum_update_callback',
372     add_row_callback => 'add_row_callback',
373 &>
374
375 <BR>
376 <INPUT TYPE="button" VALUE="Post payment batch" name="btnsubmit" onclick="window.onbeforeunload = null; document.OneTrueForm.submit(); this.disabled = true;">
377
378 </FORM>
379
380 %if ( $cgi->param('error') ) {
381 <SCRIPT TYPE="text/javascript">
382 %  for ( my $row = 0; defined($cgi->param("custnum$row")); $row++ ) {
383      select_discount_term(<% $row %>, '');
384 %  }
385 </SCRIPT>
386 %}
387
388 <% include('/elements/footer.html') %>
389
390 <%init>
391
392 die "access denied"
393   unless $FS::CurrentUser::CurrentUser->access_right('Post payment batch');
394
395 my $conf = new FS::Conf;
396 my $money_char = $conf->config('money_char') || '$';
397
398 my @header  = ( 'Amount', 'Check #' );
399 my @fields  = ( 'paid', 'payinfo' );
400 my @types   = ( '', '' );
401 my @align   = ( 'r', 'r' );
402 my @sizes   = ( 8, 10 );
403 my @colors  = ( '', '' );
404 my %param   = ();
405 my @footer  = ( '_TOTAL', '' );
406 my @footer_align = ( 'r', 'r' );
407 my @onchange = ( '', '' );;
408 my $use_discounts = '';
409
410 if ( FS::Record->scalar_sql('SELECT COUNT(*) FROM part_pkg_discount') ) {
411   #push @header, 'Discount';
412   push @header, '';
413   push @fields, 'discount_term';
414   push @types, 'immutable';
415   push @align, 'r';
416   push @sizes, '0';
417   push @colors, '';
418   push @footer, '';
419   push @footer_align, '';
420   push @onchange, '';
421   $use_discounts = 'Y';
422 }
423
424 push @header, 'Allocate';
425 push @fields, 'enable_app';
426 push @types, 'checkbox';
427 push @align, 'c';
428 push @sizes, '0';
429 push @colors, '';
430 push @footer, '';
431 push @footer_align, '';
432 push @onchange, 'toggle_application_row';
433
434 #push @header, 'Error';
435 push @header, '';
436 push @fields, 'error';
437 push @types, 'immutable';
438 push @align, 'l';
439 push @sizes, '0';
440 push @colors, '#ff0000';
441 push @footer, '';
442 push @footer_align, '';
443 push @onchange, '';
444
445 $m->comp('/elements/handle_uri_query');
446
447 # set up for preloading
448 my @rows;
449 my @row_errors;
450 if ( $cgi->param('error') ) {
451   my $param = $cgi->Vars;
452   my $enum = 0; #errors numbered separately
453   for( my $row = 0; exists($param->{"custnum$row"}); $row++ ) {
454     $rows[$row] = [];
455     $row_errors[$row] = $param->{"error$enum"};
456     $enum++;
457     for( my $app = 0; exists($param->{"invnum$row.$app"}); $app++ ) {
458       next if !$param->{"invnum$row.$app"};
459       my %this_app = map { $_ => ($param->{$_.$row.'.'.$app} || '') } 
460         qw( invnum amount );
461       $this_app{'error'} = $param->{"error$enum"} || '';
462       $param->{"error$enum"} = ''; # don't pass this error through
463       $rows[$row][$app] = \%this_app;
464       $enum++;
465     }
466   }
467   for( my $row = 0; $row < @row_errors; $row++ ) {
468     $param->{"error$row"} = $row_errors[$row];
469   }
470 }
471 #warn Dumper {rows => \@rows, row_errors => \@row_errors };
472
473 </%init>