feat(expense+admin): 결재 워크플로 + 첨부 + 집계 + 엑셀 + 사용자 직접 등록
[admin]
- POST /api/users 사용자 직접 등록 (이메일만으로). UserStore.create_user 추가
- admin.html: 등록 폼 + 삭제 버튼 + 한글 라벨 + 신규 권한 표시
- 신규 권한 키: expense_approver, vacation_approver (APPROVER_KEYS)
- is_approver(user_rec, kind) 헬퍼
[expense 워크플로]
- 상태 전이: 작성중 → 제출 → 승인/반려 → 정산완료
- API: /api/items/{id}/{submit,revert,approve,reject,settle}
- 권한: submit/revert=owner, approve/reject/settle=expense_approver 또는 admin
- expense_items 컬럼: approver_email, decided_at, reject_reason
- 작성중/반려 상태에서만 수정/삭제/첨부 가능
[expense 첨부]
- 영수증/기타 (kind: receipt|other). 최대 20MB, 확장자 화이트리스트
- 저장: DATA_DIR/uploads/expense/{item_id}/{att_id}_{filename}
- expense_attachments 테이블 (CASCADE on item delete)
- API: 업로드/목록/다운로드/삭제
[expense 집계]
- /expense/reports: 연도별 월×카테고리 피벗 표
[expense 엑셀]
- GET /expense/api/export.xlsx?from&to&scope=mine|all
- scope=all 은 승인자/관리자만
[DDL]
- expense_db_init.sql: 신규 컬럼/테이블 포함 (멱등)
- expense_db_002_workflow_attachments.sql: 운영 DB 마이그레이션용
[deps]
- openpyxl>=3.1, python-multipart>=0.0.20
[docs]
- DATABASES.md: 워크플로 다이어그램, attachments 테이블, 마이그레이션
- PROJECT_OVERVIEW.md: 권한 키 표
- DEPLOYMENT.md: DATA_DIR/uploads 안내
This commit is contained in:
+120
-50
@@ -5,12 +5,36 @@
|
||||
<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">
|
||||
@@ -19,7 +43,6 @@
|
||||
<span class="erp-brand-system">ERP 시스템</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="erp-nav-right">
|
||||
<a class="erp-btn erp-btn-ghost" href="/">← 대시보드</a>
|
||||
<div class="erp-user-chip">
|
||||
@@ -37,39 +60,45 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="erp-main" style="max-width: 1100px;">
|
||||
<main class="erp-main" style="max-width: 1280px;">
|
||||
|
||||
<h1 class="erp-greeting">사용자 / 권한 관리</h1>
|
||||
<p class="erp-greeting-sub">
|
||||
회사 계정으로 로그인한 임직원의 모듈 접근 권한을 관리합니다.
|
||||
토글을 변경한 뒤 <strong>저장</strong> 버튼을 누르세요.
|
||||
이메일만으로 사용자 등록 가능 (회사 도메인 자동 검사).
|
||||
토글을 변경한 뒤 <strong>저장</strong>을 누르세요.
|
||||
</p>
|
||||
|
||||
<!-- 이메일 등록 폼 + 검색 -->
|
||||
<div class="erp-admin-toolbar">
|
||||
<input type="search"
|
||||
id="user-search"
|
||||
class="erp-input"
|
||||
placeholder="이메일 또는 이름으로 검색…" />
|
||||
<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: 28%;">사용자</th>
|
||||
<th style="width: 11%;">역할</th>
|
||||
<th style="width: 22%;">사용자</th>
|
||||
<th style="width: 80px;">역할</th>
|
||||
{% for key in module_keys %}
|
||||
<th style="text-align:center; width: 9%;">
|
||||
{% if key == 'corm' %}CORM
|
||||
{% elif key == 'order' %}Order
|
||||
{% elif key == 'expense' %}개인경비
|
||||
{% elif key == 'vacation' %}휴가
|
||||
{% else %}{{ key }}{% endif %}
|
||||
<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: 13%;">최근 로그인</th>
|
||||
<th style="width: 12%; text-align: right;">동작</th>
|
||||
<th style="width: 120px;">최근 로그인</th>
|
||||
<th style="width: 130px; text-align: right;">동작</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -102,7 +131,7 @@
|
||||
</select>
|
||||
</td>
|
||||
{% for key in module_keys %}
|
||||
<td style="text-align:center;">
|
||||
<td class="erp-mod-cell">
|
||||
<label class="erp-switch">
|
||||
<input type="checkbox"
|
||||
data-field="module"
|
||||
@@ -114,11 +143,15 @@
|
||||
</td>
|
||||
{% endfor %}
|
||||
<td style="font-size:12px; color: var(--color-midtone-gray);">
|
||||
{{ (u.last_login or '')[:16].replace('T', ' ') }}
|
||||
{{ (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 %}
|
||||
@@ -127,8 +160,9 @@
|
||||
</div>
|
||||
|
||||
<p class="erp-section-meta" style="margin-top: var(--sp-16);">
|
||||
※ <strong>{{ super_admin_email }}</strong> 은(는) 슈퍼 관리자이므로 권한을 변경할 수 없습니다.
|
||||
관리자(admin) 역할로 지정된 사용자에게는 모든 모듈 권한이 자동 부여됩니다.
|
||||
※ <strong>{{ super_admin_email }}</strong> 은(는) 슈퍼 관리자, 권한 변경/삭제 불가.
|
||||
admin 역할은 모든 모듈 권한 자동 부여.
|
||||
<code>expense_approver</code> / <code>vacation_approver</code> 는 결재 승인 권한.
|
||||
</p>
|
||||
|
||||
</main>
|
||||
@@ -141,6 +175,7 @@
|
||||
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"); }
|
||||
@@ -150,20 +185,16 @@
|
||||
toastEl.dataset.type = type || "info";
|
||||
toastEl.dataset.visible = "true";
|
||||
clearTimeout(showToast._t);
|
||||
showToast._t = setTimeout(() => {
|
||||
toastEl.dataset.visible = "false";
|
||||
}, 2400);
|
||||
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;
|
||||
cb.checked = true; cb.disabled = true;
|
||||
} else {
|
||||
cb.disabled = false;
|
||||
}
|
||||
@@ -173,47 +204,86 @@
|
||||
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") syncModuleSwitches(tr);
|
||||
markDirty(tr);
|
||||
});
|
||||
|
||||
tbody.addEventListener("click", async (e) => {
|
||||
const btn = e.target.closest("button[data-action='save']");
|
||||
const btn = e.target.closest("button");
|
||||
if (!btn) return;
|
||||
const tr = btn.closest("tr");
|
||||
const email = tr.dataset.email;
|
||||
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 = "저장 중…";
|
||||
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/${encodeURIComponent(email)}`, {
|
||||
method: "PUT",
|
||||
const res = await fetch("/api/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ role, modules }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
clearDirty(tr);
|
||||
showToast(`${email} 저장 완료`, "info");
|
||||
showToast(`${payload.email} 등록 완료. 새로고침합니다.`, "info");
|
||||
setTimeout(() => location.reload(), 800);
|
||||
} catch (err) {
|
||||
showToast(`저장 실패: ${err.message}`, "error");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "저장";
|
||||
showToast(`등록 실패: ${err.message}`, "error");
|
||||
}
|
||||
});
|
||||
|
||||
// 검색
|
||||
searchInput.addEventListener("input", () => {
|
||||
const q = searchInput.value.toLowerCase().trim();
|
||||
let visible = 0;
|
||||
|
||||
Reference in New Issue
Block a user