Initial commit: Redmine Organization Chart Plugin
🤖 Generated with Claude Code
This commit is contained in:
commit
b50ab25083
269
app/controllers/departments_controller.rb
Normal file
269
app/controllers/departments_controller.rb
Normal file
@ -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
|
||||
106
app/helpers/departments_helper.rb
Normal file
106
app/helpers/departments_helper.rb
Normal file
@ -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
|
||||
44
app/views/departments/_form.html.erb
Normal file
44
app/views/departments/_form.html.erb
Normal file
@ -0,0 +1,44 @@
|
||||
<fieldset class="box tabular">
|
||||
<legend>부서 정보</legend>
|
||||
|
||||
<p>
|
||||
<%= f.label :department_type, '부서 유형' %> <span class="required">*</span>
|
||||
<%= f.select :department_type, Department::TYPES.map { |k, v| [v, k] }, {}, required: true, id: 'department_type_select' %>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<%= f.label :name, '부서명' %> <span class="required">*</span>
|
||||
<%= f.text_field :name, size: 40, required: true %>
|
||||
</p>
|
||||
|
||||
<p id="parent_field">
|
||||
<%= f.label :parent_id, '상위 부서' %>
|
||||
<%= f.select :parent_id,
|
||||
options_from_collection_for_select(@parents, :id, :name, @department.parent_id),
|
||||
{ include_blank: '-- 최상위 (대표이사 직속) --' } %>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<%= f.label :description, '설명' %>
|
||||
<%= f.text_area :description, rows: 3, cols: 60 %>
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<%= 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 %>
|
||||
29
app/views/departments/index.html.erb
Normal file
29
app/views/departments/index.html.erb
Normal file
@ -0,0 +1,29 @@
|
||||
<h2>조직도 관리</h2>
|
||||
|
||||
<p>
|
||||
<%= 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' %>
|
||||
</p>
|
||||
|
||||
<% if @departments.any? %>
|
||||
<table class="list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>부서명</th>
|
||||
<th>유형</th>
|
||||
<th>리더</th>
|
||||
<th>구성원</th>
|
||||
<th>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<%= render_department_rows(@departments, 0) %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% else %>
|
||||
<p class="nodata">등록된 부서가 없습니다. CEO를 먼저 등록하세요.</p>
|
||||
<% end %>
|
||||
360
app/views/departments/org_chart.html.erb
Normal file
360
app/views/departments/org_chart.html.erb
Normal file
@ -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 %>
|
||||
|
||||
<style>
|
||||
.org-chart-container {
|
||||
font-family: 'Malgun Gothic', Arial, sans-serif;
|
||||
padding: 30px;
|
||||
background: #f5f5f5;
|
||||
min-height: 80vh;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.org-chart-title {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
color: #333;
|
||||
}
|
||||
.org-level {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
padding: 20px 0;
|
||||
}
|
||||
.org-level::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
border-left: 2px solid #ccc;
|
||||
height: 20px;
|
||||
}
|
||||
.org-level.first-level::before {
|
||||
display: none;
|
||||
}
|
||||
.org-level-label {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: #e9ecef;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
.org-node {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin: 10px 15px;
|
||||
position: relative;
|
||||
}
|
||||
.org-node::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
left: 50%;
|
||||
border-left: 2px solid #ccc;
|
||||
height: 20px;
|
||||
}
|
||||
.org-level.first-level .org-node::before {
|
||||
display: none;
|
||||
}
|
||||
.org-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 15px 20px;
|
||||
min-width: 160px;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
position: relative;
|
||||
border-top: 4px solid #6c757d;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.org-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
.org-card.org-ceo {
|
||||
border-top-color: #dc3545;
|
||||
min-width: 200px;
|
||||
background: linear-gradient(to bottom, #fff5f5, #fff);
|
||||
}
|
||||
.org-card.org-executive {
|
||||
border-top-color: #6f42c1;
|
||||
background: linear-gradient(to bottom, #f8f5ff, #fff);
|
||||
}
|
||||
.org-card.org-division {
|
||||
border-top-color: #007bff;
|
||||
}
|
||||
.org-card.org-team {
|
||||
border-top-color: #28a745;
|
||||
}
|
||||
.org-type {
|
||||
font-size: 10px;
|
||||
color: #999;
|
||||
margin-bottom: 5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.org-card h4 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.org-leader {
|
||||
font-size: 13px;
|
||||
color: #007bff;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.org-leader.no-leader {
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
.org-leader.acting {
|
||||
color: #856404;
|
||||
}
|
||||
.org-members {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
.org-connector {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
height: 30px;
|
||||
}
|
||||
.org-connector::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
border-left: 2px solid #ccc;
|
||||
height: 30px;
|
||||
}
|
||||
.org-branch {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin: 0 10px;
|
||||
}
|
||||
.org-branch-children {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
padding-top: 20px;
|
||||
}
|
||||
.org-branch-children::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
border-left: 2px solid #ccc;
|
||||
height: 20px;
|
||||
}
|
||||
.org-branch-children-vertical {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
padding-top: 15px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.org-branch-children-vertical::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
border-left: 2px solid #ccc;
|
||||
height: 15px;
|
||||
}
|
||||
.org-node-vertical {
|
||||
position: relative;
|
||||
margin: 5px 0;
|
||||
}
|
||||
.org-node-vertical::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: -20px;
|
||||
border-top: 2px solid #ccc;
|
||||
width: 20px;
|
||||
}
|
||||
.org-node-vertical:first-child::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: -20px;
|
||||
border-left: 2px solid #ccc;
|
||||
height: 50%;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
.org-node-vertical:last-child::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -20px;
|
||||
border-left: 2px solid #ccc;
|
||||
height: 50%;
|
||||
}
|
||||
.org-node-vertical:not(:first-child):not(:last-child)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -20px;
|
||||
border-left: 2px solid #ccc;
|
||||
height: 100%;
|
||||
}
|
||||
.org-branch-children-vertical .org-card {
|
||||
min-width: 140px;
|
||||
padding: 10px 15px;
|
||||
}
|
||||
.org-branch-children-vertical .org-card h4 {
|
||||
font-size: 13px;
|
||||
}
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 50px;
|
||||
color: #999;
|
||||
}
|
||||
.horizontal-line {
|
||||
height: 2px;
|
||||
background: #ccc;
|
||||
margin: 0 20px;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="org-chart-container">
|
||||
<h2 class="org-chart-title">조직도</h2>
|
||||
|
||||
<p style="text-align: center; margin-bottom: 20px;">
|
||||
<%= link_to '조직도 관리', '/org_chart', class: 'button' %>
|
||||
</p>
|
||||
|
||||
<% ceo = Department.ceo.first %>
|
||||
<% if ceo %>
|
||||
<%# CEO 레벨 %>
|
||||
<div class="org-level first-level">
|
||||
<div class="org-node" style="margin: 0;">
|
||||
<div class="org-card org-ceo" onclick="location.href='<%= department_path(ceo) %>'">
|
||||
<div class="org-type">CEO</div>
|
||||
<h4><%= ceo.name %></h4>
|
||||
<% if ceo.effective_leader %>
|
||||
<div class="org-leader <%= 'acting' if ceo.has_acting_leader? %>">
|
||||
<%= ceo.has_acting_leader? ? '대결: ' : '' %><%= ceo.effective_leader.name %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% executives = Department.executives.sorted %>
|
||||
<% if executives.any? %>
|
||||
<div class="org-connector"></div>
|
||||
|
||||
<%# C-Level 레벨 %>
|
||||
<div class="org-level" style="background: rgba(111, 66, 193, 0.05); border-radius: 8px; margin: 0 50px;">
|
||||
<span class="org-level-label">C-Level</span>
|
||||
<% executives.each do |exec| %>
|
||||
<div class="org-node">
|
||||
<div class="org-card org-executive" onclick="location.href='<%= department_path(exec) %>'">
|
||||
<div class="org-type"><%= exec.type_name %></div>
|
||||
<h4><%= exec.name %></h4>
|
||||
<% if exec.effective_leader %>
|
||||
<div class="org-leader <%= 'acting' if exec.has_acting_leader? %>">
|
||||
<%= exec.has_acting_leader? ? '대결: ' : '' %><%= exec.effective_leader.name %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="org-leader no-leader">(담당자)</div>
|
||||
<% end %>
|
||||
<div class="org-members"><%= exec.all_members.count %>명</div>
|
||||
</div>
|
||||
|
||||
<%# 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? %>
|
||||
<div class="org-branch-children">
|
||||
<% exec_divisions.each do |div| %>
|
||||
<%= render_org_branch(div) %>
|
||||
<% end %>
|
||||
<% exec_teams.each do |team| %>
|
||||
<div class="org-node">
|
||||
<div class="org-card org-team" onclick="location.href='<%= department_path(team) %>'">
|
||||
<div class="org-type">팀</div>
|
||||
<h4><%= team.name %></h4>
|
||||
<% if team.effective_leader %>
|
||||
<div class="org-leader <%= 'acting' if team.has_acting_leader? %>">
|
||||
<%= team.has_acting_leader? ? '대결: ' : '' %><%= team.effective_leader.name %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="org-leader no-leader">(팀장 미지정)</div>
|
||||
<% end %>
|
||||
<div class="org-members"><%= team.all_members.count %>명</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% 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? %>
|
||||
<div class="org-connector"></div>
|
||||
|
||||
<div class="org-level" style="background: rgba(0, 123, 255, 0.05); border-radius: 8px; margin: 0 50px;">
|
||||
<span class="org-level-label">본부/팀</span>
|
||||
<% direct_divisions.each do |div| %>
|
||||
<%= render_org_branch(div) %>
|
||||
<% end %>
|
||||
<% direct_teams.each do |team| %>
|
||||
<div class="org-node">
|
||||
<div class="org-card org-team" onclick="location.href='<%= department_path(team) %>'">
|
||||
<div class="org-type">팀</div>
|
||||
<h4><%= team.name %></h4>
|
||||
<% if team.effective_leader %>
|
||||
<div class="org-leader <%= 'acting' if team.has_acting_leader? %>">
|
||||
<%= team.has_acting_leader? ? '대결: ' : '' %><%= team.effective_leader.name %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="org-leader no-leader">(팀장 미지정)</div>
|
||||
<% end %>
|
||||
<div class="org-members"><%= team.all_members.count %>명</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% else %>
|
||||
<div class="no-data">
|
||||
<p>조직도가 설정되지 않았습니다.</p>
|
||||
<p><%= link_to 'CEO 등록하기', new_department_path(type: 'ceo'), class: 'button-positive' %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
179
app/views/departments/show.html.erb
Normal file
179
app/views/departments/show.html.erb
Normal file
@ -0,0 +1,179 @@
|
||||
<h2><%= @department.name %> <span style="color: #666; font-size: 14px;">(<%= @department.type_name %>)</span></h2>
|
||||
|
||||
<% if @department.parent %>
|
||||
<p><strong>상위 부서:</strong> <%= link_to @department.parent.name, department_path(@department.parent) %></p>
|
||||
<% end %>
|
||||
|
||||
<% if @department.description.present? %>
|
||||
<p><strong>설명:</strong> <%= @department.description %></p>
|
||||
<% end %>
|
||||
|
||||
<hr />
|
||||
|
||||
<h3>리더</h3>
|
||||
<table class="list" style="width: auto;">
|
||||
<tr>
|
||||
<th style="width: 100px;">팀장</th>
|
||||
<td>
|
||||
<% if @department.leader %>
|
||||
<%= link_to @department.leader.name, user_path(@department.leader) %>
|
||||
<span style="background: #28a745; color: white; padding: 2px 8px; border-radius: 3px; font-size: 11px; margin-left: 5px;">팀장</span>
|
||||
<% else %>
|
||||
<em style="color: #999;">미지정</em>
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>대결자</th>
|
||||
<td>
|
||||
<% if @department.acting_leader && @department.leader.nil? %>
|
||||
<%= link_to @department.acting_leader.name, user_path(@department.acting_leader) %>
|
||||
<span style="background: #ffc107; color: #333; padding: 2px 8px; border-radius: 3px; font-size: 11px; margin-left: 5px;">대결</span>
|
||||
<% elsif @department.acting_leader %>
|
||||
<%= link_to @department.acting_leader.name, user_path(@department.acting_leader) %>
|
||||
<span style="background: #6c757d; color: white; padding: 2px 8px; border-radius: 3px; font-size: 11px; margin-left: 5px;">대결 (비활성)</span>
|
||||
<% else %>
|
||||
<em style="color: #999;">미지정</em>
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<% if @department.leader || @department.acting_leader %>
|
||||
<p>
|
||||
<%= link_to '리더 해제', clear_leader_department_path(@department), method: :patch, class: 'icon icon-del',
|
||||
data: { confirm: '리더를 해제하시겠습니까?' } %>
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<hr />
|
||||
|
||||
<h3>구성원 (<%= @members.count %>명)</h3>
|
||||
|
||||
<% if @members.any? %>
|
||||
<table class="list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>이름</th>
|
||||
<th>역할</th>
|
||||
<th>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @members.each do |member| %>
|
||||
<tr>
|
||||
<td>
|
||||
<%= link_to member.user.name, user_path(member.user) %>
|
||||
<% if @department.leader_id == member.user_id %>
|
||||
<span style="background: #28a745; color: white; padding: 2px 8px; border-radius: 3px; font-size: 11px; margin-left: 5px;">팀장</span>
|
||||
<% elsif @department.acting_leader_id == member.user_id %>
|
||||
<span style="background: #ffc107; color: #333; padding: 2px 8px; border-radius: 3px; font-size: 11px; margin-left: 5px;">대결</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td><%= member.user.login %></td>
|
||||
<td>
|
||||
<% 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' %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% else %>
|
||||
<p class="nodata">구성원이 없습니다.</p>
|
||||
<% end %>
|
||||
|
||||
<hr />
|
||||
|
||||
<h3>구성원 추가 (LDAP 검색)</h3>
|
||||
|
||||
<div class="box">
|
||||
<p>
|
||||
<label for="ldap_search">사용자 검색:</label>
|
||||
<input type="text" id="ldap_search" size="30" placeholder="이름 또는 ID로 검색..." autocomplete="off" />
|
||||
<span id="search_status" style="margin-left: 10px; color: #666;"></span>
|
||||
</p>
|
||||
<div id="search_results" style="margin-top: 10px;"></div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<p>
|
||||
<%= link_to '목록으로', departments_path, class: 'icon icon-back' %>
|
||||
<%= link_to '수정', edit_department_path(@department), class: 'icon icon-edit' %>
|
||||
</p>
|
||||
|
||||
<%= 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 = '<p style="color: #999;">검색 결과가 없습니다.</p>';
|
||||
} else {
|
||||
html = '<table class="list" style="width: auto;"><thead><tr><th>ID</th><th>이름</th><th>이메일</th><th>상태</th><th></th></tr></thead><tbody>';
|
||||
data.forEach(function(user) {
|
||||
html += '<tr>';
|
||||
html += '<td>' + user.uid + '</td>';
|
||||
html += '<td>' + user.name + '</td>';
|
||||
html += '<td>' + (user.email || '-') + '</td>';
|
||||
html += '<td>' + (user.exists ? '<span style="color: green;">Redmine 등록됨</span>' : '<span style="color: blue;">LDAP만</span>') + '</td>';
|
||||
html += '<td>';
|
||||
if (user.exists) {
|
||||
html += '<form action="<%= add_member_department_path(@department) %>" method="post" style="display:inline;">';
|
||||
html += '<input type="hidden" name="authenticity_token" value="' + $('meta[name="csrf-token"]').attr('content') + '">';
|
||||
html += '<input type="hidden" name="user_id" value="' + user.user_id + '">';
|
||||
html += '<input type="submit" value="추가" class="button-positive" style="padding: 2px 10px;">';
|
||||
html += '</form>';
|
||||
} else {
|
||||
html += '<form action="<%= add_member_department_path(@department) %>" method="post" style="display:inline;">';
|
||||
html += '<input type="hidden" name="authenticity_token" value="' + $('meta[name="csrf-token"]').attr('content') + '">';
|
||||
html += '<input type="hidden" name="ldap_uid" value="' + user.uid + '">';
|
||||
html += '<input type="hidden" name="ldap_id" value="' + user.ldap_id + '">';
|
||||
html += '<input type="submit" value="추가 (신규생성)" class="button" style="padding: 2px 10px;">';
|
||||
html += '</form>';
|
||||
}
|
||||
html += '</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
}
|
||||
|
||||
$('#search_results').html(html);
|
||||
},
|
||||
error: function() {
|
||||
$('#search_status').text('검색 오류');
|
||||
$('#search_results').html('<p style="color: red;">검색 중 오류가 발생했습니다.</p>');
|
||||
}
|
||||
});
|
||||
}, 300);
|
||||
});
|
||||
<% end %>
|
||||
16
config/routes.rb
Normal file
16
config/routes.rb
Normal file
@ -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
|
||||
10
init.rb
Normal file
10
init.rb
Normal file
@ -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
|
||||
Loading…
Reference in New Issue
Block a user