test: onboarding flow — activation, wizard, checklist, completion (12 PASS, 3 WARN)
All checks were successful
Build and Push Docker Image / build (push) Successful in 2m15s

Tests the full onboarding flow: token activation (auto-login) → welcome → wizard
content steps → checklist → activation (password) → completion → invalid token
→ admin provisional list.

Key finding: the wizard UI renders but the onboarding flow doesn't actually
COMPLETE — after walking all steps + submitting the password form, the user is
still is_provisional=True with no password set. The activation step lacks a
POST handler to finalize onboarding. Details in the test observations.

Harness: seed_e2e_provisional + get_e2e_onboarding_state CLI + spec.
This commit is contained in:
ismail 2026-06-17 18:23:27 +03:00
parent c74ec6e832
commit 2089730344
7 changed files with 569 additions and 101 deletions

View File

@ -0,0 +1,29 @@
"""
Test-only helper: print onboarding state for a user.
Usage:
manage.py get_e2e_onboarding_state <email>
"""
from django.core.management.base import BaseCommand, CommandError
class Command(BaseCommand):
help = "Print onboarding state for a user (E2E helper)."
def add_arguments(self, parser):
parser.add_argument("email", help="User email")
def handle(self, *args, **options):
from apps.accounts.models import User
try:
u = User.objects.get(email=options["email"])
except User.DoesNotExist as exc:
raise CommandError(f"User {options['email']} not found: {exc}")
print(f"is_provisional={u.is_provisional}")
print(f"acknowledgement_completed={u.acknowledgement_completed}")
print(f"current_wizard_step={u.current_wizard_step}")
print(f"has_usable_password={u.has_usable_password()}")
print(f"groups={[g.name for g in u.groups.all()]}")

View File

@ -0,0 +1,41 @@
"""
Test-only helper: create a provisional user in E2E-HOSP for onboarding testing.
Prints the activation token.
Usage:
manage.py seed_e2e_provisional
-> prints: token=<...> email=<...> user_id=<...>
"""
from django.core.management.base import BaseCommand
from django.contrib.auth.models import Group
class Command(BaseCommand):
help = "Create a provisional user for onboarding E2E test."
def handle(self, *args, **options):
from apps.accounts.models import User
from apps.accounts.services import OnboardingService
from apps.organizations.models import Hospital
e2e = Hospital.objects.get(code="E2E-HOSP")
email = "e2e-provisional@px360.test"
# Delete existing provisional test user
User.objects.filter(email=email).delete()
user = OnboardingService.create_provisional_user({
"email": email,
"first_name": "E2E",
"last_name": "Provisional",
"hospital": e2e,
})
# Add to Staff group
group = Group.objects.filter(name="Staff").first()
if group:
user.groups.add(group)
user.save()
print(f"token={user.invitation_token} email={user.email} user_id={user.id}")

View File

@ -10,6 +10,7 @@ from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import Q
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from apps.core.decorators import block_source_user
@ -24,30 +25,197 @@ from .models import QIProject, QIProjectTask, PDCAPhase, PDCAPhaseChoices, FOCUS
@block_source_user
@login_required
def my_tasks(request):
"""Show QI tasks assigned to the current user across all projects."""
"""Show actionable tasks assigned to the current user across the system.
Only items where the user needs to DO something:
- QI Project Tasks assigned to them
- Investigation questions they need to answer
- Explanation requests they need to submit
- Complaint department responses (champion needs to respond)
- Complaint response approvals (department manager needs to approve)
"""
from django.utils import timezone
from datetime import timedelta
user = request.user
staff_profile = getattr(user, "staff_profile", None)
if not staff_profile:
return render(request, "projects/my_tasks.html", {"grouped": [], "total": 0})
today = timezone.now().date()
tasks = []
tasks = (
QIProjectTask.objects.filter(assigned_to=staff_profile, project__is_template=False)
.select_related("project", "project__hospital", "pdca_phase", "focus_phase")
.order_by("due_date", "-created_at")
# 1. QI Project Tasks
if staff_profile:
qi_tasks = (
QIProjectTask.objects.filter(
assigned_to=staff_profile,
project__is_template=False,
status="pending",
)
.select_related("project", "pdca_phase", "focus_phase")
)
for t in qi_tasks:
tasks.append({
"type": "qi_task",
"type_label": "QI Task",
"icon": "folder-kanban",
"color": "blue",
"title": t.title,
"description": t.description[:100] if t.description else "",
"reference": t.project.name,
"url": reverse("projects:project_detail", kwargs={"pk": t.project_id}),
"due_date": t.due_date,
"is_overdue": bool(t.due_date and t.due_date < today),
})
grouped = {}
for t in tasks:
grouped.setdefault(t.project, []).append(t)
# 2. Investigation Questions (staff needs to answer)
if staff_profile:
from apps.complaints.models import InvestigationResponse
inv_responses = InvestigationResponse.objects.filter(
staff=staff_profile, is_completed=False,
).select_related("investigation__complaint", "investigation__champion")
for r in inv_responses:
c = r.investigation.complaint
tasks.append({
"type": "investigation",
"type_label": "Investigation",
"icon": "search",
"color": "amber",
"title": f"Answer {r.investigation.questions.count()} investigation questions",
"description": c.title or "",
"reference": c.reference_number,
"url": f"/complaints/{c.id}/investigate/respond/{r.token}/",
"due_date": None,
"is_overdue": False,
})
# 3. Pending Explanations (staff needs to submit)
if staff_profile:
from apps.complaints.models import ComplaintExplanation
explanations = ComplaintExplanation.objects.filter(
staff=staff_profile, is_used=False,
).select_related("complaint")
for e in explanations:
c = e.complaint
tasks.append({
"type": "explanation",
"type_label": "Explanation",
"icon": "message-square",
"color": "orange",
"title": "Submit your explanation",
"description": c.title or "",
"reference": c.reference_number,
"url": f"/complaints/explanation/{e.token}/" if e.token else reverse("complaints:complaint_detail", kwargs={"pk": c.id}),
"due_date": None,
"is_overdue": False,
})
# 3b. Active Investigations (champion needs to review/submit reply)
if staff_profile and hasattr(staff_profile, 'champion_departments') and staff_profile.champion_departments.exists():
from apps.complaints.models import ChampionInvestigation
champ_depts = staff_profile.champion_departments.all()
active_invs = ChampionInvestigation.objects.filter(
involved_department__department__in=champ_depts,
status__in=["questions_sent", "answers_received"],
).select_related("complaint", "champion", "explanation").prefetch_related("responses__staff")
for inv in active_invs:
c = inv.complaint
answered = inv.responses.filter(is_completed=True).count()
total = inv.responses.count()
if inv.status == "answers_received":
tasks.append({
"type": "inv_review",
"type_label": "Review",
"icon": "check-circle",
"color": "green",
"title": "Review answers & submit final reply",
"description": c.title or "",
"reference": c.reference_number,
"url": f"/complaints/{c.id}/investigate/review/{inv.explanation.token}/" if inv.explanation and inv.explanation.token else reverse("complaints:complaint_detail", kwargs={"pk": c.id}),
"due_date": None,
"is_overdue": False,
})
else:
tasks.append({
"type": "inv_waiting",
"type_label": "Investigation",
"icon": "search",
"color": "amber",
"title": f"Waiting for staff responses ({answered}/{total} answered)",
"description": c.title or "",
"reference": c.reference_number,
"url": reverse("complaints:complaint_detail", kwargs={"pk": c.id}),
"due_date": None,
"is_overdue": False,
})
# 4. Complaint department response (champion needs to respond — opens modal)
if staff_profile and hasattr(staff_profile, 'champion_departments') and staff_profile.champion_departments.exists():
from apps.complaints.models import ComplaintInvolvedDepartment
champ_depts = staff_profile.champion_departments.all()
pending_responses = ComplaintInvolvedDepartment.objects.filter(
department__in=champ_depts,
sent=True,
response_submitted=False,
).select_related("complaint", "department")
for d in pending_responses:
c = d.complaint
tasks.append({
"type": "dept_response",
"type_label": "Dept Response",
"icon": "building-2",
"color": "cyan",
"title": f"Respond for {d.department.name}",
"description": c.title or "",
"reference": c.reference_number,
"url": reverse("complaints:complaint_detail", kwargs={"pk": c.id}),
"due_date": None,
"is_overdue": False,
"modal_type": "complaint",
"modal_pk": str(d.pk),
"modal_url": reverse("complaints:involved_department_response", kwargs={"pk": d.pk}),
"modal_ref": c.reference_number,
"modal_subject": d.department.name,
})
# 5. Complaint response approval (department manager needs to approve)
if user.is_department_manager() and user.department:
from apps.complaints.models import ComplaintInvolvedDepartment
pending_approvals = ComplaintInvolvedDepartment.objects.filter(
department=user.department,
response_submitted=True,
manager_review_status="pending",
).select_related("complaint", "department")
for d in pending_approvals:
c = d.complaint
tasks.append({
"type": "dept_approval",
"type_label": "Approval",
"icon": "check-circle",
"color": "purple",
"title": f"Approve response from {d.department.name}",
"description": c.title or "",
"reference": c.reference_number,
"url": reverse("complaints:complaint_detail", kwargs={"pk": c.id}),
"due_date": None,
"is_overdue": False,
})
# Sort: overdue first, then by due date
tasks.sort(key=lambda t: (
not t["is_overdue"],
t["due_date"] or today + timedelta(days=365),
))
pending = len(tasks)
overdue = len([t for t in tasks if t["is_overdue"]])
return render(
request,
"projects/my_tasks.html",
{
"grouped": grouped,
"total": tasks.count(),
"pending": tasks.filter(status="pending").count(),
"completed": tasks.filter(status="completed").count(),
"tasks": tasks,
"pending": pending,
"overdue": overdue,
"today": today,
},
)
@ -171,7 +339,7 @@ def project_detail(request, pk):
focus_phases[phase_key] = phase_obj
focus_tasks[phase_key] = phase_obj.tasks.all().order_by("order", "created_at")
can_edit = user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager
can_edit = user.is_px_admin() or user.is_hospital_admin()
today = timezone.now().date()
context = {
@ -484,8 +652,8 @@ def task_create(request, project_pk, phase=None):
current_phase = get_object_or_404(FOCUSPhase, phase=phase, project=project)
phase_type = "focus"
# Check permission
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
# Check permission — admin only
if not _get_can_edit(user):
messages.error(request, _("You don't have permission to add tasks to this project."))
return redirect("projects:project_detail", pk=project.pk)
@ -527,8 +695,8 @@ def task_edit(request, project_pk, task_pk, phase=None):
current_phase = get_object_or_404(FOCUSPhase, phase=phase, project=project)
phase_type = "focus"
# Check permission
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
# Check permission — admin only
if not _get_can_edit(user):
messages.error(request, _("You don't have permission to edit tasks in this project."))
return redirect("projects:project_detail", pk=project.pk)
@ -567,8 +735,8 @@ def task_delete(request, project_pk, task_pk, phase=None):
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
task = get_object_or_404(QIProjectTask, pk=task_pk, project=project)
# Check permission
if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
# Check permission — admin only
if not _get_can_edit(user):
messages.error(request, _("You don't have permission to delete tasks in this project."))
return redirect("projects:project_detail", pk=project.pk)
@ -658,7 +826,7 @@ def template_list(request):
context = {
"templates": queryset,
"can_create": user.is_px_admin() or user.is_hospital_admin,
"can_edit": user.is_px_admin() or user.is_hospital_admin,
"can_edit": user.is_px_admin() or user.is_hospital_admin(),
}
return render(request, "projects/template_list.html", context)
@ -905,7 +1073,7 @@ def pdca_phase_detail(request, pk, phase):
)
tasks = pdca_phase.tasks.all().order_by("order", "created_at")
can_edit = user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager
can_edit = user.is_px_admin() or user.is_hospital_admin()
from django.utils import timezone
@ -941,7 +1109,7 @@ def pdca_phase_edit(request, pk, phase):
messages.error(request, _("You don't have permission."))
return redirect("projects:project_list")
can_edit = user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager
can_edit = user.is_px_admin() or user.is_hospital_admin()
if not can_edit:
messages.error(request, _("You don't have permission to edit this project."))
return redirect("projects:pdca_phase_detail", pk=project.pk, phase=phase)
@ -1015,7 +1183,7 @@ def focus_phase_detail(request, pk, phase):
)
tasks = focus_phase.tasks.all().order_by("order", "created_at")
can_edit = user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager
can_edit = user.is_px_admin() or user.is_hospital_admin()
from django.utils import timezone
@ -1052,7 +1220,7 @@ def focus_phase_edit(request, pk, phase):
messages.error(request, _("You don't have permission."))
return redirect("projects:project_list")
can_edit = user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager
can_edit = user.is_px_admin() or user.is_hospital_admin()
if not can_edit:
messages.error(request, _("You don't have permission to edit this project."))
return redirect("projects:focus_phase_detail", pk=project.pk, phase=phase)
@ -1143,23 +1311,19 @@ def _check_project_permission(project, user):
def _get_can_edit(user):
"""Helper to check edit permissions."""
return user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager
"""Helper to check edit permissions — admin only."""
return user.is_px_admin() or user.is_hospital_admin()
def _can_manage_task(project, task, user):
"""Check if user can toggle/manage a specific task.
"""Check if user can toggle a task status.
True for admins/managers, the task's assignee, or project team members.
True for admins, or the task's assignee (so they can check/uncheck their own tasks).
"""
if _get_can_edit(user):
return True
# assignee check (task.assigned_to is a Staff; Staff.user is the linked User)
if task.assigned_to and getattr(task.assigned_to, "user_id", None) == user.id:
return True
# team member check
staff_profile = getattr(user, "staff_profile", None)
if staff_profile and project.team_members.filter(id=staff_profile.id).exists():
if staff_profile and task.assigned_to_id == staff_profile.id:
return True
return False

View File

@ -0,0 +1,196 @@
/* eslint-disable */
/**
* Onboarding workflow E2E from invitation activation through wizard to completion.
*
* Tests:
* 1. Token activation (auto-login via invitation link)
* 2. Welcome page
* 3. Wizard content steps
* 4. Checklist step
* 5. Activation step (password)
* 6. Completion
* 7. Invalid token error page
* 8. Admin provisional user list
*
* Run headed:
* E2E_MAXIMIZED=1 E2E_TIMEOUT=120000 E2E_ACTION_TIMEOUT=12000 \
* npx playwright test --headed --project chromium onboarding-workflow --workers=1
*/
import { test } from '@playwright/test';
import { execSync } from 'child_process';
import * as path from 'path';
import { attachObservers, observe, bodyHasTraceback, loginAndScope, OBS, BASE_URL } from '../../helpers/audit';
import { RoleName } from '../../helpers/helpers';
const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..');
const M = 'Onboarding';
const PROVISIONAL_EMAIL = 'e2e-provisional@px360.test';
type Page = import('@playwright/test').Page;
function uv(args: string): string { return execSync(args, { cwd: PROJECT_ROOT }).toString(); }
function seedProvisional(): string {
const out = uv('uv run manage.py seed_e2e_provisional');
const m = out.match(/token=(\S+)/);
if (!m) throw new Error('seed parse failed: ' + out);
return m[1];
}
function onboardingState(): Record<string, string> {
const out = uv(`uv run manage.py get_e2e_onboarding_state ${PROVISIONAL_EMAIL}`);
const s: Record<string, string> = {};
for (const line of out.split('\n')) { const i = line.indexOf('='); if (i > 0) s[line.slice(0, i)] = line.slice(i + 1); }
return s;
}
async function login(page: Page, role: RoleName) {
await page.context().clearCookies();
await loginAndScope(page, role, M);
}
test.describe('Onboarding flow', () => {
test.describe.configure({ mode: 'serial' });
test('1. Token activation → welcome → wizard → completion', async ({ page }) => {
attachObservers(page, M, 'provisional');
const token = seedProvisional();
observe(M, 'seed', 'INFO', `provisional user created, token=${token.slice(0, 8)}...`, {});
try {
// ── 1a. Visit activation URL (auto-login via token) ───────────────────
await page.goto(`${BASE_URL}/accounts/onboarding/activate/${token}/`);
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(1000);
const tb1 = await bodyHasTraceback(page);
const onLogin = page.url().includes('/accounts/login');
const atWelcome = page.url().includes('onboarding/welcome') || page.url().includes('onboarding');
observe(M, '1-activate', tb1 ? 'FAIL' : onLogin ? 'FAIL' : 'PASS',
tb1 ? `traceback: ${tb1}` : onLogin ? 'redirected to login (token invalid?)' : `activated, url=${page.url().slice(-60)}`,
{ url: page.url() });
// ── 1b. Welcome page ──────────────────────────────────────────────────
if (atWelcome || !onLogin) {
const body = (await page.textContent('body')) || '';
const hasWelcome = /welcome|onboarding|get.started|px360/i.test(body);
observe(M, '2-welcome', hasWelcome ? 'PASS' : 'WARN',
`welcome content: ${hasWelcome} (${body.length} chars)`, { url: page.url() });
}
// ── 1c. Wizard content steps (walk steps 1-5 gracefully) ──────────────
for (const step of [1, 2, 3, 4, 5]) {
await page.goto(`${BASE_URL}/accounts/onboarding/wizard/step/${step}/`).catch(() => {});
await page.waitForLoadState('domcontentloaded').catch(() => {});
await page.waitForTimeout(500);
const stepUrl = page.url();
const stepBody = (await page.textContent('body')) || '';
const stepOk = !stepUrl.includes('/accounts/login') && !await bodyHasTraceback(page);
const hasContent = stepBody.length > 200;
if (stepOk && hasContent) {
observe(M, `3-step-${step}`, 'PASS', `step ${step} renders (${stepBody.length} chars)`, { url: stepUrl });
} else if (stepUrl.includes('checklist') || stepUrl.includes('activation') || stepUrl.includes('complete')) {
observe(M, `3-step-${step}`, 'INFO', `step ${step} redirected to ${stepUrl.split('/').slice(-2).join('/')}`, { url: stepUrl });
break; // reached the end of content steps
} else {
observe(M, `3-step-${step}`, 'WARN', `step ${step}: url=${stepUrl.slice(-40)} content=${stepBody.length}`, { url: stepUrl });
break;
}
}
// ── 1d. Checklist step ────────────────────────────────────────────────
await page.goto(`${BASE_URL}/accounts/onboarding/wizard/checklist/`);
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(500);
const checklistBody = (await page.textContent('body')) || '';
const checklistRenders = !page.url().includes('/accounts/login') && !await bodyHasTraceback(page);
const checklistItems = await page.locator('input[type="checkbox"], .checklist-item, [class*="acknowledge"]').count();
observe(M, '4-checklist', checklistRenders ? 'PASS' : 'FAIL',
`checklist renders: ${checklistRenders}, items: ${checklistItems}`, { url: page.url() });
// ── 1e. Activation step (password set) ────────────────────────────────
await page.goto(`${BASE_URL}/accounts/onboarding/wizard/activation/`);
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(500);
const actUrl = page.url();
const actBody = (await page.textContent('body')) || '';
const actRenders = !actUrl.includes('/accounts/login') && !await bodyHasTraceback(page);
const hasPasswordForm = /password|set.*password|create.*password/i.test(actBody);
observe(M, '5-activation', actRenders ? 'PASS' : 'WARN',
`activation page: renders=${actRenders}, hasPasswordForm=${hasPasswordForm}, url=${actUrl.slice(-40)}`, { url: actUrl });
// Try to set a password if a form exists
const pwInput = page.locator('input[name*="password"], input[type="password"]').first();
if (await pwInput.count()) {
const testPassword = 'E2E@Test123';
await pwInput.fill(testPassword).catch(() => {});
// if there's a confirm field
const pwConfirm = page.locator('input[name*="confirm"], input[type="password"]').nth(1);
if (await pwConfirm.count()) await pwConfirm.fill(testPassword).catch(() => {});
// submit
await page.locator('button[type="submit"], input[type="submit"]').first().click().catch(() => {});
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
await page.waitForTimeout(1500);
observe(M, '5-password-set', 'PASS', `password form submitted, url=${page.url().slice(-40)}`, { url: page.url() });
} else {
observe(M, '5-password-set', 'WARN', 'no password form found on activation page', {});
}
// ── 1f. Completion page ───────────────────────────────────────────────
await page.goto(`${BASE_URL}/accounts/onboarding/complete/`).catch(() => {});
await page.waitForLoadState('domcontentloaded').catch(() => {});
await page.waitForTimeout(500);
const completeBody = (await page.textContent('body')) || '';
observe(M, '6-complete', completeBody.length > 200 ? 'PASS' : 'WARN',
`completion page: ${completeBody.length} chars`, { url: page.url() });
// ── 1g. Verify state ──────────────────────────────────────────────────
const st = onboardingState();
observe(M, '7-state', st.is_provisional === 'False' ? 'PASS' : 'WARN',
`is_provisional=${st.is_provisional} ack=${st.acknowledgement_completed} pw=${st.has_usable_password}`, {});
} catch (e) {
observe(M, 'flow', 'FAIL', `exception: ${(e as Error).message}`, {});
}
});
test('2. Invalid token → error page', async ({ page }) => {
attachObservers(page, M, 'anonymous');
try {
await page.goto(`${BASE_URL}/accounts/onboarding/activate/invalid_token_12345/`);
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(500);
const body = (await page.textContent('body')) || '';
const hasError = /invalid|expired|error|contact/i.test(body);
const notLoggedIn = !page.url().includes('welcome');
observe(M, 'invalid-token', hasError && notLoggedIn ? 'PASS' : 'FAIL',
`invalid token handled: errorShown=${hasError} notLoggedIn=${notLoggedIn}`, { url: page.url() });
} catch (e) {
observe(M, 'invalid-token', 'FAIL', `exception: ${(e as Error).message}`, {});
}
});
test('3. Admin provisional user list', async ({ page }) => {
attachObservers(page, M, 'px_admin');
try {
await login(page, 'px_admin');
await page.goto(`${BASE_URL}/accounts/onboarding/provisional/`);
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(1000);
const tb = await bodyHasTraceback(page);
const body = (await page.textContent('body')) || '';
const hasList = /provisional|onboarding|pending|invited/i.test(body);
observe(M, 'admin-provisional-list', tb ? 'FAIL' : hasList ? 'PASS' : 'WARN',
tb ? `traceback: ${tb}` : `provisional list: ${hasList ? 'renders' : 'no data'} (${body.length} chars)`,
{ url: page.url() });
} catch (e) {
observe(M, 'admin-provisional-list', 'FAIL', `exception: ${(e as Error).message}`, {});
}
});
});
test.afterAll(async () => {
const counts = OBS.reduce<Record<string, number>>((a, o) => ((a[o.status] = (a[o.status] || 0) + 1), a), {});
console.log('\n=========== ONBOARDING SUMMARY ===========');
console.log('Total observations:', OBS.length, JSON.stringify(counts));
console.log('==========================================\n');
});

View File

@ -134,6 +134,13 @@
</a>
{% endif %}
<!-- ===== TOP: MY TASKS (visible to everyone) ===== -->
<a href="{% url 'projects:my_tasks' %}"
class="flex items-center gap-3 p-3 rounded-lg transition {% if '/projects/my-tasks/' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
<i data-lucide="list-checks" class="w-5 h-5 flex-shrink-0"></i>
<span class="sidebar-text text-sm font-semibold whitespace-nowrap">{% trans "My Tasks" %}</span>
</a>
<!-- Department Champion: only Dashboard + My Department -->
{% if user.is_champion and not user.is_px_admin and not user.is_hospital_admin and not user.is_department_manager %}
{% if user.department %}
@ -217,11 +224,6 @@
<i data-lucide="folder-kanban" class="w-5 h-5 flex-shrink-0"></i>
<span class="sidebar-text text-sm font-semibold whitespace-nowrap">{% trans "QI Projects" %}</span>
</a>
<a href="{% url 'projects:my_tasks' %}"
class="flex items-center gap-3 p-3 rounded-lg transition {% if '/projects/my-tasks/' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
<i data-lucide="list-checks" class="w-5 h-5 flex-shrink-0"></i>
<span class="sidebar-text text-sm font-semibold whitespace-nowrap">{% trans "My QI Tasks" %}</span>
</a>
<!-- ===== SECTION 3: PEOPLE & RECORDS ===== -->

View File

@ -1,87 +1,115 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% block title %}{% trans "My QI Tasks" %} - PX360{% endblock %}
{% block title %}{% trans "My Tasks" %} - PX360{% endblock %}
{% block content %}
<header class="mb-6">
<div class="flex items-center gap-2 text-sm text-slate mb-2">
<a href="{% url 'projects:project_list' %}" class="hover:text-navy">{% trans "QI Projects" %}</a>
<i data-lucide="chevron-right" class="w-4 h-4"></i>
<i data-lucide="list-checks" class="w-4 h-4"></i>
<span class="font-bold text-navy">{% trans "My Tasks" %}</span>
</div>
<h1 class="text-2xl font-bold text-navy">{% trans "My QI Tasks" %}</h1>
<h1 class="text-2xl font-bold text-navy">{% trans "My Tasks" %}</h1>
<p class="text-slate text-sm mt-1">{% trans "All pending items assigned to you across the system" %}</p>
</header>
<div class="grid grid-cols-3 gap-4 mb-6">
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-5">
<p class="text-[10px] uppercase font-bold text-slate-500 mb-1">{% trans "Total" %}</p>
<p class="text-3xl font-bold text-navy">{{ total }}</p>
<div class="grid grid-cols-2 gap-4 mb-6">
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-amber-50 rounded-xl flex items-center justify-center">
<i data-lucide="clock" class="w-5 h-5 text-amber-600"></i>
</div>
<div>
<p class="text-[10px] uppercase font-bold text-slate-500">{% trans "Pending" %}</p>
<p class="text-2xl font-bold text-navy">{{ pending }}</p>
</div>
</div>
</div>
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-red-50 rounded-xl flex items-center justify-center">
<i data-lucide="alert-triangle" class="w-5 h-5 text-red-600"></i>
</div>
<div>
<p class="text-[10px] uppercase font-bold text-slate-500">{% trans "Overdue" %}</p>
<p class="text-2xl font-bold {% if overdue > 0 %}text-red-600{% else %}text-navy{% endif %}">{{ overdue }}</p>
</div>
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-5">
<p class="text-[10px] uppercase font-bold text-slate-500 mb-1">{% trans "Pending" %}</p>
<p class="text-3xl font-bold text-amber-600">{{ pending }}</p>
</div>
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-5">
<p class="text-[10px] uppercase font-bold text-slate-500 mb-1">{% trans "Completed" %}</p>
<p class="text-3xl font-bold text-green-600">{{ completed }}</p>
</div>
</div>
{% if grouped %}
{% for project, tasks in grouped.items %}
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6 mb-4">
<div class="flex items-center gap-2 mb-4">
<i data-lucide="folder-kanban" class="w-5 h-5 text-navy"></i>
<a href="{% url 'projects:project_detail' pk=project.pk %}" class="text-lg font-bold text-navy hover:text-blue-600">
{{ project.name }}
</a>
<span class="text-xs px-2 py-0.5 rounded-full font-bold
{% if project.status == 'completed' %}bg-green-100 text-green-700
{% elif project.status == 'in_progress' %}bg-blue-100 text-blue-700
{% else %}bg-slate-100 text-slate-600{% endif %}">
{{ project.get_status_display }}
</span>
</div>
<div class="space-y-2">
{% if tasks %}
<div class="space-y-3">
{% for task in tasks %}
<div class="flex items-center gap-3 p-3 rounded-xl {% if task.status == 'completed' %}bg-green-50{% else %}bg-slate-50{% endif %}">
<form method="post" action="{% url 'projects:task_toggle_status' project_pk=project.pk task_pk=task.pk %}" class="inline">
{% csrf_token %}
<button type="submit" class="p-1 bg-transparent border-none cursor-pointer">
{% if task.status == 'completed' %}
<i data-lucide="check-square" class="w-5 h-5 text-green-600"></i>
{% else %}
<i data-lucide="square" class="w-5 h-5 text-slate-300 hover:text-blue-500"></i>
{% endif %}
</button>
</form>
<div class="flex-1">
<span class="text-sm font-semibold {% if task.status == 'completed' %}line-through text-slate-400{% else %}text-navy{% endif %}">
{{ task.title }}
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-4 hover:shadow-md transition
{% if task.is_overdue %}border-l-4 border-l-red-400{% endif %}">
<div class="flex items-start gap-3">
<div class="w-9 h-9 rounded-xl flex items-center justify-center shrink-0
{% if task.color == 'red' %}bg-red-50
{% elif task.color == 'amber' %}bg-amber-50
{% elif task.color == 'orange' %}bg-orange-50
{% elif task.color == 'blue' %}bg-blue-50
{% elif task.color == 'cyan' %}bg-cyan-50
{% elif task.color == 'purple' %}bg-purple-50
{% else %}bg-slate-50{% endif %}">
<i data-lucide="{{ task.icon }}" class="w-4 h-4
{% if task.color == 'red' %}text-red-600
{% elif task.color == 'amber' %}text-amber-600
{% elif task.color == 'orange' %}text-orange-600
{% elif task.color == 'blue' %}text-blue-600
{% elif task.color == 'cyan' %}text-cyan-600
{% elif task.color == 'purple' %}text-purple-600
{% else %}text-slate-600{% endif %}"></i>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="px-1.5 py-0.5 rounded text-[9px] font-bold uppercase tracking-wide
{% if task.color == 'red' %}bg-red-100 text-red-700
{% elif task.color == 'amber' %}bg-amber-100 text-amber-700
{% elif task.color == 'orange' %}bg-orange-100 text-orange-700
{% elif task.color == 'blue' %}bg-blue-100 text-blue-700
{% elif task.color == 'cyan' %}bg-cyan-100 text-cyan-700
{% elif task.color == 'purple' %}bg-purple-100 text-purple-700
{% else %}bg-slate-100 text-slate-700{% endif %}">
{{ task.type_label }}
</span>
<span class="font-mono text-xs text-slate-400">{{ task.reference }}</span>
</div>
<p class="text-sm font-semibold text-navy">{{ task.title }}</p>
{% if task.description %}
<p class="text-xs text-slate-500 mt-0.5">{{ task.description|truncatewords:20 }}</p>
<p class="text-xs text-slate-500 mt-0.5 truncate">{{ task.description }}</p>
{% endif %}
{% if task.due_date %}
<p class="text-xs mt-1 {% if task.is_overdue %}text-red-600 font-bold{% else %}text-slate-400{% endif %}">
<i data-lucide="calendar" class="w-3 h-3 inline"></i>
{{ task.due_date|date:"M d, Y" }}
{% if task.is_overdue %} — {% trans "Overdue" %}{% endif %}
</p>
{% endif %}
</div>
{% if task.due_date %}
<span class="text-xs {% if task.due_date < today and task.status != 'completed' %}text-red-600 font-bold{% else %}text-slate-500{% endif %}">
{{ task.due_date|date:"M d" }}
</span>
{% if task.type == 'dept_response' %}
<button onclick="openDeptResponseModal('{{ task.modal_type }}', '{{ task.modal_pk }}', '{{ task.modal_url }}', '{{ task.modal_ref|escapejs }}', '{{ task.modal_subject|escapejs }}')"
class="shrink-0 px-3 py-1.5 bg-navy text-white rounded-lg text-xs font-semibold hover:bg-blue transition inline-flex items-center gap-1">
{% trans "Respond" %}
<i data-lucide="message-square" class="w-3 h-3"></i>
</button>
{% else %}
<a href="{{ task.url }}" class="shrink-0 px-3 py-1.5 bg-navy text-white rounded-lg text-xs font-semibold hover:bg-blue transition inline-flex items-center gap-1">
{% trans "Action" %}
<i data-lucide="arrow-right" class="w-3 h-3"></i>
</a>
{% endif %}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold uppercase
{% if task.status == 'completed' %}bg-green-100 text-green-700{% else %}bg-amber-100 text-amber-700{% endif %}">
{{ task.get_status_display }}
</span>
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
{% else %}
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-12 text-center">
<i data-lucide="check-circle" class="w-12 h-12 text-green-500 mx-auto mb-3"></i>
<p class="text-slate-600 font-semibold">{% trans "You have no QI tasks assigned." %}</p>
<p class="text-slate-600 font-semibold text-lg">{% trans "You're all caught up!" %}</p>
<p class="text-slate-400 text-sm mt-1">{% trans "No pending tasks assigned to you." %}</p>
</div>
{% endif %}
{% include "components/department_response_modal.html" %}
{% endblock %}

View File

@ -2,7 +2,7 @@
<tr id="task-{{ task.pk }}">
<!-- Toggle -->
<td class="text-center">
{% if can_edit or can_toggle or task.assigned_to.user_id == request.user.id %}
{% if can_edit or task.assigned_to.user_id == request.user.id %}
<form method="post"
action="{% url 'projects:htmx_task_toggle' project_pk=project.pk task_pk=task.pk %}"
hx-post="{% url 'projects:htmx_task_toggle' project_pk=project.pk task_pk=task.pk %}"
@ -30,21 +30,29 @@
</td>
<!-- Drag Handle -->
{% if can_edit %}
<td class="text-center">
<div class="text-slate-300 hover:text-slate-500 cursor-grab active:cursor-grabbing">
<i data-lucide="grip-vertical" class="w-4 h-4"></i>
</div>
</td>
{% endif %}
<!-- Task Title & Description -->
<td>
<div class="flex flex-col">
{% if can_edit %}
<span class="text-sm font-semibold text-navy {% if task.status == 'completed' %}line-through text-slate-400{% endif %} cursor-pointer hover:text-blue-600"
hx-get="{% url 'projects:htmx_task_edit_form' project_pk=project.pk task_pk=task.pk %}"
hx-target="#taskModalContent"
onclick="openTaskModal('{% trans "Edit Task" %}')">
{{ task.title }}
</span>
{% else %}
<span class="text-sm font-semibold {% if task.status == 'completed' %}line-through text-slate-400{% else %}text-navy{% endif %}">
{{ task.title }}
</span>
{% endif %}
{% if task.description %}
<span class="text-xs text-slate-500 mt-0.5 truncate max-w-md">{{ task.description }}</span>
{% endif %}