Initial commit: Redmine Organization Chart Plugin

🤖 Generated with Claude Code
This commit is contained in:
ioresponse 2025-12-23 00:21:49 +09:00
commit b50ab25083
8 changed files with 1013 additions and 0 deletions

View 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

View 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 = ('&nbsp;&nbsp;&nbsp;&nbsp;' * 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

View 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 %>

View 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 %>

View 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>

View 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
View 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
View 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