Neuer Part Controller
authorG. Richardson <information@kivitendo-premium.de>
Sun, 7 Aug 2016 20:20:09 +0000 (22:20 +0200)
committerG. Richardson <information@kivitendo-premium.de>
Tue, 22 Nov 2016 13:42:49 +0000 (14:42 +0100)
Soll ic.pl komplett ersetzen.

21 files changed:
SL/Controller/Part.pm
js/kivi.Order.js
js/kivi.Part.js [new file with mode: 0644]
js/locale/de.js
locale/de/all
templates/webpages/part/_assembly.html [new file with mode: 0644]
templates/webpages/part/_assembly_row.html [new file with mode: 0644]
templates/webpages/part/_assortment.html [new file with mode: 0644]
templates/webpages/part/_assortment_row.html [new file with mode: 0644]
templates/webpages/part/_basic_data.html [new file with mode: 0644]
templates/webpages/part/_cvars.html [new file with mode: 0644]
templates/webpages/part/_edit_translations.html [new file with mode: 0644]
templates/webpages/part/_makemodel.html [new file with mode: 0644]
templates/webpages/part/_makemodel_row.html [new file with mode: 0644]
templates/webpages/part/_multi_assortments_dialog.html [new file with mode: 0644]
templates/webpages/part/_multi_items_dialog.html [new file with mode: 0644]
templates/webpages/part/_multi_items_result.html [new file with mode: 0644]
templates/webpages/part/_pricegroup_prices.html [new file with mode: 0644]
templates/webpages/part/_sales_price_information.html [new file with mode: 0644]
templates/webpages/part/form.html [new file with mode: 0644]
templates/webpages/part/history.html [new file with mode: 0644]

index 7b812e8..5622193 100644 (file)
@@ -8,15 +8,473 @@ use SL::DB::Part;
 use SL::Controller::Helper::GetModels;
 use SL::Locale::String qw(t8);
 use SL::JSON;
+use List::Util qw(sum);
+use SL::Helper::Flash;
+use Data::Dumper;
+use DateTime;
+use SL::DB::History;
+use SL::CVar;
+use Carp;
 
 use Rose::Object::MakeMethods::Generic (
-  'scalar --get_set_init' => [ qw(parts models part) ],
+  'scalar --get_set_init' => [ qw(parts models part p warehouses multi_items_models
+                                  makemodels
+                                  orphaned
+                                  assortment assortment_items assembly assembly_items
+                                  all_pricegroups all_translations all_partsgroups all_units
+                                  all_buchungsgruppen all_payment_terms all_warehouses
+                                  all_languages all_units all_pricefactors) ],
+  'scalar'                => [ qw(warehouse bin) ],
 );
 
 # safety
 __PACKAGE__->run_before(sub { $::auth->assert('part_service_assembly_edit') },
                         except => [ qw(ajax_autocomplete part_picker_search part_picker_result) ]);
 
+__PACKAGE__->run_before('check_part_id', only   => [ qw(edit delete) ]);
+
+# actions for editing parts
+#
+sub action_add_part {
+  my ($self, %params) = @_;
+
+  $self->part( SL::DB::Part->new_part );
+  $self->add;
+};
+
+sub action_add_service {
+  my ($self, %params) = @_;
+
+  $self->part( SL::DB::Part->new_service );
+  $self->add;
+};
+
+sub action_add_assembly {
+  my ($self, %params) = @_;
+
+  $self->part( SL::DB::Part->new_assembly );
+  $self->add;
+};
+
+sub action_add_assortment {
+  my ($self, %params) = @_;
+
+  $self->part( SL::DB::Part->new_assortment );
+  $self->add;
+};
+
+sub action_add {
+  my ($self) = @_;
+
+  check_has_valid_part_type($::form->{part_type});
+
+  $self->action_add_part       if $::form->{part_type} eq 'part';
+  $self->action_add_service    if $::form->{part_type} eq 'service';
+  $self->action_add_assembly   if $::form->{part_type} eq 'assembly';
+  $self->action_add_assortment if $::form->{part_type} eq 'assortment';
+};
+
+sub action_save {
+  my ($self, %params) = @_;
+
+  # checks that depend only on submitted $::form
+  $self->check_form or return $self->js->render;
+
+  my $is_new = !$self->part->id; # $ part gets loaded here
+
+  # check that the part hasn't been modified
+  unless ( $is_new ) {
+    $self->check_part_not_modified or
+      return $self->js->error(t8('The document has been changed by another user. Please reopen it in another window and copy the changes to the new window'))->render;
+  }
+
+  if ( $is_new and !$::form->{part}{partnumber} ) {
+    $self->check_next_transnumber_is_free or return $self->js->error(t8('The next partnumber in the number range already exists!'))->render;
+  }
+
+  $self->parse_form;
+
+  my @errors = $self->part->validate;
+  return $self->js->error(@errors)->render if @errors;
+
+  # $self->part has been loaded, parsed and validated without errors and is ready to be saved
+  $self->part->db->with_transaction(sub {
+
+    if ( $params{save_as_new} ) {
+      $self->part( $self->part->clone_and_reset_deep );
+      $self->part->partnumber(undef); # will be assigned by _before_save_set_partnumber
+    };
+
+    $self->part->save(cascade => 1);
+
+    SL::DB::History->new(
+      trans_id    => $self->part->id,
+      snumbers    => 'partnumber_' . $self->part->partnumber,
+      employee_id => SL::DB::Manager::Employee->current->id,
+      what_done   => 'part',
+      addition    => 'SAVED',
+    )->save();
+
+    CVar->save_custom_variables(
+        dbh          => $self->part->db->dbh,
+        module       => 'IC',
+        trans_id     => $self->part->id,
+        variables    => $::form, # $::form->{cvar} would be nicer
+        always_valid => 1,
+    );
+
+    1;
+  }) or return $self->js->error(t8('The item couldn\'t be saved!') . " " . $self->part->db->error )->render;
+
+  flash_later('info', $is_new ? t8('The item has been created.') : t8('The item has been saved.'));
+
+  # reload item, this also resets last_modification!
+  $self->redirect_to(controller => 'Part', action => 'edit', 'part.id' => $self->part->id);
+}
+
+sub action_save_as_new {
+  my ($self) = @_;
+  $self->action_save(save_as_new=>1);
+}
+
+sub action_delete {
+  my ($self) = @_;
+
+  my $db = $self->part->db; # $self->part has a get_set_init on $::form
+
+  my $partnumber = $self->part->partnumber; # remember for history log
+
+  $db->do_transaction(
+    sub {
+
+      # delete part, together with relationships that don't already
+      # have an ON DELETE CASCADE, e.g. makemodel and translation.
+      $self->part->delete(cascade => 1);
+
+      SL::DB::History->new(
+        trans_id    => $self->part->id,
+        snumbers    => 'partnumber_' . $partnumber,
+        employee_id => SL::DB::Manager::Employee->current->id,
+        what_done   => 'part',
+        addition    => 'DELETED',
+      )->save();
+      1;
+  }) or return $self->js->error(t8('The item couldn\'t be deleted!') . " " . $self->part->db->error)->render;
+
+  flash_later('info', t8('The item has been deleted.'));
+  my @redirect_params = (
+    controller => 'controller.pl',
+    action => 'LoginScreen/user_login'
+  );
+  $self->redirect_to(@redirect_params);
+}
+
+sub action_use_as_new {
+  my ($self, %params) = @_;
+
+  my $oldpart = SL::DB::Manager::Part->find_by( id => $::form->{old_id}) or die "can't find old part";
+  $::form->{oldpartnumber} = $oldpart->partnumber;
+
+  $self->part($oldpart->clone_and_reset_deep);
+  $self->parse_form;
+  $self->part->partnumber(undef);
+
+  $self->render_form;
+}
+
+sub action_edit {
+  my ($self, %params) = @_;
+
+  $self->render_form;
+}
+
+sub render_form {
+  my ($self, %params) = @_;
+
+  $self->_set_javascript;
+
+  my (%assortment_vars, %assembly_vars);
+  %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
+  %assembly_vars   = %{ $self->prepare_assembly_render_vars   } if $self->part->is_assembly;
+
+  $params{CUSTOM_VARIABLES}  = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);
+
+  CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id)
+    if (scalar @{ $params{CUSTOM_VARIABLES} });
+
+  my %title_hash = ( part       => t8('Edit Part'),
+                     assembly   => t8('Edit Assembly'),
+                     service    => t8('Edit Service'),
+                     assortment => t8('Edit Assortment'),
+                   );
+
+  $self->part->prices([])       unless $self->part->prices;
+  $self->part->translations([]) unless $self->part->translations;
+
+  $self->render(
+    'part/form',
+    title             => $title_hash{$self->part->part_type},
+    show_edit_buttons => $::auth->assert('part_service_assembly_edit'),
+    %assortment_vars,
+    %assembly_vars,
+    translations_map  => { map { ($_->language_id   => $_) } @{$self->part->translations} },
+    prices_map        => { map { ($_->pricegroup_id => $_) } @{$self->part->prices      } },
+    oldpartnumber     => $::form->{oldpartnumber},
+    old_id            => $::form->{old_id},
+    %params,
+  );
+}
+
+sub action_history {
+  my ($self) = @_;
+
+  my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
+  $_[0]->render('part/history', { layout => 0 },
+                                  history_entries => $history_entries);
+}
+
+sub action_update_item_totals {
+  my ($self) = @_;
+
+  my $part_type = $::form->{part_type};
+  die unless $part_type =~ /^(assortment|assembly)$/;
+
+  my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
+  my $lastcost_sum  = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
+
+  my $sum_diff      = $sellprice_sum-$lastcost_sum;
+
+  $self->js
+    ->html('#items_sellprice_sum',       $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
+    ->html('#items_lastcost_sum',        $::form->format_amount(\%::myconfig, $lastcost_sum,  2, 0))
+    ->html('#items_sum_diff',            $::form->format_amount(\%::myconfig, $sum_diff,      2, 0))
+    ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
+    ->html('#items_lastcost_sum_basic',  $::form->format_amount(\%::myconfig, $lastcost_sum,  2, 0))
+    ->render();
+}
+
+sub action_add_multi_assortment_items {
+  my ($self) = @_;
+
+  my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
+  my $html         = $self->render_assortment_items_to_html($item_objects);
+
+  $self->js->run('kivi.Part.close_multi_items_dialog')
+           ->append('#assortment_rows', $html)
+           ->run('kivi.Part.renumber_positions')
+           ->run('kivi.Part.assortment_recalc')
+           ->render();
+}
+
+sub action_add_multi_assembly_items {
+  my ($self) = @_;
+
+  my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
+  my $html         = $self->render_assembly_items_to_html($item_objects);
+
+  $self->js->run('kivi.Part.close_multi_items_dialog')
+           ->append('#assembly_rows', $html)
+           ->run('kivi.Part.renumber_positions')
+           ->run('kivi.Part.assembly_recalc')
+           ->render();
+}
+
+sub action_add_assortment_item {
+  my ($self, %params) = @_;
+
+  validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
+
+  carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;
+
+  my $add_item_id = $::form->{add_items}->[0]->{parts_id};
+  if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
+    return $self->js->flash('error', t8("This part has already been added."))->render;
+  };
+
+  my $number_of_items = scalar @{$self->assortment_items};
+  my $item_objects    = $self->parse_add_items_to_objects(part_type => 'assortment');
+  my $html            = $self->render_assortment_items_to_html($item_objects, $number_of_items);
+
+  push(@{$self->assortment_items}, @{$item_objects});
+  my $part = SL::DB::Part->new(part_type => 'assortment');
+  $part->assortment_items(@{$self->assortment_items});
+  my $items_sellprice_sum = $part->items_sellprice_sum;
+  my $items_lastcost_sum  = $part->items_lastcost_sum;
+  my $items_sum_diff      = $items_sellprice_sum - $items_lastcost_sum;
+
+  $self->js
+    ->append('#assortment_rows'        , $html)  # append in tbody
+    ->val('.add_assortment_item_input' , '')
+    ->run('kivi.Part.focus_last_assortment_input')
+    ->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
+    ->html("#items_lastcost_sum",  $::form->format_amount(\%::myconfig, $items_lastcost_sum,  2, 0))
+    ->html("#items_sum_diff",      $::form->format_amount(\%::myconfig, $items_sum_diff,      2, 0))
+    ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
+    ->html('#items_lastcost_sum_basic',  $::form->format_amount(\%::myconfig, $items_lastcost_sum,  2, 0))
+    ->render;
+}
+sub action_add_assembly_item {
+  my ($self) = @_;
+
+  validate_add_items() or return $self->js->error(t8("No part was selected."))->render;
+
+  carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;
+
+  my $add_item_id = $::form->{add_items}->[0]->{parts_id};
+  my $duplicate_warning = 0; # duplicates are allowed, just warn
+  if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
+    $duplicate_warning++;
+  };
+
+  my $number_of_items = scalar @{$self->assembly_items};
+  my $item_objects    = $self->parse_add_items_to_objects(part_type => 'assembly');
+  my $html            = $self->render_assembly_items_to_html($item_objects, $number_of_items);
+
+  $self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;
+
+  push(@{$self->assembly_items}, @{$item_objects});
+  my $part = SL::DB::Part->new(part_type => 'assembly');
+  $part->assemblies(@{$self->assembly_items});
+  my $items_sellprice_sum = $part->items_sellprice_sum;
+  my $items_lastcost_sum  = $part->items_lastcost_sum;
+  my $items_sum_diff      = $items_sellprice_sum - $items_lastcost_sum;
+
+  $self->js
+    ->append('#assembly_rows', $html)  # append in tbody
+    ->val('.add_assembly_item_input' , '')
+    ->run('kivi.Part.focus_last_assembly_input')
+    ->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
+    ->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
+    ->html('#items_sum_diff',      $::form->format_amount(\%::myconfig, $items_sum_diff     , 2, 0))
+    ->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
+    ->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
+    ->render;
+}
+
+sub action_show_multi_items_dialog {
+  require SL::DB::PartsGroup;
+  $_[0]->render('part/_multi_items_dialog', { layout => 0 },
+                part_type => 'assortment',
+                partfilter => '', # can I get at the current input of the partpicker here?
+                all_partsgroups => SL::DB::Manager::PartsGroup->get_all);
+}
+
+sub action_multi_items_update_result {
+  my $max_count = 100;
+
+  $::form->{multi_items}->{filter}->{obsolete} = 0;
+
+  my $count = $_[0]->multi_items_models->count;
+
+  if ($count == 0) {
+    my $text = SL::Presenter::EscapedText->new(text => $::locale->text('No results.'));
+    $_[0]->render($text, { layout => 0 });
+  } elsif ($count > $max_count) {
+    my $text = SL::Presenter::EscapedText->new(text => $::locale->text('Too many results (#1 from #2).', $count, $max_count));
+    $_[0]->render($text, { layout => 0 });
+  } else {
+    my $multi_items = $_[0]->multi_items_models->get;
+    $_[0]->render('part/_multi_items_result', { layout => 0 },
+                  multi_items => $multi_items);
+  }
+}
+
+sub action_add_makemodel_row {
+  my ($self) = @_;
+
+  my $vendor_id = $::form->{add_makemodel};
+
+  my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
+    return $self->js->error(t8("No vendor selected or found!"))->render;
+
+  if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
+    $self->js->flash('info', t8("This vendor has already been added."));
+  };
+
+  my $position = scalar @{$self->makemodels} + 1;
+
+  my $mm = SL::DB::MakeModel->new(# parts_id    => $::form->{part}->{id},
+                                  make        => $vendor->id,
+                                  model       => '',
+                                  lastcost    => 0,
+                                  sortorder    => $position,
+                                 ) or die "Can't create MakeModel object";
+
+  my $row_as_html = $self->p->render('part/_makemodel_row',
+                                     makemodel => $mm,
+                                     listrow   => $position % 2 ? 0 : 1,
+  );
+
+  # after selection focus on the model field in the row that was just added
+  $self->js
+    ->append('#makemodel_rows', $row_as_html)  # append in tbody
+    ->val('.add_makemodel_input', '')
+    ->run('kivi.Part.focus_last_makemodel_input')
+    ->render;
+}
+
+sub action_reorder_items {
+  my ($self) = @_;
+
+  my $part_type = $::form->{part_type};
+
+  my %sort_keys = (
+    partnumber  => sub { $_[0]->part->partnumber },
+    description => sub { $_[0]->part->description },
+    qty         => sub { $_[0]->qty },
+    sellprice   => sub { $_[0]->part->sellprice },
+    lastcost    => sub { $_[0]->part->lastcost },
+    partsgroup  => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
+  );
+
+  my $method = $sort_keys{$::form->{order_by}};
+
+  my @items;
+  if ($part_type eq 'assortment') {
+    @items = @{ $self->assortment_items };
+  } else {
+    @items = @{ $self->assembly_items };
+  };
+
+  my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
+  if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
+    if ($::form->{sort_dir}) {
+      @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
+    } else {
+      @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
+    }
+  } else {
+    if ($::form->{sort_dir}) {
+      @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
+    } else {
+      @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
+    }
+  };
+
+  $self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
+}
+
+sub action_warehouse_changed {
+  my ($self) = @_;
+
+  $self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
+  die unless ref($self->warehouse) eq 'SL::DB::Warehouse';
+
+  if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
+    $self->bin($self->warehouse->bins->[0]);
+    $self->js
+      ->html('#bin', $self->build_bin_select)
+      ->focus('#part_bin_id');
+  } else {
+    # no warehouse was selected, empty the bin field and reset the id
+    $self->js
+        ->val('#part_bin_id', undef)
+        ->html('#bin', '');
+  };
+
+  return $self->js->render;
+}
+
 sub action_ajax_autocomplete {
   my ($self, %params) = @_;
 
@@ -85,6 +543,212 @@ sub action_show {
   }
 }
 
+# helper functions
+sub validate_add_items {
+  scalar @{$::form->{add_items}};
+}
+
+sub prepare_assortment_render_vars {
+  my ($self) = @_;
+
+  my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
+               items_lastcost_sum  => $self->part->items_lastcost_sum,
+               assortment_html     => $self->render_assortment_items_to_html( \@{$self->part->items} ),
+             );
+  $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
+
+  return \%vars;
+}
+
+sub prepare_assembly_render_vars {
+  my ($self) = @_;
+
+  my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
+               items_lastcost_sum  => $self->part->items_lastcost_sum,
+               assembly_html       => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
+             );
+  $vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};
+
+  return \%vars;
+}
+
+sub add {
+  my ($self) = @_;
+
+  check_has_valid_part_type($self->part->part_type);
+
+  $self->_set_javascript;
+
+  my %title_hash = ( part       => t8('Add Part'),
+                     assembly   => t8('Add Assembly'),
+                     service    => t8('Add Service'),
+                     assortment => t8('Add Assortment'),
+                   );
+
+  $self->render(
+    'part/form',
+    title             => $title_hash{$self->part->part_type},
+    show_edit_buttons => $::auth->assert('part_service_assembly_edit'),
+  );
+}
+
+
+sub _set_javascript {
+  my ($self) = @_;
+  $::request->layout->use_javascript("${_}.js")  for qw(kivi.Part kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery);
+  $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
+}
+
+sub recalc_item_totals {
+  my ($self, %params) = @_;
+
+  if ( $params{part_type} eq 'assortment' ) {
+    return 0 unless scalar @{$self->assortment_items};
+  } elsif ( $params{part_type} eq 'assembly' ) {
+    return 0 unless scalar @{$self->assembly_items};
+  } else {
+    carp "can only calculate sum for assortments and assemblies";
+  };
+
+  my $part = SL::DB::Part->new(part_type => $params{part_type});
+  if ( $part->is_assortment ) {
+    $part->assortment_items( @{$self->assortment_items} );
+    if ( $params{price_type} eq 'lastcost' ) {
+      return $part->items_lastcost_sum;
+    } else {
+      if ( $params{pricegroup_id} ) {
+        return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
+      } else {
+        return $part->items_sellprice_sum;
+      };
+    }
+  } elsif ( $part->is_assembly ) {
+    $part->assemblies( @{$self->assembly_items} );
+    if ( $params{price_type} eq 'lastcost' ) {
+      return $part->items_lastcost_sum;
+    } else {
+      return $part->items_sellprice_sum;
+    }
+  }
+}
+
+sub check_part_not_modified {
+  my ($self) = @_;
+
+  return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));
+
+}
+
+sub parse_form {
+  my ($self) = @_;
+
+  my $is_new = !$self->part->id;
+
+  my $params = delete($::form->{part}) || { };
+
+  delete $params->{id};
+  # never overwrite existing partnumber, should be a read-only field anyway
+  delete $params->{partnumber} if $self->part->partnumber;
+  $self->part->assign_attributes(%{ $params});
+  $self->part->bin_id(undef) unless $self->part->warehouse_id;
+
+  # Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
+  # will be the case for used assortments when saving, or when a used assortment
+  # is "used as new"
+  if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
+    $self->part->assortment_items([]);
+    $self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
+  };
+
+  if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
+    $self->part->assemblies([]); # completely rewrite assortments each time
+    $self->part->add_assemblies( @{ $self->assembly_items } );
+  };
+
+  $self->part->translations([]);
+  $self->parse_form_translations;
+
+  $self->part->prices([]);
+  $self->parse_form_prices;
+
+  $self->parse_form_makemodels;
+}
+
+sub parse_form_prices {
+  my ($self) = @_;
+  # only save prices > 0
+  my $prices = delete($::form->{prices}) || [];
+  foreach my $price ( @{$prices} ) {
+    my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
+    next unless $sellprice > 0; # skip negative prices as well
+    my $p = SL::DB::Price->new(parts_id      => $self->part->id,
+                               pricegroup_id => $price->{pricegroup_id},
+                               price         => $sellprice,
+                              );
+    $self->part->add_prices($p);
+  };
+}
+
+sub parse_form_translations {
+  my ($self) = @_;
+  # don't add empty translations
+  my $translations = delete($::form->{translations}) || [];
+  foreach my $translation ( @{$translations} ) {
+    next unless $translation->{translation};
+    my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
+    $self->part->add_translations( $translation );
+  };
+}
+
+sub parse_form_makemodels {
+  my ($self) = @_;
+
+  my $makemodels_map;
+  if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
+    $makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
+  };
+
+  $self->part->makemodels([]);
+
+  my $position = 0;
+  my $makemodels = delete($::form->{makemodels}) || [];
+  foreach my $makemodel ( @{$makemodels} ) {
+    next unless $makemodel->{make};
+    $position++;
+    my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";
+
+    my $mm = SL::DB::MakeModel->new( # parts_id   => $self->part->id, # will be assigned by row add_makemodels
+                                     id         => $makemodel->{id},
+                                     make       => $makemodel->{make},
+                                     model      => $makemodel->{model} || '',
+                                     lastcost   => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
+                                     sortorder  => $position,
+                                   );
+    if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
+      # lastupdate isn't set, original lastcost is 0 and new lastcost is 0
+      # don't change lastupdate
+    } elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
+      # new makemodel, no lastcost entered, leave lastupdate empty
+    } elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
+      # lastcost hasn't changed, use original lastupdate
+      $mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
+    } else {
+      $mm->lastupdate(DateTime->now);
+    };
+    $self->part->makemodel( scalar @{$self->part->makemodels} ? 1 : 0 ); # do we need this boolean anymore?
+    $self->part->add_makemodels($mm);
+  };
+}
+
+sub build_bin_select {
+  $_[0]->p->select_tag('part.bin_id', [ $_[0]->warehouse->bins ],
+    title_key => 'description',
+    default   => $_[0]->bin->id,
+  );
+}
+
+# get_set_inits for partpicker
+
 sub init_parts {
   if ($::form->{no_paginate}) {
     $_[0]->models->disable_plugin('paginated');
@@ -93,8 +757,23 @@ sub init_parts {
   $_[0]->models->get;
 }
 
+# get_set_inits for part controller
 sub init_part {
-  SL::DB::Part->new(id => $::form->{id} || $::form->{part}{id})->load;
+  my ($self) = @_;
+
+  # used by edit, save, delete and add
+
+  if ( $::form->{part}{id} ) {
+    return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels prices translations partsgroup) ]);
+  } else {
+    die "part_type missing" unless $::form->{part}{part_type};
+    return SL::DB::Part->new(part_type => $::form->{part}{part_type});
+  };
+}
+
+sub init_orphaned {
+  my ($self) = @_;
+  return $self->part->orphaned;
 }
 
 sub init_models {
@@ -114,4 +793,705 @@ sub init_models {
   );
 }
 
+sub init_p {
+  SL::Presenter->get;
+}
+
+
+sub init_assortment_items {
+  # this init is used while saving and whenever assortments change dynamically
+  my ($self) = @_;
+  my $position = 0;
+  my @array;
+  my $assortment_items = delete($::form->{assortment_items}) || [];
+  foreach my $assortment_item ( @{$assortment_items} ) {
+    next unless $assortment_item->{parts_id};
+    $position++;
+    my $part = SL::DB::Manager::Part->find_by(id => $assortment_item->{parts_id}) || die "Can't determine item to be added";
+    my $ai = SL::DB::AssortmentItem->new( parts_id      => $part->id,
+                                          qty           => $::form->parse_amount(\%::myconfig, $assortment_item->{qty_as_number}),
+                                          charge        => $assortment_item->{charge},
+                                          unit          => $assortment_item->{unit} || $part->unit,
+                                          position      => $position,
+    );
+
+    push(@array, $ai);
+  };
+  return \@array;
+}
+
+sub init_makemodels {
+  my ($self) = @_;
+
+  my $position = 0;
+  my @makemodel_array = ();
+  my $makemodels = delete($::form->{makemodels}) || [];
+
+  foreach my $makemodel ( @{$makemodels} ) {
+    next unless $makemodel->{make};
+    $position++;
+    my $mm = SL::DB::MakeModel->new( # parts_id   => $self->part->id, # will be assigned by row add_makemodels
+                                    id        => $makemodel->{id},
+                                    make      => $makemodel->{make},
+                                    model     => $makemodel->{model} || '',
+                                    lastcost  => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0),
+                                    sortorder => $position,
+                                  ) or die "Can't create mm";
+    # $mm->id($makemodel->{id}) if $makemodel->{id};
+    push(@makemodel_array, $mm);
+  };
+  return \@makemodel_array;
+}
+
+sub init_assembly_items {
+  my ($self) = @_;
+  my $position = 0;
+  my @array;
+  my $assembly_items = delete($::form->{assembly_items}) || [];
+  foreach my $assembly_item ( @{$assembly_items} ) {
+    next unless $assembly_item->{parts_id};
+    $position++;
+    my $part = SL::DB::Manager::Part->find_by(id => $assembly_item->{parts_id}) || die "Can't determine item to be added";
+    my $ai = SL::DB::Assembly->new(parts_id    => $part->id,
+                                   bom         => $assembly_item->{bom},
+                                   qty         => $::form->parse_amount(\%::myconfig, $assembly_item->{qty_as_number}),
+                                   position    => $position,
+                                  );
+    push(@array, $ai);
+  };
+  return \@array;
+}
+
+sub init_all_warehouses {
+  my ($self) = @_;
+  SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef, id => $self->part->warehouse_id ] ]);
+}
+
+sub init_all_languages {
+  SL::DB::Manager::Language->get_all_sorted;
+}
+
+sub init_all_partsgroups {
+  SL::DB::Manager::PartsGroup->get_all_sorted;
+}
+
+sub init_all_buchungsgruppen {
+  my ($self) = @_;
+  if ( $self->part->orphaned ) {
+    return SL::DB::Manager::Buchungsgruppe->get_all_sorted;
+  } else {
+    return SL::DB::Manager::Buchungsgruppe->get_all(where => [ id => $self->part->buchungsgruppen_id ]);
+  }
+}
+
+sub init_all_units {
+  my ($self) = @_;
+  if ( $self->part->orphaned ) {
+    return SL::DB::Manager::Unit->get_all_sorted;
+  } else {
+    return SL::DB::Manager::Unit->get_all(where => [ unit => $self->part->unit ]);
+  }
+}
+
+sub init_all_payment_terms {
+  SL::DB::Manager::PaymentTerm->get_all_sorted;
+}
+
+sub init_all_price_factors {
+  SL::DB::Manager::PriceFactor->get_all_sorted;
+}
+
+sub init_all_pricegroups {
+  SL::DB::Manager::Pricegroup->get_all_sorted;
+}
+
+# model used to filter/display the parts in the multi-items dialog
+sub init_multi_items_models {
+  SL::Controller::Helper::GetModels->new(
+    controller     => $_[0],
+    model          => 'Part',
+    with_objects   => [ qw(unit_obj partsgroup) ],
+    disable_plugin => 'paginated',
+    source         => $::form->{multi_items},
+    sorted         => {
+      _default    => {
+        by  => 'partnumber',
+        dir => 1,
+      },
+      partnumber  => t8('Partnumber'),
+      description => t8('Description')}
+  );
+}
+
+# simple checks to run on $::form before saving
+
+sub form_check_part_description_exists {
+  my ($self) = @_;
+
+  return 1 if $::form->{part}{description};
+
+  $self->js->flash('error', t8('Part Description missing!'))
+           ->run('kivi.Part.set_tab_active_by_name', 'basic_data')
+           ->focus('#part_description');
+  return 0;
+}
+
+sub form_check_assortment_items_exist {
+  my ($self) = @_;
+
+  return 1 unless $::form->{part}{part_type} eq 'assortment';
+  # skip check for existing parts that have been used
+  return 1 if ($self->part->id and !$self->part->orphaned);
+
+  # new or orphaned parts must have items in $::form->{assortment_items}
+  unless ( $::form->{assortment_items} and scalar @{$::form->{assortment_items}} ) {
+    $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
+             ->focus('#add_assortment_item_name')
+             ->flash('error', t8('The assortment doesn\'t have any items.'));
+    return 0;
+  };
+  return 1;
+}
+
+sub form_check_assortment_items_unique {
+  my ($self) = @_;
+
+  return 1 unless $::form->{part}{part_type} eq 'assortment';
+
+  my %duplicate_elements;
+  my %count;
+  for (map { $_->{parts_id} } @{$::form->{assortment_items}}) {
+    $duplicate_elements{$_}++ if $count{$_}++;
+  };
+
+  if ( keys %duplicate_elements ) {
+    $self->js->run('kivi.Part.set_tab_active_by_name', 'assortment_tab')
+             ->flash('error', t8('There are duplicate assortment items'));
+    return 0;
+  };
+  return 1;
+}
+
+sub form_check_assembly_items_exist {
+  my ($self) = @_;
+
+  return 1 unless $::form->{part}->{part_type} eq 'assembly';
+
+  unless ( $::form->{assembly_items} and scalar @{$::form->{assembly_items}} ) {
+    $self->js->run('kivi.Part.set_tab_active_by_name', 'assembly_tab')
+             ->focus('#add_assembly_item_name')
+             ->flash('error', t8('The assembly doesn\'t have any items.'));
+    return 0;
+  };
+  return 1;
+}
+
+sub form_check_partnumber_is_unique {
+  my ($self) = @_;
+
+  if ( !$::form->{part}{id} and $::form->{part}{partnumber} ) {
+    my $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $::form->{part}{partnumber} ]);
+    if ( $count ) {
+      $self->js->flash('error', t8('The partnumber already exists!'))
+               ->focus('#part_description');
+      return 0;
+    };
+  };
+  return 1;
+}
+
+# general checking functions
+sub check_next_transnumber_is_free {
+  my ($self) = @_;
+
+  my ($next_transnumber, $count);
+  $self->part->db->with_transaction(sub {
+    $next_transnumber = $self->part->get_next_trans_number;
+    $count = SL::DB::Manager::Part->get_all_count(where => [ partnumber => $next_transnumber ]);
+    return 1;
+  }) or die $@;
+  $count ? return 0 : return 1;
+}
+
+sub check_part_id {
+  die t8("Can't load item without a valid part.id") . "\n" unless $::form->{part}{id};
+}
+
+sub check_form {
+  my ($self) = @_;
+
+  $self->form_check_part_description_exists || return 0;
+  $self->form_check_assortment_items_exist  || return 0;
+  $self->form_check_assortment_items_unique || return 0;
+  $self->form_check_assembly_items_exist    || return 0;
+  $self->form_check_partnumber_is_unique    || return 0;
+
+  return 1;
+}
+
+sub check_has_valid_part_type {
+  die "invalid part_type" unless $_[0] =~ /^(part|service|assembly|assortment)$/;
+}
+
+sub render_assortment_items_to_html {
+  my ($self, $assortment_items, $number_of_items) = @_;
+
+  my $position = $number_of_items + 1;
+  my $html;
+  foreach my $ai (@$assortment_items) {
+    $html .= $self->p->render('part/_assortment_row',
+                              PART     => $self->part,
+                              orphaned => $self->orphaned,
+                              ITEM     => $ai,
+                              listrow  => $position % 2 ? 1 : 0,
+                              position => $position, # for legacy assemblies
+                             );
+    $position++;
+  };
+  return $html;
+}
+
+sub render_assembly_items_to_html {
+  my ($self, $assembly_items, $number_of_items) = @_;
+
+  my $position = $number_of_items + 1;
+  my $html;
+  foreach my $ai (@{$assembly_items}) {
+    $html .= $self->p->render('part/_assembly_row',
+                              PART     => $self->part,
+                              orphaned => $self->orphaned,
+                              ITEM     => $ai,
+                              listrow  => $position % 2 ? 1 : 0,
+                              position => $position, # for legacy assemblies
+                             );
+    $position++;
+  };
+  return $html;
+}
+
+sub parse_add_items_to_objects {
+  my ($self, %params) = @_;
+  my $part_type = $params{part_type};
+  die unless $params{part_type} =~ /^(assortment|assembly)$/;
+  my $position = $params{position} || 1;
+
+  my @add_items = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
+
+  my @item_objects;
+  foreach my $item ( @add_items ) {
+    my $part = SL::DB::Manager::Part->find_by(id => $item->{parts_id}) || die "Can't load part";
+    my $ai;
+    if ( $part_type eq 'assortment' ) {
+       $ai = SL::DB::AssortmentItem->new(part          => $part,
+                                         qty           => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
+                                         unit          => $part->unit, # TODO: $item->{unit} || $part->unit
+                                         position      => $position,
+                                        ) or die "Can't create AssortmentItem from item";
+    } elsif ( $part_type eq 'assembly' ) {
+      $ai = SL::DB::Assembly->new(parts_id    => $part->id,
+                                 # id          => $self->assembly->id, # will be set on save
+                                 qty         => $::form->parse_amount(\%::myconfig, $item->{qty_as_number}),
+                                 bom         => 0, # default when adding: no bom
+                                 position    => $position,
+                                );
+    } else {
+      die "part_type must be assortment or assembly";
+    }
+    push(@item_objects, $ai);
+    $position++;
+  };
+
+  return \@item_objects;
+}
+
 1;
+
+__END__
+
+=encoding utf-8
+
+=head1 NAME
+
+SL::Controller::Part - Part CRUD controller
+
+=head1 DESCRIPTION
+
+Controller for adding/editing/saving/deleting parts.
+
+All the relations are loaded at once and saving the part, adding a history
+entry and saving CVars happens inside one transaction.  When saving the old
+relations are deleted and written as new to the database.
+
+Relations for parts:
+
+=over 2
+
+=item makemodels
+
+=item translations
+
+=item assembly items
+
+=item assortment items
+
+=item prices
+
+=back
+
+=head1 PART_TYPES
+
+There are 4 different part types:
+
+=over 4
+
+=item C<part>
+
+The "default" part type.
+
+inventory_accno_id is set.
+
+=item C<service>
+
+Services can't be stocked.
+
+inventory_accno_id isn't set.
+
+=item C<assembly>
+
+Assemblies consist of other parts, services, assemblies or assortments. They
+aren't meant to be bought, only sold. To add assemblies to stock you typically
+have to make them, which reduces the stock by its respective components. Once
+an assembly item has been created there is currently no way to "disassemble" it
+again. An assembly item can appear several times in one assembly. An assmbly is
+sold as one item with a defined sellprice and lastcost. If the component prices
+change the assortment price remains the same. The assembly items may be printed
+in a record if the item's "bom" is set.
+
+=item C<assortment>
+
+Similar to assembly, but each assortment item may only appear once per
+assortment. When selling an assortment the assortment items are added to the
+record together with the assortment, which is added with sellprice 0.
+
+Technically an assortment doesn't have a sellprice, but rather the sellprice is
+determined by the sum of the current assortment item prices when the assortment
+is added to a record. This also means that price rules and customer discounts
+will be applied to the assortment items.
+
+Once the assortment items have been added they may be modified or deleted, just
+as if they had been added manually, the individual assortment items aren't
+linked to the assortment or the other assortment items in any way.
+
+=back
+
+=head1 URL ACTIONS
+
+=over 4
+
+=item C<action_add_part>
+
+=item C<action_add_service>
+
+=item C<action_add_assembly>
+
+=item C<action_add_assortment>
+
+=item C<action_add PART_TYPE>
+
+An alternative to the action_add_$PART_TYPE actions, takes the mandatory
+parameter part_type as an action. Example:
+
+  controller.pl?action=Part/add&part_type=service
+
+=item C<action_save>
+
+Saves the current part and then reloads the edit page for the part.
+
+=item C<action_use_as_new>
+
+Takes the information from the current part, plus any modifications made on the
+page, and creates a new edit page that is ready to be saved. The partnumber is
+set empty, so a new partnumber from the number range will be used if the user
+doesn't enter one manually.
+
+Unsaved changes to the original part aren't updated.
+
+The part type cannot be changed in this way.
+
+=item C<action_delete>
+
+Deletes the current part and then redirects to the main page, there is no
+callback.
+
+The delete button only appears if the part is 'orphaned', according to
+SL::DB::Part orphaned.
+
+The part can't be deleted if it appears in invoices, orders, delivery orders,
+the inventory, or is part of an assembly or assortment.
+
+If the part is deleted its relations prices, makdemodel, assembly,
+assortment_items and translation are are also deleted via DELETE ON CASCADE.
+
+Before this controller items that appeared in inventory didn't count as
+orphaned and could be deleted and the inventory entries were also deleted, this
+"feature" hasn't been implemented.
+
+=item C<action_edit part.id>
+
+Load and display a part for editing.
+
+  controller.pl?action=Part/edit&part.id=12345
+
+Passing the part id is mandatory, and the parameter is "part.id", not "id".
+
+=back
+
+=head1 BUTTON ACTIONS
+
+=over 4
+
+=item C<history>
+
+Opens a popup displaying all the history entries. Once a new history controller
+is written the button could link there instead, with the part already selected.
+
+=back
+
+=head1 AJAX ACTIONS
+
+=over 4
+
+=item C<action_update_item_totals>
+
+Is called whenever an element with the .recalc class loses focus, e.g. the qty
+amount of an item changes. The sum of all sellprices and lastcosts is
+calculated and the totals updated. Uses C<recalc_item_totals>.
+
+=item C<action_add_assortment_item>
+
+Adds a new assortment item from a part picker seleciton to the assortment item list
+
+If the item already exists in the assortment the item isn't added and a Flash
+error shown.
+
+Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
+after adding each new item, add the new object to the item objects that were
+already parsed, calculate totals via a dummy part then update the row and the
+totals.
+
+=item C<action_add_assembly_item>
+
+Adds a new assembly item from a part picker seleciton to the assembly item list
+
+If the item already exists in the assembly a flash info is generated, but the
+item is added.
+
+Rather than running kivi.Part.renumber_positions and kivi.Part.assembly_recalc
+after adding each new item, add the new object to the item objects that were
+already parsed, calculate totals via a dummy part then update the row and the
+totals.
+
+=item C<action_add_multi_assortment_items>
+
+Parses the items to be added from the form generated by the multi input and
+appends the html of the tr-rows to the assortment item table. Afterwards all
+assortment items are renumbered and the sums recalculated via
+kivi.Part.renumber_positions and kivi.Part.assortment_recalc.
+
+=item C<action_add_multi_assembly_items>
+
+Parses the items to be added from the form generated by the multi input and
+appends the html of the tr-rows to the assembly item table. Afterwards all
+assembly items are renumbered and the sums recalculated via
+kivi.Part.renumber_positions and kivi.Part.assembly_recalc.
+
+=item C<action_show_multi_items_dialog>
+
+=item C<action_multi_items_update_result>
+
+=item C<action_add_makemodel_row>
+
+Add a new makemodel row with the vendor that was selected via the vendor
+picker.
+
+Checks the already existing makemodels and warns if a row with that vendor
+already exists. Currently it is possible to have duplicate vendor rows.
+
+=item C<action_reorder_items>
+
+Sorts the item table for assembly or assortment items.
+
+=item C<action_warehouse_changed>
+
+=back
+
+=head1 ACTIONS part picker
+
+=over 4
+
+=item C<action_ajax_autocomplete>
+
+=item C<action_test_page>
+
+=item C<action_part_picker_search>
+
+=item C<action_part_picker_result>
+
+=item C<action_show>
+
+=back
+
+=head1 FORM CHECKS
+
+=over 2
+
+=item C<check_form>
+
+Calls some simple checks that test the submitted $::form for obvious errors.
+Return 1 if all the tests were successfull, 0 as soon as one test fails.
+
+Errors from the failed tests are stored as ClientJS actions in $self->js. In
+some cases extra actions are taken, e.g. if the part description is missing the
+basic data tab is selected and the description input field is focussed.
+
+=back
+
+=over 4
+
+=item C<form_check_part_description_exists>
+
+=item C<form_check_assortment_items_exist>
+
+=item C<form_check_assortment_items_unique>
+
+=item C<form_check_assembly_items_exist>
+
+=item C<form_check_partnumber_is_unique>
+
+=back
+
+=head1 HELPER FUNCTIONS
+
+=over 4
+
+=item C<parse_form>
+
+When submitting the form for saving, parses the transmitted form. Expects the
+following data:
+
+ $::form->{part}
+ $::form->{makemodels}
+ $::form->{translations}
+ $::form->{prices}
+ $::form->{assemblies}
+ $::form->{assortments}
+
+CVar data is currently stored directly in $::form, e.g. $::form->{cvar_size}.
+
+=item C<recalc_item_totals %params>
+
+Helper function for calculating the total lastcost and sellprice for assemblies
+or assortments according to their items, which are parsed from the current
+$::form.
+
+Is called whenever the qty of an item is changed or items are deleted.
+
+Takes two params:
+
+* part_type : 'assortment' or 'assembly' (mandatory)
+
+* price_type: 'lastcost' or 'sellprice', default is 'sellprice'
+
+Depending on the price_type the lastcost sum or sellprice sum is returned.
+
+Doesn't work for recursive items.
+
+=back
+
+=head1 GET SET INITS
+
+There are get_set_inits for
+
+* assembly items
+
+* assortment items
+
+* makemodels
+
+which parse $::form and automatically create an array of objects.
+
+These inits are used during saving and each time a new element is added.
+
+=over 4
+
+=item C<init_makemodels>
+
+Parses $::form->{makemodels}, creates an array of makemodel objects and stores them in
+$self->part->makemodels, ready to be saved.
+
+Used for saving parts and adding new makemodel rows.
+
+=item C<parse_add_items_to_objects PART_TYPE>
+
+Parses the resulting form from either the part-picker submit or the multi-item
+submit, and creates an arrayref of assortment_item or assembly objects, that
+can be rendered via C<render_assortment_items_to_html> or
+C<render_assembly_items_to_html>.
+
+Mandatory param: part_type: assortment or assembly (the resulting html will differ)
+Optional param: position (used for numbering and listrow class)
+
+=item C<render_assortment_items_to_html ITEM_OBJECTS>
+
+Takes an array_ref of assortment_items, and generates tables rows ready for
+adding to the assortment table.  Is used when a part is loaded, or whenever new
+assortment items are added.
+
+=item C<parse_form_makemodels>
+
+Makemodels can't just be overwritten, because of the field "lastupdate", that
+remembers when the lastcost for that vendor changed the last time.
+
+So the original values are cloned and remembered, so we can compare if lastcost
+was changed in $::form, and keep or update lastupdate.
+
+lastcost isn't updated until the first time it was saved with a value, until
+then it is empty.
+
+Also a boolean "makemodel" needs to be written in parts, depending on whether
+makemodel entries exist or not.
+
+We still need init_makemodels for when we open the part for editing.
+
+=back
+
+=head1 TODO
+
+=over 4
+
+=item *
+
+It should be possible to jump to the edit page in a specific tab
+
+=item *
+
+Support callbacks, e.g. creating a new part from within an order, and jumping
+back to the order again afterwards.
+
+=item *
+
+Support units when adding assembly items or assortment items. Currently the
+default unit of the item is always used.
+
+=item *
+
+Calculate sellprice and lastcost totals recursively, in case e.g. an assembly
+consists of other assemblies.
+
+=back
+
+=head1 AUTHOR
+
+G. Richardson E<lt>grichardson@kivitendo-premium.deE<gt>
+
+=cut
index c4aabf7..db22f5c 100644 (file)
@@ -468,6 +468,13 @@ $(function(){
 
   $('#row_table_id').on('sortstop', function(event, ui) {
     $('#row_table_id thead a img').remove();
+    if (part_type === 'assortment') {
+      $('#assortment thead a img').remove();
+      data = $('#assortment :input').serializeArray();
+    } else if ( part_type === 'assembly') {
+      $('#assembly thead a img').remove();
+      data = $('#assembly :input').serializeArray();
+    };
     kivi.Order.renumber_positions();
   });
 });
diff --git a/js/kivi.Part.js b/js/kivi.Part.js
new file mode 100644 (file)
index 0000000..cec7aec
--- /dev/null
@@ -0,0 +1,307 @@
+namespace('kivi.Part', function(ns) {
+
+  ns.open_history_popup = function() {
+    var id = $("#part_id").val();
+    kivi.popup_dialog({
+      url:    'controller.pl?action=Part/history&part.id=' + id,
+      dialog: { title: kivi.t8('History') },
+    });
+  }
+
+  ns.save = function() {
+    var data = $('#ic').serializeArray();
+    data.push({ name: 'action', value: 'Part/save' });
+
+    $.post("controller.pl", data, kivi.eval_json_result);
+  };
+
+  ns.use_as_new = function() {
+    var oldid = $("#part_id").val();
+    $('#ic').attr('action', 'controller.pl?action=Part/use_as_new&old_id=' + oldid);
+    $('#ic').submit();
+  };
+
+  ns.delete = function() {
+    var data = $('#ic').serializeArray();
+    data.push({ name: 'action', value: 'Part/delete' });
+
+    $.post("controller.pl", data, kivi.eval_json_result);
+  };
+
+  ns.reformat_number = function(event) {
+    $(event.target).val(kivi.format_amount(kivi.parse_amount($(event.target).val()), -2));
+  };
+
+  ns.set_tab_active_by_index = function (index) {
+    $("#ic_tabs").tabs({active: index})
+  };
+
+  ns.set_tab_active_by_name= function (name) {
+    var index = $('#ic_tabs a[href=#' + name + ']').parent().index();
+    ns.set_tab_active_by_index(index);
+  };
+
+  ns.reorder_items = function(order_by) {
+    var dir = $('#' + order_by + '_header_id a img').attr("data-sort-dir");
+    var part_type = $("#part_part_type").val();
+
+    var data;
+    if (part_type === 'assortment') {
+      $('#assortment thead a img').remove();
+      data = $('#assortment :input').serializeArray();
+    } else if ( part_type === 'assembly') {
+      $('#assembly thead a img').remove();
+      data = $('#assembly :input').serializeArray();
+    };
+
+    var src;
+    if (dir == "1") {
+      dir = "0";
+      src = "image/up.png";
+    } else {
+      dir = "1";
+      src = "image/down.png";
+    }
+
+    $('#' + order_by + '_header_id a').append('<img border=0 data-sort-dir=' + dir + ' src=' + src + ' alt="' + kivi.t8('sort items') + '">');
+
+    data.push({ name: 'action',    value: 'Part/reorder_items' },
+              { name: 'order_by',  value: order_by             },
+              { name: 'part_type', value: part_type            },
+              { name: 'sort_dir',  value: dir                  });
+
+    $.post("controller.pl", data, kivi.eval_json_result);
+  };
+
+  ns.assortment_recalc = function() {
+    var data = $('#assortment :input').serializeArray();
+    data.push({ name: 'action', value: 'Part/update_item_totals' },
+              { name: 'part_type', value: 'assortment'                   });
+
+    $.post("controller.pl", data, kivi.eval_json_result);
+  };
+
+  ns.assembly_recalc = function() {
+    var data = $('#assembly :input').serializeArray();
+    data.push( { name: 'action',    value: 'Part/update_item_totals' },
+               { name: 'part_type', value: 'assembly'                        });
+
+    $.post("controller.pl", data, kivi.eval_json_result);
+  };
+
+  ns.set_assortment_sellprice = function() {
+    $("#part_sellprice_as_number").val($("#items_sellprice_sum").html());
+    // ns.set_tab_active_by_name('basic_data');
+    // $("#part_sellprice_as_number").focus();
+  };
+
+  ns.set_assortment_lsg_sellprice = function() {
+    $("#items_lsg_sellprice_sum_basic").closest('td').find('input').val($("#items_lsg_sellprice_sum").html());
+  };
+
+  ns.set_assortment_douglas_sellprice = function() {
+    $("#items_douglas_sellprice_sum_basic").closest('td').find('input').val($("#items_douglas_sellprice_sum").html());
+  };
+
+  ns.set_assortment_lastcost = function() {
+    $("#part_lastcost_as_number").val($("#items_lastcost_sum").html());
+    // ns.set_tab_active_by_name('basic_data');
+    // $("#part_lastcost_as_number").focus();
+  };
+
+  ns.set_assembly_sellprice = function() {
+    $("#part_sellprice_as_number").val($("#items_sellprice_sum").html());
+    // ns.set_tab_active_by_name('basic_data');
+    // $("#part_sellprice_as_number").focus();
+  };
+
+  ns.renumber_positions = function() {
+    var part_type = $("#part_part_type").val();
+    var rows;
+    if (part_type === 'assortment') {
+      rows = $('.assortment_item_row [name="position"]');
+    } else if ( part_type === 'assembly') {
+      rows = $('.assembly_item_row [name="position"]');
+    };
+    $(rows).each(function(idx, elt) {
+      $(elt).html(idx+1);
+      var row = $(elt).closest('tr');
+      if ( idx % 2 === 0 ) {
+        if ( row.hasClass('listrow1') ) {
+          row.removeClass('listrow1');
+          row.addClass('listrow0');
+        };
+      } else {
+        if ( row.hasClass('listrow0') ) {
+          row.removeClass('listrow0');
+          row.addClass('listrow1');
+        };
+      };
+    });
+  };
+
+  ns.delete_item_row = function(clicked) {
+    var row = $(clicked).closest('tr');
+    $(row).remove();
+    var part_type = $("#part_part_type").val();
+    ns.renumber_positions();
+    if (part_type === 'assortment') {
+      ns.assortment_recalc();
+    } else if ( part_type === 'assembly') {
+      ns.assembly_recalc();
+    };
+  };
+
+  ns.add_assortment_item = function() {
+    if ($('#add_assortment_item_id').val() === '') return;
+
+    $('#row_table_id thead a img').remove();
+
+    var data = $('#assortment :input').serializeArray();
+    data.push({ name: 'action', value: 'Part/add_assortment_item' },
+              { name: 'part.id', value: $('#part_id').val()       },
+              { name: 'part.part_type', value: 'assortment'       });
+
+    $.post("controller.pl", data, kivi.eval_json_result);
+  };
+
+  ns.add_assembly_item = function() {
+    if ($('#add_assembly_item_id').val() === '') return;
+
+    var data = $('#assembly :input').serializeArray();
+    data.push({ name: 'action', value: 'Part/add_assembly_item' },
+              { name: 'part.id', value: $("#part_id").val()     },
+              { name: 'part.part_type', value: 'assortment'     });
+
+    $.post("controller.pl", data, kivi.eval_json_result);
+  };
+
+  ns.redisplay_items = function(data) {
+    var old_rows;
+    var part_type = $("#part_part_type").val();
+    if (part_type === 'assortment') {
+      old_rows = $('.assortment_item_row').detach();
+    } else if ( part_type === 'assembly') {
+      old_rows = $('.assembly_item_row').detach();
+    };
+    var new_rows = [];
+    $(data).each(function(idx, elt) {
+      new_rows.push(old_rows[elt.old_pos - 1]);
+    });
+    if (part_type === 'assortment') {
+      $(new_rows).appendTo($('#assortment_items'));
+    } else if ( part_type === 'assembly') {
+      $(new_rows).appendTo($('#assembly_items'));
+    };
+    ns.renumber_positions();
+  };
+
+  ns.focus_last_assortment_input = function () {
+    $("#assortment_items tr:last").find('input[type=text]').filter(':visible:first').focus();
+  };
+
+  ns.focus_last_assembly_input = function () {
+    $("#assembly_rows tr:last").find('input[type=text]').filter(':visible:first').focus();
+  };
+
+  ns.show_multi_items_dialog = function(part_type) {
+
+    $('#row_table_id thead a img').remove();
+
+    kivi.popup_dialog({
+      url: 'controller.pl?action=Part/show_multi_items_dialog',
+      data: { callback:         'Part/add_multi_' + part_type + '_items',
+              callback_data_id: 'ic',
+              'part.part_type': part_type,
+            },
+      id: 'jq_multi_items_dialog',
+      dialog: {
+        title: kivi.t8('Add multiple items'),
+        width:  800,
+        height: 800
+      }
+    });
+    return true;
+  };
+
+  ns.close_multi_items_dialog = function() {
+    $('#jq_multi_items_dialog').dialog('close');
+  };
+
+
+  // makemodel
+  ns.makemodel_renumber_positions = function() {
+    $('.makemodel_row [name="position"]').each(function(idx, elt) {
+      $(elt).html(idx+1);
+    });
+  };
+
+  ns.delete_makemodel_row = function(clicked) {
+    var row = $(clicked).closest('tr');
+    $(row).remove();
+
+    ns.makemodel_renumber_positions();
+  };
+
+  ns.add_makemodel_row = function() {
+    if ($('#add_makemodelid').val() === '') return;
+
+    var data = $('#makemodel_table :input').serializeArray();
+    data.push({ name: 'action', value: 'Part/add_makemodel_row' });
+
+    $.post("controller.pl", data, kivi.eval_json_result);
+  };
+
+  ns.focus_last_makemodel_input = function () {
+    $("#makemodel_rows tr:last").find('input[type=text]').filter(':visible:first').focus();
+  };
+
+
+  ns.reload_bin_selection = function() {
+    $.post("controller.pl", { action: 'Part/warehouse_changed', warehouse_id: function(){ return $('#part_warehouse_id').val() } },   kivi.eval_json_result);
+  }
+
+  $(function(){
+
+    // assortment
+    // TODO: allow units for assortment items
+    $('#add_assortment_item_id').on('set_item:PartPicker', function(e,o) { $('#add_item_unit').val(o.unit) });
+
+    $('#ic').on('focusout', '.reformat_number', function(event) {
+       ns.reformat_number(event);
+    })
+
+    $('.add_assortment_item_input').keydown(function(event) {
+      if(event.keyCode == 13) {
+        event.preventDefault();
+        if ($("input[name='add_items[+].parts_id']").val() != '' ) {
+          kivi.Part.show_multi_items_dialog("assortment");
+         // ns.add_assortment_item();
+        };
+        return false;
+      }
+    });
+
+    $('.add_assembly_item_input').keydown(function(event) {
+      if(event.keyCode == 13) {
+        event.preventDefault();
+        if ($("input[name='add_items[+].parts_id']").val() != '' ) {
+          kivi.Part.show_multi_items_dialog("assortment");
+          // ns.add_assembly_item();
+        }
+        return false;
+      }
+    });
+
+    $('.add_makemodel_input').keydown(function(event) {
+      if(event.keyCode == 13) {
+        event.preventDefault();
+        ns.add_makemodel_row();
+        return false;
+      }
+    });
+
+    $('#part_warehouse_id').change(kivi.Part.reload_bin_selection);
+
+  });
+})
index 316faeb..499b12f 100644 (file)
@@ -47,6 +47,7 @@ namespace("kivi").setupLocale({
 "Edit text block":"Textblock bearbeiten",
 "Enter longdescription":"Langtext eingeben",
 "Function block actions":"Funktionsblockaktionen",
+"History":"Historie",
 "If you switch to a different tab without saving you will lose the data you've entered in the current tab.":"Wenn Sie auf einen anderen Tab wechseln, ohne vorher zu speichern, so gehen die im aktuellen Tab eingegebenen Daten verloren.",
 "Map":"Karte",
 "No":"Nein",
index d9dbc2f..69d4190 100755 (executable)
@@ -304,6 +304,7 @@ $self->{texts} = {
   'Assembly Description'        => 'Erzeugnis-Beschreibung',
   'Assembly Number'             => 'Erzeugnis-Nummer',
   'Assembly Number missing!'    => 'Erzeugnisnummer fehlt!',
+  'Assembly items'              => 'Erzeugnisbestandteile',
   'Asset'                       => 'Aktiva/Mittelverwendung',
   'Assets'                      => 'Aktiva',
   'Assign article'              => 'Artikel zuweisen',
@@ -540,6 +541,7 @@ $self->{texts} = {
   'Changed text blocks: #1'     => 'Geänderte Textblöcke: #1',
   'Changes in this block are only sensible if the account is NOT a summary account AND there exists one valid taxkey. To select both Receivables and Payables only make sense for Payment / Receipt (i.e. account cash).' => 'Es ist nur sinnvoll Ã„nderungen vorzunehmen, wenn das Konto KEIN Sammelkonto ist und wenn ein gültiger Steuerschlüssel für das Konto existiert. Gleichzeitig Haken bei Forderungen und Verbindlichkeiten zu setzen, macht auch NUR für den Zahlungsein- und Ausgang (bspw. Bank oder Kasse) Sinn.',
   'Changes to Receivables and Payables are only possible if no transactions to this account are posted yet.' => 'Änderungen bei Forderungen oder Verbindlichkeiten sind nur möglich, wenn dieses Konto noch nicht bebucht wurde.',
+  'Charge'                      => 'Berechnen',
   'Charge Number'               => 'Chargennummer',
   'Charge number'               => 'Chargennummer',
   'Charset'                     => 'Zeichensatz',
@@ -1842,6 +1844,7 @@ $self->{texts} = {
   'No invoices have been selected.' => 'Es wurden keine Rechnungen ausgewählt.',
   'No or an unknown authenticantion module specified in "config/kivitendo.conf".' => 'Es wurde kein oder ein unbekanntes Authentifizierungsmodul in "config/kivitendo.conf" angegeben.',
   'No part was found matching the search parameters.' => 'Es wurde kein Artikel gefunden, auf den die Suchparameter zutreffen.',
+  'No part was selected.'       => 'Es wurde kein Artikel ausgewählt',
   'No payment term has been created yet.' => 'Es wurden noch keine Zahlungsbedingungen angelegt.',
   'No picture has been uploaded' => 'Es wurde kein Bild hochgeladen',
   'No picture uploaded yet'     => 'Noch kein Bild hochgeladen',
@@ -1876,6 +1879,7 @@ $self->{texts} = {
   'No users have been created yet.' => 'Es wurden noch keine Benutzer angelegt.',
   'No valid number entered for pricegroup "#1".' => 'Für Preisgruppe "#1" wurde keine gültige Nummer eingegeben.',
   'No vendor has been selected yet.' => 'Es wurde noch kein Lieferant ausgewählt.',
+  'No vendor selected!'         => 'Kein Lieferant ausgewählt!',
   'No warehouse has been created yet or the quantity of the bins is not configured yet.' => 'Es wurde noch kein Lager angelegt, bzw. die dazugehörigen Lagerplätze sind noch nicht konfiguriert.',
   'No year given for method year' => 'Für diese Exportmethode wird ein Jahr benötigt',
   'No.'                         => 'Position',
@@ -2544,6 +2548,8 @@ $self->{texts} = {
   'Set (set to)'                => 'Setze',
   'Set count for one or more of the items to select them' => 'Zum Selektieren bitte Menge für einen oder mehrere Artikel setzen',
   'Set eMail text'              => 'E-Mail Text eingeben',
+  'Set lastcost'                => 'EK-Preis Ã¼bernehmen',
+  'Set sellprice'               => 'VK-Preis Ã¼bernehmen',
   'Set to paid missing'         => 'Fehlbetrag setzen',
   'Settings'                    => 'Einstellungen',
   'Setup Menu'                  => 'Menü-Variante',
@@ -2817,6 +2823,7 @@ $self->{texts} = {
   'The action you\'ve chosen has not been executed because the document does not contain any item yet.' => 'Die von Ihnen ausgewählte Aktion wurde nicht ausgeführt, weil der Beleg noch keine Positionen enthält.',
   'The administration area is always accessible.' => 'Der Administrationsbereich ist immer zugänglich.',
   'The application "#1" was not found on the system.' => 'Die Anwendung "#1" wurde auf dem System nicht gefunden.',
+  'The assembly doesn\'t have any items.' => 'Das Erzeugnis enthält keine Artikel.',
   'The assembly has been created.' => 'Das Erzeugnis wurde hergestellt.',
   'The assistant could not find anything wrong with #1. Maybe the problem has been solved in the meantime.' => 'Der Korrekturassistent konnte kein Problem bei #1 feststellen. Eventuell wurde das Problem in der Zwischenzeit bereits behoben.',
   'The assortment doesn\'t have any items.' => 'Das Sortiment enthält keine Artikel.',
@@ -2939,6 +2946,10 @@ $self->{texts} = {
   'The installation is currently locked.' => 'Die Installation ist momentan gesperrt.',
   'The installation is currently unlocked.' => 'Die Installation ist momentan entsperrt.',
   'The invoices have been created. They\'re pre-selected below.' => 'Die Rechnungen wurden erzeugt. Sie sind unten vorausgewählt.',
+  'The item couldn\'t be saved!' => 'Der Artikel konnte nicht gespeichert werden!',
+  'The item has been created.'  => 'Der Artikel wurde angelegt.',
+  'The item has been deleted.'  => 'Der Artikel wurde gelöscht.',
+  'The item has been saved.'    => 'Der Artikel wurde gespeichert.',
   'The items are imported accoring do their number "X" regardless of the column order inside the file.' => 'Die Einträge werden in der Reihenfolge ihrer Indizes "X" unabhängig von der Spaltenreihenfolge in der Datei importiert.',
   'The link target to add has been created from the existing record.' => 'Das auszuwählende Verknüpfungsziel wurde aus dem bestehenden Beleg erstellt.',
   'The list has been printed.'  => 'Die Liste wurde ausgedruckt.',
@@ -2956,6 +2967,7 @@ $self->{texts} = {
   'The name must only consist of letters, numbers and underscores and start with a letter.' => 'Der Name darf nur aus Buchstaben (keine Umlaute), Ziffern und Unterstrichen bestehen und muss mit einem Buchstaben beginnen.',
   'The new requirement spec template will be a copy of \'#1\'.' => 'Die neue Pflichtenheftvorlage wird eine Kopie von \'#1\' sein.',
   'The new requirement spec will be a copy of \'#1\' for customer \'#2\'.' => 'Das neue Pflichtenheft wird eine Kopie von \'#1\' für Kunde \'#2\' sein.',
+  'The next partnumber in the number range already exists!' => 'Die nächste Artikelnummer im Nummernkreis existiert schon!',
   'The number of days for full payment' => 'Die Anzahl Tage, bis die Rechnung in voller Höhe bezahlt werden muss',
   'The numbering will start at 1 with each requirement spec.' => 'Die Nummerierung beginnt bei jedem Pflichtenheft bei 1.',
   'The option field is empty.'  => 'Das Optionsfeld ist leer.',
@@ -3070,6 +3082,7 @@ $self->{texts} = {
   'The unit has been saved.'    => 'Die Einheit wurde gespeichert.',
   'The unit in row %d has been deleted in the meantime.' => 'Die Einheit in Zeile %d ist in der Zwischentzeit gel&ouml;scht worden.',
   'The unit in row %d has been used in the meantime and cannot be changed anymore.' => 'Die Einheit in Zeile %d wurde in der Zwischenzeit benutzt und kann nicht mehr ge&auml;ndert werden.',
+  'The unit is missing.'        => 'Die Einheit fehlt.',
   'The units have been saved.'  => 'Die Einheiten wurden gespeichert.',
   'The user can chose which client to connect to during login.' => 'Bei der Anmeldung kann der Benutzer auswählen, welchen Mandanten er benutzen möchte.',
   'The user cannot be deleted as it is used in the following clients: #1' => 'Die BenutzerIn kann nicht gelöscht werden, da sie für die folgenden Mandanten benötigt wird: #1',
@@ -3102,6 +3115,7 @@ $self->{texts} = {
   'There are currently no open invoices, or none matches your filter conditions.' => 'Es gibt momentan keine offenen Rechnungen, oder keine erfüllt die Filterkriterien.',
   'There are currently no open sales delivery orders.' => 'Es gibt zur Zeit keine offenen Verkaufslieferscheine.',
   'There are double partnumbers in your database.' => 'In ihrer Datenbank befinden sich mehrfach vergebene Artikelnummern.',
+  'There are duplicate assortment items' => 'Es kommen doppelte Artikel im Sortiment vor',
   'There are duplicate parts at positions' => 'Es gibt doppelte Artikel bei den Positionen',
   'There are entries in tax where taxkey is NULL.' => 'In der Datenbank sind Steuern ohne Steuerschlüssel vorhanden (in der Tabelle tax Spalte taxkey).',
   'There are invalid taxnumbers in use.' => 'Es werden ungültige Steuerautomatik-Konten benutzt.',
@@ -3171,6 +3185,7 @@ $self->{texts} = {
   'This option controls the method used for determining the startdate for the balance report.' => 'Diese Option bestimmt, wie das Startdatum für den Bilanzbericht ermittelt wird',
   'This option controls the method used for profit determination.' => 'Dieser Parameter legt die Berechnungsmethode für die Gewinnermittlung fest.',
   'This option controls the posting and calculation behavior for the accounting method.' => 'Dieser Parameter steuert die Buchungs- und Berechnungsmethoden für die Versteuerungsart.',
+  'This part has already been added.' => 'Dieser Artikel wurde schon hinzugefügt',
   'This partnumber is not unique. You should change it.' => 'Diese Artikelnummer ist nicht eindeutig. Bitte wählen Sie eine andere.',
   'This price has since gone down' => 'Dieser Preis ist mittlerweile niedriger',
   'This price has since gone up' => 'Dieser Preis ist mittlerweile höher',
@@ -3332,6 +3347,7 @@ $self->{texts} = {
   'Use Income'                  => 'GUV und BWA verwenden',
   'Use UStVA'                   => 'UStVA verwenden',
   'Use WebDAV Repository'       => 'WebDAV-Ablage verwenden',
+  'Use as new'                  => 'Als neu verwenden',
   'Use default booking group because setting is \'all\'' => 'Standardbuchungsgruppe wird verwendet',
   'Use default booking group because wanted is missing' => 'Fehlende Buchungsgruppe, deshalb Standardbuchungsgruppe',
   'Use default warehouse for assembly transfer' => 'Zum Fertigen Standardlager des Bestandteils verwenden',
diff --git a/templates/webpages/part/_assembly.html b/templates/webpages/part/_assembly.html
new file mode 100644 (file)
index 0000000..1e2bbd1
--- /dev/null
@@ -0,0 +1,99 @@
+[%- USE T8 %]
+[%- USE HTML %]
+[%- USE LxERP %]
+[%- USE L %]
+
+<div id="assembly" name="assembly">
+
+<h2>[% 'Assembly items' | $T8 %]</h2>
+
+[% L.hidden_tag('assembly_id', SELF.part.id) %]
+
+<table id="assembly_items">
+ <thead>
+   <tr class="listheading">
+     <th class="listheading" style='display:none'></th>
+     [% IF SELF.orphaned %]
+     <th class="listheading" style='text-align:center' nowrap width="1"><img src="image/close.png" alt="[%- LxERP.t8('delete item') %]"></th>
+     [% END %]
+     <th class="listheading" nowrap width="3" >[%- 'position'     | $T8 %] </th>
+     [% IF SELF.orphaned %]
+     <th class="listheading" style='text-align:center' nowrap width="1"><img src="image/updown.png" alt="[%- LxERP.t8('reorder item') %]"></th>
+     [% END %]
+     <th id="partnumber_header_id"  class="listheading" nowrap width="15"><a href='#' onClick='javascript:kivi.Part.reorder_items("partnumber")' >[%- 'Partnumber'  | $T8 %]</a></th>
+     <th id="partdescription_header_id"  class="listheading" nowrap width="15"><a href='#' onClick='javascript:kivi.Part.reorder_items("description")' >[%- 'Description' | $T8 %]</a></th>
+     <th id="qty_header_id"         class="listheading" nowrap width="5" ><a href='#' onClick='javascript:kivi.Part.reorder_items("qty")'        >[%- 'Qty'         | $T8 %]</a></th>
+     <th class="listheading" nowrap width="5" >[%- 'Unit'         | $T8 %] </th>
+     <th class="listheading" nowrap width="5" >[%- 'BOM'          | $T8 %] </th>
+     <th class="listheading" nowrap width="5" >[%- 'Line Total'   | $T8 %] </th>
+     <th id="sellprice_header_id"   class="listheading" nowrap width="10" ><a href='#' onClick='javascript:kivi.Part.reorder_items("sellprice")' >[%- 'Sellprice'       | $T8 %]</a></th>
+     <th id="lastcost_header_id"   class="listheading" nowrap width="10" ><a href='#' onClick='javascript:kivi.Part.reorder_items("lastcost")'   >[%- 'Lastcost'       | $T8 %]</a></th>
+     <th id="_header_id"   class="listheading" nowrap width="15" ><a href='#' onClick='javascript:kivi.Part.reorder_items("partsgroup")'         >[%- 'Group'       | $T8 %]</a></th>
+   </tr>
+ </thead>
+<tbody id="assembly_rows">
+  [% assembly_html %]
+</tbody>
+<tbody id="assembly_input">
+<tr>
+ [% IF SELF.orphaned %]
+ <td></td>
+ <td></td>
+ <td align="right">[% 'Part' | $T8 %]:</td>
+ <td>[% L.part_picker('add_items[+].parts_id'   , ''  , style='width: 300px' , class="add_assembly_item_input") %][% L.hidden_tag('add_items[].qty_as_number', 1) %]</td>
+ <td>[%- L.button_tag("kivi.Part.add_assembly_item()", LxERP.t8("Add")) %]</td>
+ <td>[% L.button_tag('kivi.Part.show_multi_items_dialog("assembly")', LxERP.t8('Add multiple items')) %]</td>
+ [% ELSE %]
+ <td></td>
+ <td></td>
+ <td></td>
+ <td></td>
+ <td></td>
+ [% END %]
+ <td></td>
+ <td align="right">[% 'Totals' | $T8 %]:</td>
+ <td></td>
+ <td id="items_sellprice_sum" class="numeric">[%- LxERP.format_amount(items_sellprice_sum, 2, 0) %]</td>
+ <td id="items_lastcost_sum"  class="numeric">[%- LxERP.format_amount(items_lastcost_sum,  2, 0) %]</td>
+ <td id="items_sum_diff"      class="numeric">[%- LxERP.format_amount(items_sum_diff,      2, 0) %]</td>
+</tr>
+<tr>
+ [% IF SELF.orphaned %]
+ <td></td>
+ <td></td>
+ [% END %]
+ <td></td>
+ <td></td>
+ <td></td>
+ <td></td>
+ <td></td>
+ <td></td>
+ <td></td>
+ <td align="right">[% L.button_tag("kivi.Part.set_assembly_sellprice()", LxERP.t8("Set sellprice")) %]</td>
+ <td></td>
+</tr>
+</tbody>
+</table>
+
+[% L.sortable_element('#assembly_rows') %]
+
+<div>
+<p>
+</p>
+</div>
+
+
+</div>
+
+<script type="text/javascript">
+  $(function() {
+    $("#assembly").on( "focusout", ".recalc", function( event )  {
+      kivi.Part.assembly_recalc();
+    });
+
+    $('#assembly_rows').on('sortstop', function(event, ui) {
+      $('#assembly thead a img').remove();
+      kivi.Part.renumber_positions();
+    });
+  })
+</script>
diff --git a/templates/webpages/part/_assembly_row.html b/templates/webpages/part/_assembly_row.html
new file mode 100644 (file)
index 0000000..82e5c46
--- /dev/null
@@ -0,0 +1,69 @@
+[%- USE T8 %]
+[%- USE HTML %]
+[%- USE LxERP %]
+[%- USE L %]
+[%- USE P %]
+  <tr class="listrow[% listrow %] assembly_item_row">
+    <td style='display:none'>
+      [% IF orphaned %]
+      [% L.hidden_tag("assembly_items[+].parts_id", ITEM.part.id) %]
+      [% END %]
+    </td>
+    <td align="center" [% UNLESS orphaned %]style='display:none'[% END %]>
+      [%- L.button_tag("kivi.Part.delete_item_row(this)",
+                       LxERP.t8("X")) %] [% # , confirm=LxERP.t8("Are you sure?")) %]
+    </td>
+    <td>
+      <div name="position" class="numeric">
+        [% HTML.escape(position) or HTML.escape(ITEM.position) %]
+      </div>
+    </td>
+    <td align="center" [% UNLESS orphaned %]style='display:none'[% END %]>
+      <img src="image/updown.png" alt="[%- LxERP.t8('reorder item') %]" class="dragdrop">
+    </td>
+    <td nowrap>
+       [% P.part(ITEM.part) %]
+    </td>
+    <td>
+       [% HTML.escape(ITEM.part.description) %]
+    </td>
+    <td>
+    [% IF orphaned %]
+      [%- L.input_tag("assembly_items[].qty_as_number",
+                      ITEM.qty_as_number,
+                      size = 10,
+                      class="recalc reformat_number numeric") %]
+    [% ELSE %]
+      [% ITEM.qty_as_number | html %]
+    [% END %]
+    </td>
+    <td nowrap>
+    [% IF orphaned %]
+      [%- L.select_tag("assembly_items[].unit",
+                      ITEM.part.available_units,
+                      default = ITEM.part.unit,
+                      title_key = 'name',
+                      value_key = 'name',
+                      class = 'unitselect') %]
+    [% ELSE %]
+      [% ITEM.part.unit | html %]
+    [% END %]
+    </td>
+    [% IF orphaned %]
+    <td>[% L.checkbox_tag("assembly_items[].bom", checked=ITEM.bom, for_submit=1) %]</td>
+    [% ELSE %]
+    <td>[% IF ITEM.bom %][% 'Yes' | $T8 %][% ELSE %][% 'No' | $T8 %][% END %]</td>
+    [% END %]
+    <td align="right">
+      [%- L.div_tag(LxERP.format_amount(ITEM.linetotal_sellprice, 3, 0), name="linetotal") %]
+      </td>
+    <td align="right">
+      [% ITEM.part.sellprice_as_number %]
+      </td>
+    <td align="right">
+      [% ITEM.part.lastcost_as_number %]
+      </td>
+    <td align="right">
+      [% HTML.escape(ITEM.part.partsgroup.partsgroup) %]
+      </td>
+  </tr>
diff --git a/templates/webpages/part/_assortment.html b/templates/webpages/part/_assortment.html
new file mode 100644 (file)
index 0000000..d441c06
--- /dev/null
@@ -0,0 +1,98 @@
+[%- USE T8 %]
+[%- USE HTML %]
+[%- USE LxERP %]
+[%- USE L %]
+
+<div id="assortment" name="assortment">
+
+<h2>[% 'Assortment items' | $T8 %]</h2>
+
+[% L.hidden_tag('assortment_id', SELF.part.id) %]
+
+<table id="assortment_items">
+ <thead>
+   <tr class="listheading">
+     <th class="listheading" style='display:none'></th>
+     [% IF SELF.orphaned %]
+     <th class="listheading" style='text-align:center' nowrap width="1"><img src="image/close.png" alt="[%- LxERP.t8('delete item') %]"></th>
+     [% END %]
+     <th class="listheading" nowrap width="3" >[%- 'position'     | $T8 %] </th>
+     [% IF SELF.orphaned %]
+     <th class="listheading" style='text-align:center' nowrap width="1"><img src="image/updown.png" alt="[%- LxERP.t8('reorder item') %]"></th>
+     [% END %]
+     <th id="partnumber_header_id"  class="listheading" nowrap width="15"><a href='#' onClick='javascript:kivi.Part.reorder_items("partnumber")'> [%- 'Partnumber'  | $T8 %]</a></th>
+     <th id="partdescription_header_id"  class="listheading" nowrap width="15"><a href='#' onClick='javascript:kivi.Part.reorder_items("description")' >[%- 'Description' | $T8 %]</a></th>
+     <th id="qty_header_id"         class="listheading" nowrap width="5" ><a href='#' onClick='javascript:kivi.Part.reorder_items("qty")'>        [%- 'Qty'         | $T8 %]</a></th>
+     <th class="listheading" nowrap width="5" >[%- 'Unit'         | $T8 %] </th>
+     <th class="listheading" nowrap width="5" >[%- 'Charge'       | $T8 %] </th>
+     <th class="listheading" nowrap width="5" >[%- 'Line Total'   | $T8 %] </th>
+     <th id="sellprice_header_id"   class="listheading" nowrap width="10" ><a href='#' onClick='javascript:kivi.Part.reorder_items("sellprice")'> [%- 'Sellprice'   | $T8 %]</a></th>
+     <th id="lastcost_header_id"   class="listheading" nowrap width="10" ><a href='#' onClick='javascript:kivi.Part.reorder_items("lastcost")'> [%- 'Lastcost'      | $T8 %]</a></th>
+     <th id="_header_id"   class="listheading" nowrap width="15" ><a href='#' onClick='javascript:kivi.Part.reorder_items("partsgroup")'> [%- 'Group'       | $T8 %]</a></th>
+   </tr>
+ </thead>
+<tbody id="assortment_rows">
+  [% assortment_html %]
+</tbody>
+<tbody id="assortment_input">
+<tr>
+ [% IF SELF.orphaned %]
+ <td></td>
+ <td></td>
+ <td align="right">[% 'Part' | $T8 %]:</td>
+ <td>[% L.part_picker('add_items[+].parts_id'   , ''  , style='width: 300px' , class="add_assortment_item_input") %][% L.hidden_tag('add_items[].qty_as_number', 1) %]</td>
+ <td>[%- L.button_tag("kivi.Part.add_assortment_item()", LxERP.t8("Add")) %]</td>
+ <td>[% L.button_tag('kivi.Part.show_multi_items_dialog("assortment")', LxERP.t8('Add multiple items')) %]</td>
+ <td></td>
+ [% ELSE %]
+ <td></td>
+ <td></td>
+ <td></td>
+ <td></td>
+ <td></td>
+ [% END %]
+ <td></td>
+ <td align="right">[% 'Totals' | $T8 %]:</td>
+ <th id="items_sellprice_sum" class="numeric">[%- LxERP.format_amount(items_sellprice_sum, 2, 0) %]</td>
+ <th id="items_lastcost_sum"  class="numeric">[%- LxERP.format_amount(items_lastcost_sum,  2, 0) %]</td>
+ <th id="items_sum_diff"      class="numeric">[%- LxERP.format_amount(items_sum_diff,      2, 0) %]</td>
+</tr>
+<tr>
+ [% IF SELF.orphaned %]
+ <td></td>
+ <td></td>
+ [% END %]
+ <td></td>
+ <td></td>
+ <td></td>
+ <td></td>
+ <td></td>
+ <td></td>
+ <td></td>
+ <td align="right">[% L.button_tag("kivi.Part.set_assortment_sellprice()", LxERP.t8("Set sellprice")) %]</td>
+ <td align="right">[% L.button_tag("kivi.Part.set_assortment_lastcost()",  LxERP.t8("Set lastcost"))  %]</td>
+ <td></td>
+</tr>
+</tbody>
+</table>
+
+[% L.sortable_element('#assortment_rows') %]
+
+</div>
+
+<script type="text/javascript">
+  $(function() {
+    $("#assortment").on( "focusout", ".recalc", function( event )  {
+      kivi.Part.assortment_recalc();
+    });
+
+    $("#assortment").on( "change", ":checkbox", function( event )  {
+      kivi.Part.assortment_recalc();
+    });
+
+    $('#assortment_rows').on('sortstop', function(event, ui) {
+      $('#assortment thead a img').remove();
+      kivi.Part.renumber_positions();
+    });
+  })
+</script>
diff --git a/templates/webpages/part/_assortment_row.html b/templates/webpages/part/_assortment_row.html
new file mode 100644 (file)
index 0000000..a86412c
--- /dev/null
@@ -0,0 +1,71 @@
+[%- USE T8 %]
+[%- USE HTML %]
+[%- USE LxERP %]
+[%- USE L %]
+[%- USE P %]
+  <tr class="listrow[% listrow %] assortment_item_row">
+    <td style='display:none'>
+      [% IF orphaned %]
+      [% L.hidden_tag("assortment_items[+].parts_id", ITEM.part.id) %]
+      [% END %]
+    </td>
+    <td align="center" [% UNLESS orphaned %]style='display:none'[% END %]>
+      [%- L.button_tag("kivi.Part.delete_item_row(this)",
+                       LxERP.t8("X")) %] [% # , confirm=LxERP.t8("Are you sure?")) %]
+    </td>
+    <td>
+      <div name="position" class="numeric">
+        [% HTML.escape(position) or HTML.escape(ITEM.position) %]
+      </div>
+    </td>
+    <td align="center" [% UNLESS orphaned %]style='display:none'[% END %]>
+      <img src="image/updown.png" alt="[%- LxERP.t8('reorder item') %]" class="dragdrop">
+    </td>
+    <td nowrap>
+      [% P.part(ITEM.part) %]
+    </td>
+    <td>
+       [% HTML.escape(ITEM.part.description) %]
+    </td>
+    <td nowrap>
+    [% IF orphaned %]
+      [%- L.input_tag("assortment_items[].qty_as_number",
+                      ITEM.qty_as_number,
+                      size = 10,
+                      class="recalc reformat_number numeric") %]
+    [% ELSE %]
+      [% ITEM.qty_as_number | html %]
+    [% END %]
+    </td>
+    <td nowrap>
+    [% IF orphaned %]
+      [%- L.select_tag("assortment_items[].unit",
+                      ITEM.part.available_units,
+                      default = ITEM.part.unit,
+                      title_key = 'name',
+                      value_key = 'name',
+                      class = 'unitselect') %]
+    [% ELSE %]
+      [% ITEM.part.unit | html %]
+    [% END %]
+    </td>
+    <td>
+    [% IF orphaned %]
+      [% L.checkbox_tag('assortment_items[].charge', checked => ITEM.charge, class => 'checkbox', for_submit=1) %]
+    [% ELSE %]
+      [% IF ITEM.charge %][% 'Yes' | $T8 %][%- ELSE %][% 'No' | $T8 %][%- END %]
+    [% END %]
+    </td>
+    <td align="right">
+      [%- L.div_tag(LxERP.format_amount(ITEM.linetotal_sellprice, 2, 0), name="linetotal") %]
+      </td>
+    <td align="right">
+      [% ITEM.part.sellprice_as_number %]
+      </td>
+    <td align="right">
+      [% ITEM.part.lastcost_as_number %]
+      </td>
+    <td align="right">
+      [% HTML.escape(ITEM.part.partsgroup.partsgroup) %]
+      </td>
+  </tr>
diff --git a/templates/webpages/part/_basic_data.html b/templates/webpages/part/_basic_data.html
new file mode 100644 (file)
index 0000000..0fb2b3b
--- /dev/null
@@ -0,0 +1,238 @@
+[%- USE T8 %]
+[%- USE HTML %]
+[%- USE LxERP %]
+[%- USE L %]
+[%- USE P %]
+   <table width="100%" id="basic_data_table">
+    <tr>
+     <td>
+      <table width="100%" id="ic1">
+       <tr valign="top">
+        <td>
+         [%- IF SELF.part.image && INSTANCE_CONF.get_parts_show_image %]
+         <a href="[% SELF.part.image | html %]" target="_blank"><img style="[% INSTANCE_CONF.get_parts_image_css %]" src="[% SELF.part.image | html %]"/></a>
+         [%- END %]
+
+         <table id="ic2">
+          <tr>
+           <td colspan="2">
+            <table id="ic3">
+             <tr>
+              <th align="right">[% 'Part Number' | $T8 %]</th>
+              [% SET readonly = 1 %]
+              [% UNLESS SELF.part.id %][% readonly = 0 %][% END %]
+              <td>[% L.input_tag("part.partnumber", SELF.part.partnumber, size=40, readonly=readonly class="initial_focus") %]</td>
+             </tr>
+             <tr>
+              <th align="right">[% 'Part Description' | $T8 %]</th>
+              <td>
+               [%- IF description_area %]
+               <textarea name="description" rows="[% HTML.escape(rows) %]" cols="40" wrap="soft">[% HTML.escape(description) %]</textarea>
+               [%- ELSE %]
+               [% L.input_tag("part.description", SELF.part.description, size=40) %]</td>
+               [%- END %]
+              </td>
+             </tr>
+             <tr>
+               <th align="right">[% 'EAN-Code' | $T8 %]</th>
+               <td>[% L.input_tag("part.ean", SELF.part.ean, size=40) %]</td>
+             </tr>
+             <tr>
+              [%- IF SELF.all_partsgroups.size %]
+              <th align="right">[% 'Group' | $T8 %]</th>
+              <td>[%- L.select_tag('part.partsgroup_id', SELF.all_partsgroups, default=SELF.part.partsgroup_id, title_key='partsgroup', value_key='id', with_empty=1 style='width: 200px') %]</td>
+              [% END %]
+             </tr>
+             [%- IF SELF.all_buchungsgruppen.size %]
+             <tr>
+              <th align="right">[% 'Booking group' | $T8 %]</th>
+              <td>[%- L.select_tag('part.buchungsgruppen_id', SELF.all_buchungsgruppen, default=SELF.part.buchungsgruppen_id, title_key='description', value_key='id', with_empty=0 style='width: 200px') %]</td>
+             </tr>
+             [%- END %]
+             [%- IF SELF.all_payment_terms.size %]
+             <tr>
+              <th align="right">[% 'Payment Terms' | $T8 %]</th>
+              <td>
+              [%- L.select_tag('part.payment_id', SELF.all_payment_terms, default=SELF.part.payment_id, title_key='description', value_key='id', with_empty=1 style='width: 200px') %]</td>
+             </tr>
+             [% END %]
+            </table>
+           </td>
+          </tr>
+
+          <tr height="5"></tr>
+
+          <tr>
+           <td>
+            <table id="ic4">
+             <tr>
+              <th align="left">[% 'Part Notes' | $T8 %]</th>
+              <th align="left">[% 'Formula' | $T8 %]</th>
+             </tr>
+             <tr valign="top">
+              <td>
+               [% L.textarea_tag("part.notes", P.restricted_html(SELF.part.notes), class="texteditor", style="width: 600px; height: 200px") %]
+              </td>
+              <td>
+                 <textarea id="part.formel" name="part.formel" rows="[% HTML.escape(notes_rows) %]" cols="30" wrap="soft" class="tooltipster-html" title="[% 'The formula needs the following syntax:<br>For regular article:<br>Variablename= Variable Unit;<br>Variablename2= Variable2 Unit2;<br>...<br>###<br>Variable + ( Variable2 / Variable )<br><b>Please be beware of the spaces in the formula</b><br>' | $T8 %]">[% HTML.escape(SELF.part.formel) %]</textarea>
+               </td>
+             </tr>
+            </table>
+           </td>
+          </tr>
+         </table>
+        </td>
+
+        <td>
+         <table id="ic5">
+          <tr>
+           <th align="right" nowrap="true">[% 'Updated' | $T8 %]</th>
+           <td>
+           [% SELF.part.priceupdate.to_kivitendo | html %]
+           </td>
+          </tr>
+
+          <tr>
+           <th align="right" nowrap="true">[% 'List Price' | $T8 %]</th>
+           <td>[% L.input_tag("part.listprice_as_number", SELF.part.listprice_as_number, size=11 class='reformat_number numeric') %]</td>
+          </tr>
+
+          <tr  >
+           <th align="right" nowrap="true">[% 'Sell Price' | $T8 %]</th>
+           <td>[% L.input_tag("part.sellprice_as_number", SELF.part.sellprice_as_number, size=11, class='reformat_number numeric') %] [% IF (SELF.part.is_assortment or SELF.part.is_assembly) %] (<span id="items_sellprice_sum_basic">[% LxERP.format_amount(SELF.part.items_sellprice_sum, 2) %]</span>) [% END %]</td>
+          </tr>
+
+          [%- UNLESS SELF.part.is_assembly %]
+          <tr>
+           <th align="right" nowrap="true">[% 'Last Cost' | $T8 %]</th>
+           <td>[% L.input_tag("part.lastcost_as_number", SELF.part.lastcost_as_number, size=11 class='reformat_number numeric') %]
+           [% IF SELF.part.is_assortment %] (<span id="items_lastcost_sum_basic">[% LxERP.format_amount(SELF.part.items_lastcost_sum, 2) %]</span>) [% END %]</td>
+          </tr>
+          [%- END %]
+
+          [%- IF SELF.all_price_factors.size %]
+          <tr>
+           <th align="right">[% 'Price Factor' | $T8 %]</th>
+           <td>
+            [%- L.select_tag('part.price_factor_id', SELF.all_price_factors, default=SELF.part.price_factor_id, title_key='description', value_key='id', with_empty=1) %]</td>
+           </td>
+          </tr>
+          [%- END %]
+
+          <tr>
+           <th align="right" nowrap="true">[% 'Unit' | $T8 %]</th>
+           <td>
+            [%- IF !SELF.part.id or SELF.part.orphaned # same logic as unit_changable %]
+            [%- L.select_tag('part.unit', SELF.all_units, default=SELF.part.unit, title_key='name', value_key='name') %]</td>
+            [%- ELSE %]
+            [% L.hidden_tag('part.unit', SELF.part.unit) %] [% HTML.escape(SELF.part.unit) %]
+            [%- END %]
+           </td>
+          </tr>
+
+        [%- UNLESS SELF.part.is_service %]
+          <tr>
+           <th align="right" nowrap="true">[% 'Weight' | $T8 %]</th>
+           <td>
+            [%- IF SELF.part.is_assembly %]
+              [% LxERP.format_amount(SELF.part.weight) %]
+            [% ELSE %]
+              [% L.input_tag('part.weight_as_number', SELF.part.weight_as_number, size=10, class='reformat_number numeric') %]
+            [% END %]
+            [% HTML.escape(INSTANCE_CONF.get_weightunit) %]
+           </td>
+          </tr>
+          <tr>
+           <th align="right" nowrap>[% 'On Hand' | $T8 %]</th>
+           <th align="left" nowrap>[% LxERP.format_amount(SELF.part.onhand) %] [% SELF.part.unit | html %]</th>
+          </tr>
+          <tr>
+           <th align="right" nowrap="true">[% 'ROP' | $T8 %]</th>
+           <td>[% L.input_tag("part.rop_as_number", SELF.part.rop_as_number, size=10, class="reformat_number numeric") %]</td>
+          </tr>
+          [% IF SELF.all_warehouses.size %]
+          <tr>
+           <th align="right" nowrap="true">[% 'Default Warehouse' | $T8 %]</th>
+           <td>[% L.select_tag('part.warehouse_id', SELF.all_warehouses, default=SELF.part.warehouse.id, title_key='description', with_empty=>1) %]
+           </td>
+          </tr>
+          [% END %]
+          <tr>
+           <th align="right" nowrap="true">[% 'Default Bin' | $T8 %]</th>
+           <td>
+            <span id='bin'>
+            [% IF SELF.part.warehouse.id %]
+            [% L.select_tag('part.bin_id', SELF.part.warehouse.bins, default=SELF.part.bin.id, title_key='description') %]
+            [%- END %]
+            </span>
+           </td>
+          </tr>
+        [%- END %]
+          <tr>
+           <th align="right" nowrap="true">[% 'Verrechnungseinheit' | $T8 %]</th>
+           <td>[% L.input_tag("part.ve", SELF.part.ve, size=10) %]</td>
+          </tr>
+          <tr>
+           <th align="right" nowrap="true">[% 'Business Volume' | $T8 %]</th>
+           <td>[% L.input_tag("part.gv_as_number", SELF.part.gv_as_number, size=10, class='reformat_number numeric') %]</td>
+          </tr>
+          <tr>
+           <th align="right" nowrap><label for="not_discountable">[% 'Not Discountable' | $T8 %]</label></th>
+           <td>[% L.checkbox_tag('part.not_discountable', checked = SELF.part.not_discountable, for_submit=1) %]</td>
+          </tr>
+        [%- IF SELF.part.id %]
+          <tr>
+           <th align="right" nowrap="true"><label for="obsolete">[% 'Obsolete' | $T8 %]</label></th>
+           <td>[% L.checkbox_tag('part.obsolete', checked = SELF.part.obsolete, for_submit=1) %]</td>
+          </tr>
+        [%- END %]
+        [%- UNLESS SELF.part.is_service %]
+          <tr>
+           <th align="right" nowrap><label for="has_sernumber">[% 'Has serial number' | $T8 %]</label></th>
+           <td>[% L.checkbox_tag('part.has_sernumber', checked = SELF.part.has_sernumber, for_submit=1) %]</td>
+          </tr>
+        [%- END %]
+          <tr>
+           <th align="right" nowrap><label for="shop">[% 'Shop article' | $T8 %]</label></th>
+           <td>[% L.checkbox_tag('part.shop', checked = SELF.part.shop, for_submit=1) %]</td>
+          </tr>
+         </table>
+        </td>
+       </tr>
+      </table>
+     </td>
+    </tr>
+
+
+    <tr>
+     <td>
+      <table id="ic6">
+       <tr>
+        <th align="right" nowrap>[% 'Image' | $T8 %]</th>
+        <td>[% L.input_tag("part.image", SELF.part.image, size=40) %]</td>
+        <th align="right" nowrap>[% 'Microfiche' | $T8 %]</th>
+        <td>[% L.input_tag("part.microfiche", SELF.part.microfiche, size=20) %]</td>
+       </tr>
+       <tr>
+        <th align="right" nowrap>[% 'Drawing' | $T8 %]</th>
+        <td>[% L.input_tag("part.drawing", SELF.part.drawing, size=40) %]</td>
+       </tr>
+      </table>
+     </td>
+    </tr>
+
+<div id="pricegroups">
+ [% PROCESS 'part/_pricegroup_prices.html' %]
+</div>
+
+[%- UNLESS SELF.part.is_assembly %]
+<div id="makemodel">
+ [% PROCESS 'part/_makemodel.html' %]
+</div>
+[% END %]
+
+  <tr>
+    <td><hr size="3" noshade></td>
+  </tr>
+ </table>
+
diff --git a/templates/webpages/part/_cvars.html b/templates/webpages/part/_cvars.html
new file mode 100644 (file)
index 0000000..24bad16
--- /dev/null
@@ -0,0 +1,15 @@
+[%- USE HTML  %]
+
+ <p>[% 'Unchecked custom variables will not appear in orders and invoices.' | $T8 %]</p>
+
+  <table>
+   [%- FOREACH var = CUSTOM_VARIABLES %]
+   <tr>
+    <td align="right" valign="top">[% var.VALID_BOX %]</td>
+    [%- IF !var.partsgroup_filtered %]
+      <td align="right" valign="top">[% HTML.escape(var.description) %]</td>
+    [%- END %]
+    <td valign="top">[% var.HTML_CODE %]</td>
+   </tr>
+   [%- END %]
+  </table>
diff --git a/templates/webpages/part/_edit_translations.html b/templates/webpages/part/_edit_translations.html
new file mode 100644 (file)
index 0000000..601bb10
--- /dev/null
@@ -0,0 +1,22 @@
+[%- USE HTML %][%- USE L -%][%- USE P -%][%- USE LxERP -%]
+
+<div id="translations_tab">
+ <table>
+  <tr class="listheading">
+   <th>[% LxERP.t8("Language") %]</th>
+   <th>[% LxERP.t8("Description") %]</th>
+   <th>[% LxERP.t8("Long Description") %]</th>
+  </tr>
+
+  [%- FOREACH language = SELF.all_languages %]
+   [% SET language_id = language.id
+          translation = translations_map.$language_id %]
+   [% L.hidden_tag('translations[+].language_id', language.id) %]
+   <tr class="listrow" valign="top">
+    <td>[% HTML.escape(language.description) %]</td>
+    <td>[% L.input_tag("translations[].translation", translation.translation) %]</td>
+    <td>[% L.textarea_tag("translations[].longdescription", P.restricted_html(translation.longdescription), id="translations_longdescription_" _ language_id, class="texteditor", style="width: 500px; height: 100px") %]</td>
+   </tr>
+  [%- END %]
+ </table>
+</div>
diff --git a/templates/webpages/part/_makemodel.html b/templates/webpages/part/_makemodel.html
new file mode 100644 (file)
index 0000000..8822aba
--- /dev/null
@@ -0,0 +1,50 @@
+[%- USE T8 %]
+[%- USE L %]
+[%- USE HTML %]
+[%- USE LxERP %]
+  <tr>
+  </tr>
+  <tr>
+    <td>
+      <table id="makemodel_table">
+        <thead>
+          <th class="listheading" style='text-align:center' nowrap width="1"><img src="image/close.png" alt="[%- LxERP.t8('delete item') %]"></th>
+          <th class="listheading">[% 'position'     | $T8 %]</th>
+          <th class="listheading" style='text-align:center' nowrap width="1"><img src="image/updown.png" alt="[%- LxERP.t8('reorder item') %]"></th>
+          <th class="listheading">[% 'Vendor Number' | $T8 %]</th>
+          <th class="listheading">[% 'Vendor'        | $T8 %]</th>
+          <th class="listheading">[% 'Model'         | $T8 %]</th>
+          <th class="listheading">[% 'Last Cost'     | $T8 %]</th>
+          <th class="listheading">[% 'Updated'       | $T8 %]</th>
+        </thead>
+        <tbody id="makemodel_rows">
+        [% SET listrow = 0 %]
+        [%- FOREACH makemodel = SELF.part.makemodels %]
+        [% listrow = listrow + 1 %]
+        [% PROCESS 'part/_makemodel_row.html' makemodel=makemodel listrow=listrow %]
+        [%- END %]
+       </tbody>
+       <tbody>
+        <tr>
+         <td></td>
+         <td></td>
+         <td></td>
+         <td align="right">[% 'Vendor' | $T8 %]</td>
+         <td rowspan="2">[% L.customer_vendor_picker('add_makemodel', '', type='vendor', style='width: 300px', class="add_makemodel_input") %]</td>
+         <td rowspan="2" align="right">[% L.button_tag('kivi.Part.add_makemodel_row()', LxERP.t8('Add')) %]</td>
+        </tr>
+       </tbody>
+      </table>
+    </td>
+  </tr>
+  [% L.sortable_element('#makemodel_rows') %]
+
+  <script type="text/javascript">
+  $(function() {
+
+    $('#makemodel_rows').on('sortstop', function(event, ui) {
+      kivi.Part.makemodel_renumber_positions();
+    });
+
+  })
+  </script>
diff --git a/templates/webpages/part/_makemodel_row.html b/templates/webpages/part/_makemodel_row.html
new file mode 100644 (file)
index 0000000..07ecd3f
--- /dev/null
@@ -0,0 +1,23 @@
+[%- USE T8 %]
+[%- USE L %]
+[%- USE HTML %]
+[%- USE LxERP %]
+        <tr class="listrow[% listrow % 2 %] makemodel_row">
+         <td style='display:none'>
+         [% L.hidden_tag("makemodels[+].make", makemodel.make) %]
+         [% L.hidden_tag("makemodels[].id"   , makemodel.id) %]
+         </td>
+         <td align="center">
+           [%- L.button_tag("kivi.Part.delete_makemodel_row(this)",
+                            LxERP.t8("X")) %] [% # , confirm=LxERP.t8("Are you sure?")) %]
+         </td>
+         <td><span name="position" class="numeric">[% HTML.escape(makemodel.sortorder) %]</span></td>
+         <td align="center">
+           <img src="image/updown.png" alt="[%- LxERP.t8('reorder item') %]" class="dragdrop">
+         </td>
+         <td>[% makemodel.vendor.vendornumber | html %]</td>
+         <td>[% makemodel.vendor.name         | html %] </td>
+         <td>[% L.input_tag('makemodels[].model'              , makemodel.model                   , size=30 ) %]</td>
+         <td>[% L.input_tag('makemodels[].lastcost_as_number' , makemodel.lastcost_as_number      , size=15 , class="reformat_number numeric") %]</td>
+         <td>[% L.hidden_tag('makemodels[].lastupdate'         , makemodel.lastupdate.to_kivitendo) %][% makemodel.lastupdate.to_kivitendo | html %]</td>
+        </tr>
diff --git a/templates/webpages/part/_multi_assortments_dialog.html b/templates/webpages/part/_multi_assortments_dialog.html
new file mode 100644 (file)
index 0000000..e76d713
--- /dev/null
@@ -0,0 +1,87 @@
+[%- USE T8 %][%- USE HTML %][%- USE L %][%- USE LxERP %]
+
+<form method="post" id="multi_items_form" method="POST">
+
+<table id='multi_items_filter_table'>
+  <tr>
+    <th>[%- LxERP.t8("Description") %]/[%- LxERP.t8("Partnumber") %]:</th>
+    <td>[%- L.input_tag('multi_items.filter.all:substr:multi::ilike', '') %]</td>
+    <th>[%- LxERP.t8("Group") %]</th>
+    <td>[%- L.select_tag('multi_items.filter.partsgroup_id', all_partsgroups, title_key='displayable_name', value_key='id', with_empty=1) %]</td>
+  <tr>
+</table>
+
+[% L.button_tag('update_result()', LxERP.t8('Filter')) %]
+<a href='#' onClick='javascript:$("#multi_items_filter_table input").val("");$("#multi_items_filter_table input[type=checkbox]").prop("checked", 0);$("#multi_items_filter_table select").prop("selectedIndex", 0);'>[% 'Reset' | $T8 %]</a>
+
+<hr>
+<div id='multi_items_result'></div>
+<hr>
+
+[% L.button_tag('add_multi_items()', LxERP.t8('Continue'), id='continue_button') %]
+<a href="#" onclick="kivi.Part.close_multi_items_dialog();">[%- LxERP.t8("Cancel") %]</a>
+
+<script type='text/javascript'>
+function update_result() {
+  var data = $('#multi_items_form').serializeArray();
+  data.push({ name: 'type', value: '[%- FORM.type %]' });
+  $.ajax({
+    url: 'controller.pl?action=Part/multi_items_update_result',
+    data: data,
+    method: 'post',
+    success: function(data){
+      $('#multi_items_result').html(data);
+      enable_continue();
+    }
+  });
+}
+
+function disable_continue() {
+  // disable keydown-event and continue button to prevent
+  // impatient users to add parts multiple times
+  $('#multi_items_result input').off("keydown");
+  $('#continue_button').prop('disabled', true);
+}
+
+function enable_continue() {
+  $('#multi_items_result input').keydown(function(event) {
+    if(event.keyCode == 13) {
+      event.preventDefault();
+      add_multi_items();
+      return false;
+    }
+  });
+  $('#continue_button').prop('disabled', false);
+}
+
+function add_multi_items() {
+  // rows at all
+  var n_rows = $('.multi_items_qty').length;
+  if ( n_rows == 0) { return; }
+
+  // filled rows
+  n_rows = $('.multi_items_qty').filter(function() {
+    return $(this).val().length > 0;
+  }).length;
+  if ( n_rows == 0) { return; }
+
+  disable_continue();
+
+  var data = $('#[%- FORM.callback_data_id %]').serializeArray();
+  data = data.concat($('#multi_items_form').serializeArray());
+  data.push({ name: 'action', value: '[%- FORM.callback %]' });
+  $.post("controller.pl", data, kivi.eval_json_result);
+}
+
+$('#multi_items_filter_table input, #multi_items_filter_table select').keydown(function(event) {
+  if(event.keyCode == 13) {
+    event.preventDefault();
+    update_result();
+    return false;
+  }
+});
+
+$('#multi_items_filter_all_substr_multi_ilike').focus();
+</script>
+
+</form>
diff --git a/templates/webpages/part/_multi_items_dialog.html b/templates/webpages/part/_multi_items_dialog.html
new file mode 100644 (file)
index 0000000..9938bf6
--- /dev/null
@@ -0,0 +1,91 @@
+[%- USE T8 %][%- USE HTML %][%- USE L %][%- USE LxERP %]
+
+<form method="post" id="multi_items_form" method="POST">
+
+[% L.hidden_tag('part.part_type', FORM.part.part_type) %]
+
+<table id='multi_items_filter_table'>
+  <tr>
+    <th>[%- LxERP.t8("Description") %]/[%- LxERP.t8("Partnumber") %]:</th>
+    <td>[%- L.input_tag('multi_items.filter.all:substr:multi::ilike', partfilter) %]</td>
+    <th>[%- LxERP.t8("Group") %]</th>
+    <td>[%- L.select_tag('multi_items.filter.partsgroup_id', all_partsgroups, title_key='partsgroup', value_key='id', with_empty=1) %]</td>
+  <tr>
+</table>
+
+[% L.button_tag('update_result()', LxERP.t8('Filter')) %]
+<a href='#' onClick='javascript:$("#multi_items_filter_table input").val("");$("#multi_items_filter_table input[type=checkbox]").prop("checked", 0);$("#multi_items_filter_table select").prop("selectedIndex", 0);'>[% 'Reset' | $T8 %]</a>
+
+<hr>
+<div id='multi_items_result'></div>
+<hr>
+
+[% L.button_tag('add_multi_items()', LxERP.t8('Continue'), id='continue_button') %]
+<a href="#" onclick="kivi.Part.close_multi_items_dialog();">[%- LxERP.t8("Cancel") %]</a>
+
+<script type='text/javascript'>
+function update_result() {
+  var data = $('#multi_items_form').serializeArray();
+  data.push({ name: 'type', value: '[%- FORM.type %]' });
+  $.ajax({
+    url: 'controller.pl?action=Part/multi_items_update_result',
+    data: data,
+    method: 'post',
+    success: function(data){
+      $('#multi_items_result').html(data);
+      enable_continue();
+    }
+  });
+}
+
+function disable_continue() {
+  // disable keydown-event and continue button to prevent
+  // impatient users to add parts multiple times
+  $('#multi_items_result input').off("keydown");
+  $('#continue_button').prop('disabled', true);
+}
+
+function enable_continue() {
+  $('#multi_items_result input').keydown(function(event) {
+    if(event.keyCode == 13) {
+      event.preventDefault();
+      add_multi_items();
+      return false;
+    }
+  });
+  $('#continue_button').prop('disabled', false);
+}
+
+function add_multi_items() {
+  // rows at all
+  var n_rows = $('.multi_items_qty').length;
+  if ( n_rows == 0) { return; }
+
+  // filled rows
+  n_rows = $('.multi_items_qty').filter(function() {
+    return $(this).val().length > 0;
+  }).length;
+  if ( n_rows == 0) { return; }
+
+  disable_continue();
+
+  // var data = $('#[%- FORM.callback_data_id %]').serializeArray(); /* do i need to serialize this as well? */
+  // var data = data.concat($('#multi_items_form').serializeArray());
+  var data = $('#multi_items_form').serializeArray();
+  data.push({ name: 'action', value: '[%- FORM.callback %]' });
+  data.push({ name: 'part_type', value: '[%- part_type %]' });
+  $.post("controller.pl", data, kivi.eval_json_result);
+}
+
+$('#multi_items_filter_table input, #multi_items_filter_table select').keydown(function(event) {
+  if(event.keyCode == 13) {
+    event.preventDefault();
+    update_result();
+    return false;
+  }
+});
+
+$('#multi_items_filter_all_substr_multi_ilike').focus();
+</script>
+
+</form>
diff --git a/templates/webpages/part/_multi_items_result.html b/templates/webpages/part/_multi_items_result.html
new file mode 100644 (file)
index 0000000..15ca30c
--- /dev/null
@@ -0,0 +1,46 @@
+[%- USE T8 %][%- USE HTML %][%- USE L %][%- USE LxERP %][% USE P %]
+
+<table id="multi_items">
+    <tr>
+      <td>[% 'for all' | $T8 %]
+      <td>[% L.input_tag("multi_items.all_qty", '', size = 5, class='numeric') %]</td>
+    </tr>
+    <tr>
+      <td colspan="5"><hr></td>
+    </tr>
+    <tr>
+      <th></th>
+      <th>[% 'Qty'       | $T8 %]</th>
+      <th>[% 'Unit'      | $T8 %]</th>
+      <th>[% 'Article'   | $T8 %]</th>
+      <th>[% 'Sellprice' | $T8 %]</th>
+      <th>[% 'Group'     | $T8 %]</th>
+    </tr>
+  [%- FOREACH item = multi_items %]
+    <tr>
+      <td></td>
+      <td>
+        [% L.hidden_tag("add_items[+].parts_id", item.id) %]
+        [% L.input_tag("add_items[].qty_as_number", '', size = 5,
+                       class = 'multi_items_qty numeric', onclick = 'set_qty_to_one(this)') %]
+      </td>
+      <td>[% HTML.escape(item.unit) %]</td>
+      <td>[% P.part(item) %]</td>
+      <td class="numeric">[% HTML.escape(item.sellprice_as_number) %]</td>
+      <td class="numeric">[% HTML.escape(item.partsgroup.partsgroup) %]</td>
+    </tr>
+  [%- END %]
+</table>
+
+<script type='text/javascript'>
+  function set_qty_to_one(clicked) {
+    if ($(clicked).val() == '') {
+      $(clicked).val('[%- LxERP.format_amount(1.00, -2) %]');
+    }
+    $(clicked).select();
+  }
+
+  $('#multi_items_all_qty').change(function(event){
+    $('.multi_items_qty').val($(event.target).val());
+  });
+</script>
diff --git a/templates/webpages/part/_pricegroup_prices.html b/templates/webpages/part/_pricegroup_prices.html
new file mode 100644 (file)
index 0000000..f481724
--- /dev/null
@@ -0,0 +1,24 @@
+[%- USE T8 %]
+[%- USE L %]
+[%- USE HTML %]
+[%- USE LxERP %]
+  <tr>
+    <td>
+      <table width=50%>
+        <tr>
+          <th class="listheading">[% 'Price group' | $T8 %]</th>
+          <th class="listheading">[% 'Price'       | $T8 %]</th>
+        </tr>
+        [%- FOREACH pricegroup = SELF.all_pricegroups %]
+          [% SET pricegroup_id = pricegroup.id
+                 price         = prices_map.$pricegroup_id %]
+        <tr class="listrow[% loop.count % 2 %]">
+          <td style='display:none'>[% L.hidden_tag('prices[+].pricegroup_id', pricegroup.id) %]
+          [% L.hidden_tag('prices[].price_id', price.id) # id not used? %]</td>
+          <td width=50%>[% L.hidden_tag('prices[].pricegroup', pricegroup.pricegroup) %][% HTML.escape(pricegroup.pricegroup) %]</td>
+          <td width=50%>[% L.input_tag('prices[].price', price.price_as_number, size=11, class='numeric reformat_number') %]</td>
+        </tr>
+        [%- END %]
+      </table>
+    </td>
+  </tr>
diff --git a/templates/webpages/part/_sales_price_information.html b/templates/webpages/part/_sales_price_information.html
new file mode 100644 (file)
index 0000000..012a68b
--- /dev/null
@@ -0,0 +1,34 @@
+<div id='sales_price_information_sales_order'></div>
+<div id='sales_price_information_sales_quotation'></div>
+<div id='parts_price_history'></div>
+
+<script type='text/javascript'>
+  function get_report(target, source, data){
+    $.ajax({
+      url:        source,
+//      beforeSend: function () { $(target).html('<img src="image/spinner.gif">') },
+      success:    function (rsp) {
+        $(target).html(rsp);
+        $(target).find('.paginate').find('a').click(function(event){ redirect_event(event, target) });
+        $(target).find('a.report-generator-header-link').click(function(event){ redirect_event(event, target) });
+      },
+      data:       data,
+    });
+  };
+
+  function redirect_event(event, target){
+    event.preventDefault();
+    get_report(target, event.target + '', {});
+  }
+
+  $('.tabwidget').on('tabsbeforeactivate', function(event, ui){
+    if (ui.newPanel.attr('id') == 'sales_price_information') {
+      get_report('#sales_price_information_sales_order', 'controller.pl', { action: 'SellPriceInformation/list', 'filter.part.id': [% id %], 'filter.order.type': 'sales_order' });
+      get_report('#sales_price_information_sales_quotation', 'controller.pl', { action: 'SellPriceInformation/list', 'filter.part.id': [% id %], 'filter.order.type': 'sales_quotation' });
+      get_report('#parts_price_history', 'controller.pl', { action: 'PartsPriceHistory/list', 'filter.part_id': [% id %] });
+    }
+    return 1;
+  });
+
+
+</script>
diff --git a/templates/webpages/part/form.html b/templates/webpages/part/form.html
new file mode 100644 (file)
index 0000000..03433f3
--- /dev/null
@@ -0,0 +1,95 @@
+[%- USE T8 %]
+[%- USE HTML %]
+[%- USE LxERP %]
+[%- USE L %]
+[%- USE P %]
+<h1>[% FORM.title %] [% IF SELF.part.id %]: [% HTML.escape(SELF.part.displayable_name) %][% END %]</h1>
+
+[% INCLUDE 'common/flash.html' %]
+
+ <form method="post" id="ic" name="ic" action="controller.pl">
+
+  [% L.hidden_tag('part.part_type'   , SELF.part.part_type) %]
+  [% L.hidden_tag('part.id'          , SELF.part.id) %]
+  [% L.hidden_tag('last_modification', SELF.part.last_modification) %]
+
+  <div id="ic_tabs" class="tabwidget">
+   <ul>
+    <li><a href="#basic_data">[% 'Basic Data' | $T8 %]</a></li>
+    [%- IF SELF.part.is_assortment %]
+    <li><a href="#assortment_tab">[% 'Assortment items' | $T8 %]</a></li>
+    [%- END %]
+    [%- IF SELF.part.is_assembly %]
+    <li><a href="#assembly_tab">[% 'Assembly items' | $T8 %]</a></li>
+    [%- END %]
+    [% IF SELF.all_languages.size %]
+    <li><a href="#translations_tab">[% 'Translations' | $T8 %]</a></li>
+    [% END %]
+    [%- IF SELF.part.id %]
+    <li><a href="#sales_price_information">[% 'Price information' | $T8 %]</a></li>
+    [%- END %]
+    [%- IF SELF.part.id  %]
+    <li><a href="#price_rules">[% 'Price Rules' | $T8 %]</a></li>
+    [% END %]
+    [%- IF CUSTOM_VARIABLES.size %]
+    <li><a href="#custom_variables">[% 'Custom Variables' | $T8 %]</a></li>
+    [%- END %]
+   </ul>
+
+   <div id="basic_data">
+   [%- PROCESS 'part/_basic_data.html' %]
+   </div>
+
+   [%- IF SELF.part.is_assortment %]
+   <div id="assortment_tab">
+    [% PROCESS 'part/_assortment.html' id=part.id assortment_id=SELF.part.id %]
+   </div>
+   [%- END %]
+
+   [%- IF SELF.part.is_assembly %]
+   <div id="assembly_tab">
+    [% PROCESS 'part/_assembly.html' id=part.id assembly_id=SELF.part.id %]
+   </div>
+   [%- END %]
+
+   [%- IF SELF.all_languages.size %]
+    [% PROCESS 'part/_edit_translations.html' %]
+   [%- END %]
+
+   [%- IF SELF.part.id %]
+   <div id="sales_price_information">
+     [% PROCESS part/_sales_price_information.html id=SELF.part.id %]
+   </div>
+   [%- END %]
+
+   [%- IF CUSTOM_VARIABLES.size %]
+   <div id="custom_variables">
+      [%- PROCESS 'part/_cvars.html' %]
+   </div>
+   [%- END %]
+
+   [%- IF SELF.part.id %]
+   <div id='price_rules'>
+     <div id='price_rules_customer_report'></div>
+     <div id='price_rules_vendor_report'></div>
+   </div>
+   [%- END %]
+
+</div>
+
+  <p>
+  [% L.hidden_tag('action', 'Part/dispatch') %]
+
+  [% IF show_edit_buttons %]
+    [% L.button_tag('kivi.Part.save()', LxERP.t8('Save')) %]
+    [% IF SELF.part.id %]
+    [% L.button_tag('kivi.Part.use_as_new()', LxERP.t8('Use as new')) %]
+      [% IF SELF.part.orphaned %]
+        [% L.button_tag('kivi.Part.delete()', LxERP.t8('Delete')) %]
+      [% END %]
+    [% L.button_tag('kivi.Part.open_history_popup()', LxERP.t8('History')) %]
+    [% END %]
+  [% END %]
+  </p>
+
+</form>
diff --git a/templates/webpages/part/history.html b/templates/webpages/part/history.html
new file mode 100644 (file)
index 0000000..9184675
--- /dev/null
@@ -0,0 +1,17 @@
+[% USE T8 %]
+[% USE HTML %]
+
+<table>
+<tr>
+<th>[% 'Time' | $T8 %]</th>
+<th>[% 'Aktion' | $T8 %]</th>
+<th>[% 'Employee' | $T8 %]</th>
+</tr>
+[% FOREACH history = history_entries %]
+<tr>
+ <td>[% history.itime.to_kivitendo %] [% history.itime.to_kivitendo_time %]</td>
+ <td>[% history.addition | $T8 %]</td>
+ <td>[% HTML.escape(history.employee.name) %]</td>
+</tr>
+[% END %]
+</table>