Files

303 lines
12 KiB
HTML

<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>사용자/권한 관리 — DBX ERP</title>
<link rel="stylesheet" href="/static/erp.css" />
<style>
.erp-admin-toolbar {
display: flex; align-items: center; gap: var(--sp-12);
margin: var(--sp-16) 0;
flex-wrap: wrap;
}
.erp-admin-toolbar .erp-input { flex: 0 1 280px; }
.erp-add-form {
display: flex; gap: var(--sp-8); align-items: center;
margin-left: auto; flex-wrap: wrap;
}
.erp-add-form input, .erp-add-form select {
border: 1px solid var(--color-subtle-ash);
border-radius: var(--r-input);
padding: 6px 10px; font-size: 13px;
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;
background: var(--color-ghost-gray); color: var(--color-midtone-gray);
font-size: 10px; font-weight: 500; }
</style>
</head>
<body class="erp-body">
<div class="erp-shell">
<nav class="erp-nav">
<div class="erp-nav-left">
<a href="/" class="erp-brand">
<img src="/static/dbx-logo.png" alt="DBX" class="erp-brand-logo" />
<span class="erp-brand-divider"></span>
<span class="erp-brand-system">DBX ERP System</span>
</a>
</div>
<div class="erp-nav-right">
<a class="erp-btn erp-btn-ghost" href="/">← 대시보드</a>
<div class="erp-user-chip">
{% if user.picture %}
<img class="erp-user-avatar" src="{{ user.picture }}" alt="{{ user.name }}" />
{% else %}
<span class="erp-user-avatar-fallback">{{ user.name[0] | upper }}</span>
{% endif %}
<div class="erp-user-info">
<span class="erp-user-name">{{ user.name }}</span>
<span class="erp-user-email">{{ user.email }}</span>
</div>
</div>
<a class="erp-btn erp-btn-outline" href="/logout">로그아웃</a>
</div>
</nav>
<main class="erp-main" style="max-width: 1280px;">
<h1 class="erp-greeting">사용자 / 권한 관리</h1>
<p class="erp-greeting-sub">
이메일만으로 사용자 등록 가능 (회사 도메인 자동 검사).
토글을 변경한 뒤 <strong>저장</strong>을 누르세요.
</p>
<!-- 이메일 등록 폼 + 검색 -->
<div class="erp-admin-toolbar">
<input type="search" id="user-search" class="erp-input"
placeholder="이메일/이름 검색…" style="padding: 6px 10px;" />
<span class="erp-section-meta" id="user-count">총 {{ users | length }}명</span>
<form id="add-user-form" class="erp-add-form">
<input type="email" name="email" required placeholder="email@dbxcorp.co.kr" style="width: 220px;" />
<input type="text" name="name" placeholder="이름(선택)" style="width: 120px;" />
<select name="role">
<option value="user">user</option>
<option value="admin">admin</option>
</select>
<button type="submit" class="erp-btn erp-btn-primary">사용자 추가</button>
</form>
</div>
<div class="erp-table-wrap">
<table class="erp-table" id="users-table">
<thead>
<tr>
<th style="width: 22%;">사용자</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 style="width: 130px; text-align: right;">동작</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr data-email="{{ u.email }}" data-dirty="false"
data-super="{{ 'true' if u.is_super_admin else 'false' }}">
<td>
<div class="erp-user-cell">
{% if u.picture %}
<img class="erp-user-avatar" src="{{ u.picture }}" alt="{{ u.name }}" />
{% else %}
<span class="erp-user-avatar-fallback">{{ (u.name or u.email)[0] | upper }}</span>
{% endif %}
<div>
<div class="erp-user-cell-name">
{{ u.name or u.email }}
{% if u.is_super_admin %}
<span class="erp-badge erp-badge-inverse" style="margin-left:6px;">슈퍼관리자</span>
{% endif %}
</div>
<div class="erp-user-cell-email">{{ u.email }}</div>
</div>
</div>
</td>
<td>
<select class="erp-select" data-field="role"
{% if u.is_super_admin %}disabled{% endif %}>
<option value="user" {% if u.role == 'user' %}selected{% endif %}>user</option>
<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>
{% endfor %}
<td style="font-size:12px; color: var(--color-midtone-gray);">
{{ (u.last_login or '미접속')[:16].replace('T', ' ') }}
</td>
<td style="text-align: right;">
<button class="erp-btn erp-btn-primary" data-action="save"
{% if u.is_super_admin %}disabled{% endif %}>저장</button>
{% if not u.is_super_admin %}
<button class="erp-btn erp-btn-outline" data-action="delete"
style="padding: 4px 8px; font-size: 12px;">삭제</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="erp-section-meta" style="margin-top: var(--sp-16);">
<strong>{{ super_admin_email }}</strong> 은(는) 슈퍼 관리자, 권한 변경/삭제 불가.
admin 역할은 모든 모듈 권한 자동 부여.
<code>expense_approver</code> / <code>vacation_approver</code> 는 결재 승인 권한.
</p>
</main>
</div>
<div id="toast" class="erp-toast" role="status" aria-live="polite"></div>
<script>
const tbody = document.querySelector("#users-table tbody");
const searchInput = document.getElementById("user-search");
const userCountEl = document.getElementById("user-count");
const toastEl = document.getElementById("toast");
const addForm = document.getElementById("add-user-form");
function markDirty(tr) { tr.setAttribute("data-dirty", "true"); }
function clearDirty(tr) { tr.setAttribute("data-dirty", "false"); }
function showToast(msg, type) {
toastEl.textContent = msg;
toastEl.dataset.type = type || "info";
toastEl.dataset.visible = "true";
clearTimeout(showToast._t);
showToast._t = setTimeout(() => { toastEl.dataset.visible = "false"; }, 2400);
}
function syncModuleSwitches(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) => {
if (isSuper || adminRole) {
cb.checked = true; cb.disabled = true;
} else {
cb.disabled = false;
}
});
}
tbody.addEventListener("change", (e) => {
const tr = e.target.closest("tr");
if (!tr) return;
if (e.target.dataset.field === "role") syncModuleSwitches(tr);
markDirty(tr);
});
tbody.addEventListener("click", async (e) => {
const btn = e.target.closest("button");
if (!btn) return;
const tr = btn.closest("tr");
const email = tr.dataset.email;
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;
});
btn.disabled = true; btn.textContent = "저장 중…";
try {
const res = await fetch(`/api/users/${encodeURIComponent(email)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ role, modules }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.detail || `HTTP ${res.status}`);
}
clearDirty(tr);
showToast(`${email} 저장 완료`, "info");
} catch (err) {
showToast(`저장 실패: ${err.message}`, "error");
} finally {
btn.disabled = false; btn.textContent = "저장";
}
} else if (btn.dataset.action === "delete") {
if (!confirm(`${email} 사용자를 삭제할까요? 복구 불가.`)) return;
try {
const res = await fetch(`/api/users/${encodeURIComponent(email)}`, {
method: "DELETE", credentials: "same-origin",
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.detail || `HTTP ${res.status}`);
}
tr.remove();
showToast(`${email} 삭제 완료`, "info");
} catch (err) {
showToast(`삭제 실패: ${err.message}`, "error");
}
}
});
// 사용자 추가
addForm.addEventListener("submit", async (e) => {
e.preventDefault();
const fd = new FormData(addForm);
const payload = {
email: fd.get("email").trim().toLowerCase(),
name: fd.get("name").trim(),
role: fd.get("role"),
};
try {
const res = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify(payload),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.detail || `HTTP ${res.status}`);
}
showToast(`${payload.email} 등록 완료. 새로고침합니다.`, "info");
setTimeout(() => location.reload(), 800);
} catch (err) {
showToast(`등록 실패: ${err.message}`, "error");
}
});
searchInput.addEventListener("input", () => {
const q = searchInput.value.toLowerCase().trim();
let visible = 0;
tbody.querySelectorAll("tr").forEach((tr) => {
const text = tr.textContent.toLowerCase();
const match = !q || text.includes(q);
tr.style.display = match ? "" : "none";
if (match) visible++;
});
userCountEl.textContent = q
? `검색 결과 ${visible}`
: `${tbody.querySelectorAll("tr").length}`;
});
</script>
</body>
</html>