diff --git a/app/assets/stylesheets/styles.scss b/app/assets/stylesheets/styles.scss index 9305262aa..e2c9b4a45 100644 --- a/app/assets/stylesheets/styles.scss +++ b/app/assets/stylesheets/styles.scss @@ -1,5 +1,6 @@ $skills-blue: #3b7bbe; $skills-text-gray: #8e8e8e; +$skills-gray: #f5f5f5; @import "bootstrap"; @@ -42,4 +43,24 @@ pzsh-topbar { .text-gray { color: $skills-text-gray; +} + +.person-profile-header { + background: $skills-gray; + height: 3.25rem; + padding: 12px 20px 12px 20px; + font-size: 18px; +} + +.bg-skills-blue { + background-color: $skills-blue; +} + +.bg-skills-blue:hover { + background-color: #3268a1; +} + +.fixed-table { + table-layout: fixed; + width: 100%; } \ No newline at end of file diff --git a/app/controllers/concerns/param_converters.rb b/app/controllers/concerns/param_converters.rb new file mode 100644 index 000000000..8325fcbb3 --- /dev/null +++ b/app/controllers/concerns/param_converters.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module ParamConverters + + private + + def true?(value) + %w[1 yes true].include?(value.to_s.downcase) + end + + def false?(value) + %w[0 no false].include?(value.to_s.downcase) + end + + def nil_param?(value) + value == 'null' ? nil : value + end +end diff --git a/app/controllers/crud_controller.rb b/app/controllers/crud_controller.rb index 6ce7f425a..e2bdcd269 100644 --- a/app/controllers/crud_controller.rb +++ b/app/controllers/crud_controller.rb @@ -244,6 +244,15 @@ def error_messages # rubocop:enable Rails/OutputSafety end + def get_asset_path(filename) + manifest_file = Rails.application.assets_manifest.assets[filename] + if manifest_file + File.join(Rails.application.assets_manifest.directory, manifest_file) + else + Rails.application.assets&.[](filename)&.filename + end + end + # Class methods for CrudActions. class << self # Convenience callback to apply a callback on both form actions diff --git a/app/controllers/people/picture_controller.rb b/app/controllers/people/picture_controller.rb new file mode 100644 index 000000000..d9d5521bb --- /dev/null +++ b/app/controllers/people/picture_controller.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module People + class PictureController < CrudController + + self.permitted_attrs = %i[picture] + + def update + person.update!(picture: params[:picture]) + render json: { data: { picture_path: picture_person_path(params[:id]) } } + end + + def show + picture_url = person.picture.file.nil? ? default_avatar_path : person.picture.url + send_file(picture_url, disposition: 'inline') + end + + private + + def model_class + Person + end + + def person + @person ||= Person.find(params[:id]) + end + + def default_avatar_path + get_asset_path 'default_avatar.png' + end + end +end diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb index 4e4430c0d..5e6a6d8c9 100644 --- a/app/controllers/people_controller.rb +++ b/app/controllers/people_controller.rb @@ -2,16 +2,20 @@ class PeopleController < CrudController include ExportController + include ParamConverters - # self.permitted_attrs = %i[birthdate location - # marital_status updated_by name nationality nationality2 title - # competence_notes company_id email department_id shortname] + self.permitted_attrs = [:birthdate, :location, :marital_status, :updated_by, :name, :nationality, + :nationality2, :title, :competence_notes, :company_id, :email, + :department_id, :shortname, :picture, :picture_cache, + { person_roles_attributes: [:role_id, :person_role_level_id, + :percent, :id, :_destroy] }] - # self.nested_models = %i[advanced_trainings activities projects - # educations language_skills person_roles - # people_skills categories] - - # self.permitted_relationships = %i[person_roles people_skills] + def update + if false?(params['has_nationality2']['checked']) + params['person']['nationality2'] = nil + end + super + end def show if format_odt? diff --git a/app/helpers/person_helper.rb b/app/helpers/person_helper.rb new file mode 100644 index 000000000..33613661c --- /dev/null +++ b/app/helpers/person_helper.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module PersonHelper + def nationality_string(nationality, nationality2) + if nationality2.blank? + ISO3166::Country[nationality].translations[I18n.locale] + else + "#{ISO3166::Country[nationality].translations[I18n.locale]}, + #{ISO3166::Country[nationality2].translations[I18n.locale]}" + end + end + + def person_role_string(person_role) + "#{person_role.role.name} #{person_role.person_role_level&.level} + #{person_role.percent.nil? ? '' : "#{person_role.percent.to_i}%"}" + end + + def marital_status_translation_map + Person.marital_statuses.keys.map do |marital_status| + [marital_status, t("marital_statuses.#{marital_status}")] + end + end + + def country_alpha2_translation_map + ISO3166::Country.all.sort.map do |country| + [country.alpha2, country.translations[I18n.locale]] + end + end + + # If the path of the avatar includes tmp, the picture is cached + # and we can load it directly without the picture controller + def avatar_cached?(picture) + picture&.file&.file&.include? 'tmp' + end +end diff --git a/app/helpers/role_form_helper.rb b/app/helpers/role_form_helper.rb new file mode 100644 index 000000000..1b06d48b2 --- /dev/null +++ b/app/helpers/role_form_helper.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module RoleFormHelper + # This method creates a link with `data-id` `data-fields` attributes. These attributes are used + # to create new instances of the nested fields through Javascript. + def link_to_add_role(name, form, association) + # Takes an object and creates a new instance of its associated model + new_role = form.object.send(association).klass.new + # Saves the unique ID of the object into a variable. + # This is needed to ensure the key of the associated array is unique. This makes parsing the + # content in the `data-fields` attribute easier through Javascript. + id = new_role.object_id + # child_index` is used to ensure the key of the associated array is unique, and that it matched + # the value in the `data-id` attribute. + fields = form.fields_for(association, new_role, child_index: id) do |builder| + render("#{association.to_s.singularize}_fields", person_role: builder) + end + + + # This renders a simple link, but passes information into `data` attributes. + # This info can be named anything we want, but in this case + # we chose `data-id:` and `data-fields:`. + # We use `gsub("\n", "")` to remove anywhite space from the rendered partial. + # The `id:` value needs to match the value used in `child_index: id`. + link_to(name, '#', + { class: 'add_fields', 'data-action' => 'person-roles#addField', + data: { id: id, fields: fields.gsub("\n", '') } }) + end +end diff --git a/app/javascript/controllers/image_upload_controller.js b/app/javascript/controllers/image_upload_controller.js new file mode 100644 index 000000000..4a29899dd --- /dev/null +++ b/app/javascript/controllers/image_upload_controller.js @@ -0,0 +1,9 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + changeImage() { + const avatarUploader = document.getElementById("avatar-uploader"); + const avatar = document.getElementById("avatar"); + avatar.src = URL.createObjectURL(avatarUploader.files[0]) + } +} \ No newline at end of file diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index d2fdc8dca..2046c521f 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -4,8 +4,17 @@ import { application } from "./application" -import DropdownLinksController from "./dropdown_controller" -application.register("dropdown", DropdownLinksController) +import DropdownController from "./dropdown_controller" +application.register("dropdown", DropdownController) + +import ImageUploadController from "./image_upload_controller" +application.register("image-upload", ImageUploadController) + +import NationalityTwoController from "./nationality_two_controller" +application.register("nationality-two", NationalityTwoController) + +import PersonRolesController from "./person_roles_controller" +application.register("person-roles", PersonRolesController) import RemoteModalController from "./remote_modal_controller" application.register("remote-modal", RemoteModalController) diff --git a/app/javascript/controllers/nationality_two_controller.js b/app/javascript/controllers/nationality_two_controller.js new file mode 100644 index 000000000..1626e5d70 --- /dev/null +++ b/app/javascript/controllers/nationality_two_controller.js @@ -0,0 +1,18 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + setVisibility() { + const natTwoElements = document.getElementsByClassName('nationality-two') + const natTwoCheckbox = document.getElementById('nat-two-checkbox') + for(const element of natTwoElements) { + !natTwoCheckbox.checked ? element.classList.add('d-none') : element.classList.remove('d-none') + } + } + + connect() { + this.setVisibility(); + } + nationalityTwoVisible() { + this.setVisibility() + } +} \ No newline at end of file diff --git a/app/javascript/controllers/person_roles_controller.js b/app/javascript/controllers/person_roles_controller.js new file mode 100644 index 000000000..15a7f57cf --- /dev/null +++ b/app/javascript/controllers/person_roles_controller.js @@ -0,0 +1,47 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + connect() { + for(let nested_field of document.getElementsByClassName("nested-fields")) { + if(nested_field.querySelector('input[type="hidden"]').value === "true") { + nested_field.style.display = "none"; + } + } + } + addField(e) { + let link = e.target + // Stop the function from executing if a link or event were not passed into the function. + if (!link || !e) return; + // Prevent the browser from following the URL. + e.preventDefault(); + // Save a unique timestamp to ensure the key of the associated array is unique. + let time = new Date().getTime(); + // Save the data id attribute into a variable. This corresponds to `new_object.object_id`. + let linkId = link.dataset.id; + // Create a new regular expression needed to find any instance of the `new_object.object_id` used in the fields data attribute if there's a value in `linkId`. + let regexp = linkId ? new RegExp(linkId, "g") : null; + // Replace all instances of the `new_object.object_id` with `time`, and save markup into a variable if there's a value in `regexp`. + let newFields = regexp ? link.dataset.fields.replace(regexp, time) : null; + // Add the new markup to the form if there are fields to add. + newFields ? link.insertAdjacentHTML("beforebegin", newFields) : null; + } + + removeField(e) { + let link = e.target + // Stop the function from executing if a link or event were not passed into the function. + if (!link || !e) return; + // Prevent the browser from following the URL. + e.preventDefault(); + // Find the parent wrapper for the set of nested fields. + let fieldParent = link.closest(".nested-fields"); + // If there is a parent wrapper, find the hidden delete field. + let deleteField = fieldParent + ? fieldParent.querySelector('input[type="hidden"]') + : null; + // If there is a delete field, update the value to `1` and hide the corresponding nested fields. + if (deleteField) { + deleteField.value = 1; + fieldParent.style.display = "none"; + } + } +} \ No newline at end of file diff --git a/app/models/person.rb b/app/models/person.rb index 85d09d036..a99e11bb4 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -39,6 +39,7 @@ class Person < ApplicationRecord has_many :expertise_topics, through: :expertise_topic_skill_values has_many :language_skills, dependent: :delete_all has_many :person_roles, dependent: :destroy + accepts_nested_attributes_for :person_roles, allow_destroy: true has_many :people_skills, dependent: :destroy has_many :skills, through: :people_skills has_many :roles, through: :person_roles diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index dde0ec35e..606a8267b 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -10,7 +10,7 @@ = javascript_include_tag "application", "data-turbo-track": "reload", type: "module" = favicon_link_tag "favicon.png" %body.d-flex.justify-content-center - %pzsh-container.content + %div.content %div.position-sticky.top-0.z-3 %div.d-flex.justify-content-between.bg-white %div.d-flex diff --git a/app/views/people/_form.html.haml b/app/views/people/_form.html.haml new file mode 100644 index 000000000..e3ede064e --- /dev/null +++ b/app/views/people/_form.html.haml @@ -0,0 +1,70 @@ += form_with model: @person do |form| + %div.d-flex.flex-xl-row.flex-column + %div.col-xl-3.col-12 + - if avatar_cached?(@person.picture) + %img.rounded-circle#avatar{src: "#{@person.picture}?#{Time.now.to_f}", width: '141', height: '141'} + - else + %img.rounded-circle#avatar{src: "#{@person.id}/picture?#{Time.now.to_f}", width: '141', height: '141'} + %br + %label.btn.btn-link{for: "avatar-uploader"} Bild ändern + %div.visually-hidden{"data-controller"=>"image-upload"}= form.file_field :picture, { accept: "image/", "data-action" => "image-upload#changeImage", id: "avatar-uploader" } + = form.hidden_field :picture_cache + %div.pe-5.col-xl-3.col-12 + %table + %tbody + %th.fw-normal Name + %tr + %td= form.text_field :name, class: "mw-100 form-control" + %th.fw-normal Email + %tr + %td= form.text_field :email, class: "mw-100, form-control" + %th.fw-normal Abschluss + %tr + %td= form.text_field :title, class: "mw-100, form-control" + %th.fw-normal Funktionen + %div + = form.fields_for :person_roles do |person_role| + %tr + %td= render "person_role_fields", person_role: person_role + %tr + %td{"data-controller"=>"person-roles"} + = link_to_add_role "Neue Funktion", form, :person_roles + %th.fw-normal Organisationseinheit + %tr + %td= form.collection_select :department_id, Department.order(:name), :id, :name, {}, class: "form-select mw-100" + %th.fw-normal Firma + %tr + %td= form.collection_select :company_id, Company.order(:name), :id, :name, {}, class: "form-select mw-100" + %th.fw-normal Wohnort (Stadt) + %tr + %td= form.text_field :location, class: "form-control mw-100" + + %div.pe-5.col-xl-3.col-12 + %table + %tbody + %th.fw-normal Geburtsdatum + %tr + %td= form.date_field :birthdate, class: "form-control mw-100" + %th.fw-normal Doppelbürger + %tr + %td{"data-controller"=>"nationality-two"}= check_box :has_nationality2, "checked", {"checked" => @person.nationality2?, "data-action" => "nationality-two#nationalityTwoVisible", "id" => "nat-two-checkbox"} + %th.fw-normal Erste Nationalität + %tr + %td= form.collection_select :nationality, country_alpha2_translation_map, :first, :last, {}, class: "form-select mw-100" + %th.fw-normal.nationality-two Zweite Nationalität + %tr.nationality-two + %td= form.collection_select :nationality2, country_alpha2_translation_map, :first, :last, {}, class: "form-select mw-100" + %th.fw-normal Zivilstand + %tr + %td= form.collection_select :marital_status, marital_status_translation_map, :first, :last, { selected: @person.marital_status }, class: "form-select mw-100" + %th.fw-normal Kürzel + %tr + %td= form.text_field :shortname, class: "mw-100 form-control" + %div.col-xl-3.col-12.mw-100 + %div.fw-normal Sprachen + %div.border.border-dark-subtle.mt-1.p-2.rounded + - @person.language_skills.each do |language| + %div.mb-1= "#{language.language}: #{language.level} - #{language.certificate}" + %div.mt-3 + = form.submit :Speichern, { class: "btn btn-primary me-3 bg-skills-blue", id: "save-button" } + = link_to "Abbrechen", person_path, { id: "cancel-button" } \ No newline at end of file diff --git a/app/views/people/_person_role_fields.html.haml b/app/views/people/_person_role_fields.html.haml new file mode 100644 index 000000000..9b4b24cca --- /dev/null +++ b/app/views/people/_person_role_fields.html.haml @@ -0,0 +1,10 @@ +%div.border.border-dark-subtle.rounded.p-1.fw-light.nested-fields + = person_role.hidden_field :_destroy + Rolle + = person_role.collection_select :role_id, Role.order(:name), :id, :name, {}, class: "form-select w-100 role-select" + %div + Stufe + %div.d-flex.fw-light + = person_role.collection_select :person_role_level_id, PersonRoleLevel.order(:level), :id, :level, {}, class: "form-select w-50 me-1 role-level-select" + = person_role.number_field :percent, in: 0..200, step: 1, class: "form-control w-50 person-role-percent" + %div{"data-controller"=>"person-roles"}= link_to "Remove", "#", { class: "remove_fields", 'data-action' => 'person-roles#removeField' } \ No newline at end of file diff --git a/app/views/people/edit.html.haml b/app/views/people/edit.html.haml new file mode 100644 index 000000000..b8c5f218c --- /dev/null +++ b/app/views/people/edit.html.haml @@ -0,0 +1,2 @@ +%turbo-frame{id: "#{dom_id @person}"} + = render "form", person: @person \ No newline at end of file diff --git a/app/views/people/edit.turbo_stream.erb b/app/views/people/edit.turbo_stream.erb new file mode 100644 index 000000000..64cee993c --- /dev/null +++ b/app/views/people/edit.turbo_stream.erb @@ -0,0 +1,12 @@ +<%= turbo_stream.update "#{dom_id @person}" do %> + <%= render "form", person: @person %> + <% if @person.errors.any? %> +