feat: [approvals] 결재선 카드 드래그 앤 드롭 순서 변경

- SortableJS로 결재선 요약 카드 드래그 앤 드롭 지원
- 순서 변경 시 Alpine 데이터 동기화 및 카드 라벨 자동 갱신
- hover/grab/ghost/chosen 시각 피드백 CSS 추가
- 2명 이상 시 '드래그하여 순서를 변경할 수 있습니다' 힌트 표시
- CSS ::after로 카드 간 화살표 표시 (드래그 시 자연스럽게 이동)
This commit is contained in:
김보곤
2026-02-28 14:55:15 +09:00
parent 55865155de
commit 367a7bbe56
2 changed files with 114 additions and 12 deletions

View File

@@ -121,6 +121,34 @@ class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm fon
#quill-container .ql-editor { min-height: 260px; font-size: 0.875rem; }
#quill-container .ql-toolbar { border-radius: 0.5rem 0.5rem 0 0; border-color: #d1d5db; }
#quill-container .ql-container { border-radius: 0 0 0.5rem 0.5rem; border-color: #d1d5db; }
#summary-sortable .step-card {
cursor: grab;
position: relative;
margin-right: 24px;
user-select: none;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
#summary-sortable .step-card:last-child { margin-right: 0; }
#summary-sortable .step-card:not(:last-child)::after {
content: '→';
position: absolute;
right: -18px;
top: 50%;
transform: translateY(-50%);
color: #d1d5db;
font-size: 14px;
pointer-events: none;
}
#summary-sortable .step-card:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
transform: translateY(-1px);
}
#summary-sortable .step-card:active { cursor: grabbing; }
#summary-sortable .step-card.sortable-ghost { opacity: 0.3; }
#summary-sortable .step-card.sortable-chosen {
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
transform: translateY(-2px);
}
</style>
@endpush
@@ -128,6 +156,7 @@ class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm fon
<script src="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.js"></script>
<script>
let quillInstance = null;
var summarySortableInstance = null;
function toggleEditor() {
const useEditor = document.getElementById('useEditor').checked;
@@ -200,6 +229,8 @@ function updateApprovalLineSummary() {
const steps = editorEl._x_dataStack[0].steps;
const summaryEl = document.getElementById('approval-line-summary');
if (summarySortableInstance) { summarySortableInstance.destroy(); summarySortableInstance = null; }
if (!steps || steps.length === 0) {
summaryEl.className = 'p-3 bg-gray-50 rounded-lg border border-gray-200 flex items-center';
summaryEl.innerHTML = '<span class="text-sm text-gray-400">결재선이 설정되지 않았습니다.</span>';
@@ -221,12 +252,8 @@ function updateApprovalLineSummary() {
var stepLabel = s.step_type === 'reference' ? label : count + '차 ' + label;
var position = s.position || '';
if (i > 0) {
cards.push('<span class="text-gray-300 flex items-center mx-1">&rarr;</span>');
}
cards.push(
'<div class="text-center px-3 py-2 rounded-lg border ' + bg + '" style="min-width: 72px;">' +
'<div class="step-card text-center px-3 py-2 rounded-lg border ' + bg + '" data-index="' + i + '" style="min-width: 72px;">' +
'<div class="text-xs font-medium ' + labelColor + '">' + stepLabel + '</div>' +
(position ? '<div class="text-xs text-gray-400 mt-0.5">' + position + '</div>' : '') +
'<div class="text-sm font-semibold text-gray-800 mt-0.5">' + s.user_name + '</div>' +
@@ -235,7 +262,31 @@ function updateApprovalLineSummary() {
});
summaryEl.className = 'p-3 bg-gray-50 rounded-lg border border-gray-200';
summaryEl.innerHTML = '<div class="flex flex-wrap items-center gap-1">' + cards.join('') + '</div>';
summaryEl.innerHTML = '<div id="summary-sortable" class="flex flex-wrap items-center">' + cards.join('') + '</div>' +
(steps.length > 1 ? '<div class="text-xs text-gray-400 mt-2">드래그하여 순서를 변경할 수 있습니다</div>' : '');
initSummarySortable();
}
function initSummarySortable() {
if (summarySortableInstance) { summarySortableInstance.destroy(); summarySortableInstance = null; }
var el = document.getElementById('summary-sortable');
if (!el || typeof Sortable === 'undefined') return;
summarySortableInstance = Sortable.create(el, {
animation: 150,
ghostClass: 'sortable-ghost',
chosenClass: 'sortable-chosen',
onEnd: function(evt) {
if (evt.oldIndex === evt.newIndex) return;
var editorEl = document.getElementById('approval-line-editor');
if (!editorEl || !editorEl._x_dataStack) return;
var steps = editorEl._x_dataStack[0].steps;
var item = steps.splice(evt.oldIndex, 1)[0];
steps.splice(evt.newIndex, 0, item);
updateApprovalLineSummary();
}
});
}
document.addEventListener('keydown', function(e) {

View File

@@ -155,6 +155,34 @@ class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm fon
#quill-container .ql-editor { min-height: 260px; font-size: 0.875rem; }
#quill-container .ql-toolbar { border-radius: 0.5rem 0.5rem 0 0; border-color: #d1d5db; }
#quill-container .ql-container { border-radius: 0 0 0.5rem 0.5rem; border-color: #d1d5db; }
#summary-sortable .step-card {
cursor: grab;
position: relative;
margin-right: 24px;
user-select: none;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
#summary-sortable .step-card:last-child { margin-right: 0; }
#summary-sortable .step-card:not(:last-child)::after {
content: '→';
position: absolute;
right: -18px;
top: 50%;
transform: translateY(-50%);
color: #d1d5db;
font-size: 14px;
pointer-events: none;
}
#summary-sortable .step-card:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
transform: translateY(-1px);
}
#summary-sortable .step-card:active { cursor: grabbing; }
#summary-sortable .step-card.sortable-ghost { opacity: 0.3; }
#summary-sortable .step-card.sortable-chosen {
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
transform: translateY(-2px);
}
</style>
@endpush
@@ -162,6 +190,7 @@ class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm fon
<script src="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.js"></script>
<script>
let quillInstance = null;
var summarySortableInstance = null;
function toggleEditor() {
const useEditor = document.getElementById('useEditor').checked;
@@ -234,6 +263,8 @@ function updateApprovalLineSummary() {
const steps = editorEl._x_dataStack[0].steps;
const summaryEl = document.getElementById('approval-line-summary');
if (summarySortableInstance) { summarySortableInstance.destroy(); summarySortableInstance = null; }
if (!steps || steps.length === 0) {
summaryEl.className = 'p-3 bg-gray-50 rounded-lg border border-gray-200 flex items-center';
summaryEl.innerHTML = '<span class="text-sm text-gray-400">결재선이 설정되지 않았습니다.</span>';
@@ -255,12 +286,8 @@ function updateApprovalLineSummary() {
var stepLabel = s.step_type === 'reference' ? label : count + '차 ' + label;
var position = s.position || '';
if (i > 0) {
cards.push('<span class="text-gray-300 flex items-center mx-1">&rarr;</span>');
}
cards.push(
'<div class="text-center px-3 py-2 rounded-lg border ' + bg + '" style="min-width: 72px;">' +
'<div class="step-card text-center px-3 py-2 rounded-lg border ' + bg + '" data-index="' + i + '" style="min-width: 72px;">' +
'<div class="text-xs font-medium ' + labelColor + '">' + stepLabel + '</div>' +
(position ? '<div class="text-xs text-gray-400 mt-0.5">' + position + '</div>' : '') +
'<div class="text-sm font-semibold text-gray-800 mt-0.5">' + s.user_name + '</div>' +
@@ -269,7 +296,31 @@ function updateApprovalLineSummary() {
});
summaryEl.className = 'p-3 bg-gray-50 rounded-lg border border-gray-200';
summaryEl.innerHTML = '<div class="flex flex-wrap items-center gap-1">' + cards.join('') + '</div>';
summaryEl.innerHTML = '<div id="summary-sortable" class="flex flex-wrap items-center">' + cards.join('') + '</div>' +
(steps.length > 1 ? '<div class="text-xs text-gray-400 mt-2">드래그하여 순서를 변경할 수 있습니다</div>' : '');
initSummarySortable();
}
function initSummarySortable() {
if (summarySortableInstance) { summarySortableInstance.destroy(); summarySortableInstance = null; }
var el = document.getElementById('summary-sortable');
if (!el || typeof Sortable === 'undefined') return;
summarySortableInstance = Sortable.create(el, {
animation: 150,
ghostClass: 'sortable-ghost',
chosenClass: 'sortable-chosen',
onEnd: function(evt) {
if (evt.oldIndex === evt.newIndex) return;
var editorEl = document.getElementById('approval-line-editor');
if (!editorEl || !editorEl._x_dataStack) return;
var steps = editorEl._x_dataStack[0].steps;
var item = steps.splice(evt.oldIndex, 1)[0];
steps.splice(evt.newIndex, 0, item);
updateApprovalLineSummary();
}
});
}
document.addEventListener('keydown', function(e) {