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
285 lines
12 KiB
Plaintext
285 lines
12 KiB
Plaintext
<%
|
|
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 %>
|