diff --git a/CLAUDE.md b/CLAUDE.md index 81f2048..72c1bf0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` diff --git a/crud.py b/crud.py index 127a69d..898b1d8 100644 --- a/crud.py +++ b/crud.py @@ -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: diff --git a/main.py b/main.py index d01aea6..f9e4ad6 100644 --- a/main.py +++ b/main.py @@ -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"), diff --git a/models.py b/models.py index 0ccb554..9064bf0 100644 --- a/models.py +++ b/models.py @@ -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 diff --git a/static/style.css b/static/style.css index b5c89b4..7cb1d29 100644 --- a/static/style.css +++ b/static/style.css @@ -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; } \ No newline at end of file diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html index db2d8ed..9524cd1 100644 --- a/templates/admin/dashboard.html +++ b/templates/admin/dashboard.html @@ -416,7 +416,36 @@ - + +
+
+ + +
+
+ +
+ + + + + +
+ +
+
+
@@ -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'; + }); +} {% endblock %} \ No newline at end of file diff --git a/templates/agent_management.html b/templates/agent_management.html index a71df4d..0390ec4 100644 --- a/templates/agent_management.html +++ b/templates/agent_management.html @@ -77,7 +77,7 @@
-
+
@@ -99,11 +99,22 @@
-
+
+ +
+
- + +
+
+ + +
+
+ +
+ + + + + +
+ +
+
+
@@ -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'} ${agent.agent_version ? `v${agent.agent_version}` : ''} + ${agent.discipline ? `${agent.discipline}` : ''} + ${agent.rating ? `${agent.rating.toFixed(1)}` : ''} ${agent.quality_audit_status ? '' : ''} ${agent.quality_audit_status && agent.risk_factor ? getRiskFactorBadge(agent.risk_factor) : ''} ${agent.total_messages ? `${agent.total_messages.toLocaleString()}` : ''} @@ -710,6 +754,10 @@ async function showAgentDetails(agentId) { Version:${agent.agent_version || 'N/A'} Purpose:${agent.agent_purpose || 'N/A'} ${agent.url ? `Agent Link:Open Agent` : ''} + Discipline:${agent.discipline ? `${agent.discipline}` : 'N/A'} + Rating: + ${agent.rating ? getStarDisplay(agent.rating) : 'Not yet rated'} + Quality Audit: ${agent.quality_audit_status ? 'Audited' : @@ -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 'Not yet rated'; + let stars = ''; + for (let i = 1; i <= 5; i++) { + if (i <= Math.floor(rating)) { + stars += ''; + } else if (i - 0.5 <= rating) { + stars += ''; + } else { + stars += ''; + } + } + return stars + ` (${rating.toFixed(1)})`; +} + +function getClickableStars(currentRating, agentId) { + let html = ''; + for (let i = 1; i <= 5; i++) { + const filled = i <= Math.round(currentRating); + html += ``; + } + 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 => { diff --git a/templates/agent_register.html b/templates/agent_register.html index ab31c08..2f6c875 100644 --- a/templates/agent_register.html +++ b/templates/agent_register.html @@ -99,6 +99,19 @@
+
+ + +