commit b50ab2508347869551a6329a73a7de70c8dfd062 Author: ioresponse Date: Tue Dec 23 00:21:49 2025 +0900 Initial commit: Redmine Organization Chart Plugin πŸ€– Generated with Claude Code diff --git a/app/controllers/departments_controller.rb b/app/controllers/departments_controller.rb new file mode 100644 index 0000000..bdebf1b --- /dev/null +++ b/app/controllers/departments_controller.rb @@ -0,0 +1,269 @@ +class DepartmentsController < ApplicationController + layout 'admin' + before_action :require_admin + before_action :find_department, only: [:show, :edit, :update, :destroy, :add_member, :remove_member, :set_leader, :set_acting_leader, :clear_leader] + + def index + @departments = Department.roots.sorted.includes(:children, :leader, :acting_leader, :users) + end + + def show + @members = @department.department_members.includes(:user).order(:position) + end + + def new + @department = Department.new + @department.parent_id = params[:parent_id] if params[:parent_id] + @department.department_type = params[:type] || 'team' + load_parent_options + end + + def create + @department = Department.new(department_params) + if @department.save + flash[:notice] = l(:notice_successful_create) + redirect_to '/org_chart' + else + load_parent_options + render :new + end + end + + def edit + load_parent_options + end + + def update + if @department.update(department_params) + flash[:notice] = l(:notice_successful_update) + redirect_to '/org_chart' + else + load_parent_options + render :edit + end + end + + def destroy + @department.destroy + flash[:notice] = l(:notice_successful_delete) + redirect_to '/org_chart' + end + + def add_member + user = find_or_create_user_from_ldap(params[:user_id], params[:ldap_uid], params[:ldap_id]) + + if user + member = @department.department_members.find_or_initialize_by(user: user) + member.role = 'member' + if member.save + flash[:notice] = "#{user.name} added to #{@department.name}" + else + flash[:error] = member.errors.full_messages.join(', ') + end + else + flash[:error] = 'User not found' + end + redirect_to department_path(@department) + end + + def remove_member + member = @department.department_members.find_by(user_id: params[:user_id]) + if member + @department.update(leader_id: nil) if @department.leader_id == member.user_id + @department.update(acting_leader_id: nil) if @department.acting_leader_id == member.user_id + member.destroy + flash[:notice] = 'Member removed' + end + redirect_to department_path(@department) + end + + def set_leader + user = User.find(params[:user_id]) + @department.department_members.find_or_create_by(user: user) + @department.update(leader_id: user.id, acting_leader_id: nil) + flash[:notice] = "#{user.name} is now the leader" + redirect_to department_path(@department) + end + + def set_acting_leader + user = User.find(params[:user_id]) + @department.department_members.find_or_create_by(user: user) + @department.update(acting_leader_id: user.id) + flash[:notice] = "#{user.name} is now the acting leader" + redirect_to department_path(@department) + end + + def clear_leader + @department.update(leader_id: nil, acting_leader_id: nil) + flash[:notice] = 'Leader cleared' + redirect_to department_path(@department) + end + + def org_chart + @ceo = Department.ceo.first + @departments = Department.roots.sorted.includes(:children, :leader, :acting_leader, :users) + render layout: 'base' + end + + def search_ldap + query = params[:q].to_s.strip + if query.length < 2 + render json: [] + return + end + + results = [] + + AuthSourceLdap.all.each do |ldap| + begin + ldap_users = search_ldap_users(ldap, query) + ldap_users.each do |entry| + uid = entry[ldap.attr_login]&.first + next unless uid + + existing_user = User.find_by(login: uid) + + results << { + uid: uid, + name: "#{entry[ldap.attr_firstname]&.first} #{entry[ldap.attr_lastname]&.first}".strip, + email: entry[ldap.attr_mail]&.first, + ldap_id: ldap.id, + exists: existing_user.present?, + user_id: existing_user&.id + } + end + rescue => e + Rails.logger.error "LDAP search error: #{e.message}" + end + end + + render json: results.uniq { |r| r[:uid] }.first(20) + end + + private + + def find_department + @department = Department.find(params[:id]) + rescue ActiveRecord::RecordNotFound + render_404 + end + + def department_params + params.require(:department).permit(:name, :parent_id, :position, :description, :department_type, :leader_id, :acting_leader_id) + end + + def load_parent_options + if @department.new_record? + @parents = @department.available_parents.sorted + else + @parents = @department.available_parents.where.not(id: @department.self_and_descendants.map(&:id)).sorted + end + end + + def search_ldap_users(ldap, query) + options = { + host: ldap.host, + port: ldap.port, + auth: { + method: :simple, + username: ldap.account, + password: ldap.account_password + } + } + + if ldap.tls + if ldap.verify_peer + options[:encryption] = { method: :simple_tls } + else + options[:encryption] = { method: :simple_tls, tls_options: { verify_mode: OpenSSL::SSL::VERIFY_NONE } } + end + end + + conn = Net::LDAP.new(options) + + filter = Net::LDAP::Filter.begins(ldap.attr_login, query) | + Net::LDAP::Filter.begins(ldap.attr_firstname, query) | + Net::LDAP::Filter.begins(ldap.attr_lastname, query) + + if ldap.filter.present? + base_filter = Net::LDAP::Filter.construct(ldap.filter) + filter = base_filter & filter + end + + conn.search( + base: ldap.base_dn, + filter: filter, + attributes: [ldap.attr_login, ldap.attr_firstname, ldap.attr_lastname, ldap.attr_mail], + size: 20 + ) || [] + end + + def find_or_create_user_from_ldap(user_id, ldap_uid, ldap_id) + return User.find(user_id) if user_id.present? + + return nil if ldap_uid.blank? + + user = User.find_by(login: ldap_uid) + return user if user + + ldap = AuthSourceLdap.find_by(id: ldap_id) + return nil unless ldap + + user_info = get_ldap_user_info(ldap, ldap_uid) + return nil unless user_info + + user = User.new( + login: ldap_uid, + firstname: user_info[:firstname] || ldap_uid, + lastname: user_info[:lastname] || '-', + mail: user_info[:mail] || "#{ldap_uid}@example.com", + auth_source_id: ldap.id, + status: User::STATUS_ACTIVE + ) + user.random_password + user.save ? user : nil + end + + def get_ldap_user_info(ldap, uid) + options = { + host: ldap.host, + port: ldap.port, + auth: { + method: :simple, + username: ldap.account, + password: ldap.account_password + } + } + + if ldap.tls + if ldap.verify_peer + options[:encryption] = { method: :simple_tls } + else + options[:encryption] = { method: :simple_tls, tls_options: { verify_mode: OpenSSL::SSL::VERIFY_NONE } } + end + end + + conn = Net::LDAP.new(options) + + filter = Net::LDAP::Filter.eq(ldap.attr_login, uid) + if ldap.filter.present? + base_filter = Net::LDAP::Filter.construct(ldap.filter) + filter = base_filter & filter + end + + result = conn.search( + base: ldap.base_dn, + filter: filter, + attributes: [ldap.attr_login, ldap.attr_firstname, ldap.attr_lastname, ldap.attr_mail], + size: 1 + )&.first + + return nil unless result + + { + firstname: result[ldap.attr_firstname]&.first, + lastname: result[ldap.attr_lastname]&.first, + mail: result[ldap.attr_mail]&.first + } + end +end diff --git a/app/helpers/departments_helper.rb b/app/helpers/departments_helper.rb new file mode 100644 index 0000000..c426ea3 --- /dev/null +++ b/app/helpers/departments_helper.rb @@ -0,0 +1,106 @@ +module DepartmentsHelper + def render_department_rows(departments, level) + html = ''.html_safe + departments.each do |dept| + html << render_department_row(dept, level) + html << render_department_rows(dept.children.sorted, level + 1) if dept.children.any? + end + html + end + + def render_department_row(dept, level) + indent = ('    ' * level).html_safe + prefix = level > 0 ? 'β”” '.html_safe : ''.html_safe + leader = dept.effective_leader + member_count = dept.all_members.count + + content_tag(:tr) do + content_tag(:td) do + indent + prefix + link_to(dept.name, department_path(dept), class: 'icon icon-group') + end + + content_tag(:td) do + type_badge(dept.department_type) + end + + content_tag(:td) do + if leader + name = link_to(leader.name, user_path(leader)) + badge = dept.has_acting_leader? ? content_tag(:span, 'λŒ€κ²°', style: 'background: #ffc107; color: #333; padding: 1px 5px; border-radius: 3px; font-size: 10px; margin-left: 3px;') : ''.html_safe + name + badge + else + '-'.html_safe + end + end + + content_tag(:td, member_count) + + content_tag(:td) do + link_to('관리', department_path(dept), class: 'icon icon-edit') + ' '.html_safe + + link_to('μˆ˜μ •', edit_department_path(dept), class: 'icon icon-edit') + ' '.html_safe + + link_to('μ‚­μ œ', "/org_chart/#{dept.id}", method: :delete, + data: { confirm: '정말 μ‚­μ œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?' }, class: 'icon icon-del') + end + end + end + + def type_badge(type) + colors = { + 'ceo' => '#dc3545', + 'executive' => '#6f42c1', + 'division' => '#007bff', + 'team' => '#28a745' + } + labels = { + 'ceo' => 'CEO', + 'executive' => 'C-Level', + 'division' => 'λ³ΈλΆ€', + 'team' => 'νŒ€' + } + color = colors[type] || '#6c757d' + label = labels[type] || type + content_tag(:span, label, style: "background: #{color}; color: white; padding: 2px 8px; border-radius: 3px; font-size: 11px;") + end + + # 본뢀와 ν•˜μœ„ νŒ€μ„ λ Œλ”λ§ (νŒ€μ€ μ„Έλ‘œ λ°°μ—΄) + def render_org_branch(division) + teams = Department.teams.where(parent_id: division.id).sorted + + content_tag(:div, class: 'org-node') do + card = content_tag(:div, class: 'org-card org-division', onclick: "location.href='#{department_path(division)}'") do + html = content_tag(:div, 'λ³ΈλΆ€', class: 'org-type') + html += content_tag(:h4, division.name) + if division.effective_leader + leader_class = division.has_acting_leader? ? 'org-leader acting' : 'org-leader' + leader_text = division.has_acting_leader? ? "λŒ€κ²°: #{division.effective_leader.name}" : division.effective_leader.name + html += content_tag(:div, leader_text, class: leader_class) + else + html += content_tag(:div, '(λ³ΈλΆ€μž₯ λ―Έμ§€μ •)', class: 'org-leader no-leader') + end + html += content_tag(:div, "#{division.all_members.count}λͺ…", class: 'org-members') + html + end + + if teams.any? + children = content_tag(:div, class: 'org-branch-children-vertical') do + teams.map do |team| + content_tag(:div, class: 'org-node-vertical') do + content_tag(:div, class: 'org-card org-team', onclick: "location.href='#{department_path(team)}'") do + t_html = content_tag(:div, 'νŒ€', class: 'org-type') + t_html += content_tag(:h4, team.name) + if team.effective_leader + leader_class = team.has_acting_leader? ? 'org-leader acting' : 'org-leader' + leader_text = team.has_acting_leader? ? "λŒ€κ²°: #{team.effective_leader.name}" : team.effective_leader.name + t_html += content_tag(:div, leader_text, class: leader_class) + else + t_html += content_tag(:div, '(νŒ€μž₯ λ―Έμ§€μ •)', class: 'org-leader no-leader') + end + t_html += content_tag(:div, "#{team.all_members.count}λͺ…", class: 'org-members') + t_html + end + end + end.join.html_safe + end + card + children + else + card + end + end + end +end diff --git a/app/views/departments/_form.html.erb b/app/views/departments/_form.html.erb new file mode 100644 index 0000000..e2c8f28 --- /dev/null +++ b/app/views/departments/_form.html.erb @@ -0,0 +1,44 @@ +
+ λΆ€μ„œ 정보 + +

+ <%= f.label :department_type, 'λΆ€μ„œ μœ ν˜•' %> * + <%= f.select :department_type, Department::TYPES.map { |k, v| [v, k] }, {}, required: true, id: 'department_type_select' %> +

+ +

+ <%= f.label :name, 'λΆ€μ„œλͺ…' %> * + <%= f.text_field :name, size: 40, required: true %> +

+ +

+ <%= f.label :parent_id, 'μƒμœ„ λΆ€μ„œ' %> + <%= f.select :parent_id, + options_from_collection_for_select(@parents, :id, :name, @department.parent_id), + { include_blank: '-- μ΅œμƒμœ„ (λŒ€ν‘œμ΄μ‚¬ 직속) --' } %> +

+ +

+ <%= f.label :description, 'μ„€λͺ…' %> + <%= f.text_area :description, rows: 3, cols: 60 %> +

+
+ +<%= javascript_tag do %> +$(document).ready(function() { + function updateParentOptions() { + var type = $('#department_type_select').val(); + var parentField = $('#parent_field'); + + if (type === 'ceo') { + parentField.hide(); + $('#department_parent_id').val(''); + } else { + parentField.show(); + } + } + + $('#department_type_select').on('change', updateParentOptions); + updateParentOptions(); +}); +<% end %> diff --git a/app/views/departments/index.html.erb b/app/views/departments/index.html.erb new file mode 100644 index 0000000..28023f2 --- /dev/null +++ b/app/views/departments/index.html.erb @@ -0,0 +1,29 @@ +

쑰직도 관리

+ +

+ <%= link_to 'CEO 등둝', new_department_path(type: 'ceo'), class: 'icon icon-add' %> + <%= link_to 'C-Level μΆ”κ°€', new_department_path(type: 'executive'), class: 'icon icon-add' %> + <%= link_to 'λ³ΈλΆ€ μΆ”κ°€', new_department_path(type: 'division'), class: 'icon icon-add' %> + <%= link_to 'νŒ€ μΆ”κ°€', new_department_path(type: 'team'), class: 'icon icon-add' %> + | + <%= link_to '쑰직도 보기', org_chart_view_path, class: 'icon icon-stats', target: '_blank' %> +

+ +<% if @departments.any? %> + + + + + + + + + + + + <%= render_department_rows(@departments, 0) %> + +
λΆ€μ„œλͺ…μœ ν˜•λ¦¬λ”κ΅¬μ„±μ›μž‘μ—…
+<% else %> +

λ“±λ‘λœ λΆ€μ„œκ°€ μ—†μŠ΅λ‹ˆλ‹€. CEOλ₯Ό λ¨Όμ € λ“±λ‘ν•˜μ„Έμš”.

+<% end %> diff --git a/app/views/departments/org_chart.html.erb b/app/views/departments/org_chart.html.erb new file mode 100644 index 0000000..1147467 --- /dev/null +++ b/app/views/departments/org_chart.html.erb @@ -0,0 +1,360 @@ +<%= javascript_tag do %> +function toggleDept(id) { + var children = document.querySelectorAll('.dept-children-' + id); + var toggle = document.getElementById('toggle-' + id); + children.forEach(function(el) { + if (el.style.display === 'none') { + el.style.display = 'flex'; + toggle.textContent = '[-]'; + } else { + el.style.display = 'none'; + toggle.textContent = '[+]'; + } + }); +} +<% end %> + + + +
+

쑰직도

+ +

+ <%= link_to '쑰직도 관리', '/org_chart', class: 'button' %> +

+ + <% ceo = Department.ceo.first %> + <% if ceo %> + <%# CEO 레벨 %> +
+
+
+
CEO
+

<%= ceo.name %>

+ <% if ceo.effective_leader %> +
+ <%= ceo.has_acting_leader? ? 'λŒ€κ²°: ' : '' %><%= ceo.effective_leader.name %> +
+ <% end %> +
+
+
+ + <% executives = Department.executives.sorted %> + <% if executives.any? %> +
+ + <%# C-Level 레벨 %> +
+ C-Level + <% executives.each do |exec| %> +
+
+
<%= exec.type_name %>
+

<%= exec.name %>

+ <% if exec.effective_leader %> +
+ <%= exec.has_acting_leader? ? 'λŒ€κ²°: ' : '' %><%= exec.effective_leader.name %> +
+ <% else %> +
(λ‹΄λ‹Ήμž)
+ <% end %> +
<%= exec.all_members.count %>λͺ…
+
+ + <%# C-Level μ‚°ν•˜ λ³ΈλΆ€/νŒ€ %> + <% exec_divisions = Department.divisions.where(parent_id: exec.id).sorted %> + <% exec_teams = Department.teams.where(parent_id: exec.id).sorted %> + <% if exec_divisions.any? || exec_teams.any? %> +
+ <% exec_divisions.each do |div| %> + <%= render_org_branch(div) %> + <% end %> + <% exec_teams.each do |team| %> +
+
+
νŒ€
+

<%= team.name %>

+ <% if team.effective_leader %> +
+ <%= team.has_acting_leader? ? 'λŒ€κ²°: ' : '' %><%= team.effective_leader.name %> +
+ <% else %> +
(νŒ€μž₯ λ―Έμ§€μ •)
+ <% end %> +
<%= team.all_members.count %>λͺ…
+
+
+ <% end %> +
+ <% end %> +
+ <% end %> +
+ <% end %> + + <%# CEO 직속 λ³ΈλΆ€/νŒ€ (C-Level κ±°μΉ˜μ§€ μ•ŠλŠ”) %> + <% direct_divisions = Department.divisions.where(parent_id: [nil, ceo.id]).sorted %> + <% direct_teams = Department.teams.where(parent_id: [nil, ceo.id]).sorted %> + + <% if direct_divisions.any? || direct_teams.any? %> +
+ +
+ λ³ΈλΆ€/νŒ€ + <% direct_divisions.each do |div| %> + <%= render_org_branch(div) %> + <% end %> + <% direct_teams.each do |team| %> +
+
+
νŒ€
+

<%= team.name %>

+ <% if team.effective_leader %> +
+ <%= team.has_acting_leader? ? 'λŒ€κ²°: ' : '' %><%= team.effective_leader.name %> +
+ <% else %> +
(νŒ€μž₯ λ―Έμ§€μ •)
+ <% end %> +
<%= team.all_members.count %>λͺ…
+
+
+ <% end %> +
+ <% end %> + + <% else %> +
+

쑰직도가 μ„€μ •λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.

+

<%= link_to 'CEO λ“±λ‘ν•˜κΈ°', new_department_path(type: 'ceo'), class: 'button-positive' %>

+
+ <% end %> +
diff --git a/app/views/departments/show.html.erb b/app/views/departments/show.html.erb new file mode 100644 index 0000000..7125f59 --- /dev/null +++ b/app/views/departments/show.html.erb @@ -0,0 +1,179 @@ +

<%= @department.name %> (<%= @department.type_name %>)

+ +<% if @department.parent %> +

μƒμœ„ λΆ€μ„œ: <%= link_to @department.parent.name, department_path(@department.parent) %>

+<% end %> + +<% if @department.description.present? %> +

μ„€λͺ…: <%= @department.description %>

+<% end %> + +
+ +

리더

+ + + + + + + + + +
νŒ€μž₯ + <% if @department.leader %> + <%= link_to @department.leader.name, user_path(@department.leader) %> + νŒ€μž₯ + <% else %> + λ―Έμ§€μ • + <% end %> +
λŒ€κ²°μž + <% if @department.acting_leader && @department.leader.nil? %> + <%= link_to @department.acting_leader.name, user_path(@department.acting_leader) %> + λŒ€κ²° + <% elsif @department.acting_leader %> + <%= link_to @department.acting_leader.name, user_path(@department.acting_leader) %> + λŒ€κ²° (λΉ„ν™œμ„±) + <% else %> + λ―Έμ§€μ • + <% end %> +
+ +<% if @department.leader || @department.acting_leader %> +

+ <%= link_to '리더 ν•΄μ œ', clear_leader_department_path(@department), method: :patch, class: 'icon icon-del', + data: { confirm: '리더λ₯Ό ν•΄μ œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?' } %> +

+<% end %> + +
+ +

ꡬ성원 (<%= @members.count %>λͺ…)

+ +<% if @members.any? %> + + + + + + + + + + <% @members.each do |member| %> + + + + + + <% end %> + +
μ΄λ¦„μ—­ν• μž‘μ—…
+ <%= link_to member.user.name, user_path(member.user) %> + <% if @department.leader_id == member.user_id %> + νŒ€μž₯ + <% elsif @department.acting_leader_id == member.user_id %> + λŒ€κ²° + <% end %> + <%= member.user.login %> + <% unless @department.leader_id == member.user_id %> + <%= link_to 'νŒ€μž₯ μ§€μ •', set_leader_department_path(@department, user_id: member.user_id), + method: :patch, class: 'icon icon-user', title: 'νŒ€μž₯으둜 μ§€μ •' %> + <% end %> + <% unless @department.acting_leader_id == member.user_id %> + <%= link_to 'λŒ€κ²°μž', set_acting_leader_department_path(@department, user_id: member.user_id), + method: :patch, class: 'icon icon-user', title: 'λŒ€κ²°μžλ‘œ μ§€μ •' %> + <% end %> + <%= link_to '제거', remove_member_department_path(@department, user_id: member.user_id), + method: :delete, data: { confirm: '이 ꡬ성원을 μ œκ±°ν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?' }, class: 'icon icon-del' %> +
+<% else %> +

ꡬ성원이 μ—†μŠ΅λ‹ˆλ‹€.

+<% end %> + +
+ +

ꡬ성원 μΆ”κ°€ (LDAP 검색)

+ +
+

+ + + +

+
+
+ +
+ +

+ <%= link_to 'λͺ©λ‘μœΌλ‘œ', departments_path, class: 'icon icon-back' %> + <%= link_to 'μˆ˜μ •', edit_department_path(@department), class: 'icon icon-edit' %> +

+ +<%= javascript_tag do %> +var searchTimeout = null; + +$('#ldap_search').on('keyup', function() { + var query = $(this).val(); + + if (searchTimeout) clearTimeout(searchTimeout); + + if (query.length < 2) { + $('#search_results').html(''); + $('#search_status').text(''); + return; + } + + $('#search_status').text('검색 쀑...'); + + searchTimeout = setTimeout(function() { + $.ajax({ + url: '<%= search_ldap_departments_path %>', + data: { q: query }, + dataType: 'json', + success: function(data) { + $('#search_status').text(data.length + 'λͺ… 발견'); + var html = ''; + + if (data.length === 0) { + html = '

검색 κ²°κ³Όκ°€ μ—†μŠ΅λ‹ˆλ‹€.

'; + } else { + html = ''; + data.forEach(function(user) { + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + }); + html += '
IDμ΄λ¦„μ΄λ©”μΌμƒνƒœ
' + user.uid + '' + user.name + '' + (user.email || '-') + '' + (user.exists ? 'Redmine 등둝됨' : 'LDAP만') + ''; + if (user.exists) { + html += '
'; + html += ''; + html += ''; + html += ''; + html += '
'; + } else { + html += '
'; + html += ''; + html += ''; + html += ''; + html += ''; + html += '
'; + } + html += '
'; + } + + $('#search_results').html(html); + }, + error: function() { + $('#search_status').text('검색 였λ₯˜'); + $('#search_results').html('

검색 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.

'); + } + }); + }, 300); +}); +<% end %> diff --git a/config/routes.rb b/config/routes.rb new file mode 100644 index 0000000..125a0ab --- /dev/null +++ b/config/routes.rb @@ -0,0 +1,16 @@ +Rails.application.routes.draw do + get 'org_chart', to: 'departments#index' + get 'org_chart/new', to: 'departments#new', as: 'new_department' + get 'org_chart/view', to: 'departments#org_chart', as: 'org_chart_view' + get 'org_chart/search_ldap', to: 'departments#search_ldap', as: 'search_ldap_departments' + post 'org_chart/create', to: 'departments#create', as: 'departments' + get 'org_chart/:id', to: 'departments#show', as: 'department' + get 'org_chart/:id/edit', to: 'departments#edit', as: 'edit_department' + patch 'org_chart/:id', to: 'departments#update', as: 'update_department' + delete 'org_chart/:id', to: 'departments#destroy', as: 'destroy_department' + post 'org_chart/:id/add_member', to: 'departments#add_member', as: 'add_member_department' + delete 'org_chart/:id/remove_member/:user_id', to: 'departments#remove_member', as: 'remove_member_department' + patch 'org_chart/:id/set_leader/:user_id', to: 'departments#set_leader', as: 'set_leader_department' + patch 'org_chart/:id/set_acting_leader/:user_id', to: 'departments#set_acting_leader', as: 'set_acting_leader_department' + patch 'org_chart/:id/clear_leader', to: 'departments#clear_leader', as: 'clear_leader_department' +end diff --git a/init.rb b/init.rb new file mode 100644 index 0000000..1fe7702 --- /dev/null +++ b/init.rb @@ -0,0 +1,10 @@ +Redmine::Plugin.register :org_chart do + name 'Organization Chart' + author 'Admin' + description 'Organization chart management with departments, leaders, and members' + version '1.0.0' + url 'https://github.com/example/org_chart' + + menu :admin_menu, :org_chart, { controller: 'departments', action: 'index' }, + caption: '쑰직도', html: { class: 'icon icon-group' } +end