Prepare ERP source for Gitea deployment
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
{% set path = request.url.path %}
|
||||
<div class="mb-6 overflow-x-auto rounded-2xl border border-slate-200 bg-white p-2 shadow-soft">
|
||||
<div class="flex min-w-max gap-2 text-sm font-medium">
|
||||
<a href="/partner/dashboard" class="rounded-xl px-4 py-2 transition {% if path == '/partner/dashboard' %}bg-brand-600 text-white{% else %}text-slate-700 hover:bg-slate-100{% endif %}">Overview</a>
|
||||
<a href="/partner/reviews" class="rounded-xl px-4 py-2 transition {% if path.startswith('/partner/reviews') %}bg-brand-600 text-white{% else %}text-slate-700 hover:bg-slate-100{% endif %}">Review Board</a>
|
||||
<a href="/partner/clients" class="rounded-xl px-4 py-2 transition {% if path.startswith('/partner/clients') %}bg-brand-600 text-white{% else %}text-slate-700 hover:bg-slate-100{% endif %}">My Clients</a>
|
||||
<a href="/services/engagements" class="rounded-xl px-4 py-2 text-slate-700 transition hover:bg-slate-100">Engagements</a>
|
||||
<a href="/documents" class="rounded-xl px-4 py-2 text-slate-700 transition hover:bg-slate-100">Documents</a>
|
||||
<a href="/billing" class="rounded-xl px-4 py-2 text-slate-700 transition hover:bg-slate-100">Billing</a>
|
||||
<a href="/alerts" class="rounded-xl px-4 py-2 transition {% if path.startswith('/alerts') %}bg-brand-600 text-white{% else %}text-slate-700 hover:bg-slate-100{% endif %}">My Alert</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,32 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/partners/templates/partners/_partner_tabs.html" %}
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div><h2 class="text-xl font-semibold">My Client Portfolio</h2><p class="text-sm text-slate-500">Client engagements assigned to you as engagement partner or review partner.</p></div>
|
||||
<form method="get" class="flex gap-2"><input name="q" value="{{ q or '' }}" placeholder="Search" class="w-72 rounded-xl border border-slate-300 px-3 py-2 text-sm"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Search</button></form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{% for engagement in payload.engagements %}
|
||||
<a href="/partner/engagements/{{ engagement.id }}" class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft transition hover:border-brand-300 hover:bg-brand-50/30">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div><h3 class="font-semibold text-slate-900">{{ engagement.client_display }}</h3><p class="mt-1 text-sm text-slate-500">{{ engagement.display_label }}</p></div>
|
||||
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-700">{{ engagement.status|replace('_',' ')|title }}</span>
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-3 gap-2 text-center text-xs">
|
||||
<div class="rounded-2xl bg-slate-50 p-3"><div class="text-lg font-semibold">{{ engagement.task_counts.total }}</div><div class="text-slate-500">Tasks</div></div>
|
||||
<div class="rounded-2xl bg-slate-50 p-3"><div class="text-lg font-semibold">{{ engagement.task_counts.open }}</div><div class="text-slate-500">Open</div></div>
|
||||
<div class="rounded-2xl bg-slate-50 p-3"><div class="text-lg font-semibold">{{ engagement.task_counts.completed }}</div><div class="text-slate-500">Done</div></div>
|
||||
</div>
|
||||
<div class="mt-4 text-xs text-slate-500">{% if engagement.current_due_date %}Due: {{ engagement.current_due_date }}{% else %}No due date configured{% endif %}{% if engagement.is_overdue %}<span class="ml-2 rounded-full bg-red-100 px-2 py-1 font-semibold text-red-700">Overdue</span>{% endif %}</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="rounded-3xl border border-dashed border-slate-300 bg-white p-8 text-center text-sm text-slate-500 xl:col-span-3">No assigned client engagements found.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,88 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/partners/templates/partners/_partner_tabs.html" %}
|
||||
|
||||
<div class="space-y-6">
|
||||
<section class="rounded-3xl bg-gradient-to-r from-brand-700 to-slate-900 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-sm uppercase tracking-[0.22em] text-brand-100">Partner Workspace</p>
|
||||
<h2 class="mt-2 text-2xl font-semibold">Review, control and escalation dashboard</h2>
|
||||
<p class="mt-2 max-w-3xl text-sm text-brand-100">Track assigned client engagements, pending reviews, blocked work, overdue items and documents requiring attention.</p>
|
||||
{% if current_user_roles and ('Manager' in current_user_roles or 'Branch Manager' in current_user_roles or 'Firm Admin' in current_user_roles) %}
|
||||
<p class="mt-3 inline-flex rounded-full bg-white/10 px-3 py-1 text-xs font-semibold text-white">Role-aware view: this partner login also carries team/administration responsibility.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/partner/reviews" class="rounded-xl bg-white px-4 py-2 text-sm font-semibold text-brand-700 hover:bg-brand-50">Open Review Board</a>
|
||||
{% if current_user_roles and ('Manager' in current_user_roles or 'Branch Manager' in current_user_roles) %}<a href="/manager/dashboard" class="rounded-xl border border-white/40 px-4 py-2 text-sm font-semibold text-white hover:bg-white/10">Team Workspace</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-4 md:grid-cols-3 xl:grid-cols-6">
|
||||
<a href="/partner/reviews" class="af-metric-card hover:border-brand-200"><div class="text-xs font-semibold uppercase text-slate-500">Tasks</div><div class="mt-2 text-3xl font-semibold">{{ payload.summary.total }}</div><div class="mt-1 text-xs text-slate-500">Visible portfolio</div></a>
|
||||
<a href="/partner/reviews?status=pending_review" class="af-metric-card border-amber-200 bg-amber-50 hover:border-amber-300"><div class="text-xs font-semibold uppercase text-amber-700">Pending Review</div><div class="mt-2 text-3xl font-semibold text-amber-700">{{ payload.summary.pending_review }}</div><div class="mt-1 text-xs text-amber-700">Needs partner action</div></a>
|
||||
<a href="/partner/reviews?status=blocked" class="af-metric-card border-red-200 bg-red-50 hover:border-red-300"><div class="text-xs font-semibold uppercase text-red-700">Blocked</div><div class="mt-2 text-3xl font-semibold text-red-700">{{ payload.summary.blocked }}</div><div class="mt-1 text-xs text-red-700">Escalation</div></a>
|
||||
<a href="/partner/reviews?date_bucket=overdue" class="af-metric-card border-red-200 bg-red-50 hover:border-red-300"><div class="text-xs font-semibold uppercase text-red-700">Overdue</div><div class="mt-2 text-3xl font-semibold text-red-700">{{ payload.summary.overdue }}</div><div class="mt-1 text-xs text-red-700">High risk</div></a>
|
||||
<a href="/partner/clients" class="af-metric-card hover:border-brand-200"><div class="text-xs font-semibold uppercase text-slate-500">Clients</div><div class="mt-2 text-3xl font-semibold">{{ payload.summary.clients }}</div><div class="mt-1 text-xs text-slate-500">Assigned clients</div></a>
|
||||
<a href="/partner/reviews" class="af-metric-card hover:border-brand-200"><div class="text-xs font-semibold uppercase text-slate-500">Engagements</div><div class="mt-2 text-3xl font-semibold">{{ payload.summary.engagements }}</div><div class="mt-1 text-xs text-slate-500">Active portfolio</div></a>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_420px]">
|
||||
<div class="af-card">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div><h3 class="text-lg font-semibold text-slate-900">Attention Required</h3><p class="text-sm text-slate-500">Items that need partner review, decision or intervention.</p></div>
|
||||
<a href="/partner/reviews" class="af-btn af-btn-primary">Open Review Board</a>
|
||||
</div>
|
||||
<div class="mt-5 space-y-3">
|
||||
{% set shown = namespace(count=0) %}
|
||||
{% for column in payload.columns %}
|
||||
{% if column.code in ['pending_review', 'clarification_required', 'blocked'] %}
|
||||
{% for task in column.tasks[:5] %}
|
||||
{% set shown.count = shown.count + 1 %}
|
||||
<a href="/partner/engagements/{{ task.subscription_id }}" class="block rounded-2xl border border-slate-200 p-4 transition hover:border-brand-300 hover:bg-brand-50/40">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div><div class="font-semibold text-slate-900">{{ task.task_name }}</div><div class="mt-1 text-sm text-slate-500">{{ task.client_display }} · {{ task.engagement_label }}</div></div>
|
||||
<span class="af-badge {% if column.code == 'blocked' %}af-badge-danger{% elif column.code == 'pending_review' %}af-badge-warning{% else %}af-badge-info{% endif %}">{{ column.label }}</span>
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-slate-500">Assigned to {{ task.assignee_display }}{% if task.internal_target_date %} · Target {{ task.internal_target_date }}{% endif %}</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if shown.count == 0 %}<div class="rounded-2xl border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500">No pending partner attention items found.</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="space-y-6">
|
||||
<div class="af-card">
|
||||
<div class="flex items-center justify-between gap-3"><div><h3 class="text-lg font-semibold text-slate-900">Client Portfolio</h3><p class="text-sm text-slate-500">Recent assigned engagements.</p></div><a href="/partner/clients" class="text-sm font-semibold text-brand-700 hover:underline">View all</a></div>
|
||||
<div class="mt-4 space-y-3">
|
||||
{% for engagement in payload.engagements[:6] %}
|
||||
<a href="/partner/engagements/{{ engagement.id }}" class="block rounded-2xl border border-slate-200 p-4 hover:bg-slate-50">
|
||||
<div class="font-semibold text-slate-900">{{ engagement.client_display }}</div>
|
||||
<div class="mt-1 text-sm text-slate-500">{{ engagement.display_label }}</div>
|
||||
<div class="mt-2 flex flex-wrap gap-2 text-xs"><span class="rounded-full bg-slate-100 px-2 py-1">Open {{ engagement.task_counts.open }}</span><span class="rounded-full bg-slate-100 px-2 py-1">Done {{ engagement.task_counts.completed }}</span>{% if engagement.is_overdue %}<span class="rounded-full bg-red-100 px-2 py-1 text-red-700">Overdue</span>{% endif %}</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500">No partner engagements found.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if current_user_roles and ('Manager' in current_user_roles or 'Branch Manager' in current_user_roles or 'Firm Admin' in current_user_roles) %}
|
||||
<div class="af-card border-brand-200 bg-brand-50/60">
|
||||
<h3 class="font-semibold text-slate-900">Small Firm / Multi-role Actions</h3>
|
||||
<p class="mt-1 text-sm text-slate-600">Use these when the partner is also handling manager-level follow-up.</p>
|
||||
<div class="mt-4 grid gap-2 text-sm">
|
||||
<a href="/manager/work" class="rounded-xl border border-brand-200 bg-white px-4 py-3 font-semibold text-brand-700 hover:bg-brand-50">Team Work Board</a>
|
||||
<a href="/employees/progress" class="rounded-xl border border-brand-200 bg-white px-4 py-3 font-semibold text-brand-700 hover:bg-brand-50">Engagement Progress</a>
|
||||
<a href="/employees/attendance" class="rounded-xl border border-brand-200 bg-white px-4 py-3 font-semibold text-brand-700 hover:bg-brand-50">Team Attendance</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,56 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/partners/templates/partners/_partner_tabs.html" %}
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p class="text-sm uppercase tracking-[0.18em] text-slate-400">Engagement Review</p>
|
||||
<h2 class="mt-1 text-2xl font-semibold">{{ engagement.client.client_name if engagement.client else 'Client' }}</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ engagement.display_label }}{% if engagement.current_due_date %} · Due {{ engagement.current_due_date }}{% endif %}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2"><a href="/documents?engagement_id={{ engagement.id }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Open Documents</a><a href="/services/engagements" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">All Engagements</a></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<section class="space-y-4">
|
||||
{% for task in tasks %}
|
||||
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div><h3 class="font-semibold text-slate-900">{{ loop.index }}. {{ task.task_name }}</h3><p class="mt-1 text-sm text-slate-500">{{ task.description or 'No description.' }}</p><div class="mt-3 flex flex-wrap gap-2 text-xs"><span class="rounded-full bg-slate-100 px-2.5 py-1">{{ task.status_label }}</span><span class="rounded-full bg-slate-100 px-2.5 py-1">{{ task.priority_label }}</span><span class="rounded-full bg-slate-100 px-2.5 py-1">Assigned: {{ task.assignee_display }}</span>{% if task.is_overdue %}<span class="rounded-full bg-red-100 px-2.5 py-1 font-semibold text-red-700">Overdue</span>{% endif %}</div></div>
|
||||
<a href="/employees/work/tasks/{{ task.id }}/communication" class="rounded-xl border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Communication</a>
|
||||
</div>
|
||||
<form method="post" action="/partner/tasks/{{ task.id }}/review" class="mt-4 grid gap-3 rounded-2xl bg-slate-50 p-4 lg:grid-cols-[180px_minmax(0,1fr)_auto]">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<select name="action" class="rounded-xl border border-slate-300 px-3 py-2 text-sm"><option value="approve">Approve</option><option value="send_rework">Send for Rework</option><option value="clarification">Request Clarification</option></select>
|
||||
<input name="message" placeholder="Optional partner note" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Submit</button>
|
||||
</form>
|
||||
{% if task.comments %}
|
||||
<div class="mt-4 space-y-2">
|
||||
{% for comment in task.comments[:3] %}
|
||||
<div class="rounded-2xl border border-slate-200 p-3 text-sm"><div class="text-xs text-slate-500">{{ comment.comment_type|replace('_',' ')|title }} · {{ comment.created_at_utc }}</div><div class="mt-1 text-slate-700">{{ comment.message }}</div></div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="rounded-3xl border border-dashed border-slate-300 bg-white p-8 text-center text-sm text-slate-500">No tasks found for this engagement.</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
<aside class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="flex items-center justify-between gap-3"><div><h3 class="font-semibold">Documents</h3><p class="text-sm text-slate-500">Related engagement documents.</p></div><span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold">{{ documents|length }}</span></div>
|
||||
<div class="mt-4 space-y-3">
|
||||
{% for doc in documents %}
|
||||
<div class="rounded-2xl border border-slate-200 p-4"><div class="font-medium text-slate-900">{{ doc.title }}</div><div class="mt-1 text-xs text-slate-500">{{ doc.document_type }} · v{{ doc.current_version_no }} · {{ doc.status|title }}</div></div>
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500">No documents uploaded yet.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,31 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/partners/templates/partners/_partner_tabs.html" %}
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 rounded-3xl border border-slate-200 bg-white p-5 shadow-soft lg:flex-row lg:items-center lg:justify-between">
|
||||
<div><h2 class="text-xl font-semibold">Partner Review Board</h2><p class="text-sm text-slate-500">Review completed work, blocked tasks, rework and in-progress engagement tasks.</p></div>
|
||||
<form method="get" class="flex gap-2"><input name="q" value="{{ q or '' }}" placeholder="Search client, task or service" class="w-72 rounded-xl border border-slate-300 px-3 py-2 text-sm"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Search</button></form>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 xl:grid-cols-5">
|
||||
{% for column in payload.columns %}
|
||||
<section class="min-h-[420px] rounded-3xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="mb-4 flex items-start justify-between gap-3"><div><h3 class="font-semibold">{{ column.label }}</h3><p class="text-xs text-slate-500">{{ column.hint }}</p></div><span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold">{{ column.count }}</span></div>
|
||||
<div class="space-y-3">
|
||||
{% for task in column.tasks %}
|
||||
<div class="rounded-2xl border border-slate-200 p-4">
|
||||
<div class="flex items-start justify-between gap-3"><div><a href="/work/engagements/{{ task.subscription_id }}" class="font-semibold text-slate-900 hover:text-brand-700">{{ task.task_name }}</a><div class="mt-1 text-xs text-slate-500">{{ task.client_display }}</div></div>{% if task.is_overdue %}<span class="rounded-full bg-red-100 px-2 py-1 text-[11px] font-semibold text-red-700">Overdue</span>{% endif %}</div>
|
||||
<div class="mt-2 text-xs text-slate-500">{{ task.engagement_label }}</div>
|
||||
<div class="mt-3 flex flex-wrap gap-2 text-[11px]"><span class="rounded-full bg-slate-100 px-2 py-1">{{ task.status_label }}</span><span class="rounded-full bg-slate-100 px-2 py-1">{{ task.priority_label }}</span><span class="rounded-full bg-slate-100 px-2 py-1">{{ task.assignee_display }}</span></div>
|
||||
<a href="/work/engagements/{{ task.subscription_id }}" class="mt-3 inline-flex rounded-xl border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Open Review Details</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-center text-xs text-slate-500">No items.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,408 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.core.db.common import CommonSessionLocal
|
||||
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
|
||||
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.documents.models import EngagementDocument
|
||||
from app.modules.clients.models import Client
|
||||
from app.modules.services.execution import CLOSED_TASK_STATUSES, TASK_PRIORITIES, TASK_STATUSES
|
||||
from app.modules.services.models import (
|
||||
ClientServiceSubscription,
|
||||
ClientServiceTaskInstance,
|
||||
ServiceCatalogue,
|
||||
ServiceTaskComment,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/partner", tags=["partner-workspace-ui"])
|
||||
|
||||
REVIEW_ACTIONS = {
|
||||
"approve": ("completed", "partner_review", "partner_review"),
|
||||
"send_rework": ("pending", "partner_review_note", "partner_review"),
|
||||
"clarification": ("blocked", "client_clarification", "internal"),
|
||||
}
|
||||
|
||||
|
||||
def _redirect_login():
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
|
||||
|
||||
def _redirect_denied():
|
||||
return RedirectResponse(url="/employee/dashboard", status_code=303)
|
||||
|
||||
|
||||
def _is_partner_user(db: Session, current_user) -> bool:
|
||||
roles = set(get_user_roles(db, current_user.id))
|
||||
return bool(roles.intersection({"Partner", "Firm Admin", "System Admin"}))
|
||||
|
||||
|
||||
def _base_ctx(request: Request, db: Session, current_user, **ctx):
|
||||
base = {
|
||||
"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),
|
||||
"task_statuses": TASK_STATUSES,
|
||||
"task_priorities": TASK_PRIORITIES,
|
||||
}
|
||||
base.update(ctx)
|
||||
return base
|
||||
|
||||
|
||||
def _render(request: Request, template_name: str, db: Session, current_user, **ctx):
|
||||
return templates.TemplateResponse(template_name, _base_ctx(request, db, current_user, **ctx))
|
||||
|
||||
|
||||
def _active_tenant_branch(request: Request, current_user, roles: set[str]) -> tuple[int, int | None]:
|
||||
tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id
|
||||
branch_id = request.session.get("active_branch_id")
|
||||
if "System Admin" not in roles:
|
||||
tenant_id = current_user.tenant_id
|
||||
if not roles.intersection({"System Admin", "Firm Admin"}):
|
||||
branch_id = current_user.branch_id
|
||||
if branch_id in (0, "0", "", None):
|
||||
branch_id = None
|
||||
return int(tenant_id), int(branch_id) if branch_id is not None else None
|
||||
|
||||
|
||||
|
||||
|
||||
def _active_financial_year(request: 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 _subscription_scope_filter(stmt, tenant_id: int, branch_id: int | None, current_user, roles: set[str], financial_year: str | None = None):
|
||||
stmt = stmt.where(ClientServiceSubscription.tenant_id == tenant_id, ClientServiceSubscription.is_active.is_(True))
|
||||
if branch_id is not None:
|
||||
stmt = stmt.where(ClientServiceSubscription.branch_id == branch_id)
|
||||
if financial_year:
|
||||
stmt = stmt.where(ClientServiceSubscription.financial_year == financial_year.strip())
|
||||
if not roles.intersection({"System Admin", "Firm Admin"}):
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
ClientServiceSubscription.assigned_partner_user_id == current_user.id,
|
||||
ClientServiceSubscription.review_partner_user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
return stmt
|
||||
|
||||
|
||||
def _task_scope_filter(stmt, tenant_id: int, branch_id: int | None, current_user, roles: set[str], financial_year: str | None = None):
|
||||
stmt = stmt.where(ClientServiceTaskInstance.tenant_id == tenant_id, ClientServiceTaskInstance.is_active.is_(True))
|
||||
if branch_id is not None:
|
||||
stmt = stmt.where(ClientServiceTaskInstance.branch_id == branch_id)
|
||||
if financial_year:
|
||||
stmt = stmt.where(ClientServiceTaskInstance.financial_year == financial_year.strip())
|
||||
if not roles.intersection({"System Admin", "Firm Admin"}):
|
||||
stmt = stmt.where(
|
||||
ClientServiceTaskInstance.subscription.has(
|
||||
or_(
|
||||
ClientServiceSubscription.assigned_partner_user_id == current_user.id,
|
||||
ClientServiceSubscription.review_partner_user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
return stmt
|
||||
|
||||
|
||||
def _task_status_label(task: ClientServiceTaskInstance) -> str:
|
||||
return dict(TASK_STATUSES).get(getattr(task, "status", ""), (getattr(task, "status", "") or "-").replace("_", " ").title())
|
||||
|
||||
|
||||
def _task_priority_label(task: ClientServiceTaskInstance) -> str:
|
||||
return dict(TASK_PRIORITIES).get(getattr(task, "priority", ""), (getattr(task, "priority", "") or "normal").replace("_", " ").title())
|
||||
|
||||
|
||||
def _engagement_label(subscription: ClientServiceSubscription | None, task: ClientServiceTaskInstance | None = None) -> str:
|
||||
catalogue = getattr(subscription, "catalogue", None) if subscription else getattr(task, "catalogue", None)
|
||||
service_name = getattr(catalogue, "service_name", None) or getattr(catalogue, "name", None) or "Engagement"
|
||||
fy = getattr(subscription, "financial_year", None) or getattr(task, "financial_year", None)
|
||||
return f"{service_name} · FY {fy}" if fy else str(service_name)
|
||||
|
||||
|
||||
def _decorate_task(task: ClientServiceTaskInstance, today: date) -> ClientServiceTaskInstance:
|
||||
client = getattr(task, "client", None)
|
||||
assignee = getattr(task, "assigned_to", None)
|
||||
subscription = getattr(task, "subscription", None)
|
||||
target = getattr(task, "internal_target_date", None)
|
||||
status = (task.status or "pending").strip().lower()
|
||||
is_closed = status in CLOSED_TASK_STATUSES
|
||||
task.status_label = _task_status_label(task)
|
||||
task.priority_label = _task_priority_label(task)
|
||||
task.client_display = getattr(client, "client_name", None) or "Unlinked Client"
|
||||
task.client_code_display = getattr(client, "client_code", None) or ""
|
||||
task.assignee_display = getattr(assignee, "full_name", None) or getattr(assignee, "email", None) or "Unassigned"
|
||||
task.engagement_label = _engagement_label(subscription, task)
|
||||
task.is_overdue = bool(target and target < today and not is_closed)
|
||||
task.is_due_today = bool(target and target == today and not is_closed)
|
||||
task.comment_count = len([c for c in getattr(task, "comments", []) if not getattr(c, "is_deleted", False)])
|
||||
return task
|
||||
|
||||
|
||||
def _task_bucket(task: ClientServiceTaskInstance) -> str:
|
||||
status = (task.status or "pending").strip().lower()
|
||||
if status == "blocked":
|
||||
return "clarification_required"
|
||||
if status == "completed":
|
||||
return "pending_review"
|
||||
if status in CLOSED_TASK_STATUSES:
|
||||
return "completed"
|
||||
if status in {"pending", "rework", "rework_required"}:
|
||||
return "rework_sent"
|
||||
return "approved"
|
||||
|
||||
|
||||
def build_partner_payload(db: Session, request: Request, current_user, *, q: str = "") -> dict[str, Any]:
|
||||
roles = set(get_user_roles(db, current_user.id))
|
||||
tenant_id, branch_id = _active_tenant_branch(request, current_user, roles)
|
||||
financial_year = _active_financial_year(request)
|
||||
today = date.today()
|
||||
|
||||
task_stmt = (
|
||||
select(ClientServiceTaskInstance)
|
||||
.options(
|
||||
selectinload(ClientServiceTaskInstance.client),
|
||||
selectinload(ClientServiceTaskInstance.catalogue),
|
||||
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue),
|
||||
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_manager),
|
||||
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_staff),
|
||||
selectinload(ClientServiceTaskInstance.assigned_to),
|
||||
selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by),
|
||||
)
|
||||
)
|
||||
task_stmt = _task_scope_filter(task_stmt, tenant_id, branch_id, current_user, roles, financial_year=financial_year)
|
||||
if q.strip():
|
||||
like = f"%{q.strip()}%"
|
||||
task_stmt = task_stmt.where(
|
||||
or_(
|
||||
ClientServiceTaskInstance.task_name.ilike(like),
|
||||
ClientServiceTaskInstance.description.ilike(like),
|
||||
ClientServiceTaskInstance.client.has(or_(Client.client_name.ilike(like), Client.client_code.ilike(like))),
|
||||
ClientServiceTaskInstance.catalogue.has(or_(ServiceCatalogue.service_name.ilike(like), ServiceCatalogue.service_code.ilike(like))),
|
||||
)
|
||||
)
|
||||
tasks = db.execute(
|
||||
task_stmt.order_by(
|
||||
ClientServiceTaskInstance.internal_target_date.is_(None),
|
||||
ClientServiceTaskInstance.internal_target_date.asc(),
|
||||
ClientServiceTaskInstance.priority.desc(),
|
||||
ClientServiceTaskInstance.id.desc(),
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
columns = [
|
||||
{"code": "pending_review", "label": "Pending Review", "hint": "Completed tasks waiting for partner review", "tasks": []},
|
||||
{"code": "clarification_required", "label": "Clarification Required", "hint": "Blocked tasks needing partner attention", "tasks": []},
|
||||
{"code": "rework_sent", "label": "Rework Sent", "hint": "Pending/reopened after review notes", "tasks": []},
|
||||
{"code": "approved", "label": "In Progress", "hint": "Work currently moving with the team", "tasks": []},
|
||||
{"code": "completed", "label": "Completed", "hint": "Closed tasks", "tasks": []},
|
||||
]
|
||||
lookup = {c["code"]: c for c in columns}
|
||||
summary = {"total": len(tasks), "pending_review": 0, "blocked": 0, "overdue": 0, "due_today": 0, "completed": 0, "clients": set(), "engagements": set()}
|
||||
|
||||
for task in tasks:
|
||||
_decorate_task(task, today)
|
||||
status = (task.status or "pending").lower()
|
||||
if getattr(task, "client_id", None):
|
||||
summary["clients"].add(task.client_id)
|
||||
if getattr(task, "subscription_id", None):
|
||||
summary["engagements"].add(task.subscription_id)
|
||||
if status == "completed":
|
||||
summary["pending_review"] += 1
|
||||
if status == "blocked":
|
||||
summary["blocked"] += 1
|
||||
if getattr(task, "is_overdue", False):
|
||||
summary["overdue"] += 1
|
||||
if getattr(task, "is_due_today", False):
|
||||
summary["due_today"] += 1
|
||||
if status in CLOSED_TASK_STATUSES:
|
||||
summary["completed"] += 1
|
||||
lookup[_task_bucket(task)]["tasks"].append(task)
|
||||
|
||||
summary["clients"] = len(summary["clients"])
|
||||
summary["engagements"] = len(summary["engagements"])
|
||||
for col in columns:
|
||||
col["count"] = len(col["tasks"])
|
||||
|
||||
engagement_stmt = (
|
||||
select(ClientServiceSubscription)
|
||||
.options(
|
||||
selectinload(ClientServiceSubscription.client),
|
||||
selectinload(ClientServiceSubscription.catalogue),
|
||||
selectinload(ClientServiceSubscription.assigned_manager),
|
||||
selectinload(ClientServiceSubscription.assigned_staff),
|
||||
)
|
||||
)
|
||||
engagement_stmt = _subscription_scope_filter(engagement_stmt, tenant_id, branch_id, current_user, roles, financial_year=financial_year)
|
||||
engagements = db.execute(
|
||||
engagement_stmt.order_by(ClientServiceSubscription.current_due_date.is_(None), ClientServiceSubscription.current_due_date.asc(), ClientServiceSubscription.id.desc()).limit(25)
|
||||
).scalars().all()
|
||||
|
||||
task_counts_by_subscription: dict[int, dict[str, int]] = defaultdict(lambda: {"total": 0, "completed": 0, "open": 0})
|
||||
for task in tasks:
|
||||
bucket = task_counts_by_subscription[int(task.subscription_id)]
|
||||
bucket["total"] += 1
|
||||
if (task.status or "").lower() in CLOSED_TASK_STATUSES:
|
||||
bucket["completed"] += 1
|
||||
else:
|
||||
bucket["open"] += 1
|
||||
|
||||
for engagement in engagements:
|
||||
engagement.display_label = _engagement_label(engagement)
|
||||
engagement.client_display = getattr(getattr(engagement, "client", None), "client_name", None) or "Unlinked Client"
|
||||
engagement.task_counts = task_counts_by_subscription.get(int(engagement.id), {"total": 0, "completed": 0, "open": 0})
|
||||
due = getattr(engagement, "current_due_date", None)
|
||||
engagement.is_overdue = bool(due and due < today and (engagement.status or "").lower() not in {"completed", "closed", "locked"})
|
||||
|
||||
return {"summary": summary, "columns": columns, "engagements": engagements, "q": q, "today": today, "financial_year": financial_year}
|
||||
|
||||
|
||||
def _get_partner_task_or_redirect(db: Session, request: Request, current_user, task_id: int) -> ClientServiceTaskInstance | None:
|
||||
roles = set(get_user_roles(db, current_user.id))
|
||||
tenant_id, branch_id = _active_tenant_branch(request, current_user, roles)
|
||||
financial_year = _active_financial_year(request)
|
||||
stmt = select(ClientServiceTaskInstance).options(selectinload(ClientServiceTaskInstance.subscription)).where(ClientServiceTaskInstance.id == int(task_id))
|
||||
stmt = _task_scope_filter(stmt, tenant_id, branch_id, current_user, roles, financial_year=financial_year)
|
||||
return db.execute(stmt).scalar_one_or_none()
|
||||
|
||||
|
||||
@router.get("/dashboard")
|
||||
def partner_dashboard(request: Request, q: str = ""):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
current_user = get_current_user(request, db=db)
|
||||
if not current_user:
|
||||
return _redirect_login()
|
||||
if not _is_partner_user(db, current_user):
|
||||
return _redirect_denied()
|
||||
payload = build_partner_payload(db, request, current_user, q=q)
|
||||
return _render(request, "modules/partners/templates/partners/dashboard.html", db, current_user, title="Partner Workspace", payload=payload, q=q, errors=[])
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/reviews")
|
||||
def partner_review_board(request: Request, q: str = ""):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
current_user = get_current_user(request, db=db)
|
||||
if not current_user:
|
||||
return _redirect_login()
|
||||
if not _is_partner_user(db, current_user):
|
||||
return _redirect_denied()
|
||||
payload = build_partner_payload(db, request, current_user, q=q)
|
||||
return _render(request, "modules/partners/templates/partners/review_board.html", db, current_user, title="Partner Review Board", payload=payload, q=q, errors=[])
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/clients")
|
||||
def partner_clients(request: Request, q: str = ""):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
current_user = get_current_user(request, db=db)
|
||||
if not current_user:
|
||||
return _redirect_login()
|
||||
if not _is_partner_user(db, current_user):
|
||||
return _redirect_denied()
|
||||
payload = build_partner_payload(db, request, current_user, q=q)
|
||||
return _render(request, "modules/partners/templates/partners/clients.html", db, current_user, title="My Client Portfolio", payload=payload, q=q, errors=[])
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/engagements/{engagement_id}")
|
||||
def partner_engagement_detail(request: Request, engagement_id: int):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
current_user = get_current_user(request, db=db)
|
||||
if not current_user:
|
||||
return _redirect_login()
|
||||
if not _is_partner_user(db, current_user):
|
||||
return _redirect_denied()
|
||||
roles = set(get_user_roles(db, current_user.id))
|
||||
tenant_id, branch_id = _active_tenant_branch(request, current_user, roles)
|
||||
financial_year = _active_financial_year(request)
|
||||
engagement_stmt = select(ClientServiceSubscription).options(
|
||||
selectinload(ClientServiceSubscription.client),
|
||||
selectinload(ClientServiceSubscription.catalogue),
|
||||
selectinload(ClientServiceSubscription.assigned_manager),
|
||||
selectinload(ClientServiceSubscription.assigned_staff),
|
||||
).where(ClientServiceSubscription.id == int(engagement_id))
|
||||
engagement_stmt = _subscription_scope_filter(engagement_stmt, tenant_id, branch_id, current_user, roles, financial_year=financial_year)
|
||||
engagement = db.execute(engagement_stmt).scalar_one_or_none()
|
||||
if not engagement:
|
||||
return _redirect_denied()
|
||||
|
||||
task_stmt = select(ClientServiceTaskInstance).options(
|
||||
selectinload(ClientServiceTaskInstance.client),
|
||||
selectinload(ClientServiceTaskInstance.catalogue),
|
||||
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue),
|
||||
selectinload(ClientServiceTaskInstance.assigned_to),
|
||||
selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by),
|
||||
).where(ClientServiceTaskInstance.subscription_id == engagement.id, ClientServiceTaskInstance.is_active.is_(True))
|
||||
tasks = db.execute(task_stmt.order_by(ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.id.asc())).scalars().all()
|
||||
today = date.today()
|
||||
for task in tasks:
|
||||
_decorate_task(task, today)
|
||||
documents = db.execute(
|
||||
select(EngagementDocument)
|
||||
.where(EngagementDocument.engagement_id == engagement.id, EngagementDocument.is_deleted.is_(False))
|
||||
.order_by(EngagementDocument.updated_at_utc.desc(), EngagementDocument.id.desc())
|
||||
).scalars().all()
|
||||
engagement.display_label = _engagement_label(engagement)
|
||||
return _render(request, "modules/partners/templates/partners/engagement_detail.html", db, current_user, title="Partner Engagement Review", engagement=engagement, tasks=tasks, documents=documents, errors=[], financial_year=financial_year)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/review")
|
||||
def partner_review_task(request: Request, task_id: int, action: str = Form(...), message: str = Form(""), csrf_token: str = Form(...)):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
validate_csrf(request, csrf_token)
|
||||
current_user = get_current_user(request, db=db)
|
||||
if not current_user:
|
||||
return _redirect_login()
|
||||
if not _is_partner_user(db, current_user):
|
||||
return _redirect_denied()
|
||||
task = _get_partner_task_or_redirect(db, request, current_user, task_id)
|
||||
if not task:
|
||||
return _redirect_denied()
|
||||
new_status, comment_type, visibility = REVIEW_ACTIONS.get(action, REVIEW_ACTIONS["approve"])
|
||||
task.status = new_status
|
||||
task.updated_by_user_id = current_user.id
|
||||
task.updated_at_utc = datetime.now(timezone.utc)
|
||||
note = (message or "").strip()
|
||||
if not note:
|
||||
note = {
|
||||
"approve": "Approved by partner.",
|
||||
"send_rework": "Sent back for rework by partner.",
|
||||
"clarification": "Clarification requested by partner.",
|
||||
}.get(action, "Partner review updated.")
|
||||
db.add(ServiceTaskComment(
|
||||
tenant_id=task.tenant_id,
|
||||
branch_id=task.branch_id,
|
||||
subscription_id=task.subscription_id,
|
||||
task_instance_id=task.id,
|
||||
comment_type=comment_type,
|
||||
visibility=visibility,
|
||||
message=note,
|
||||
created_by_user_id=current_user.id,
|
||||
))
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/partner/engagements/{task.subscription_id}", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
Reference in New Issue
Block a user