style: [approvals] 결재선 관리 모달 Toss 스타일 리디자인
- CSS 변수 기반 Toss 디자인 시스템 적용 - backdrop blur + slide-up 애니메이션 - 카드 기반 결재선 목록 (arrow flow 표시) - 커스텀 step type select, pill 버튼 - 모달/인풋/버튼 전체 톤앤매너 통일
This commit is contained in:
@@ -8,10 +8,11 @@
|
||||
<h1 class="text-2xl font-bold text-gray-800">기안함</h1>
|
||||
<div class="flex gap-2 w-full sm:w-auto">
|
||||
<button onclick="openLineManager()"
|
||||
class="bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-2 rounded-lg transition text-center flex-1 sm:flex-none">
|
||||
class="toss-btn-ghost flex-1 sm:flex-none">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
결재선 관리
|
||||
</button>
|
||||
<a href="{{ route('approvals.create') }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition text-center flex-1 sm:flex-none">
|
||||
<a href="{{ route('approvals.create') }}" class="toss-btn-primary flex-1 sm:flex-none">
|
||||
+ 새 기안
|
||||
</a>
|
||||
</div>
|
||||
@@ -50,40 +51,41 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
|
||||
<!-- 페이지네이션 -->
|
||||
<div id="pagination-area" class="mt-4"></div>
|
||||
|
||||
<!-- 결재선 관리 모달 -->
|
||||
<!-- 결재선 관리 모달 (Toss Style) -->
|
||||
<div id="lineManagerModal" class="fixed inset-0 z-50 hidden">
|
||||
<div class="fixed inset-0 bg-black/50" onclick="closeLineManager()"></div>
|
||||
<div class="fixed inset-0 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-xl shadow-2xl w-full relative" style="max-width: 720px; max-height: 90vh; display: flex; flex-direction: column;">
|
||||
<div class="toss-backdrop" onclick="closeLineManager()"></div>
|
||||
<div class="fixed inset-0 flex items-center justify-center p-4" style="pointer-events: none;">
|
||||
<div class="toss-modal" style="pointer-events: auto;">
|
||||
<!-- 모달 헤더 -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 shrink-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<button id="lineBackBtn" onclick="backToLineList()" class="hidden p-1 hover:bg-gray-100 rounded-lg transition">
|
||||
<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
<div class="toss-modal-header">
|
||||
<div class="flex items-center gap-2">
|
||||
<button id="lineBackBtn" onclick="backToLineList()" class="hidden toss-icon-btn">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<h2 id="lineManagerTitle" class="text-lg font-bold text-gray-800">결재선 관리</h2>
|
||||
<h2 id="lineManagerTitle" class="toss-modal-title">결재선 관리</h2>
|
||||
</div>
|
||||
<button onclick="closeLineManager()" class="p-1 hover:bg-gray-100 rounded-lg transition">
|
||||
<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
<button onclick="closeLineManager()" class="toss-icon-btn">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 모달 바디 -->
|
||||
<div class="flex-1 overflow-y-auto" style="min-height: 0;">
|
||||
<div class="toss-modal-body">
|
||||
<!-- 목록 화면 -->
|
||||
<div id="lineListView">
|
||||
<div class="p-4">
|
||||
<button onclick="openLineEdit()" class="w-full bg-blue-50 hover:bg-blue-100 text-blue-600 font-medium px-4 py-3 rounded-lg transition border border-blue-200 border-dashed text-sm">
|
||||
+ 새 결재선 만들기
|
||||
<div style="padding: 20px 20px 12px;">
|
||||
<button onclick="openLineEdit()" class="toss-add-btn">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/></svg>
|
||||
새 결재선 만들기
|
||||
</button>
|
||||
</div>
|
||||
<div id="lineListBody" class="px-4 pb-4 space-y-2">
|
||||
<div class="flex justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<div id="lineListBody" style="padding: 0 20px 20px;">
|
||||
<div class="flex justify-center" style="padding: 40px 0;">
|
||||
<div class="toss-spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -91,62 +93,303 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
|
||||
<!-- 편집 화면 -->
|
||||
<div id="lineEditView" class="hidden">
|
||||
<!-- 이름 입력 -->
|
||||
<div class="p-4 border-b border-gray-100">
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1">결재선 이름</label>
|
||||
<div style="padding: 20px 20px 16px;">
|
||||
<label class="toss-label">결재선 이름</label>
|
||||
<input type="text" id="lineNameInput" placeholder="예: 일반 결재선, 팀장 결재선..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
class="toss-input">
|
||||
</div>
|
||||
|
||||
<!-- 2패널 구조 -->
|
||||
<div class="flex" style="min-height: 360px;">
|
||||
<div class="flex" style="min-height: 340px; border-top: 1px solid #f2f4f6;">
|
||||
<!-- 좌측: 인원 목록 -->
|
||||
<div class="border-r border-gray-200" style="flex: 0 0 250px; max-width: 250px;">
|
||||
<div class="p-3 border-b border-gray-100">
|
||||
<div style="flex: 0 0 240px; max-width: 240px; border-right: 1px solid #f2f4f6;">
|
||||
<div style="padding: 12px;">
|
||||
<div class="relative">
|
||||
<svg class="absolute left-2.5 top-2.5 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="absolute" style="left: 10px; top: 9px; width: 16px; height: 16px; color: #b0b8c1;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<input type="text" id="lineUserSearch" placeholder="이름/부서 검색..."
|
||||
class="w-full pl-8 pr-3 py-2 border border-gray-300 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<input type="text" id="lineUserSearch" placeholder="이름, 부서 검색"
|
||||
class="toss-input-sm" style="padding-left: 32px;">
|
||||
</div>
|
||||
</div>
|
||||
<div id="lineDeptList" class="overflow-y-auto" style="max-height: 340px;"></div>
|
||||
<div id="lineDeptList" class="overflow-y-auto" style="max-height: 300px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- 우측: 결재선 -->
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<div id="lineStepList" class="flex-1 overflow-y-auto p-3 space-y-2" style="max-height: 360px;"></div>
|
||||
<div id="lineStepEmpty" class="flex-1 flex flex-col items-center justify-center py-12 text-gray-400">
|
||||
<svg class="w-10 h-10 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
<span class="text-xs">좌측에서 결재자를 추가하세요</span>
|
||||
<div id="lineStepList" class="flex-1 overflow-y-auto" style="padding: 12px; max-height: 340px;"></div>
|
||||
<div id="lineStepEmpty" class="flex-1 flex flex-col items-center justify-center" style="padding: 48px 0;">
|
||||
<div class="toss-empty-icon">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span style="font-size: 13px; color: #8b95a1; margin-top: 12px;">좌측에서 결재자를 추가하세요</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 하단 요약 -->
|
||||
<div class="px-4 py-2 border-t border-gray-200 bg-gray-50 text-xs text-gray-500">
|
||||
<span id="lineSummary">결재: 0명 | 합의: 0명 | 참조: 0명 — 합계 0명</span>
|
||||
<div class="toss-summary-bar">
|
||||
<span id="lineSummary">결재 0명 · 합의 0명 · 참조 0명</span>
|
||||
<span id="lineSummaryTotal" class="toss-summary-total">총 0명</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 모달 푸터 (편집 모드에서만) -->
|
||||
<div id="lineEditFooter" class="hidden px-6 py-3 border-t border-gray-200 flex justify-end gap-2 shrink-0">
|
||||
<button onclick="backToLineList()" class="px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition text-sm">
|
||||
취소
|
||||
</button>
|
||||
<button onclick="saveLine()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition text-sm">
|
||||
저장
|
||||
</button>
|
||||
<div id="lineEditFooter" class="hidden toss-modal-footer">
|
||||
<button onclick="backToLineList()" class="toss-btn-secondary" style="flex: 1;">취소</button>
|
||||
<button onclick="saveLine()" class="toss-btn-primary" style="flex: 2;">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('styles')
|
||||
<style>
|
||||
/* ====== Toss Design System — 결재선 관리 ====== */
|
||||
:root {
|
||||
--toss-blue: #3182f6;
|
||||
--toss-blue-hover: #1b64da;
|
||||
--toss-blue-light: #e8f3ff;
|
||||
--toss-blue-lighter: #f2f7ff;
|
||||
--toss-text-primary: #191f28;
|
||||
--toss-text-secondary: #4e5968;
|
||||
--toss-text-tertiary: #8b95a1;
|
||||
--toss-text-disabled: #b0b8c1;
|
||||
--toss-bg: #f7f8fa;
|
||||
--toss-bg-card: #ffffff;
|
||||
--toss-border: #f2f4f6;
|
||||
--toss-border-hover: #e5e8eb;
|
||||
--toss-green: #00c471;
|
||||
--toss-red: #f04452;
|
||||
--toss-radius: 16px;
|
||||
--toss-radius-sm: 12px;
|
||||
--toss-radius-xs: 8px;
|
||||
--toss-shadow: 0 2px 8px rgba(0,0,0,0.04), 0 0 1px rgba(0,0,0,0.06);
|
||||
--toss-shadow-lg: 0 8px 32px rgba(0,0,0,0.12), 0 0 1px rgba(0,0,0,0.08);
|
||||
--toss-transition: 0.2s cubic-bezier(0.33, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* 버튼 */
|
||||
.toss-btn-primary {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
padding: 10px 20px; border-radius: var(--toss-radius-sm); font-size: 14px; font-weight: 600;
|
||||
background: var(--toss-blue); color: #fff; border: none; cursor: pointer;
|
||||
transition: background var(--toss-transition), transform 0.1s;
|
||||
text-decoration: none; text-align: center; line-height: 1.4;
|
||||
}
|
||||
.toss-btn-primary:hover { background: var(--toss-blue-hover); }
|
||||
.toss-btn-primary:active { transform: scale(0.97); }
|
||||
|
||||
.toss-btn-secondary {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
padding: 10px 20px; border-radius: var(--toss-radius-sm); font-size: 14px; font-weight: 600;
|
||||
background: var(--toss-bg); color: var(--toss-text-secondary); border: none; cursor: pointer;
|
||||
transition: background var(--toss-transition), transform 0.1s;
|
||||
}
|
||||
.toss-btn-secondary:hover { background: #eceef0; }
|
||||
.toss-btn-secondary:active { transform: scale(0.97); }
|
||||
|
||||
.toss-btn-ghost {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
padding: 10px 16px; border-radius: var(--toss-radius-sm); font-size: 14px; font-weight: 500;
|
||||
background: transparent; color: var(--toss-text-secondary); border: 1px solid var(--toss-border-hover);
|
||||
cursor: pointer; transition: all var(--toss-transition);
|
||||
}
|
||||
.toss-btn-ghost:hover { background: var(--toss-bg); border-color: #d1d6db; }
|
||||
|
||||
.toss-icon-btn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 36px; height: 36px; border-radius: 50%; border: none; cursor: pointer;
|
||||
background: transparent; color: var(--toss-text-tertiary);
|
||||
transition: all var(--toss-transition);
|
||||
}
|
||||
.toss-icon-btn:hover { background: var(--toss-bg); color: var(--toss-text-primary); }
|
||||
|
||||
/* 모달 */
|
||||
.toss-backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45); backdrop-filter: blur(4px);
|
||||
animation: toss-fade-in 0.2s ease;
|
||||
}
|
||||
.toss-modal {
|
||||
width: 100%; max-width: 700px; max-height: 88vh;
|
||||
background: var(--toss-bg-card); border-radius: 24px;
|
||||
box-shadow: var(--toss-shadow-lg);
|
||||
display: flex; flex-direction: column;
|
||||
animation: toss-slide-up 0.3s cubic-bezier(0.33, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.toss-modal-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 20px 20px 16px; flex-shrink: 0;
|
||||
}
|
||||
.toss-modal-title {
|
||||
font-size: 18px; font-weight: 700; color: var(--toss-text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.toss-modal-body { flex: 1; overflow-y: auto; min-height: 0; }
|
||||
.toss-modal-footer {
|
||||
display: flex; gap: 8px; padding: 16px 20px;
|
||||
border-top: 1px solid var(--toss-border); flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 새 결재선 버튼 */
|
||||
.toss-add-btn {
|
||||
width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
padding: 14px; border-radius: var(--toss-radius-sm);
|
||||
background: var(--toss-blue-lighter); color: var(--toss-blue);
|
||||
font-size: 14px; font-weight: 600; border: 2px dashed #c2d9f7;
|
||||
cursor: pointer; transition: all var(--toss-transition);
|
||||
}
|
||||
.toss-add-btn:hover { background: var(--toss-blue-light); border-color: #9ec3f5; }
|
||||
|
||||
/* 결재선 카드 */
|
||||
.toss-line-card {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 16px; border-radius: var(--toss-radius-sm);
|
||||
background: var(--toss-bg-card); cursor: pointer;
|
||||
transition: all var(--toss-transition);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.toss-line-card:hover { background: var(--toss-bg); }
|
||||
.toss-line-card + .toss-line-card { margin-top: 4px; }
|
||||
.toss-line-card .toss-card-actions {
|
||||
display: flex; gap: 2px; opacity: 0;
|
||||
transition: opacity var(--toss-transition);
|
||||
}
|
||||
.toss-line-card:hover .toss-card-actions { opacity: 1; }
|
||||
|
||||
/* 결재선 번호 아이콘 */
|
||||
.toss-line-icon {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 40px; height: 40px; border-radius: 12px; flex-shrink: 0;
|
||||
background: var(--toss-blue-light); color: var(--toss-blue);
|
||||
font-size: 14px; font-weight: 700;
|
||||
}
|
||||
|
||||
/* 인풋 */
|
||||
.toss-label {
|
||||
display: block; font-size: 13px; font-weight: 600; color: var(--toss-text-secondary);
|
||||
margin-bottom: 8px; letter-spacing: -0.01em;
|
||||
}
|
||||
.toss-input {
|
||||
width: 100%; padding: 12px 14px; border-radius: var(--toss-radius-xs);
|
||||
border: 1px solid var(--toss-border-hover); font-size: 15px; color: var(--toss-text-primary);
|
||||
background: var(--toss-bg-card); transition: all var(--toss-transition);
|
||||
outline: none;
|
||||
}
|
||||
.toss-input:focus { border-color: var(--toss-blue); box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.12); }
|
||||
.toss-input::placeholder { color: var(--toss-text-disabled); }
|
||||
|
||||
.toss-input-sm {
|
||||
width: 100%; padding: 8px 10px; border-radius: var(--toss-radius-xs);
|
||||
border: 1px solid var(--toss-border-hover); font-size: 13px; color: var(--toss-text-primary);
|
||||
background: var(--toss-bg); outline: none; transition: all var(--toss-transition);
|
||||
}
|
||||
.toss-input-sm:focus { border-color: var(--toss-blue); background: #fff; box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.08); }
|
||||
|
||||
/* 스피너 */
|
||||
.toss-spinner {
|
||||
width: 28px; height: 28px; border: 3px solid var(--toss-border); border-top-color: var(--toss-blue);
|
||||
border-radius: 50%; animation: spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Empty 아이콘 */
|
||||
.toss-empty-icon {
|
||||
width: 56px; height: 56px; border-radius: 50%;
|
||||
background: var(--toss-bg); color: var(--toss-text-disabled);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
|
||||
/* 하단 요약 */
|
||||
.toss-summary-bar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 20px; border-top: 1px solid var(--toss-border);
|
||||
background: var(--toss-bg); font-size: 13px; color: var(--toss-text-tertiary);
|
||||
}
|
||||
.toss-summary-total { font-weight: 700; color: var(--toss-text-primary); }
|
||||
|
||||
/* 부서 인원 목록 */
|
||||
.toss-dept-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
width: 100%; padding: 10px 12px; border: none; cursor: pointer;
|
||||
background: transparent; font-size: 12px; font-weight: 700; color: var(--toss-text-secondary);
|
||||
letter-spacing: -0.01em; transition: background var(--toss-transition);
|
||||
text-align: left;
|
||||
}
|
||||
.toss-dept-header:hover { background: var(--toss-bg); }
|
||||
|
||||
.toss-user-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 7px 12px; transition: background var(--toss-transition);
|
||||
}
|
||||
.toss-user-row:hover { background: var(--toss-blue-lighter); }
|
||||
|
||||
.toss-user-add-btn {
|
||||
flex-shrink: 0; padding: 4px 10px; border-radius: 6px;
|
||||
font-size: 12px; font-weight: 600; border: none; cursor: pointer;
|
||||
transition: all var(--toss-transition);
|
||||
}
|
||||
.toss-user-add-btn.active { background: var(--toss-blue-light); color: var(--toss-blue); }
|
||||
.toss-user-add-btn.active:hover { background: #d3e5ff; }
|
||||
.toss-user-add-btn.disabled { background: var(--toss-bg); color: var(--toss-text-disabled); cursor: default; }
|
||||
|
||||
/* Step 카드 */
|
||||
.toss-step-card {
|
||||
display: flex; align-items: center; gap: 10px; padding: 10px 12px;
|
||||
border-radius: var(--toss-radius-xs); background: var(--toss-bg);
|
||||
transition: all var(--toss-transition);
|
||||
}
|
||||
.toss-step-card + .toss-step-card { margin-top: 6px; }
|
||||
.toss-step-card:hover { background: #eef0f3; }
|
||||
.toss-step-card .step-actions { opacity: 0; transition: opacity var(--toss-transition); }
|
||||
.toss-step-card:hover .step-actions { opacity: 1; }
|
||||
|
||||
.toss-step-num {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 24px; height: 24px; border-radius: 50%; flex-shrink: 0;
|
||||
background: var(--toss-blue); color: #fff;
|
||||
font-size: 11px; font-weight: 700;
|
||||
}
|
||||
|
||||
.toss-step-type {
|
||||
flex-shrink: 0; padding: 3px 8px; border-radius: 6px; border: none;
|
||||
font-size: 12px; font-weight: 500; background: #fff;
|
||||
color: var(--toss-text-secondary); cursor: pointer;
|
||||
outline: none; transition: all var(--toss-transition);
|
||||
-webkit-appearance: none; appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b95a1' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat; background-position: right 4px center;
|
||||
padding-right: 20px;
|
||||
}
|
||||
.toss-step-type:focus { box-shadow: 0 0 0 2px rgba(49, 130, 246, 0.2); }
|
||||
|
||||
/* 뱃지 */
|
||||
.toss-badge {
|
||||
display: inline-flex; align-items: center; padding: 2px 8px;
|
||||
border-radius: 6px; font-size: 11px; font-weight: 600;
|
||||
}
|
||||
.toss-badge-blue { background: var(--toss-blue-light); color: var(--toss-blue); }
|
||||
.toss-badge-default { background: #e8f5e9; color: #2e7d32; }
|
||||
|
||||
/* 화살표 아이콘 */
|
||||
.toss-arrow-flow {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
font-size: 12px; color: var(--toss-text-tertiary);
|
||||
}
|
||||
.toss-arrow-flow .arrow { color: var(--toss-text-disabled); font-size: 10px; }
|
||||
|
||||
/* 애니메이션 */
|
||||
@keyframes toss-fade-in { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes toss-slide-up { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } }
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
@@ -298,7 +541,7 @@ function switchToLineEdit() {
|
||||
|
||||
function loadLineList() {
|
||||
const body = document.getElementById('lineListBody');
|
||||
body.innerHTML = '<div class="flex justify-center py-8"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div></div>';
|
||||
body.innerHTML = '<div class="flex justify-center" style="padding: 40px 0;"><div class="toss-spinner"></div></div>';
|
||||
|
||||
fetch('/api/admin/approvals/lines', {
|
||||
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken }
|
||||
@@ -307,49 +550,45 @@ function loadLineList() {
|
||||
.then(data => {
|
||||
const lines = data.data || [];
|
||||
if (!lines.length) {
|
||||
body.innerHTML = '<div class="py-8 text-center text-gray-400 text-sm">등록된 결재선이 없습니다.</div>';
|
||||
body.innerHTML = `<div class="flex flex-col items-center" style="padding: 48px 0;">
|
||||
<div class="toss-empty-icon"><svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg></div>
|
||||
<span style="font-size: 14px; color: var(--toss-text-tertiary); margin-top: 14px;">등록된 결재선이 없습니다</span>
|
||||
<span style="font-size: 13px; color: var(--toss-text-disabled); margin-top: 4px;">위 버튼으로 새 결재선을 만들어 보세요</span>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
body.innerHTML = lines.map(line => {
|
||||
body.innerHTML = lines.map((line, idx) => {
|
||||
const stepsArr = line.steps || [];
|
||||
const summary = stepsArr.map(s => s.user_name || '?').join(' → ');
|
||||
const typeCounts = {
|
||||
approval: stepsArr.filter(s => s.step_type === 'approval').length,
|
||||
agreement: stepsArr.filter(s => s.step_type === 'agreement').length,
|
||||
reference: stepsArr.filter(s => s.step_type === 'reference').length,
|
||||
};
|
||||
const typeLabel = [
|
||||
typeCounts.approval ? `결재 ${typeCounts.approval}` : '',
|
||||
typeCounts.agreement ? `합의 ${typeCounts.agreement}` : '',
|
||||
typeCounts.reference ? `참조 ${typeCounts.reference}` : '',
|
||||
].filter(Boolean).join(', ');
|
||||
const flowHtml = stepsArr.map((s, si) => {
|
||||
const typeLabel = s.step_type === 'agreement' ? '합의' : s.step_type === 'reference' ? '참조' : '';
|
||||
const suffix = typeLabel ? `<span style="font-size: 10px; color: var(--toss-text-disabled);">(${typeLabel})</span>` : '';
|
||||
const arrow = si < stepsArr.length - 1 ? '<span class="arrow">→</span>' : '';
|
||||
return `<span>${escapeHtml(s.user_name || '?')}${suffix}</span>${arrow}`;
|
||||
}).join('');
|
||||
|
||||
return `<div class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg border border-gray-200 group hover:border-blue-300 transition">
|
||||
return `<div class="toss-line-card" onclick="openLineEdit(${line.id})">
|
||||
<div class="toss-line-icon">${idx + 1}</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-sm text-gray-800">${escapeHtml(line.name)}</span>
|
||||
${line.is_default ? '<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-blue-100 text-blue-600">기본</span>' : ''}
|
||||
<span style="font-size: 15px; font-weight: 600; color: var(--toss-text-primary);">${escapeHtml(line.name)}</span>
|
||||
${line.is_default ? '<span class="toss-badge toss-badge-default">기본</span>' : ''}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5 truncate">${escapeHtml(summary)}</div>
|
||||
<div class="text-xs text-gray-400 mt-0.5">${typeLabel} (${stepsArr.length}단계)</div>
|
||||
<div class="toss-arrow-flow" style="margin-top: 4px;">${flowHtml}</div>
|
||||
<div style="font-size: 12px; color: var(--toss-text-disabled); margin-top: 2px;">${stepsArr.length}단계</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition">
|
||||
<button onclick="openLineEdit(${line.id})" class="p-1.5 hover:bg-blue-100 rounded-lg transition" title="수정">
|
||||
<svg class="w-4 h-4 text-gray-500 hover:text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
<div class="toss-card-actions">
|
||||
<button onclick="event.stopPropagation(); openLineEdit(${line.id})" class="toss-icon-btn" style="width: 32px; height: 32px;" title="수정">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
</button>
|
||||
<button onclick="deleteLine(${line.id}, '${escapeHtml(line.name)}')" class="p-1.5 hover:bg-red-100 rounded-lg transition" title="삭제">
|
||||
<svg class="w-4 h-4 text-gray-500 hover:text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
<button onclick="event.stopPropagation(); deleteLine(${line.id}, '${escapeHtml(line.name)}')" class="toss-icon-btn" style="width: 32px; height: 32px; color: var(--toss-red);" title="삭제">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
})
|
||||
.catch(() => {
|
||||
body.innerHTML = '<div class="py-8 text-center text-red-400 text-sm">목록을 불러올 수 없습니다.</div>';
|
||||
body.innerHTML = '<div style="padding: 40px 0; text-align: center; color: var(--toss-red); font-size: 14px;">목록을 불러올 수 없습니다</div>';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -523,27 +762,27 @@ function renderLineSteps() {
|
||||
|
||||
container.innerHTML = lineSteps.map((step, i) => {
|
||||
const info = [step.department, step.position].filter(Boolean).join(' / ');
|
||||
return `<div class="flex items-center gap-2 p-2 bg-gray-50 rounded-lg border border-gray-200 group">
|
||||
<div class="shrink-0 flex flex-col gap-0.5">
|
||||
<button onclick="moveLineStep(${i}, -1)" class="p-0.5 text-gray-300 hover:text-gray-600 transition ${i === 0 ? 'invisible' : ''}">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/></svg>
|
||||
return `<div class="toss-step-card">
|
||||
<div style="flex-shrink: 0; display: flex; flex-direction: column; gap: 1px;">
|
||||
<button onclick="moveLineStep(${i}, -1)" class="toss-icon-btn" style="width: 22px; height: 22px; ${i === 0 ? 'visibility: hidden;' : ''}">
|
||||
<svg style="width: 12px; height: 12px;" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7"/></svg>
|
||||
</button>
|
||||
<button onclick="moveLineStep(${i}, 1)" class="p-0.5 text-gray-300 hover:text-gray-600 transition ${i === lineSteps.length - 1 ? 'invisible' : ''}">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
|
||||
<button onclick="moveLineStep(${i}, 1)" class="toss-icon-btn" style="width: 22px; height: 22px; ${i === lineSteps.length - 1 ? 'visibility: hidden;' : ''}">
|
||||
<svg style="width: 12px; height: 12px;" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<span class="shrink-0 flex items-center justify-center bg-blue-100 text-blue-700 rounded-full font-medium text-xs" style="width: 22px; height: 22px;">${i + 1}</span>
|
||||
<div class="toss-step-num">${i + 1}</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="font-medium text-xs text-gray-800">${escapeHtml(step.user_name)}</span>
|
||||
<div class="text-xs text-gray-400 truncate">${escapeHtml(info)}</div>
|
||||
<div style="font-size: 13px; font-weight: 600; color: var(--toss-text-primary);">${escapeHtml(step.user_name)}</div>
|
||||
<div style="font-size: 11px; color: var(--toss-text-tertiary); margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(info)}</div>
|
||||
</div>
|
||||
<select onchange="changeLineStepType(${i}, this.value)" class="shrink-0 px-1.5 py-1 border border-gray-300 rounded text-xs focus:outline-none focus:ring-1 focus:ring-blue-500" style="width: 60px;">
|
||||
<select onchange="changeLineStepType(${i}, this.value)" class="toss-step-type">
|
||||
<option value="approval" ${step.step_type === 'approval' ? 'selected' : ''}>결재</option>
|
||||
<option value="agreement" ${step.step_type === 'agreement' ? 'selected' : ''}>합의</option>
|
||||
<option value="reference" ${step.step_type === 'reference' ? 'selected' : ''}>참조</option>
|
||||
</select>
|
||||
<button onclick="removeLineStep(${i})" class="shrink-0 p-1 text-gray-300 hover:text-red-500 transition opacity-0 group-hover:opacity-100">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
<button onclick="removeLineStep(${i})" class="toss-icon-btn step-actions" style="width: 28px; height: 28px; color: var(--toss-red);">
|
||||
<svg style="width: 14px; height: 14px;" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
@@ -571,35 +810,36 @@ function renderLineDeptList() {
|
||||
}
|
||||
|
||||
if (!depts.length) {
|
||||
container.innerHTML = '<div class="py-8 text-center text-gray-400 text-xs">인원 정보가 없습니다.</div>';
|
||||
container.innerHTML = `<div class="flex flex-col items-center" style="padding: 32px 0;">
|
||||
<span style="font-size: 12px; color: var(--toss-text-disabled);">${query ? '검색 결과가 없습니다' : '인원 정보가 없습니다'}</span>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = depts.map(dept => {
|
||||
const deptKey = dept.department_id ?? 'none';
|
||||
const expanded = lineExpandedDepts[deptKey] !== false;
|
||||
return `<div class="border-b border-gray-50">
|
||||
<button type="button" onclick="toggleLineDept('${deptKey}')"
|
||||
class="w-full flex items-center justify-between px-3 py-2 text-xs font-semibold text-gray-600 bg-gray-50 hover:bg-gray-100 transition">
|
||||
return `<div>
|
||||
<button type="button" onclick="toggleLineDept('${deptKey}')" class="toss-dept-header">
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-3 h-3 transition-transform ${expanded ? 'rotate-90' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
<svg style="width: 12px; height: 12px; transition: transform 0.2s; ${expanded ? 'transform: rotate(90deg);' : ''}" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
${escapeHtml(dept.department_name)}
|
||||
</span>
|
||||
<span class="text-gray-400">${dept.users.length}명</span>
|
||||
<span style="color: var(--toss-text-disabled); font-weight: 500;">${dept.users.length}</span>
|
||||
</button>
|
||||
<div id="lineDept-${deptKey}" class="${expanded ? '' : 'hidden'}">
|
||||
${dept.users.map(user => {
|
||||
const added = lineSteps.some(s => s.user_id === user.id);
|
||||
return `<div class="flex items-center justify-between px-3 py-1.5 hover:bg-blue-50 transition ${added ? 'opacity-50' : ''}">
|
||||
return `<div class="toss-user-row" style="${added ? 'opacity: 0.45;' : ''}">
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="text-xs font-medium text-gray-800">${escapeHtml(user.name)}</span>
|
||||
<span class="text-xs text-gray-400 ml-1">${escapeHtml(user.position || user.job_title || '')}</span>
|
||||
<span style="font-size: 13px; font-weight: 500; color: var(--toss-text-primary);">${escapeHtml(user.name)}</span>
|
||||
<span style="font-size: 11px; color: var(--toss-text-disabled); margin-left: 4px;">${escapeHtml(user.position || user.job_title || '')}</span>
|
||||
</div>
|
||||
<button onclick='addLineStep(${JSON.stringify({id: user.id, name: user.name, position: user.position || user.job_title || ""})}, "${escapeHtml(dept.department_name)}")'
|
||||
${added ? 'disabled' : ''}
|
||||
class="shrink-0 ml-2 text-xs px-2 py-0.5 rounded transition ${added ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'bg-blue-50 text-blue-600 hover:bg-blue-100'}">
|
||||
class="toss-user-add-btn ${added ? 'disabled' : 'active'}">
|
||||
${added ? '추가됨' : '+ 추가'}
|
||||
</button>
|
||||
</div>`;
|
||||
@@ -620,7 +860,8 @@ function updateLineSummary() {
|
||||
lineSteps.forEach(s => { if (counts[s.step_type] !== undefined) counts[s.step_type]++; });
|
||||
const total = lineSteps.length;
|
||||
document.getElementById('lineSummary').textContent =
|
||||
`결재: ${counts.approval}명 | 합의: ${counts.agreement}명 | 참조: ${counts.reference}명 — 합계 ${total}명`;
|
||||
`결재 ${counts.approval}명 \u00B7 합의 ${counts.agreement}명 \u00B7 참조 ${counts.reference}명`;
|
||||
document.getElementById('lineSummaryTotal').textContent = `총 ${total}명`;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
|
||||
Reference in New Issue
Block a user