Initial commit: Redmine Workflow Engine Plugin

Features:
- Custom workflow creation per project/tracker
- Step-by-step workflow definition
- Assignees per step (user, role group, department)
- Next/Previous step navigation
- Reject to first step
- Skip step (admin only)
- Step deadline settings
- Workflow dashboard
- Group member selection when proceeding

🤖 Generated with Claude Code
This commit is contained in:
ioresponse 2025-12-23 00:16:43 +09:00
commit e67fb92189
25 changed files with 1800 additions and 0 deletions

View File

@ -0,0 +1,177 @@
class IssueWorkflowsController < ApplicationController
before_action :find_issue
before_action :find_or_create_workflow_state
def show
@steps = @workflow_state.custom_workflow.workflow_steps.ordered
@current_step = @workflow_state.current_step
end
def next_step
if @workflow_state.can_proceed?(User.current)
selected_assignee_id = params[:assignee_id]
if @workflow_state.proceed!(User.current, selected_assignee_id)
flash[:notice] = '다음 단계로 이동했습니다.'
else
flash[:error] = '다음 단계로 이동할 수 없습니다.'
end
else
flash[:error] = '이 단계를 완료할 권한이 없습니다.'
end
redirect_to issue_path(@issue)
end
def prev_step
if @workflow_state.can_go_back?(User.current)
if @workflow_state.go_back!(User.current)
flash[:notice] = '이전 단계로 되돌렸습니다.'
else
flash[:error] = '이전 단계로 이동할 수 없습니다.'
end
else
flash[:error] = '이전 단계로 되돌릴 권한이 없습니다.'
end
redirect_to issue_path(@issue)
end
def complete
current_step = @workflow_state.current_step
if current_step&.is_end && (User.current.admin? || current_step.assignee?(User.current))
@issue.init_journal(User.current, "워크플로우 완료 처리")
@issue.status_id = 5 # 완료 상태
if @issue.save(validate: false)
@workflow_state.update(completed_at: Time.current, completed_by: User.current)
flash[:notice] = '이슈가 완료 처리되었습니다.'
else
flash[:error] = '완료 처리에 실패했습니다.'
end
else
flash[:error] = '완료 처리 권한이 없습니다.'
end
redirect_to issue_path(@issue)
end
def reject
current_step = @workflow_state.current_step
reason = params[:reason].to_s.strip
unless current_step && !current_step.is_start && (User.current.admin? || current_step.assignee?(User.current))
flash[:error] = '반려 권한이 없습니다.'
redirect_to issue_path(@issue)
return
end
if reason.blank?
flash[:error] = '반려 사유를 입력해주세요.'
redirect_to issue_path(@issue)
return
end
# 항상 최초 단계로 반려
first_step = @workflow_state.custom_workflow.workflow_steps.ordered.first
if first_step
# 이력 남기기
@issue.init_journal(User.current, "[반려] #{current_step.name}#{first_step.name}\n사유: #{reason}")
# 상태 변경 (최초 단계의 상태로)
if first_step.issue_status_id.present?
@issue.status_id = first_step.issue_status_id
end
# 담당자 변경 - 개인 사용자일 때만, 그룹/부서는 미지정
user_assignees = first_step.workflow_step_assignees.where(assignee_type: 'user')
if user_assignees.any?
@issue.assigned_to_id = user_assignees.first.assignee_id
elsif first_step.workflow_step_assignees.any?
@issue.assigned_to_id = nil
end
@issue.save(validate: false)
@workflow_state.update(current_step: first_step, completed_at: nil, completed_by: nil)
flash[:notice] = "#{first_step.name} 단계로 반려되었습니다."
else
flash[:error] = '반려 대상 단계를 찾을 수 없습니다.'
end
redirect_to issue_path(@issue)
end
def skip_step
# 관리자만 단계 건너뛰기 가능
unless User.current.admin?
flash[:error] = '관리자만 단계를 건너뛸 수 있습니다.'
redirect_to issue_path(@issue)
return
end
current_step = @workflow_state.current_step
target_step_id = params[:target_step_id]
reason = params[:reason].to_s.strip
if target_step_id.blank?
flash[:error] = '이동할 단계를 선택해주세요.'
redirect_to issue_path(@issue)
return
end
target_step = WorkflowStep.find_by(id: target_step_id)
if target_step && target_step.custom_workflow_id == @workflow_state.custom_workflow_id
# 이력 남기기
note = "[단계 건너뛰기] #{current_step&.name || '(시작)'}#{target_step.name}"
note += "\n사유: #{reason}" if reason.present?
@issue.init_journal(User.current, note)
# 상태 변경
if target_step.issue_status_id.present?
@issue.status_id = target_step.issue_status_id
end
# 담당자 변경 - 개인 사용자일 때만, 그룹/부서는 미지정
user_assignees = target_step.workflow_step_assignees.where(assignee_type: 'user')
if user_assignees.any?
@issue.assigned_to_id = user_assignees.first.assignee_id
elsif target_step.workflow_step_assignees.any?
@issue.assigned_to_id = nil
end
@issue.save(validate: false)
@workflow_state.update(current_step: target_step, completed_at: nil, completed_by: nil)
flash[:notice] = "#{target_step.name} 단계로 이동했습니다."
else
flash[:error] = '이동할 단계를 찾을 수 없습니다.'
end
redirect_to issue_path(@issue)
end
private
def find_issue
@issue = Issue.find(params[:issue_id])
rescue ActiveRecord::RecordNotFound
render_404
end
def find_or_create_workflow_state
@workflow_state = IssueWorkflowState.find_by(issue_id: @issue.id)
unless @workflow_state
workflow = CustomWorkflow.find_for_issue(@issue)
if workflow
@workflow_state = IssueWorkflowState.create!(
issue: @issue,
custom_workflow: workflow,
current_step: workflow.start_step,
started_at: Time.current
)
else
flash[:warning] = '이 이슈에 적용할 워크플로우가 없습니다.'
redirect_to issue_path(@issue)
end
end
end
end

View File

@ -0,0 +1,92 @@
class WorkflowDashboardController < ApplicationController
before_action :require_login
def index
@projects = Project.visible.has_module(:issue_tracking).to_a
# 전체 워크플로우 통계
@total_stats = {
active_workflows: CustomWorkflow.where(active: true).count,
issues_in_workflow: IssueWorkflowState.where(completed_at: nil).count,
completed_today: IssueWorkflowState.where('completed_at >= ?', Date.today).count
}
# 프로젝트별 현황
@project_stats = []
@projects.each do |project|
workflows = CustomWorkflow.where(project_id: [project.id, nil]).where(active: true)
issue_ids = Issue.where(project_id: project.id).pluck(:id)
states = IssueWorkflowState.where(issue_id: issue_ids)
in_progress = states.where(completed_at: nil).count
completed = states.where.not(completed_at: nil).count
if in_progress > 0 || completed > 0
@project_stats << {
project: project,
workflows_count: workflows.count,
in_progress: in_progress,
completed: completed
}
end
end
# 내 담당 이슈 (현재 단계 담당자가 나인 경우)
@my_issues = find_my_workflow_issues(User.current)
# 지연된 이슈 (기한이 있고 초과된 경우)
@overdue_issues = find_overdue_issues
end
def project
@project = Project.find(params[:project_id])
# 해당 프로젝트의 워크플로우들
@workflows = CustomWorkflow.where(project_id: [params[:project_id], nil]).where(active: true)
# 프로젝트 이슈들의 워크플로우 상태
issue_ids = Issue.where(project_id: @project.id).pluck(:id)
@workflow_states = IssueWorkflowState.includes(:issue, :current_step, :custom_workflow)
.where(issue_id: issue_ids)
.order(updated_at: :desc)
# 단계별 그룹핑
@steps_summary = {}
@workflow_states.where(completed_at: nil).each do |state|
step_name = state.current_step&.name || '(알 수 없음)'
@steps_summary[step_name] ||= []
@steps_summary[step_name] << state
end
end
private
def find_my_workflow_issues(user)
return [] unless user.logged?
states = IssueWorkflowState.includes(:issue, :current_step, :custom_workflow)
.where(completed_at: nil)
my_states = states.select do |state|
state.current_step&.assignee?(user)
end
my_states.take(20) # 최대 20개
end
def find_overdue_issues
states = IssueWorkflowState.includes(:issue, :current_step)
.where(completed_at: nil)
overdue = states.select do |state|
next false unless state.current_step&.due_days.present?
# 현재 단계에 머문 시간 계산
step_started_at = state.updated_at
due_date = step_started_at + state.current_step.due_days.days
due_date < Time.current
end
overdue.take(20)
end
end

View File

@ -0,0 +1,91 @@
class WorkflowStepsController < ApplicationController
layout 'admin'
before_action :require_admin
before_action :find_workflow, only: [:create, :update, :destroy, :move]
before_action :find_step, only: [:update, :destroy, :move]
def create
@step = @workflow.workflow_steps.build(step_params)
if @step.save
flash[:notice] = '단계가 추가되었습니다.'
else
flash[:error] = @step.errors.full_messages.join(', ')
end
redirect_to custom_workflow_path(@workflow)
end
def update
if @step.update(step_params)
flash[:notice] = '단계가 수정되었습니다.'
else
flash[:error] = @step.errors.full_messages.join(', ')
end
redirect_to custom_workflow_path(@workflow)
end
def destroy
@step.destroy
flash[:notice] = '단계가 삭제되었습니다.'
redirect_to custom_workflow_path(@workflow)
end
def move
direction = params[:direction]
if direction == 'up' && @step.position > 0
swap_step = @workflow.workflow_steps.find_by(position: @step.position - 1)
if swap_step
swap_step.update(position: @step.position)
@step.update(position: @step.position - 1)
end
elsif direction == 'down'
swap_step = @workflow.workflow_steps.find_by(position: @step.position + 1)
if swap_step
swap_step.update(position: @step.position)
@step.update(position: @step.position + 1)
end
end
redirect_to custom_workflow_path(@workflow)
end
# 담당자 추가
def add_assignee
@step = WorkflowStep.find(params[:step_id])
assignee = @step.workflow_step_assignees.build(
assignee_type: params[:assignee_type],
assignee_id: params[:assignee_id]
)
if assignee.save
flash[:notice] = '담당자가 추가되었습니다.'
else
flash[:error] = assignee.errors.full_messages.join(', ')
end
redirect_to custom_workflow_path(@step.custom_workflow)
end
# 담당자 제거
def remove_assignee
@step = WorkflowStep.find(params[:step_id])
assignee = @step.workflow_step_assignees.find(params[:id])
assignee.destroy
flash[:notice] = '담당자가 제거되었습니다.'
redirect_to custom_workflow_path(@step.custom_workflow)
end
private
def find_workflow
@workflow = CustomWorkflow.find(params[:workflow_id])
rescue ActiveRecord::RecordNotFound
render_404
end
def find_step
@step = @workflow.workflow_steps.find(params[:id])
rescue ActiveRecord::RecordNotFound
render_404
end
def step_params
params.require(:workflow_step).permit(:name, :description, :issue_status_id, :is_start, :is_end, :due_days)
end
end

View File

@ -0,0 +1,97 @@
class WorkflowsController < ApplicationController
layout 'admin'
before_action :require_admin
before_action :find_workflow, only: [:show, :edit, :update, :destroy]
def index
@workflows = CustomWorkflow.includes(:tracker, :project, :workflow_steps).order(:name)
end
def show
@steps = @workflow.workflow_steps.includes(:workflow_step_assignees).ordered
end
def new
@workflow = CustomWorkflow.new
end
def create
@workflow = CustomWorkflow.new(workflow_params)
if @workflow.save
flash[:notice] = '워크플로우가 생성되었습니다.'
redirect_to custom_workflow_path(@workflow)
else
render :new
end
end
def edit
end
def update
if @workflow.update(workflow_params)
flash[:notice] = '워크플로우가 수정되었습니다.'
redirect_to '/custom_workflows'
else
render :edit
end
end
def destroy
@workflow.destroy
flash[:notice] = '워크플로우가 삭제되었습니다.'
redirect_to '/custom_workflows'
end
# 사용자 검색
def search_users
query = params[:q].to_s.strip
if query.length < 2
render json: []
return
end
users = User.active
.where('LOWER(login) LIKE :q OR LOWER(firstname) LIKE :q OR LOWER(lastname) LIKE :q',
q: "%#{query.downcase}%")
.limit(20)
render json: users.map { |u| { id: u.id, name: u.name, login: u.login } }
end
# 역할 그룹 검색
def search_role_groups
query = params[:q].to_s.strip
groups = if query.length >= 2
RoleGroup.where('LOWER(name) LIKE ?', "%#{query.downcase}%").limit(20)
else
RoleGroup.sorted.limit(20)
end
render json: groups.map { |g| { id: g.id, name: g.name, count: g.member_count } }
end
# 부서 검색
def search_departments
query = params[:q].to_s.strip
depts = if query.length >= 2
Department.where('LOWER(name) LIKE ?', "%#{query.downcase}%").limit(20)
else
Department.sorted.limit(20)
end
render json: depts.map { |d| { id: d.id, name: d.name, type: d.type_name, count: d.member_count } }
end
private
def find_workflow
@workflow = CustomWorkflow.find(params[:id])
rescue ActiveRecord::RecordNotFound
render_404
end
def workflow_params
params.require(:custom_workflow).permit(:name, :description, :tracker_id, :project_id, :is_default, :active)
end
end

View File

@ -0,0 +1,41 @@
class CustomWorkflow < ActiveRecord::Base
belongs_to :tracker, optional: true
belongs_to :project, optional: true
has_many :workflow_steps, -> { order(:position) }, dependent: :destroy
has_many :issue_workflow_states, dependent: :destroy
validates :name, presence: true
scope :active, -> { where(active: true) }
scope :for_tracker, ->(tracker_id) { where(tracker_id: [tracker_id, nil]) }
scope :for_project, ->(project_id) { where(project_id: [project_id, nil]) }
def start_step
workflow_steps.find_by(is_start: true) || workflow_steps.first
end
def end_step
workflow_steps.find_by(is_end: true) || workflow_steps.last
end
def step_count
workflow_steps.count
end
def self.find_for_issue(issue)
# 프로젝트 + 트래커 매칭 먼저
wf = active.where(project_id: issue.project_id, tracker_id: issue.tracker_id).first
return wf if wf
# 트래커만 매칭
wf = active.where(project_id: nil, tracker_id: issue.tracker_id).first
return wf if wf
# 프로젝트만 매칭
wf = active.where(project_id: issue.project_id, tracker_id: nil).first
return wf if wf
# 기본 워크플로우
active.where(is_default: true).first
end
end

View File

@ -0,0 +1,111 @@
class IssueWorkflowState < ActiveRecord::Base
belongs_to :issue
belongs_to :custom_workflow
belongs_to :current_step, class_name: 'WorkflowStep', optional: true
belongs_to :completed_by, class_name: 'User', optional: true
validates :issue_id, presence: true, uniqueness: true
validates :custom_workflow_id, presence: true
def current_step_name
current_step&.name || '(시작 전)'
end
def progress_percent
return 0 unless current_step
total = custom_workflow.step_count
return 100 if current_step.last_step?
current_position = current_step.position + 1
(current_position.to_f / total * 100).round
end
def can_proceed?(user)
return false unless current_step
return false if completed?
return true if user.admin? # 관리자는 항상 가능
return true if current_step.first_step? && issue.author == user # 첫 단계는 작성자가 진행 가능
return true if current_step.workflow_step_assignees.empty? # 담당자 없으면 누구나 가능
current_step.assignee?(user)
end
def can_go_back?(user)
return false unless current_step
return false if current_step.first_step?
# 관리자이거나 이전 단계 담당자면 가능
user.admin? || current_step.prev_step&.assignee?(user)
end
def proceed!(user, selected_assignee_id = nil)
return false unless can_proceed?(user)
next_step = current_step.next_step
if next_step
update(current_step: next_step)
update_issue_for_step(next_step, selected_assignee_id)
else
# 마지막 단계 완료
update(completed_at: Time.current, completed_by: user)
# 마지막 단계의 상태로 변경
update_issue_for_step(current_step)
end
true
end
def go_back!(user)
return false unless can_go_back?(user)
prev_step = current_step.prev_step
if prev_step
update(current_step: prev_step, completed_at: nil, completed_by: nil)
update_issue_for_step(prev_step)
end
true
end
def completed?
completed_at.present?
end
def visible_to?(user)
return true if user.admin?
return true if issue.author == user
return true if current_step&.assignee?(user)
# 모든 단계의 담당자인지 확인
custom_workflow.workflow_steps.any? { |step| step.assignee?(user) }
end
private
def update_issue_for_step(step, selected_assignee_id = nil)
return unless step
# 이력 남기기 위해 journal 초기화
issue.init_journal(User.current, "워크플로우: #{step.name} 단계로 이동")
# 상태 변경
if step.issue_status_id.present?
issue.status_id = step.issue_status_id
end
# 담당자 변경
if selected_assignee_id.present?
# 선택된 담당자가 있으면 그 사람으로 할당
issue.assigned_to_id = selected_assignee_id
else
# 개인 사용자가 지정된 경우 첫번째 사용자로 할당
user_assignees = step.workflow_step_assignees.where(assignee_type: 'user')
if user_assignees.any?
issue.assigned_to_id = user_assignees.first.assignee_id
elsif step.workflow_step_assignees.any?
# 그룹/부서만 있고 선택 안된 경우 첫번째 멤버로 할당
assignees = step.all_assignee_users
issue.assigned_to_id = assignees.first&.id
end
end
# validate: false로 저장 (프로젝트 멤버 체크 우회, 이력은 남김)
issue.save(validate: false)
end
end

View File

@ -0,0 +1,60 @@
class WorkflowStep < ActiveRecord::Base
belongs_to :custom_workflow
belongs_to :issue_status, optional: true
has_many :workflow_step_assignees, dependent: :destroy
has_many :issue_workflow_states, foreign_key: :current_step_id
validates :name, presence: true
validates :custom_workflow_id, presence: true
scope :ordered, -> { order(:position) }
before_create :set_position
def next_step
custom_workflow.workflow_steps.where('position > ?', position).order(:position).first
end
def prev_step
custom_workflow.workflow_steps.where('position < ?', position).order(position: :desc).first
end
def first_step?
is_start || position == custom_workflow.workflow_steps.minimum(:position)
end
def last_step?
is_end || position == custom_workflow.workflow_steps.maximum(:position)
end
# 이 단계의 모든 담당자 (사람, 역할그룹 멤버, 부서원 포함)
def all_assignee_users
users = []
workflow_step_assignees.each do |assignee|
case assignee.assignee_type
when 'user'
user = User.find_by(id: assignee.assignee_id)
users << user if user
when 'role_group'
role_group = RoleGroup.find_by(id: assignee.assignee_id)
users.concat(role_group.users.to_a) if role_group
when 'department'
department = Department.find_by(id: assignee.assignee_id)
users.concat(department.users.to_a) if department
end
end
users.uniq
end
# 특정 사용자가 이 단계의 담당자인지 확인
def assignee?(user)
all_assignee_users.include?(user)
end
private
def set_position
max_position = custom_workflow.workflow_steps.maximum(:position) || -1
self.position = max_position + 1
end
end

View File

@ -0,0 +1,41 @@
class WorkflowStepAssignee < ActiveRecord::Base
belongs_to :workflow_step
validates :workflow_step_id, presence: true
validates :assignee_type, presence: true, inclusion: { in: %w[user role_group department] }
validates :assignee_id, presence: true
validates :assignee_id, uniqueness: { scope: [:workflow_step_id, :assignee_type] }
def assignee
case assignee_type
when 'user'
User.find_by(id: assignee_id)
when 'role_group'
RoleGroup.find_by(id: assignee_id)
when 'department'
Department.find_by(id: assignee_id)
end
end
def assignee_name
obj = assignee
return '(삭제됨)' unless obj
case assignee_type
when 'user'
obj.name
when 'role_group'
"#{obj.name} (역할그룹)"
when 'department'
"#{obj.name} (부서)"
end
end
def type_label
case assignee_type
when 'user' then '사용자'
when 'role_group' then '역할 그룹'
when 'department' then '부서'
end
end
end

View File

@ -0,0 +1,98 @@
<div class="box" style="margin-bottom: 20px;">
<h3 style="margin-top: 0;">워크플로우 진행 상태</h3>
<p>
<strong>워크플로우:</strong> <%= @workflow_state.custom_workflow.name %>
<% if @workflow_state.custom_workflow.description.present? %>
<br/><small style="color: #666;"><%= @workflow_state.custom_workflow.description %></small>
<% end %>
</p>
<div style="display: flex; align-items: center; flex-wrap: wrap; gap: 5px; margin: 15px 0;">
<% @steps.each_with_index do |step, idx| %>
<% is_current = @current_step && @current_step.id == step.id %>
<% is_completed = @current_step && step.position < @current_step.position %>
<div style="
padding: 8px 15px;
border-radius: 4px;
font-size: 13px;
<% if is_current %>
background: #007bff;
color: white;
font-weight: bold;
<% elsif is_completed %>
background: #28a745;
color: white;
<% else %>
background: #e9ecef;
color: #666;
<% end %>
">
<%= step.name %>
<% if step.is_start %>
<span style="font-size: 10px;">(시작)</span>
<% end %>
<% if step.is_end %>
<span style="font-size: 10px;">(종료)</span>
<% end %>
</div>
<% if idx < @steps.length - 1 %>
<span style="color: #999; font-size: 18px;">&rarr;</span>
<% end %>
<% end %>
</div>
<% if @current_step %>
<p>
<strong>현재 단계:</strong> <%= @current_step.name %>
</p>
<% if @current_step.workflow_step_assignees.any? %>
<p>
<strong>담당자:</strong>
<% @current_step.workflow_step_assignees.each do |assignee| %>
<span style="display: inline-block; background: #e9ecef; padding: 2px 8px; border-radius: 3px; margin: 2px; font-size: 12px;">
<%= assignee.assignee_name %>
</span>
<% end %>
</p>
<% end %>
<div style="margin-top: 15px;">
<% unless @current_step.is_end %>
<% if @workflow_state.can_proceed?(User.current) %>
<%= link_to '다음 단계로 &rarr;'.html_safe,
issue_workflow_next_step_path(@issue),
method: :post,
class: 'button button-positive',
data: { confirm: '다음 단계로 진행하시겠습니까?' } %>
<% else %>
<span class="button" style="opacity: 0.5; cursor: not-allowed;">다음 단계로 &rarr; (권한 없음)</span>
<% end %>
<% end %>
<% unless @current_step.is_start %>
<% if @workflow_state.can_go_back?(User.current) %>
<%= link_to '&larr; 이전 단계로'.html_safe,
issue_workflow_prev_step_path(@issue),
method: :post,
class: 'button',
data: { confirm: '이전 단계로 되돌리시겠습니까?' } %>
<% end %>
<% end %>
</div>
<% else %>
<p class="nodata">워크플로우 단계가 설정되지 않았습니다.</p>
<% end %>
<% if @workflow_state.started_at %>
<p style="margin-top: 15px; font-size: 11px; color: #999;">
시작일: <%= @workflow_state.started_at.strftime('%Y-%m-%d %H:%M') %>
<% if @workflow_state.completed_at %>
| 완료일: <%= @workflow_state.completed_at.strftime('%Y-%m-%d %H:%M') %>
<% end %>
</p>
<% end %>
</div>

View File

@ -0,0 +1,284 @@
<%
workflow_state = IssueWorkflowState.find_by(issue_id: @issue.id)
unless workflow_state
# 이슈에 적용 가능한 워크플로우 찾기
workflow = CustomWorkflow.find_for_issue(@issue)
if workflow && workflow.start_step
workflow_state = IssueWorkflowState.create(
issue: @issue,
custom_workflow: workflow,
current_step: workflow.start_step,
started_at: Time.current
)
end
end
%>
<% if workflow_state && workflow_state.custom_workflow %>
<%
steps = workflow_state.custom_workflow.workflow_steps.ordered
current_step = workflow_state.current_step
%>
<div class="box" style="margin-top: 15px; margin-bottom: 15px; background: #f8f9fa;">
<h3 style="margin-top: 0; margin-bottom: 10px; font-size: 14px;">
<span style="color: #007bff;">&#9654;</span>
워크플로우: <%= workflow_state.custom_workflow.name %>
</h3>
<div style="display: flex; align-items: center; flex-wrap: wrap; gap: 5px; margin: 10px 0;">
<% steps.each_with_index do |step, idx| %>
<%
is_current = current_step && current_step.id == step.id
is_completed = current_step && step.position < current_step.position
%>
<div style="
padding: 6px 12px;
border-radius: 4px;
font-size: 12px;
<% if is_current %>
background: #007bff;
color: white;
font-weight: bold;
<% elsif is_completed %>
background: #28a745;
color: white;
<% else %>
background: #e9ecef;
color: #666;
<% end %>
">
<%= step.name %>
</div>
<% if idx < steps.length - 1 %>
<span style="color: #999; font-size: 14px;">&rarr;</span>
<% end %>
<% end %>
</div>
<% if current_step %>
<div style="font-size: 12px; margin-top: 10px;">
<strong>현재 단계:</strong> <%= current_step.name %>
<% if current_step.workflow_step_assignees.any? %>
&nbsp;|&nbsp;
<strong>담당자:</strong>
<% current_step.workflow_step_assignees.each do |assignee| %>
<span style="background: #fff; border: 1px solid #ddd; padding: 1px 6px; border-radius: 3px; margin-left: 3px; font-size: 11px;">
<%= assignee.assignee_name %>
</span>
<% end %>
<% end %>
<% if current_step.is_start && @issue.editable?(User.current) %>
&nbsp;|&nbsp;
<a href="/issues/<%= @issue.id %>/edit" target="_blank" class="icon icon-edit" style="color: #007bff;">이슈 편집</a>
<% end %>
</div>
<%
# 다음 단계 정보 확인
next_step = current_step.next_step
next_step_has_group = next_step && next_step.workflow_step_assignees.where(assignee_type: ['role_group', 'department']).any?
next_step_group_members = next_step_has_group ? next_step.all_assignee_users : []
%>
<div style="margin-top: 12px;">
<% unless current_step.is_end %>
<% if workflow_state.can_proceed?(User.current) %>
<% if next_step_has_group && next_step_group_members.count > 1 %>
<a href="#" onclick="showNextStepModal(); return false;"
class="button button-positive"
style="font-size: 11px; padding: 4px 10px;">
다음 단계로 &rarr;
</a>
<% else %>
<%= link_to '다음 단계로 &rarr;'.html_safe,
issue_workflow_next_step_path(@issue),
method: :post,
class: 'button button-positive',
style: 'font-size: 11px; padding: 4px 10px;',
data: { confirm: '다음 단계로 진행하시겠습니까?' } %>
<% end %>
<% else %>
<span class="button" style="font-size: 11px; padding: 4px 10px; opacity: 0.5; cursor: not-allowed;">
다음 단계로 (권한 없음)
</span>
<% end %>
<% else %>
<span style="color: #28a745; font-weight: bold; font-size: 12px;">&#10003; 워크플로우 완료</span>
<% if @issue.status_id != 5 && (User.current.admin? || current_step.assignee?(User.current)) %>
<%= link_to '완료 처리'.html_safe,
issue_workflow_complete_path(@issue),
method: :post,
class: 'button button-positive',
style: 'font-size: 11px; padding: 4px 10px; margin-left: 10px;',
data: { confirm: '이슈를 완료 처리하시겠습니까?' } %>
<% end %>
<% end %>
<% if !current_step.is_start && workflow_state.can_go_back?(User.current) %>
<%= link_to '&larr; 이전'.html_safe,
issue_workflow_prev_step_path(@issue),
method: :post,
class: 'button',
style: 'font-size: 11px; padding: 4px 10px; margin-left: 5px;',
data: { confirm: '이전 단계로 되돌리시겠습니까?' } %>
<% end %>
<% if !current_step.is_start && (User.current.admin? || current_step.assignee?(User.current)) %>
<a href="#" onclick="showRejectModal(); return false;"
class="button"
style="font-size: 11px; padding: 4px 10px; margin-left: 5px; background: #dc3545; color: white;">
반려
</a>
<% end %>
<% if User.current.admin? %>
<a href="#" onclick="showSkipModal(); return false;"
class="button"
style="font-size: 11px; padding: 4px 10px; margin-left: 5px; background: #6c757d; color: white;">
단계 이동
</a>
<% end %>
</div>
<% if current_step.due_days.present? %>
<%
step_started_at = workflow_state.updated_at
due_date = step_started_at + current_step.due_days.days
remaining = ((due_date - Time.current) / 1.day).to_i
%>
<div style="margin-top: 8px; font-size: 11px;">
<% if remaining < 0 %>
<span style="color: #dc3545; font-weight: bold;">
기한 초과 <%= remaining.abs %>일
</span>
<% elsif remaining == 0 %>
<span style="color: #f57c00; font-weight: bold;">
오늘 마감
</span>
<% else %>
<span style="color: #666;">
남은 기한: <%= remaining %>일 (<%= due_date.strftime('%Y-%m-%d') %>)
</span>
<% end %>
</div>
<% end %>
<% end %>
</div>
<!-- 반려 모달 -->
<div id="reject-modal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000;">
<div style="background: white; width: 400px; margin: 100px auto; padding: 20px; border-radius: 8px;">
<h3 style="margin-top: 0; color: #dc3545;">반려</h3>
<p style="color: #666; font-size: 12px;">최초 단계(<%= steps.first&.name %>)로 반려됩니다.</p>
<%= form_tag issue_workflow_reject_path(@issue), method: :post do %>
<p>
<label><strong>반려 사유:</strong></label><br/>
<textarea name="reason" rows="4" style="width: 100%; padding: 5px;" placeholder="반려 사유를 입력하세요..." required></textarea>
</p>
<p style="text-align: right;">
<button type="button" onclick="closeRejectModal()" class="button">취소</button>
<button type="submit" class="button" style="background: #dc3545; color: white;">반려</button>
</p>
<% end %>
</div>
</div>
<!-- 다음 단계 담당자 선택 모달 -->
<% if next_step_has_group && next_step_group_members.count > 1 %>
<div id="next-step-modal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000;">
<div style="background: white; width: 400px; margin: 100px auto; padding: 20px; border-radius: 8px;">
<h3 style="margin-top: 0; color: #007bff;">다음 단계: <%= next_step.name %></h3>
<p style="color: #666; font-size: 12px;">담당자를 선택하세요.</p>
<%= form_tag issue_workflow_next_step_path(@issue), method: :post do %>
<p>
<label><strong>담당자 선택:</strong></label><br/>
<% next_step_group_members.each do |member| %>
<label style="display: block; padding: 8px; margin: 5px 0; background: #f8f9fa; border-radius: 4px; cursor: pointer;">
<input type="radio" name="assignee_id" value="<%= member.id %>" required style="margin-right: 8px;">
<%= member.name %>
</label>
<% end %>
</p>
<p style="text-align: right;">
<button type="button" onclick="closeNextStepModal()" class="button">취소</button>
<button type="submit" class="button button-positive">다음 단계로</button>
</p>
<% end %>
</div>
</div>
<% end %>
<!-- 단계 이동 모달 (관리자용) -->
<% if User.current.admin? %>
<div id="skip-modal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000;">
<div style="background: white; width: 400px; margin: 100px auto; padding: 20px; border-radius: 8px;">
<h3 style="margin-top: 0; color: #6c757d;">단계 이동 (관리자)</h3>
<p style="color: #666; font-size: 12px;">원하는 단계로 직접 이동합니다.</p>
<%= form_tag issue_workflow_skip_step_path(@issue), method: :post do %>
<p>
<label><strong>이동할 단계:</strong></label><br/>
<select name="target_step_id" style="width: 100%; padding: 5px;" required>
<option value="">-- 선택 --</option>
<% steps.each do |step| %>
<% next if current_step && step.id == current_step.id %>
<option value="<%= step.id %>">
<%= step.name %>
<% if step.is_start %>(시작)<% end %>
<% if step.is_end %>(종료)<% end %>
</option>
<% end %>
</select>
</p>
<p>
<label><strong>사유 (선택):</strong></label><br/>
<textarea name="reason" rows="3" style="width: 100%; padding: 5px;" placeholder="이동 사유를 입력하세요..."></textarea>
</p>
<p style="text-align: right;">
<button type="button" onclick="closeSkipModal()" class="button">취소</button>
<button type="submit" class="button" style="background: #6c757d; color: white;">이동</button>
</p>
<% end %>
</div>
</div>
<% end %>
<script>
function showRejectModal() {
document.getElementById('reject-modal').style.display = 'block';
}
function closeRejectModal() {
document.getElementById('reject-modal').style.display = 'none';
}
document.getElementById('reject-modal').addEventListener('click', function(e) {
if (e.target === this) closeRejectModal();
});
<% if next_step_has_group && next_step_group_members.count > 1 %>
function showNextStepModal() {
document.getElementById('next-step-modal').style.display = 'block';
}
function closeNextStepModal() {
document.getElementById('next-step-modal').style.display = 'none';
}
document.getElementById('next-step-modal').addEventListener('click', function(e) {
if (e.target === this) closeNextStepModal();
});
<% end %>
<% if User.current.admin? %>
function showSkipModal() {
document.getElementById('skip-modal').style.display = 'block';
}
function closeSkipModal() {
document.getElementById('skip-modal').style.display = 'none';
}
document.getElementById('skip-modal').addEventListener('click', function(e) {
if (e.target === this) closeSkipModal();
});
<% end %>
</script>
<% end %>

View File

@ -0,0 +1,136 @@
<h2>워크플로우 대시보드</h2>
<!-- 전체 통계 -->
<div style="display: flex; gap: 20px; margin-bottom: 20px;">
<div class="box" style="flex: 1; text-align: center; background: #e3f2fd;">
<div style="font-size: 32px; font-weight: bold; color: #1976d2;"><%= @total_stats[:active_workflows] %></div>
<div style="color: #666;">활성 워크플로우</div>
</div>
<div class="box" style="flex: 1; text-align: center; background: #fff3e0;">
<div style="font-size: 32px; font-weight: bold; color: #f57c00;"><%= @total_stats[:issues_in_workflow] %></div>
<div style="color: #666;">진행 중인 이슈</div>
</div>
<div class="box" style="flex: 1; text-align: center; background: #e8f5e9;">
<div style="font-size: 32px; font-weight: bold; color: #388e3c;"><%= @total_stats[:completed_today] %></div>
<div style="color: #666;">오늘 완료</div>
</div>
</div>
<div style="display: flex; gap: 20px;">
<!-- 왼쪽: 프로젝트별 현황 -->
<div style="flex: 1;">
<div class="box">
<h3 style="margin-top: 0;">프로젝트별 현황</h3>
<% if @project_stats.any? %>
<table class="list" style="width: 100%;">
<thead>
<tr>
<th>프로젝트</th>
<th style="width: 80px; text-align: center;">진행 중</th>
<th style="width: 80px; text-align: center;">완료</th>
<th style="width: 60px;"></th>
</tr>
</thead>
<tbody>
<% @project_stats.each do |stat| %>
<tr>
<td><%= link_to stat[:project].name, project_path(stat[:project]) %></td>
<td style="text-align: center;">
<span style="background: #fff3e0; padding: 2px 8px; border-radius: 10px; color: #f57c00; font-weight: bold;">
<%= stat[:in_progress] %>
</span>
</td>
<td style="text-align: center;">
<span style="background: #e8f5e9; padding: 2px 8px; border-radius: 10px; color: #388e3c;">
<%= stat[:completed] %>
</span>
</td>
<td>
<%= link_to '상세', workflow_dashboard_project_path(stat[:project]), class: 'button', style: 'padding: 2px 8px; font-size: 11px;' %>
</td>
</tr>
<% end %>
</tbody>
</table>
<% else %>
<p class="nodata">워크플로우가 적용된 이슈가 없습니다.</p>
<% end %>
</div>
</div>
<!-- 오른쪽: 내 담당 이슈 -->
<div style="flex: 1;">
<div class="box">
<h3 style="margin-top: 0;">내 담당 이슈</h3>
<% if @my_issues.any? %>
<table class="list" style="width: 100%;">
<thead>
<tr>
<th>이슈</th>
<th>현재 단계</th>
<th style="width: 80px;"></th>
</tr>
</thead>
<tbody>
<% @my_issues.each do |state| %>
<tr>
<td>
<%= link_to "##{state.issue.id}", issue_path(state.issue) %>
<%= truncate(state.issue.subject, length: 30) %>
</td>
<td>
<span style="background: #007bff; color: white; padding: 2px 6px; border-radius: 3px; font-size: 11px;">
<%= state.current_step&.name %>
</span>
</td>
<td>
<%= link_to '처리', issue_path(state.issue), class: 'button button-positive', style: 'padding: 2px 8px; font-size: 11px;' %>
</td>
</tr>
<% end %>
</tbody>
</table>
<% else %>
<p class="nodata">담당 이슈가 없습니다.</p>
<% end %>
</div>
<!-- 지연된 이슈 -->
<% if @overdue_issues.any? %>
<div class="box" style="margin-top: 15px; background: #ffebee;">
<h3 style="margin-top: 0; color: #c62828;">지연된 이슈</h3>
<table class="list" style="width: 100%;">
<thead>
<tr>
<th>이슈</th>
<th>현재 단계</th>
<th>지연일</th>
</tr>
</thead>
<tbody>
<% @overdue_issues.each do |state| %>
<%
step_started_at = state.updated_at
due_date = step_started_at + state.current_step.due_days.days
overdue_days = ((Time.current - due_date) / 1.day).to_i
%>
<tr>
<td>
<%= link_to "##{state.issue.id}", issue_path(state.issue) %>
<%= truncate(state.issue.subject, length: 25) %>
</td>
<td><%= state.current_step&.name %></td>
<td style="color: #c62828; font-weight: bold;"><%= overdue_days %>일</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</div>
</div>
<hr />
<p>
<%= link_to '워크플로우 관리', '/custom_workflows', class: 'icon icon-settings' %>
</p>

View File

@ -0,0 +1,99 @@
<h2>워크플로우 현황: <%= @project.name %></h2>
<p>
<%= link_to '&larr; 대시보드로'.html_safe, workflow_dashboard_path, class: 'icon icon-back' %>
</p>
<!-- 단계별 현황 -->
<div class="box">
<h3 style="margin-top: 0;">단계별 이슈 현황</h3>
<% if @steps_summary.any? %>
<div style="display: flex; flex-wrap: wrap; gap: 15px;">
<% @steps_summary.each do |step_name, states| %>
<div style="flex: 1; min-width: 250px; border: 1px solid #ddd; border-radius: 8px; padding: 15px; background: #fafafa;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<strong style="font-size: 14px;"><%= step_name %></strong>
<span style="background: #007bff; color: white; padding: 2px 10px; border-radius: 12px; font-size: 12px;">
<%= states.count %>건
</span>
</div>
<div style="max-height: 200px; overflow-y: auto;">
<% states.each do |state| %>
<div style="background: white; padding: 8px; margin-bottom: 5px; border-radius: 4px; border-left: 3px solid #007bff;">
<div>
<%= link_to "##{state.issue.id}", issue_path(state.issue), style: 'font-weight: bold;' %>
<%= truncate(state.issue.subject, length: 30) %>
</div>
<div style="font-size: 11px; color: #666; margin-top: 3px;">
담당: <%= state.issue.assigned_to&.name || '-' %>
<% if state.current_step&.due_days.present? %>
<%
step_started_at = state.updated_at
due_date = step_started_at + state.current_step.due_days.days
remaining = ((due_date - Time.current) / 1.day).to_i
%>
<% if remaining < 0 %>
| <span style="color: #dc3545; font-weight: bold;">지연 <%= remaining.abs %>일</span>
<% elsif remaining == 0 %>
| <span style="color: #f57c00; font-weight: bold;">오늘 마감</span>
<% else %>
| 남은 기한: <%= remaining %>일
<% end %>
<% end %>
</div>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
<% else %>
<p class="nodata">진행 중인 워크플로우 이슈가 없습니다.</p>
<% end %>
</div>
<!-- 전체 이슈 목록 -->
<div class="box" style="margin-top: 20px;">
<h3 style="margin-top: 0;">전체 워크플로우 이슈</h3>
<% if @workflow_states.any? %>
<table class="list">
<thead>
<tr>
<th style="width: 60px;">#</th>
<th>제목</th>
<th style="width: 120px;">워크플로우</th>
<th style="width: 100px;">현재 단계</th>
<th style="width: 100px;">담당자</th>
<th style="width: 80px;">상태</th>
<th style="width: 100px;">갱신일</th>
</tr>
</thead>
<tbody>
<% @workflow_states.each do |state| %>
<tr class="<%= state.completed_at ? 'even' : '' %>">
<td><%= link_to "##{state.issue.id}", issue_path(state.issue) %></td>
<td><%= link_to state.issue.subject, issue_path(state.issue) %></td>
<td><%= state.custom_workflow&.name %></td>
<td>
<% if state.completed_at %>
<span style="background: #28a745; color: white; padding: 2px 6px; border-radius: 3px; font-size: 11px;">완료</span>
<% else %>
<span style="background: #007bff; color: white; padding: 2px 6px; border-radius: 3px; font-size: 11px;">
<%= state.current_step&.name %>
</span>
<% end %>
</td>
<td><%= state.issue.assigned_to&.name || '-' %></td>
<td><%= state.issue.status&.name %></td>
<td><%= format_time(state.updated_at) %></td>
</tr>
<% end %>
</tbody>
</table>
<% else %>
<p class="nodata">워크플로우 이슈가 없습니다.</p>
<% end %>
</div>

View File

@ -0,0 +1,53 @@
<%= form_for @workflow, url: @workflow.new_record? ? '/custom_workflows' : custom_workflow_path(@workflow), method: @workflow.new_record? ? :post : :patch, html: { class: 'tabular' } do |f| %>
<% if @workflow.errors.any? %>
<div id="errorExplanation">
<ul>
<% @workflow.errors.full_messages.each do |msg| %>
<li><%= msg %></li>
<% end %>
</ul>
</div>
<% end %>
<p>
<label for="custom_workflow_name">워크플로우명 <span class="required">*</span></label>
<%= f.text_field :name, size: 50, required: true %>
</p>
<p>
<label for="custom_workflow_description">설명</label>
<%= f.text_area :description, rows: 3, cols: 60 %>
</p>
<p>
<label for="custom_workflow_project_id">프로젝트</label>
<%= f.collection_select :project_id, Project.all.order(:name), :id, :name,
{ include_blank: '-- 모든 프로젝트 --' }, { style: 'width: 300px;' } %>
<br/>
<em class="info">특정 프로젝트에서만 사용하려면 선택하세요.</em>
</p>
<p>
<label for="custom_workflow_tracker_id">트래커</label>
<%= f.collection_select :tracker_id, Tracker.all.order(:position), :id, :name,
{ include_blank: '-- 모든 트래커 --' }, { style: 'width: 300px;' } %>
<br/>
<em class="info">특정 트래커에서만 사용하려면 선택하세요.</em>
</p>
<p>
<label for="custom_workflow_is_default">기본 워크플로우</label>
<%= f.check_box :is_default %>
<em class="info">프로젝트/트래커가 일치하는 이슈에 자동 적용됩니다.</em>
</p>
<p>
<label for="custom_workflow_active">활성화</label>
<%= f.check_box :active %>
</p>
<p>
<%= submit_tag @workflow.new_record? ? '생성' : '저장', class: 'button-positive' %>
<%= link_to '취소', '/custom_workflows', class: 'button' %>
</p>
<% end %>

View File

@ -0,0 +1,3 @@
<h2>워크플로우 수정: <%= @workflow.name %></h2>
<%= render partial: 'form' %>

View File

@ -0,0 +1,50 @@
<h2>워크플로우 관리</h2>
<p>
<%= link_to '새 워크플로우', new_custom_workflow_path, class: 'icon icon-add' %>
</p>
<% if @workflows.any? %>
<table class="list">
<thead>
<tr>
<th>워크플로우명</th>
<th>프로젝트</th>
<th>트래커</th>
<th>단계 수</th>
<th>상태</th>
<th>작업</th>
</tr>
</thead>
<tbody>
<% @workflows.each do |wf| %>
<tr>
<td>
<strong><%= link_to wf.name, custom_workflow_path(wf) %></strong>
<% if wf.is_default %>
<span style="background: #28a745; color: white; padding: 1px 6px; border-radius: 3px; font-size: 10px; margin-left: 5px;">기본</span>
<% end %>
</td>
<td><%= wf.project&.name || '(모든 프로젝트)' %></td>
<td><%= wf.tracker&.name || '(모든 트래커)' %></td>
<td><%= wf.step_count %>개</td>
<td>
<% if wf.active %>
<span style="color: green;">활성</span>
<% else %>
<span style="color: #999;">비활성</span>
<% end %>
</td>
<td>
<%= link_to '관리', custom_workflow_path(wf), class: 'icon icon-edit' %>
<%= link_to '수정', edit_custom_workflow_path(wf), class: 'icon icon-edit' %>
<%= link_to '삭제', destroy_custom_workflow_path(wf), method: :delete,
data: { confirm: '정말 삭제하시겠습니까?' }, class: 'icon icon-del' %>
</td>
</tr>
<% end %>
</tbody>
</table>
<% else %>
<p class="nodata">등록된 워크플로우가 없습니다.</p>
<% end %>

View File

@ -0,0 +1,3 @@
<h2>새 워크플로우</h2>
<%= render partial: 'form' %>

View File

@ -0,0 +1,228 @@
<h2><%= @workflow.name %></h2>
<% if @workflow.description.present? %>
<p><%= @workflow.description %></p>
<% end %>
<p>
<strong>프로젝트:</strong> <%= @workflow.project&.name || '모든 프로젝트' %> |
<strong>트래커:</strong> <%= @workflow.tracker&.name || '모든 트래커' %> |
<strong>상태:</strong> <%= @workflow.active ? '활성' : '비활성' %>
<% if @workflow.is_default %>
| <strong>기본 워크플로우</strong>
<% end %>
</p>
<hr />
<h3>워크플로우 단계</h3>
<div class="box" style="margin-bottom: 20px;">
<%= form_tag custom_workflow_steps_path(@workflow), method: :post, class: 'tabular' do %>
<p>
<label>단계명:</label>
<%= text_field_tag 'workflow_step[name]', '', size: 20, required: true, placeholder: '예: 개발, QA...' %>
&nbsp;
<label>이슈 상태:</label>
<%= select_tag 'workflow_step[issue_status_id]',
options_from_collection_for_select(IssueStatus.sorted, :id, :name),
include_blank: '-- 선택 --', style: 'width: 150px;' %>
&nbsp;
<%= submit_tag '단계 추가', class: 'button-positive' %>
</p>
<% end %>
</div>
<% if @steps.any? %>
<table class="list">
<thead>
<tr>
<th style="width: 50px;">순서</th>
<th>단계명</th>
<th>이슈 상태</th>
<th style="width: 80px;">기한(일)</th>
<th>담당자</th>
<th style="width: 200px;">작업</th>
</tr>
</thead>
<tbody>
<% @steps.each_with_index do |step, idx| %>
<tr>
<td style="text-align: center;">
<%= idx + 1 %>
<% if step.is_start %>
<br/><span style="background: #007bff; color: white; padding: 1px 4px; border-radius: 2px; font-size: 9px;">시작</span>
<% end %>
<% if step.is_end %>
<br/><span style="background: #dc3545; color: white; padding: 1px 4px; border-radius: 2px; font-size: 9px;">종료</span>
<% end %>
</td>
<td>
<strong><%= step.name %></strong>
<% if step.description.present? %>
<br/><small style="color: #666;"><%= step.description %></small>
<% end %>
</td>
<td>
<%= form_tag custom_workflow_step_path(@workflow, step), method: :patch, style: 'display: inline;' do %>
<%= select_tag 'workflow_step[issue_status_id]',
options_from_collection_for_select(IssueStatus.sorted, :id, :name, step.issue_status_id),
include_blank: '-- 선택 --',
style: 'font-size: 11px; padding: 2px;',
onchange: 'this.form.submit();' %>
<% end %>
</td>
<td>
<%= form_tag custom_workflow_step_path(@workflow, step), method: :patch, style: 'display: inline;' do %>
<%= number_field_tag 'workflow_step[due_days]', step.due_days,
min: 1, max: 365,
style: 'width: 50px; font-size: 11px; padding: 2px;',
placeholder: '-',
onchange: 'this.form.submit();' %>
<% end %>
</td>
<td>
<% if step.workflow_step_assignees.any? %>
<% step.workflow_step_assignees.each do |assignee| %>
<span style="display: inline-block; background: #e9ecef; padding: 2px 8px; border-radius: 3px; margin: 2px; font-size: 12px;">
<%= assignee.assignee_name %>
<%= link_to '×', step_assignee_path(step_id: step.id, id: assignee.id),
method: :delete, style: 'color: #dc3545; margin-left: 5px;', title: '제거' %>
</span>
<% end %>
<% else %>
<em style="color: #999;">담당자 없음</em>
<% end %>
<br/>
<a href="#" onclick="showAssigneeModal(<%= step.id %>); return false;" style="font-size: 11px;">+ 담당자 추가</a>
</td>
<td>
<%= link_to '▲', move_custom_workflow_step_path(@workflow, step, direction: 'up'), method: :patch, class: 'icon', title: '위로' unless idx == 0 %>
<%= link_to '▼', move_custom_workflow_step_path(@workflow, step, direction: 'down'), method: :patch, class: 'icon', title: '아래로' unless idx == @steps.length - 1 %>
|
<%= link_to '삭제', custom_workflow_step_path(@workflow, step), method: :delete,
data: { confirm: '이 단계를 삭제하시겠습니까?' }, class: 'icon icon-del' %>
</td>
</tr>
<% end %>
</tbody>
</table>
<% else %>
<p class="nodata">단계가 없습니다. 위에서 단계를 추가하세요.</p>
<% end %>
<hr />
<p>
<%= link_to '목록으로', '/custom_workflows', class: 'icon icon-back' %>
<%= link_to '수정', edit_custom_workflow_path(@workflow), class: 'icon icon-edit' %>
</p>
<!-- 담당자 추가 모달 -->
<div id="assignee-modal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000;">
<div style="background: white; width: 500px; margin: 100px auto; padding: 20px; border-radius: 8px; max-height: 80vh; overflow-y: auto;">
<h3>담당자 추가</h3>
<input type="hidden" id="modal-step-id" />
<div style="margin-bottom: 15px;">
<label><strong>담당자 유형:</strong></label><br/>
<select id="assignee-type" onchange="loadAssigneeOptions()" style="width: 100%; padding: 5px;">
<option value="user">사용자</option>
<option value="role_group">역할 그룹</option>
<option value="department">부서</option>
</select>
</div>
<div style="margin-bottom: 15px;">
<label><strong>검색:</strong></label><br/>
<input type="text" id="assignee-search" placeholder="검색어 입력..." style="width: 100%; padding: 5px;" />
</div>
<div id="assignee-list" style="max-height: 300px; overflow-y: auto; border: 1px solid #ddd; padding: 10px;">
<p style="color: #999;">유형을 선택하고 검색하세요.</p>
</div>
<div style="margin-top: 15px; text-align: right;">
<button onclick="closeAssigneeModal()" class="button">닫기</button>
</div>
</div>
</div>
<%= javascript_tag do %>
var currentStepId = null;
var searchTimeout = null;
function showAssigneeModal(stepId) {
currentStepId = stepId;
document.getElementById('modal-step-id').value = stepId;
document.getElementById('assignee-modal').style.display = 'block';
document.getElementById('assignee-search').value = '';
loadAssigneeOptions();
}
function closeAssigneeModal() {
document.getElementById('assignee-modal').style.display = 'none';
currentStepId = null;
}
function loadAssigneeOptions() {
var type = document.getElementById('assignee-type').value;
var query = document.getElementById('assignee-search').value;
var listDiv = document.getElementById('assignee-list');
var url = '';
if (type === 'user') {
url = '<%= search_users_workflows_path %>';
} else if (type === 'role_group') {
url = '<%= search_role_groups_workflows_path %>';
} else if (type === 'department') {
url = '<%= search_departments_workflows_path %>';
}
$.ajax({
url: url,
data: { q: query },
dataType: 'json',
success: function(data) {
var html = '';
if (data.length === 0) {
html = '<p style="color: #999;">결과 없음</p>';
} else {
html = '<table style="width: 100%;"><tbody>';
data.forEach(function(item) {
var label = item.name;
if (item.count !== undefined) {
label += ' (' + item.count + '명)';
}
if (item.login) {
label += ' - ' + item.login;
}
html += '<tr>';
html += '<td>' + label + '</td>';
html += '<td style="text-align: right;">';
html += '<form action="/workflow_steps/' + currentStepId + '/assignees" method="post" style="display:inline;">';
html += '<input type="hidden" name="authenticity_token" value="' + $('meta[name="csrf-token"]').attr('content') + '">';
html += '<input type="hidden" name="assignee_type" value="' + type + '">';
html += '<input type="hidden" name="assignee_id" value="' + item.id + '">';
html += '<input type="submit" value="추가" class="button-positive" style="padding: 2px 8px; font-size: 11px;">';
html += '</form>';
html += '</td>';
html += '</tr>';
});
html += '</tbody></table>';
}
listDiv.innerHTML = html;
}
});
}
$('#assignee-search').on('keyup', function() {
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(loadAssigneeOptions, 300);
});
// 모달 외부 클릭시 닫기
document.getElementById('assignee-modal').addEventListener('click', function(e) {
if (e.target === this) closeAssigneeModal();
});
<% end %>

39
config/routes.rb Normal file
View File

@ -0,0 +1,39 @@
Rails.application.routes.draw do
# 워크플로우 대시보드
get 'workflow_dashboard', to: 'workflow_dashboard#index', as: 'workflow_dashboard'
get 'workflow_dashboard/project/:project_id', to: 'workflow_dashboard#project', as: 'workflow_dashboard_project'
# 커스텀 워크플로우 관리 (Redmine 기본 /workflows와 충돌 방지)
get 'custom_workflows', to: 'workflows#index'
get 'custom_workflows/new', to: 'workflows#new', as: 'new_custom_workflow'
post 'custom_workflows', to: 'workflows#create'
# 검색 API (반드시 :id 라우트 이전에 위치)
get 'custom_workflows/search/users', to: 'workflows#search_users', as: 'search_users_workflows'
get 'custom_workflows/search/role_groups', to: 'workflows#search_role_groups', as: 'search_role_groups_workflows'
get 'custom_workflows/search/departments', to: 'workflows#search_departments', as: 'search_departments_workflows'
# 워크플로우 CRUD
get 'custom_workflows/:id', to: 'workflows#show', as: 'custom_workflow'
get 'custom_workflows/:id/edit', to: 'workflows#edit', as: 'edit_custom_workflow'
patch 'custom_workflows/:id', to: 'workflows#update', as: 'update_custom_workflow'
delete 'custom_workflows/:id', to: 'workflows#destroy', as: 'destroy_custom_workflow'
# 워크플로우 단계 관리
post 'custom_workflows/:workflow_id/steps', to: 'workflow_steps#create', as: 'custom_workflow_steps'
patch 'custom_workflows/:workflow_id/steps/:id', to: 'workflow_steps#update', as: 'custom_workflow_step'
delete 'custom_workflows/:workflow_id/steps/:id', to: 'workflow_steps#destroy'
patch 'custom_workflows/:workflow_id/steps/:id/move', to: 'workflow_steps#move', as: 'move_custom_workflow_step'
# 단계별 담당자 관리
post 'workflow_steps/:step_id/assignees', to: 'workflow_steps#add_assignee', as: 'step_assignees'
delete 'workflow_steps/:step_id/assignees/:id', to: 'workflow_steps#remove_assignee', as: 'step_assignee'
# 티켓 워크플로우 액션
post 'issues/:issue_id/workflow/next', to: 'issue_workflows#next_step', as: 'issue_workflow_next_step'
post 'issues/:issue_id/workflow/prev', to: 'issue_workflows#prev_step', as: 'issue_workflow_prev_step'
post 'issues/:issue_id/workflow/complete', to: 'issue_workflows#complete', as: 'issue_workflow_complete'
post 'issues/:issue_id/workflow/reject', to: 'issue_workflows#reject', as: 'issue_workflow_reject'
post 'issues/:issue_id/workflow/skip', to: 'issue_workflows#skip_step', as: 'issue_workflow_skip_step'
get 'issues/:issue_id/workflow', to: 'issue_workflows#show', as: 'issue_workflow'
end

View File

@ -0,0 +1,17 @@
class CreateCustomWorkflows < ActiveRecord::Migration[6.1]
def change
create_table :custom_workflows do |t|
t.string :name, null: false
t.text :description
t.integer :tracker_id
t.integer :project_id
t.boolean :is_default, default: false
t.boolean :active, default: true
t.timestamps
end
add_index :custom_workflows, :tracker_id
add_index :custom_workflows, :project_id
add_index :custom_workflows, :is_default
end
end

View File

@ -0,0 +1,19 @@
class CreateWorkflowSteps < ActiveRecord::Migration[6.1]
def change
create_table :workflow_steps do |t|
t.bigint :custom_workflow_id, null: false
t.string :name, null: false
t.text :description
t.integer :position, default: 0
t.integer :issue_status_id
t.boolean :is_start, default: false
t.boolean :is_end, default: false
t.timestamps
end
add_index :workflow_steps, :custom_workflow_id
add_index :workflow_steps, :position
add_index :workflow_steps, :issue_status_id
add_foreign_key :workflow_steps, :custom_workflows
end
end

View File

@ -0,0 +1,14 @@
class CreateWorkflowStepAssignees < ActiveRecord::Migration[6.1]
def change
create_table :workflow_step_assignees do |t|
t.bigint :workflow_step_id, null: false
t.string :assignee_type, null: false # 'user', 'role_group', 'department'
t.integer :assignee_id, null: false # user_id, role_group_id, or department_id
t.timestamps
end
add_index :workflow_step_assignees, :workflow_step_id
add_index :workflow_step_assignees, [:assignee_type, :assignee_id]
add_foreign_key :workflow_step_assignees, :workflow_steps
end
end

View File

@ -0,0 +1,20 @@
class CreateIssueWorkflowStates < ActiveRecord::Migration[6.1]
def change
create_table :issue_workflow_states do |t|
t.integer :issue_id, null: false
t.bigint :custom_workflow_id, null: false
t.bigint :current_step_id
t.integer :completed_by_id
t.datetime :started_at
t.datetime :completed_at
t.timestamps
end
add_index :issue_workflow_states, :issue_id, unique: true
add_index :issue_workflow_states, :custom_workflow_id
add_index :issue_workflow_states, :current_step_id
add_foreign_key :issue_workflow_states, :issues
add_foreign_key :issue_workflow_states, :custom_workflows
add_foreign_key :issue_workflow_states, :workflow_steps, column: :current_step_id
end
end

View File

@ -0,0 +1,5 @@
class AddDueDaysToWorkflowSteps < ActiveRecord::Migration[6.1]
def change
add_column :workflow_steps, :due_days, :integer, default: nil
end
end

16
init.rb Normal file
View File

@ -0,0 +1,16 @@
require_relative 'lib/workflow_engine/hooks'
Redmine::Plugin.register :workflow_engine do
name '워크플로우 엔진'
author 'Admin'
description 'Custom workflow engine for issue tracking with step-by-step assignments'
version '1.0.0'
menu :admin_menu, :custom_workflows, '/custom_workflows',
caption: '커스텀 워크플로우', html: { class: 'icon icon-workflows' }
project_module :workflow_engine do
permission :view_workflow, { workflows: [:show] }
permission :manage_workflow, { workflows: [:index, :new, :create, :edit, :update, :destroy] }
end
end

View File

@ -0,0 +1,6 @@
module WorkflowEngine
class Hooks < Redmine::Hook::ViewListener
# 이슈 상세 페이지 상단에 워크플로우 진행 상태 표시
render_on :view_issues_show_description_bottom, partial: 'issues/workflow_status'
end
end