feat(admin): 권한 열을 칩 토글로 — 모듈 늘어도 가로 안 넓어짐

모듈마다 토글 열을 두던 방식은 모듈이 늘수록 표가 가로로 무한 확장됐다.
모듈 열 전체를 단일 '접근 권한' 셀의 칩(pill) 토글로 교체. 칩이 줄바꿈되어
모듈이 늘어도 세로로만 늘고 한 화면에서 관리 가능. 칩 클릭=토글, admin/슈퍼는
잠금(전체 ON). 승인 권한 칩은 켜지면 파랑으로 구분.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 12:12:14 +09:00
parent 8ed1263bef
commit 4008eee088
+59 -33
View File
@@ -24,11 +24,30 @@
font-family: inherit;
}
.erp-mod-th { text-align: center; font-size: 11px; padding: 6px 4px; }
.erp-mod-cell { text-align: center; padding: 4px; }
.erp-mod-group { display: inline-block; padding: 2px 6px; border-radius: 6px;
.erp-mod-group { display: inline-block; margin-left: 6px; padding: 2px 6px; border-radius: 6px;
background: var(--color-ghost-gray); color: var(--color-midtone-gray);
font-size: 10px; font-weight: 500; }
font-size: 10px; font-weight: 500; vertical-align: middle; }
/* 권한 칩 — 모듈 수가 늘어도 줄바꿈되어 가로로 안 넓어짐 */
.erp-perm-cell { padding: 8px 6px; }
.erp-perm-chip {
display: inline-flex; align-items: center; gap: 4px;
margin: 3px; padding: 4px 11px; border-radius: 999px;
border: 1px solid var(--color-subtle-ash); background: #fff;
color: var(--color-midtone-gray); font-size: 12px; font-weight: 500;
font-family: inherit; cursor: pointer; user-select: none;
transition: background .1s, color .1s, border-color .1s;
}
.erp-perm-chip:hover { border-color: var(--color-midtone-gray); }
.erp-perm-chip.is-on {
background: var(--color-rich-black); color: #fff; border-color: var(--color-rich-black);
}
/* 승인 권한 칩 — 켜지면 파랑 강조로 구분 */
.erp-perm-chip.is-approver.is-on {
background: #1d4ed8; border-color: #1d4ed8;
}
.erp-perm-chip[data-locked="true"] { opacity: .5; cursor: not-allowed; }
.erp-perm-chip[data-locked="true"]:hover { border-color: var(--color-subtle-ash); }
</style>
</head>
<body class="erp-body">
@@ -89,15 +108,10 @@
<table class="erp-table" id="users-table">
<thead>
<tr>
<th style="width: 22%;">사용자</th>
<th style="width: 200px;">사용자</th>
<th style="width: 80px;">역할</th>
{% for key in module_keys %}
<th class="erp-mod-th">
{{ module_labels.get(key, key) }}
{% if key in approver_keys %}<br><span class="erp-mod-group">승인</span>{% endif %}
</th>
{% endfor %}
<th style="width: 120px;">최근 로그인</th>
<th>접근 권한 <span class="erp-mod-group">클릭하여 토글</span></th>
<th style="width: 110px;">최근 로그인</th>
<th style="width: 130px; text-align: right;">동작</th>
</tr>
</thead>
@@ -130,18 +144,19 @@
<option value="admin" {% if u.role == 'admin' %}selected{% endif %}>admin</option>
</select>
</td>
{% for key in module_keys %}
<td class="erp-mod-cell">
<label class="erp-switch">
<input type="checkbox"
data-field="module"
data-module="{{ key }}"
{% if u.modules[key] %}checked{% endif %}
{% if u.is_super_admin or u.role == 'admin' %}disabled{% endif %} />
<span class="erp-switch-slider"></span>
</label>
<td class="erp-perm-cell">
{% for key in module_keys %}
<button type="button"
class="erp-perm-chip
{% if u.modules[key] %}is-on{% endif %}
{% if key in approver_keys %}is-approver{% endif %}"
data-module="{{ key }}"
aria-pressed="{{ 'true' if u.modules[key] else 'false' }}"
{% if u.is_super_admin or u.role == 'admin' %}data-locked="true"{% endif %}>
{{ module_labels.get(key, key) }}{% if key in approver_keys %} 승인{% endif %}
</button>
{% endfor %}
</td>
{% endfor %}
<td style="font-size:12px; color: var(--color-midtone-gray);">
{{ (u.last_login or '미접속')[:16].replace('T', ' ') }}
</td>
@@ -188,15 +203,16 @@
showToast._t = setTimeout(() => { toastEl.dataset.visible = "false"; }, 2400);
}
function syncModuleSwitches(tr) {
function syncPermChips(tr) {
const isSuper = tr.dataset.super === "true";
const role = tr.querySelector("select[data-field='role']").value;
const adminRole = role === "admin";
tr.querySelectorAll("input[data-field='module']").forEach((cb) => {
const adminRole = tr.querySelector("select[data-field='role']").value === "admin";
tr.querySelectorAll(".erp-perm-chip").forEach((chip) => {
if (isSuper || adminRole) {
cb.checked = true; cb.disabled = true;
chip.classList.add("is-on");
chip.setAttribute("aria-pressed", "true");
chip.setAttribute("data-locked", "true");
} else {
cb.disabled = false;
chip.removeAttribute("data-locked");
}
});
}
@@ -204,12 +220,22 @@
tbody.addEventListener("change", (e) => {
const tr = e.target.closest("tr");
if (!tr) return;
if (e.target.dataset.field === "role") syncModuleSwitches(tr);
if (e.target.dataset.field === "role") syncPermChips(tr);
markDirty(tr);
});
tbody.addEventListener("click", async (e) => {
const btn = e.target.closest("button");
// 권한 칩 토글 (잠금 상태 제외)
const chip = e.target.closest(".erp-perm-chip");
if (chip) {
if (chip.getAttribute("data-locked") === "true") return;
const on = chip.classList.toggle("is-on");
chip.setAttribute("aria-pressed", on ? "true" : "false");
markDirty(chip.closest("tr"));
return;
}
const btn = e.target.closest("button[data-action]");
if (!btn) return;
const tr = btn.closest("tr");
const email = tr.dataset.email;
@@ -217,8 +243,8 @@
if (btn.dataset.action === "save") {
const role = tr.querySelector("select[data-field='role']").value;
const modules = {};
tr.querySelectorAll("input[data-field='module']").forEach((cb) => {
modules[cb.dataset.module] = cb.checked;
tr.querySelectorAll(".erp-perm-chip").forEach((c) => {
modules[c.dataset.module] = c.classList.contains("is-on");
});
btn.disabled = true; btn.textContent = "저장 중…";
try {