Add staff dashboard and work wizards v1
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Staff dashboard module."""
|
||||
@@ -0,0 +1,245 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.modules.clients.models import Client
|
||||
from app.modules.core.rbac.models import Role, UserRole
|
||||
from app.modules.employees.service import build_employee_scope, get_employee_for_user
|
||||
from app.modules.services.execution import CLOSED_TASK_STATUSES
|
||||
from app.modules.services.models import ClientServiceSubscription, ClientServiceTaskInstance, ServiceCatalogue, ServiceTaskComment
|
||||
|
||||
STAFF_ROLES = {"Staff", "Branch Manager", "Manager", "System Admin", "Firm Admin", "Partner"}
|
||||
STAFF_PERMISSIONS = {"employees.work.view_self", "employees.ess.view", "services.tasks.update_self"}
|
||||
CLIENT_PENDING_STATUSES = {"blocked", "client_pending", "waiting_client", "documents_pending"}
|
||||
RETURNED_STATUSES = {"returned", "correction_required", "rework", "rejected"}
|
||||
REVIEW_STATUSES = {"completed", "ready_review", "review_pending", "pending_review", "manager_review"}
|
||||
|
||||
|
||||
def get_user_role_names(db: Session, user_id: int) -> list[str]:
|
||||
return list(
|
||||
db.execute(
|
||||
select(Role.name)
|
||||
.join(UserRole, UserRole.role_id == Role.id)
|
||||
.where(UserRole.user_id == int(user_id), Role.is_active.is_(True))
|
||||
.order_by(Role.name.asc())
|
||||
).scalars().all()
|
||||
)
|
||||
|
||||
|
||||
def get_user_permission_names(db: Session, user_id: int) -> set[str]:
|
||||
rows = db.execute(
|
||||
select(Role.permissions)
|
||||
.join(UserRole, UserRole.role_id == Role.id)
|
||||
.where(UserRole.user_id == int(user_id), Role.is_active.is_(True))
|
||||
).scalars().all()
|
||||
permissions: set[str] = set()
|
||||
for value in rows:
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
permissions.update(str(v) for v in value if v)
|
||||
elif isinstance(value, str):
|
||||
cleaned = value.replace("[", "").replace("]", "").replace('"', "").replace("'", "")
|
||||
permissions.update(v.strip() for v in cleaned.split(",") if v.strip())
|
||||
return permissions
|
||||
|
||||
|
||||
def can_access_staff_dashboard(db: Session, current_user) -> bool:
|
||||
roles = set(get_user_role_names(db, current_user.id))
|
||||
if roles.intersection(STAFF_ROLES):
|
||||
return True
|
||||
if get_employee_for_user(db, current_user):
|
||||
return True
|
||||
return bool(get_user_permission_names(db, current_user.id).intersection(STAFF_PERMISSIONS))
|
||||
|
||||
|
||||
def _active_financial_year(request) -> str | None:
|
||||
value = request.session.get("active_financial_year") or getattr(request.state, "year_code", None)
|
||||
value = (value or "").strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def _scope(db: Session, request, current_user):
|
||||
return build_employee_scope(
|
||||
db,
|
||||
current_user,
|
||||
tenant_id=request.session.get("active_tenant_id") or getattr(current_user, "tenant_id", None),
|
||||
branch_id=request.session.get("active_branch_id"),
|
||||
)
|
||||
|
||||
|
||||
def _is_open(status: str | None) -> bool:
|
||||
return (status or "pending").strip().lower() not in CLOSED_TASK_STATUSES
|
||||
|
||||
|
||||
def _status_label(status: str | None) -> str:
|
||||
return (status or "pending").replace("_", " ").replace("-", " ").title()
|
||||
|
||||
|
||||
def _days_overdue(due_date) -> int:
|
||||
if not due_date:
|
||||
return 0
|
||||
delta = (date.today() - due_date).days
|
||||
return delta if delta > 0 else 0
|
||||
|
||||
|
||||
def _task_href(task: ClientServiceTaskInstance) -> str:
|
||||
task_id = getattr(task, "id", None)
|
||||
if task_id:
|
||||
return f"/employees/work/tasks/{task_id}/communication"
|
||||
return "/employees/work"
|
||||
|
||||
|
||||
def _service_name(task: ClientServiceTaskInstance) -> str:
|
||||
catalogue = getattr(task, "catalogue", None)
|
||||
service_name = getattr(catalogue, "service_name", None) or getattr(catalogue, "name", None)
|
||||
if not service_name:
|
||||
subscription = getattr(task, "subscription", None)
|
||||
sub_catalogue = getattr(subscription, "catalogue", None) if subscription else None
|
||||
service_name = getattr(sub_catalogue, "service_name", None) or getattr(sub_catalogue, "name", None)
|
||||
return service_name or "Service"
|
||||
|
||||
|
||||
def _task_row(task: ClientServiceTaskInstance) -> dict[str, Any]:
|
||||
today = date.today()
|
||||
client = getattr(task, "client", None)
|
||||
status = (getattr(task, "status", None) or "pending").strip().lower()
|
||||
due_date = getattr(task, "internal_target_date", None)
|
||||
is_open = _is_open(status)
|
||||
return {
|
||||
"id": getattr(task, "id", None),
|
||||
"client_name": getattr(client, "client_name", None) or "Unlinked Client",
|
||||
"client_code": getattr(client, "client_code", None) or "",
|
||||
"service_name": _service_name(task),
|
||||
"task_name": getattr(task, "task_name", None) or "Task",
|
||||
"period": getattr(task, "financial_year", None) or "-",
|
||||
"due_date": due_date,
|
||||
"status": status,
|
||||
"status_label": _status_label(status),
|
||||
"priority": getattr(task, "priority", None) or "normal",
|
||||
"remarks": getattr(task, "remarks", None) or "",
|
||||
"comment_count": len([c for c in getattr(task, "comments", []) if not getattr(c, "is_deleted", False)]),
|
||||
"document_count": len(getattr(task, "documents", []) or []),
|
||||
"is_open": is_open,
|
||||
"is_overdue": bool(due_date and due_date < today and is_open),
|
||||
"is_due_today": bool(due_date and due_date == today and is_open),
|
||||
"is_due_week": bool(due_date and today <= due_date <= today + timedelta(days=7) and is_open),
|
||||
"days_overdue": _days_overdue(due_date) if is_open else 0,
|
||||
"href": _task_href(task),
|
||||
}
|
||||
|
||||
|
||||
def _load_my_tasks(db: Session, scope, fy: str | None) -> list[ClientServiceTaskInstance]:
|
||||
stmt = (
|
||||
select(ClientServiceTaskInstance)
|
||||
.options(
|
||||
selectinload(ClientServiceTaskInstance.client),
|
||||
selectinload(ClientServiceTaskInstance.catalogue),
|
||||
selectinload(ClientServiceTaskInstance.assigned_to),
|
||||
selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by),
|
||||
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue),
|
||||
selectinload(ClientServiceTaskInstance.documents),
|
||||
)
|
||||
.where(
|
||||
ClientServiceTaskInstance.tenant_id == scope.tenant_id,
|
||||
ClientServiceTaskInstance.assigned_to_user_id == scope.own_user_id,
|
||||
ClientServiceTaskInstance.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
if getattr(scope, "branch_id", None) is not None:
|
||||
stmt = stmt.where(ClientServiceTaskInstance.branch_id == scope.branch_id)
|
||||
if fy:
|
||||
stmt = stmt.where(ClientServiceTaskInstance.financial_year == fy)
|
||||
stmt = stmt.order_by(
|
||||
ClientServiceTaskInstance.internal_target_date.is_(None),
|
||||
ClientServiceTaskInstance.internal_target_date.asc(),
|
||||
ClientServiceTaskInstance.priority.desc(),
|
||||
ClientServiceTaskInstance.id.desc(),
|
||||
).limit(250)
|
||||
return list(db.execute(stmt).scalars().all())
|
||||
|
||||
|
||||
def _report_cards() -> list[dict[str, str]]:
|
||||
return [
|
||||
{"group": "My Work", "title": "My Pending Tasks", "desc": "All open work assigned to me.", "href": "/staff/dashboard?tab=my-tasks"},
|
||||
{"group": "Due Control", "title": "Due Today", "desc": "Tasks that must be acted on today.", "href": "/staff/dashboard?tab=due-today"},
|
||||
{"group": "Due Control", "title": "Overdue Tasks", "desc": "Assigned open work past internal target date.", "href": "/staff/dashboard?tab=overdue"},
|
||||
{"group": "Client Follow-up", "title": "Client Pending", "desc": "Tasks blocked due to client data, documents or clarification pending.", "href": "/staff/dashboard?tab=client-pending"},
|
||||
{"group": "Documents", "title": "Document Pending", "desc": "Assigned work requiring document upload or verification follow-up.", "href": "/staff/dashboard?tab=documents"},
|
||||
{"group": "Correction", "title": "Returned Work", "desc": "Work returned for correction or rework.", "href": "/staff/dashboard?tab=returned-work"},
|
||||
]
|
||||
|
||||
|
||||
def _wizard_cards() -> list[dict[str, str]]:
|
||||
return [
|
||||
{"title": "My Task Work Wizard", "desc": "Open assigned task, read instructions, update status and add work notes.", "href": "/employees/work"},
|
||||
{"title": "Document Upload Wizard", "desc": "Open document area for uploading supporting records and work papers.", "href": "/documents"},
|
||||
{"title": "Mark Client Pending Wizard", "desc": "Use task communication to record document or clarification pending from client.", "href": "/staff/dashboard?tab=client-pending"},
|
||||
{"title": "Submit for Review Wizard", "desc": "Open completed tasks and submit them through existing task communication workflow.", "href": "/staff/dashboard?tab=my-tasks"},
|
||||
{"title": "Time / Remarks Wizard", "desc": "Update remarks and progress on current assigned task using existing work screen.", "href": "/employees/work"},
|
||||
{"title": "Correction / Returned Work Wizard", "desc": "Open returned tasks and complete rework before resubmission.", "href": "/staff/dashboard?tab=returned-work"},
|
||||
]
|
||||
|
||||
|
||||
def build_staff_dashboard_payload(db: Session, request, current_user) -> dict[str, Any]:
|
||||
scope = _scope(db, request, current_user)
|
||||
fy = _active_financial_year(request)
|
||||
today = date.today()
|
||||
tasks = _load_my_tasks(db, scope, fy)
|
||||
task_rows = [_task_row(t) for t in tasks]
|
||||
open_rows = [r for r in task_rows if r["is_open"]]
|
||||
completed_rows = [r for r in task_rows if not r["is_open"]]
|
||||
due_today_rows = [r for r in open_rows if r["is_due_today"]]
|
||||
overdue_rows = [r for r in open_rows if r["is_overdue"]]
|
||||
due_week_rows = [r for r in open_rows if r["is_due_week"]]
|
||||
client_pending_rows = [r for r in open_rows if r["status"] in CLIENT_PENDING_STATUSES]
|
||||
returned_rows = [r for r in open_rows if r["status"] in RETURNED_STATUSES]
|
||||
documents_rows = [r for r in open_rows if r["document_count"] > 0 or r["status"] in CLIENT_PENDING_STATUSES]
|
||||
review_rows = [r for r in task_rows if r["status"] in REVIEW_STATUSES]
|
||||
in_progress_rows = [r for r in open_rows if r["status"] == "in_progress"]
|
||||
|
||||
status_summary: dict[str, int] = {}
|
||||
service_summary: dict[str, int] = {}
|
||||
for row in open_rows:
|
||||
status_summary[row["status_label"]] = status_summary.get(row["status_label"], 0) + 1
|
||||
service_summary[row["service_name"]] = service_summary.get(row["service_name"], 0) + 1
|
||||
|
||||
employee = get_employee_for_user(db, current_user)
|
||||
overview = {
|
||||
"tenant_id": getattr(scope, "tenant_id", None),
|
||||
"branch_id": getattr(scope, "branch_id", None),
|
||||
"financial_year": fy,
|
||||
"employee": employee,
|
||||
"today": today,
|
||||
"total_tasks": len(task_rows),
|
||||
"open_count": len(open_rows),
|
||||
"in_progress_count": len(in_progress_rows),
|
||||
"due_today_count": len(due_today_rows),
|
||||
"due_week_count": len(due_week_rows),
|
||||
"overdue_count": len(overdue_rows),
|
||||
"client_pending_count": len(client_pending_rows),
|
||||
"documents_count": len(documents_rows),
|
||||
"returned_count": len(returned_rows),
|
||||
"review_count": len(review_rows),
|
||||
"completed_count": len(completed_rows),
|
||||
}
|
||||
|
||||
return {
|
||||
"roles": sorted(get_user_role_names(db, current_user.id)),
|
||||
"overview": overview,
|
||||
"my_tasks": open_rows[:100],
|
||||
"due_today": due_today_rows[:50],
|
||||
"due_week": due_week_rows[:50],
|
||||
"overdue": overdue_rows[:50],
|
||||
"client_pending": client_pending_rows[:50],
|
||||
"documents_pending": documents_rows[:50],
|
||||
"returned_work": returned_rows[:50],
|
||||
"completed": completed_rows[:50],
|
||||
"review_ready": review_rows[:50],
|
||||
"status_summary": sorted([{"label": k, "count": v} for k, v in status_summary.items()], key=lambda x: x["count"], reverse=True)[:10],
|
||||
"service_summary": sorted([{"label": k, "count": v} for k, v in service_summary.items()], key=lambda x: x["count"], reverse=True)[:10],
|
||||
"reports": _report_cards(),
|
||||
"wizards": _wizard_cards(),
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<section class="rounded-3xl bg-gradient-to-r from-slate-900 via-brand-700 to-brand-600 p-6 text-white shadow-soft">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-brand-100">Staff Work Execution</p>
|
||||
<h1 class="mt-2 text-2xl font-bold">Staff Dashboard V1</h1>
|
||||
<p class="mt-2 max-w-3xl text-sm text-brand-100">Your assigned tasks, due work, client-pending items, documents, returned work and staff work wizards in one place.</p>
|
||||
<p class="mt-3 text-xs text-brand-100">FY: {{ overview.financial_year or 'All' }}{% if overview.employee %} · {{ overview.employee.full_name }}{% endif %}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/employees/work" class="rounded-xl bg-white px-4 py-2 text-sm font-semibold text-brand-700 hover:bg-brand-50">Open My Work</a>
|
||||
<a href="/documents" class="rounded-xl border border-white/40 px-4 py-2 text-sm font-semibold text-white hover:bg-white/10">Documents</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% set tabs = [
|
||||
('my-tasks','My Tasks'),
|
||||
('due-today','Due Today'),
|
||||
('overdue','Overdue'),
|
||||
('client-pending','Client Pending'),
|
||||
('documents','Documents'),
|
||||
('returned-work','Returned Work'),
|
||||
('reports','Reports'),
|
||||
('wizards','Wizards')
|
||||
] %}
|
||||
|
||||
<section class="rounded-3xl border border-slate-200 bg-white p-3 shadow-soft">
|
||||
<div class="flex gap-2 overflow-x-auto" id="staff-tabs">
|
||||
{% for code, label in tabs %}
|
||||
<button type="button" data-tab="{{ code }}" class="staff-tab whitespace-nowrap rounded-2xl px-4 py-2 text-sm font-semibold transition {% if active_tab == code %}bg-brand-600 text-white{% else %}text-slate-600 hover:bg-slate-100{% endif %}">{{ label }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="staff-dashboard-panel">
|
||||
{% include 'modules/staff_dashboard/templates/staff_dashboard/partials/' ~ (active_tab|replace('-', '_')) ~ '.html' ignore missing %}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
const tabs = document.querySelectorAll('.staff-tab');
|
||||
const panel = document.getElementById('staff-dashboard-panel');
|
||||
async function loadTab(tab) {
|
||||
tabs.forEach(btn => {
|
||||
const active = btn.dataset.tab === tab;
|
||||
btn.classList.toggle('bg-brand-600', active);
|
||||
btn.classList.toggle('text-white', active);
|
||||
btn.classList.toggle('text-slate-600', !active);
|
||||
btn.classList.toggle('hover:bg-slate-100', !active);
|
||||
});
|
||||
panel.innerHTML = '<div class="rounded-3xl border border-slate-200 bg-white p-8 text-center text-sm text-slate-500 shadow-soft">Loading...</div>';
|
||||
try {
|
||||
const res = await fetch('/staff/dashboard/tab/' + encodeURIComponent(tab), {headers: {'X-Requested-With':'fetch'}});
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
panel.innerHTML = await res.text();
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('tab', tab);
|
||||
window.history.replaceState({}, '', url);
|
||||
} catch (err) {
|
||||
panel.innerHTML = '<div class="rounded-3xl border border-red-200 bg-red-50 p-8 text-center text-sm font-semibold text-red-700 shadow-soft">Unable to load tab. Please refresh the page.</div>';
|
||||
}
|
||||
}
|
||||
tabs.forEach(btn => btn.addEventListener('click', () => loadTab(btn.dataset.tab)));
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,46 @@
|
||||
<div class="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-soft">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Client</th>
|
||||
<th class="px-4 py-3">Service / Task</th>
|
||||
<th class="px-4 py-3">Due</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Priority</th>
|
||||
<th class="px-4 py-3">Docs / Notes</th>
|
||||
<th class="px-4 py-3 text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for row in rows %}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3">
|
||||
<div class="font-semibold text-slate-900">{{ row.client_name }}</div>
|
||||
<div class="text-xs text-slate-500">{{ row.client_code or '-' }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-brand-600">{{ row.service_name }}</div>
|
||||
<div class="font-medium text-slate-900">{{ row.task_name }}</div>
|
||||
<div class="text-xs text-slate-500">Period: {{ row.period }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
{% if row.due_date %}
|
||||
<div class="font-medium {% if row.is_overdue %}text-red-700{% elif row.is_due_today %}text-amber-700{% else %}text-slate-700{% endif %}">{{ row.due_date.strftime('%d-%b-%Y') }}</div>
|
||||
{% if row.is_overdue %}<div class="text-xs text-red-600">{{ row.days_overdue }} day(s) overdue</div>{% endif %}
|
||||
{% else %}
|
||||
<span class="text-slate-400">No due date</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3"><span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700">{{ row.status_label }}</span></td>
|
||||
<td class="px-4 py-3"><span class="rounded-full bg-brand-50 px-3 py-1 text-xs font-semibold text-brand-700">{{ row.priority|title }}</span></td>
|
||||
<td class="px-4 py-3 text-xs text-slate-500">Docs: {{ row.document_count }}<br>Notes: {{ row.comment_count }}</td>
|
||||
<td class="px-4 py-3 text-right"><a href="{{ row.href }}" class="rounded-xl bg-brand-600 px-3 py-2 text-xs font-semibold text-white hover:bg-brand-700">Open</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="px-4 py-10 text-center text-sm text-slate-500">No tasks found for this view.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-3xl border border-orange-200 bg-orange-50 p-5 shadow-soft"><h2 class="text-lg font-bold text-orange-900">Client Pending</h2><p class="mt-1 text-sm text-orange-700">Work blocked due to client data, document or clarification pending.</p></div>
|
||||
{% set rows = client_pending %}
|
||||
{% include 'modules/staff_dashboard/templates/staff_dashboard/partials/_task_table.html' %}
|
||||
</div>
|
||||
@@ -0,0 +1,8 @@
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col gap-3 rounded-3xl border border-slate-200 bg-white p-5 shadow-soft md:flex-row md:items-center md:justify-between">
|
||||
<div><h2 class="text-lg font-bold text-slate-900">Documents</h2><p class="mt-1 text-sm text-slate-500">Document-linked or document-pending tasks assigned to you.</p></div>
|
||||
<a href="/documents" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Open Documents</a>
|
||||
</div>
|
||||
{% set rows = documents_pending %}
|
||||
{% include 'modules/staff_dashboard/templates/staff_dashboard/partials/_task_table.html' %}
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-3xl border border-amber-200 bg-amber-50 p-5 shadow-soft"><h2 class="text-lg font-bold text-amber-900">Due Today</h2><p class="mt-1 text-sm text-amber-700">Tasks assigned to you with internal target date today.</p></div>
|
||||
{% set rows = due_today %}
|
||||
{% include 'modules/staff_dashboard/templates/staff_dashboard/partials/_task_table.html' %}
|
||||
</div>
|
||||
@@ -0,0 +1,10 @@
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft"><p class="text-xs font-semibold uppercase text-slate-500">Open Tasks</p><p class="mt-2 text-3xl font-bold text-slate-900">{{ overview.open_count }}</p></div>
|
||||
<div class="rounded-3xl border border-amber-200 bg-amber-50 p-5 shadow-soft"><p class="text-xs font-semibold uppercase text-amber-700">Due Today</p><p class="mt-2 text-3xl font-bold text-amber-800">{{ overview.due_today_count }}</p></div>
|
||||
<div class="rounded-3xl border border-red-200 bg-red-50 p-5 shadow-soft"><p class="text-xs font-semibold uppercase text-red-700">Overdue</p><p class="mt-2 text-3xl font-bold text-red-800">{{ overview.overdue_count }}</p></div>
|
||||
<div class="rounded-3xl border border-brand-200 bg-brand-50 p-5 shadow-soft"><p class="text-xs font-semibold uppercase text-brand-700">In Progress</p><p class="mt-2 text-3xl font-bold text-brand-800">{{ overview.in_progress_count }}</p></div>
|
||||
</div>
|
||||
{% set rows = my_tasks %}
|
||||
{% include 'modules/staff_dashboard/templates/staff_dashboard/partials/_task_table.html' %}
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-3xl border border-red-200 bg-red-50 p-5 shadow-soft"><h2 class="text-lg font-bold text-red-900">Overdue Tasks</h2><p class="mt-1 text-sm text-red-700">Assigned open tasks past internal target date.</p></div>
|
||||
{% set rows = overdue %}
|
||||
{% include 'modules/staff_dashboard/templates/staff_dashboard/partials/_task_table.html' %}
|
||||
</div>
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="space-y-5">
|
||||
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft"><h2 class="text-lg font-bold text-slate-900">Staff Reports</h2><p class="mt-1 text-sm text-slate-500">Quick report cards based on your assigned work.</p></div>
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{% for card in reports %}
|
||||
<a href="{{ card.href }}" class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft transition hover:-translate-y-0.5 hover:shadow-lg">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-brand-600">{{ card.group }}</p>
|
||||
<h3 class="mt-2 text-base font-bold text-slate-900">{{ card.title }}</h3>
|
||||
<p class="mt-2 text-sm text-slate-500">{{ card.desc }}</p>
|
||||
<p class="mt-4 text-sm font-semibold text-brand-700">View report →</p>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-3xl border border-purple-200 bg-purple-50 p-5 shadow-soft"><h2 class="text-lg font-bold text-purple-900">Returned / Correction Work</h2><p class="mt-1 text-sm text-purple-700">Tasks returned for correction, rework or resubmission.</p></div>
|
||||
{% set rows = returned_work %}
|
||||
{% include 'modules/staff_dashboard/templates/staff_dashboard/partials/_task_table.html' %}
|
||||
</div>
|
||||
@@ -0,0 +1,12 @@
|
||||
<div class="space-y-5">
|
||||
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft"><h2 class="text-lg font-bold text-slate-900">Staff Work Wizards</h2><p class="mt-1 text-sm text-slate-500">Action shortcuts for task execution using existing ERP screens.</p></div>
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{% for card in wizards %}
|
||||
<a href="{{ card.href }}" class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft transition hover:-translate-y-0.5 hover:shadow-lg">
|
||||
<h3 class="text-base font-bold text-slate-900">{{ card.title }}</h3>
|
||||
<p class="mt-2 text-sm text-slate-500">{{ card.desc }}</p>
|
||||
<p class="mt-4 text-sm font-semibold text-brand-700">Open →</p>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from app.core.db.common import CommonSessionLocal
|
||||
from app.core.http_responses import ui_access_denied
|
||||
from app.core.security.csrf import get_or_create_csrf_token
|
||||
from app.core.security.session_auth import get_current_user
|
||||
from app.core.templating import templates
|
||||
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
||||
from app.modules.staff_dashboard.service import build_staff_dashboard_payload, can_access_staff_dashboard
|
||||
|
||||
router = APIRouter(prefix="/staff", tags=["staff-dashboard-v1-ui"])
|
||||
|
||||
VALID_TABS = {
|
||||
"my-tasks": "modules/staff_dashboard/templates/staff_dashboard/partials/my_tasks.html",
|
||||
"due-today": "modules/staff_dashboard/templates/staff_dashboard/partials/due_today.html",
|
||||
"overdue": "modules/staff_dashboard/templates/staff_dashboard/partials/overdue.html",
|
||||
"client-pending": "modules/staff_dashboard/templates/staff_dashboard/partials/client_pending.html",
|
||||
"documents": "modules/staff_dashboard/templates/staff_dashboard/partials/documents.html",
|
||||
"returned-work": "modules/staff_dashboard/templates/staff_dashboard/partials/returned_work.html",
|
||||
"reports": "modules/staff_dashboard/templates/staff_dashboard/partials/reports.html",
|
||||
"wizards": "modules/staff_dashboard/templates/staff_dashboard/partials/wizards.html",
|
||||
}
|
||||
|
||||
|
||||
def _ctx(request: Request, db, current_user, *, active_tab: str = "my-tasks"):
|
||||
payload = build_staff_dashboard_payload(db, request, current_user)
|
||||
return {
|
||||
"request": request,
|
||||
"current_user": current_user,
|
||||
"current_user_roles": get_user_roles(db, current_user.id),
|
||||
"current_user_permissions": get_user_permissions(db, current_user.id),
|
||||
"csrf_token": get_or_create_csrf_token(request),
|
||||
"title": "Staff Dashboard",
|
||||
"active_tab": active_tab,
|
||||
**payload,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/dashboard")
|
||||
def dashboard(request: Request, tab: str = "my-tasks"):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
current_user = get_current_user(request, db=db)
|
||||
if not current_user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
if not can_access_staff_dashboard(db, current_user):
|
||||
return ui_access_denied()
|
||||
active_tab = tab if tab in VALID_TABS else "my-tasks"
|
||||
return templates.TemplateResponse(
|
||||
"modules/staff_dashboard/templates/staff_dashboard/dashboard.html",
|
||||
_ctx(request, db, current_user, active_tab=active_tab),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/dashboard/tab/{tab_name}")
|
||||
def dashboard_tab(request: Request, tab_name: str):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
current_user = get_current_user(request, db=db)
|
||||
if not current_user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
if not can_access_staff_dashboard(db, current_user):
|
||||
return ui_access_denied()
|
||||
active_tab = tab_name if tab_name in VALID_TABS else "my-tasks"
|
||||
return templates.TemplateResponse(
|
||||
VALID_TABS[active_tab],
|
||||
_ctx(request, db, current_user, active_tab=active_tab),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
@@ -3,6 +3,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.modules.clients.ui import router as clients_ui_router, portal_router as client_portal_router
|
||||
from app.modules.consultants.ui import router as consultants_ui_router, portal_router as consultant_portal_router
|
||||
from app.modules.staff_dashboard.ui import router as staff_dashboard_router
|
||||
from app.modules.employees.ui import router as employees_ui_router, portal_router as employee_portal_router
|
||||
from app.modules.manager_dashboard.ui import router as manager_dashboard_router
|
||||
from app.modules.managers.ui import router as managers_ui_router
|
||||
@@ -54,6 +55,7 @@ def mount_ui(app: FastAPI) -> None:
|
||||
app.include_router(system_admin_dashboard_router)
|
||||
app.include_router(work_detail_ui_router)
|
||||
app.include_router(clients_ui_router)
|
||||
app.include_router(staff_dashboard_router)
|
||||
app.include_router(employees_ui_router)
|
||||
app.include_router(manager_dashboard_router)
|
||||
app.include_router(managers_ui_router)
|
||||
@@ -66,3 +68,4 @@ def mount_ui(app: FastAPI) -> None:
|
||||
app.include_router(consultant_portal_router)
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user