Pflichtenhefte: Unterstützung für an Textblöcke angehängte Bilder
authorMoritz Bunkus <m.bunkus@linet-services.de>
Thu, 8 Aug 2013 10:21:26 +0000 (12:21 +0200)
committerMoritz Bunkus <m.bunkus@linet-services.de>
Tue, 1 Apr 2014 11:09:10 +0000 (13:09 +0200)
23 files changed:
SL/Controller/RequirementSpec.pm
SL/Controller/RequirementSpecTextBlock.pm
SL/DB/Helper/ALL.pm
SL/DB/Helper/Mappings.pm
SL/DB/Manager/RequirementSpecPicture.pm [new file with mode: 0644]
SL/DB/MetaSetup/RequirementSpec.pm
SL/DB/MetaSetup/RequirementSpecPicture.pm [new file with mode: 0644]
SL/DB/RequirementSpec.pm
SL/DB/RequirementSpecPicture.pm [new file with mode: 0644]
SL/DB/RequirementSpecTextBlock.pm
SL/InstallationCheck.pm
SL/Template/Plugin/Base64.pm [new file with mode: 0644]
css/requirement_spec.css
image/add-picture.png [new file with mode: 0644]
image/download.png [new file with mode: 0644]
js/locale/de.js
js/requirement_spec.js
locale/de/all
sql/Pg-upgrade2/requirement_spec_pictures.sql [new file with mode: 0644]
templates/print/Standard/requirement_spec.tex
templates/webpages/requirement_spec_text_block/_picture_form.html [new file with mode: 0644]
templates/webpages/requirement_spec_text_block/_text_block.html
templates/webpages/requirement_spec_text_block/_text_block_picture.html [new file with mode: 0644]

index c7f4b5b..9727d58 100644 (file)
@@ -5,7 +5,10 @@ use utf8;
 
 use parent qw(SL::Controller::Base);
 
+use File::Spec ();
+
 use SL::ClientJS;
+use SL::Common ();
 use SL::Controller::Helper::GetModels;
 use SL::Controller::Helper::Filtered;
 use SL::Controller::Helper::Paginated;
@@ -208,8 +211,11 @@ sub action_create_pdf {
   my ($self, %params) = @_;
 
   my $base_name       = $self->requirement_spec->type->template_file_name || 'requirement_spec';
+  my @pictures        = $self->prepare_pictures_for_printing;
   my %result          = SL::Template::LaTeX->parse_and_create_pdf("${base_name}.tex", SELF => $self, rspec => $self->requirement_spec);
 
+  unlink @pictures unless ($::lx_office_conf{debug} || {})->{keep_temp_files};
+
   $::form->error(t8('Conversion to PDF failed: #1', $result{error})) if $result{error};
 
   my $attachment_name  =  $self->requirement_spec->type->description . ' ' . ($self->requirement_spec->working_copy_id || $self->requirement_spec->id);
@@ -255,7 +261,7 @@ sub setup {
 
   $::auth->assert('sales_quotation_edit');
   $::request->{layout}->use_stylesheet("${_}.css") for qw(jquery.contextMenu requirement_spec);
-  $::request->{layout}->use_javascript("${_}.js") for qw(jquery.jstree jquery/jquery.contextMenu client_js requirement_spec);
+  $::request->{layout}->use_javascript("${_}.js") for qw(jquery.jstree jquery/jquery.contextMenu requirement_spec);
   $self->init_visible_section;
 
   return 1;
@@ -469,4 +475,25 @@ sub render_first_pasted_section_as_list {
     ->jstree->select_node('#tree', '#fb-' . $section->id);
 }
 
+sub prepare_pictures_for_printing {
+  my ($self) = @_;
+
+  my @files;
+  my $userspath = File::Spec->rel2abs($::lx_office_conf{paths}->{userspath});
+  my $target    =  "${userspath}/kivitendo-print-requirement-spec-picture-" . Common::unique_id() . '-';
+
+  foreach my $picture (map { @{ $_->pictures } } @{ $self->requirement_spec->text_blocks }) {
+    my $output_file_name        = $target . $picture->id . '.' . $picture->get_default_file_name_extension;
+    $picture->{print_file_name} = File::Spec->abs2rel($output_file_name, $userspath);
+    my $out                     = IO::File->new($output_file_name, 'w') || die("Could not create file " . $output_file_name);
+    $out->binmode;
+    $out->print($picture->picture_content);
+    $out->close;
+
+    push @files, $output_file_name;
+  }
+
+  return @files;
+}
+
 1;
index 1901ce8..f52cbc8 100644 (file)
@@ -12,6 +12,7 @@ use SL::ClientJS;
 use SL::Clipboard;
 use SL::Controller::Helper::RequirementSpec;
 use SL::DB::RequirementSpec;
+use SL::DB::RequirementSpecPicture;
 use SL::DB::RequirementSpecPredefinedText;
 use SL::DB::RequirementSpecTextBlock;
 use SL::Helper::Flash;
@@ -19,11 +20,11 @@ use SL::Locale::String;
 
 use Rose::Object::MakeMethods::Generic
 (
-  scalar                  => [ qw(text_block) ],
+  scalar                  => [ qw(text_block picture) ],
   'scalar --get_set_init' => [ qw(predefined_texts js) ],
 );
 
-__PACKAGE__->run_before('load_requirement_spec_text_block', only => [qw(ajax_edit ajax_update ajax_delete ajax_flag dragged_and_dropped ajax_copy)]);
+__PACKAGE__->run_before('load_requirement_spec_text_block', only => [qw(ajax_edit ajax_update ajax_delete ajax_flag dragged_and_dropped ajax_copy ajax_add_picture)]);
 
 #
 # actions
@@ -259,6 +260,95 @@ sub action_ajax_paste {
     ->render($self);
 }
 
+#
+# actions for pictures
+#
+
+sub action_ajax_add_picture {
+  my ($self) = @_;
+
+  $self->picture(SL::DB::RequirementSpecPicture->new);
+  $self->render('requirement_spec_text_block/_picture_form', { layout => 0 });
+}
+
+sub action_ajax_edit_picture {
+  my ($self) = @_;
+
+  $self->picture(SL::DB::RequirementSpecPicture->new(id => $::form->{picture_id})->load);
+  $self->text_block($self->picture->text_block);
+  $self->render('requirement_spec_text_block/_picture_form', { layout => 0 });
+}
+
+sub action_ajax_create_picture {
+  my ($self, %params)              = @_;
+
+  my $attributes                   = $::form->{ $::form->{form_prefix} } || die "Missing attributes";
+  $attributes->{picture_file_name} = ((($::form->{ATTACHMENTS} || {})->{ $::form->{form_prefix} } || {})->{picture_content} || {})->{filename};
+  my @errors                       = $self->picture(SL::DB::RequirementSpecPicture->new(%{ $attributes }))->validate;
+
+  return $self->js->error(@errors)->render($self) if @errors;
+
+  $self->picture->save;
+
+  $self->text_block($self->picture->text_block);
+  my $html = $self->render('requirement_spec_text_block/_text_block_picture', { output => 0 }, picture => $self->picture);
+
+  $self->invalidate_version
+    ->dialog->close('#jqueryui_popup_dialog')
+    ->append('#text-block-' . $self->text_block->id . '-pictures', $html)
+    ->show('#text-block-' . $self->text_block->id . '-pictures')
+    ->render($self);
+}
+
+sub action_ajax_update_picture {
+  my ($self)     = @_;
+
+  my $attributes = $::form->{ $::form->{form_prefix} } || die "Missing attributes";
+  $self->picture(SL::DB::RequirementSpecPicture->new(id => $::form->{id})->load);
+
+  if (!$attributes->{picture_content}) {
+    delete $attributes->{picture_content};
+  } else {
+    $attributes->{picture_file_name} = ((($::form->{ATTACHMENTS} || {})->{ $::form->{form_prefix} } || {})->{picture_content} || {})->{filename};
+  }
+
+  $self->picture->assign_attributes(%{ $attributes });
+  my @errors = $self->picture->validate;
+
+  return $self->js->error(@errors)->render($self) if @errors;
+
+  $self->picture->save;
+
+  $self->text_block($self->picture->text_block);
+  my $html = $self->render('requirement_spec_text_block/_text_block_picture', { output => 0 }, picture => $self->picture);
+
+  $self->invalidate_version
+    ->dialog->close('#jqueryui_popup_dialog')
+    ->replaceWith('#text-block-picture-' . $self->picture->id, $html)
+    ->show('#text-block-' . $self->text_block->id . '-pictures')
+    ->render($self);
+}
+
+sub action_ajax_delete_picture {
+  my ($self) = @_;
+
+  $self->picture(SL::DB::RequirementSpecPicture->new(id => $::form->{id})->load);
+  $self->picture->delete;
+  $self->text_block(SL::DB::RequirementSpecTextBlock->new(id => $self->picture->text_block_id)->load);
+
+  $self->invalidate_version
+    ->remove('#text-block-picture-' . $self->picture->id)
+    ->action_if(!@{ $self->text_block->pictures }, 'hide', '#text-block-' . $self->text_block->id . '-pictures')
+    ->render($self);
+}
+
+sub action_ajax_download_picture {
+  my ($self) = @_;
+
+  $self->picture(SL::DB::RequirementSpecPicture->new(id => $::form->{id})->load);
+  $self->send_file(\$self->picture->{picture_content}, type => $self->picture->picture_content_type, name => $self->picture->picture_file_name);
+}
+
 #
 # filters
 #
index 5e78a73..c7d38e5 100644 (file)
@@ -82,6 +82,7 @@ use SL::DB::RequirementSpecComplexity;
 use SL::DB::RequirementSpecDependency;
 use SL::DB::RequirementSpecItem;
 use SL::DB::RequirementSpecOrder;
+use SL::DB::RequirementSpecPicture;
 use SL::DB::RequirementSpecPredefinedText;
 use SL::DB::RequirementSpecRisk;
 use SL::DB::RequirementSpecStatus;
index 3ccfb57..d034554 100644 (file)
@@ -162,6 +162,7 @@ my %kivitendo_package_names = (
   requirement_spec_item_dependencies   => 'RequirementSpecDependency',
   requirement_spec_items               => 'RequirementSpecItem',
   requirement_spec_orders              => 'RequirementSpecOrder',
+  requirement_spec_pictures            => 'RequirementSpecPicture',
   requirement_spec_predefined_texts    => 'RequirementSpecPredefinedText',
   requirement_spec_risks               => 'RequirementSpecRisk',
   requirement_spec_statuses            => 'RequirementSpecStatus',
diff --git a/SL/DB/Manager/RequirementSpecPicture.pm b/SL/DB/Manager/RequirementSpecPicture.pm
new file mode 100644 (file)
index 0000000..e0e7802
--- /dev/null
@@ -0,0 +1,15 @@
+# This file has been auto-generated only because it didn't exist.
+# Feel free to modify it at will; it will not be overwritten automatically.
+
+package SL::DB::Manager::RequirementSpecPicture;
+
+use strict;
+
+use SL::DB::Helper::Manager;
+use base qw(SL::DB::Helper::Manager);
+
+sub object_class { 'SL::DB::RequirementSpecPicture' }
+
+__PACKAGE__->make_manager_methods;
+
+1;
index bb37ba6..c13400c 100644 (file)
@@ -16,6 +16,7 @@ __PACKAGE__->meta->columns(
   itime                   => { type => 'timestamp', default => 'now()' },
   mtime                   => { type => 'timestamp' },
   previous_fb_number      => { type => 'integer', not_null => 1 },
+  previous_picture_number => { type => 'integer', default => '0', not_null => 1 },
   previous_section_number => { type => 'integer', not_null => 1 },
   project_id              => { type => 'integer' },
   status_id               => { type => 'integer' },
diff --git a/SL/DB/MetaSetup/RequirementSpecPicture.pm b/SL/DB/MetaSetup/RequirementSpecPicture.pm
new file mode 100644 (file)
index 0000000..731c5ca
--- /dev/null
@@ -0,0 +1,49 @@
+# This file has been auto-generated. Do not modify it; it will be overwritten
+# by rose_auto_create_model.pl automatically.
+package SL::DB::RequirementSpecPicture;
+
+use strict;
+
+use base qw(SL::DB::Object);
+
+__PACKAGE__->meta->table('requirement_spec_pictures');
+
+__PACKAGE__->meta->columns(
+  description            => { type => 'text' },
+  id                     => { type => 'serial', not_null => 1 },
+  itime                  => { type => 'timestamp', default => 'now()', not_null => 1 },
+  mtime                  => { type => 'timestamp' },
+  number                 => { type => 'text', not_null => 1 },
+  picture_content        => { type => 'bytea', not_null => 1 },
+  picture_content_type   => { type => 'text', not_null => 1 },
+  picture_file_name      => { type => 'text', not_null => 1 },
+  picture_height         => { type => 'integer', not_null => 1 },
+  picture_mtime          => { type => 'timestamp', default => 'now()', not_null => 1 },
+  picture_width          => { type => 'integer', not_null => 1 },
+  position               => { type => 'integer', not_null => 1 },
+  requirement_spec_id    => { type => 'integer', not_null => 1 },
+  text_block_id          => { type => 'integer', not_null => 1 },
+  thumbnail_content      => { type => 'bytea', not_null => 1 },
+  thumbnail_content_type => { type => 'text', not_null => 1 },
+  thumbnail_height       => { type => 'integer', not_null => 1 },
+  thumbnail_width        => { type => 'integer', not_null => 1 },
+);
+
+__PACKAGE__->meta->primary_key_columns([ 'id' ]);
+
+__PACKAGE__->meta->allow_inline_column_values(1);
+
+__PACKAGE__->meta->foreign_keys(
+  requirement_spec => {
+    class       => 'SL::DB::RequirementSpec',
+    key_columns => { requirement_spec_id => 'id' },
+  },
+
+  text_block => {
+    class       => 'SL::DB::RequirementSpecTextBlock',
+    key_columns => { text_block_id => 'id' },
+  },
+);
+
+1;
+;
index 52ae4ec..9ec553c 100644 (file)
@@ -60,8 +60,9 @@ sub validate {
 sub _before_save_initialize_not_null_columns {
   my ($self) = @_;
 
-  $self->previous_section_number(0) if !defined $self->previous_section_number;
-  $self->previous_fb_number(0)      if !defined $self->previous_fb_number;
+  for (qw(previous_section_number previous_fb_number previous_picture_number)) {
+    $self->$_(0) if !defined $self->$_;
+  }
 
   return 1;
 }
@@ -149,17 +150,25 @@ sub _copy_from {
 
   # Copy attributes.
   if (!$params->{paste_template}) {
-    $self->assign_attributes(map({ ($_ => $source->$_) } qw(type_id status_id customer_id project_id title hourly_rate time_estimation previous_section_number previous_fb_number is_template)),
+    $self->assign_attributes(map({ ($_ => $source->$_) } qw(type_id status_id customer_id project_id title hourly_rate time_estimation previous_section_number previous_fb_number previous_picture_number is_template)),
                              %attributes);
   }
 
   my %paste_template_result;
 
-  # Clone text blocks.
+  # Clone text blocks and pictures.
+  my $clone_picture = sub {
+    my ($picture) = @_;
+    my $cloned    = Rose::DB::Object::Helpers::clone_and_reset($picture);
+    $cloned->position(undef);
+    return $cloned;
+  };
+
   my $clone_text_block = sub {
     my ($text_block) = @_;
     my $cloned       = Rose::DB::Object::Helpers::clone_and_reset($text_block);
     $cloned->position(undef);
+    $cloned->pictures([ map { $clone_picture->($_) } @{ $text_block->pictures_sorted } ]);
     return $cloned;
   };
 
diff --git a/SL/DB/RequirementSpecPicture.pm b/SL/DB/RequirementSpecPicture.pm
new file mode 100644 (file)
index 0000000..0597889
--- /dev/null
@@ -0,0 +1,140 @@
+package SL::DB::RequirementSpecPicture;
+
+use strict;
+
+use Carp;
+use GD;
+use Image::Info;
+use List::MoreUtils qw(apply);
+use List::Util qw(max);
+use Rose::DB::Object::Util;
+use SL::DB::MetaSetup::RequirementSpecPicture;
+use SL::DB::Manager::RequirementSpecPicture;
+use SL::DB::Helper::ActsAsList;
+use SL::Locale::String;
+
+__PACKAGE__->meta->initialize;
+
+__PACKAGE__->configure_acts_as_list(group_by => [qw(requirement_spec_id text_block_id)]);
+
+__PACKAGE__->before_save(\&_create_picture_number);
+__PACKAGE__->before_save(\&_update_thumbnail);
+
+our %supported_mime_types = (
+  'image/gif'  => { extension => 'gif', convert_to_png => 1, },
+  'image/png'  => { extension => 'png' },
+  'image/jpeg' => { extension => 'jpg' },
+);
+
+sub _create_picture_number {
+  my ($self) = @_;
+
+  return 1 if  $self->number;
+  return 0 if !$self->requirement_spec_id;
+
+  my $next_number = $self->requirement_spec->previous_picture_number + 1;
+
+  $self->requirement_spec->update_attributes(previous_picture_number => $next_number) || return 0;
+
+  $self->number($next_number);
+
+  return 1;
+}
+
+sub _update_thumbnail {
+  my ($self) = @_;
+
+  return 1 if !$self->picture_content || !$self->picture_content_type || !Rose::DB::Object::Util::get_column_value_modified($self, 'picture_content');
+  $self->create_thumbnail;
+  return 1;
+}
+
+sub create_thumbnail {
+  my ($self) = @_;
+
+  croak "No picture set yet" if !$self->picture_content;
+
+  my $image            = GD::Image->new($self->picture_content);
+  my ($width, $height) = $image->getBounds;
+  my $max_dim          = 64;
+  my $curr_max         = max $width, $height, 1;
+  my $factor           = $curr_max <= $max_dim ? 1 : $curr_max / $max_dim;
+  my $new_width        = int($width  / $factor + 0.5);
+  my $new_height       = int($height / $factor + 0.5);
+  my $thumbnail        = GD::Image->new($new_width, $new_height);
+
+  $thumbnail->copyResized($image, 0, 0, 0, 0, $new_width, $new_height, $width, $height);
+
+  $self->thumbnail_content($thumbnail->png);
+  $self->thumbnail_content_type('image/png');
+  $self->thumbnail_width($new_width);
+  $self->thumbnail_height($new_height);
+
+  return 1;
+}
+
+sub update_type_and_dimensions {
+  my ($self) = @_;
+
+  return () if !$self->picture_content;
+  return () if $self->picture_content_type && $self->picture_width && $self->picture_height && !Rose::DB::Object::Util::get_column_value_modified($self, 'picture_content');
+
+  my @errors = $self->probe_type;
+  return @errors if @errors;
+
+  my $info = $supported_mime_types{ $self->picture_content_type };
+  if ($info->{convert_to_png}) {
+    $self->picture_content(GD::Image->new($self->picture_content)->png);
+    $self->picture_content_type('image/png');
+    $self->picture_file_name(apply { s/\.[^\.]+$//;  $_ .= '.png'; } $self->picture_file_name);
+  }
+
+  return ();
+}
+
+sub probe_type {
+  my ($self) = @_;
+
+  return (t8("No picture uploaded yet")) if !$self->picture_content;
+
+  my $info = Image::Info::image_info(\$self->{picture_content});
+  if (!$info || $info->{error} || !$info->{file_media_type} || !$supported_mime_types{ $info->{file_media_type} }) {
+    $::lxdebug->warn("Image::Info error: " . $info->{error}) if $info && $info->{error};
+    return (t8('Unsupported image type (supported types: #1)', join(' ', sort keys %supported_mime_types)));
+  }
+
+  $self->picture_content_type($info->{file_media_type});
+  $self->picture_width($info->{width});
+  $self->picture_height($info->{height});
+  $self->picture_mtime(DateTime->now_local);
+
+  $self->create_thumbnail;
+
+  return ();
+}
+
+sub get_default_file_name_extension {
+  my ($self) = @_;
+
+  my $info = $supported_mime_types{ $self->picture_content_type } || croak("Unsupported content type " . $self->picture_content_type);
+  return $info->{extension};
+}
+
+sub validate {
+  my ($self) = @_;
+
+  my @errors;
+
+  push @errors, t8('The file name is missing') if !$self->picture_file_name;
+
+  if (!length($self->picture_content // '')) {
+    push @errors, t8('No picture has been uploaded');
+
+  } else {
+    push @errors, $self->update_type_and_dimensions;
+  }
+
+  return @errors;
+}
+
+1;
index 832d341..421293a 100644 (file)
@@ -2,6 +2,7 @@ package SL::DB::RequirementSpecTextBlock;
 
 use strict;
 
+use Carp;
 use List::MoreUtils qw(any);
 use Rose::DB::Object::Helpers;
 use Rose::DB::Object::Util;
@@ -11,6 +12,14 @@ use SL::DB::Manager::RequirementSpecTextBlock;
 use SL::DB::Helper::ActsAsList;
 use SL::Locale::String;
 
+__PACKAGE__->meta->add_relationship(
+  pictures => {
+    type         => 'one to many',
+    class        => 'SL::DB::RequirementSpecPicture',
+    column_map   => { id => 'text_block_id' },
+  },
+);
+
 __PACKAGE__->meta->initialize;
 
 __PACKAGE__->configure_acts_as_list(group_by => [qw(requirement_spec_id output_position)]);
@@ -48,4 +57,12 @@ sub _before_delete_invalidate_requirement_spec_version {
   return 1;
 }
 
+sub pictures_sorted {
+  my ($self, @args) = @_;
+
+  croak "Not a writer" if @args;
+
+  return [ sort { $a->position <=> $b->position } $self->pictures ];
+}
+
 1;
index a3aa509..3d30564 100644 (file)
@@ -27,6 +27,8 @@ BEGIN {
   { name => "Email::MIME",                         url => "http://search.cpan.org/~rjbs/",      debian => 'libemail-mime-perl' },
   { name => "FCGI",            version => '0.72',  url => "http://search.cpan.org/~mstrout/",   debian => 'libfcgi-perl' },
   { name => "File::Copy::Recursive",               url => "http://search.cpan.org/~dmuey/",     debian => 'libfile-copy-recursive-perl' },
+  { name => "GD",                                  url => "http://search.cpan.org/~lds/",       debian => 'libgd-gd2-perl', },
+  { name => "Image::Info",                         url => "http://search.cpan.org/~srezic/",    debian => 'libimage-info-perl' },
   { name => "JSON",                                url => "http://search.cpan.org/~makamaka",   debian => 'libjson-perl' },
   { name => "List::MoreUtils", version => '0.21',  url => "http://search.cpan.org/~vparseval/", debian => 'liblist-moreutils-perl' },
   { name => "Params::Validate",                    url => "http://search.cpan.org/~drolsky/",   debian => 'libparams-validate-perl' },
diff --git a/SL/Template/Plugin/Base64.pm b/SL/Template/Plugin/Base64.pm
new file mode 100644 (file)
index 0000000..897a49a
--- /dev/null
@@ -0,0 +1,78 @@
+package SL::Template::Plugin::Base64;
+
+use strict;
+use vars qw($VERSION);
+
+$VERSION = 0.01;
+
+use base qw(Template::Plugin);
+use Template::Plugin;
+use Template::Stash;
+use MIME::Base64 ();
+
+$Template::Stash::SCALAR_OPS->{'encode_base64'} = \&_encode_base64;
+$Template::Stash::SCALAR_OPS->{'decode_base64'} = \&_decode_base64;
+
+sub new {
+  my ($class, $context, $options) = @_;
+
+  $context->define_filter('encode_base64', \&_encode_base64);
+  $context->define_filter('decode_base64', \&_decode_base64);
+  return bless {}, $class;
+}
+
+sub _encode_base64 {
+  return MIME::Base64::encode_base64(shift, '');
+}
+
+sub _decode_base64 {
+  my ($self, $var) = @_;
+  return MIME::Base64::decode_base64(shift);
+}
+
+1;
+
+__END__
+
+=head1 NAME
+
+SL::Template::Plugin::Base64 - TT2 interface to base64 encoding/decoding
+
+=head1 SYNOPSIS
+
+  [% USE Base64 -%]
+  [% SELF.some_object.binary_stuff.encode_base64 -%]
+  [% SELF.some_object.binary_stuff FILTER encode_base64 -%]
+
+=head1 DESCRIPTION
+
+The I<Base64> Template Toolkit plugin provides access to the Base64
+routines from L<MIME::Base64>.
+
+The following filters (and vmethods of the same name) are installed
+into the current context:
+
+=over 4
+
+=item C<encode_base64>
+
+Returns the string encoded as Base64.
+
+=item C<decode_base64>
+
+Returns the Base64 string decoded back to binary.
+
+=back
+
+As the filters are also available as vmethods the following are all
+equivalent:
+
+    FILTER encode_base64; content; END;
+    content FILTER encode_base64;
+    content.encode_base64;
+
+=head1 AUTHOR
+
+Moritz Bunkus E<lt>m.bunkus@linet-services.de<gt>
+
+=cut
index 5afc008..f4c4cd4 100644 (file)
@@ -48,6 +48,8 @@ table.rs_input_field input, table.rs_input_field select {
 .context-menu-item.icon-save   { background-image: url("../image/document-save.png"); }
 .context-menu-item.icon-revert { background-image: url("../image/edit-undo.png"); }
 .context-menu-item.icon-pdf    { background-image: url("../image/application-pdf.png"); }
+.context-menu-item.icon-add-picture { background-image: url("../image/add-picture.png"); }
+.context-menu-item.icon-download    { background-image: url("../image/download.png"); }
 
 /* ------------------------------------------------------------ */
 /* Sections & function blocks */
@@ -158,6 +160,27 @@ table.rs_input_field input, table.rs_input_field select {
   margin-left: 0;
 }
 
+.requirement-spec-text-block-picture-thumbnail {
+  border-radius: 5px;
+  border: 2px solid #ebebeb;
+  float: left;
+  margin-right: 20px;
+  padding: 5px;
+  text-align: center;
+  width: 130px;
+}
+
+.requirement-spec-text-block-picture-thumbnail-img-container {
+  height: 64px;
+  margin: auto;
+  padding: auto;
+  width: 64px;
+}
+
+.requirement-spec-text-block-picture-thumbnail.selected {
+  border: 2px solid #cbb120;
+}
+
 /* ------------------------------------------------------------ */
 /* Time/cost estimation */
 /* ------------------------------------------------------------ */
diff --git a/image/add-picture.png b/image/add-picture.png
new file mode 100644 (file)
index 0000000..92ad6cf
Binary files /dev/null and b/image/add-picture.png differ
diff --git a/image/download.png b/image/download.png
new file mode 100644 (file)
index 0000000..7165d1b
Binary files /dev/null and b/image/download.png differ
index da287e6..9cdf4a8 100644 (file)
@@ -1,6 +1,8 @@
 namespace("kivi").setupLocale({
 "Add function block":"Funktionsblock hinzufügen",
 "Add linked record":"Verknüpften Beleg hinzufügen",
+"Add picture":"Bild hinzufügen",
+"Add picture to text block":"Bild dem Textblock hinzufügen",
 "Add section":"Abschnitt hinzufügen",
 "Add sub function block":"Unterfunktionsblock hinzufügen",
 "Add text block":"Textblock erfassen",
@@ -18,6 +20,7 @@ namespace("kivi").setupLocale({
 "Create new version":"Neue Version anlegen",
 "Database Connection Test":"Test der Datenbankverbindung",
 "Delete":"Löschen",
+"Delete picture":"Bild löschen",
 "Delete quotation/order":"Angebot/Auftrag löschen",
 "Delete requirement spec":"Pflichtenheft löschen",
 "Delete template":"Vorlage löschen",
@@ -25,14 +28,15 @@ namespace("kivi").setupLocale({
 "Do you really want to cancel?":"Wollen Sie wirklich abbrechen?",
 "Do you really want to revert to this version?":"Wollen Sie wirklich auf diese Version zurücksetzen?",
 "Do you want to set the account number \"#1\" to \"#2\" and the name \"#3\" to \"#4\"?":"Soll die Kontonummer \"#1\" zu \"#2\" und den Name \"#3\" zu \"#4\" geändert werden?",
+"Download picture":"Bild herunterladen",
 "Edit":"Bearbeiten",
 "Edit article/section assignments":"Zuweisung Artikel/Abschnitte bearbeiten",
+"Edit picture":"Bild bearbeiten",
 "Edit text block":"Textblock bearbeiten",
 "Enter longdescription":"Langtext eingeben",
 "Function block actions":"Funktionsblockaktionen",
 "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",
-"Orders/Quotations actions":"",
 "Part picker":"Artikelauswahl",
 "Paste":"Einfügen",
 "Paste template":"Vorlage einfügen",
@@ -44,6 +48,7 @@ namespace("kivi").setupLocale({
 "Section/Function block actions":"Abschnitts-/Funktionsblockaktionen",
 "Select template to paste":"Einzufügende Vorlage auswählen",
 "Text block actions":"Textblockaktionen",
+"Text block picture actions":"Aktionen für Textblockbilder",
 "The description is missing.":"Die Beschreibung fehlt.",
 "The name is missing.":"Der Name fehlt.",
 "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.",
index 00e4319..2d86dec 100644 (file)
@@ -108,6 +108,10 @@ ns.tree_node_clicked = function(event) {
 
 ns.find_text_block_id = function(clicked_elt) {
   var id = $(clicked_elt).attr('id');
+
+  if (/^text-block-picture-\d+$/.test(id))
+    id = $(clicked_elt).closest('.text-block-context-menu').attr('id');
+
   // console.log("id: " + id);
   if (/^text-block-\d+$/.test(id)) {
     // console.log("find_text_block_id: case 1: " + id.substr(11));
@@ -183,6 +187,62 @@ ns.text_block_input_key_down = function(event) {
   }
 };
 
+ns.find_text_block_picture_id = function(clicked_elt) {
+  var id    = $(clicked_elt).attr('id');
+  var match = id.match(/^text-block-picture-(\d+)$/);
+  if (match)
+    return match[1] * 1;
+
+  return undefined;
+};
+
+ns.add_edit_text_block_picture_ajax_call = function(key, opt) {
+  var title = key == 'add_picture' ? kivi.t8('Add picture to text block') : kivi.t8('Edit picture');
+
+  kivi.popup_dialog({ url:    'controller.pl',
+                      data:   { action:     'RequirementSpecTextBlock/ajax_' + key,
+                                id:         ns.find_text_block_id(opt.$trigger),
+                                picture_id: ns.find_text_block_picture_id(opt.$trigger) },
+                      dialog: { title:      title }});
+
+  return true;
+};
+
+ns.standard_text_block_picture_ajax_call = function(key, opt) {
+  var data = {
+      action: "RequirementSpecTextBlock/ajax_" + key
+    , id:     ns.find_text_block_picture_id(opt.$trigger)
+  };
+
+  if (key == 'download_picture')
+    $.download("controller.pl", data);
+  else
+    $.post("controller.pl", data, kivi.eval_json_result);
+
+  return true;
+};
+
+ns.ask_delete_text_block_picture = function(key, opt) {
+  if (confirm(kivi.t8("Are you sure?")))
+    ns.standard_text_block_picture_ajax_call(key, opt);
+  return true;
+};
+
+ns.handle_text_block_picture_popup_menu_markings = function(opt, add) {
+  var id = ns.find_text_block_picture_id(opt.$trigger);
+  if (id)
+    $('#text-block-picture-' + id ).toggleClass('selected', add);
+  return true;
+};
+
+ns.text_block_picture_popup_menu_shown = function(opt) {
+  return ns.handle_text_block_picture_popup_menu_markings(opt, true);
+};
+
+ns.text_block_picture_popup_menu_hidden = function(opt) {
+  return ns.handle_text_block_picture_popup_menu_markings(opt, false);
+};
+
 // --------------------------------------------------------------------------------
 // ------------------------------ sections and items ------------------------------
 // --------------------------------------------------------------------------------
@@ -568,11 +628,6 @@ ns.create_context_menus = function(is_template) {
     };
   }                             // if (is_template) ... else ...
 
-  var events = {
-      show: kivi.requirement_spec.text_block_popup_menu_shown
-    , hide: kivi.requirement_spec.text_block_popup_menu_hidden
-  };
-
   $.contextMenu({
     selector: '.text-block-context-menu',
     events:   {
@@ -584,6 +639,7 @@ ns.create_context_menus = function(is_template) {
       , add:     { name: kivi.t8('Add text block'),        icon: "add",    callback: kivi.requirement_spec.standard_text_block_ajax_call }
       , edit:    { name: kivi.t8('Edit text block'),       icon: "edit",   callback: kivi.requirement_spec.standard_text_block_ajax_call, disabled: kivi.requirement_spec.disable_edit_text_block_commands }
       , delete:  { name: kivi.t8('Delete text block'),     icon: "delete", callback: kivi.requirement_spec.ask_delete_text_block,         disabled: kivi.requirement_spec.disable_edit_text_block_commands }
+      , add_picture: { name: kivi.t8('Add picture to text block'), icon: "add-picture", callback: kivi.requirement_spec.add_edit_text_block_picture_ajax_call, disabled: kivi.requirement_spec.disable_edit_text_block_commands }
       , sep1:    "---------"
       , flag:    { name: kivi.t8('Toggle marker'),         icon: "flag",   callback: kivi.requirement_spec.standard_text_block_ajax_call, disabled: kivi.requirement_spec.disable_edit_text_block_commands }
       , sep2:    "---------"
@@ -592,6 +648,21 @@ ns.create_context_menus = function(is_template) {
     }, general_actions)
   });
 
+  $.contextMenu({
+    selector: '.text-block-picture-context-menu',
+    events:   {
+        show: kivi.requirement_spec.text_block_picture_popup_menu_shown
+      , hide: kivi.requirement_spec.text_block_picture_popup_menu_hidden
+    },
+    items:    $.extend({
+        heading:          { name: kivi.t8('Text block picture actions'), className: 'context-menu-heading'                                                 }
+      , add_picture:      { name: kivi.t8('Add picture'),      icon: "add-picture", callback: kivi.requirement_spec.add_edit_text_block_picture_ajax_call  }
+      , edit_picture:     { name: kivi.t8('Edit picture'),     icon: "edit",        callback: kivi.requirement_spec.add_edit_text_block_picture_ajax_call  }
+      , delete_picture:   { name: kivi.t8('Delete picture'),   icon: "delete",      callback: kivi.requirement_spec.ask_delete_text_block_picture          }
+      , download_picture: { name: kivi.t8('Download picture'), icon: "download",    callback: kivi.requirement_spec.standard_text_block_picture_ajax_call  }
+    }, general_actions)
+  });
+
   $.contextMenu({
     selector: '.basic-settings-context-menu',
     items:    $.extend({
@@ -671,7 +742,7 @@ ns.create_context_menus = function(is_template) {
     items:    general_actions
   });
 
-  events = {
+  var events = {
       show: kivi.requirement_spec.item_popup_menu_shown
     , hide: kivi.requirement_spec.item_popup_menu_hidden
   };
index 0f838eb..eacf655 100755 (executable)
@@ -177,7 +177,8 @@ $self->{texts} = {
   'Add new currency'            => 'Neue Währung hinzufügen',
   'Add new custom variable'     => 'Neue benutzerdefinierte Variable erfassen',
   'Add note'                    => 'Notiz erfassen',
-  'Add printer'                 => 'Drucker hinzufügen',
+  'Add picture'                 => 'Bild hinzufügen',
+  'Add picture to text block'   => 'Bild dem Textblock hinzufügen',
   'Add section'                 => 'Abschnitt hinzufügen',
   'Add sub function block'      => 'Unterfunktionsblock hinzufügen',
   'Add text block'              => 'Textblock erfassen',
@@ -631,6 +632,7 @@ $self->{texts} = {
   'Current Earnings'            => 'Gewinn',
   'Current assets account'      => 'Konto für Umlaufvermögen',
   'Current filter'              => 'Aktueller Filter',
+  'Current picture'             => 'Aktuelles Bild',
   'Current profile'             => 'Aktuelles Profil',
   'Current status'              => 'Aktueller Status',
   'Current value:'              => 'Aktueller Wert:',
@@ -742,6 +744,7 @@ $self->{texts} = {
   'Delete Shipto'               => 'Lieferadresse löschen',
   'Delete drafts'               => 'Entwürfe löschen',
   'Delete links'                => 'Verknüpfungen löschen',
+  'Delete picture'              => 'Bild löschen',
   'Delete profile'              => 'Profil löschen',
   'Delete quotation/order'      => 'Angebot/Auftrag löschen',
   'Delete requirement spec'     => 'Pflichtenheft löschen',
@@ -789,6 +792,7 @@ $self->{texts} = {
   'Detail view'                 => 'Detailanzeige',
   'Details (one letter abbreviation)' => 'D',
   'Difference'                  => 'Differenz',
+  'Dimensions'                  => 'Abmessungen',
   'Directory'                   => 'Verzeichnis',
   'Discard duplicate entries in CSV file' => 'Doppelte Einträge in CSV-Datei verwerfen',
   'Discard entries with duplicates in database or CSV file' => 'Einträge aus CSV-Datei verwerfen, die es bereits in der Datenbank oder der CSV-Datei gibt',
@@ -828,6 +832,7 @@ $self->{texts} = {
   'Done'                        => 'Fertig',
   'Double partnumbers'          => 'Doppelte Artikelnummern',
   'Download SEPA XML export file' => 'SEPA-XML-Exportdatei herunterladen',
+  'Download picture'            => 'Bild herunterladen',
   'Download sample file'        => 'Beispieldatei herunterladen',
   'Draft saved.'                => 'Entwurf gespeichert.',
   'Drawing'                     => 'Zeichnung',
@@ -923,6 +928,7 @@ $self->{texts} = {
   'Edit greetings'              => 'Anreden bearbeiten',
   'Edit note'                   => 'Notiz bearbeiten',
   'Edit payment term'           => 'Zahlungsbedingungen bearbeiten',
+  'Edit picture'                => 'Bild bearbeiten',
   'Edit predefined text'        => 'Vordefinierten Textblock bearbeiten',
   'Edit prices and discount (if not used, textfield is ONLY set readonly)' => 'Preise und Rabatt in Formularen frei anpassen (falls deaktiviert, wird allerdings NUR das textfield auf READONLY gesetzt / kann je nach Browserversion und technischen Fähigkeiten des Anwenders noch umgangen werden)',
   'Edit project'                => 'Projekt bearbeiten',
@@ -937,6 +943,7 @@ $self->{texts} = {
   'Edit templates'              => 'Vorlagen bearbeiten',
   'Edit text block'             => 'Textblock bearbeiten',
   'Edit text block \'#1\''      => 'Textblock \'#1\' bearbeiten',
+  'Edit text block picture #1'  => 'Textblockbild #1 bearbeiten',
   'Edit the Delivery Order'     => 'Lieferschein bearbeiten',
   'Edit the configuration for periodic invoices' => 'Konfiguration für wiederkehrende Rechnungen bearbeiten',
   'Edit the currency names in order to rename them.' => 'Bearbeiten Sie den Namen, um eine Währung umzubennen.',
@@ -1372,6 +1379,7 @@ $self->{texts} = {
   'Long Description'            => 'Langtext',
   'MAILED'                      => 'Gesendet',
   'MD'                          => 'PT',
+  'MIME type'                   => 'MIME-Typ',
   'Machine'                     => 'Maschine',
   'Main Preferences'            => 'Grundeinstellungen',
   'Main sorting'                => 'Hauptsortierung',
@@ -1495,6 +1503,8 @@ $self->{texts} = {
   '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 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',
   'No predefined texts has been created yet.' => 'Es wurden noch keine vordefinierten Textblöcken angelegt.',
   'No prices will be updated because no prices have been entered.' => 'Es werden keine Preise aktualisiert, weil keine gültigen Preisänderungen eingegeben wurden.',
   'No print templates have been created for this client yet. Please do so in the client configuration.' => 'Für diesen Mandanten wurden noch keine Druckvorlagen angelegt. Bitte holen Sie dies in der Mandantenkonfiguration nach.',
@@ -1685,6 +1695,7 @@ $self->{texts} = {
   'Phone1'                      => 'Telefon 1 ',
   'Phone2'                      => 'Telefon 2',
   'Pick List'                   => 'Sammelliste',
+  'Picture #1: #2'              => 'Abbildung #1: #2',
   'Pictures for parts'          => 'Bilder für Waren',
   'Pictures for search parts'   => 'Bilder für Warensuche',
   'Please Check the bank information for each customer:' => 'Bitte überprüfen Sie die Bankinformationen der Kunden:',
@@ -2017,6 +2028,7 @@ $self->{texts} = {
   'Select a vendor'             => 'Einen Lieferanten ausw&auml;hlen',
   'Select all'                  => 'Alle auswählen',
   'Select federal state...'     => 'Bundesland auswählen...',
+  'Select file to upload'       => 'Datei zum Hochladen auswählen',
   'Select from one of the items below' => 'Wählen Sie einen der untenstehenden Einträge',
   'Select from one of the names below' => 'Wählen Sie einen der untenstehenden Namen',
   'Select from one of the projects below' => 'Wählen Sie eines der untenstehenden Projekte',
@@ -2236,6 +2248,7 @@ $self->{texts} = {
   'Test and preview'            => 'Test und Vorschau',
   'Test database connectivity'  => 'Datenbankverbindung testen',
   'Text block actions'          => 'Textblockaktionen',
+  'Text block picture actions'  => 'Aktionen für Textblockbilder',
   'Text blocks back'            => 'Textblöcke hinten',
   'Text blocks front'           => 'Textblöcke vorne',
   'Text field'                  => 'Textfeld',
@@ -2347,6 +2360,7 @@ $self->{texts} = {
   'The existing record has been created from the link target to add.' => 'Der bestehende Beleg wurde aus dem auszuwählenden Verknüpfungsziel erstellt.',
   'The factor is missing in row %d.' => 'Der Faktor fehlt in Zeile %d.',
   'The factor is missing.'      => 'Der Faktor fehlt.',
+  'The file name is missing'    => 'Der Dateiname fehlt',
   'The first reason is that kivitendo contained a bug which resulted in the wrong taxkeys being recorded for transactions in which two entries are posted for the same chart with different taxkeys.' => 'Der erste Grund war ein Fehler in kivitendo, der dazu führte, dass bei einer Transaktion, bei der zwei Buchungen mit unterschiedlichen Steuerschlüsseln auf dasselbe Konto durchgeführt wurden, die falschen Steuerschlüssel gespeichert wurden.',
   'The follow-up date is missing.' => 'Das Wiedervorlagedatum fehlt.',
   'The following currencies have been used, but they are not defined:' => 'Die folgenden Währungen wurden benutzt, sind aber nicht ordnungsgemäß in der Datenbank eingetragen:',
@@ -2634,6 +2648,7 @@ $self->{texts} = {
   'Unknown dependency \'%s\'.'  => 'Unbekannte Abh&auml;ngigkeit \'%s\'.',
   'Unknown problem type.'       => 'Unbekannter Problem-Typ',
   'Unlock System'               => 'System entsperren',
+  'Unsupported image type (supported types: #1)' => 'Nicht unterstützter Bildtyp (unterstützte Typen: #1)',
   'Until'                       => 'Bis',
   'Update'                      => 'Erneuern',
   'Update Prices'               => 'Preise aktualisieren',
@@ -2649,6 +2664,7 @@ $self->{texts} = {
   'Updating existing entry in database' => 'Existierenden Eintrag in Datenbank aktualisieren',
   'Updating prices of existing entry in database' => 'Preis des Eintrags in der Datenbank wird aktualisiert',
   'Updating the client fields in the database "#1" on host "#2:#3" failed.' => 'Die Aktualisierung der Mandantenfelder in der Datenbank "#1" auf Host "#2:#3" schlug fehl.',
+  'Uploaded at'                 => 'Hochgeladen um',
   'Uploaded on #1, size #2 kB'  => 'Am #1 hochgeladen, Größe #2 kB',
   'Use As New'                  => 'Als neu verwenden',
   'Use WebDAV Repository'       => 'WebDAV-Ablage verwenden',
@@ -2690,14 +2706,14 @@ $self->{texts} = {
   'Vendor missing!'             => 'Lieferant fehlt!',
   'Vendor not on file or locked!' => 'Dieser Lieferant existiert nicht oder ist gesperrt.',
   'Vendor not on file!'         => 'Lieferant ist nicht in der Datenbank!',
-  'Vendor saved!'               => 'Lieferant gespeichert!',
   'Vendor saved'                => 'Lieferant gespeichert',
+  'Vendor saved!'               => 'Lieferant gespeichert!',
   'Vendor type'                 => 'Lieferantentyp',
   'Vendors'                     => 'Lieferanten',
   'Verrechnungseinheit'         => 'Verrechnungseinheit',
+  'Version'                     => 'Version',
   'Version actions'             => 'Aktionen für Versionen',
   'Version number'              => 'Versionsnummer',
-  'Version'                     => 'Version',
   'Versions'                    => 'Versionen',
   'View SEPA export'            => 'SEPA-Export-Details ansehen',
   'View background job execution result' => 'Verlauf der Hintergrund-Job-Ausführungen anzeigen',
diff --git a/sql/Pg-upgrade2/requirement_spec_pictures.sql b/sql/Pg-upgrade2/requirement_spec_pictures.sql
new file mode 100644 (file)
index 0000000..7479d44
--- /dev/null
@@ -0,0 +1,35 @@
+-- @tag: requirement_spec_pictures
+-- @description: Pflichtenhefte: Support für Bilder
+-- @depends: requirement_specs
+
+CREATE TABLE requirement_spec_pictures (
+  id                     SERIAL    NOT NULL,
+  requirement_spec_id    INTEGER   NOT NULL,
+  text_block_id          INTEGER   NOT NULL,
+  position               INTEGER   NOT NULL,
+  number                 TEXT      NOT NULL,
+  description            TEXT,
+  picture_file_name      TEXT      NOT NULL,
+  picture_content_type   TEXT      NOT NULL,
+  picture_mtime          TIMESTAMP NOT NULL DEFAULT now(),
+  picture_content        BYTEA     NOT NULL,
+  picture_width          INTEGER   NOT NULL,
+  picture_height         INTEGER   NOT NULL,
+  thumbnail_content_type TEXT      NOT NULL,
+  thumbnail_content      BYTEA     NOT NULL,
+  thumbnail_width        INTEGER   NOT NULL,
+  thumbnail_height       INTEGER   NOT NULL,
+  itime                  TIMESTAMP NOT NULL DEFAULT now(),
+  mtime                  TIMESTAMP,
+
+  PRIMARY KEY (id),
+  FOREIGN KEY (requirement_spec_id) REFERENCES requirement_specs            (id) ON DELETE CASCADE,
+  FOREIGN KEY (text_block_id)       REFERENCES requirement_spec_text_blocks (id) ON DELETE CASCADE
+);
+
+CREATE TRIGGER mtime_requirement_spec_pictures BEFORE UPDATE ON requirement_spec_pictures FOR EACH ROW EXECUTE PROCEDURE set_mtime();
+
+ALTER TABLE requirement_specs ADD COLUMN previous_picture_number INTEGER;
+UPDATE requirement_specs SET previous_picture_number = 0;
+ALTER TABLE requirement_specs ALTER COLUMN previous_picture_number SET NOT NULL;
+ALTER TABLE requirement_specs ALTER COLUMN previous_picture_number SET DEFAULT 0;
index b36ad08..95e0ff6 100644 (file)
@@ -85,6 +85,17 @@ $( USE P )$
 \end{longtable}
 %$( END )$
 
+%$( BLOCK picture_outputter )$
+%  $( SET width_cm = (picture.picture_width / 150.0) * 2.54 )$
+%  $( SET width_cm = width_cm < 16.4 ? width_cm : 16.4 )$
+\begin{figure}[h!]
+  \centering
+  \includegraphics[width=$( width_cm )$cm,keepaspectratio]{$( picture.print_file_name )$}
+
+\mbox{Abbildung $( picture.number )$: $( LxLatex.filter(picture.description ? picture.description : picture.picture_file_name) )$}
+\end{figure}
+%$( END )$
+
 %$( BLOCK text_block_outputter )$
 %  $( SET text_blocks = rspec.text_blocks_sorted(output_position=output_position) )$
 %  $( IF text_blocks.size )$
@@ -99,6 +110,10 @@ $( USE P )$
 
 $( LxLatex.filter(text_block.text) )$
 
+%      $( FOREACH picture = text_block.pictures_sorted.as_list )$
+$( PROCESS picture_outputter picture=picture )$
+%      $( END )$
+
 %    $( END )$
 %  $( END )$
 %$( END )$
diff --git a/templates/webpages/requirement_spec_text_block/_picture_form.html b/templates/webpages/requirement_spec_text_block/_picture_form.html
new file mode 100644 (file)
index 0000000..95505f7
--- /dev/null
@@ -0,0 +1,71 @@
+[%- USE LxERP -%][%- USE L -%][%- USE HTML -%][%- USE JavaScript -%][% USE Base64 %][% SET style="width: 500px" %]
+[% SET id_base = 'edit_text_block_picture_' _ (SELF.picture.id ? SELF.picture.id : 'new') %]
+<form method="post" id="[% id_base %]_form" method="POST" enctype="multipart/form-data">
+ [% L.hidden_tag('form_prefix',                    id_base,         id=id_base _ '_form_prefix') %]
+ [% L.hidden_tag('id',                             SELF.picture.id, no_id=1) %]
+ [% L.hidden_tag(id_base _ '.text_block_id',       SELF.text_block.id) %]
+ [% L.hidden_tag(id_base _ '.requirement_spec_id', SELF.text_block.requirement_spec_id) %]
+
+ <h2>
+  [%- IF SELF.picture.id %]
+   [%- LxERP.t8("Edit text block picture #1", SELF.picture.number) %]
+  [%- ELSE %]
+   [%- LxERP.t8("Add picture to text block") %]
+  [%- END %]
+ </h2>
+
+ <table>
+[% IF SELF.picture.number %]
+  <tr>
+   <th align="right">[%- LxERP.t8("Number") %]:</th>
+   <td>[% HTML.escape(SELF.picture.number) %]</td>
+  </tr>
+[% END %]
+
+  <tr>
+   <th align="right">[%- LxERP.t8("Description") %]:</th>
+   <td>[% L.input_tag(id_base _ '.description', SELF.picture.description, style=style) %]</td>
+  </tr>
+
+[% IF SELF.picture.picture_content %]
+  <tr>
+   <th align="right">[%- LxERP.t8("File name") %]:</th>
+   <td>[% HTML.escape(SELF.picture.picture_file_name) %]</td>
+  </tr>
+
+  <tr>
+   <th align="right">[%- LxERP.t8("MIME type") %]:</th>
+   <td>[% HTML.escape(SELF.picture.picture_content_type) %]</td>
+  </tr>
+
+  <tr>
+   <th align="right">[%- LxERP.t8("Dimensions") %]:</th>
+   <td>[% HTML.escape(SELF.picture.picture_width) %]x[% HTML.escape(SELF.picture.picture_height) %]</td>
+  </tr>
+
+  <tr>
+   <th align="right">[%- LxERP.t8("Uploaded at") %]:</th>
+   <td>[% HTML.escape(SELF.picture.picture_mtime.to_kivitendo(precision='second')) %]</td>
+  </tr>
+[% END %]
+
+  <tr>
+   <th align="right">[%- LxERP.t8("Select file to upload") %]:</th>
+   <td>[% L.input_tag(id_base _ '.picture_content', '', type='file') %]</td>
+  </tr>
+ </table>
+
+ <p>
+  [%- L.ajax_submit_tag('controller.pl?action=RequirementSpecTextBlock/ajax_' _ (SELF.picture.id ? 'update' : 'create') _ '_picture', '#' _ id_base _ '_form', LxERP.t8('Save'), no_id=1) %]
+  <a href="#" onclick="$('#jqueryui_popup_dialog').dialog('close');">[%- LxERP.t8("Cancel") %]</a>
+ </p>
+
+</form>
+
+[% IF SELF.picture.id %]
+<h2>[% LxERP.t8("Current picture") %]</h2>
+
+<div>
+ <img src="data:[% HTML.escape(SELF.picture.picture_content_type) %];base64,[% SELF.picture.picture_content.encode_base64 %]">
+</div>
+[% END %]
index 6d456bf..8102c57 100644 (file)
    [% L.simple_format(text_block.text) %]
   </div>
  [% END %]
+
+ <div class="clearfix" id="text-block-[% HTML.escape(text_block.id) %]-pictures"[% UNLESS text_block.pictures.as_list.size %] style="display: none"[% END %]>
+  [% FOREACH picture = text_block.pictures_sorted %]
+   [% INCLUDE 'requirement_spec_text_block/_text_block_picture.html' picture=picture %]
+  [% END %]
+ </div>
 </div>
diff --git a/templates/webpages/requirement_spec_text_block/_text_block_picture.html b/templates/webpages/requirement_spec_text_block/_text_block_picture.html
new file mode 100644 (file)
index 0000000..26bc05e
--- /dev/null
@@ -0,0 +1,10 @@
+[%- USE HTML -%][%- USE LxERP -%][% USE Base64 %]
+[% SET title=LxERP.t8('Picture #1: #2', picture.number, picture.description ? picture.description _ ' (' _ picture.picture_file_name _ ')' : picture.picture_file_name) %]
+<div id="text-block-picture-[% HTML.escape(picture.id) %]" class="requirement-spec-text-block-picture-thumbnail text-block-picture-context-menu">
+ <div class="requirement-spec-text-block-picture-thumbnail-img-container">
+  <img src="data:[% HTML.escape(picture.thumbnail_content_type) %];base64,[% picture.thumbnail_content.encode_base64 %]" alt="[% title %]">
+ </div>
+ <div>
+  [% LxERP.t8('Picture #1: #2', picture.number, picture.description ? picture.description : picture.picture_file_name) %]
+ </div>
+</div>