Add Corporate Memory admin review queue UI (Phase 2)

Admin page at /corporate-memory/admin with three tabs:
- Review Queue: pending items with approve/mandate/reject + batch ops
- All Items: status filter, promote/demote/revoke actions
- Audit Log: filterable action history table

Features:
- Keyboard shortcuts (j/k navigate, a/r/m = approve/reject/mandate)
- Inline mandate form (mandatory reason + audience targeting)
- Toast notifications on action success/error
- Pending count badge on main Corporate Memory page
- Matches existing visual design (CSS variables, card styles)
This commit is contained in:
Petr 2026-03-23 19:32:33 +01:00
parent 1318b74ff1
commit e85d296b0a
4 changed files with 1664 additions and 11 deletions

View file

@ -40,6 +40,7 @@ from .corporate_memory_service import (
vote as memory_vote,
is_km_admin,
get_governance_mode,
get_groups as get_memory_groups,
approve_item,
reject_item,
mandate_item,
@ -49,6 +50,7 @@ from .corporate_memory_service import (
get_pending_queue,
get_audit_log,
migrate_existing_items,
VALID_STATUSES,
)
from .user_service import (
UserInfo,
@ -1393,9 +1395,11 @@ def register_routes(app: Flask) -> None:
knowledge = get_knowledge(page=0, per_page=20)
# Governance context for admin features
_is_admin = is_km_admin(email) if email else False
governance = {
"mode": get_governance_mode(),
"is_km_admin": is_km_admin(email) if email else False,
"is_km_admin": _is_admin,
"pending_count": get_memory_stats().get("pending_count", 0) if _is_admin else 0,
}
return render_template(
@ -1407,6 +1411,31 @@ def register_routes(app: Flask) -> None:
governance=governance,
)
@app.route("/corporate-memory/admin")
@login_required
@km_admin_required
def corporate_memory_admin():
"""Corporate Memory admin review queue page."""
user = session.get("user", {})
email = user.get("email", "")
stats = get_memory_stats()
groups = get_memory_groups()
# Build groups list for audience dropdown (name + member count)
groups_list = [
{"name": name, "members_count": len(g.get("members", []))}
for name, g in groups.items()
if isinstance(g, dict)
]
return render_template(
"corporate_memory_admin.html",
stats=stats,
groups=groups_list,
governance_mode=get_governance_mode(),
)
# ─────────────────────────────────────────────────────────────────
# Activity Center routes
# ─────────────────────────────────────────────────────────────────
@ -1440,8 +1469,12 @@ def register_routes(app: Flask) -> None:
# Admin status filter (only km_admins can filter by status)
status = request.args.get("status")
include_statuses = None
if status and is_km_admin(email):
include_statuses = {status}
if is_km_admin(email):
if status:
include_statuses = {status}
elif request.args.get("all_statuses", "").lower() == "true":
# Admin requesting all statuses (for admin "All Items" view)
include_statuses = set(VALID_STATUSES)
result = get_knowledge(
category=category,
@ -1723,9 +1756,16 @@ def register_routes(app: Flask) -> None:
def corporate_memory_admin_config():
"""Get current governance configuration."""
try:
groups = get_memory_groups()
groups_list = [
{"name": name, "members_count": len(g.get("members", []))}
for name, g in groups.items()
if isinstance(g, dict)
]
return jsonify({
"ok": True,
"governance_mode": get_governance_mode(),
"groups": groups_list,
})
except Exception as e:
logger.exception("Error fetching governance config")

View file

@ -104,6 +104,16 @@ def get_governance_mode() -> str | None:
return gov.get("distribution_mode", "hybrid")
def get_groups() -> dict:
"""Return the groups dict from instance config (public wrapper around _load_groups).
Returns:
Dict mapping group name to group config (with 'members' list).
Empty dict if no groups configured.
"""
return _load_groups()
def get_approval_mode() -> str | None:
"""Return the approval mode, or None if legacy (no config)."""
gov = _load_governance_config()

View file

@ -437,6 +437,40 @@
opacity: 0.5;
}
/* Admin Link Button */
.admin-link-btn {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: var(--surface);
border: 1px solid var(--primary);
border-radius: var(--radius-md);
color: var(--primary);
text-decoration: none;
font-size: var(--text-sm);
font-weight: var(--font-medium);
transition: all 0.15s;
}
.admin-link-btn:hover {
background: var(--primary-light);
}
.pending-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: var(--radius-full);
background: var(--warning);
color: white;
font-size: 10px;
font-weight: var(--font-bold);
}
/* Responsive */
@media (max-width: 768px) {
.page-header {
@ -499,15 +533,28 @@
<h1>Corporate Memory</h1>
</div>
</div>
<div class="user-info-v2">
{% if session.user.picture %}
<img src="{{ session.user.picture }}" alt="Profile" class="avatar-v2">
{% else %}
<div class="avatar-v2" style="background: var(--primary-light); display: flex; align-items: center; justify-content: center; font-weight: 600; color: var(--primary); font-size: 12px;">
{{ session.user.email[:2].upper() }}
</div>
<div style="display: flex; align-items: center; gap: var(--space-4);">
{% if governance.is_km_admin %}
<a href="{{ url_for('corporate_memory_admin') }}" class="admin-link-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
Admin Review
{% if governance.pending_count > 0 %}
<span class="pending-badge">{{ governance.pending_count }}</span>
{% endif %}
</a>
{% endif %}
{{ session.user.email }}
<div class="user-info-v2">
{% if session.user.picture %}
<img src="{{ session.user.picture }}" alt="Profile" class="avatar-v2">
{% else %}
<div class="avatar-v2" style="background: var(--primary-light); display: flex; align-items: center; justify-content: center; font-weight: 600; color: var(--primary); font-size: 12px;">
{{ session.user.email[:2].upper() }}
</div>
{% endif %}
{{ session.user.email }}
</div>
</div>
</header>

File diff suppressed because it is too large Load diff