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:
commit
e67fb92189
177
app/controllers/issue_workflows_controller.rb
Normal file
177
app/controllers/issue_workflows_controller.rb
Normal 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
|
||||
92
app/controllers/workflow_dashboard_controller.rb
Normal file
92
app/controllers/workflow_dashboard_controller.rb
Normal 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
|
||||
91
app/controllers/workflow_steps_controller.rb
Normal file
91
app/controllers/workflow_steps_controller.rb
Normal 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
|
||||
97
app/controllers/workflows_controller.rb
Normal file
97
app/controllers/workflows_controller.rb
Normal 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
|
||||
41
app/models/custom_workflow.rb
Normal file
41
app/models/custom_workflow.rb
Normal 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
|
||||
111
app/models/issue_workflow_state.rb
Normal file
111
app/models/issue_workflow_state.rb
Normal 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
|
||||
60
app/models/workflow_step.rb
Normal file
60
app/models/workflow_step.rb
Normal 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
|
||||
41
app/models/workflow_step_assignee.rb
Normal file
41
app/models/workflow_step_assignee.rb
Normal 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
|
||||
98
app/views/issue_workflows/show.html.erb
Normal file
98
app/views/issue_workflows/show.html.erb
Normal 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;">→</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 '다음 단계로 →'.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;">다음 단계로 → (권한 없음)</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% unless @current_step.is_start %>
|
||||
<% if @workflow_state.can_go_back?(User.current) %>
|
||||
<%= link_to '← 이전 단계로'.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>
|
||||
284
app/views/issues/_workflow_status.html.erb
Normal file
284
app/views/issues/_workflow_status.html.erb
Normal 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;">▶</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;">→</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? %>
|
||||
|
|
||||
<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) %>
|
||||
|
|
||||
<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;">
|
||||
다음 단계로 →
|
||||
</a>
|
||||
<% else %>
|
||||
<%= link_to '다음 단계로 →'.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;">✓ 워크플로우 완료</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 '← 이전'.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 %>
|
||||
136
app/views/workflow_dashboard/index.html.erb
Normal file
136
app/views/workflow_dashboard/index.html.erb
Normal 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>
|
||||
99
app/views/workflow_dashboard/project.html.erb
Normal file
99
app/views/workflow_dashboard/project.html.erb
Normal file
@ -0,0 +1,99 @@
|
||||
<h2>워크플로우 현황: <%= @project.name %></h2>
|
||||
|
||||
<p>
|
||||
<%= link_to '← 대시보드로'.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>
|
||||
53
app/views/workflows/_form.html.erb
Normal file
53
app/views/workflows/_form.html.erb
Normal 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 %>
|
||||
3
app/views/workflows/edit.html.erb
Normal file
3
app/views/workflows/edit.html.erb
Normal file
@ -0,0 +1,3 @@
|
||||
<h2>워크플로우 수정: <%= @workflow.name %></h2>
|
||||
|
||||
<%= render partial: 'form' %>
|
||||
50
app/views/workflows/index.html.erb
Normal file
50
app/views/workflows/index.html.erb
Normal 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 %>
|
||||
3
app/views/workflows/new.html.erb
Normal file
3
app/views/workflows/new.html.erb
Normal file
@ -0,0 +1,3 @@
|
||||
<h2>새 워크플로우</h2>
|
||||
|
||||
<%= render partial: 'form' %>
|
||||
228
app/views/workflows/show.html.erb
Normal file
228
app/views/workflows/show.html.erb
Normal 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...' %>
|
||||
|
||||
<label>이슈 상태:</label>
|
||||
<%= select_tag 'workflow_step[issue_status_id]',
|
||||
options_from_collection_for_select(IssueStatus.sorted, :id, :name),
|
||||
include_blank: '-- 선택 --', style: 'width: 150px;' %>
|
||||
|
||||
<%= 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
39
config/routes.rb
Normal 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
|
||||
17
db/migrate/001_create_custom_workflows.rb
Normal file
17
db/migrate/001_create_custom_workflows.rb
Normal 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
|
||||
19
db/migrate/002_create_workflow_steps.rb
Normal file
19
db/migrate/002_create_workflow_steps.rb
Normal 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
|
||||
14
db/migrate/003_create_workflow_step_assignees.rb
Normal file
14
db/migrate/003_create_workflow_step_assignees.rb
Normal 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
|
||||
20
db/migrate/004_create_issue_workflow_states.rb
Normal file
20
db/migrate/004_create_issue_workflow_states.rb
Normal 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
|
||||
5
db/migrate/005_add_due_days_to_workflow_steps.rb
Normal file
5
db/migrate/005_add_due_days_to_workflow_steps.rb
Normal 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
16
init.rb
Normal 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
|
||||
6
lib/workflow_engine/hooks.rb
Normal file
6
lib/workflow_engine/hooks.rb
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user