From e67fb92189f1a2adb131d080cc19ea0ff3090a90 Mon Sep 17 00:00:00 2001 From: ioresponse Date: Tue, 23 Dec 2025 00:16:43 +0900 Subject: [PATCH] Initial commit: Redmine Workflow Engine Plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/controllers/issue_workflows_controller.rb | 177 +++++++++++ .../workflow_dashboard_controller.rb | 92 ++++++ app/controllers/workflow_steps_controller.rb | 91 ++++++ app/controllers/workflows_controller.rb | 97 ++++++ app/models/custom_workflow.rb | 41 +++ app/models/issue_workflow_state.rb | 111 +++++++ app/models/workflow_step.rb | 60 ++++ app/models/workflow_step_assignee.rb | 41 +++ app/views/issue_workflows/show.html.erb | 98 ++++++ app/views/issues/_workflow_status.html.erb | 284 ++++++++++++++++++ app/views/workflow_dashboard/index.html.erb | 136 +++++++++ app/views/workflow_dashboard/project.html.erb | 99 ++++++ app/views/workflows/_form.html.erb | 53 ++++ app/views/workflows/edit.html.erb | 3 + app/views/workflows/index.html.erb | 50 +++ app/views/workflows/new.html.erb | 3 + app/views/workflows/show.html.erb | 228 ++++++++++++++ config/routes.rb | 39 +++ db/migrate/001_create_custom_workflows.rb | 17 ++ db/migrate/002_create_workflow_steps.rb | 19 ++ .../003_create_workflow_step_assignees.rb | 14 + .../004_create_issue_workflow_states.rb | 20 ++ .../005_add_due_days_to_workflow_steps.rb | 5 + init.rb | 16 + lib/workflow_engine/hooks.rb | 6 + 25 files changed, 1800 insertions(+) create mode 100644 app/controllers/issue_workflows_controller.rb create mode 100644 app/controllers/workflow_dashboard_controller.rb create mode 100644 app/controllers/workflow_steps_controller.rb create mode 100644 app/controllers/workflows_controller.rb create mode 100644 app/models/custom_workflow.rb create mode 100644 app/models/issue_workflow_state.rb create mode 100644 app/models/workflow_step.rb create mode 100644 app/models/workflow_step_assignee.rb create mode 100644 app/views/issue_workflows/show.html.erb create mode 100644 app/views/issues/_workflow_status.html.erb create mode 100644 app/views/workflow_dashboard/index.html.erb create mode 100644 app/views/workflow_dashboard/project.html.erb create mode 100644 app/views/workflows/_form.html.erb create mode 100644 app/views/workflows/edit.html.erb create mode 100644 app/views/workflows/index.html.erb create mode 100644 app/views/workflows/new.html.erb create mode 100644 app/views/workflows/show.html.erb create mode 100644 config/routes.rb create mode 100644 db/migrate/001_create_custom_workflows.rb create mode 100644 db/migrate/002_create_workflow_steps.rb create mode 100644 db/migrate/003_create_workflow_step_assignees.rb create mode 100644 db/migrate/004_create_issue_workflow_states.rb create mode 100644 db/migrate/005_add_due_days_to_workflow_steps.rb create mode 100644 init.rb create mode 100644 lib/workflow_engine/hooks.rb diff --git a/app/controllers/issue_workflows_controller.rb b/app/controllers/issue_workflows_controller.rb new file mode 100644 index 0000000..0ace452 --- /dev/null +++ b/app/controllers/issue_workflows_controller.rb @@ -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 diff --git a/app/controllers/workflow_dashboard_controller.rb b/app/controllers/workflow_dashboard_controller.rb new file mode 100644 index 0000000..3385645 --- /dev/null +++ b/app/controllers/workflow_dashboard_controller.rb @@ -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 diff --git a/app/controllers/workflow_steps_controller.rb b/app/controllers/workflow_steps_controller.rb new file mode 100644 index 0000000..cfe1006 --- /dev/null +++ b/app/controllers/workflow_steps_controller.rb @@ -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 diff --git a/app/controllers/workflows_controller.rb b/app/controllers/workflows_controller.rb new file mode 100644 index 0000000..d0bf809 --- /dev/null +++ b/app/controllers/workflows_controller.rb @@ -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 diff --git a/app/models/custom_workflow.rb b/app/models/custom_workflow.rb new file mode 100644 index 0000000..1e76ce7 --- /dev/null +++ b/app/models/custom_workflow.rb @@ -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 diff --git a/app/models/issue_workflow_state.rb b/app/models/issue_workflow_state.rb new file mode 100644 index 0000000..8f2ac7e --- /dev/null +++ b/app/models/issue_workflow_state.rb @@ -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 diff --git a/app/models/workflow_step.rb b/app/models/workflow_step.rb new file mode 100644 index 0000000..eb2649b --- /dev/null +++ b/app/models/workflow_step.rb @@ -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 diff --git a/app/models/workflow_step_assignee.rb b/app/models/workflow_step_assignee.rb new file mode 100644 index 0000000..35a9a7f --- /dev/null +++ b/app/models/workflow_step_assignee.rb @@ -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 diff --git a/app/views/issue_workflows/show.html.erb b/app/views/issue_workflows/show.html.erb new file mode 100644 index 0000000..50fea0a --- /dev/null +++ b/app/views/issue_workflows/show.html.erb @@ -0,0 +1,98 @@ +
+

μ›Œν¬ν”Œλ‘œμš° μ§„ν–‰ μƒνƒœ

+ +

+ μ›Œν¬ν”Œλ‘œμš°: <%= @workflow_state.custom_workflow.name %> + <% if @workflow_state.custom_workflow.description.present? %> +
<%= @workflow_state.custom_workflow.description %> + <% end %> +

+ +
+ <% @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 %> + +
+ <%= step.name %> + <% if step.is_start %> + (μ‹œμž‘) + <% end %> + <% if step.is_end %> + (μ’…λ£Œ) + <% end %> +
+ + <% if idx < @steps.length - 1 %> + + <% end %> + <% end %> +
+ + <% if @current_step %> +

+ ν˜„μž¬ 단계: <%= @current_step.name %> +

+ + <% if @current_step.workflow_step_assignees.any? %> +

+ λ‹΄λ‹Ήμž: + <% @current_step.workflow_step_assignees.each do |assignee| %> + + <%= assignee.assignee_name %> + + <% end %> +

+ <% end %> + +
+ <% 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 %> + λ‹€μŒ λ‹¨κ³„λ‘œ → (κΆŒν•œ μ—†μŒ) + <% 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 %> +
+ <% else %> +

μ›Œν¬ν”Œλ‘œμš° 단계가 μ„€μ •λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.

+ <% end %> + + <% if @workflow_state.started_at %> +

+ μ‹œμž‘μΌ: <%= @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 %> +

+ <% end %> +
diff --git a/app/views/issues/_workflow_status.html.erb b/app/views/issues/_workflow_status.html.erb new file mode 100644 index 0000000..6324aba --- /dev/null +++ b/app/views/issues/_workflow_status.html.erb @@ -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 + %> +
+

+ + μ›Œν¬ν”Œλ‘œμš°: <%= workflow_state.custom_workflow.name %> +

+ +
+ <% 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 + %> + +
+ <%= step.name %> +
+ + <% if idx < steps.length - 1 %> + + <% end %> + <% end %> +
+ + <% if current_step %> +
+ ν˜„μž¬ 단계: <%= current_step.name %> + + <% if current_step.workflow_step_assignees.any? %> +  |  + λ‹΄λ‹Ήμž: + <% current_step.workflow_step_assignees.each do |assignee| %> + + <%= assignee.assignee_name %> + + <% end %> + <% end %> + + <% if current_step.is_start && @issue.editable?(User.current) %> +  |  + 이슈 νŽΈμ§‘ + <% end %> +
+ + <% + # λ‹€μŒ 단계 정보 확인 + 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 : [] + %> +
+ <% unless current_step.is_end %> + <% if workflow_state.can_proceed?(User.current) %> + <% if next_step_has_group && next_step_group_members.count > 1 %> + + λ‹€μŒ λ‹¨κ³„λ‘œ → + + <% 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 %> + + λ‹€μŒ λ‹¨κ³„λ‘œ (κΆŒν•œ μ—†μŒ) + + <% end %> + <% else %> + ✓ μ›Œν¬ν”Œλ‘œμš° μ™„λ£Œ + <% 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)) %> + + 반렀 + + <% end %> + + <% if User.current.admin? %> + + 단계 이동 + + <% end %> +
+ + <% 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 + %> +
+ <% if remaining < 0 %> + + κΈ°ν•œ 초과 <%= remaining.abs %>일 + + <% elsif remaining == 0 %> + + 였늘 마감 + + <% else %> + + 남은 κΈ°ν•œ: <%= remaining %>일 (<%= due_date.strftime('%Y-%m-%d') %>) + + <% end %> +
+ <% end %> + <% end %> +
+ + + + + + <% if next_step_has_group && next_step_group_members.count > 1 %> + + <% end %> + + + <% if User.current.admin? %> + + <% end %> + + +<% end %> diff --git a/app/views/workflow_dashboard/index.html.erb b/app/views/workflow_dashboard/index.html.erb new file mode 100644 index 0000000..29f2a92 --- /dev/null +++ b/app/views/workflow_dashboard/index.html.erb @@ -0,0 +1,136 @@ +

μ›Œν¬ν”Œλ‘œμš° λŒ€μ‹œλ³΄λ“œ

+ + +
+
+
<%= @total_stats[:active_workflows] %>
+
ν™œμ„± μ›Œν¬ν”Œλ‘œμš°
+
+
+
<%= @total_stats[:issues_in_workflow] %>
+
μ§„ν–‰ 쀑인 이슈
+
+
+
<%= @total_stats[:completed_today] %>
+
였늘 μ™„λ£Œ
+
+
+ +
+ +
+
+

ν”„λ‘œμ νŠΈλ³„ ν˜„ν™©

+ <% if @project_stats.any? %> + + + + + + + + + + + <% @project_stats.each do |stat| %> + + + + + + + <% end %> + +
ν”„λ‘œμ νŠΈμ§„ν–‰ μ€‘μ™„λ£Œ
<%= link_to stat[:project].name, project_path(stat[:project]) %> + + <%= stat[:in_progress] %> + + + + <%= stat[:completed] %> + + + <%= link_to '상세', workflow_dashboard_project_path(stat[:project]), class: 'button', style: 'padding: 2px 8px; font-size: 11px;' %> +
+ <% else %> +

μ›Œν¬ν”Œλ‘œμš°κ°€ 적용된 μ΄μŠˆκ°€ μ—†μŠ΅λ‹ˆλ‹€.

+ <% end %> +
+
+ + +
+
+

λ‚΄ λ‹΄λ‹Ή 이슈

+ <% if @my_issues.any? %> + + + + + + + + + + <% @my_issues.each do |state| %> + + + + + + <% end %> + +
μ΄μŠˆν˜„μž¬ 단계
+ <%= link_to "##{state.issue.id}", issue_path(state.issue) %> + <%= truncate(state.issue.subject, length: 30) %> + + + <%= state.current_step&.name %> + + + <%= link_to '처리', issue_path(state.issue), class: 'button button-positive', style: 'padding: 2px 8px; font-size: 11px;' %> +
+ <% else %> +

λ‹΄λ‹Ή μ΄μŠˆκ°€ μ—†μŠ΅λ‹ˆλ‹€.

+ <% end %> +
+ + + <% if @overdue_issues.any? %> +
+

μ§€μ—°λœ 이슈

+ + + + + + + + + + <% @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 + %> + + + + + + <% end %> + +
μ΄μŠˆν˜„μž¬ 단계지연일
+ <%= link_to "##{state.issue.id}", issue_path(state.issue) %> + <%= truncate(state.issue.subject, length: 25) %> + <%= state.current_step&.name %><%= overdue_days %>일
+
+ <% end %> +
+
+ +
+

+ <%= link_to 'μ›Œν¬ν”Œλ‘œμš° 관리', '/custom_workflows', class: 'icon icon-settings' %> +

diff --git a/app/views/workflow_dashboard/project.html.erb b/app/views/workflow_dashboard/project.html.erb new file mode 100644 index 0000000..1fbcbd7 --- /dev/null +++ b/app/views/workflow_dashboard/project.html.erb @@ -0,0 +1,99 @@ +

μ›Œν¬ν”Œλ‘œμš° ν˜„ν™©: <%= @project.name %>

+ +

+ <%= link_to '← λŒ€μ‹œλ³΄λ“œλ‘œ'.html_safe, workflow_dashboard_path, class: 'icon icon-back' %> +

+ + +
+

단계별 이슈 ν˜„ν™©

+ + <% if @steps_summary.any? %> +
+ <% @steps_summary.each do |step_name, states| %> +
+
+ <%= step_name %> + + <%= states.count %>건 + +
+ +
+ <% states.each do |state| %> +
+
+ <%= link_to "##{state.issue.id}", issue_path(state.issue), style: 'font-weight: bold;' %> + <%= truncate(state.issue.subject, length: 30) %> +
+
+ λ‹΄λ‹Ή: <%= 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 %> + | μ§€μ—° <%= remaining.abs %>일 + <% elsif remaining == 0 %> + | 였늘 마감 + <% else %> + | 남은 κΈ°ν•œ: <%= remaining %>일 + <% end %> + <% end %> +
+
+ <% end %> +
+
+ <% end %> +
+ <% else %> +

μ§„ν–‰ 쀑인 μ›Œν¬ν”Œλ‘œμš° μ΄μŠˆκ°€ μ—†μŠ΅λ‹ˆλ‹€.

+ <% end %> +
+ + +
+

전체 μ›Œν¬ν”Œλ‘œμš° 이슈

+ + <% if @workflow_states.any? %> + + + + + + + + + + + + + + <% @workflow_states.each do |state| %> + + + + + + + + + + <% end %> + +
#제λͺ©μ›Œν¬ν”Œλ‘œμš°ν˜„μž¬ λ‹¨κ³„λ‹΄λ‹Ήμžμƒνƒœκ°±μ‹ μΌ
<%= link_to "##{state.issue.id}", issue_path(state.issue) %><%= link_to state.issue.subject, issue_path(state.issue) %><%= state.custom_workflow&.name %> + <% if state.completed_at %> + μ™„λ£Œ + <% else %> + + <%= state.current_step&.name %> + + <% end %> + <%= state.issue.assigned_to&.name || '-' %><%= state.issue.status&.name %><%= format_time(state.updated_at) %>
+ <% else %> +

μ›Œν¬ν”Œλ‘œμš° μ΄μŠˆκ°€ μ—†μŠ΅λ‹ˆλ‹€.

+ <% end %> +
diff --git a/app/views/workflows/_form.html.erb b/app/views/workflows/_form.html.erb new file mode 100644 index 0000000..6e62395 --- /dev/null +++ b/app/views/workflows/_form.html.erb @@ -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? %> +
+ +
+ <% end %> + +

+ + <%= f.text_field :name, size: 50, required: true %> +

+ +

+ + <%= f.text_area :description, rows: 3, cols: 60 %> +

+ +

+ + <%= f.collection_select :project_id, Project.all.order(:name), :id, :name, + { include_blank: '-- λͺ¨λ“  ν”„λ‘œμ νŠΈ --' }, { style: 'width: 300px;' } %> +
+ νŠΉμ • ν”„λ‘œμ νŠΈμ—μ„œλ§Œ μ‚¬μš©ν•˜λ €λ©΄ μ„ νƒν•˜μ„Έμš”. +

+ +

+ + <%= f.collection_select :tracker_id, Tracker.all.order(:position), :id, :name, + { include_blank: '-- λͺ¨λ“  트래컀 --' }, { style: 'width: 300px;' } %> +
+ νŠΉμ • νŠΈλž˜μ»€μ—μ„œλ§Œ μ‚¬μš©ν•˜λ €λ©΄ μ„ νƒν•˜μ„Έμš”. +

+ +

+ + <%= f.check_box :is_default %> + ν”„λ‘œμ νŠΈ/νŠΈλž˜μ»€κ°€ μΌμΉ˜ν•˜λŠ” μ΄μŠˆμ— μžλ™ μ μš©λ©λ‹ˆλ‹€. +

+ +

+ + <%= f.check_box :active %> +

+ +

+ <%= submit_tag @workflow.new_record? ? '생성' : 'μ €μž₯', class: 'button-positive' %> + <%= link_to 'μ·¨μ†Œ', '/custom_workflows', class: 'button' %> +

+<% end %> diff --git a/app/views/workflows/edit.html.erb b/app/views/workflows/edit.html.erb new file mode 100644 index 0000000..0bc22d2 --- /dev/null +++ b/app/views/workflows/edit.html.erb @@ -0,0 +1,3 @@ +

μ›Œν¬ν”Œλ‘œμš° μˆ˜μ •: <%= @workflow.name %>

+ +<%= render partial: 'form' %> diff --git a/app/views/workflows/index.html.erb b/app/views/workflows/index.html.erb new file mode 100644 index 0000000..c29a8c8 --- /dev/null +++ b/app/views/workflows/index.html.erb @@ -0,0 +1,50 @@ +

μ›Œν¬ν”Œλ‘œμš° 관리

+ +

+ <%= link_to 'μƒˆ μ›Œν¬ν”Œλ‘œμš°', new_custom_workflow_path, class: 'icon icon-add' %> +

+ +<% if @workflows.any? %> + + + + + + + + + + + + + <% @workflows.each do |wf| %> + + + + + + + + + <% end %> + +
μ›Œν¬ν”Œλ‘œμš°λͺ…ν”„λ‘œμ νŠΈνŠΈλž˜μ»€λ‹¨κ³„ μˆ˜μƒνƒœμž‘μ—…
+ <%= link_to wf.name, custom_workflow_path(wf) %> + <% if wf.is_default %> + κΈ°λ³Έ + <% end %> + <%= wf.project&.name || '(λͺ¨λ“  ν”„λ‘œμ νŠΈ)' %><%= wf.tracker&.name || '(λͺ¨λ“  트래컀)' %><%= wf.step_count %>개 + <% if wf.active %> + ν™œμ„± + <% else %> + λΉ„ν™œμ„± + <% end %> + + <%= 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' %> +
+<% else %> +

λ“±λ‘λœ μ›Œν¬ν”Œλ‘œμš°κ°€ μ—†μŠ΅λ‹ˆλ‹€.

+<% end %> diff --git a/app/views/workflows/new.html.erb b/app/views/workflows/new.html.erb new file mode 100644 index 0000000..0347750 --- /dev/null +++ b/app/views/workflows/new.html.erb @@ -0,0 +1,3 @@ +

μƒˆ μ›Œν¬ν”Œλ‘œμš°

+ +<%= render partial: 'form' %> diff --git a/app/views/workflows/show.html.erb b/app/views/workflows/show.html.erb new file mode 100644 index 0000000..ec9c5bf --- /dev/null +++ b/app/views/workflows/show.html.erb @@ -0,0 +1,228 @@ +

<%= @workflow.name %>

+ +<% if @workflow.description.present? %> +

<%= @workflow.description %>

+<% end %> + +

+ ν”„λ‘œμ νŠΈ: <%= @workflow.project&.name || 'λͺ¨λ“  ν”„λ‘œμ νŠΈ' %> | + 트래컀: <%= @workflow.tracker&.name || 'λͺ¨λ“  트래컀' %> | + μƒνƒœ: <%= @workflow.active ? 'ν™œμ„±' : 'λΉ„ν™œμ„±' %> + <% if @workflow.is_default %> + | κΈ°λ³Έ μ›Œν¬ν”Œλ‘œμš° + <% end %> +

+ +
+ +

μ›Œν¬ν”Œλ‘œμš° 단계

+ +
+ <%= form_tag custom_workflow_steps_path(@workflow), method: :post, class: 'tabular' do %> +

+ + <%= text_field_tag 'workflow_step[name]', '', size: 20, required: true, placeholder: '예: 개발, QA...' %> +   + + <%= 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' %> +

+ <% end %> +
+ +<% if @steps.any? %> + + + + + + + + + + + + + <% @steps.each_with_index do |step, idx| %> + + + + + + + + + <% end %> + +
μˆœμ„œλ‹¨κ³„λͺ…μ΄μŠˆ μƒνƒœκΈ°ν•œ(일)λ‹΄λ‹Ήμžμž‘μ—…
+ <%= idx + 1 %> + <% if step.is_start %> +
μ‹œμž‘ + <% end %> + <% if step.is_end %> +
μ’…λ£Œ + <% end %> +
+ <%= step.name %> + <% if step.description.present? %> +
<%= step.description %> + <% end %> +
+ <%= 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 %> + + <%= 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 %> + + <% if step.workflow_step_assignees.any? %> + <% step.workflow_step_assignees.each do |assignee| %> + + <%= assignee.assignee_name %> + <%= link_to 'Γ—', step_assignee_path(step_id: step.id, id: assignee.id), + method: :delete, style: 'color: #dc3545; margin-left: 5px;', title: '제거' %> + + <% end %> + <% else %> + λ‹΄λ‹Ήμž μ—†μŒ + <% end %> +
+ + λ‹΄λ‹Ήμž μΆ”κ°€ +
+ <%= 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' %> +
+<% else %> +

단계가 μ—†μŠ΅λ‹ˆλ‹€. μœ„μ—μ„œ 단계λ₯Ό μΆ”κ°€ν•˜μ„Έμš”.

+<% end %> + +
+ +

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

+ + + + +<%= 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 = '

κ²°κ³Ό μ—†μŒ

'; + } else { + html = ''; + data.forEach(function(item) { + var label = item.name; + if (item.count !== undefined) { + label += ' (' + item.count + 'λͺ…)'; + } + if (item.login) { + label += ' - ' + item.login; + } + html += ''; + html += ''; + html += ''; + html += ''; + }); + html += '
' + label + ''; + html += '
'; + html += ''; + html += ''; + html += ''; + html += ''; + html += '
'; + html += '
'; + } + 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 %> diff --git a/config/routes.rb b/config/routes.rb new file mode 100644 index 0000000..ffff4f1 --- /dev/null +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/001_create_custom_workflows.rb b/db/migrate/001_create_custom_workflows.rb new file mode 100644 index 0000000..a491ae8 --- /dev/null +++ b/db/migrate/001_create_custom_workflows.rb @@ -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 diff --git a/db/migrate/002_create_workflow_steps.rb b/db/migrate/002_create_workflow_steps.rb new file mode 100644 index 0000000..fd8cdc7 --- /dev/null +++ b/db/migrate/002_create_workflow_steps.rb @@ -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 diff --git a/db/migrate/003_create_workflow_step_assignees.rb b/db/migrate/003_create_workflow_step_assignees.rb new file mode 100644 index 0000000..76e9116 --- /dev/null +++ b/db/migrate/003_create_workflow_step_assignees.rb @@ -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 diff --git a/db/migrate/004_create_issue_workflow_states.rb b/db/migrate/004_create_issue_workflow_states.rb new file mode 100644 index 0000000..37b7290 --- /dev/null +++ b/db/migrate/004_create_issue_workflow_states.rb @@ -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 diff --git a/db/migrate/005_add_due_days_to_workflow_steps.rb b/db/migrate/005_add_due_days_to_workflow_steps.rb new file mode 100644 index 0000000..f55b5fe --- /dev/null +++ b/db/migrate/005_add_due_days_to_workflow_steps.rb @@ -0,0 +1,5 @@ +class AddDueDaysToWorkflowSteps < ActiveRecord::Migration[6.1] + def change + add_column :workflow_steps, :due_days, :integer, default: nil + end +end diff --git a/init.rb b/init.rb new file mode 100644 index 0000000..f97650d --- /dev/null +++ b/init.rb @@ -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 diff --git a/lib/workflow_engine/hooks.rb b/lib/workflow_engine/hooks.rb new file mode 100644 index 0000000..c51849d --- /dev/null +++ b/lib/workflow_engine/hooks.rb @@ -0,0 +1,6 @@ +module WorkflowEngine + class Hooks < Redmine::Hook::ViewListener + # 이슈 상세 νŽ˜μ΄μ§€ 상단에 μ›Œν¬ν”Œλ‘œμš° μ§„ν–‰ μƒνƒœ ν‘œμ‹œ + render_on :view_issues_show_description_bottom, partial: 'issues/workflow_status' + end +end