Add discipline field, star rating system, and dashboard filtering

- Add discipline (business category) and rating (1-5 stars) fields to agent models
- Discipline dropdown on registration form (required) and edit modals (both user and admin)
- Interactive star rating widget with immediate save via PUT /api/agents/{id}/rating
- Discipline filter and rating sort on agent management dashboard
- Purple discipline badge and gold star badge on agent cards
- CSV export/import support for discipline, rating, and total_tokens
- Initialize total_tokens on manually-created agents for consistent CSV exports
- Search agents by discipline field

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
nickviljoen 2026-02-16 21:38:53 +02:00
parent 0e7940801b
commit a50ef3ec64
8 changed files with 400 additions and 23 deletions

View file

@ -57,11 +57,11 @@ Create `.env` file with:
### Data Layer
**models.py**: Pydantic models for:
- `AiAgent`: Core agent model with comprehensive fields
- `AiAgent`: Core agent model with comprehensive fields (includes `discipline`, `rating`)
- `UsageTimelineEntry`: Daily usage data including `message_count` and `token_count`
- `UserCreate/UserResponse`: User management models
- `AiAgentCreate/AiAgentResponse`: API request/response models (includes `total_tokens`)
- `AgentCollectorCreate`: Collector API input model (includes `total_tokens`)
- `AiAgentCreate/AiAgentResponse`: API request/response models (includes `total_tokens`, `discipline`, `rating`)
- `AgentCollectorCreate`: Collector API input model (includes `total_tokens`, `discipline`)
- `AgentUsageStatsResponse`: Usage statistics response (includes `total_tokens`)
**crud.py**: Database operations using Motor (async MongoDB driver):
@ -118,9 +118,20 @@ Located in `templates/` directory:
- Status tracking (Active, Inactive, Development, Deprecated)
- Rich metadata: tags, userbase, department, contact person
- Search functionality across multiple fields
- Filtering by status and audit status (Audited / Not Audited)
- Filtering by status, audit status (Audited / Not Audited), and discipline
- Admin can view/manage all agents
### Discipline & Star Rating
- `discipline` field classifies agents into business categories: Strategy, Creative, Oversight including delivery, Optimization, Back Office including operations
- Required on registration form, optional on edit (to support legacy agents)
- `rating` field is a 1-5 star rating for quality assessment by discipline leads
- Interactive star rating widget in detail modal saves immediately via `PUT /api/agents/{id}/rating`
- Star rating also editable in the edit modal (both agent management page and admin dashboard)
- Dashboard supports filtering by discipline and sorting by rating
- Discipline badge (purple) and star rating badge displayed on agent cards
- Both fields included in CSV export/import
- Discipline passed through collector API; rating is human-only (not in collector)
### Token Usage Tracking
- `total_tokens` field on agents tracks cumulative LLM token consumption
- `token_count` per day in usage timeline entries alongside `message_count`

11
crud.py
View file

@ -151,6 +151,12 @@ async def create_agent(agent_data: dict, user_id: str, skip_time_check: bool = F
agent_doc["quality_audit_updated_by_name"] = None
if "risk_factor" not in agent_doc:
agent_doc["risk_factor"] = None
if "discipline" not in agent_doc:
agent_doc["discipline"] = None
if "rating" not in agent_doc:
agent_doc["rating"] = None
if "total_tokens" not in agent_doc:
agent_doc["total_tokens"] = None
result = await agents_collection.insert_one(agent_doc)
agent_doc["_id"] = result.inserted_id
return agent_doc
@ -198,7 +204,8 @@ async def search_agents(search_term: str, user_id: str = None):
{"agent_name": {"$regex": search_term, "$options": "i"}},
{"agent_description": {"$regex": search_term, "$options": "i"}},
{"agent_tags": {"$regex": search_term, "$options": "i"}},
{"agent_department": {"$regex": search_term, "$options": "i"}}
{"agent_department": {"$regex": search_term, "$options": "i"}},
{"discipline": {"$regex": search_term, "$options": "i"}}
]
}
@ -403,7 +410,7 @@ def _agent_data_differs(existing_agent: dict, new_agent_data: dict) -> bool:
"agent_description", "agent_purpose", "agent_version", "agent_status",
"agent_location", "agent_department", "agent_contact_person",
"agent_tags", "agent_userbase", "agent_capabilities", "agent_metadata",
"url"
"url", "discipline"
]
for field in comparable_fields:

41
main.py
View file

@ -225,6 +225,8 @@ def create_agent_response(agent: dict) -> models.AiAgentResponse:
quality_audit_updated_by_name=agent.get("quality_audit_updated_by_name"),
risk_factor=agent.get("risk_factor"),
last_edited_by=agent.get("last_edited_by"),
discipline=agent.get("discipline"),
rating=agent.get("rating"),
created_by=agent["created_by"],
# Usage tracking fields
usage_timeline=agent.get("usage_timeline"),
@ -279,6 +281,7 @@ def map_agent_collector_to_internal(collector_data: models.AgentCollectorCreate)
"agent_tags": collector_data.tags,
"agent_metadata": collector_data.metadata,
"url": collector_data.url,
"discipline": collector_data.discipline,
# Usage tracking fields
"usage_timeline": usage_timeline,
"conversation_count": collector_data.conversation_count,
@ -570,6 +573,7 @@ async def agent_register_form(
agent_location: str = Form(None),
agent_department: str = Form(None),
agent_contact_person: str = Form(None),
discipline: str = Form(...),
agent_tags: str = Form(None),
agent_userbase: str = Form(None),
agent_capabilities: str = Form(None),
@ -595,6 +599,7 @@ async def agent_register_form(
"agent_location": agent_location,
"agent_department": agent_department,
"agent_contact_person": agent_contact_person,
"discipline": discipline,
}
# Process tags, userbase, and capabilities (convert comma-separated to lists)
@ -886,6 +891,28 @@ async def update_agent(agent_id: str, agent: models.AiAgentCreate, current_user:
return create_agent_response(updated_agent)
@app.put("/api/agents/{agent_id}/rating")
async def update_agent_rating(agent_id: str, request: Request, current_user: dict = Depends(get_current_user_from_cookie)):
"""Update only the star rating of an agent (lightweight endpoint for immediate saves)"""
existing_agent = await crud.get_agent_by_id(agent_id)
if not existing_agent:
raise HTTPException(status_code=404, detail="Agent not found")
if not can_user_edit_agent(existing_agent, current_user):
raise HTTPException(status_code=403, detail="Not authorized to rate this agent")
body = await request.json()
rating_value = body.get("rating")
if rating_value is None or not (1 <= float(rating_value) <= 5):
raise HTTPException(status_code=422, detail="Rating must be between 1 and 5")
updated = await crud.update_agent(agent_id, {"rating": float(rating_value)}, last_edited_by=current_user.get("email"))
if not updated:
raise HTTPException(status_code=500, detail="Failed to update rating")
return {"message": "Rating updated", "rating": float(rating_value)}
@app.delete("/api/agents/{agent_id}")
async def delete_agent(agent_id: str, current_user: dict = Depends(get_current_user_from_cookie)):
print(f"🗑️ DELETE attempt - Agent ID: {agent_id}, User ID: {current_user['_id']}")
@ -1086,7 +1113,9 @@ async def export_agents_csv(current_user: dict = Depends(require_admin)):
"risk_factor",
"last_edited_by",
"created_by",
"total_tokens"
"total_tokens",
"discipline",
"rating"
]
writer = csv.DictWriter(output, fieldnames=fieldnames)
@ -1121,7 +1150,9 @@ async def export_agents_csv(current_user: dict = Depends(require_admin)):
"risk_factor": str(agent.get("risk_factor", "")) if agent.get("risk_factor") is not None else "",
"last_edited_by": agent.get("last_edited_by", ""),
"created_by": agent.get("created_by", ""),
"total_tokens": str(agent.get("total_tokens", "")) if agent.get("total_tokens") is not None else ""
"total_tokens": str(agent.get("total_tokens", "")) if agent.get("total_tokens") is not None else "",
"discipline": agent.get("discipline", ""),
"rating": str(agent.get("rating", "")) if agent.get("rating") is not None else ""
}
writer.writerow(row)
@ -1180,7 +1211,10 @@ async def import_agents_csv(
"agent_contact_person": row.get("agent_contact_person"),
"url": row.get("url"),
"quality_audit_status": row.get("quality_audit_status", "False").lower() == "true",
"risk_factor": int(row.get("risk_factor")) if row.get("risk_factor") else None
"risk_factor": int(row.get("risk_factor")) if row.get("risk_factor") else None,
"discipline": row.get("discipline") or None,
"rating": float(row.get("rating")) if row.get("rating") else None,
"total_tokens": int(row.get("total_tokens")) if row.get("total_tokens") else None
}
# Handle lists (pipe separated)
@ -1323,6 +1357,7 @@ async def create_agent_collector(
# Update agent document with new usage data (replace strategy)
update_fields = {
"url": internal_data.get("url"),
"discipline": internal_data.get("discipline"),
"usage_timeline": internal_data.get("usage_timeline"),
"conversation_count": internal_data.get("conversation_count"),
"unique_users": internal_data.get("unique_users"),

View file

@ -32,6 +32,8 @@ class AiAgent(BaseModel):
quality_audit_updated_by_name: str | None = Field(default=None, title="Admin user name who updated quality audit")
risk_factor: int | None = Field(default=None, title="Risk factor rating (1-5)", ge=1, le=5)
last_edited_by: str | None = Field(default=None, title="Email of user who last edited this agent")
discipline: str | None = Field(default=None, title="Business discipline/category", max_length=100)
rating: float | None = Field(default=None, title="Star rating (1-5)", ge=1, le=5)
@ -98,6 +100,8 @@ class AiAgentCreate(BaseModel):
quality_audit_updated_by_name: Optional[str] = None
risk_factor: Optional[int] = Field(default=None, ge=1, le=5)
last_edited_by: Optional[str] = None
discipline: Optional[str] = None
rating: Optional[float] = Field(default=None, ge=1, le=5)
class AiAgentResponse(BaseModel):
agent_id: str
@ -123,6 +127,8 @@ class AiAgentResponse(BaseModel):
quality_audit_updated_by_name: Optional[str] = None
risk_factor: Optional[int] = None
last_edited_by: Optional[str] = None
discipline: Optional[str] = None
rating: Optional[float] = None
created_by: str
# Usage tracking fields (new)
@ -152,6 +158,7 @@ class AgentCollectorCreate(BaseModel):
tags: Optional[list[str]] = None
metadata: Optional[dict] = None
url: Optional[str] = None
discipline: Optional[str] = None
# Usage tracking fields (new)
usage_timeline: Optional[List[UsageTimelineEntry]] = None

View file

@ -402,4 +402,25 @@ h2 {
.container h2 {
-webkit-text-fill-color: var(--text-dark);
color: var(--text-dark);
}
/* Star rating styles */
.star-interactive, .star-clickable {
cursor: pointer;
font-size: 1.3rem;
transition: color 0.2s, transform 0.2s;
color: #dee2e6;
}
.star-interactive:hover, .star-clickable:hover {
transform: scale(1.2);
}
.star-rating-edit {
padding: 8px 0;
}
/* Discipline badge */
.bg-purple {
background-color: #6f42c1 !important;
}

View file

@ -416,7 +416,36 @@
<label for="editAgentCapabilities" class="form-label">Capabilities</label>
<input type="text" class="form-control" id="editAgentCapabilities" placeholder="Separate with commas">
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="editAgentDiscipline" class="form-label">
<i class="fas fa-layer-group me-2"></i>Discipline
</label>
<select class="form-select" id="editAgentDiscipline">
<option value="">Select Discipline</option>
<option value="Strategy">Strategy</option>
<option value="Creative">Creative</option>
<option value="Oversight including delivery">Oversight including delivery</option>
<option value="Optimization">Optimization</option>
<option value="Back Office including operations">Back Office including operations</option>
</select>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">
<i class="fas fa-star me-2"></i>Rating
</label>
<div class="star-rating-edit" id="editStarRating">
<span class="star-interactive" data-value="1">&#9733;</span>
<span class="star-interactive" data-value="2">&#9733;</span>
<span class="star-interactive" data-value="3">&#9733;</span>
<span class="star-interactive" data-value="4">&#9733;</span>
<span class="star-interactive" data-value="5">&#9733;</span>
</div>
<input type="hidden" id="editAgentRating" value="">
</div>
</div>
<div class="mb-3" id="editQualityAuditSection">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="editQualityAuditStatus" onchange="toggleAdminRiskFactor()">
@ -637,6 +666,21 @@
color: white !important;
}
.star-interactive {
cursor: pointer;
font-size: 1.3rem;
transition: color 0.2s, transform 0.2s;
color: #dee2e6;
}
.star-interactive:hover {
transform: scale(1.2);
}
.star-rating-edit {
padding: 8px 0;
}
@media (max-width: 768px) {
.stat-card {
text-align: center;
@ -661,6 +705,7 @@ let allAgents = [];
document.addEventListener('DOMContentLoaded', function() {
loadAdminData();
setupEventListeners();
setupEditStarRating();
});
function setupEventListeners() {
@ -1118,7 +1163,10 @@ async function editAgentAdmin(agentId) {
document.getElementById('editAgentTags').value = agent.agent_tags ? agent.agent_tags.join(', ') : '';
document.getElementById('editAgentUserbase').value = agent.agent_userbase ? agent.agent_userbase.join(', ') : '';
document.getElementById('editAgentCapabilities').value = agent.agent_capabilities ? agent.agent_capabilities.join(', ') : '';
document.getElementById('editAgentDiscipline').value = agent.discipline || '';
document.getElementById('editAgentRating').value = agent.rating || '';
updateEditStarDisplay(agent.rating || 0);
// Handle Quality Audit field (admin always has access)
document.getElementById('editQualityAuditStatus').checked = agent.quality_audit_status || false;
@ -1173,9 +1221,11 @@ async function handleEditAgentSubmit(e) {
agent_tags: document.getElementById('editAgentTags').value.split(',').map(s => s.trim()).filter(s => s),
agent_userbase: document.getElementById('editAgentUserbase').value.split(',').map(s => s.trim()).filter(s => s),
agent_capabilities: document.getElementById('editAgentCapabilities').value.split(',').map(s => s.trim()).filter(s => s),
discipline: document.getElementById('editAgentDiscipline').value || null,
rating: document.getElementById('editAgentRating').value ? parseFloat(document.getElementById('editAgentRating').value) : null,
quality_audit_status: document.getElementById('editQualityAuditStatus').checked
};
// Add Risk Factor
const riskFactorValue = document.getElementById('editRiskFactor').value;
agentData.risk_factor = riskFactorValue ? parseInt(riskFactorValue) : null;
@ -1346,5 +1396,45 @@ async function handleResetPasswordSubmit(e) {
showError('Failed to reset password');
}
}
// Star rating functions for edit modal
function setupEditStarRating() {
const container = document.getElementById('editStarRating');
if (!container) return;
const stars = container.querySelectorAll('.star-interactive');
const hiddenInput = document.getElementById('editAgentRating');
stars.forEach(star => {
star.addEventListener('mouseenter', function() {
const val = parseInt(this.dataset.value);
stars.forEach(s => {
s.style.color = parseInt(s.dataset.value) <= val ? '#ffc107' : '#dee2e6';
});
});
star.addEventListener('mouseleave', function() {
const current = parseInt(hiddenInput.value) || 0;
stars.forEach(s => {
s.style.color = parseInt(s.dataset.value) <= current ? '#ffc107' : '#dee2e6';
});
});
star.addEventListener('click', function() {
const val = parseInt(this.dataset.value);
hiddenInput.value = val;
stars.forEach(s => {
s.style.color = parseInt(s.dataset.value) <= val ? '#ffc107' : '#dee2e6';
});
});
});
}
function updateEditStarDisplay(rating) {
const container = document.getElementById('editStarRating');
if (!container) return;
const stars = container.querySelectorAll('.star-interactive');
const val = Math.round(rating) || 0;
stars.forEach(s => {
s.style.color = parseInt(s.dataset.value) <= val ? '#ffc107' : '#dee2e6';
});
}
</script>
{% endblock %}

View file

@ -77,7 +77,7 @@
<div class="card shadow-sm border-0">
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-3 mb-3 mb-md-0">
<div class="col-md-2 mb-3 mb-md-0">
<div class="input-group">
<span class="input-group-text"><i class="fas fa-search"></i></span>
<input type="text" class="form-control" id="searchInput" placeholder="Search agents...">
@ -99,11 +99,22 @@
<option value="not_audited">Not Audited</option>
</select>
</div>
<div class="col-md-3 mb-3 mb-md-0">
<div class="col-md-2 mb-3 mb-md-0">
<select class="form-select" id="disciplineFilter">
<option value="">All Disciplines</option>
<option value="Strategy">Strategy</option>
<option value="Creative">Creative</option>
<option value="Oversight including delivery">Oversight including delivery</option>
<option value="Optimization">Optimization</option>
<option value="Back Office including operations">Back Office including operations</option>
</select>
</div>
<div class="col-md-2 mb-3 mb-md-0">
<select class="form-select" id="sortBy">
<option value="created_at">Sort by Created Date</option>
<option value="name">Sort by Name</option>
<option value="status">Sort by Status</option>
<option value="rating">Sort by Rating</option>
<option value="total_messages">Sort by Total Messages</option>
<option value="total_tokens">Sort by Total Tokens</option>
<option value="unique_users">Sort by Unique Users</option>
@ -316,7 +327,36 @@
<label for="editAgentCapabilities" class="form-label">Capabilities</label>
<input type="text" class="form-control" id="editAgentCapabilities" placeholder="Separate with commas">
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="editAgentDiscipline" class="form-label">
<i class="fas fa-layer-group me-2"></i>Discipline
</label>
<select class="form-select" id="editAgentDiscipline">
<option value="">Select Discipline</option>
<option value="Strategy">Strategy</option>
<option value="Creative">Creative</option>
<option value="Oversight including delivery">Oversight including delivery</option>
<option value="Optimization">Optimization</option>
<option value="Back Office including operations">Back Office including operations</option>
</select>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">
<i class="fas fa-star me-2"></i>Rating
</label>
<div class="star-rating-edit" id="editStarRating">
<span class="star-interactive" data-value="1">&#9733;</span>
<span class="star-interactive" data-value="2">&#9733;</span>
<span class="star-interactive" data-value="3">&#9733;</span>
<span class="star-interactive" data-value="4">&#9733;</span>
<span class="star-interactive" data-value="5">&#9733;</span>
</div>
<input type="hidden" id="editAgentRating" value="">
</div>
</div>
<div class="mb-3" id="editQualityAuditSection">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="editQualityAuditStatus" onchange="toggleEditRiskFactor()">
@ -462,12 +502,14 @@ document.addEventListener('DOMContentLoaded', function() {
loadAgentsForCurrentView();
});
setupEventListeners();
setupEditStarRating();
});
function setupEventListeners() {
document.getElementById('searchInput').addEventListener('input', filterAgents);
document.getElementById('statusFilter').addEventListener('change', filterAgents);
document.getElementById('auditFilter').addEventListener('change', filterAgents);
document.getElementById('disciplineFilter').addEventListener('change', filterAgents);
document.getElementById('sortBy').addEventListener('change', sortAndDisplayAgents);
document.getElementById('refreshBtn').addEventListener('click', function() {
// Reload both datasets to ensure counts are accurate
@ -622,6 +664,8 @@ function displayAgents(agentsToShow) {
${agent.agent_status || 'Development'}
</span>
${agent.agent_version ? `<span class="badge bg-light text-dark">v${agent.agent_version}</span>` : ''}
${agent.discipline ? `<span class="badge bg-purple" title="Discipline"><i class="fas fa-layer-group me-1"></i>${agent.discipline}</span>` : ''}
${agent.rating ? `<span class="badge bg-warning text-dark" title="Rating: ${agent.rating}/5"><i class="fas fa-star me-1"></i>${agent.rating.toFixed(1)}</span>` : ''}
${agent.quality_audit_status ? '<span class="badge bg-success" title="Quality Audited"><i class="fas fa-certificate"></i></span>' : ''}
${agent.quality_audit_status && agent.risk_factor ? getRiskFactorBadge(agent.risk_factor) : ''}
${agent.total_messages ? `<span class="badge bg-info text-white" title="Total Messages"><i class="fas fa-comments me-1"></i>${agent.total_messages.toLocaleString()}</span>` : ''}
@ -710,6 +754,10 @@ async function showAgentDetails(agentId) {
<tr><td><strong>Version:</strong></td><td>${agent.agent_version || 'N/A'}</td></tr>
<tr><td><strong>Purpose:</strong></td><td>${agent.agent_purpose || 'N/A'}</td></tr>
${agent.url ? `<tr><td><strong>Agent Link:</strong></td><td><a href="${agent.url}" target="_blank" class="btn btn-sm btn-success"><i class="fas fa-external-link-alt me-1"></i>Open Agent</a></td></tr>` : ''}
<tr><td><strong>Discipline:</strong></td><td>${agent.discipline ? `<span class="badge bg-purple"><i class="fas fa-layer-group me-1"></i>${agent.discipline}</span>` : '<span class="text-muted">N/A</span>'}</td></tr>
<tr><td><strong>Rating:</strong></td><td>
<span id="detailRatingDisplay" data-agent-id="${agent.agent_id}">${agent.rating ? getStarDisplay(agent.rating) : '<span class="text-muted">Not yet rated</span>'}</span>
</td></tr>
<tr><td><strong>Quality Audit:</strong></td><td>
${agent.quality_audit_status ?
'<span class="badge bg-success"><i class="fas fa-certificate me-1"></i>Audited</span>' :
@ -852,7 +900,17 @@ async function showAgentDetails(agentId) {
const modal = new bootstrap.Modal(document.getElementById('agentModal'));
modal.show();
// Add clickable star rating if user can edit
if (canEdit) {
const ratingContainer = document.getElementById('detailRatingDisplay');
if (ratingContainer) {
const currentRating = agent.rating || 0;
ratingContainer.innerHTML = getClickableStars(currentRating, agent.agent_id);
setupDetailStarListeners(agent.agent_id, currentRating);
}
}
// Load usage data after modal is shown
loadUsageChart(agent.agent_name);
@ -905,7 +963,10 @@ async function showEditModal() {
document.getElementById('editAgentTags').value = agent.agent_tags ? agent.agent_tags.join(', ') : '';
document.getElementById('editAgentUserbase').value = agent.agent_userbase ? agent.agent_userbase.join(', ') : '';
document.getElementById('editAgentCapabilities').value = agent.agent_capabilities ? agent.agent_capabilities.join(', ') : '';
document.getElementById('editAgentDiscipline').value = agent.discipline || '';
document.getElementById('editAgentRating').value = agent.rating || '';
updateEditStarDisplay(agent.rating || 0);
// Handle Quality Audit field
const qualityAuditCheckbox = document.getElementById('editQualityAuditStatus');
const qualityAuditSection = document.getElementById('editQualityAuditSection');
@ -975,9 +1036,11 @@ async function updateAgent(e) {
agent_contact_person: document.getElementById('editAgentContact').value,
agent_tags: document.getElementById('editAgentTags').value.split(',').map(s => s.trim()).filter(s => s),
agent_userbase: document.getElementById('editAgentUserbase').value.split(',').map(s => s.trim()).filter(s => s),
agent_capabilities: document.getElementById('editAgentCapabilities').value.split(',').map(s => s.trim()).filter(s => s)
agent_capabilities: document.getElementById('editAgentCapabilities').value.split(',').map(s => s.trim()).filter(s => s),
discipline: document.getElementById('editAgentDiscipline').value || null,
rating: document.getElementById('editAgentRating').value ? parseFloat(document.getElementById('editAgentRating').value) : null
};
// Add Quality Audit status and Risk Factor if user is admin
if (currentUserIsAdmin) {
agentData.quality_audit_status = document.getElementById('editQualityAuditStatus').checked;
@ -1064,6 +1127,7 @@ function filterAgents() {
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
const statusFilter = document.getElementById('statusFilter').value;
const auditFilter = document.getElementById('auditFilter').value;
const disciplineFilter = document.getElementById('disciplineFilter').value;
let filtered = agents.filter(agent => {
const matchesSearch = agent.agent_name.toLowerCase().includes(searchTerm) ||
@ -1073,7 +1137,8 @@ function filterAgents() {
const matchesAudit = !auditFilter ||
(auditFilter === 'audited' && agent.quality_audit_status) ||
(auditFilter === 'not_audited' && !agent.quality_audit_status);
return matchesSearch && matchesStatus && matchesAudit;
const matchesDiscipline = !disciplineFilter || agent.discipline === disciplineFilter;
return matchesSearch && matchesStatus && matchesAudit && matchesDiscipline;
});
displayAgents(filtered);
@ -1100,6 +1165,8 @@ function sortAndDisplayAgents() {
const aVal = a.unique_users || 0;
const bVal = b.unique_users || 0;
return bVal - aVal; // Descending order
} else if (sortBy === 'rating') {
return (b.rating || 0) - (a.rating || 0); // Highest rated first, unrated at bottom
} else {
// Default: created_at (newest first)
return new Date(b.agent_created_at) - new Date(a.agent_created_at);
@ -1176,13 +1243,20 @@ function toggleEditRiskFactor() {
function validateEditForm() {
const qualityAuditStatus = document.getElementById('editQualityAuditStatus');
const riskFactor = document.getElementById('editRiskFactor');
const discipline = document.getElementById('editAgentDiscipline');
// Validate Discipline is selected
if (!discipline.value) {
showError('Discipline is required!');
return false;
}
// Validate Risk Factor if Quality Audit is checked and user is admin
if (currentUserIsAdmin && qualityAuditStatus.checked && (!riskFactor.value || riskFactor.value === '')) {
showError('Risk Factor is required when Quality Audit is checked!');
return false;
}
return true;
}
@ -1354,6 +1428,118 @@ function renderUsageChart(chartData) {
});
}
// Star rating helper functions
function getStarDisplay(rating) {
if (!rating) return '<span class="text-muted">Not yet rated</span>';
let stars = '';
for (let i = 1; i <= 5; i++) {
if (i <= Math.floor(rating)) {
stars += '<span class="text-warning">&#9733;</span>';
} else if (i - 0.5 <= rating) {
stars += '<span class="text-warning">&#9733;</span>';
} else {
stars += '<span class="text-muted">&#9734;</span>';
}
}
return stars + ` <small class="text-muted">(${rating.toFixed(1)})</small>`;
}
function getClickableStars(currentRating, agentId) {
let html = '';
for (let i = 1; i <= 5; i++) {
const filled = i <= Math.round(currentRating);
html += `<span class="star-clickable ${filled ? 'text-warning' : 'text-muted'}" data-value="${i}" data-agent-id="${agentId}">&#9733;</span>`;
}
return html;
}
function setupDetailStarListeners(agentId, currentRating) {
const stars = document.querySelectorAll('#detailRatingDisplay .star-clickable');
stars.forEach(star => {
star.addEventListener('mouseenter', function() {
const val = parseInt(this.dataset.value);
stars.forEach(s => {
s.classList.toggle('text-warning', parseInt(s.dataset.value) <= val);
s.classList.toggle('text-muted', parseInt(s.dataset.value) > val);
});
});
star.addEventListener('mouseleave', function() {
const saved = currentRating;
stars.forEach(s => {
s.classList.toggle('text-warning', parseInt(s.dataset.value) <= Math.round(saved));
s.classList.toggle('text-muted', parseInt(s.dataset.value) > Math.round(saved));
});
});
star.addEventListener('click', async function() {
const val = parseInt(this.dataset.value);
try {
const response = await fetch(`{{ base_path }}/api/agents/${agentId}/rating`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ rating: val })
});
if (response.ok) {
currentRating = val;
stars.forEach(s => {
s.classList.toggle('text-warning', parseInt(s.dataset.value) <= val);
s.classList.toggle('text-muted', parseInt(s.dataset.value) > val);
});
// Update agents cache
const idx = allAgents.findIndex(a => a.agent_id === agentId);
if (idx !== -1) allAgents[idx].rating = val;
const myIdx = myAgents.findIndex(a => a.agent_id === agentId);
if (myIdx !== -1) myAgents[myIdx].rating = val;
} else {
showError('Failed to save rating');
}
} catch (err) {
showError('Failed to save rating');
}
});
});
}
// Edit modal star rating interaction
function setupEditStarRating() {
const container = document.getElementById('editStarRating');
if (!container) return;
const stars = container.querySelectorAll('.star-interactive');
const hiddenInput = document.getElementById('editAgentRating');
stars.forEach(star => {
star.addEventListener('mouseenter', function() {
const val = parseInt(this.dataset.value);
stars.forEach(s => {
s.style.color = parseInt(s.dataset.value) <= val ? '#ffc107' : '#dee2e6';
});
});
star.addEventListener('mouseleave', function() {
const current = parseInt(hiddenInput.value) || 0;
stars.forEach(s => {
s.style.color = parseInt(s.dataset.value) <= current ? '#ffc107' : '#dee2e6';
});
});
star.addEventListener('click', function() {
const val = parseInt(this.dataset.value);
hiddenInput.value = val;
stars.forEach(s => {
s.style.color = parseInt(s.dataset.value) <= val ? '#ffc107' : '#dee2e6';
});
});
});
}
function updateEditStarDisplay(rating) {
const container = document.getElementById('editStarRating');
if (!container) return;
const stars = container.querySelectorAll('.star-interactive');
const val = Math.round(rating) || 0;
stars.forEach(s => {
s.style.color = parseInt(s.dataset.value) <= val ? '#ffc107' : '#dee2e6';
});
}
function setupChartEventListeners(agentName) {
// Period toggle listeners
document.querySelectorAll('input[name="period"]').forEach(radio => {

View file

@ -99,6 +99,19 @@
</div>
<div class="row">
<div class="col-md-6 mb-4">
<label for="agentDiscipline" class="form-label">
<i class="fas fa-layer-group me-2"></i>Discipline *
</label>
<select name="discipline" class="form-select" id="agentDiscipline" required>
<option value="">Select Discipline</option>
<option value="Strategy">Strategy</option>
<option value="Creative">Creative</option>
<option value="Oversight including delivery">Oversight including delivery</option>
<option value="Optimization">Optimization</option>
<option value="Back Office including operations">Back Office including operations</option>
</select>
</div>
<div class="col-md-6 mb-4">
<label for="agentLocation" class="form-label">
<i class="fas fa-map-marker-alt me-2"></i>Location
@ -319,7 +332,14 @@ document.getElementById('agentForm').addEventListener('submit', function(e) {
alert('Agent tool is required!');
return false;
}
const discipline = document.getElementById('agentDiscipline').value;
if (!discipline) {
e.preventDefault();
alert('Discipline is required!');
return false;
}
// Validate Risk Factor if Quality Audit is checked
if (qualityAuditStatus.checked && (!riskFactor.value || riskFactor.value === '')) {
e.preventDefault();