FS RT #27208 - adding Mobile phone to FS::ConfDefaults::cust_fields_avail and related...
[freeside.git] / FS / FS / UI / Web.pm
1 package FS::UI::Web;
2
3 use strict;
4 use vars qw($DEBUG @ISA @EXPORT_OK $me);
5 use Exporter;
6 use Carp qw( confess );
7 use HTML::Entities;
8 use FS::Conf;
9 use FS::Misc::DateTime qw( parse_datetime day_end );
10 use FS::Record qw(dbdef);
11 use FS::cust_main;  # are sql_balance and sql_date_balance in the right module?
12
13 #use vars qw(@ISA);
14 #use FS::UI
15 #@ISA = qw( FS::UI );
16 @ISA = qw( Exporter );
17
18 @EXPORT_OK = qw( svc_url );
19
20 $DEBUG = 0;
21 $me = '[FS::UID::Web]';
22
23 ###
24 # date parsing
25 ###
26
27 use Date::Parse;
28 sub parse_beginning_ending {
29   my($cgi, $prefix) = @_;
30   $prefix .= '_' if $prefix;
31
32   my $beginning = 0;
33   if ( $cgi->param($prefix.'begin') =~ /^(\d+)$/ ) {
34     $beginning = $1;
35   } elsif ( $cgi->param($prefix.'beginning') =~ /^([ 0-9\-\/\:]{1,64})$/ ) {
36     $beginning = parse_datetime($1) || 0;
37   }
38
39   my $ending = 4294967295; #2^32-1
40   if ( $cgi->param($prefix.'end') =~ /^(\d+)$/ ) {
41     $ending = $1 - 1;
42   } elsif ( $cgi->param($prefix.'ending') =~ /^([ 0-9\-\/\:]{1,64})$/ ) {
43     $ending = parse_datetime($1);
44     $ending = day_end($ending) unless $ending =~ /:/;
45   }
46
47   ( $beginning, $ending );
48 }
49
50 =item svc_url
51
52 Returns a service URL, first checking to see if there is a service-specific
53 page to link to, otherwise to a generic service handling page.  Options are
54 passed as a list of name-value pairs, and include:
55
56 =over 4
57
58 =item * m - Mason request object ($m)
59
60 =item * action - The action for which to construct "edit", "view", or "search"
61
62 =item ** part_svc - Service definition (see L<FS::part_svc>)
63
64 =item ** svcdb - Service table
65
66 =item *** query - Query string
67
68 =item *** svc   - FS::cust_svc or FS::svc_* object
69
70 =item ahref - Optional flag, if set true returns <A HREF="$url"> instead of just the URL.
71
72 =back 
73
74 * Required fields
75
76 ** part_svc OR svcdb is required
77
78 *** query OR svc is required
79
80 =cut
81
82   # ##
83   # #required
84   # ##
85   #  'm'        => $m, #mason request object
86   #  'action'   => 'edit', #or 'view'
87   #
88   #  'part_svc' => $part_svc, #usual
89   #   #OR
90   #  'svcdb'    => 'svc_table',
91   #
92   #  'query'    => #optional query string
93   #                # (pass a blank string if you want a "raw" URL to add your
94   #                #  own svcnum to)
95   #   #OR
96   #  'svc'      => $svc_x, #or $cust_svc, it just needs a svcnum
97   #
98   # ##
99   # #optional
100   # ##
101   #  'ahref'    => 1, # if set true, returns <A HREF="$url">
102
103 use FS::CGI qw(rooturl);
104 sub svc_url {
105   my %opt = @_;
106
107   #? return '' unless ref($opt{part_svc});
108
109   my $svcdb = $opt{svcdb} || $opt{part_svc}->svcdb;
110   my $query = exists($opt{query}) ? $opt{query} : $opt{svc}->svcnum;
111   my $url;
112   warn "$me [svc_url] checking for /$opt{action}/$svcdb.cgi component"
113     if $DEBUG;
114   if ( $opt{m}->interp->comp_exists("/$opt{action}/$svcdb.cgi") ) {
115     $url = "$svcdb.cgi?";
116   } else {
117
118     my $generic = $opt{action} eq 'search' ? 'cust_svc' : 'svc_Common';
119
120     $url = "$generic.html?svcdb=$svcdb;";
121     $url .= 'svcnum=' if $query =~ /^\d+(;|$)/ or $query eq '';
122   }
123
124   import FS::CGI 'rooturl'; #WTF!  why is this necessary
125   my $return = rooturl(). "$opt{action}/$url$query";
126
127   $return = qq!<A HREF="$return">! if $opt{ahref};
128
129   $return;
130 }
131
132 sub svc_link {
133   my($m, $part_svc, $cust_svc) = @_ or return '';
134   svc_X_link( $part_svc->svc, @_ );
135 }
136
137 sub svc_label_link {
138   my($m, $part_svc, $cust_svc) = @_ or return '';
139   my($svc, $label, $svcdb) = $cust_svc->label;
140   svc_X_link( $label, @_ );
141 }
142
143 sub svc_X_link {
144   my ($x, $m, $part_svc, $cust_svc) = @_ or return '';
145
146   return $x
147    unless $FS::CurrentUser::CurrentUser->access_right('View customer services');
148
149   confess "svc_X_link called without a service ($x, $m, $part_svc, $cust_svc)\n"
150     unless $cust_svc;
151
152   my $ahref = svc_url(
153     'ahref'    => 1,
154     'm'        => $m,
155     'action'   => 'view',
156     'part_svc' => $part_svc,
157     'svc'      => $cust_svc,
158   );
159
160   "$ahref$x</A>";
161 }
162
163 #this probably needs an ACL too...
164 sub svc_export_links {
165   my ($m, $part_svc, $cust_svc) = @_ or return '';
166
167   my $ahref = $cust_svc->export_links;
168
169   join('', @$ahref);
170 }
171
172 sub parse_lt_gt {
173   my($cgi, $field) = @_;
174
175   my @search = ();
176
177   my %op = ( 
178     'lt' => '<',
179     'gt' => '>',
180   );
181
182   foreach my $op (keys %op) {
183
184     warn "checking for ${field}_$op field\n"
185       if $DEBUG;
186
187     if ( $cgi->param($field."_$op") =~ /^\s*\$?\s*(-?[\d\,\s]+(\.\d\d)?)\s*$/ ) {
188
189       my $num = $1;
190       $num =~ s/[\,\s]+//g;
191       my $search = "$field $op{$op} $num";
192       push @search, $search;
193
194       warn "found ${field}_$op field; adding search element $search\n"
195         if $DEBUG;
196     }
197
198   }
199
200   @search;
201
202 }
203
204 ###
205 # cust_main report subroutines
206 ###
207
208
209 =item cust_header [ CUST_FIELDS_VALUE ]
210
211 Returns an array of customer information headers according to the supplied
212 customer fields value, or if no value is supplied, the B<cust-fields>
213 configuration value.
214
215 =cut
216
217 use vars qw( @cust_fields @cust_colors @cust_styles @cust_aligns );
218
219 sub cust_header {
220
221   warn "FS::UI:Web::cust_header called"
222     if $DEBUG;
223
224   my $conf = new FS::Conf;
225
226   my %header2method = (
227     'Customer'                 => 'name',
228     'Cust. Status'             => 'ucfirst_cust_status',
229     'Cust#'                    => 'custnum',
230     'Name'                     => 'contact',
231     'Company'                  => 'company',
232
233     # obsolete but might still be referenced in configuration
234     '(bill) Customer'          => 'name',
235     '(service) Customer'       => 'ship_name',
236     '(bill) Name'              => 'contact',
237     '(service) Name'           => 'ship_contact',
238     '(bill) Company'           => 'company',
239     '(service) Company'        => 'ship_company',
240     '(bill) Day phone'         => 'daytime',
241     '(bill) Night phone'       => 'night',
242     '(bill) Fax number'        => 'fax',
243  
244     'Customer'                 => 'name',
245     'Address 1'                => 'bill_address1',
246     'Address 2'                => 'bill_address2',
247     'City'                     => 'bill_city',
248     'State'                    => 'bill_state',
249     'Zip'                      => 'bill_zip',
250     'Country'                  => 'bill_country_full',
251     'Day phone'                => 'daytime', # XXX should use msgcat, but how?
252     'Night phone'              => 'night',   # XXX should use msgcat, but how?
253     'Mobile phone'             => 'mobile',  # XXX should use msgcat, but how?
254     'Fax number'               => 'fax',
255     '(bill) Address 1'         => 'bill_address1',
256     '(bill) Address 2'         => 'bill_address2',
257     '(bill) City'              => 'bill_city',
258     '(bill) State'             => 'bill_state',
259     '(bill) Zip'               => 'bill_zip',
260     '(bill) Country'           => 'bill_country_full',
261     '(service) Address 1'      => 'ship_address1',
262     '(service) Address 2'      => 'ship_address2',
263     '(service) City'           => 'ship_city',
264     '(service) State'          => 'ship_state',
265     '(service) Zip'            => 'ship_zip',
266     '(service) Country'        => 'ship_country_full',
267     'Invoicing email(s)'       => 'invoicing_list_emailonly_scalar',
268     'Payment Type'             => 'payby',
269     'Current Balance'          => 'current_balance',
270   );
271   $header2method{'Cust#'} = 'display_custnum'
272     if $conf->exists('cust_main-default_agent_custid');
273
274   my %header2colormethod = (
275     'Cust. Status' => 'cust_statuscolor',
276   );
277   my %header2style = (
278     'Cust. Status' => 'b',
279   );
280   my %header2align = (
281     'Cust. Status' => 'c',
282     'Cust#'        => 'r',
283   );
284
285   my $cust_fields;
286   my @cust_header;
287   if ( @_ && $_[0] ) {
288
289     warn "  using supplied cust-fields override".
290           " (ignoring cust-fields config file)"
291       if $DEBUG;
292     $cust_fields = shift;
293
294   } else {
295
296     if (    $conf->exists('cust-fields')
297          && $conf->config('cust-fields') =~ /^([\w\. \|\#\(\)]+):?/
298        )
299     {
300       warn "  found cust-fields configuration value"
301         if $DEBUG;
302       $cust_fields = $1;
303     } else { 
304       warn "  no cust-fields configuration value found; using default 'Cust. Status | Customer'"
305         if $DEBUG;
306       $cust_fields = 'Cust. Status | Customer';
307     }
308   
309   }
310
311   @cust_header = split(/ \| /, $cust_fields);
312   @cust_fields = map { $header2method{$_} || $_ } @cust_header;
313   @cust_colors = map { exists $header2colormethod{$_}
314                          ? $header2colormethod{$_}
315                          : ''
316                      }
317                      @cust_header;
318   @cust_styles = map { exists $header2style{$_} ? $header2style{$_} : '' }
319                      @cust_header;
320   @cust_aligns = map { exists $header2align{$_} ? $header2align{$_} : 'l' }
321                      @cust_header;
322
323   #my $svc_x = shift;
324   @cust_header;
325 }
326
327 sub cust_sort_fields {
328   cust_header(@_) if( @_ or !@cust_fields );
329   #inefficientish, but tiny lists and only run once per page
330
331   map { $_ eq 'custnum' ? 'custnum' : '' } @cust_fields;
332
333 }
334
335 =item cust_sql_fields [ CUST_FIELDS_VALUE ]
336
337 Returns a list of fields for the SELECT portion of an SQL query.
338
339 As with L<the cust_header subroutine|/cust_header>, the fields returned are
340 defined by the supplied customer fields setting, or if no customer fields
341 setting is supplied, the <B>cust-fields</B> configuration value. 
342
343 =cut
344
345 sub cust_sql_fields {
346
347   my @fields = qw( last first company );
348 #  push @fields, map "ship_$_", @fields;
349
350   cust_header(@_) if( @_ or !@cust_fields );
351   #inefficientish, but tiny lists and only run once per page
352
353   my @location_fields;
354   foreach my $field (qw( address1 address2 city state zip )) {
355     foreach my $pre ('bill_','ship_') {
356       if ( grep { $_ eq $pre.$field } @cust_fields ) {
357         push @location_fields, $pre.'location.'.$field.' AS '.$pre.$field;
358       }
359     }
360   }
361   foreach my $pre ('bill_','ship_') {
362     if ( grep { $_ eq $pre.'country_full' } @cust_fields ) {
363       push @location_fields, $pre.'locationnum';
364     }
365   }
366
367   foreach my $field (qw(daytime night mobile fax payby)) {
368     push @fields, $field if (grep { $_ eq $field } @cust_fields);
369   }
370   push @fields, 'agent_custid';
371
372   my @extra_fields = ();
373   if (grep { $_ eq 'current_balance' } @cust_fields) {
374     push @extra_fields, FS::cust_main->balance_sql . " AS current_balance";
375   }
376
377   map("cust_main.$_", @fields), @location_fields, @extra_fields;
378 }
379
380 =item join_cust_main [ TABLE[.CUSTNUM] ] [ LOCATION_TABLE[.LOCATIONNUM] ]
381
382 Returns an SQL join phrase for the FROM clause so that the fields listed
383 in L<cust_sql_fields> will be available.  Currently joins to cust_main 
384 itself, as well as cust_location (under the aliases 'bill_location' and
385 'ship_location') if address fields are needed.  L<cust_header()> should have
386 been called already.
387
388 All of these will be left joins; if you want to exclude rows with no linked
389 cust_main record (or bill_location/ship_location), you can do so in the 
390 WHERE clause.
391
392 TABLE is the table containing the custnum field.  If CUSTNUM (a field name
393 in that table) is specified, that field will be joined to cust_main.custnum.
394 Otherwise, this function will assume the field is named "custnum".  If the 
395 argument isn't present at all, the join will just say "USING (custnum)", 
396 which might work.
397
398 As a special case, if TABLE is 'cust_main', only the joins to cust_location
399 will be returned.
400
401 LOCATION_TABLE is an optional table name to use for joining ship_location,
402 in case your query also includes package information and you want the 
403 "service address" columns to reflect package addresses.
404
405 =cut
406
407 sub join_cust_main {
408   my ($cust_table, $location_table) = @_;
409   my ($custnum, $locationnum);
410   ($cust_table, $custnum) = split(/\./, $cust_table);
411   $custnum ||= 'custnum';
412   ($location_table, $locationnum) = split(/\./, $location_table);
413   $locationnum ||= 'locationnum';
414
415   my $sql = '';
416   if ( $cust_table ) {
417     $sql = " LEFT JOIN cust_main ON (cust_main.custnum = $cust_table.$custnum)"
418       unless $cust_table eq 'cust_main';
419   } else {
420     $sql = " LEFT JOIN cust_main USING (custnum)";
421   }
422
423   if ( !@cust_fields or grep /^bill_/, @cust_fields ) {
424
425     $sql .= ' LEFT JOIN cust_location bill_location'.
426             ' ON (bill_location.locationnum = cust_main.bill_locationnum)';
427
428   }
429
430   if ( !@cust_fields or grep /^ship_/, @cust_fields ) {
431
432     if (!$location_table) {
433       $location_table = 'cust_main';
434       $locationnum = 'ship_locationnum';
435     }
436
437     $sql .= ' LEFT JOIN cust_location ship_location'.
438             " ON (ship_location.locationnum = $location_table.$locationnum) ";
439   }
440
441   $sql;
442 }
443
444 =item cust_fields OBJECT [ CUST_FIELDS_VALUE ]
445
446 Given an object that contains fields from cust_main (say, from a
447 JOINed search.  See httemplate/search/svc_* for examples), returns an array
448 of customer information, or "(unlinked)" if this service is not linked to a
449 customer.
450
451 As with L<the cust_header subroutine|/cust_header>, the fields returned are
452 defined by the supplied customer fields setting, or if no customer fields
453 setting is supplied, the <B>cust-fields</B> configuration value. 
454
455 =cut
456
457
458 sub cust_fields {
459   my $record = shift;
460   warn "FS::UI::Web::cust_fields called for $record ".
461        "(cust_fields: @cust_fields)"
462     if $DEBUG > 1;
463
464   #cust_header(@_) unless @cust_fields; #now need to cache to keep cust_fields
465   #                                     #override incase we were passed as a sub
466   
467   my $seen_unlinked = 0;
468
469   map { 
470     if ( $record->custnum ) {
471       warn "  $record -> $_" if $DEBUG > 1;
472       encode_entities( $record->$_(@_) );
473     } else {
474       warn "  ($record unlinked)" if $DEBUG > 1;
475       $seen_unlinked++ ? '' : '(unlinked)';
476     }
477   } @cust_fields;
478 }
479
480 =item cust_fields_subs
481
482 Returns an array of subroutine references for returning customer field values.
483 This is similar to cust_fields, but returns each field's sub as a distinct 
484 element.
485
486 =cut
487
488 sub cust_fields_subs {
489   my $unlinked_warn = 0;
490
491   return map { 
492     my $f = $_;
493     if ( $unlinked_warn++ ) {
494
495       sub {
496         my $record = shift;
497         if ( $record->custnum ) {
498           encode_entities( $record->$f(@_) );
499         } else {
500           '(unlinked)'
501         };
502       };
503
504     } else {
505
506       sub {
507         my $record = shift;
508         $record->custnum ? encode_entities( $record->$f(@_) ) : '';
509       };
510
511     }
512
513   } @cust_fields;
514 }
515
516 =item cust_colors
517
518 Returns an array of subroutine references (or empty strings) for returning
519 customer information colors.
520
521 As with L<the cust_header subroutine|/cust_header>, the fields returned are
522 defined by the supplied customer fields setting, or if no customer fields
523 setting is supplied, the <B>cust-fields</B> configuration value. 
524
525 =cut
526
527 sub cust_colors {
528   map { 
529     my $method = $_;
530     if ( $method ) {
531       sub { shift->$method(@_) };
532     } else {
533       '';
534     }
535   } @cust_colors;
536 }
537
538 =item cust_styles
539
540 Returns an array of customer information styles.
541
542 As with L<the cust_header subroutine|/cust_header>, the fields returned are
543 defined by the supplied customer fields setting, or if no customer fields
544 setting is supplied, the <B>cust-fields</B> configuration value. 
545
546 =cut
547
548 sub cust_styles {
549   map { 
550     if ( $_ ) {
551       $_;
552     } else {
553       '';
554     }
555   } @cust_styles;
556 }
557
558 =item cust_aligns
559
560 Returns an array or scalar (depending on context) of customer information
561 alignments.
562
563 As with L<the cust_header subroutine|/cust_header>, the fields returned are
564 defined by the supplied customer fields setting, or if no customer fields
565 setting is supplied, the <B>cust-fields</B> configuration value. 
566
567 =cut
568
569 sub cust_aligns {
570   if ( wantarray ) {
571     @cust_aligns;
572   } else {
573     join('', @cust_aligns);
574   }
575 }
576
577 =item is_mobile
578
579 Utility function to determine if the client is a mobile browser.
580
581 =cut
582
583 sub is_mobile {
584   my $ua = $ENV{'HTTP_USER_AGENT'} || '';
585   if ( $ua =~ /(?:hiptop|Blazer|Novarra|Vagabond|SonyEricsson|Symbian|NetFront|UP.Browser|UP.Link|Windows CE|MIDP|J2ME|DoCoMo|J-PHONE|PalmOS|PalmSource|iPhone|iPod|AvantGo|Nokia|Android|WebOS|S60|Opera Mini|Opera Mobi)/io ) {
586     return 1;
587   }
588   return 0;
589 }
590     
591 ###
592 # begin JSRPC code...
593 ###
594
595 package FS::UI::Web::JSRPC;
596
597 use strict;
598 use vars qw($DEBUG);
599 use Carp;
600 use Storable qw(nfreeze);
601 use MIME::Base64;
602 use JSON::XS;
603 use FS::CurrentUser;
604 use FS::Record qw(qsearchs);
605 use FS::queue;
606 use FS::CGI qw(rooturl);
607
608 $DEBUG = 0;
609
610 sub new {
611         my $class = shift;
612         my $self  = {
613                 env => {},
614                 job => shift,
615                 cgi => shift,
616         };
617
618         bless $self, $class;
619
620         croak "CGI object required as second argument" unless $self->{'cgi'};
621
622         return $self;
623 }
624
625 sub process {
626
627   my $self = shift;
628
629   my $cgi = $self->{'cgi'};
630
631   # XXX this should parse JSON foo and build a proper data structure
632   my @args = $cgi->param('arg');
633
634   #work around konqueror bug!
635   @args = map { s/\x00$//; $_; } @args;
636
637   my $sub = $cgi->param('sub'); #????
638
639   warn "FS::UI::Web::JSRPC::process:\n".
640        "  cgi=$cgi\n".
641        "  sub=$sub\n".
642        "  args=".join(', ',@args)."\n"
643     if $DEBUG;
644
645   if ( $sub eq 'start_job' ) {
646
647     $self->start_job(@args);
648
649   } elsif ( $sub eq 'job_status' ) {
650
651     $self->job_status(@args);
652
653   } else {
654
655     die "unknown sub $sub";
656
657   }
658
659 }
660
661 sub start_job {
662   my $self = shift;
663
664   warn "FS::UI::Web::start_job: ". join(', ', @_) if $DEBUG;
665 #  my %param = @_;
666   my %param = ();
667   while ( @_ ) {
668     my( $field, $value ) = splice(@_, 0, 2);
669     unless ( exists( $param{$field} ) ) {
670       $param{$field} = $value;
671     } elsif ( ! ref($param{$field}) ) {
672       $param{$field} = [ $param{$field}, $value ];
673     } else {
674       push @{$param{$field}}, $value;
675     }
676   }
677   $param{CurrentUser} = $FS::CurrentUser::CurrentUser->username;
678   $param{RootURL} = rooturl($self->{cgi}->self_url);
679   warn "FS::UI::Web::start_job\n".
680        join('', map {
681                       if ( ref($param{$_}) ) {
682                         "  $_ => [ ". join(', ', @{$param{$_}}). " ]\n";
683                       } else {
684                         "  $_ => $param{$_}\n";
685                       }
686                     } keys %param )
687     if $DEBUG;
688
689   #first get the CGI params shipped off to a job ASAP so an id can be returned
690   #to the caller
691   
692   my $job = new FS::queue { 'job' => $self->{'job'} };
693   
694   #too slow to insert all the cgi params as individual args..,?
695   #my $error = $queue->insert('_JOB', $cgi->Vars);
696   
697   #warn 'froze string of size '. length(nfreeze(\%param)). " for job args\n"
698   #  if $DEBUG;
699   #
700   #  XXX FS::queue::insert knows how to do this.
701   #  not changing it here because that requires changing it everywhere else,
702   #  too, but we should eventually fix it
703
704   my $error = $job->insert( '_JOB', encode_base64(nfreeze(\%param)) );
705
706   if ( $error ) {
707
708     warn "job not inserted: $error\n"
709       if $DEBUG;
710
711     $error;  #this doesn't seem to be handled well,
712              # will trigger "illegal jobnum" below?
713              # (should never be an error inserting the job, though, only thing
714              #  would be Pg f%*kage)
715   } else {
716
717     warn "job inserted successfully with jobnum ". $job->jobnum. "\n"
718       if $DEBUG;
719
720     $job->jobnum;
721   }
722   
723 }
724
725 sub job_status {
726   my( $self, $jobnum ) = @_; #$url ???
727
728   sleep 1; # XXX could use something better...
729
730   my $job;
731   if ( $jobnum =~ /^(\d+)$/ ) {
732     $job = qsearchs('queue', { 'jobnum' => $jobnum } );
733   } else {
734     die "FS::UI::Web::job_status: illegal jobnum $jobnum\n";
735   }
736
737   my @return;
738   if ( $job && $job->status ne 'failed' && $job->status ne 'done' ) {
739     my ($progress, $action) = split ',', $job->statustext, 2; 
740     $action ||= 'Server processing job';
741     @return = ( 'progress', $progress, $action );
742   } elsif ( !$job ) { #handle job gone case : job successful
743                       # so close popup, redirect parent window...
744     @return = ( 'complete' );
745   } elsif ( $job->status eq 'done' ) {
746     @return = ( 'done', $job->statustext, '' );
747   } else {
748     @return = ( 'error', $job ? $job->statustext : $jobnum );
749   }
750
751   encode_json \@return;
752
753 }
754
755 1;
756