Prepare ERP source for Gitea deployment
This commit is contained in:
@@ -0,0 +1 @@
|
||||
from __future__ import annotations
|
||||
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
|
||||
class UserAlert(CommonBase):
|
||||
"""Common role-aware alert table for all dashboards and portals.
|
||||
|
||||
Phase 7H foundation only stores and displays alerts. Later phases can call
|
||||
app.modules.alerts.service.create_alert() from task, document, attendance,
|
||||
client and consultant workflows without changing this schema.
|
||||
"""
|
||||
|
||||
__tablename__ = "user_alerts"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
role_context: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
|
||||
alert_type: Mapped[str] = mapped_column(String(80), nullable=False, default="general", index=True)
|
||||
priority: Mapped[str] = mapped_column(String(20), nullable=False, default="normal", index=True)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
target_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
is_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
|
||||
read_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False, index=True
|
||||
)
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
|
||||
user = relationship("User", foreign_keys=[user_id])
|
||||
created_by = relationship("User", foreign_keys=[created_by_user_id])
|
||||
@@ -0,0 +1,174 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Iterable
|
||||
|
||||
from sqlalchemy import Select, func, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.alerts.models import UserAlert
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.email_integration.event_service import send_alert_created_email
|
||||
|
||||
ALERT_PRIORITIES = ("low", "normal", "high", "critical")
|
||||
ALERT_TYPES = (
|
||||
"general",
|
||||
"task_assigned",
|
||||
"task_due",
|
||||
"task_overdue",
|
||||
"task_review",
|
||||
"document_uploaded",
|
||||
"clarification",
|
||||
"attendance",
|
||||
"leave",
|
||||
"payroll",
|
||||
"consultant",
|
||||
"client",
|
||||
)
|
||||
|
||||
|
||||
def normalize_priority(priority: str | None) -> str:
|
||||
value = (priority or "normal").strip().lower()
|
||||
return value if value in ALERT_PRIORITIES else "normal"
|
||||
|
||||
|
||||
def normalize_alert_type(alert_type: str | None) -> str:
|
||||
value = (alert_type or "general").strip().lower()
|
||||
return value or "general"
|
||||
|
||||
|
||||
def create_alert(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: int,
|
||||
title: str,
|
||||
message: str | None = None,
|
||||
tenant_id: int | None = None,
|
||||
branch_id: int | None = None,
|
||||
role_context: str | None = None,
|
||||
alert_type: str = "general",
|
||||
priority: str = "normal",
|
||||
target_url: str | None = None,
|
||||
created_by_user_id: int | None = None,
|
||||
commit: bool = True,
|
||||
) -> UserAlert:
|
||||
alert = UserAlert(
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
user_id=user_id,
|
||||
role_context=(role_context or None),
|
||||
alert_type=normalize_alert_type(alert_type),
|
||||
priority=normalize_priority(priority),
|
||||
title=(title or "Alert").strip()[:255],
|
||||
message=(message or None),
|
||||
target_url=(target_url or None),
|
||||
created_by_user_id=created_by_user_id,
|
||||
)
|
||||
db.add(alert)
|
||||
db.flush()
|
||||
try:
|
||||
send_alert_created_email(db, alert)
|
||||
except Exception:
|
||||
# Email notification must never block in-app alert creation.
|
||||
pass
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(alert)
|
||||
return alert
|
||||
|
||||
|
||||
def create_bulk_alerts(
|
||||
db: Session,
|
||||
*,
|
||||
user_ids: Iterable[int],
|
||||
title: str,
|
||||
message: str | None = None,
|
||||
tenant_id: int | None = None,
|
||||
branch_id: int | None = None,
|
||||
role_context: str | None = None,
|
||||
alert_type: str = "general",
|
||||
priority: str = "normal",
|
||||
target_url: str | None = None,
|
||||
created_by_user_id: int | None = None,
|
||||
) -> list[UserAlert]:
|
||||
rows: list[UserAlert] = []
|
||||
for user_id in sorted({int(uid) for uid in user_ids if uid}):
|
||||
rows.append(
|
||||
create_alert(
|
||||
db,
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
message=message,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
role_context=role_context,
|
||||
alert_type=alert_type,
|
||||
priority=priority,
|
||||
target_url=target_url,
|
||||
created_by_user_id=created_by_user_id,
|
||||
commit=False,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
for row in rows:
|
||||
db.refresh(row)
|
||||
return rows
|
||||
|
||||
|
||||
def _user_alert_query(current_user: User) -> Select:
|
||||
return select(UserAlert).where(UserAlert.user_id == current_user.id)
|
||||
|
||||
|
||||
def list_my_alerts(
|
||||
db: Session,
|
||||
current_user: User,
|
||||
*,
|
||||
status: str = "all",
|
||||
priority: str = "all",
|
||||
limit: int = 100,
|
||||
) -> list[UserAlert]:
|
||||
q = _user_alert_query(current_user)
|
||||
if status == "unread":
|
||||
q = q.where(UserAlert.is_read.is_(False))
|
||||
elif status == "read":
|
||||
q = q.where(UserAlert.is_read.is_(True))
|
||||
if priority in ALERT_PRIORITIES:
|
||||
q = q.where(UserAlert.priority == priority)
|
||||
q = q.order_by(UserAlert.is_read.asc(), UserAlert.created_at_utc.desc()).limit(max(1, min(limit, 500)))
|
||||
return list(db.execute(q).scalars().all())
|
||||
|
||||
|
||||
def count_unread_alerts(db: Session, current_user: User | None) -> int:
|
||||
if not current_user:
|
||||
return 0
|
||||
value = db.execute(
|
||||
select(func.count(UserAlert.id)).where(UserAlert.user_id == current_user.id, UserAlert.is_read.is_(False))
|
||||
).scalar_one()
|
||||
return int(value or 0)
|
||||
|
||||
|
||||
def get_my_alert_or_404(db: Session, current_user: User, alert_id: int) -> UserAlert | None:
|
||||
return db.execute(
|
||||
select(UserAlert).where(UserAlert.id == alert_id, UserAlert.user_id == current_user.id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def mark_alert_read(db: Session, current_user: User, alert_id: int) -> bool:
|
||||
alert = get_my_alert_or_404(db, current_user, alert_id)
|
||||
if not alert:
|
||||
return False
|
||||
if not alert.is_read:
|
||||
alert.is_read = True
|
||||
alert.read_at_utc = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
def mark_all_alerts_read(db: Session, current_user: User) -> int:
|
||||
result = db.execute(
|
||||
update(UserAlert)
|
||||
.where(UserAlert.user_id == current_user.id, UserAlert.is_read.is_(False))
|
||||
.values(is_read=True, read_at_utc=datetime.now(timezone.utc))
|
||||
)
|
||||
db.commit()
|
||||
return int(result.rowcount or 0)
|
||||
@@ -0,0 +1,73 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
{% set _role_text = (current_user_roles or [])|join('|')|lower %}
|
||||
{% if 'partner' in _role_text %}
|
||||
{% include "modules/partners/templates/partners/_partner_tabs.html" %}
|
||||
{% elif 'manager' in _role_text %}
|
||||
{% include "modules/managers/templates/managers/_manager_tabs.html" %}
|
||||
{% else %}
|
||||
{% include "modules/employees/templates/employees/_my_workspace_tabs.html" %}
|
||||
{% endif %}
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">My Alerts</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Role-wise alerts for tasks, documents, attendance, leave, payroll, client and consultant workflows.</p>
|
||||
</div>
|
||||
<form method="post" action="/alerts/read-all">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50" {% if unread_count == 0 %}disabled{% endif %}>Mark all as read</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Unread</div><div class="mt-1 text-2xl font-semibold">{{ unread_count }}</div></div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Showing</div><div class="mt-1 text-2xl font-semibold">{{ alerts|length }}</div></div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Filter</div><div class="mt-1 text-sm text-slate-600">{{ status.replace('_',' ').title() }} · {{ priority.title() }}</div></div>
|
||||
</div>
|
||||
|
||||
<form method="get" action="/alerts" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="grid gap-3 md:grid-cols-[220px_220px_auto]">
|
||||
<select name="status" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="all" {% if status == 'all' %}selected{% endif %}>All alerts</option>
|
||||
<option value="unread" {% if status == 'unread' %}selected{% endif %}>Unread only</option>
|
||||
<option value="read" {% if status == 'read' %}selected{% endif %}>Read only</option>
|
||||
</select>
|
||||
<select name="priority" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="all" {% if priority == 'all' %}selected{% endif %}>All priorities</option>
|
||||
{% for p in priorities %}<option value="{{ p }}" {% if priority == p %}selected{% endif %}>{{ p.title() }}</option>{% endfor %}
|
||||
</select>
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Apply Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="space-y-3">
|
||||
{% for alert in alerts %}
|
||||
<div class="rounded-2xl border {% if alert.is_read %}border-slate-200 bg-white{% else %}border-brand-100 bg-brand-50{% endif %} p-5 shadow-soft">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="font-semibold text-slate-900">{{ alert.title }}</h3>
|
||||
<span class="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-slate-600">{{ alert.priority }}</span>
|
||||
{% if not alert.is_read %}<span class="rounded-full bg-brand-600 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-white">Unread</span>{% endif %}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-slate-500">{{ alert.alert_type.replace('_',' ').title() }}{% if alert.role_context %} · {{ alert.role_context }}{% endif %} · {{ alert.created_at_utc.strftime('%d-%m-%Y %H:%M') if alert.created_at_utc else '-' }}</div>
|
||||
{% if alert.message %}<p class="mt-3 text-sm text-slate-700">{{ alert.message }}</p>{% endif %}
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap justify-end gap-2">
|
||||
{% if alert.target_url %}<a href="{{ alert.target_url }}" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Open</a>{% endif %}
|
||||
{% if not alert.is_read %}
|
||||
<form method="post" action="/alerts/{{ alert.id }}/read">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Mark read</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-8 text-center text-sm text-slate-500 shadow-soft">No alerts found for the selected filter.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
|
||||
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.alerts.service import ALERT_PRIORITIES, count_unread_alerts, list_my_alerts, mark_alert_read, mark_all_alerts_read
|
||||
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
||||
|
||||
router = APIRouter(prefix="/alerts", tags=["alerts-ui"])
|
||||
|
||||
|
||||
def _redirect_login():
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
|
||||
|
||||
def _base_ctx(request: Request, db, 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),
|
||||
}
|
||||
base.update(ctx)
|
||||
return base
|
||||
|
||||
|
||||
@router.get("/poll")
|
||||
def poll_unread_alerts(request: Request, limit: int = 5):
|
||||
"""Lightweight polling endpoint used by the base layout toast popup.
|
||||
|
||||
Returns a small list of unread alerts for the logged-in user. It does not
|
||||
mark alerts as read; the normal /alerts page and existing read actions
|
||||
continue to control read status.
|
||||
"""
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
current_user = get_current_user(request, db)
|
||||
if not current_user:
|
||||
return JSONResponse({"authenticated": False, "unread_count": 0, "alerts": []}, status_code=401)
|
||||
|
||||
safe_limit = max(1, min(int(limit or 5), 10))
|
||||
rows = list_my_alerts(db, current_user, status="unread", priority="all", limit=safe_limit)
|
||||
payload = []
|
||||
for row in rows:
|
||||
created_at = getattr(row, "created_at_utc", None)
|
||||
payload.append(
|
||||
{
|
||||
"id": row.id,
|
||||
"title": row.title or "Alert",
|
||||
"message": row.message or "",
|
||||
"priority": row.priority or "normal",
|
||||
"alert_type": row.alert_type or "general",
|
||||
"target_url": row.target_url or "/alerts",
|
||||
"created_at_utc": created_at.isoformat() if created_at else None,
|
||||
}
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"authenticated": True,
|
||||
"unread_count": count_unread_alerts(db, current_user),
|
||||
"alerts": payload,
|
||||
}
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("")
|
||||
def alerts_list(request: Request, status: str = "all", priority: str = "all"):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
current_user = get_current_user(request, db)
|
||||
if not current_user:
|
||||
return _redirect_login()
|
||||
status = status if status in {"all", "unread", "read"} else "all"
|
||||
priority = priority if priority in ALERT_PRIORITIES else "all"
|
||||
rows = list_my_alerts(db, current_user, status=status, priority=priority, limit=150)
|
||||
return templates.TemplateResponse(
|
||||
"modules/alerts/templates/alerts/list.html",
|
||||
_base_ctx(
|
||||
request,
|
||||
db,
|
||||
current_user,
|
||||
title="My Alerts",
|
||||
alerts=rows,
|
||||
status=status,
|
||||
priority=priority,
|
||||
priorities=ALERT_PRIORITIES,
|
||||
unread_count=count_unread_alerts(db, current_user),
|
||||
),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/{alert_id}/read")
|
||||
def mark_read(request: Request, alert_id: int, csrf_token: str = Form(...)):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
current_user = get_current_user(request, db)
|
||||
if not current_user:
|
||||
return _redirect_login()
|
||||
validate_csrf(request, csrf_token)
|
||||
mark_alert_read(db, current_user, alert_id)
|
||||
return RedirectResponse(url="/alerts", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/read-all")
|
||||
def mark_all_read(request: Request, csrf_token: str = Form(...)):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
current_user = get_current_user(request, db)
|
||||
if not current_user:
|
||||
return _redirect_login()
|
||||
validate_csrf(request, csrf_token)
|
||||
mark_all_alerts_read(db, current_user)
|
||||
return RedirectResponse(url="/alerts", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1 @@
|
||||
"""Billing module for firm-level invoices and fee structure imports."""
|
||||
@@ -0,0 +1,138 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.modules.billing.models import BillingInvoice, BillingInvoiceLine, BillingPayment
|
||||
from app.modules.billing.services import build_invoice_print_context, is_cashfree_ready, is_payumoney_ready, money
|
||||
|
||||
CLIENT_VISIBLE_INVOICE_STATUSES = {"ISSUED", "PARTLY_PAID", "PAID", "OVERDUE"}
|
||||
|
||||
|
||||
def _client_ids(client_row: Any) -> tuple[int, int]:
|
||||
"""Return (tenant_id, client_id) from dict/row/model style client payload."""
|
||||
if isinstance(client_row, dict):
|
||||
return int(client_row.get("tenant_id") or 0), int(client_row.get("id") or 0)
|
||||
return int(getattr(client_row, "tenant_id", 0) or 0), int(getattr(client_row, "id", 0) or 0)
|
||||
|
||||
|
||||
def list_client_portal_invoices(db: Session, client_row: Any, *, q: str = "", include_paid: bool = True, financial_year: str | None = None) -> list[BillingInvoice]:
|
||||
tenant_id, client_id = _client_ids(client_row)
|
||||
stmt = (
|
||||
select(BillingInvoice)
|
||||
.options(
|
||||
selectinload(BillingInvoice.lines).selectinload(BillingInvoiceLine.service),
|
||||
selectinload(BillingInvoice.payments),
|
||||
)
|
||||
.where(
|
||||
BillingInvoice.tenant_id == tenant_id,
|
||||
BillingInvoice.client_id == client_id,
|
||||
BillingInvoice.status.in_(CLIENT_VISIBLE_INVOICE_STATUSES),
|
||||
)
|
||||
)
|
||||
if not include_paid:
|
||||
stmt = stmt.where(BillingInvoice.status != "PAID")
|
||||
if financial_year and financial_year.upper() != "ALL":
|
||||
stmt = stmt.where(BillingInvoice.financial_year == financial_year)
|
||||
if q.strip():
|
||||
term = f"%{q.strip()}%"
|
||||
stmt = stmt.where(or_(BillingInvoice.invoice_no.ilike(term), BillingInvoice.invoice_title.ilike(term)))
|
||||
return db.execute(stmt.order_by(BillingInvoice.invoice_date.desc(), BillingInvoice.id.desc())).scalars().unique().all()
|
||||
|
||||
|
||||
def get_client_portal_invoice(db: Session, client_row: Any, invoice_id: int, *, financial_year: str | None = None) -> BillingInvoice | None:
|
||||
tenant_id, client_id = _client_ids(client_row)
|
||||
stmt = (
|
||||
select(BillingInvoice)
|
||||
.options(
|
||||
selectinload(BillingInvoice.client),
|
||||
selectinload(BillingInvoice.lines).selectinload(BillingInvoiceLine.service),
|
||||
selectinload(BillingInvoice.payments),
|
||||
)
|
||||
.where(
|
||||
BillingInvoice.id == invoice_id,
|
||||
BillingInvoice.tenant_id == tenant_id,
|
||||
BillingInvoice.client_id == client_id,
|
||||
BillingInvoice.status.in_(CLIENT_VISIBLE_INVOICE_STATUSES),
|
||||
)
|
||||
)
|
||||
if financial_year and financial_year.upper() != "ALL":
|
||||
stmt = stmt.where(BillingInvoice.financial_year == financial_year)
|
||||
return db.execute(stmt).scalars().unique().one_or_none()
|
||||
|
||||
|
||||
def get_client_portal_payment(db: Session, client_row: Any, payment_id: int, *, financial_year: str | None = None) -> BillingPayment | None:
|
||||
tenant_id, client_id = _client_ids(client_row)
|
||||
stmt = (
|
||||
select(BillingPayment)
|
||||
.options(
|
||||
selectinload(BillingPayment.invoice).selectinload(BillingInvoice.lines),
|
||||
selectinload(BillingPayment.client),
|
||||
)
|
||||
.where(
|
||||
BillingPayment.id == payment_id,
|
||||
BillingPayment.tenant_id == tenant_id,
|
||||
BillingPayment.client_id == client_id,
|
||||
BillingPayment.status == "RECEIVED",
|
||||
)
|
||||
)
|
||||
if financial_year and financial_year.upper() != "ALL":
|
||||
stmt = stmt.where(BillingPayment.financial_year == financial_year)
|
||||
return db.execute(stmt).scalars().unique().one_or_none()
|
||||
|
||||
|
||||
def build_client_billing_summary(db: Session, client_row: Any, *, financial_year: str | None = None) -> dict[str, Any]:
|
||||
invoices = list_client_portal_invoices(db, client_row, include_paid=True, financial_year=financial_year)
|
||||
open_invoices = [row for row in invoices if row.status in {"ISSUED", "PARTLY_PAID", "OVERDUE"} and money(row.balance_amount) > Decimal("0.00")]
|
||||
paid_invoices = [row for row in invoices if row.status == "PAID"]
|
||||
outstanding = sum((money(row.balance_amount) for row in open_invoices), Decimal("0.00"))
|
||||
latest_invoice = invoices[0] if invoices else None
|
||||
latest_due_invoice = open_invoices[0] if open_invoices else None
|
||||
return {
|
||||
"billing_invoices": invoices,
|
||||
"billing_open_invoices": open_invoices,
|
||||
"billing_paid_invoices": paid_invoices,
|
||||
"billing_outstanding_amount": money(outstanding),
|
||||
"billing_latest_invoice": latest_invoice,
|
||||
"billing_latest_due_invoice": latest_due_invoice,
|
||||
"billing_open_count": len(open_invoices),
|
||||
"billing_paid_count": len(paid_invoices),
|
||||
"billing_total_count": len(invoices),
|
||||
}
|
||||
|
||||
|
||||
def build_client_payment_context(db: Session, invoice: BillingInvoice) -> dict[str, Any]:
|
||||
invoice_ctx = build_invoice_print_context(db, invoice)
|
||||
settings = invoice_ctx.get("settings")
|
||||
amount_due = money(invoice.balance_amount)
|
||||
firm_name = invoice_ctx.get("firm_name") or "Audit Firm"
|
||||
upi_id = getattr(settings, "upi_id", None) if settings else None
|
||||
upi_link = None
|
||||
if upi_id and amount_due > Decimal("0.00"):
|
||||
upi_link = (
|
||||
"upi://pay?"
|
||||
f"pa={quote(str(upi_id))}"
|
||||
f"&pn={quote(str(firm_name))}"
|
||||
f"&am={quote(str(amount_due))}"
|
||||
"&cu=INR"
|
||||
f"&tn={quote('Invoice ' + str(invoice.invoice_no))}"
|
||||
)
|
||||
return {
|
||||
"invoice_ctx": invoice_ctx,
|
||||
"amount_due": amount_due,
|
||||
"upi_link": upi_link,
|
||||
"upi_id": upi_id,
|
||||
"bank_name": invoice_ctx.get("bank_name"),
|
||||
"bank_account_name": invoice_ctx.get("bank_account_name"),
|
||||
"bank_account_number": invoice_ctx.get("bank_account_number"),
|
||||
"bank_ifsc": invoice_ctx.get("bank_ifsc"),
|
||||
"payment_instructions": getattr(settings, "bank_details", None) if settings else None,
|
||||
"payumoney_enabled": is_payumoney_ready(settings),
|
||||
"payumoney_mode": getattr(settings, "payumoney_mode", "TEST") if settings else "TEST",
|
||||
"cashfree_enabled": is_cashfree_ready(settings),
|
||||
"cashfree_mode": getattr(settings, "cashfree_mode", "TEST") if settings else "TEST",
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
|
||||
class BillingSettings(CommonBase):
|
||||
__tablename__ = "billing_settings"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "branch_id", name="uq_billing_settings_tenant_branch"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
invoice_prefix: Mapped[str] = mapped_column(String(40), nullable=False, default="INV")
|
||||
next_invoice_no: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
padding: Mapped[int] = mapped_column(Integer, nullable=False, default=4)
|
||||
default_gst_rate: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=Decimal("18.00"))
|
||||
default_tax_type: Mapped[str] = mapped_column(String(20), nullable=False, default="CGST_SGST")
|
||||
|
||||
legal_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
gstin: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
pan: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
||||
state_code: Mapped[str | None] = mapped_column(String(2), nullable=True)
|
||||
billing_address: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
contact_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
contact_mobile: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
website_url: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
invoice_title: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
invoice_number_format: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
default_due_days: Mapped[int] = mapped_column(Integer, nullable=False, default=15)
|
||||
default_sac_code: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
|
||||
bank_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
bank_account_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
bank_account_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
bank_ifsc: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
upi_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
bank_details: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
payumoney_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
payumoney_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="TEST")
|
||||
payumoney_merchant_key: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
payumoney_merchant_salt: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
payumoney_merchant_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
payumoney_product_info: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
|
||||
cashfree_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
cashfree_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="TEST")
|
||||
cashfree_client_id: Mapped[str | None] = mapped_column(String(180), nullable=True)
|
||||
cashfree_client_secret: Mapped[str | None] = mapped_column(String(240), nullable=True)
|
||||
cashfree_api_version: Mapped[str] = mapped_column(String(20), nullable=False, default="2023-08-01")
|
||||
cashfree_order_note: Mapped[str | None] = mapped_column(String(250), nullable=True)
|
||||
|
||||
authorised_signatory_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
declaration: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
terms: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
footer_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
|
||||
|
||||
class BillingInvoiceGenerationBatch(CommonBase):
|
||||
__tablename__ = "billing_invoice_generation_batches"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
billing_period_from: Mapped[date] = mapped_column(Date, nullable=False, index=True)
|
||||
billing_period_to: Mapped[date] = mapped_column(Date, nullable=False, index=True)
|
||||
financial_year: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
frequency: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="DRAFT_CREATED", index=True)
|
||||
|
||||
selected_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
created_invoice_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
skipped_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
error_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
generated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
generated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
|
||||
invoices = relationship("BillingInvoice", back_populates="generation_batch")
|
||||
|
||||
|
||||
class BillingInvoice(CommonBase):
|
||||
__tablename__ = "billing_invoices"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "invoice_no", name="uq_billing_invoices_tenant_invoice_no"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||
engagement_id: Mapped[int | None] = mapped_column(ForeignKey("client_service_subscriptions.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
generation_batch_id: Mapped[int | None] = mapped_column(ForeignKey("billing_invoice_generation_batches.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
invoice_no: Mapped[str] = mapped_column(String(60), nullable=False, index=True)
|
||||
invoice_date: Mapped[date] = mapped_column(Date, nullable=False, default=date.today, index=True)
|
||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
|
||||
billing_period_from: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
billing_period_to: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
financial_year: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
|
||||
invoice_title: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
place_of_supply: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
reverse_charge: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
client_legal_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
client_trade_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
client_gstin: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
client_pan: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
client_billing_address: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
client_state: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
client_state_code: Mapped[str | None] = mapped_column(String(2), nullable=True)
|
||||
client_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
client_mobile: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
|
||||
tax_type: Mapped[str] = mapped_column(String(20), nullable=False, default="CGST_SGST")
|
||||
subtotal: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
discount_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
taxable_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
cgst_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
sgst_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
igst_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
round_off: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
total_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
amount_received: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
tds_deducted: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
bank_charges: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
balance_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
amount_in_words: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="DRAFT", index=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
terms: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
approved_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
posted_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
|
||||
client = relationship("Client")
|
||||
engagement = relationship("ClientServiceSubscription", foreign_keys=[engagement_id])
|
||||
generation_batch = relationship("BillingInvoiceGenerationBatch", back_populates="invoices")
|
||||
lines = relationship("BillingInvoiceLine", back_populates="invoice", cascade="all, delete-orphan", order_by="BillingInvoiceLine.sort_order.asc()")
|
||||
payments = relationship("BillingPayment", back_populates="invoice", cascade="all, delete-orphan", order_by="BillingPayment.payment_date.desc(), BillingPayment.id.desc()")
|
||||
online_transactions = relationship("BillingOnlinePaymentTransaction", back_populates="invoice", cascade="all, delete-orphan", order_by="BillingOnlinePaymentTransaction.created_at_utc.desc(), BillingOnlinePaymentTransaction.id.desc()")
|
||||
|
||||
|
||||
class BillingPayment(CommonBase):
|
||||
__tablename__ = "billing_payments"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "receipt_no", name="uq_billing_payments_tenant_receipt_no"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
invoice_id: Mapped[int] = mapped_column(ForeignKey("billing_invoices.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||
|
||||
receipt_no: Mapped[str] = mapped_column(String(60), nullable=False, index=True)
|
||||
receipt_date: Mapped[date] = mapped_column(Date, nullable=False, default=date.today, index=True)
|
||||
payment_date: Mapped[date] = mapped_column(Date, nullable=False, default=date.today, index=True)
|
||||
financial_year: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
amount_received: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
tds_deducted: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
bank_charges: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
mode: Mapped[str] = mapped_column(String(30), nullable=False, default="BANK")
|
||||
reference_no: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
payment_gateway: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
gateway_transaction_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="RECEIVED", index=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
|
||||
invoice = relationship("BillingInvoice", back_populates="payments")
|
||||
client = relationship("Client")
|
||||
|
||||
|
||||
class BillingOnlinePaymentTransaction(CommonBase):
|
||||
__tablename__ = "billing_online_payment_transactions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "txnid", name="uq_billing_online_payment_tenant_txnid"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
invoice_id: Mapped[int] = mapped_column(ForeignKey("billing_invoices.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||
|
||||
provider: Mapped[str] = mapped_column(String(40), nullable=False, default="PAYUMONEY", index=True)
|
||||
mode: Mapped[str] = mapped_column(String(20), nullable=False, default="TEST")
|
||||
txnid: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||
amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
productinfo: Mapped[str | None] = mapped_column(String(250), nullable=True)
|
||||
firstname: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
phone: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
|
||||
payu_payment_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
|
||||
cashfree_order_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
|
||||
cashfree_cf_order_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
|
||||
cashfree_payment_session_id: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
cashfree_payment_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
|
||||
webhook_event_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
|
||||
bank_ref_num: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
mihpayid: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, default="INITIATED", index=True)
|
||||
gateway_status: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
response_hash: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
raw_response: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
receipt_payment_id: Mapped[int | None] = mapped_column(ForeignKey("billing_payments.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
completed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
invoice = relationship("BillingInvoice", back_populates="online_transactions")
|
||||
client = relationship("Client")
|
||||
receipt_payment = relationship("BillingPayment", foreign_keys=[receipt_payment_id])
|
||||
|
||||
|
||||
class BillingInvoiceLine(CommonBase):
|
||||
__tablename__ = "billing_invoice_lines"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
invoice_id: Mapped[int] = mapped_column(ForeignKey("billing_invoices.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
service_id: Mapped[int | None] = mapped_column(ForeignKey("service_catalogues.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
engagement_id: Mapped[int | None] = mapped_column(ForeignKey("client_service_subscriptions.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
fee_group_id: Mapped[int | None] = mapped_column(ForeignKey("billing_fee_groups.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
description: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
sac_code: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
billing_period_from: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
billing_period_to: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
quantity: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("1.00"))
|
||||
rate: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
discount_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
taxable_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
gst_rate: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=Decimal("18.00"))
|
||||
cgst_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
sgst_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
igst_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
line_total: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
|
||||
invoice = relationship("BillingInvoice", back_populates="lines")
|
||||
service = relationship("ServiceCatalogue")
|
||||
|
||||
|
||||
class BillingFeeGroup(CommonBase):
|
||||
__tablename__ = "billing_fee_groups"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "group_code", name="uq_billing_fee_groups_tenant_code"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
partner_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
group_code: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||
group_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
billing_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="PACKAGE")
|
||||
frequency: Mapped[str] = mapped_column(String(20), nullable=False, default="Monthly")
|
||||
fee_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
gst_rate: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=Decimal("18.00"))
|
||||
tax_type: Mapped[str] = mapped_column(String(20), nullable=False, default="CGST_SGST")
|
||||
effective_from: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
effective_to: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
auto_generate: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
|
||||
client = relationship("Client")
|
||||
services = relationship("BillingFeeGroupService", back_populates="fee_group", cascade="all, delete-orphan", order_by="BillingFeeGroupService.sort_order.asc()")
|
||||
|
||||
|
||||
class BillingFeeGroupService(CommonBase):
|
||||
__tablename__ = "billing_fee_group_services"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("fee_group_id", "service_id", name="uq_billing_fee_group_services_group_service"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
fee_group_id: Mapped[int] = mapped_column(ForeignKey("billing_fee_groups.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
service_id: Mapped[int] = mapped_column(ForeignKey("service_catalogues.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||
line_description: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
allocation_type: Mapped[str] = mapped_column(String(20), nullable=False, default="Included")
|
||||
line_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
percentage: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), nullable=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
|
||||
fee_group = relationship("BillingFeeGroup", back_populates="services")
|
||||
service = relationship("ServiceCatalogue")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/clients/templates/clients/_client_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-slate-900">Invoice {{ invoice.invoice_no }}</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Issued on {{ invoice.invoice_date.strftime('%d-%m-%Y') if invoice.invoice_date else '-' }}{% if invoice.due_date %} • Due {{ invoice.due_date.strftime('%d-%m-%Y') }}{% endif %}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/client/billing" class="af-btn af-btn-secondary">Back to Bills</a>
|
||||
<a href="/client/billing/{{ invoice.id }}/print" class="af-btn af-btn-secondary">Print / Save PDF</a>
|
||||
{% if invoice.balance_amount and invoice.balance_amount > 0 %}<a href="/client/billing/{{ invoice.id }}/pay-now" class="af-btn af-btn-primary">Pay Now</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="grid gap-4 md:grid-cols-4">
|
||||
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Invoice Total</div><div class="mt-2 text-2xl font-semibold">₹ {{ '%.2f'|format(invoice.total_amount or 0) }}</div></div>
|
||||
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Received</div><div class="mt-2 text-2xl font-semibold text-emerald-700">₹ {{ '%.2f'|format(invoice.amount_received or 0) }}</div></div>
|
||||
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">TDS</div><div class="mt-2 text-2xl font-semibold text-slate-900">₹ {{ '%.2f'|format(invoice.tds_deducted or 0) }}</div></div>
|
||||
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Balance</div><div class="mt-2 text-2xl font-semibold text-amber-700">₹ {{ '%.2f'|format(invoice.balance_amount or 0) }}</div></div>
|
||||
</section>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div class="af-card">
|
||||
<div class="flex items-center justify-between gap-3"><h3 class="text-lg font-semibold text-slate-900">Invoice Lines</h3><span class="af-badge {% if invoice.status == 'PAID' %}af-badge-success{% else %}af-badge-warning{% endif %}">{{ invoice.status.replace('_', ' ') }}</span></div>
|
||||
<div class="mt-5 overflow-x-auto rounded-2xl border border-slate-200">
|
||||
<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">Description</th><th class="px-4 py-3">SAC</th><th class="px-4 py-3 text-right">Taxable</th><th class="px-4 py-3 text-right">GST</th><th class="px-4 py-3 text-right">Total</th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-100 bg-white">
|
||||
{% for line in invoice.lines %}
|
||||
<tr><td class="px-4 py-3 font-medium text-slate-900 whitespace-pre-line">{{ line.description }}</td><td class="px-4 py-3 text-slate-600">{{ line.sac_code or '-' }}</td><td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(line.taxable_amount or 0) }}</td><td class="px-4 py-3 text-right">₹ {{ '%.2f'|format((line.cgst_amount or 0) + (line.sgst_amount or 0) + (line.igst_amount or 0)) }}</td><td class="px-4 py-3 text-right font-semibold">₹ {{ '%.2f'|format(line.line_total or 0) }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="space-y-6">
|
||||
<div class="af-card">
|
||||
<h3 class="text-base font-semibold text-slate-900">Payment Status</h3>
|
||||
<div class="mt-4 space-y-3 text-sm">
|
||||
<div class="flex justify-between"><span class="text-slate-500">Status</span><span class="font-semibold">{{ invoice.status.replace('_', ' ') }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-slate-500">Due Amount</span><span class="font-semibold text-amber-700">₹ {{ '%.2f'|format(invoice.balance_amount or 0) }}</span></div>
|
||||
{% if invoice.balance_amount and invoice.balance_amount > 0 %}<a href="/client/billing/{{ invoice.id }}/pay-now" class="mt-2 w-full justify-center af-btn af-btn-primary">Pay Now</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="af-card">
|
||||
<h3 class="text-base font-semibold text-slate-900">Receipts</h3>
|
||||
<div class="mt-4 space-y-3 text-sm">
|
||||
{% for p in invoice.payments %}
|
||||
{% if p.status == 'RECEIVED' %}
|
||||
<a href="/client/billing/receipts/{{ p.id }}" class="block rounded-2xl border border-slate-200 p-3 hover:bg-slate-50"><div class="font-semibold text-brand-700">{{ p.receipt_no }}</div><div class="mt-1 text-xs text-slate-500">{{ p.payment_date.strftime('%d-%m-%Y') if p.payment_date else '-' }} • ₹ {{ '%.2f'|format(p.amount_received or 0) }}</div></a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-dashed border-slate-300 p-4 text-slate-500">No receipts recorded yet.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,75 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/clients/templates/clients/_client_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 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-brand-100">Client Portal</p>
|
||||
<h2 class="mt-2 text-2xl font-semibold">My Bills & Payments</h2>
|
||||
<p class="mt-2 max-w-3xl text-sm text-brand-100">View invoices issued by your audit firm, download receipts and use Pay Now for pending bills.</p>
|
||||
<p class="mt-1 text-xs text-brand-100">Active FY: {{ active_financial_year or 'All Years' }}</p>
|
||||
</div>
|
||||
{% if billing_latest_due_invoice %}
|
||||
<a href="/client/billing/{{ billing_latest_due_invoice.id }}/pay-now" class="rounded-xl bg-white px-4 py-2 text-sm font-semibold text-brand-700 hover:bg-brand-50">Pay Latest Due</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-4 md:grid-cols-3">
|
||||
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Outstanding</div><div class="mt-2 text-3xl font-semibold text-amber-700">₹ {{ '%.2f'|format(billing_outstanding_amount or 0) }}</div><div class="mt-1 text-xs text-slate-500">{{ billing_open_count or 0 }} open bill(s)</div></div>
|
||||
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Total Invoices</div><div class="mt-2 text-3xl font-semibold text-slate-900">{{ billing_total_count or 0 }}</div><div class="mt-1 text-xs text-slate-500">Issued by firm</div></div>
|
||||
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Paid</div><div class="mt-2 text-3xl font-semibold text-emerald-700">{{ billing_paid_count or 0 }}</div><div class="mt-1 text-xs text-slate-500">Completed payments</div></div>
|
||||
</section>
|
||||
|
||||
<div class="af-card">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-slate-900">Invoices</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Draft and cancelled invoices are not shown in the client portal.</p>
|
||||
</div>
|
||||
<form method="get" action="/client/billing" class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<input name="q" value="{{ q or '' }}" placeholder="Search invoice no" class="rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
<select name="include_paid" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="yes" {% if include_paid != 'no' %}selected{% endif %}>All invoices</option>
|
||||
<option value="no" {% if include_paid == 'no' %}selected{% endif %}>Only pending</option>
|
||||
</select>
|
||||
<button class="af-btn af-btn-secondary" type="submit">Filter</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 overflow-x-auto rounded-2xl border border-slate-200">
|
||||
<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">Invoice</th>
|
||||
<th class="px-4 py-3">Date</th><th class="px-4 py-3">FY</th>
|
||||
<th class="px-4 py-3">Due Date</th>
|
||||
<th class="px-4 py-3 text-right">Total</th>
|
||||
<th class="px-4 py-3 text-right">Balance</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3 text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 bg-white">
|
||||
{% for row in rows %}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3 font-semibold text-slate-900"><a class="text-brand-700 hover:underline" href="/client/billing/{{ row.id }}">{{ row.invoice_no }}</a></td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ row.invoice_date.strftime('%d-%m-%Y') if row.invoice_date else '-' }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ row.due_date.strftime('%d-%m-%Y') if row.due_date else '-' }}</td>
|
||||
<td class="px-4 py-3 text-right font-medium">₹ {{ '%.2f'|format(row.total_amount or 0) }}</td>
|
||||
<td class="px-4 py-3 text-right font-medium {% if row.balance_amount and row.balance_amount > 0 %}text-amber-700{% else %}text-emerald-700{% endif %}">₹ {{ '%.2f'|format(row.balance_amount or 0) }}</td>
|
||||
<td class="px-4 py-3"><span class="af-badge {% if row.status == 'PAID' %}af-badge-success{% elif row.status == 'OVERDUE' %}af-badge-danger{% else %}af-badge-warning{% endif %}">{{ row.status.replace('_', ' ') }}</span></td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
{% if row.balance_amount and row.balance_amount > 0 %}<a href="/client/billing/{{ row.id }}/pay-now" class="af-btn af-btn-primary">Pay Now</a>{% else %}<a href="/client/billing/{{ row.id }}" class="af-btn af-btn-secondary">View</a>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">No invoices found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,94 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/clients/templates/clients/_client_tabs.html" %}
|
||||
<div class="mx-auto max-w-4xl space-y-6">
|
||||
<section class="rounded-3xl bg-gradient-to-r from-brand-700 to-slate-900 p-6 text-white shadow-soft">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-brand-100">Pay Now</p>
|
||||
<h2 class="mt-2 text-2xl font-semibold">Invoice {{ invoice.invoice_no }}</h2>
|
||||
<p class="mt-2 text-sm text-brand-100">Pay the outstanding amount using online gateway, UPI or bank transfer. Online gateway receipts are created automatically after successful verification.</p>
|
||||
</section>
|
||||
|
||||
<div class="grid gap-6 md:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div class="af-card">
|
||||
<h3 class="text-lg font-semibold text-slate-900">Payment Options</h3>
|
||||
<div class="mt-5 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||
<div class="font-semibold">Amount payable: ₹ {{ '%.2f'|format(amount_due or 0) }}</div>
|
||||
<div class="mt-1">Invoice balance only is shown here. TDS or bank charges will be adjusted by the firm while recording receipt.</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% if payumoney_enabled %}
|
||||
<div class="mt-5 rounded-2xl border border-emerald-200 bg-emerald-50 p-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-emerald-900">Online Payment Gateway</div>
|
||||
<p class="mt-1 text-sm text-emerald-800">Pay securely through PayUMoney / PayU. Receipt will be created automatically after successful confirmation.</p>
|
||||
{% if payumoney_mode != 'LIVE' %}<p class="mt-1 text-xs font-semibold text-amber-700">Currently running in TEST mode.</p>{% endif %}
|
||||
</div>
|
||||
<form method="post" action="/client/billing/{{ invoice.id }}/payumoney/start">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<button type="submit" class="af-btn af-btn-primary whitespace-nowrap">Pay Online</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if cashfree_enabled %}
|
||||
<div class="mt-5 rounded-2xl border border-sky-200 bg-sky-50 p-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-sky-900">Cashfree Payment Gateway</div>
|
||||
<p class="mt-1 text-sm text-sky-800">Pay securely through Cashfree checkout. Receipt will be created automatically after successful confirmation.</p>
|
||||
{% if cashfree_mode != 'LIVE' %}<p class="mt-1 text-xs font-semibold text-amber-700">Currently running in TEST / Sandbox mode.</p>{% endif %}
|
||||
</div>
|
||||
<form method="post" action="/client/billing/{{ invoice.id }}/cashfree/start">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<button type="submit" class="af-btn af-btn-primary whitespace-nowrap">Pay with Cashfree</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if upi_link %}
|
||||
<div class="mt-5 rounded-2xl border border-brand-200 bg-brand-50 p-4">
|
||||
<div class="text-sm font-semibold text-brand-800">UPI Payment</div>
|
||||
<div class="mt-2 text-sm text-slate-700">UPI ID: <span class="font-semibold">{{ upi_id }}</span></div>
|
||||
<a href="{{ upi_link }}" class="mt-4 inline-flex af-btn af-btn-primary">Open UPI App</a>
|
||||
<p class="mt-3 text-xs text-slate-500">This opens a UPI app on supported devices. After payment, share the UTR/reference number with the firm if requested.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-5 rounded-2xl border border-slate-200 p-4">
|
||||
<div class="text-sm font-semibold text-slate-900">Bank Transfer</div>
|
||||
<dl class="mt-3 grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div><dt class="text-xs uppercase text-slate-500">Bank</dt><dd class="font-medium">{{ bank_name or '-' }}</dd></div>
|
||||
<div><dt class="text-xs uppercase text-slate-500">Account Name</dt><dd class="font-medium">{{ bank_account_name or '-' }}</dd></div>
|
||||
<div><dt class="text-xs uppercase text-slate-500">Account No.</dt><dd class="font-medium">{{ bank_account_number or '-' }}</dd></div>
|
||||
<div><dt class="text-xs uppercase text-slate-500">IFSC</dt><dd class="font-medium">{{ bank_ifsc or '-' }}</dd></div>
|
||||
</dl>
|
||||
{% if payment_instructions %}<div class="mt-4 whitespace-pre-line rounded-xl bg-slate-50 p-3 text-sm text-slate-700">{{ payment_instructions }}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="space-y-6">
|
||||
<div class="af-card">
|
||||
<h3 class="text-base font-semibold text-slate-900">Invoice Summary</h3>
|
||||
<div class="mt-4 space-y-3 text-sm">
|
||||
<div class="flex justify-between"><span class="text-slate-500">Invoice Total</span><span class="font-semibold">₹ {{ '%.2f'|format(invoice.total_amount or 0) }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-slate-500">Received</span><span class="font-semibold">₹ {{ '%.2f'|format(invoice.amount_received or 0) }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-slate-500">TDS</span><span class="font-semibold">₹ {{ '%.2f'|format(invoice.tds_deducted or 0) }}</span></div>
|
||||
<div class="border-t border-slate-200 pt-3 flex justify-between"><span class="text-slate-500">Balance</span><span class="font-semibold text-amber-700">₹ {{ '%.2f'|format(invoice.balance_amount or 0) }}</span></div>
|
||||
</div>
|
||||
<div class="mt-5 grid gap-2">
|
||||
<a href="/client/billing/{{ invoice.id }}" class="af-btn af-btn-secondary justify-center">View Invoice</a>
|
||||
<a href="/client/billing/{{ invoice.id }}/print" class="af-btn af-btn-secondary justify-center">Print / Save PDF</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 text-xs leading-5 text-slate-500 shadow-soft">
|
||||
{% if payumoney_enabled or cashfree_enabled %}Online gateway confirmation is enabled. UPI/bank transfer can still be used when the client prefers manual payment.{% else %}Online gateway is not enabled yet. This page helps the client pay through UPI/bank details and the firm records receipt manually.{% endif %}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,27 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/clients/templates/clients/_client_tabs.html" %}
|
||||
<div class="mx-auto max-w-3xl space-y-6">
|
||||
<section class="af-card p-6">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] {% if result == 'success' %}text-emerald-700{% else %}text-rose-700{% endif %}">PayUMoney Payment</p>
|
||||
<h1 class="mt-2 text-2xl font-bold text-slate-900">{{ heading }}</h1>
|
||||
<p class="mt-2 text-sm text-slate-600">{{ message }}</p>
|
||||
|
||||
{% if transaction %}
|
||||
<dl class="mt-5 grid gap-3 rounded-2xl bg-slate-50 p-4 text-sm sm:grid-cols-2">
|
||||
<div><dt class="text-xs uppercase text-slate-500">Invoice</dt><dd class="font-semibold">{{ transaction.invoice.invoice_no }}</dd></div>
|
||||
<div><dt class="text-xs uppercase text-slate-500">Amount</dt><dd class="font-semibold">₹ {{ '%.2f'|format(transaction.amount or 0) }}</dd></div>
|
||||
<div><dt class="text-xs uppercase text-slate-500">Txn ID</dt><dd class="font-mono text-xs font-semibold">{{ transaction.txnid }}</dd></div>
|
||||
<div><dt class="text-xs uppercase text-slate-500">Gateway Status</dt><dd class="font-semibold">{{ transaction.gateway_status or transaction.status }}</dd></div>
|
||||
{% if transaction.bank_ref_num %}<div><dt class="text-xs uppercase text-slate-500">Bank Ref.</dt><dd class="font-semibold">{{ transaction.bank_ref_num }}</dd></div>{% endif %}
|
||||
{% if transaction.mihpayid %}<div><dt class="text-xs uppercase text-slate-500">PayU ID</dt><dd class="font-semibold">{{ transaction.mihpayid }}</dd></div>{% endif %}
|
||||
</dl>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-6 flex flex-wrap gap-3">
|
||||
{% if transaction %}<a href="/client/billing/{{ transaction.invoice_id }}" class="af-btn af-btn-primary">View Invoice</a>{% endif %}
|
||||
<a href="/client/billing" class="af-btn af-btn-secondary">Back to My Bills</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,124 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-slate-900">Create GST Invoice</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Prepare a professional tax invoice with SAC, GST breakup, place of supply and firm billing defaults.</p>
|
||||
<p class="mt-1 text-xs text-slate-400">Invoice will be tagged to active FY: <span class="font-semibold text-slate-600">{{ active_financial_year or 'Current FY' }}</span></p>
|
||||
</div>
|
||||
<a href="/billing/settings" class="af-btn af-btn-secondary">Billing Settings</a>
|
||||
</div>
|
||||
|
||||
<form method="post" class="space-y-6 af-card">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
|
||||
<section class="space-y-4">
|
||||
<div class="af-panel-header">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-slate-900">Invoice Header</h2>
|
||||
<p class="text-xs text-slate-500">Client, date, GST treatment and billing period.</p>
|
||||
</div>
|
||||
<span class="af-badge af-badge-info">{{ settings.invoice_title or 'Tax Invoice' }}</span>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-slate-700">Client</span>
|
||||
<select name="client_id" required class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="">Select client</option>
|
||||
{% for client in clients %}
|
||||
<option value="{{ client.id }}">{{ client.client_code }} - {{ client.client_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-slate-700">Invoice Date</span>
|
||||
<input type="date" name="invoice_date" value="{{ today }}" required class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-slate-700">Due Date</span>
|
||||
<input type="date" name="due_date" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-slate-700">Billing Period From</span>
|
||||
<input type="date" name="billing_period_from" value="{{ default_billing_period_from or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-slate-700">Billing Period To</span>
|
||||
<input type="date" name="billing_period_to" value="{{ default_billing_period_to or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-slate-700">Tax Type</span>
|
||||
<select name="tax_type" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
{% for tax_type in tax_types %}<option value="{{ tax_type }}" {% if settings.default_tax_type == tax_type %}selected{% endif %}>{{ tax_type }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-slate-700">Place of Supply</span>
|
||||
<input name="place_of_supply" placeholder="State / Union Territory" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-slate-700">Client State Code</span>
|
||||
<input name="client_state_code" maxlength="2" placeholder="e.g. 33" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="mt-7 inline-flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="reverse_charge" value="yes" class="rounded border-slate-300" />
|
||||
Reverse charge applicable
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<div class="af-panel-header">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-slate-900">Invoice Lines</h2>
|
||||
<p class="text-xs text-slate-500">SAC defaults to billing settings if left blank. Blank description rows are ignored.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto rounded-xl border border-slate-200">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase text-slate-500">
|
||||
<tr>
|
||||
<th class="px-3 py-2">Service</th>
|
||||
<th class="px-3 py-2">Description</th>
|
||||
<th class="px-3 py-2">SAC</th>
|
||||
<th class="px-3 py-2">Qty</th>
|
||||
<th class="px-3 py-2">Rate</th>
|
||||
<th class="px-3 py-2">Discount</th>
|
||||
<th class="px-3 py-2">GST %</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for i in range(1, 8) %}
|
||||
<tr>
|
||||
<td class="px-3 py-2">
|
||||
<select name="line_service_id" class="w-48 rounded-lg border border-slate-300 px-2 py-1.5">
|
||||
<option value="">No service</option>
|
||||
{% for service in services %}<option value="{{ service.id }}">{{ service.service_code }} - {{ service.service_name }}</option>{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td class="px-3 py-2"><input name="line_description" class="w-80 rounded-lg border border-slate-300 px-2 py-1.5" placeholder="Professional fees / service description" /></td>
|
||||
<td class="px-3 py-2"><input name="line_sac_code" value="{{ settings.default_sac_code or '' }}" class="w-24 rounded-lg border border-slate-300 px-2 py-1.5" /></td>
|
||||
<td class="px-3 py-2"><input name="line_quantity" value="1" class="w-20 rounded-lg border border-slate-300 px-2 py-1.5" /></td>
|
||||
<td class="px-3 py-2"><input name="line_rate" value="0" class="w-28 rounded-lg border border-slate-300 px-2 py-1.5" /></td>
|
||||
<td class="px-3 py-2"><input name="line_discount" value="0" class="w-28 rounded-lg border border-slate-300 px-2 py-1.5" /></td>
|
||||
<td class="px-3 py-2"><input name="line_gst_rate" value="{{ settings.default_gst_rate or 18 }}" class="w-20 rounded-lg border border-slate-300 px-2 py-1.5" /></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<label class="block"><span class="text-sm font-medium text-slate-700">Declaration / Notes</span><textarea name="notes" rows="3" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ settings.declaration or '' }}</textarea></label>
|
||||
<label class="block"><span class="text-sm font-medium text-slate-700">Terms & Conditions</span><textarea name="terms" rows="3" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ settings.terms or '' }}</textarea></label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<a href="/billing" class="af-btn af-btn-secondary">Cancel</a>
|
||||
<button class="af-btn af-btn-primary">Save Draft Invoice</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,120 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-slate-900">{{ invoice_ctx.invoice_title }} {{ invoice.invoice_no }}</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ invoice.client_legal_name or (invoice.client.client_name if invoice.client else '') }} • {{ invoice.invoice_date }}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% if invoice.status == 'DRAFT' %}
|
||||
<form method="post" action="/billing/{{ invoice.id }}/issue">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<button class="af-btn af-btn-primary">Issue Invoice</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if can_record_payment and invoice.status not in ['DRAFT','CANCELLED','PAID'] %}
|
||||
<a href="/billing/{{ invoice.id }}/payments/new" class="af-btn af-btn-primary">Record Payment</a>
|
||||
{% endif %}
|
||||
<a href="/billing/{{ invoice.id }}/print" target="_blank" class="af-btn af-btn-secondary">Print / PDF</a>
|
||||
<a href="/billing" class="af-btn af-btn-secondary">Back</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="af-card space-y-6">
|
||||
<div class="grid gap-4 md:grid-cols-4 lg:grid-cols-7">
|
||||
<div><div class="text-xs uppercase text-slate-500">Status</div><div class="font-semibold">{{ invoice.status }}</div></div>
|
||||
<div><div class="text-xs uppercase text-slate-500">Due Date</div><div class="font-semibold">{{ invoice.due_date or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase text-slate-500">Place of Supply</div><div class="font-semibold">{{ invoice.place_of_supply or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase text-slate-500">Total</div><div class="font-semibold">₹ {{ '%.2f'|format(invoice.total_amount or 0) }}</div></div>
|
||||
<div><div class="text-xs uppercase text-slate-500">Amount Received</div><div class="font-semibold text-emerald-700">₹ {{ '%.2f'|format(invoice.amount_received or 0) }}</div></div>
|
||||
<div><div class="text-xs uppercase text-slate-500">TDS Deducted</div><div class="font-semibold text-blue-700">₹ {{ '%.2f'|format(invoice.tds_deducted or 0) }}</div></div>
|
||||
<div><div class="text-xs uppercase text-slate-500">Balance</div><div class="font-semibold {% if invoice.balance_amount and invoice.balance_amount > 0 %}text-amber-700{% else %}text-emerald-700{% endif %}">₹ {{ '%.2f'|format(invoice.balance_amount or 0) }}</div></div>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="rounded-xl border border-slate-200 p-4">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Supplier</h2>
|
||||
<div class="mt-2 font-semibold text-slate-900">{{ invoice_ctx.firm_name }}</div>
|
||||
<div class="text-sm text-slate-600 whitespace-pre-line">{{ invoice_ctx.firm_address or '-' }}</div>
|
||||
<div class="mt-2 text-sm text-slate-600">GSTIN: {{ invoice_ctx.firm_gstin or '-' }} • PAN: {{ invoice_ctx.firm_pan or '-' }}</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-200 p-4">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Bill To</h2>
|
||||
<div class="mt-2 font-semibold text-slate-900">{{ invoice.client_legal_name or '-' }}</div>
|
||||
<div class="text-sm text-slate-600">{{ invoice.client_billing_address or '-' }}</div>
|
||||
<div class="mt-2 text-sm text-slate-600">GSTIN: {{ invoice.client_gstin or '-' }} • PAN: {{ invoice.client_pan or '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase text-slate-500">
|
||||
<tr><th class="px-4 py-3">Description</th><th class="px-4 py-3">SAC</th><th class="px-4 py-3 text-right">Qty</th><th class="px-4 py-3 text-right">Rate</th><th class="px-4 py-3 text-right">Taxable</th><th class="px-4 py-3 text-right">GST</th><th class="px-4 py-3 text-right">Total</th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for line in invoice.lines %}
|
||||
<tr>
|
||||
<td class="px-4 py-3"><div class="font-medium text-slate-900">{{ line.description }}</div><div class="text-xs text-slate-500">{{ line.service.service_name if line.service else '' }}</div></td>
|
||||
<td class="px-4 py-3">{{ line.sac_code or '-' }}</td>
|
||||
<td class="px-4 py-3 text-right">{{ line.quantity }}</td>
|
||||
<td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(line.rate or 0) }}</td>
|
||||
<td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(line.taxable_amount or 0) }}</td>
|
||||
<td class="px-4 py-3 text-right">{{ line.gst_rate }}%</td>
|
||||
<td class="px-4 py-3 text-right font-semibold">₹ {{ '%.2f'|format(line.line_total or 0) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot class="bg-slate-50 text-sm font-semibold">
|
||||
<tr><td colspan="6" class="px-4 py-3 text-right">Subtotal</td><td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(invoice.subtotal or 0) }}</td></tr>
|
||||
<tr><td colspan="6" class="px-4 py-3 text-right">Discount</td><td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(invoice.discount_amount or 0) }}</td></tr>
|
||||
<tr><td colspan="6" class="px-4 py-3 text-right">Taxable Value</td><td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(invoice.taxable_amount or 0) }}</td></tr>
|
||||
<tr><td colspan="6" class="px-4 py-3 text-right">CGST</td><td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(invoice.cgst_amount or 0) }}</td></tr>
|
||||
<tr><td colspan="6" class="px-4 py-3 text-right">SGST</td><td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(invoice.sgst_amount or 0) }}</td></tr>
|
||||
<tr><td colspan="6" class="px-4 py-3 text-right">IGST</td><td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(invoice.igst_amount or 0) }}</td></tr>
|
||||
<tr><td colspan="6" class="px-4 py-3 text-right text-base">Grand Total</td><td class="px-4 py-3 text-right text-base">₹ {{ '%.2f'|format(invoice.total_amount or 0) }}</td></tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="af-card space-y-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="font-semibold text-slate-900">Payment History</h2>
|
||||
<p class="text-sm text-slate-500">Receipts, TDS deductions and outstanding balance for this invoice.</p>
|
||||
</div>
|
||||
{% if can_record_payment and invoice.status not in ['DRAFT','CANCELLED','PAID'] %}
|
||||
<a href="/billing/{{ invoice.id }}/payments/new" class="af-btn af-btn-primary">Record Payment</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-xl border border-slate-200">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase text-slate-500">
|
||||
<tr><th class="px-4 py-3">Receipt</th><th class="px-4 py-3">Date</th><th class="px-4 py-3">Mode</th><th class="px-4 py-3">Reference</th><th class="px-4 py-3 text-right">Received</th><th class="px-4 py-3 text-right">TDS</th><th class="px-4 py-3"></th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for payment in invoice.payments %}
|
||||
<tr>
|
||||
<td class="px-4 py-3 font-medium text-slate-900">{{ payment.receipt_no }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ payment.payment_date }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ payment.mode }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ payment.reference_no or '-' }}</td>
|
||||
<td class="px-4 py-3 text-right font-semibold">₹ {{ '%.2f'|format(payment.amount_received or 0) }}</td>
|
||||
<td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(payment.tds_deducted or 0) }}</td>
|
||||
<td class="px-4 py-3 text-right"><a href="/billing/payments/{{ payment.id }}/receipt" target="_blank" class="text-brand-600 hover:underline">Receipt</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="px-4 py-6 text-center text-slate-500">No payments recorded yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="af-card"><h2 class="font-semibold text-slate-900">Amount in Words</h2><p class="mt-2 text-sm text-slate-600">{{ invoice.amount_in_words or '-' }}</p></div>
|
||||
<div class="af-card"><h2 class="font-semibold text-slate-900">Bank / UPI Details</h2><p class="mt-2 text-sm text-slate-600 whitespace-pre-line">{% if invoice_ctx.bank_name %}{{ invoice_ctx.bank_name }}{% endif %}{% if invoice_ctx.bank_account_number %}\nA/c: {{ invoice_ctx.bank_account_number }}{% endif %}{% if invoice_ctx.bank_ifsc %}\nIFSC: {{ invoice_ctx.bank_ifsc }}{% endif %}{% if invoice_ctx.upi_id %}\nUPI: {{ invoice_ctx.upi_id }}{% endif %}</p></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,53 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-slate-900">Import Fee Structure</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Upload Excel with Fee_Structure and Fee_Services sheets.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/billing/fee-structures/template" class="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-2 text-sm font-medium text-emerald-800 shadow-sm hover:bg-emerald-100">Download Excel Template</a>
|
||||
<a href="/billing/fee-structures/list" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Fee Structure List</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if result %}
|
||||
<div class="rounded-2xl border {{ 'border-emerald-200 bg-emerald-50 text-emerald-900' if result.success else 'border-red-200 bg-red-50 text-red-900' }} p-4">
|
||||
{% if result.success %}
|
||||
<div class="font-semibold">Import completed</div>
|
||||
<div class="mt-1 text-sm">Created: {{ result.created }} | Updated: {{ result.updated }}</div>
|
||||
{% else %}
|
||||
<div class="font-semibold">Import failed</div>
|
||||
<ul class="mt-2 list-disc pl-5 text-sm">
|
||||
{% for error in result.errors %}<li>{{ error }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" enctype="multipart/form-data" class="space-y-5 rounded-2xl border border-slate-200 bg-white p-6 shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-slate-700">Excel File</span>
|
||||
<input type="file" name="import_file" accept=".xlsx,.xlsm" required class="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<div class="rounded-xl bg-slate-50 p-4 text-sm text-slate-600">
|
||||
<div class="font-semibold text-slate-800">How to import using template</div>
|
||||
<ol class="mt-2 list-decimal space-y-1 pl-5">
|
||||
<li>Click <strong>Download Excel Template</strong>.</li>
|
||||
<li>Fill <strong>Fee_Structure</strong> for client-wise package/header details.</li>
|
||||
<li>Fill <strong>Fee_Services</strong> for services included in each package.</li>
|
||||
<li>Upload the completed file here. Imported fee structures can then be used in <strong>Generate Bills</strong>.</li>
|
||||
</ol>
|
||||
<div class="mt-4 font-semibold text-slate-800">Required sheets</div>
|
||||
<div class="mt-1">Fee_Structure: client, billing group, mode, frequency, fee and tax details.</div>
|
||||
<div>Fee_Services: services included in each billing group.</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3">
|
||||
<a href="/billing/fee-structures/list" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700">Back</a>
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Import Fee Structure</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,63 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-slate-900">Fee Structure</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Client-wise billing packages with multiple services grouped for future invoice generation.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/billing/generate" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Generate Bills</a>
|
||||
<a href="/billing" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Invoices</a>
|
||||
{% if can_import %}
|
||||
<a href="/billing/fee-structures/template" class="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-2 text-sm font-medium text-emerald-800 shadow-sm hover:bg-emerald-100">Download Template</a>
|
||||
<a href="/billing/fee-structures/import" class="rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-emerald-700">Import Using Template</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="flex gap-3">
|
||||
<input name="q" value="{{ q or '' }}" placeholder="Search group, client code or client name" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none" />
|
||||
<button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<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">Group Code</th>
|
||||
<th class="px-4 py-3">Client</th>
|
||||
<th class="px-4 py-3">Package</th>
|
||||
<th class="px-4 py-3">Mode</th>
|
||||
<th class="px-4 py-3">Frequency</th>
|
||||
<th class="px-4 py-3 text-right">Fee</th>
|
||||
<th class="px-4 py-3">Services</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for row in rows %}
|
||||
<tr class="align-top hover:bg-slate-50">
|
||||
<td class="px-4 py-3 font-medium text-slate-900">{{ row.group_code }}</td>
|
||||
<td class="px-4 py-3 text-slate-700">{{ row.client.client_name if row.client else row.client_id }}</td>
|
||||
<td class="px-4 py-3 text-slate-700">{{ row.group_name }}</td>
|
||||
<td class="px-4 py-3"><span class="rounded-full bg-slate-100 px-2 py-1 text-xs">{{ row.billing_mode }}</span></td>
|
||||
<td class="px-4 py-3">{{ row.frequency }}</td>
|
||||
<td class="px-4 py-3 text-right font-semibold">₹ {{ '%.2f'|format(row.fee_amount or 0) }}</td>
|
||||
<td class="px-4 py-3 text-xs text-slate-600">
|
||||
{% for item in row.services %}
|
||||
<div>{{ item.service.service_code if item.service else item.service_id }} - {{ item.line_description or (item.service.service_name if item.service else '') }}</div>
|
||||
{% else %}
|
||||
<span class="text-slate-400">No services mapped</span>
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">No fee structures found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,182 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-slate-900">Generate Draft Invoices</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Create draft GST invoices from fee structures and automatically link matching client service subscriptions / engagements for the selected financial year. Existing invoices for the same fee group and period are skipped by default.</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="/billing" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Invoices</a>
|
||||
<a href="/billing/fee-structures/list" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Fee Structure</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if result %}
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<h2 class="text-lg font-semibold text-slate-900">Generation Result</h2>
|
||||
<div class="mt-3 grid gap-3 sm:grid-cols-3">
|
||||
<div class="rounded-xl bg-emerald-50 p-3 text-sm text-emerald-800"><div class="text-xs uppercase tracking-wide">Draft invoices created</div><div class="mt-1 text-2xl font-bold">{{ result.created|length }}</div></div>
|
||||
<div class="rounded-xl bg-amber-50 p-3 text-sm text-amber-800"><div class="text-xs uppercase tracking-wide">Skipped</div><div class="mt-1 text-2xl font-bold">{{ result.skipped|length }}</div></div>
|
||||
<div class="rounded-xl bg-rose-50 p-3 text-sm text-rose-800"><div class="text-xs uppercase tracking-wide">Errors</div><div class="mt-1 text-2xl font-bold">{{ result.errors|length }}</div></div>
|
||||
</div>
|
||||
|
||||
{% if result.created %}
|
||||
<div class="mt-4">
|
||||
<div class="text-sm font-semibold text-slate-700">Created Draft Invoices</div>
|
||||
<div class="mt-2 overflow-hidden rounded-xl border border-slate-200">
|
||||
<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-3 py-2">Invoice</th><th class="px-3 py-2">Client</th><th class="px-3 py-2 text-right">Amount</th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for invoice in result.created %}
|
||||
<tr>
|
||||
<td class="px-3 py-2"><a href="/billing/{{ invoice.id }}" class="font-semibold text-brand-700 hover:underline">{{ invoice.invoice_no }}</a></td>
|
||||
<td class="px-3 py-2">{{ invoice.client.client_name if invoice.client else invoice.client_id }}</td>
|
||||
<td class="px-3 py-2 text-right">₹ {{ '%.2f'|format(invoice.total_amount or 0) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if result.skipped %}
|
||||
<div class="mt-4 rounded-xl bg-amber-50 p-3 text-sm text-amber-800">
|
||||
<div class="font-semibold">Skipped rows</div>
|
||||
<ul class="mt-1 list-disc space-y-1 pl-5">
|
||||
{% for item in result.skipped %}<li>{{ item }}</li>{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if result.errors %}
|
||||
<div class="mt-4 rounded-xl bg-rose-50 p-3 text-sm text-rose-800">
|
||||
<div class="font-semibold">Errors</div>
|
||||
<ul class="mt-1 list-disc space-y-1 pl-5">
|
||||
{% for item in result.errors %}<li>{{ item }}</li>{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="rounded-2xl border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900">
|
||||
<div class="font-semibold">Engagement-to-invoice refinement</div>
|
||||
<div class="mt-1">This screen continues to use your existing fee-structure billing logic. During generation, the system checks the client, service and active financial year ({{ active_financial_year or 'current FY' }}) and links the invoice / invoice lines to the matching client service subscription wherever available. No duplicate module is created.</div>
|
||||
</div>
|
||||
|
||||
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="grid gap-3 lg:grid-cols-6">
|
||||
<div>
|
||||
<label class="text-xs font-semibold uppercase tracking-wide text-slate-500">Frequency</label>
|
||||
<select name="frequency" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none">
|
||||
<option value="">All</option>
|
||||
{% for f in frequencies %}<option value="{{ f }}" {% if frequency == f %}selected{% endif %}>{{ f }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold uppercase tracking-wide text-slate-500">Period From</label>
|
||||
<input type="date" name="billing_period_from" value="{{ billing_period_from }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold uppercase tracking-wide text-slate-500">Period To</label>
|
||||
<input type="date" name="billing_period_to" value="{{ billing_period_to }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold uppercase tracking-wide text-slate-500">Auto Generate</label>
|
||||
<select name="auto_generate_only" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none">
|
||||
<option value="yes" {% if auto_generate_only != 'no' %}selected{% endif %}>Only Yes</option>
|
||||
<option value="no" {% if auto_generate_only == 'no' %}selected{% endif %}>All Active</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="lg:col-span-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-wide text-slate-500">Search</label>
|
||||
<div class="mt-1 flex gap-2">
|
||||
<input name="q" value="{{ q or '' }}" placeholder="Client / group code / package" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none" />
|
||||
<button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white">Filter</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form method="post" class="rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<input type="hidden" name="frequency" value="{{ frequency or '' }}" />
|
||||
<input type="hidden" name="billing_period_from" value="{{ billing_period_from }}" />
|
||||
<input type="hidden" name="billing_period_to" value="{{ billing_period_to }}" />
|
||||
<input type="hidden" name="auto_generate_only" value="{{ auto_generate_only }}" />
|
||||
<input type="hidden" name="q" value="{{ q or '' }}" />
|
||||
|
||||
<div class="flex flex-col gap-3 border-b border-slate-200 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div class="font-semibold text-slate-900">Eligible Fee Structures</div>
|
||||
<div class="text-sm text-slate-500">Select packages and create draft invoices for {{ billing_period_from }} to {{ billing_period_to }}.</div>
|
||||
</div>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-slate-600">
|
||||
<input type="checkbox" name="skip_duplicates" value="yes" checked class="rounded border-slate-300 text-brand-600" />
|
||||
Skip duplicates
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<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"><input type="checkbox" onclick="document.querySelectorAll('.fee-check').forEach(cb => cb.checked = this.checked && !cb.disabled)" /></th>
|
||||
<th class="px-4 py-3">Group Code</th>
|
||||
<th class="px-4 py-3">Client</th>
|
||||
<th class="px-4 py-3">Package</th>
|
||||
<th class="px-4 py-3">Services / Engagement Source</th>
|
||||
<th class="px-4 py-3">Mode</th>
|
||||
<th class="px-4 py-3 text-right">Fee</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for row in rows %}
|
||||
{% set duplicate = duplicate_map.get(row.id) %}
|
||||
<tr class="align-top hover:bg-slate-50">
|
||||
<td class="px-4 py-3"><input class="fee-check rounded border-slate-300 text-brand-600" type="checkbox" name="fee_group_ids" value="{{ row.id }}" {% if duplicate %}disabled{% endif %} /></td>
|
||||
<td class="px-4 py-3 font-medium text-slate-900">{{ row.group_code }}</td>
|
||||
<td class="px-4 py-3 text-slate-700">{{ row.client.client_name if row.client else row.client_id }}</td>
|
||||
<td class="px-4 py-3 text-slate-700">
|
||||
<div class="font-medium text-slate-900">{{ row.group_name }}</div>
|
||||
<div class="mt-1 text-xs text-slate-500">{{ row.frequency }} billing</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-slate-600">
|
||||
{% if row.services %}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{% for item in row.services[:4] %}
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1">{{ item.service.service_name if item.service else item.service_id }}</span>
|
||||
{% endfor %}
|
||||
{% if row.services|length > 4 %}<span class="rounded-full bg-slate-100 px-2 py-1">+{{ row.services|length - 4 }}</span>{% endif %}
|
||||
</div>
|
||||
<div class="mt-1 text-[11px] text-slate-400">Matching active subscriptions are linked during generation.</div>
|
||||
{% else %}
|
||||
<span class="text-slate-400">Package line only</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3"><span class="rounded-full bg-slate-100 px-2 py-1 text-xs">{{ row.billing_mode }}</span></td>
|
||||
<td class="px-4 py-3 text-right font-semibold">₹ {{ '%.2f'|format(row.fee_amount or 0) }}</td>
|
||||
<td class="px-4 py-3 text-xs">
|
||||
{% if duplicate %}
|
||||
<span class="rounded-full bg-amber-100 px-2 py-1 font-medium text-amber-800">Already billed: {{ duplicate.invoice_no }}</span>
|
||||
{% else %}
|
||||
<span class="rounded-full bg-emerald-100 px-2 py-1 font-medium text-emerald-800">Ready</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8" class="px-4 py-8 text-center text-slate-500">No eligible fee structures found for the selected filter.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end border-t border-slate-200 p-4">
|
||||
<button class="rounded-xl bg-brand-600 px-5 py-2 text-sm font-semibold text-white shadow-soft hover:bg-brand-700">Generate Draft Invoices</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,120 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{{ invoice_ctx.invoice_title }} {{ invoice.invoice_no }}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
@page { size: A4; margin: 14mm; }
|
||||
@media print { .no-print { display: none !important; } body { background: white !important; } }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-slate-100 text-slate-900">
|
||||
<div class="no-print mx-auto my-4 flex max-w-5xl justify-end gap-2">
|
||||
<button onclick="window.print()" class="rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white">Print / Save PDF</button>
|
||||
<a href="/billing/{{ invoice.id }}" class="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700">Back</a>
|
||||
</div>
|
||||
<main class="mx-auto max-w-5xl bg-white p-8 shadow print:shadow-none">
|
||||
<header class="border-b-2 border-slate-900 pb-4">
|
||||
<div class="flex items-start justify-between gap-6">
|
||||
<div>
|
||||
<div class="text-2xl font-bold">{{ invoice_ctx.firm_name }}</div>
|
||||
<div class="mt-1 whitespace-pre-line text-sm text-slate-600">{{ invoice_ctx.firm_address or '' }}</div>
|
||||
<div class="mt-2 text-sm text-slate-700">GSTIN: <b>{{ invoice_ctx.firm_gstin or '-' }}</b> | PAN: <b>{{ invoice_ctx.firm_pan or '-' }}</b></div>
|
||||
<div class="text-sm text-slate-700">Email: {{ invoice_ctx.firm_contact_email or '-' }} | Mobile: {{ invoice_ctx.firm_contact_mobile or '-' }}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-2xl font-bold uppercase">{{ invoice_ctx.invoice_title }}</div>
|
||||
<div class="mt-2 text-sm">Invoice No: <b>{{ invoice.invoice_no }}</b></div>
|
||||
<div class="text-sm">Invoice Date: <b>{{ invoice.invoice_date }}</b></div>
|
||||
<div class="text-sm">Due Date: <b>{{ invoice.due_date or '-' }}</b></div>
|
||||
<div class="text-sm">Status: <b>{{ invoice.status }}</b></div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="mt-5 grid grid-cols-2 gap-4 text-sm">
|
||||
<div class="rounded-lg border border-slate-300 p-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Bill To</div>
|
||||
<div class="mt-2 text-base font-bold">{{ invoice.client_legal_name or '-' }}</div>
|
||||
<div class="mt-1 text-slate-700">{{ invoice.client_billing_address or '-' }}</div>
|
||||
<div class="mt-2">GSTIN: <b>{{ invoice.client_gstin or '-' }}</b></div>
|
||||
<div>PAN: <b>{{ invoice.client_pan or '-' }}</b></div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-300 p-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Tax Particulars</div>
|
||||
<div class="mt-2">Place of Supply: <b>{{ invoice.place_of_supply or '-' }}</b></div>
|
||||
<div>Tax Type: <b>{{ invoice.tax_type }}</b></div>
|
||||
<div>Reverse Charge: <b>{{ 'Yes' if invoice.reverse_charge else 'No' }}</b></div>
|
||||
<div>Client State Code: <b>{{ invoice.client_state_code or '-' }}</b></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<table class="mt-5 w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr class="bg-slate-100">
|
||||
<th class="border border-slate-300 px-2 py-2 text-left">#</th>
|
||||
<th class="border border-slate-300 px-2 py-2 text-left">Description</th>
|
||||
<th class="border border-slate-300 px-2 py-2 text-left">SAC</th>
|
||||
<th class="border border-slate-300 px-2 py-2 text-right">Qty</th>
|
||||
<th class="border border-slate-300 px-2 py-2 text-right">Rate</th>
|
||||
<th class="border border-slate-300 px-2 py-2 text-right">Taxable</th>
|
||||
<th class="border border-slate-300 px-2 py-2 text-right">GST %</th>
|
||||
<th class="border border-slate-300 px-2 py-2 text-right">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for line in invoice.lines %}
|
||||
<tr>
|
||||
<td class="border border-slate-300 px-2 py-2">{{ loop.index }}</td>
|
||||
<td class="border border-slate-300 px-2 py-2">{{ line.description }}</td>
|
||||
<td class="border border-slate-300 px-2 py-2">{{ line.sac_code or '-' }}</td>
|
||||
<td class="border border-slate-300 px-2 py-2 text-right">{{ line.quantity }}</td>
|
||||
<td class="border border-slate-300 px-2 py-2 text-right">{{ '%.2f'|format(line.rate or 0) }}</td>
|
||||
<td class="border border-slate-300 px-2 py-2 text-right">{{ '%.2f'|format(line.taxable_amount or 0) }}</td>
|
||||
<td class="border border-slate-300 px-2 py-2 text-right">{{ line.gst_rate }}</td>
|
||||
<td class="border border-slate-300 px-2 py-2 text-right">{{ '%.2f'|format(line.line_total or 0) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr><td colspan="7" class="border border-slate-300 px-2 py-2 text-right font-semibold">Subtotal</td><td class="border border-slate-300 px-2 py-2 text-right font-semibold">{{ '%.2f'|format(invoice.subtotal or 0) }}</td></tr>
|
||||
<tr><td colspan="7" class="border border-slate-300 px-2 py-2 text-right font-semibold">Discount</td><td class="border border-slate-300 px-2 py-2 text-right font-semibold">{{ '%.2f'|format(invoice.discount_amount or 0) }}</td></tr>
|
||||
<tr><td colspan="7" class="border border-slate-300 px-2 py-2 text-right font-semibold">Taxable Value</td><td class="border border-slate-300 px-2 py-2 text-right font-semibold">{{ '%.2f'|format(invoice.taxable_amount or 0) }}</td></tr>
|
||||
<tr><td colspan="7" class="border border-slate-300 px-2 py-2 text-right font-semibold">CGST</td><td class="border border-slate-300 px-2 py-2 text-right font-semibold">{{ '%.2f'|format(invoice.cgst_amount or 0) }}</td></tr>
|
||||
<tr><td colspan="7" class="border border-slate-300 px-2 py-2 text-right font-semibold">SGST</td><td class="border border-slate-300 px-2 py-2 text-right font-semibold">{{ '%.2f'|format(invoice.sgst_amount or 0) }}</td></tr>
|
||||
<tr><td colspan="7" class="border border-slate-300 px-2 py-2 text-right font-semibold">IGST</td><td class="border border-slate-300 px-2 py-2 text-right font-semibold">{{ '%.2f'|format(invoice.igst_amount or 0) }}</td></tr>
|
||||
<tr class="bg-slate-100"><td colspan="7" class="border border-slate-300 px-2 py-2 text-right text-base font-bold">Grand Total</td><td class="border border-slate-300 px-2 py-2 text-right text-base font-bold">₹ {{ '%.2f'|format(invoice.total_amount or 0) }}</td></tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<section class="mt-5 grid grid-cols-2 gap-4 text-sm">
|
||||
<div class="rounded-lg border border-slate-300 p-4">
|
||||
<div class="font-semibold">Amount in Words</div>
|
||||
<div class="mt-1">{{ invoice.amount_in_words or '-' }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-300 p-4">
|
||||
<div class="font-semibold">Payment Details</div>
|
||||
<div class="mt-1">Bank: {{ invoice_ctx.bank_name or '-' }}</div>
|
||||
<div>A/c: {{ invoice_ctx.bank_account_number or '-' }}</div>
|
||||
<div>IFSC: {{ invoice_ctx.bank_ifsc or '-' }}</div>
|
||||
<div>UPI: {{ invoice_ctx.upi_id or '-' }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mt-5 text-sm">
|
||||
{% if invoice_ctx.terms %}<div><b>Terms:</b> {{ invoice_ctx.terms }}</div>{% endif %}
|
||||
{% if invoice_ctx.declaration %}<div class="mt-2"><b>Declaration:</b> {{ invoice_ctx.declaration }}</div>{% endif %}
|
||||
</section>
|
||||
|
||||
<footer class="mt-12 flex items-end justify-between text-sm">
|
||||
<div>{{ invoice_ctx.footer_note or '' }}</div>
|
||||
<div class="text-center">
|
||||
<div class="mb-10">For {{ invoice_ctx.firm_name }}</div>
|
||||
<div class="border-t border-slate-500 px-8 pt-2">{{ invoice_ctx.authorised_signatory_name or 'Authorised Signatory' }}</div>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,81 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-slate-900">Billing Invoices</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Create, issue and print GST-ready client invoices with SAC and tax breakup.</p>
|
||||
<p class="mt-1 text-xs text-slate-400">Showing billing records for active FY: <span class="font-semibold text-slate-600">{{ active_financial_year or 'All Years' }}</span></p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% if can_generate %}
|
||||
<a href="/billing/generate" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Generate Bills</a>
|
||||
{% endif %}
|
||||
{% if can_view_fee_structure %}
|
||||
<a href="/billing/fee-structures/list" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Fee Structure</a>
|
||||
{% endif %}
|
||||
{% if can_import_fee_structure %}
|
||||
<a href="/billing/fee-structures/template" class="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-2 text-sm font-medium text-emerald-800 shadow-sm hover:bg-emerald-100">Download Fee Template</a>
|
||||
<a href="/billing/fee-structures/import" class="rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-emerald-700">Import Fee Excel</a>
|
||||
{% endif %}
|
||||
<a href="/billing/payments" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Payments</a>
|
||||
<a href="/billing/settings" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Billing Settings</a>
|
||||
{% if can_create %}
|
||||
<a href="/billing/new" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-brand-700">New Invoice</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="flex gap-3">
|
||||
<input name="q" value="{{ q or '' }}" placeholder="Search invoice no, client code or client name" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none" />
|
||||
<button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
{% if report_summary %}
|
||||
<section class="grid gap-4 md:grid-cols-4">
|
||||
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Billed</div><div class="mt-2 text-2xl font-semibold text-slate-900">₹ {{ '%.2f'|format(report_summary.total_billed or 0) }}</div><div class="mt-1 text-xs text-slate-500">{{ report_summary.invoice_count }} invoice(s)</div></div>
|
||||
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Collected + TDS</div><div class="mt-2 text-2xl font-semibold text-emerald-700">₹ {{ '%.2f'|format(report_summary.total_collected_with_tds or 0) }}</div><div class="mt-1 text-xs text-slate-500">{{ report_summary.payment_count }} receipt(s)</div></div>
|
||||
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Outstanding</div><div class="mt-2 text-2xl font-semibold text-amber-700">₹ {{ '%.2f'|format(report_summary.outstanding or 0) }}</div><div class="mt-1 text-xs text-slate-500">Active issued bills</div></div>
|
||||
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Status</div><div class="mt-2 text-sm font-semibold text-slate-800">Draft {{ report_summary.draft_count }} · Open {{ report_summary.issued_count }} · Paid {{ report_summary.paid_count }}</div><div class="mt-1 text-xs text-slate-500">FY-filtered billing report</div></div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<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">Invoice No</th>
|
||||
<th class="px-4 py-3">Date</th>
|
||||
<th class="px-4 py-3">FY</th>
|
||||
<th class="px-4 py-3">Client</th>
|
||||
<th class="px-4 py-3 text-right">Amount</th>
|
||||
<th class="px-4 py-3 text-right">Received/TDS</th>
|
||||
<th class="px-4 py-3 text-right">Balance</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3"></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 font-medium text-slate-900">{{ row.invoice_no }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ row.invoice_date }}</td>
|
||||
<td class="px-4 py-3 text-xs text-slate-500">{{ row.financial_year or '-' }}</td>
|
||||
<td class="px-4 py-3 text-slate-700">{{ row.client.client_name if row.client else row.client_id }}</td>
|
||||
<td class="px-4 py-3 text-right font-semibold">₹ {{ '%.2f'|format(row.total_amount or 0) }}</td>
|
||||
<td class="px-4 py-3 text-right text-slate-700">₹ {{ '%.2f'|format((row.amount_received or 0) + (row.tds_deducted or 0)) }}</td>
|
||||
<td class="px-4 py-3 text-right font-semibold {% if row.balance_amount and row.balance_amount > 0 %}text-amber-700{% else %}text-emerald-700{% endif %}">₹ {{ '%.2f'|format(row.balance_amount or row.total_amount or 0) }}</td>
|
||||
<td class="px-4 py-3"><span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-medium text-slate-700">{{ row.status }}</span></td>
|
||||
<td class="px-4 py-3 text-right"><div class="flex justify-end gap-3"><a href="/billing/{{ row.id }}" class="text-brand-600 hover:underline">View</a>{% if can_record_payment and row.status not in ['DRAFT','CANCELLED','PAID'] %}<a href="/billing/{{ row.id }}/payments/new" class="text-emerald-700 hover:underline">Payment</a>{% endif %}<a href="/billing/{{ row.id }}/print" target="_blank" class="text-slate-600 hover:underline">Print</a></div></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="9" class="px-4 py-8 text-center text-slate-500">No invoices found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div><h1 class="text-2xl font-semibold text-slate-900">Payments & Receipts</h1><p class="mt-1 text-sm text-slate-500">Track invoice collections, TDS deductions and receipt printouts.</p><p class="mt-1 text-xs text-slate-400">Showing receipts for active FY: <span class="font-semibold text-slate-600">{{ active_financial_year or 'All Years' }}</span></p></div>
|
||||
<a href="/billing" class="af-btn af-btn-secondary">Invoices</a>
|
||||
</div>
|
||||
<form method="get" class="af-card"><div class="flex gap-3"><input name="q" value="{{ q or '' }}" placeholder="Search receipt, invoice or client" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" /><button class="af-btn af-btn-primary">Search</button></div></form>
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase text-slate-500"><tr><th class="px-4 py-3">Receipt</th><th class="px-4 py-3">Invoice</th><th class="px-4 py-3">Client</th><th class="px-4 py-3">Date</th><th class="px-4 py-3">FY</th><th class="px-4 py-3">Mode</th><th class="px-4 py-3 text-right">Received</th><th class="px-4 py-3 text-right">TDS</th><th class="px-4 py-3"></th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for row in rows %}
|
||||
<tr><td class="px-4 py-3 font-medium">{{ row.receipt_no }}</td><td class="px-4 py-3">{{ row.invoice.invoice_no if row.invoice else row.invoice_id }}</td><td class="px-4 py-3">{{ row.client.client_name if row.client else row.client_id }}</td><td class="px-4 py-3">{{ row.payment_date }}</td><td class="px-4 py-3 text-xs text-slate-500">{{ row.financial_year or '-' }}</td><td class="px-4 py-3">{{ row.mode }}</td><td class="px-4 py-3 text-right font-semibold">₹ {{ '%.2f'|format(row.amount_received or 0) }}</td><td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(row.tds_deducted or 0) }}</td><td class="px-4 py-3 text-right"><a href="/billing/payments/{{ row.id }}/receipt" target="_blank" class="text-brand-600 hover:underline">Receipt</a></td></tr>
|
||||
{% else %}
|
||||
<tr><td colspan="9" class="px-4 py-8 text-center text-slate-500">No payments recorded.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,24 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6 max-w-4xl">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-slate-900">Record Payment</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Invoice {{ invoice.invoice_no }} • Balance ₹ {{ '%.2f'|format(invoice.balance_amount or invoice.total_amount or 0) }}</p>
|
||||
</div>
|
||||
<form method="post" class="af-card space-y-5">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<div><label class="text-sm font-medium text-slate-700">Payment date</label><input type="date" name="payment_date" value="{{ today }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" required /></div>
|
||||
<div><label class="text-sm font-medium text-slate-700">Amount received</label><input type="number" step="0.01" name="amount_received" value="{{ '%.2f'|format(invoice.balance_amount or invoice.total_amount or 0) }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" /></div>
|
||||
<div><label class="text-sm font-medium text-slate-700">TDS deducted</label><input type="number" step="0.01" name="tds_deducted" value="0.00" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" /></div>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<div><label class="text-sm font-medium text-slate-700">Bank charges</label><input type="number" step="0.01" name="bank_charges" value="0.00" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" /></div>
|
||||
<div><label class="text-sm font-medium text-slate-700">Mode</label><select name="mode" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{% for mode in payment_modes %}<option value="{{ mode }}">{{ mode }}</option>{% endfor %}</select></div>
|
||||
<div><label class="text-sm font-medium text-slate-700">Reference no.</label><input name="reference_no" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="UTR / cheque / transaction id" /></div>
|
||||
</div>
|
||||
<div><label class="text-sm font-medium text-slate-700">Remarks</label><textarea name="remarks" rows="3" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"></textarea></div>
|
||||
<div class="flex justify-end gap-2"><a href="/billing/{{ invoice.id }}" class="af-btn af-btn-secondary">Cancel</a><button class="af-btn af-btn-primary">Save & Print Receipt</button></div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,19 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-4xl bg-white p-8 print:p-0">
|
||||
<div class="mb-4 flex justify-end print:hidden"><button onclick="window.print()" class="af-btn af-btn-primary">Print Receipt</button></div>
|
||||
<div class="rounded-2xl border border-slate-300 p-8">
|
||||
<div class="flex items-start justify-between border-b border-slate-200 pb-5">
|
||||
<div><h1 class="text-2xl font-bold text-slate-900">{{ invoice_ctx.firm_name }}</h1><p class="mt-1 whitespace-pre-line text-sm text-slate-600">{{ invoice_ctx.firm_address or '' }}</p><p class="mt-1 text-sm text-slate-600">GSTIN: {{ invoice_ctx.firm_gstin or '-' }} • PAN: {{ invoice_ctx.firm_pan or '-' }}</p></div>
|
||||
<div class="text-right"><div class="text-xl font-bold text-slate-900">Receipt</div><div class="mt-1 text-sm text-slate-600">{{ payment.receipt_no }}</div><div class="text-sm text-slate-600">{{ payment.receipt_date }}</div></div>
|
||||
</div>
|
||||
<div class="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<div><div class="text-xs font-semibold uppercase text-slate-500">Received From</div><div class="mt-1 font-semibold text-slate-900">{{ payment.client.client_name if payment.client else invoice.client_legal_name }}</div><div class="text-sm text-slate-600">Invoice: {{ invoice.invoice_no }}</div></div>
|
||||
<div class="rounded-xl bg-slate-50 p-4"><div class="grid gap-2 text-sm"><div class="flex justify-between"><span>Amount Received</span><strong>₹ {{ '%.2f'|format(payment.amount_received or 0) }}</strong></div><div class="flex justify-between"><span>TDS Deducted</span><strong>₹ {{ '%.2f'|format(payment.tds_deducted or 0) }}</strong></div><div class="flex justify-between"><span>Bank Charges</span><strong>₹ {{ '%.2f'|format(payment.bank_charges or 0) }}</strong></div></div></div>
|
||||
</div>
|
||||
<div class="mt-6 grid gap-4 md:grid-cols-3 text-sm"><div><span class="text-slate-500">Mode</span><div class="font-semibold">{{ payment.mode }}</div></div><div><span class="text-slate-500">Payment Date</span><div class="font-semibold">{{ payment.payment_date }}</div></div><div><span class="text-slate-500">Reference</span><div class="font-semibold">{{ payment.reference_no or '-' }}</div></div></div>
|
||||
{% if payment.remarks %}<div class="mt-6 rounded-xl border border-slate-200 p-4 text-sm text-slate-600">{{ payment.remarks }}</div>{% endif %}
|
||||
<div class="mt-10 flex justify-end"><div class="text-center"><div class="h-12"></div><div class="border-t border-slate-400 px-8 pt-2 text-sm font-semibold">Authorised Signatory</div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,244 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-brand-600">Phase 7R.1</p>
|
||||
<h1 class="text-2xl font-bold text-slate-900">Firm Billing Settings</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Configure firm GST, invoice numbering, payment details and invoice footer defaults.</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm shadow-sm">
|
||||
<div class="font-semibold text-slate-900">{{ tenant_name }}</div>
|
||||
<div class="text-xs text-slate-500">{% if branch_name %}Branch: {{ branch_name }}{% else %}Firm-wide default{% endif %}</div>
|
||||
<div class="mt-2 text-xs text-slate-500">Next invoice preview</div>
|
||||
<div class="font-mono text-sm font-semibold text-brand-700">{{ preview_invoice_no }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<form method="post" action="/billing/settings" class="space-y-6">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
|
||||
<section class="af-card p-5">
|
||||
<div class="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-slate-900">Scope</h2>
|
||||
<p class="text-sm text-slate-500">Keep branch-specific settings for branch-wise invoice series, or use firm-wide default if you are working across branches.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<label class="rounded-2xl border border-slate-200 p-4 text-sm">
|
||||
<input type="radio" name="branch_scope" value="active" class="mr-2" {% if branch_scope != 'firm' %}checked{% endif %} />
|
||||
Active branch settings
|
||||
<div class="mt-1 text-xs text-slate-500">Recommended for branch-wise invoice numbering.</div>
|
||||
</label>
|
||||
<label class="rounded-2xl border border-slate-200 p-4 text-sm">
|
||||
<input type="radio" name="branch_scope" value="firm" class="mr-2" {% if branch_scope == 'firm' %}checked{% endif %} />
|
||||
Firm-wide default
|
||||
<div class="mt-1 text-xs text-slate-500">Available when cross-branch billing permission is active.</div>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="af-card p-5">
|
||||
<h2 class="text-lg font-semibold text-slate-900">Firm GST & Contact Details</h2>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<label class="text-sm font-medium text-slate-700">Legal / Billing Name
|
||||
<input name="legal_name" value="{{ settings.legal_name or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">GSTIN
|
||||
<input name="gstin" value="{{ settings.gstin or '' }}" maxlength="15" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm uppercase" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">PAN
|
||||
<input name="pan" value="{{ settings.pan or '' }}" maxlength="10" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm uppercase" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">State Code
|
||||
<input name="state_code" value="{{ settings.state_code or '' }}" maxlength="2" placeholder="33" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Contact Email
|
||||
<input name="contact_email" value="{{ settings.contact_email or '' }}" type="email" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Contact Mobile
|
||||
<input name="contact_mobile" value="{{ settings.contact_mobile or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700 md:col-span-2">Website
|
||||
<input name="website_url" value="{{ settings.website_url or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700 md:col-span-2">Billing Address
|
||||
<textarea name="billing_address" rows="3" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ settings.billing_address or '' }}</textarea>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="af-card p-5">
|
||||
<h2 class="text-lg font-semibold text-slate-900">Invoice Numbering & Tax Defaults</h2>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-3">
|
||||
<label class="text-sm font-medium text-slate-700">Invoice Title
|
||||
<input name="invoice_title" value="{{ settings.invoice_title or 'Tax Invoice' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Prefix
|
||||
<input name="invoice_prefix" value="{{ settings.invoice_prefix or 'INV' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm uppercase" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Next Number
|
||||
<input name="next_invoice_no" value="{{ settings.next_invoice_no or 1 }}" type="number" min="1" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Padding
|
||||
<input name="padding" value="{{ settings.padding or 4 }}" type="number" min="1" max="10" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700 md:col-span-2">Number Format
|
||||
<input name="invoice_number_format" value="{{ settings.invoice_number_format or '{prefix}/{fy}/{number}' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 font-mono text-sm" />
|
||||
<span class="mt-1 block text-xs text-slate-500">Tokens: {prefix}, {fy}, {number}, {branch_id}</span>
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Default Due Days
|
||||
<input name="default_due_days" value="{{ settings.default_due_days or 15 }}" type="number" min="0" max="365" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Default GST Rate %
|
||||
<input name="default_gst_rate" value="{{ settings.default_gst_rate or '18.00' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Default Tax Type
|
||||
<select name="default_tax_type" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
{% for tax in tax_types %}<option value="{{ tax }}" {% if settings.default_tax_type == tax %}selected{% endif %}>{{ tax }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Default SAC Code
|
||||
<input name="default_sac_code" value="{{ settings.default_sac_code or '' }}" placeholder="9982" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="af-card p-5">
|
||||
<h2 class="text-lg font-semibold text-slate-900">Bank, UPI & Payment Details</h2>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<label class="text-sm font-medium text-slate-700">Bank Name
|
||||
<input name="bank_name" value="{{ settings.bank_name or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Account Name
|
||||
<input name="bank_account_name" value="{{ settings.bank_account_name or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Account Number
|
||||
<input name="bank_account_number" value="{{ settings.bank_account_number or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">IFSC
|
||||
<input name="bank_ifsc" value="{{ settings.bank_ifsc or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm uppercase" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700 md:col-span-2">UPI ID
|
||||
<input name="upi_id" value="{{ settings.upi_id or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700 md:col-span-2">Additional Bank Details / Payment Instructions
|
||||
<textarea name="bank_details" rows="3" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ settings.bank_details or '' }}</textarea>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<section class="af-card p-5">
|
||||
<h2 class="text-lg font-semibold text-slate-900">PayUMoney / PayU Online Payment Gateway</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Enable this only after entering valid PayU/PayUMoney merchant credentials. Test mode posts to PayU test checkout.</p>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<label class="flex items-center gap-3 rounded-2xl border border-slate-200 p-4 text-sm font-medium text-slate-700 md:col-span-2">
|
||||
<input type="checkbox" name="payumoney_enabled" value="1" {% if settings.payumoney_enabled %}checked{% endif %} />
|
||||
Enable PayUMoney / PayU Pay Now for client portal
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Mode
|
||||
<select name="payumoney_mode" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="TEST" {% if settings.payumoney_mode != 'LIVE' %}selected{% endif %}>TEST / Sandbox</option>
|
||||
<option value="LIVE" {% if settings.payumoney_mode == 'LIVE' %}selected{% endif %}>LIVE / Production</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Merchant ID, optional
|
||||
<input name="payumoney_merchant_id" value="{{ settings.payumoney_merchant_id or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Merchant Key
|
||||
<input name="payumoney_merchant_key" value="{{ settings.payumoney_merchant_key or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" autocomplete="off" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Merchant Salt
|
||||
<input name="payumoney_merchant_salt" value="{{ settings.payumoney_merchant_salt or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" autocomplete="off" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700 md:col-span-2">Product Info Label
|
||||
<input name="payumoney_product_info" value="{{ settings.payumoney_product_info or 'Professional Services Invoice' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-4 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-xs leading-5 text-amber-900">
|
||||
Store separate test and live credentials carefully. Do not enable LIVE until callback testing is completed from an accessible public URL.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<section class="af-card p-5">
|
||||
<h2 class="text-lg font-semibold text-slate-900">Cashfree Online Payment Gateway</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Enable Cashfree only after adding valid Cashfree PG credentials. Sandbox mode uses Cashfree sandbox APIs.</p>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<label class="flex items-center gap-3 rounded-2xl border border-slate-200 p-4 text-sm font-medium text-slate-700 md:col-span-2">
|
||||
<input type="checkbox" name="cashfree_enabled" value="1" {% if settings.cashfree_enabled %}checked{% endif %} />
|
||||
Enable Cashfree Pay Now for client portal
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Mode
|
||||
<select name="cashfree_mode" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="TEST" {% if settings.cashfree_mode != 'LIVE' %}selected{% endif %}>TEST / Sandbox</option>
|
||||
<option value="LIVE" {% if settings.cashfree_mode == 'LIVE' %}selected{% endif %}>LIVE / Production</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">API Version
|
||||
<input name="cashfree_api_version" value="{{ settings.cashfree_api_version or '2023-08-01' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Client ID / App ID
|
||||
<input name="cashfree_client_id" value="{{ settings.cashfree_client_id or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" autocomplete="off" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Client Secret
|
||||
<input name="cashfree_client_secret" value="{{ settings.cashfree_client_secret or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" autocomplete="off" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700 md:col-span-2">Order Note
|
||||
<input name="cashfree_order_note" value="{{ settings.cashfree_order_note or 'Professional Services Invoice' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-4 rounded-2xl border border-sky-200 bg-sky-50 p-4 text-xs leading-5 text-sky-900">
|
||||
Cashfree checkout creates an order from the server and uses payment_session_id for hosted checkout. Webhook URL: <span class="font-mono">/client/billing/cashfree/webhook</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="af-card p-5">
|
||||
<h2 class="text-lg font-semibold text-slate-900">Invoice Notes, Terms & Signatory</h2>
|
||||
<div class="mt-4 grid gap-4">
|
||||
<label class="text-sm font-medium text-slate-700">Default Terms
|
||||
<textarea name="terms" rows="3" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ settings.terms or '' }}</textarea>
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Declaration
|
||||
<textarea name="declaration" rows="3" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ settings.declaration or '' }}</textarea>
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Invoice Footer Note
|
||||
<textarea name="footer_note" rows="2" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ settings.footer_note or '' }}</textarea>
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Authorised Signatory Name
|
||||
<input name="authorised_signatory_name" value="{{ settings.authorised_signatory_name or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<a href="/billing" class="af-btn af-btn-secondary">Back to Invoices</a>
|
||||
{% if can_edit_settings %}
|
||||
<button type="submit" class="af-btn af-btn-primary">Save Billing Settings</button>
|
||||
{% else %}
|
||||
<span class="text-sm text-slate-500">View-only access</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<aside class="space-y-4">
|
||||
<div class="af-card p-5">
|
||||
<h3 class="font-semibold text-slate-900">Why this matters</h3>
|
||||
<ul class="mt-3 space-y-2 text-sm text-slate-600">
|
||||
<li>• GST invoice format will use these details in Phase 7R.2.</li>
|
||||
<li>• Payment and receipt tracking will use bank/UPI details in Phase 7R.4.</li>
|
||||
<li>• Client portal Pay Now uses UPI, PayUMoney and Cashfree settings from Phase 7R.5 / 7R.6 / 7R.6A.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="af-card p-5">
|
||||
<h3 class="font-semibold text-slate-900">Recommended invoice format</h3>
|
||||
<p class="mt-2 rounded-xl bg-slate-50 px-3 py-2 font-mono text-sm text-slate-700">{prefix}/{fy}/{number}</p>
|
||||
<p class="mt-2 text-xs text-slate-500">Example: INV/2026-27/0001</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,880 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import APIRouter, File, Form, Request, UploadFile
|
||||
from fastapi.responses import RedirectResponse, StreamingResponse
|
||||
from sqlalchemy import select
|
||||
|
||||
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.billing.models import BillingFeeGroup, BillingSettings
|
||||
from app.modules.billing.services import (
|
||||
BILLING_MODES,
|
||||
FREQUENCIES,
|
||||
PAYMENT_MODES,
|
||||
TAX_TYPES,
|
||||
build_fee_structure_template,
|
||||
build_invoice_print_context,
|
||||
build_billing_report_summary,
|
||||
billing_financial_year,
|
||||
create_invoice,
|
||||
fee_group_already_billed,
|
||||
generate_draft_invoices_from_fee_groups,
|
||||
get_invoice,
|
||||
import_fee_structure_excel,
|
||||
issue_invoice,
|
||||
list_clients_for_billing,
|
||||
list_fee_groups,
|
||||
list_fee_groups_for_generation,
|
||||
list_invoices,
|
||||
list_payments,
|
||||
list_services_for_billing,
|
||||
parse_date,
|
||||
preview_invoice_number,
|
||||
record_invoice_payment,
|
||||
get_payment,
|
||||
)
|
||||
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
||||
from app.modules.core.rbac.permission_guard import require_permission
|
||||
from app.modules.core.tenancy.models import Branch, Tenant
|
||||
from app.modules.core.tenancy.year_control import redirect_if_financial_year_locked, is_row_financial_year_locked
|
||||
|
||||
router = APIRouter(prefix="/billing", tags=["billing-ui"])
|
||||
|
||||
|
||||
def _base_ctx(request: Request, user, db, **ctx):
|
||||
base = {
|
||||
"request": request,
|
||||
"current_user": user,
|
||||
"current_user_roles": get_user_roles(db, user.id),
|
||||
"current_user_permissions": get_user_permissions(db, user.id),
|
||||
"csrf_token": get_or_create_csrf_token(request),
|
||||
"tax_types": TAX_TYPES,
|
||||
"billing_modes": BILLING_MODES,
|
||||
"frequencies": FREQUENCIES,
|
||||
"payment_modes": PAYMENT_MODES,
|
||||
}
|
||||
base.update(ctx)
|
||||
return base
|
||||
|
||||
|
||||
def _render(request: Request, template: str, db, user, **ctx):
|
||||
return templates.TemplateResponse(template, _base_ctx(request, user, db, **ctx))
|
||||
|
||||
|
||||
def _redirect_denied():
|
||||
return RedirectResponse(url="/system-settings", status_code=303)
|
||||
|
||||
|
||||
def _has_perm(db, user, code: str) -> bool:
|
||||
try:
|
||||
require_permission(db, user, code)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _role_names(db, user) -> set[str]:
|
||||
return {str(r or "").strip() for r in get_user_roles(db, user.id)}
|
||||
|
||||
|
||||
def _can_manage_billing_settings(db, user) -> bool:
|
||||
roles = _role_names(db, user)
|
||||
return bool({"System Admin", "Firm Admin", "Partner"}.intersection(roles)) or _has_perm(db, user, "billing.edit")
|
||||
|
||||
|
||||
def _get_or_create_billing_settings(db, *, tenant_id: int, branch_id: int | None) -> BillingSettings:
|
||||
row = db.execute(
|
||||
select(BillingSettings).where(BillingSettings.tenant_id == tenant_id, BillingSettings.branch_id == branch_id)
|
||||
).scalar_one_or_none()
|
||||
if row:
|
||||
return row
|
||||
row = BillingSettings(tenant_id=tenant_id, branch_id=branch_id)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
return row
|
||||
|
||||
|
||||
def _decimal_form(value: str | None, default: str = "0.00") -> Decimal:
|
||||
try:
|
||||
return Decimal(str(value or default)).quantize(Decimal("0.01"))
|
||||
except Exception:
|
||||
return Decimal(default).quantize(Decimal("0.01"))
|
||||
|
||||
|
||||
def _int_form(value: str | int | None, default: int, minimum: int | None = None, maximum: int | None = None) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except Exception:
|
||||
parsed = default
|
||||
if minimum is not None:
|
||||
parsed = max(minimum, parsed)
|
||||
if maximum is not None:
|
||||
parsed = min(maximum, parsed)
|
||||
return parsed
|
||||
|
||||
|
||||
def _billing_context_names(db, *, tenant_id: int, branch_id: int | None) -> tuple[str, str | None]:
|
||||
tenant = db.get(Tenant, tenant_id)
|
||||
branch = db.get(Branch, branch_id) if branch_id else None
|
||||
return (getattr(tenant, "name", None) or f"Audit Firm {tenant_id}", getattr(branch, "name", None) if branch else None)
|
||||
|
||||
|
||||
def _active_tenant_id(request: Request, user) -> int:
|
||||
return int(request.session.get("active_tenant_id") or request.session.get("selected_tenant_id") or request.session.get("tenant_id") or user.tenant_id)
|
||||
|
||||
|
||||
def _active_branch_id(request: Request, user, db) -> int | None:
|
||||
value = request.session.get("active_branch_id")
|
||||
if value in (None, "", 0, "0"):
|
||||
if _has_perm(db, user, "billing.cross_branch"):
|
||||
return None
|
||||
return int(getattr(user, "branch_id", 0) or 0) or None
|
||||
return int(value)
|
||||
|
||||
|
||||
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()
|
||||
if not value or value.upper() == "ALL":
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _period_start_for_fy(financial_year: str | None) -> date:
|
||||
try:
|
||||
start_year = int(str(financial_year or "").split("-")[0])
|
||||
return date(start_year, 4, 1)
|
||||
except Exception:
|
||||
today = date.today()
|
||||
return date(today.year if today.month >= 4 else today.year - 1, 4, 1)
|
||||
|
||||
|
||||
def _period_end_for_fy(financial_year: str | None) -> date:
|
||||
start = _period_start_for_fy(financial_year)
|
||||
return date(start.year + 1, 3, 31)
|
||||
|
||||
|
||||
def _locked_partner_id(db, user) -> int | None:
|
||||
return int(user.id) if _has_perm(db, user, "billing.view_own") else None
|
||||
|
||||
|
||||
def _require_billing_user(request: Request, db, permission_code: str):
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return None, RedirectResponse(url="/login", status_code=303)
|
||||
try:
|
||||
require_permission(db, user, permission_code)
|
||||
except Exception:
|
||||
return user, _redirect_denied()
|
||||
return user, None
|
||||
|
||||
|
||||
@router.get("")
|
||||
def invoice_list(request: Request, q: str = ""):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_billing_user(request, db, "billing.view")
|
||||
if response:
|
||||
return response
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
partner_id = _locked_partner_id(db, user)
|
||||
financial_year = _active_financial_year(request)
|
||||
rows = list_invoices(db, tenant_id=tenant_id, branch_id=branch_id, partner_id=partner_id, financial_year=financial_year, q=q)
|
||||
report_summary = build_billing_report_summary(db, tenant_id=tenant_id, branch_id=branch_id, partner_id=partner_id, financial_year=financial_year)
|
||||
return _render(
|
||||
request,
|
||||
"modules/billing/templates/billing/list.html",
|
||||
db,
|
||||
user,
|
||||
title="Billing - Invoices",
|
||||
q=q,
|
||||
active_financial_year=financial_year,
|
||||
rows=rows,
|
||||
report_summary=report_summary,
|
||||
can_create=_has_perm(db, user, "billing.create"),
|
||||
can_import_fee_structure=_has_perm(db, user, "billing_fee_structure.import"),
|
||||
can_generate=_has_perm(db, user, "billing_invoice.generate"),
|
||||
can_view_fee_structure=_has_perm(db, user, "billing_fee_structure.view"),
|
||||
can_record_payment=_has_perm(db, user, "billing_payment.record") or _has_perm(db, user, "billing.create"),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/payments")
|
||||
def payment_list(request: Request, q: str = ""):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_billing_user(request, db, "billing.view")
|
||||
if response:
|
||||
return response
|
||||
financial_year = _active_financial_year(request)
|
||||
rows = list_payments(
|
||||
db,
|
||||
tenant_id=_active_tenant_id(request, user),
|
||||
branch_id=_active_branch_id(request, user, db),
|
||||
partner_id=_locked_partner_id(db, user),
|
||||
financial_year=financial_year,
|
||||
q=q,
|
||||
)
|
||||
return _render(request, "modules/billing/templates/billing/payments/list.html", db, user, title="Payments & Receipts", rows=rows, q=q, active_financial_year=financial_year)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/payments/{payment_id}/receipt")
|
||||
def payment_receipt_print(request: Request, payment_id: int):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_billing_user(request, db, "billing.view")
|
||||
if response:
|
||||
return response
|
||||
payment = get_payment(db, payment_id=payment_id, tenant_id=_active_tenant_id(request, user), partner_id=_locked_partner_id(db, user), financial_year=_active_financial_year(request))
|
||||
if not payment:
|
||||
return _redirect_denied()
|
||||
invoice_ctx = build_invoice_print_context(db, payment.invoice)
|
||||
return _render(request, "modules/billing/templates/billing/payments/receipt_print.html", db, user, title=f"Receipt {payment.receipt_no}", payment=payment, invoice=payment.invoice, invoice_ctx=invoice_ctx)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
def billing_settings_page(request: Request, branch_scope: str = "active"):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_billing_user(request, db, "billing.view")
|
||||
if response:
|
||||
return response
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
active_branch_id = _active_branch_id(request, user, db)
|
||||
branch_id = None if branch_scope == "firm" and _has_perm(db, user, "billing.cross_branch") else active_branch_id
|
||||
settings = _get_or_create_billing_settings(db, tenant_id=tenant_id, branch_id=branch_id)
|
||||
tenant_name, branch_name = _billing_context_names(db, tenant_id=tenant_id, branch_id=branch_id)
|
||||
return _render(
|
||||
request,
|
||||
"modules/billing/templates/billing/settings.html",
|
||||
db,
|
||||
user,
|
||||
title="Billing Settings",
|
||||
settings=settings,
|
||||
preview_invoice_no=preview_invoice_number(settings, branch_id=branch_id, financial_year=_active_financial_year(request)),
|
||||
tenant_name=tenant_name,
|
||||
branch_name=branch_name,
|
||||
branch_scope="firm" if branch_id is None else "active",
|
||||
can_edit_settings=_can_manage_billing_settings(db, user),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/settings")
|
||||
def billing_settings_submit(
|
||||
request: Request,
|
||||
branch_scope: str = Form("active"),
|
||||
legal_name: str | None = Form(None),
|
||||
gstin: str | None = Form(None),
|
||||
pan: str | None = Form(None),
|
||||
state_code: str | None = Form(None),
|
||||
billing_address: str | None = Form(None),
|
||||
contact_email: str | None = Form(None),
|
||||
contact_mobile: str | None = Form(None),
|
||||
website_url: str | None = Form(None),
|
||||
invoice_title: str | None = Form(None),
|
||||
invoice_prefix: str = Form("INV"),
|
||||
invoice_number_format: str | None = Form("{prefix}/{fy}/{number}"),
|
||||
next_invoice_no: int = Form(1),
|
||||
padding: int = Form(4),
|
||||
default_due_days: int = Form(15),
|
||||
default_gst_rate: str = Form("18.00"),
|
||||
default_tax_type: str = Form("CGST_SGST"),
|
||||
default_sac_code: str | None = Form(None),
|
||||
bank_name: str | None = Form(None),
|
||||
bank_account_name: str | None = Form(None),
|
||||
bank_account_number: str | None = Form(None),
|
||||
bank_ifsc: str | None = Form(None),
|
||||
upi_id: str | None = Form(None),
|
||||
bank_details: str | None = Form(None),
|
||||
terms: str | None = Form(None),
|
||||
footer_note: str | None = Form(None),
|
||||
declaration: str | None = Form(None),
|
||||
authorised_signatory_name: str | None = Form(None),
|
||||
payumoney_enabled: str | None = Form(None),
|
||||
payumoney_mode: str = Form("TEST"),
|
||||
payumoney_merchant_key: str | None = Form(None),
|
||||
payumoney_merchant_salt: str | None = Form(None),
|
||||
payumoney_merchant_id: str | None = Form(None),
|
||||
payumoney_product_info: str | None = Form(None),
|
||||
cashfree_enabled: str | None = Form(None),
|
||||
cashfree_mode: str = Form("TEST"),
|
||||
cashfree_client_id: str | None = Form(None),
|
||||
cashfree_client_secret: str | None = Form(None),
|
||||
cashfree_api_version: str | None = Form("2023-08-01"),
|
||||
cashfree_order_note: str | None = Form(None),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_billing_user(request, db, "billing.view")
|
||||
if response:
|
||||
return response
|
||||
if not _can_manage_billing_settings(db, user):
|
||||
return _redirect_denied()
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
active_branch_id = _active_branch_id(request, user, db)
|
||||
branch_id = None if branch_scope == "firm" and _has_perm(db, user, "billing.cross_branch") else active_branch_id
|
||||
settings = _get_or_create_billing_settings(db, tenant_id=tenant_id, branch_id=branch_id)
|
||||
|
||||
settings.legal_name = (legal_name or "").strip() or None
|
||||
settings.gstin = (gstin or "").strip().upper() or None
|
||||
settings.pan = (pan or "").strip().upper() or None
|
||||
settings.state_code = (state_code or "").strip()[:2] or None
|
||||
settings.billing_address = (billing_address or "").strip() or None
|
||||
settings.contact_email = (contact_email or "").strip() or None
|
||||
settings.contact_mobile = (contact_mobile or "").strip() or None
|
||||
settings.website_url = (website_url or "").strip() or None
|
||||
|
||||
settings.invoice_title = (invoice_title or "").strip() or None
|
||||
settings.invoice_prefix = (invoice_prefix or "INV").strip().upper()[:40] or "INV"
|
||||
settings.invoice_number_format = (invoice_number_format or "{prefix}/{fy}/{number}").strip()[:120] or "{prefix}/{fy}/{number}"
|
||||
settings.next_invoice_no = _int_form(next_invoice_no, 1, minimum=1)
|
||||
settings.padding = _int_form(padding, 4, minimum=1, maximum=10)
|
||||
settings.default_due_days = _int_form(default_due_days, 15, minimum=0, maximum=365)
|
||||
settings.default_gst_rate = _decimal_form(default_gst_rate, "18.00")
|
||||
settings.default_tax_type = default_tax_type if default_tax_type in TAX_TYPES else "CGST_SGST"
|
||||
settings.default_sac_code = (default_sac_code or "").strip()[:20] or None
|
||||
|
||||
settings.bank_name = (bank_name or "").strip() or None
|
||||
settings.bank_account_name = (bank_account_name or "").strip() or None
|
||||
settings.bank_account_number = (bank_account_number or "").strip() or None
|
||||
settings.bank_ifsc = (bank_ifsc or "").strip().upper() or None
|
||||
settings.upi_id = (upi_id or "").strip() or None
|
||||
settings.bank_details = (bank_details or "").strip() or None
|
||||
settings.terms = (terms or "").strip() or None
|
||||
settings.footer_note = (footer_note or "").strip() or None
|
||||
settings.declaration = (declaration or "").strip() or None
|
||||
settings.authorised_signatory_name = (authorised_signatory_name or "").strip() or None
|
||||
|
||||
settings.payumoney_enabled = bool(payumoney_enabled)
|
||||
settings.payumoney_mode = (payumoney_mode or "TEST").strip().upper() if (payumoney_mode or "TEST").strip().upper() in {"TEST", "LIVE"} else "TEST"
|
||||
settings.payumoney_merchant_key = (payumoney_merchant_key or "").strip() or None
|
||||
settings.payumoney_merchant_salt = (payumoney_merchant_salt or "").strip() or None
|
||||
settings.payumoney_merchant_id = (payumoney_merchant_id or "").strip() or None
|
||||
settings.payumoney_product_info = (payumoney_product_info or "").strip() or None
|
||||
db.commit()
|
||||
suffix = "?branch_scope=firm" if branch_id is None else ""
|
||||
return RedirectResponse(url=f"/billing/settings{suffix}", status_code=303)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/new")
|
||||
def invoice_create_page(request: Request):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_billing_user(request, db, "billing.create")
|
||||
if response:
|
||||
return response
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
partner_id = _locked_partner_id(db, user)
|
||||
clients = list_clients_for_billing(db, tenant_id=tenant_id, branch_id=branch_id, partner_id=partner_id)
|
||||
services = list_services_for_billing(db)
|
||||
settings = _get_or_create_billing_settings(db, tenant_id=tenant_id, branch_id=branch_id)
|
||||
return _render(
|
||||
request,
|
||||
"modules/billing/templates/billing/create.html",
|
||||
db,
|
||||
user,
|
||||
title="Create Invoice",
|
||||
active_financial_year=financial_year,
|
||||
default_billing_period_from=_period_start_for_fy(_active_financial_year(request)).isoformat(),
|
||||
default_billing_period_to=_period_end_for_fy(_active_financial_year(request)).isoformat(),
|
||||
clients=clients,
|
||||
services=services,
|
||||
settings=settings,
|
||||
today=date.today().isoformat(),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/new")
|
||||
def invoice_create_submit(
|
||||
request: Request,
|
||||
client_id: int = Form(...),
|
||||
invoice_date: str = Form(...),
|
||||
due_date: str | None = Form(None),
|
||||
billing_period_from: str | None = Form(None),
|
||||
billing_period_to: str | None = Form(None),
|
||||
tax_type: str = Form("CGST_SGST"),
|
||||
place_of_supply: str | None = Form(None),
|
||||
client_state_code: str | None = Form(None),
|
||||
reverse_charge: str | None = Form(None),
|
||||
notes: str | None = Form(None),
|
||||
terms: str | None = Form(None),
|
||||
line_description: list[str] = Form(default=[]),
|
||||
line_service_id: list[str] = Form(default=[]),
|
||||
line_quantity: list[str] = Form(default=[]),
|
||||
line_rate: list[str] = Form(default=[]),
|
||||
line_discount: list[str] = Form(default=[]),
|
||||
line_gst_rate: list[str] = Form(default=[]),
|
||||
line_sac_code: list[str] = Form(default=[]),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_billing_user(request, db, "billing.create")
|
||||
if response:
|
||||
return response
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
partner_id = _locked_partner_id(db, user)
|
||||
financial_year = _active_financial_year(request)
|
||||
locked_response = redirect_if_financial_year_locked(db, tenant_id=tenant_id, year_code=financial_year, redirect_url=f"/billing?financial_year={financial_year or ''}")
|
||||
if locked_response:
|
||||
return locked_response
|
||||
|
||||
allowed_clients = {c.id for c in list_clients_for_billing(db, tenant_id=tenant_id, branch_id=branch_id, partner_id=partner_id)}
|
||||
if client_id not in allowed_clients:
|
||||
return _redirect_denied()
|
||||
|
||||
raw_lines = []
|
||||
max_len = max(len(line_description), len(line_service_id), len(line_quantity), len(line_rate), len(line_discount), len(line_gst_rate), len(line_sac_code), 0)
|
||||
for idx in range(max_len):
|
||||
raw_lines.append({
|
||||
"description": line_description[idx] if idx < len(line_description) else "",
|
||||
"service_id": line_service_id[idx] if idx < len(line_service_id) else "",
|
||||
"quantity": line_quantity[idx] if idx < len(line_quantity) else "1",
|
||||
"rate": line_rate[idx] if idx < len(line_rate) else "0",
|
||||
"discount_amount": line_discount[idx] if idx < len(line_discount) else "0",
|
||||
"gst_rate": line_gst_rate[idx] if idx < len(line_gst_rate) else "18",
|
||||
"sac_code": line_sac_code[idx] if idx < len(line_sac_code) else "",
|
||||
})
|
||||
|
||||
invoice = create_invoice(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
client_id=client_id,
|
||||
invoice_date=parse_date(invoice_date) or date.today(),
|
||||
due_date=parse_date(due_date),
|
||||
billing_period_from=parse_date(billing_period_from),
|
||||
billing_period_to=parse_date(billing_period_to),
|
||||
tax_type=tax_type,
|
||||
notes=notes,
|
||||
terms=terms,
|
||||
place_of_supply=place_of_supply,
|
||||
client_state_code=client_state_code,
|
||||
reverse_charge=(reverse_charge == "yes"),
|
||||
created_by_user_id=user.id,
|
||||
raw_lines=raw_lines,
|
||||
financial_year=_active_financial_year(request),
|
||||
)
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/billing/{invoice.id}", status_code=303)
|
||||
except ValueError:
|
||||
db.rollback()
|
||||
return RedirectResponse(url="/billing/new", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/fee-structures/list")
|
||||
def fee_structure_list(request: Request, q: str = ""):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_billing_user(request, db, "billing_fee_structure.view")
|
||||
if response:
|
||||
return response
|
||||
rows = list_fee_groups(
|
||||
db,
|
||||
tenant_id=_active_tenant_id(request, user),
|
||||
branch_id=_active_branch_id(request, user, db),
|
||||
partner_id=_locked_partner_id(db, user),
|
||||
q=q,
|
||||
)
|
||||
return _render(
|
||||
request,
|
||||
"modules/billing/templates/billing/fee_structures/list.html",
|
||||
db,
|
||||
user,
|
||||
title="Fee Structure",
|
||||
q=q,
|
||||
active_financial_year=financial_year,
|
||||
rows=rows,
|
||||
can_import=_has_perm(db, user, "billing_fee_structure.import"),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/fee-structures/import")
|
||||
def fee_structure_import_page(request: Request):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_billing_user(request, db, "billing_fee_structure.import")
|
||||
if response:
|
||||
return response
|
||||
return _render(request, "modules/billing/templates/billing/fee_structures/import.html", db, user, title="Import Fee Structure", result=None)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/fee-structures/template")
|
||||
def fee_structure_template_download(request: Request):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_billing_user(request, db, "billing_fee_structure.import")
|
||||
if response:
|
||||
return response
|
||||
data = build_fee_structure_template()
|
||||
return StreamingResponse(
|
||||
iter([data]),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": "attachment; filename=billing_fee_structure_template.xlsx"},
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/fee-structures/import")
|
||||
async def fee_structure_import_submit(request: Request, import_file: UploadFile = File(...), csrf_token: str = Form(...)):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_billing_user(request, db, "billing_fee_structure.import")
|
||||
if response:
|
||||
return response
|
||||
filename = (import_file.filename or "").lower()
|
||||
if not filename.endswith((".xlsx", ".xlsm")):
|
||||
result = {"success": False, "created": 0, "updated": 0, "errors": ["Please upload an .xlsx file."]}
|
||||
else:
|
||||
content = await import_file.read()
|
||||
if len(content) > 5 * 1024 * 1024:
|
||||
result = {"success": False, "created": 0, "updated": 0, "errors": ["File size must be 5 MB or less."]}
|
||||
else:
|
||||
result = import_fee_structure_excel(
|
||||
db,
|
||||
tenant_id=_active_tenant_id(request, user),
|
||||
branch_id=_active_branch_id(request, user, db),
|
||||
created_by_user_id=user.id,
|
||||
file_bytes=content,
|
||||
)
|
||||
return _render(request, "modules/billing/templates/billing/fee_structures/import.html", db, user, title="Import Fee Structure", result=result)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/generate")
|
||||
def generate_invoices_page(
|
||||
request: Request,
|
||||
frequency: str = "Monthly",
|
||||
billing_period_from: str | None = None,
|
||||
billing_period_to: str | None = None,
|
||||
auto_generate_only: str = "yes",
|
||||
q: str = "",
|
||||
):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_billing_user(request, db, "billing_invoice.generate")
|
||||
if response:
|
||||
return response
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
partner_id = _locked_partner_id(db, user)
|
||||
financial_year = _active_financial_year(request)
|
||||
locked_response = redirect_if_financial_year_locked(db, tenant_id=tenant_id, year_code=financial_year, redirect_url=f"/billing?financial_year={financial_year or ''}")
|
||||
if locked_response:
|
||||
return locked_response
|
||||
period_from = parse_date(billing_period_from) or _period_start_for_fy(financial_year)
|
||||
period_to = parse_date(billing_period_to) or _period_end_for_fy(financial_year)
|
||||
rows = list_fee_groups_for_generation(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
partner_id=partner_id,
|
||||
frequency=frequency or None,
|
||||
auto_generate_only=(auto_generate_only != "no"),
|
||||
q=q,
|
||||
)
|
||||
duplicate_map = {
|
||||
row.id: fee_group_already_billed(db, tenant_id=tenant_id, fee_group_id=row.id, period_from=period_from, period_to=period_to)
|
||||
for row in rows
|
||||
}
|
||||
return _render(
|
||||
request,
|
||||
"modules/billing/templates/billing/generate.html",
|
||||
db,
|
||||
user,
|
||||
title="Generate Draft Invoices",
|
||||
rows=rows,
|
||||
duplicate_map=duplicate_map,
|
||||
frequencies=FREQUENCIES,
|
||||
frequency=frequency,
|
||||
billing_period_from=period_from.isoformat(),
|
||||
billing_period_to=period_to.isoformat(),
|
||||
auto_generate_only=auto_generate_only,
|
||||
q=q,
|
||||
active_financial_year=financial_year,
|
||||
result=None,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/generate")
|
||||
def generate_invoices_submit(
|
||||
request: Request,
|
||||
frequency: str = Form("Monthly"),
|
||||
billing_period_from: str = Form(...),
|
||||
billing_period_to: str = Form(...),
|
||||
auto_generate_only: str = Form("yes"),
|
||||
q: str = Form(""),
|
||||
fee_group_ids: list[int] = Form(default=[]),
|
||||
skip_duplicates: str = Form("yes"),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_billing_user(request, db, "billing_invoice.generate")
|
||||
if response:
|
||||
return response
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
partner_id = _locked_partner_id(db, user)
|
||||
financial_year = _active_financial_year(request)
|
||||
locked_response = redirect_if_financial_year_locked(db, tenant_id=tenant_id, year_code=financial_year, redirect_url=f"/billing/generate?year_locked=1")
|
||||
if locked_response:
|
||||
return locked_response
|
||||
period_from = parse_date(billing_period_from)
|
||||
period_to = parse_date(billing_period_to)
|
||||
if financial_year and period_from and billing_financial_year(billing_period_from=period_from) != financial_year:
|
||||
result = {"created": [], "skipped": [], "errors": [f"Billing period must fall within active FY {financial_year}."], "batch": None}
|
||||
elif period_from is None or period_to is None:
|
||||
result = {"created": [], "skipped": [], "errors": ["Billing period From and To are required."], "batch": None}
|
||||
else:
|
||||
result = generate_draft_invoices_from_fee_groups(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
partner_id=partner_id,
|
||||
generated_by_user_id=user.id,
|
||||
billing_period_from=period_from,
|
||||
billing_period_to=period_to,
|
||||
frequency=frequency or None,
|
||||
fee_group_ids=fee_group_ids,
|
||||
skip_duplicates=(skip_duplicates != "no"),
|
||||
)
|
||||
db.commit()
|
||||
rows = list_fee_groups_for_generation(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
partner_id=partner_id,
|
||||
frequency=frequency or None,
|
||||
auto_generate_only=(auto_generate_only != "no"),
|
||||
q=q,
|
||||
)
|
||||
duplicate_map = {
|
||||
row.id: fee_group_already_billed(db, tenant_id=tenant_id, fee_group_id=row.id, period_from=period_from or date.today(), period_to=period_to or date.today())
|
||||
for row in rows
|
||||
}
|
||||
return _render(
|
||||
request,
|
||||
"modules/billing/templates/billing/generate.html",
|
||||
db,
|
||||
user,
|
||||
title="Generate Draft Invoices",
|
||||
rows=rows,
|
||||
duplicate_map=duplicate_map,
|
||||
frequencies=FREQUENCIES,
|
||||
frequency=frequency,
|
||||
billing_period_from=(period_from or date.today()).isoformat(),
|
||||
billing_period_to=(period_to or date.today()).isoformat(),
|
||||
auto_generate_only=auto_generate_only,
|
||||
q=q,
|
||||
active_financial_year=financial_year,
|
||||
result=result,
|
||||
)
|
||||
except ValueError as exc:
|
||||
db.rollback()
|
||||
rows = []
|
||||
result = {"created": [], "skipped": [], "errors": [str(exc)], "batch": None}
|
||||
return _render(
|
||||
request,
|
||||
"modules/billing/templates/billing/generate.html",
|
||||
db,
|
||||
user,
|
||||
title="Generate Draft Invoices",
|
||||
rows=rows,
|
||||
duplicate_map={},
|
||||
frequencies=FREQUENCIES,
|
||||
frequency=frequency,
|
||||
billing_period_from=billing_period_from,
|
||||
billing_period_to=billing_period_to,
|
||||
auto_generate_only=auto_generate_only,
|
||||
q=q,
|
||||
active_financial_year=_active_financial_year(request),
|
||||
result=result,
|
||||
)
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
result = {"created": [], "skipped": [], "errors": [f"Generation failed: {exc}"], "batch": None}
|
||||
return _render(
|
||||
request,
|
||||
"modules/billing/templates/billing/generate.html",
|
||||
db,
|
||||
user,
|
||||
title="Generate Draft Invoices",
|
||||
rows=[],
|
||||
duplicate_map={},
|
||||
frequencies=FREQUENCIES,
|
||||
frequency=frequency,
|
||||
billing_period_from=billing_period_from,
|
||||
billing_period_to=billing_period_to,
|
||||
auto_generate_only=auto_generate_only,
|
||||
q=q,
|
||||
active_financial_year=_active_financial_year(request),
|
||||
result=result,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/{invoice_id}/payments/new")
|
||||
def invoice_payment_page(request: Request, invoice_id: int):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_billing_user(request, db, "billing.view")
|
||||
if response:
|
||||
return response
|
||||
invoice = get_invoice(db, invoice_id=invoice_id, tenant_id=_active_tenant_id(request, user), partner_id=_locked_partner_id(db, user), financial_year=_active_financial_year(request))
|
||||
if not invoice:
|
||||
return _redirect_denied()
|
||||
if invoice.status in {"DRAFT", "CANCELLED"}:
|
||||
return RedirectResponse(url=f"/billing/{invoice.id}", status_code=303)
|
||||
can_record = _has_perm(db, user, "billing_payment.record") or _has_perm(db, user, "billing.create")
|
||||
if not can_record:
|
||||
return _redirect_denied()
|
||||
return _render(request, "modules/billing/templates/billing/payments/new.html", db, user, title=f"Record Payment - {invoice.invoice_no}", invoice=invoice, today=date.today().isoformat())
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/{invoice_id}/payments/new")
|
||||
def invoice_payment_submit(
|
||||
request: Request,
|
||||
invoice_id: int,
|
||||
payment_date: str = Form(...),
|
||||
amount_received: str = Form("0.00"),
|
||||
tds_deducted: str = Form("0.00"),
|
||||
bank_charges: str = Form("0.00"),
|
||||
mode: str = Form("BANK"),
|
||||
reference_no: str | None = Form(None),
|
||||
remarks: str | None = Form(None),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_billing_user(request, db, "billing.view")
|
||||
if response:
|
||||
return response
|
||||
can_record = _has_perm(db, user, "billing_payment.record") or _has_perm(db, user, "billing.create")
|
||||
if not can_record:
|
||||
return _redirect_denied()
|
||||
invoice = get_invoice(db, invoice_id=invoice_id, tenant_id=_active_tenant_id(request, user), partner_id=_locked_partner_id(db, user), financial_year=_active_financial_year(request))
|
||||
if not invoice:
|
||||
return _redirect_denied()
|
||||
if is_row_financial_year_locked(db, invoice):
|
||||
return RedirectResponse(url=f"/billing/{invoice.id}?year_locked=1", status_code=303)
|
||||
payment = record_invoice_payment(
|
||||
db,
|
||||
invoice=invoice,
|
||||
payment_date=parse_date(payment_date) or date.today(),
|
||||
amount_received=_decimal_form(amount_received, "0.00"),
|
||||
tds_deducted=_decimal_form(tds_deducted, "0.00"),
|
||||
bank_charges=_decimal_form(bank_charges, "0.00"),
|
||||
mode=mode,
|
||||
reference_no=reference_no,
|
||||
remarks=remarks,
|
||||
created_by_user_id=user.id,
|
||||
)
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/billing/payments/{payment.id}/receipt", status_code=303)
|
||||
except ValueError:
|
||||
db.rollback()
|
||||
return RedirectResponse(url=f"/billing/{invoice_id}", status_code=303)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/{invoice_id}/print")
|
||||
def invoice_print(request: Request, invoice_id: int):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_billing_user(request, db, "billing.view")
|
||||
if response:
|
||||
return response
|
||||
invoice = get_invoice(db, invoice_id=invoice_id, tenant_id=_active_tenant_id(request, user), partner_id=_locked_partner_id(db, user), financial_year=_active_financial_year(request))
|
||||
if not invoice:
|
||||
return _redirect_denied()
|
||||
invoice_ctx = build_invoice_print_context(db, invoice)
|
||||
return _render(request, "modules/billing/templates/billing/invoice_print.html", db, user, title=f"Print Invoice {invoice.invoice_no}", invoice=invoice, invoice_ctx=invoice_ctx)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/{invoice_id}/issue")
|
||||
def invoice_issue_submit(request: Request, invoice_id: int, csrf_token: str = Form(...)):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_billing_user(request, db, "billing.create")
|
||||
if response:
|
||||
return response
|
||||
invoice = get_invoice(db, invoice_id=invoice_id, tenant_id=_active_tenant_id(request, user), partner_id=_locked_partner_id(db, user), financial_year=_active_financial_year(request))
|
||||
if not invoice:
|
||||
return _redirect_denied()
|
||||
if is_row_financial_year_locked(db, invoice):
|
||||
return RedirectResponse(url=f"/billing/{invoice.id}?year_locked=1", status_code=303)
|
||||
issue_invoice(db, invoice, user_id=user.id)
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/billing/{invoice.id}", status_code=303)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/{invoice_id}")
|
||||
def invoice_detail(request: Request, invoice_id: int):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_billing_user(request, db, "billing.view")
|
||||
if response:
|
||||
return response
|
||||
invoice = get_invoice(db, invoice_id=invoice_id, tenant_id=_active_tenant_id(request, user), partner_id=_locked_partner_id(db, user), financial_year=_active_financial_year(request))
|
||||
if not invoice:
|
||||
return _redirect_denied()
|
||||
invoice_ctx = build_invoice_print_context(db, invoice)
|
||||
return _render(request, "modules/billing/templates/billing/detail.html", db, user, title=f"Invoice {invoice.invoice_no}", invoice=invoice, invoice_ctx=invoice_ctx, can_record_payment=_has_perm(db, user, "billing_payment.record") or _has_perm(db, user, "billing.create"))
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,4 @@
|
||||
from .api import router as api_router
|
||||
from .ui import router as ui_router
|
||||
|
||||
__all__ = ["api_router", "ui_router"]
|
||||
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClientAccessScope:
|
||||
tenant_id: int
|
||||
branch_id: int | None
|
||||
allow_cross_branch: bool
|
||||
allow_cross_tenant: bool
|
||||
allow_all_clients: bool
|
||||
own_only: bool
|
||||
locked_partner_id: int | None
|
||||
can_assign_partner: bool
|
||||
can_change_branch: bool
|
||||
can_change_tenant: bool
|
||||
|
||||
|
||||
def build_scope(request, user, permission_checker):
|
||||
active_tenant_id = int(request.session.get("active_tenant_id") or user.tenant_id)
|
||||
active_branch_value = request.session.get("active_branch_id")
|
||||
active_branch_id = None if active_branch_value in (None, "", 0, "0") else int(active_branch_value)
|
||||
|
||||
allow_cross_branch = permission_checker("clients.cross_branch")
|
||||
allow_cross_tenant = permission_checker("clients.cross_tenant")
|
||||
can_assign_partner = permission_checker("clients.assign_partner")
|
||||
own_only = permission_checker("clients.view.own_only")
|
||||
|
||||
allow_all_clients = bool((allow_cross_tenant and allow_cross_branch and not own_only) or permission_checker("clients.view.all"))
|
||||
|
||||
locked_partner_id = user.id if own_only else None
|
||||
can_change_branch = allow_cross_branch
|
||||
can_change_tenant = allow_cross_tenant
|
||||
|
||||
return ClientAccessScope(
|
||||
tenant_id=active_tenant_id,
|
||||
branch_id=active_branch_id or getattr(user, "branch_id", None),
|
||||
allow_cross_branch=allow_cross_branch,
|
||||
allow_cross_tenant=allow_cross_tenant,
|
||||
allow_all_clients=allow_all_clients,
|
||||
own_only=own_only,
|
||||
locked_partner_id=locked_partner_id,
|
||||
can_assign_partner=can_assign_partner,
|
||||
can_change_branch=can_change_branch,
|
||||
can_change_tenant=can_change_tenant,
|
||||
)
|
||||
|
||||
|
||||
def effective_partner_id(row: dict):
|
||||
return row.get("assoc_partner_user_id") or row.get("partner_id")
|
||||
|
||||
|
||||
def effective_tenant_id(row: dict):
|
||||
return row.get("assoc_firm_tenant_id") or row.get("tenant_id")
|
||||
|
||||
|
||||
def effective_branch_id(row: dict):
|
||||
return row.get("branch_id")
|
||||
|
||||
|
||||
def can_view_client_row(scope: ClientAccessScope, row: dict, *, user_id: int) -> bool:
|
||||
if scope.allow_all_clients:
|
||||
return True
|
||||
|
||||
if not scope.allow_cross_tenant and effective_tenant_id(row) != scope.tenant_id:
|
||||
return False
|
||||
|
||||
branch_id = effective_branch_id(row)
|
||||
if not scope.allow_cross_branch and scope.branch_id and branch_id and branch_id != scope.branch_id:
|
||||
return False
|
||||
|
||||
if scope.own_only and scope.locked_partner_id and effective_partner_id(row) != scope.locked_partner_id:
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,178 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db.deps import get_common_db
|
||||
from app.core.security.session_auth import require_login
|
||||
from app.modules.clients.access import ClientAccessScope
|
||||
from app.modules.clients.schemas import ClientAuditLogOut, ClientFilterOptions, ClientListResponse, ClientOut, ClientUpdate, ClientCreate
|
||||
from app.modules.clients.service import (
|
||||
activate_client_service,
|
||||
archive_client_service,
|
||||
create_client_service,
|
||||
deactivate_client_service,
|
||||
export_clients_csv,
|
||||
get_client_or_404,
|
||||
get_filter_options,
|
||||
list_client_audit_logs,
|
||||
list_clients_payload,
|
||||
restore_client_service,
|
||||
update_client_service,
|
||||
)
|
||||
from app.modules.core.rbac.permission_guard import require_permission
|
||||
|
||||
router = APIRouter(prefix="/api/v1/clients", tags=["clients-api"])
|
||||
|
||||
def _api_scope_from_user(db, user):
|
||||
def has(code: str):
|
||||
try:
|
||||
require_permission(db, user, code)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
own_only = has("clients.view.own_only") or not has("clients.assign_partner")
|
||||
return ClientAccessScope(
|
||||
tenant_id=user.tenant_id,
|
||||
branch_id=user.branch_id,
|
||||
allow_cross_branch=has("clients.cross_branch"),
|
||||
allow_cross_tenant=has("clients.cross_tenant"),
|
||||
own_only=own_only,
|
||||
locked_partner_id=user.id if own_only else None,
|
||||
can_assign_partner=has("clients.assign_partner"),
|
||||
can_change_branch=has("clients.cross_branch"),
|
||||
can_change_tenant=has("clients.cross_tenant"),
|
||||
)
|
||||
|
||||
@router.get("/filters", response_model=ClientFilterOptions)
|
||||
def api_client_filters():
|
||||
return get_filter_options()
|
||||
|
||||
@router.get("", response_model=ClientListResponse)
|
||||
def api_list_clients(
|
||||
q: str = Query("", max_length=100),
|
||||
status: str = Query("", max_length=20),
|
||||
client_type: str = Query("", max_length=100),
|
||||
partner_id: int | None = Query(None),
|
||||
include_archived: bool = Query(False),
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(10, ge=1, le=100),
|
||||
sort_by: str = Query("client_name"),
|
||||
sort_order: str = Query("asc"),
|
||||
db: Session = Depends(get_common_db),
|
||||
user=Depends(require_login),
|
||||
):
|
||||
require_permission(db, user, "clients.view")
|
||||
scope = _api_scope_from_user(db, user)
|
||||
if scope.own_only:
|
||||
partner_id = scope.locked_partner_id
|
||||
return list_clients_payload(
|
||||
db,
|
||||
tenant_id=scope.tenant_id,
|
||||
branch_id=scope.branch_id,
|
||||
allow_cross_branch=scope.allow_cross_branch,
|
||||
partner_id=partner_id,
|
||||
q=q,
|
||||
status=status,
|
||||
client_type=client_type,
|
||||
include_archived=include_archived,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
|
||||
@router.get("/export")
|
||||
def api_export_clients(
|
||||
q: str = Query("", max_length=100),
|
||||
status: str = Query("", max_length=20),
|
||||
client_type: str = Query("", max_length=100),
|
||||
partner_id: int | None = Query(None),
|
||||
include_archived: bool = Query(False),
|
||||
sort_by: str = Query("client_name"),
|
||||
sort_order: str = Query("asc"),
|
||||
db: Session = Depends(get_common_db),
|
||||
user=Depends(require_login),
|
||||
):
|
||||
require_permission(db, user, "clients.export")
|
||||
scope = _api_scope_from_user(db, user)
|
||||
if scope.own_only:
|
||||
partner_id = scope.locked_partner_id
|
||||
payload = list_clients_payload(
|
||||
db,
|
||||
tenant_id=scope.tenant_id,
|
||||
branch_id=scope.branch_id,
|
||||
allow_cross_branch=scope.allow_cross_branch,
|
||||
partner_id=partner_id,
|
||||
q=q,
|
||||
status=status,
|
||||
client_type=client_type,
|
||||
include_archived=include_archived,
|
||||
page=1,
|
||||
per_page=10000,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
csv_text = export_clients_csv(payload)
|
||||
return Response(content=csv_text, media_type="text/csv", headers={"Content-Disposition": "attachment; filename=clients_export.csv"})
|
||||
|
||||
@router.get("/{client_id}", response_model=ClientOut)
|
||||
def api_get_client(client_id: int, db: Session = Depends(get_common_db), user=Depends(require_login)):
|
||||
require_permission(db, user, "clients.view")
|
||||
scope = _api_scope_from_user(db, user)
|
||||
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch)
|
||||
if scope.own_only and row.partner_id != scope.locked_partner_id:
|
||||
raise HTTPException(status_code=404, detail="Client not found.")
|
||||
return row
|
||||
|
||||
@router.post("", response_model=ClientOut, status_code=201)
|
||||
def api_create_client(data: ClientCreate, db: Session = Depends(get_common_db), user=Depends(require_login)):
|
||||
require_permission(db, user, "clients.create")
|
||||
scope = _api_scope_from_user(db, user)
|
||||
return create_client_service(db, data=data, actor_user_id=user.id, scope=scope)
|
||||
|
||||
@router.put("/{client_id}", response_model=ClientOut)
|
||||
def api_update_client(client_id: int, data: ClientUpdate, db: Session = Depends(get_common_db), user=Depends(require_login)):
|
||||
require_permission(db, user, "clients.edit")
|
||||
scope = _api_scope_from_user(db, user)
|
||||
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch)
|
||||
if scope.own_only and row.partner_id != scope.locked_partner_id:
|
||||
raise HTTPException(status_code=404, detail="Client not found.")
|
||||
return update_client_service(db, row=row, data=data, actor_user_id=user.id, scope=scope)
|
||||
|
||||
@router.post("/{client_id}/deactivate", response_model=ClientOut)
|
||||
def api_deactivate_client(client_id: int, db: Session = Depends(get_common_db), user=Depends(require_login)):
|
||||
require_permission(db, user, "clients.deactivate")
|
||||
scope = _api_scope_from_user(db, user)
|
||||
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch)
|
||||
return deactivate_client_service(db, row=row, actor_user_id=user.id)
|
||||
|
||||
@router.post("/{client_id}/activate", response_model=ClientOut)
|
||||
def api_activate_client(client_id: int, db: Session = Depends(get_common_db), user=Depends(require_login)):
|
||||
require_permission(db, user, "clients.activate")
|
||||
scope = _api_scope_from_user(db, user)
|
||||
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch)
|
||||
return activate_client_service(db, row=row, actor_user_id=user.id)
|
||||
|
||||
@router.post("/{client_id}/archive", response_model=ClientOut)
|
||||
def api_archive_client(client_id: int, db: Session = Depends(get_common_db), user=Depends(require_login)):
|
||||
require_permission(db, user, "clients.archive")
|
||||
scope = _api_scope_from_user(db, user)
|
||||
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch)
|
||||
return archive_client_service(db, row=row, actor_user_id=user.id)
|
||||
|
||||
@router.post("/{client_id}/restore", response_model=ClientOut)
|
||||
def api_restore_client(client_id: int, db: Session = Depends(get_common_db), user=Depends(require_login)):
|
||||
require_permission(db, user, "clients.restore")
|
||||
scope = _api_scope_from_user(db, user)
|
||||
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch)
|
||||
return restore_client_service(db, row=row, actor_user_id=user.id)
|
||||
|
||||
@router.get("/{client_id}/audit-logs", response_model=list[ClientAuditLogOut])
|
||||
def api_client_audit_logs(client_id: int, limit: int = Query(50, ge=1, le=200), db: Session = Depends(get_common_db), user=Depends(require_login)):
|
||||
require_permission(db, user, "clients.audit_log.view")
|
||||
scope = _api_scope_from_user(db, user)
|
||||
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch)
|
||||
return list_client_audit_logs(db, row=row, limit=limit)
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.clients.association_models import ClientAssociation
|
||||
|
||||
|
||||
def get_active_association(db: Session, client_id: int):
|
||||
stmt = (
|
||||
select(ClientAssociation)
|
||||
.where(ClientAssociation.client_id == client_id)
|
||||
.limit(1)
|
||||
)
|
||||
return db.execute(stmt).scalar_one_or_none()
|
||||
|
||||
|
||||
def ensure_active_association(db: Session, client_id: int):
|
||||
row = get_active_association(db, client_id)
|
||||
if row:
|
||||
return row
|
||||
|
||||
row = ClientAssociation(
|
||||
client_id=client_id,
|
||||
association_type="firm",
|
||||
created_source="system_admin",
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
def update_association_fields(db: Session, client_id: int, **fields):
|
||||
row = ensure_active_association(db, client_id)
|
||||
for key, value in fields.items():
|
||||
if hasattr(row, key):
|
||||
setattr(row, key, value)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
|
||||
class ClientAssociation(CommonBase):
|
||||
__tablename__ = "client_associations"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
client_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||
association_type: Mapped[str] = mapped_column(String(50), nullable=False, default="firm")
|
||||
firm_tenant_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
consultant_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
partner_user_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
created_source: Mapped[str] = mapped_column(String(50), nullable=False, default="system_admin")
|
||||
@@ -0,0 +1,27 @@
|
||||
|
||||
def build_client_association(current_user, role_name, selected_partner_id=None):
|
||||
role = (role_name or '').lower()
|
||||
if role in ('system admin', 'firm admin'):
|
||||
return {
|
||||
'association_type': 'firm',
|
||||
'firm_tenant_id': getattr(current_user, 'tenant_id', None),
|
||||
'partner_user_id': selected_partner_id,
|
||||
'created_source': 'firm_admin',
|
||||
}
|
||||
if role == 'partner':
|
||||
return {
|
||||
'association_type': 'firm',
|
||||
'firm_tenant_id': getattr(current_user, 'tenant_id', None),
|
||||
'partner_user_id': current_user.id,
|
||||
'created_source': 'partner',
|
||||
}
|
||||
if role == 'consultant':
|
||||
return {
|
||||
'association_type': 'consultant',
|
||||
'consultant_id': current_user.id,
|
||||
'created_source': 'consultant',
|
||||
}
|
||||
return {
|
||||
'association_type': 'self_service_unassigned',
|
||||
'created_source': 'self_service',
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.core.iam.profile_service import profile_photo_url
|
||||
from app.modules.core.tenancy.models import Branch, Tenant
|
||||
|
||||
|
||||
def _initials(name: str | None, email: str | None = None) -> str:
|
||||
source = (name or email or "Auditor").strip()
|
||||
parts = [p for p in source.replace("@", " ").replace(".", " ").split() if p]
|
||||
if not parts:
|
||||
return "AU"
|
||||
if len(parts) == 1:
|
||||
return parts[0][:2].upper()
|
||||
return (parts[0][:1] + parts[-1][:1]).upper()
|
||||
|
||||
|
||||
def _contact_card_from_user(
|
||||
*,
|
||||
user: User | None,
|
||||
tenant_name: str | None,
|
||||
branch_name: str | None,
|
||||
source_label: str,
|
||||
) -> dict:
|
||||
if not user:
|
||||
return {
|
||||
"available": False,
|
||||
"name": "Firm team",
|
||||
"designation": "Audit support team",
|
||||
"qualification": None,
|
||||
"email": None,
|
||||
"mobile": None,
|
||||
"photo_url": None,
|
||||
"initials": "FT",
|
||||
"firm_name": tenant_name,
|
||||
"branch_name": branch_name,
|
||||
"source_label": source_label,
|
||||
}
|
||||
|
||||
name = getattr(user, "full_name", None) or getattr(user, "email", None) or "Firm team"
|
||||
designation = getattr(user, "designation", None) or source_label or "Auditor"
|
||||
return {
|
||||
"available": True,
|
||||
"name": name,
|
||||
"designation": designation,
|
||||
"qualification": getattr(user, "qualification", None),
|
||||
"email": getattr(user, "email", None),
|
||||
"mobile": getattr(user, "mobile", None),
|
||||
"photo_url": profile_photo_url(user),
|
||||
"initials": _initials(name, getattr(user, "email", None)),
|
||||
"firm_name": tenant_name,
|
||||
"branch_name": branch_name,
|
||||
"source_label": source_label,
|
||||
}
|
||||
|
||||
|
||||
def build_client_auditor_card(db: Session, client_row: dict | None) -> dict:
|
||||
"""Return a client-facing contact card for the assigned auditor/partner.
|
||||
|
||||
Priority:
|
||||
1. Client assigned partner (`partner_id`).
|
||||
2. Default review partner, if no assigned partner exists.
|
||||
3. Firm team fallback using tenant/branch names.
|
||||
|
||||
This reuses Phase 7Q.3 user profile fields and does not create new tables.
|
||||
"""
|
||||
if not client_row:
|
||||
return _contact_card_from_user(
|
||||
user=None,
|
||||
tenant_name=None,
|
||||
branch_name=None,
|
||||
source_label="Firm team",
|
||||
)
|
||||
|
||||
tenant_name = client_row.get("tenant_name")
|
||||
branch_name = client_row.get("branch_name")
|
||||
tenant_id = client_row.get("tenant_id")
|
||||
branch_id = client_row.get("branch_id")
|
||||
|
||||
partner_id = client_row.get("partner_id") or client_row.get("assoc_partner_user_id")
|
||||
review_partner_id = client_row.get("default_review_partner_user_id")
|
||||
|
||||
target_user_id = partner_id or review_partner_id
|
||||
source_label = "Assigned Auditor" if partner_id else "Review Partner"
|
||||
|
||||
if not target_user_id:
|
||||
return _contact_card_from_user(
|
||||
user=None,
|
||||
tenant_name=tenant_name,
|
||||
branch_name=branch_name,
|
||||
source_label="Firm team",
|
||||
)
|
||||
|
||||
stmt = (
|
||||
select(User, Tenant.name.label("tenant_name"), Branch.name.label("branch_name"))
|
||||
.join(Tenant, Tenant.id == User.tenant_id, isouter=True)
|
||||
.join(Branch, Branch.id == User.branch_id, isouter=True)
|
||||
.where(User.id == int(target_user_id), User.deleted_at.is_(None))
|
||||
)
|
||||
if tenant_id:
|
||||
stmt = stmt.where(User.tenant_id == int(tenant_id))
|
||||
result = db.execute(stmt).first()
|
||||
if not result:
|
||||
return _contact_card_from_user(
|
||||
user=None,
|
||||
tenant_name=tenant_name,
|
||||
branch_name=branch_name,
|
||||
source_label="Firm team",
|
||||
)
|
||||
|
||||
user, resolved_tenant_name, resolved_branch_name = result
|
||||
return _contact_card_from_user(
|
||||
user=user,
|
||||
tenant_name=resolved_tenant_name or tenant_name,
|
||||
branch_name=resolved_branch_name or branch_name,
|
||||
source_label=source_label,
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
ENGAGEMENT_MODES = [
|
||||
"internal_managed",
|
||||
"self_tracked",
|
||||
"hybrid",
|
||||
]
|
||||
|
||||
ASSOCIATION_TYPES = [
|
||||
"firm",
|
||||
"consultant",
|
||||
"firm_consultant",
|
||||
"self_service_unassigned",
|
||||
]
|
||||
|
||||
CLIENT_TYPES = [
|
||||
"Proprietorship",
|
||||
"Partnership",
|
||||
"LLP",
|
||||
"Private Limited Company",
|
||||
"Public Limited Company",
|
||||
"Trust",
|
||||
"Society",
|
||||
"AOP",
|
||||
"HUF",
|
||||
"NRI",
|
||||
"Other",
|
||||
]
|
||||
|
||||
CLIENT_STATUS = ["active", "inactive", "archived"]
|
||||
|
||||
CLIENT_CATEGORY_OPTIONS = [
|
||||
"Audit", "Tax", "GST", "Compliance", "Payroll", "Advisory", "Litigation", "Internal", "Other",
|
||||
]
|
||||
|
||||
RISK_CATEGORIES = ["low", "medium", "high", "critical"]
|
||||
|
||||
CLIENT_SORT_FIELDS = {
|
||||
"client_code": "client_code",
|
||||
"client_name": "client_name",
|
||||
"client_type": "client_type",
|
||||
"status": "status",
|
||||
"created_at_utc": "created_at_utc",
|
||||
"updated_at_utc": "updated_at_utc",
|
||||
"onboarding_date": "onboarding_date",
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class ClientListFilters:
|
||||
q: str = ""
|
||||
status: str = ""
|
||||
client_type: str = ""
|
||||
partner_id: int | None = None
|
||||
include_archived: bool = False
|
||||
page: int = 1
|
||||
per_page: int = 10
|
||||
sort_by: str = "client_name"
|
||||
sort_order: str = "asc"
|
||||
|
||||
@classmethod
|
||||
def from_params(cls, **kwargs):
|
||||
partner_id = kwargs.get("partner_id")
|
||||
if partner_id in ("", None):
|
||||
partner_id = None
|
||||
elif not isinstance(partner_id, int):
|
||||
partner_id = int(partner_id)
|
||||
include_archived = kwargs.get("include_archived", False)
|
||||
if isinstance(include_archived, str):
|
||||
include_archived = include_archived.lower() in ("1", "true", "yes", "on")
|
||||
return cls(
|
||||
q=kwargs.get("q", "") or "",
|
||||
status=kwargs.get("status", "") or "",
|
||||
client_type=kwargs.get("client_type", "") or "",
|
||||
partner_id=partner_id,
|
||||
include_archived=include_archived,
|
||||
page=max(int(kwargs.get("page", 1) or 1), 1),
|
||||
per_page=min(max(int(kwargs.get("per_page", 10) or 10), 1), 100),
|
||||
sort_by=kwargs.get("sort_by", "client_name") or "client_name",
|
||||
sort_order=kwargs.get("sort_order", "asc") or "asc",
|
||||
)
|
||||
@@ -0,0 +1,281 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.clients import repository
|
||||
from app.modules.clients.schemas import ClientCreate
|
||||
from app.modules.clients.service import create_client_service
|
||||
|
||||
TEMPLATE_COLUMNS = [
|
||||
"uploader_user_id",
|
||||
"firm_tenant_id",
|
||||
"partner_user_id",
|
||||
"branch_id",
|
||||
"client_code",
|
||||
"client_name",
|
||||
"client_type",
|
||||
"engagement_mode",
|
||||
"email",
|
||||
"portal_password",
|
||||
"portal_password_confirm",
|
||||
"mobile",
|
||||
"pan",
|
||||
"gstin",
|
||||
"tan",
|
||||
"cin_llpin",
|
||||
"msme_no",
|
||||
"iec_code",
|
||||
"contact_person_name",
|
||||
"contact_person_designation",
|
||||
"alternate_mobile",
|
||||
"alternate_email",
|
||||
"address_line_1",
|
||||
"address_line_2",
|
||||
"city",
|
||||
"state",
|
||||
"pincode",
|
||||
"country",
|
||||
"client_category",
|
||||
"risk_category",
|
||||
"onboarding_date",
|
||||
"closing_date",
|
||||
"notes",
|
||||
"status",
|
||||
"gst_applicable",
|
||||
"income_tax_applicable",
|
||||
"tds_applicable",
|
||||
"roc_applicable",
|
||||
"audit_applicable",
|
||||
"pf_applicable",
|
||||
"esi_applicable",
|
||||
"professional_tax_applicable",
|
||||
"payroll_applicable",
|
||||
"msme_applicable",
|
||||
"import_export_applicable",
|
||||
]
|
||||
|
||||
BOOL_FIELDS = {
|
||||
"gst_applicable", "income_tax_applicable", "tds_applicable", "roc_applicable",
|
||||
"audit_applicable", "pf_applicable", "esi_applicable", "professional_tax_applicable",
|
||||
"payroll_applicable", "msme_applicable", "import_export_applicable",
|
||||
}
|
||||
|
||||
@dataclass
|
||||
class ImportPreview:
|
||||
valid_rows: list[dict]
|
||||
errors: list[dict]
|
||||
total_rows: int
|
||||
|
||||
|
||||
def _clean(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
txt = str(value).strip()
|
||||
return txt or None
|
||||
|
||||
|
||||
def _to_bool(value: Any) -> bool:
|
||||
txt = str(value or '').strip().lower()
|
||||
return txt in {'1','true','yes','y','on'}
|
||||
|
||||
|
||||
def build_client_import_template_bytes(*, current_user, tenant_id: int, partner_id: int | None) -> bytes:
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = 'clients_import'
|
||||
ws.append(TEMPLATE_COLUMNS)
|
||||
sample = [
|
||||
current_user.id, tenant_id, partner_id or current_user.id, getattr(current_user, 'branch_id', '') or '',
|
||||
'CLT-001', 'Sample Client', 'Other', 'internal_managed', 'client@example.com', 'ChangeMe@123', 'ChangeMe@123',
|
||||
'9876543210', '', '', '', '', '', '', 'Client Contact', 'Proprietor', '', '', 'Address line 1', '', 'Chennai', 'Tamil Nadu', '600001', 'India', '', '', '', '', '', 'active',
|
||||
'yes', 'yes', 'no', 'no', 'no', 'no', 'no', 'no', 'no', 'no', 'no'
|
||||
]
|
||||
ws.append(sample)
|
||||
ref = wb.create_sheet('instructions')
|
||||
ref.append(['Field', 'Notes'])
|
||||
ref.append(['uploader_user_id', 'Must match the logged-in uploader user id exactly.'])
|
||||
ref.append(['firm_tenant_id', 'Must match the active firm/tenant context of the upload.'])
|
||||
ref.append(['partner_user_id', 'Must be an active Partner user mapped to the same firm.'])
|
||||
ref.append(['branch_id', 'Optional. If blank, uploader branch or partner branch will be used.'])
|
||||
ref.append(['email', 'Used as the client frontend login email.'])
|
||||
ref.append(['portal_password', 'Minimum 8 characters.'])
|
||||
ref.append(['portal_password_confirm', 'Must match portal_password.'])
|
||||
bio = io.BytesIO()
|
||||
wb.save(bio)
|
||||
return bio.getvalue()
|
||||
|
||||
|
||||
def _row_dict(ws, row_idx: int) -> dict[str, Any]:
|
||||
headers = [str(c.value).strip() if c.value is not None else '' for c in ws[1]]
|
||||
values = [c.value for c in ws[row_idx]]
|
||||
return {headers[i]: values[i] if i < len(values) else None for i in range(len(headers)) if headers[i]}
|
||||
|
||||
|
||||
def build_preview(db: Session, *, current_user, scope, role_names: set[str], upload_bytes: bytes) -> ImportPreview:
|
||||
wb = load_workbook(io.BytesIO(upload_bytes), data_only=True)
|
||||
ws = wb[wb.sheetnames[0]]
|
||||
headers = [str(c.value).strip() if c.value is not None else '' for c in ws[1]]
|
||||
missing = [c for c in TEMPLATE_COLUMNS if c not in headers]
|
||||
if missing:
|
||||
return ImportPreview(valid_rows=[], errors=[{'row_number': 1, 'messages': [f'Missing required columns: {", ".join(missing)}']}], total_rows=0)
|
||||
|
||||
valid_rows = []
|
||||
errors = []
|
||||
active_tenant_id = int(scope.tenant_id)
|
||||
active_branch_id = int(scope.branch_id or getattr(current_user, 'branch_id', 0) or 0)
|
||||
|
||||
for row_idx in range(2, ws.max_row + 1):
|
||||
raw = _row_dict(ws, row_idx)
|
||||
if not any(v not in (None, '') for v in raw.values()):
|
||||
continue
|
||||
msgs: list[str] = []
|
||||
cleaned = {k: (_to_bool(v) if k in BOOL_FIELDS else _clean(v)) for k, v in raw.items()}
|
||||
|
||||
try:
|
||||
uploader_user_id = int(cleaned.get('uploader_user_id') or 0)
|
||||
except Exception:
|
||||
uploader_user_id = 0
|
||||
try:
|
||||
firm_tenant_id = int(cleaned.get('firm_tenant_id') or 0)
|
||||
except Exception:
|
||||
firm_tenant_id = 0
|
||||
try:
|
||||
partner_user_id = int(cleaned.get('partner_user_id') or 0)
|
||||
except Exception:
|
||||
partner_user_id = 0
|
||||
try:
|
||||
branch_id = int(cleaned.get('branch_id') or 0)
|
||||
except Exception:
|
||||
branch_id = 0
|
||||
|
||||
if uploader_user_id != int(current_user.id):
|
||||
msgs.append('uploader_user_id must match the currently logged-in user id.')
|
||||
if firm_tenant_id != active_tenant_id:
|
||||
msgs.append('firm_tenant_id must match the active firm/tenant context of the uploader.')
|
||||
partner = repository.get_partner_for_tenant(db, partner_user_id=partner_user_id, tenant_id=firm_tenant_id) if partner_user_id else None
|
||||
if not partner:
|
||||
msgs.append('partner_user_id must belong to an active Partner user in the same firm.')
|
||||
if 'partner' in role_names and partner_user_id != int(current_user.id):
|
||||
msgs.append('Partner uploader can import only for their own partner_user_id.')
|
||||
|
||||
if branch_id:
|
||||
branch = repository.get_branch(db, branch_id)
|
||||
if not branch or int(branch.tenant_id) != firm_tenant_id:
|
||||
msgs.append('branch_id must belong to the same firm/tenant.')
|
||||
else:
|
||||
branch_id = int(getattr(partner, 'branch_id', None) or active_branch_id or getattr(current_user, 'branch_id', 0) or 0)
|
||||
if not branch_id:
|
||||
msgs.append('branch_id is required when uploader and partner have no branch mapped.')
|
||||
|
||||
payload = {
|
||||
'tenant_id': firm_tenant_id,
|
||||
'branch_id': branch_id,
|
||||
'partner_id': partner_user_id or None,
|
||||
'engagement_mode': cleaned.get('engagement_mode') or 'internal_managed',
|
||||
'client_code': cleaned.get('client_code') or '',
|
||||
'client_name': cleaned.get('client_name') or '',
|
||||
'trade_name': None,
|
||||
'client_type': cleaned.get('client_type') or 'Other',
|
||||
'pan': cleaned.get('pan'),
|
||||
'gstin': cleaned.get('gstin'),
|
||||
'tan': cleaned.get('tan'),
|
||||
'cin_llpin': cleaned.get('cin_llpin'),
|
||||
'msme_no': cleaned.get('msme_no'),
|
||||
'iec_code': cleaned.get('iec_code'),
|
||||
'contact_person_name': cleaned.get('contact_person_name'),
|
||||
'contact_person_designation': cleaned.get('contact_person_designation'),
|
||||
'mobile': cleaned.get('mobile'),
|
||||
'alternate_mobile': cleaned.get('alternate_mobile'),
|
||||
'email': cleaned.get('email'),
|
||||
'alternate_email': cleaned.get('alternate_email'),
|
||||
'address_line_1': cleaned.get('address_line_1'),
|
||||
'address_line_2': cleaned.get('address_line_2'),
|
||||
'city': cleaned.get('city'),
|
||||
'state': cleaned.get('state'),
|
||||
'pincode': cleaned.get('pincode'),
|
||||
'country': cleaned.get('country') or 'India',
|
||||
'status': cleaned.get('status') or 'active',
|
||||
'client_category': cleaned.get('client_category'),
|
||||
'risk_category': cleaned.get('risk_category'),
|
||||
'onboarding_date': cleaned.get('onboarding_date'),
|
||||
'closing_date': cleaned.get('closing_date'),
|
||||
'notes': cleaned.get('notes'),
|
||||
'gst_applicable': cleaned.get('gst_applicable') or False,
|
||||
'income_tax_applicable': cleaned.get('income_tax_applicable') or False,
|
||||
'tds_applicable': cleaned.get('tds_applicable') or False,
|
||||
'roc_applicable': cleaned.get('roc_applicable') or False,
|
||||
'audit_applicable': cleaned.get('audit_applicable') or False,
|
||||
'pf_applicable': cleaned.get('pf_applicable') or False,
|
||||
'esi_applicable': cleaned.get('esi_applicable') or False,
|
||||
'professional_tax_applicable': cleaned.get('professional_tax_applicable') or False,
|
||||
'payroll_applicable': cleaned.get('payroll_applicable') or False,
|
||||
'msme_applicable': cleaned.get('msme_applicable') or False,
|
||||
'import_export_applicable': cleaned.get('import_export_applicable') or False,
|
||||
}
|
||||
|
||||
try:
|
||||
ClientCreate(**payload)
|
||||
except Exception as exc:
|
||||
msgs.append(str(exc))
|
||||
|
||||
if not cleaned.get('portal_password'):
|
||||
msgs.append('portal_password is required for imported clients.')
|
||||
if cleaned.get('portal_password') != cleaned.get('portal_password_confirm'):
|
||||
msgs.append('portal_password and portal_password_confirm must match.')
|
||||
|
||||
# intra-file duplicate client codes
|
||||
if any(v.get('client_code') == payload['client_code'] and v.get('tenant_id') == firm_tenant_id for v in valid_rows):
|
||||
msgs.append('Duplicate client_code found within the same upload file.')
|
||||
|
||||
if msgs:
|
||||
errors.append({'row_number': row_idx, 'messages': msgs, 'row': cleaned})
|
||||
continue
|
||||
|
||||
valid_rows.append({
|
||||
'row_number': row_idx,
|
||||
'tenant_id': firm_tenant_id,
|
||||
'branch_id': branch_id,
|
||||
'partner_id': partner_user_id,
|
||||
'client_payload': payload,
|
||||
'portal_password': cleaned.get('portal_password'),
|
||||
'portal_password_confirm': cleaned.get('portal_password_confirm'),
|
||||
})
|
||||
|
||||
return ImportPreview(valid_rows=valid_rows, errors=errors, total_rows=len(valid_rows) + len(errors))
|
||||
|
||||
|
||||
def serialize_preview_rows(valid_rows: list[dict]) -> str:
|
||||
return json.dumps(valid_rows, default=str)
|
||||
|
||||
|
||||
def deserialize_preview_rows(raw: str) -> list[dict]:
|
||||
rows = json.loads(raw or '[]')
|
||||
return rows if isinstance(rows, list) else []
|
||||
|
||||
|
||||
def commit_import(db: Session, *, current_user, scope, current_user_roles: list[str], preview_rows: list[dict]) -> dict:
|
||||
created = []
|
||||
failures = []
|
||||
for item in preview_rows:
|
||||
try:
|
||||
data = ClientCreate(**item['client_payload'])
|
||||
row = create_client_service(
|
||||
db,
|
||||
data=data,
|
||||
actor_user_id=current_user.id,
|
||||
scope=scope,
|
||||
current_user_roles=current_user_roles,
|
||||
portal_password=item.get('portal_password'),
|
||||
portal_password_confirm=item.get('portal_password_confirm'),
|
||||
)
|
||||
created.append({'id': row.id, 'client_code': row.client_code, 'client_name': row.client_name})
|
||||
except Exception as exc:
|
||||
failures.append({'row_number': item.get('row_number'), 'message': str(getattr(exc, 'detail', exc))})
|
||||
return {'created': created, 'failures': failures}
|
||||
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, JSON, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
|
||||
class Client(CommonBase):
|
||||
__tablename__ = "clients"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "client_code", name="uq_clients_tenant_code"),
|
||||
UniqueConstraint("tenant_id", "pan", name="uq_clients_tenant_pan"),
|
||||
UniqueConstraint("tenant_id", "gstin", name="uq_clients_tenant_gstin"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), nullable=False, index=True)
|
||||
branch_id: Mapped[int] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True)
|
||||
partner_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
default_review_partner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
engagement_mode: Mapped[str] = mapped_column(String(30), nullable=False, default="internal_managed", index=True)
|
||||
client_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
client_name: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
|
||||
trade_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
client_type: Mapped[str] = mapped_column(String(100), nullable=False, default="Other")
|
||||
pan: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
gstin: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
tan: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
cin_llpin: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
msme_no: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
iec_code: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
contact_person_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
contact_person_designation: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
mobile: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
alternate_mobile: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
alternate_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
address_line_1: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
address_line_2: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
city: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
state: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
pincode: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
country: Mapped[str | None] = mapped_column(String(100), nullable=True, default="India")
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active", index=True)
|
||||
client_category: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
risk_category: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
onboarding_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
closing_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
gst_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
income_tax_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
tds_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
roc_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
audit_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
pf_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
esi_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
professional_tax_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
payroll_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
msme_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
import_export_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
is_archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
archived_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
portal_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
|
||||
|
||||
class ClientAuditLog(CommonBase):
|
||||
__tablename__ = "client_audit_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id"), nullable=False, index=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), nullable=False, index=True)
|
||||
branch_id: Mapped[int] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True)
|
||||
actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
action: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
summary: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
payload_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
@@ -0,0 +1,15 @@
|
||||
CLIENT_PERMISSION_CODES = {
|
||||
"view": "clients.view",
|
||||
"create": "clients.create",
|
||||
"edit": "clients.edit",
|
||||
"deactivate": "clients.deactivate",
|
||||
"activate": "clients.activate",
|
||||
"archive": "clients.archive",
|
||||
"restore": "clients.restore",
|
||||
"assign_partner": "clients.assign_partner",
|
||||
"cross_branch": "clients.cross_branch",
|
||||
"cross_tenant": "clients.cross_tenant",
|
||||
"export": "clients.export",
|
||||
"audit_log_view": "clients.audit_log.view",
|
||||
"view_own_only": "clients.view.own_only",
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.modules.documents.models import EngagementDocument, PermanentClientDocument
|
||||
from app.modules.services.models import (
|
||||
ClientServiceSubscription,
|
||||
ClientServiceTaskInstance,
|
||||
ServiceTaskComment,
|
||||
)
|
||||
|
||||
OPEN_TASK_STATUSES = {"pending", "in_progress", "blocked", "ready_for_review", "rework"}
|
||||
CLOSED_TASK_STATUSES = {"completed", "approved", "closed", "not_applicable"}
|
||||
|
||||
|
||||
def _client_id(client_row: dict[str, Any]) -> int:
|
||||
return int(client_row.get("id") or 0)
|
||||
|
||||
|
||||
def _tenant_id(client_row: dict[str, Any]) -> int:
|
||||
return int(client_row.get("tenant_id") or 0)
|
||||
|
||||
|
||||
def list_client_engagements(db: Session, client_row: dict[str, Any], *, limit: int = 200, financial_year: str | None = None) -> list[ClientServiceSubscription]:
|
||||
"""Return engagements/subscriptions visible to the logged-in client."""
|
||||
query = (
|
||||
select(ClientServiceSubscription)
|
||||
.options(
|
||||
selectinload(ClientServiceSubscription.catalogue),
|
||||
selectinload(ClientServiceSubscription.assigned_partner),
|
||||
selectinload(ClientServiceSubscription.assigned_manager),
|
||||
selectinload(ClientServiceSubscription.assigned_staff),
|
||||
)
|
||||
.where(
|
||||
ClientServiceSubscription.tenant_id == _tenant_id(client_row),
|
||||
ClientServiceSubscription.client_id == _client_id(client_row),
|
||||
ClientServiceSubscription.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
if financial_year:
|
||||
query = query.where(ClientServiceSubscription.financial_year == financial_year.strip())
|
||||
rows = db.execute(
|
||||
query.order_by(
|
||||
ClientServiceSubscription.current_due_date.asc().nulls_last(),
|
||||
ClientServiceSubscription.updated_at_utc.desc(),
|
||||
)
|
||||
.limit(max(1, min(int(limit or 200), 500)))
|
||||
).scalars().all()
|
||||
return rows
|
||||
|
||||
|
||||
def list_client_tasks_for_engagement(db: Session, client_row: dict[str, Any], engagement_id: int) -> list[ClientServiceTaskInstance]:
|
||||
return db.execute(
|
||||
select(ClientServiceTaskInstance)
|
||||
.options(
|
||||
selectinload(ClientServiceTaskInstance.catalogue),
|
||||
selectinload(ClientServiceTaskInstance.assigned_to),
|
||||
selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by),
|
||||
)
|
||||
.where(
|
||||
ClientServiceTaskInstance.tenant_id == _tenant_id(client_row),
|
||||
ClientServiceTaskInstance.client_id == _client_id(client_row),
|
||||
ClientServiceTaskInstance.subscription_id == int(engagement_id),
|
||||
ClientServiceTaskInstance.is_active.is_(True),
|
||||
)
|
||||
.order_by(ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.id.asc())
|
||||
).scalars().all()
|
||||
|
||||
|
||||
def get_client_engagement(db: Session, client_row: dict[str, Any], engagement_id: int, *, financial_year: str | None = None) -> ClientServiceSubscription | None:
|
||||
query = (
|
||||
select(ClientServiceSubscription)
|
||||
.options(
|
||||
selectinload(ClientServiceSubscription.catalogue),
|
||||
selectinload(ClientServiceSubscription.assigned_partner),
|
||||
selectinload(ClientServiceSubscription.assigned_manager),
|
||||
selectinload(ClientServiceSubscription.assigned_staff),
|
||||
)
|
||||
.where(
|
||||
ClientServiceSubscription.id == int(engagement_id),
|
||||
ClientServiceSubscription.tenant_id == _tenant_id(client_row),
|
||||
ClientServiceSubscription.client_id == _client_id(client_row),
|
||||
ClientServiceSubscription.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
if financial_year:
|
||||
query = query.where(ClientServiceSubscription.financial_year == financial_year.strip())
|
||||
return db.execute(query).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_client_task(db: Session, client_row: dict[str, Any], task_id: int, *, financial_year: str | None = None) -> ClientServiceTaskInstance | None:
|
||||
query = (
|
||||
select(ClientServiceTaskInstance)
|
||||
.where(
|
||||
ClientServiceTaskInstance.id == int(task_id),
|
||||
ClientServiceTaskInstance.tenant_id == _tenant_id(client_row),
|
||||
ClientServiceTaskInstance.client_id == _client_id(client_row),
|
||||
ClientServiceTaskInstance.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
if financial_year:
|
||||
query = query.where(ClientServiceTaskInstance.financial_year == financial_year.strip())
|
||||
return db.execute(query).scalar_one_or_none()
|
||||
|
||||
|
||||
def list_client_visible_comments(db: Session, client_row: dict[str, Any], *, limit: int = 100, financial_year: str | None = None) -> list[ServiceTaskComment]:
|
||||
query = (
|
||||
select(ServiceTaskComment)
|
||||
.options(
|
||||
selectinload(ServiceTaskComment.created_by),
|
||||
selectinload(ServiceTaskComment.task).selectinload(ClientServiceTaskInstance.catalogue),
|
||||
selectinload(ServiceTaskComment.subscription).selectinload(ClientServiceSubscription.catalogue),
|
||||
)
|
||||
.join(ClientServiceTaskInstance, ClientServiceTaskInstance.id == ServiceTaskComment.task_instance_id)
|
||||
.where(
|
||||
ServiceTaskComment.tenant_id == _tenant_id(client_row),
|
||||
ServiceTaskComment.visibility == "client",
|
||||
ServiceTaskComment.is_deleted.is_(False),
|
||||
ClientServiceTaskInstance.client_id == _client_id(client_row),
|
||||
ClientServiceTaskInstance.tenant_id == _tenant_id(client_row),
|
||||
ClientServiceTaskInstance.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
if financial_year:
|
||||
query = query.where(ClientServiceTaskInstance.financial_year == financial_year.strip())
|
||||
return db.execute(
|
||||
query.order_by(ServiceTaskComment.created_at_utc.desc(), ServiceTaskComment.id.desc())
|
||||
.limit(max(1, min(int(limit or 100), 300)))
|
||||
).scalars().all()
|
||||
|
||||
|
||||
def create_client_reply(db: Session, *, client_row: dict[str, Any], task: ClientServiceTaskInstance, message: str, user) -> ServiceTaskComment:
|
||||
clean_message = (message or "").strip()
|
||||
if not clean_message:
|
||||
raise ValueError("Reply message is required.")
|
||||
if len(clean_message) > 4000:
|
||||
raise ValueError("Reply message is too long. Please keep it within 4000 characters.")
|
||||
comment = ServiceTaskComment(
|
||||
tenant_id=task.tenant_id,
|
||||
branch_id=task.branch_id,
|
||||
subscription_id=task.subscription_id,
|
||||
task_instance_id=task.id,
|
||||
comment_type="client_clarification",
|
||||
visibility="client",
|
||||
message=clean_message,
|
||||
created_by_user_id=getattr(user, "id", None),
|
||||
)
|
||||
db.add(comment)
|
||||
db.flush()
|
||||
return comment
|
||||
|
||||
|
||||
def list_client_engagement_documents(db: Session, client_row: dict[str, Any], *, engagement_id: int | None = None, financial_year: str | None = None) -> list[EngagementDocument]:
|
||||
stmt = (
|
||||
select(EngagementDocument)
|
||||
.options(selectinload(EngagementDocument.versions), selectinload(EngagementDocument.engagement).selectinload(ClientServiceSubscription.catalogue))
|
||||
.where(
|
||||
EngagementDocument.tenant_id == _tenant_id(client_row),
|
||||
EngagementDocument.client_id == _client_id(client_row),
|
||||
EngagementDocument.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
if engagement_id is not None:
|
||||
stmt = stmt.where(EngagementDocument.engagement_id == int(engagement_id))
|
||||
if financial_year:
|
||||
stmt = stmt.where(EngagementDocument.financial_year == financial_year.strip())
|
||||
return db.execute(stmt.order_by(EngagementDocument.updated_at_utc.desc())).unique().scalars().all()
|
||||
|
||||
|
||||
def list_client_permanent_documents(db: Session, client_row: dict[str, Any]) -> list[PermanentClientDocument]:
|
||||
return db.execute(
|
||||
select(PermanentClientDocument)
|
||||
.options(selectinload(PermanentClientDocument.versions))
|
||||
.where(
|
||||
PermanentClientDocument.tenant_id == _tenant_id(client_row),
|
||||
PermanentClientDocument.client_id == _client_id(client_row),
|
||||
PermanentClientDocument.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(PermanentClientDocument.category.asc(), PermanentClientDocument.updated_at_utc.desc())
|
||||
).unique().scalars().all()
|
||||
|
||||
|
||||
def build_client_portal_summary(db: Session, client_row: dict[str, Any], *, financial_year: str | None = None) -> dict[str, Any]:
|
||||
engagements = list_client_engagements(db, client_row, limit=500, financial_year=financial_year)
|
||||
engagement_ids = [row.id for row in engagements]
|
||||
today = date.today()
|
||||
|
||||
task_rows: list[ClientServiceTaskInstance] = []
|
||||
if engagement_ids:
|
||||
task_rows = db.execute(
|
||||
select(ClientServiceTaskInstance).where(
|
||||
ClientServiceTaskInstance.tenant_id == _tenant_id(client_row),
|
||||
ClientServiceTaskInstance.client_id == _client_id(client_row),
|
||||
ClientServiceTaskInstance.subscription_id.in_(engagement_ids),
|
||||
ClientServiceTaskInstance.is_active.is_(True),
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
status_counter = Counter((task.status or "pending") for task in task_rows)
|
||||
open_tasks = [task for task in task_rows if (task.status or "pending") in OPEN_TASK_STATUSES]
|
||||
overdue_tasks = [
|
||||
task for task in open_tasks
|
||||
if task.internal_target_date is not None and task.internal_target_date < today
|
||||
]
|
||||
due_soon_engagements = [
|
||||
row for row in engagements
|
||||
if row.current_due_date is not None and row.current_due_date >= today
|
||||
][:10]
|
||||
|
||||
pending_from_client = 0
|
||||
with_firm = 0
|
||||
completed = 0
|
||||
clarification_required = 0
|
||||
for row in engagements:
|
||||
tasks_for_eng = [t for t in task_rows if t.subscription_id == row.id]
|
||||
statuses = {(t.status or "pending") for t in tasks_for_eng}
|
||||
if statuses & {"blocked", "client_pending", "clarification_required"}:
|
||||
clarification_required += 1
|
||||
elif tasks_for_eng and all((t.status or "pending") in CLOSED_TASK_STATUSES for t in tasks_for_eng):
|
||||
completed += 1
|
||||
elif statuses & {"pending"}:
|
||||
pending_from_client += 1
|
||||
else:
|
||||
with_firm += 1
|
||||
|
||||
return {
|
||||
"engagements": engagements,
|
||||
"task_rows": task_rows,
|
||||
"status_counter": status_counter,
|
||||
"total_engagements": len(engagements),
|
||||
"open_tasks": len(open_tasks),
|
||||
"overdue_tasks": len(overdue_tasks),
|
||||
"completed_tasks": status_counter.get("completed", 0) + status_counter.get("approved", 0) + status_counter.get("closed", 0),
|
||||
"due_soon_engagements": due_soon_engagements,
|
||||
"pending_from_client": pending_from_client,
|
||||
"with_firm": with_firm,
|
||||
"clarification_required": clarification_required,
|
||||
"completed_engagements": completed,
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from math import ceil
|
||||
|
||||
from sqlalchemy import asc, case, desc, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security.passwords import hash_password
|
||||
from app.modules.clients.association_models import ClientAssociation
|
||||
from app.modules.clients.constants import CLIENT_SORT_FIELDS
|
||||
from app.modules.clients.models import Client, ClientAuditLog
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.core.rbac.models import Role, UserRole
|
||||
from app.modules.core.tenancy.models import Branch, Tenant
|
||||
|
||||
|
||||
def _safe_sort(sort_by: str, sort_order: str):
|
||||
attr_name = CLIENT_SORT_FIELDS.get(sort_by, "client_name")
|
||||
column = getattr(Client, attr_name)
|
||||
return desc(column) if sort_order == "desc" else asc(column)
|
||||
|
||||
|
||||
def build_clients_query(
|
||||
*,
|
||||
tenant_id: int,
|
||||
branch_id: int | None = None,
|
||||
allow_cross_branch: bool = False,
|
||||
allow_all_clients: bool = False,
|
||||
partner_id: int | None = None,
|
||||
q: str = "",
|
||||
status: str = "",
|
||||
client_type: str = "",
|
||||
include_archived: bool = False,
|
||||
):
|
||||
assoc = ClientAssociation
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
Client,
|
||||
User.full_name.label("partner_name"),
|
||||
Branch.name.label("branch_name"),
|
||||
Tenant.name.label("tenant_name"),
|
||||
assoc.association_type.label("association_type"),
|
||||
assoc.firm_tenant_id.label("assoc_firm_tenant_id"),
|
||||
assoc.consultant_id.label("assoc_consultant_id"),
|
||||
assoc.partner_user_id.label("assoc_partner_user_id"),
|
||||
assoc.created_source.label("assoc_created_source"),
|
||||
)
|
||||
.outerjoin(assoc, assoc.client_id == Client.id)
|
||||
.join(User, User.id == Client.partner_id, isouter=True)
|
||||
.join(Branch, Branch.id == Client.branch_id, isouter=True)
|
||||
.join(Tenant, Tenant.id == Client.tenant_id, isouter=True)
|
||||
)
|
||||
|
||||
if not allow_all_clients:
|
||||
stmt = stmt.where(Client.tenant_id == tenant_id)
|
||||
|
||||
if not include_archived:
|
||||
stmt = stmt.where(Client.is_archived.is_(False))
|
||||
|
||||
if branch_id and not allow_all_clients and not allow_cross_branch:
|
||||
stmt = stmt.where(Client.branch_id == branch_id)
|
||||
|
||||
if partner_id:
|
||||
stmt = stmt.where(
|
||||
(Client.partner_id == partner_id) | (assoc.partner_user_id == partner_id)
|
||||
)
|
||||
|
||||
if status:
|
||||
stmt = stmt.where(Client.status == status)
|
||||
|
||||
if client_type:
|
||||
stmt = stmt.where(Client.client_type == client_type)
|
||||
|
||||
if q:
|
||||
like = f"%{q.strip()}%"
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Client.client_code.ilike(like),
|
||||
Client.client_name.ilike(like),
|
||||
Client.trade_name.ilike(like),
|
||||
Client.pan.ilike(like),
|
||||
Client.gstin.ilike(like),
|
||||
Client.mobile.ilike(like),
|
||||
Client.email.ilike(like),
|
||||
)
|
||||
)
|
||||
|
||||
return stmt
|
||||
|
||||
|
||||
def list_clients(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_id: int,
|
||||
branch_id: int | None = None,
|
||||
allow_cross_branch: bool = False,
|
||||
allow_all_clients: bool = False,
|
||||
partner_id: int | None = None,
|
||||
q: str = "",
|
||||
status: str = "",
|
||||
client_type: str = "",
|
||||
include_archived: bool = False,
|
||||
page: int = 1,
|
||||
per_page: int = 10,
|
||||
sort_by: str = "client_name",
|
||||
sort_order: str = "asc",
|
||||
) -> dict:
|
||||
stmt = build_clients_query(
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
allow_cross_branch=allow_cross_branch,
|
||||
allow_all_clients=allow_all_clients,
|
||||
partner_id=partner_id,
|
||||
q=q,
|
||||
status=status,
|
||||
client_type=client_type,
|
||||
include_archived=include_archived,
|
||||
)
|
||||
|
||||
total = db.execute(select(func.count()).select_from(stmt.subquery())).scalar_one()
|
||||
result = db.execute(
|
||||
stmt.order_by(_safe_sort(sort_by, sort_order))
|
||||
.offset((page - 1) * per_page)
|
||||
.limit(per_page)
|
||||
).all()
|
||||
|
||||
rows = []
|
||||
for (
|
||||
client,
|
||||
partner_name,
|
||||
branch_name,
|
||||
tenant_name,
|
||||
association_type,
|
||||
assoc_firm_tenant_id,
|
||||
assoc_consultant_id,
|
||||
assoc_partner_user_id,
|
||||
assoc_created_source,
|
||||
) in result:
|
||||
row = {**client.__dict__}
|
||||
row.pop("_sa_instance_state", None)
|
||||
row.update(
|
||||
{
|
||||
"partner_name": partner_name,
|
||||
"branch_name": branch_name,
|
||||
"tenant_name": tenant_name,
|
||||
"association_type": association_type,
|
||||
"assoc_firm_tenant_id": assoc_firm_tenant_id,
|
||||
"assoc_consultant_id": assoc_consultant_id,
|
||||
"assoc_partner_user_id": assoc_partner_user_id,
|
||||
"assoc_created_source": assoc_created_source,
|
||||
"effective_partner_id": assoc_partner_user_id or row.get("partner_id"),
|
||||
}
|
||||
)
|
||||
rows.append(row)
|
||||
|
||||
stats_stmt = select(
|
||||
func.count(Client.id),
|
||||
func.sum(case((Client.status == "active", 1), else_=0)),
|
||||
func.sum(case((Client.status == "inactive", 1), else_=0)),
|
||||
func.sum(case((Client.status == "archived", 1), else_=0)),
|
||||
)
|
||||
|
||||
if not allow_all_clients:
|
||||
stats_stmt = stats_stmt.where(Client.tenant_id == tenant_id)
|
||||
if branch_id and not allow_cross_branch:
|
||||
stats_stmt = stats_stmt.where(Client.branch_id == branch_id)
|
||||
|
||||
if partner_id:
|
||||
stats_stmt = stats_stmt.where(Client.partner_id == partner_id)
|
||||
|
||||
total_all, active, inactive, archived = db.execute(stats_stmt).one()
|
||||
|
||||
pages = ceil(total / per_page) if per_page else 1
|
||||
return {
|
||||
"rows": rows,
|
||||
"meta": {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"pages": max(pages, 1),
|
||||
},
|
||||
"stats": {
|
||||
"total": int(total_all or 0),
|
||||
"active": int(active or 0),
|
||||
"inactive": int(inactive or 0),
|
||||
"archived": int(archived or 0),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_client_detail_payload(db: Session, client_id: int):
|
||||
assoc = ClientAssociation
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
Client,
|
||||
assoc.association_type.label("association_type"),
|
||||
assoc.firm_tenant_id.label("assoc_firm_tenant_id"),
|
||||
assoc.consultant_id.label("assoc_consultant_id"),
|
||||
assoc.partner_user_id.label("assoc_partner_user_id"),
|
||||
assoc.created_source.label("assoc_created_source"),
|
||||
)
|
||||
.outerjoin(assoc, assoc.client_id == Client.id)
|
||||
.where(Client.id == client_id)
|
||||
)
|
||||
|
||||
result = db.execute(stmt).one_or_none()
|
||||
if not result:
|
||||
return None
|
||||
|
||||
(
|
||||
client,
|
||||
association_type,
|
||||
assoc_firm_tenant_id,
|
||||
assoc_consultant_id,
|
||||
assoc_partner_user_id,
|
||||
assoc_created_source,
|
||||
) = result
|
||||
|
||||
row = {**client.__dict__}
|
||||
row.pop("_sa_instance_state", None)
|
||||
row.update(
|
||||
{
|
||||
"association_type": association_type,
|
||||
"assoc_firm_tenant_id": assoc_firm_tenant_id,
|
||||
"assoc_consultant_id": assoc_consultant_id,
|
||||
"assoc_partner_user_id": assoc_partner_user_id,
|
||||
"assoc_created_source": assoc_created_source,
|
||||
"effective_partner_id": assoc_partner_user_id or row.get("partner_id"),
|
||||
}
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def get_client_by_id(db: Session, client_id: int):
|
||||
return db.get(Client, client_id)
|
||||
|
||||
|
||||
def get_client_by_code(db: Session, *, tenant_id: int, client_code: str):
|
||||
return db.execute(
|
||||
select(Client).where(Client.tenant_id == tenant_id, Client.client_code == client_code)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_client_by_pan(db: Session, *, tenant_id: int, pan: str):
|
||||
return db.execute(
|
||||
select(Client).where(Client.tenant_id == tenant_id, Client.pan == pan)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_client_by_gstin(db: Session, *, tenant_id: int, gstin: str):
|
||||
return db.execute(
|
||||
select(Client).where(Client.tenant_id == tenant_id, Client.gstin == gstin)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def create_client(db: Session, payload: dict):
|
||||
row = Client(**payload)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
def update_client(db: Session, row: Client, payload: dict):
|
||||
for key, value in payload.items():
|
||||
setattr(row, key, value)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
def write_audit_log(db: Session, **kwargs):
|
||||
row = ClientAuditLog(**kwargs)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
def list_audit_logs(db: Session, *, client_id: int, limit: int = 50):
|
||||
stmt = (
|
||||
select(ClientAuditLog)
|
||||
.where(ClientAuditLog.client_id == client_id)
|
||||
.order_by(ClientAuditLog.created_at_utc.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return db.execute(stmt).scalars().all()
|
||||
|
||||
|
||||
def list_tenants(db: Session):
|
||||
return db.execute(
|
||||
select(Tenant).where(Tenant.is_active.is_(True)).order_by(Tenant.name.asc())
|
||||
).scalars().all()
|
||||
|
||||
|
||||
def list_branches_for_tenant(db: Session, tenant_id: int):
|
||||
stmt = (
|
||||
select(Branch)
|
||||
.where(Branch.tenant_id == tenant_id, Branch.is_active.is_(True))
|
||||
.order_by(Branch.name.asc())
|
||||
)
|
||||
return db.execute(stmt).scalars().all()
|
||||
|
||||
|
||||
def list_partners_for_scope(db: Session, *, tenant_id: int, branch_id: int | None = None):
|
||||
stmt = (
|
||||
select(User)
|
||||
.join(UserRole, UserRole.user_id == User.id)
|
||||
.join(Role, Role.id == UserRole.role_id)
|
||||
.where(
|
||||
Role.name == "Partner",
|
||||
User.tenant_id == tenant_id,
|
||||
User.is_active.is_(True),
|
||||
User.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(User.full_name.asc(), User.email.asc())
|
||||
)
|
||||
if branch_id:
|
||||
stmt = stmt.where(User.branch_id == branch_id)
|
||||
return db.execute(stmt).scalars().all()
|
||||
|
||||
|
||||
def get_branch(db: Session, branch_id: int):
|
||||
return db.execute(
|
||||
select(Branch).where(Branch.id == branch_id, Branch.is_active.is_(True))
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_partner(db: Session, partner_id: int):
|
||||
return db.execute(
|
||||
select(User).where(User.id == partner_id, User.is_active.is_(True), User.deleted_at.is_(None))
|
||||
).scalar_one_or_none()
|
||||
|
||||
def list_all_branches(db: Session):
|
||||
stmt = (
|
||||
select(
|
||||
Branch,
|
||||
Tenant.name.label("tenant_name"),
|
||||
)
|
||||
.join(Tenant, Tenant.id == Branch.tenant_id)
|
||||
.where(Branch.is_active.is_(True))
|
||||
.order_by(Tenant.name.asc(), Branch.name.asc())
|
||||
)
|
||||
|
||||
rows = []
|
||||
for branch, tenant_name in db.execute(stmt).all():
|
||||
branch.tenant_name = tenant_name
|
||||
rows.append(branch)
|
||||
return rows
|
||||
|
||||
def list_all_partners(db: Session):
|
||||
stmt = (
|
||||
select(
|
||||
User,
|
||||
Tenant.name.label("tenant_name"),
|
||||
)
|
||||
.join(UserRole, UserRole.user_id == User.id)
|
||||
.join(Role, Role.id == UserRole.role_id)
|
||||
.join(Tenant, Tenant.id == User.tenant_id, isouter=True)
|
||||
.where(
|
||||
Role.name == "Partner",
|
||||
User.is_active.is_(True),
|
||||
User.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(Tenant.name.asc(), User.full_name.asc(), User.email.asc())
|
||||
)
|
||||
|
||||
rows = []
|
||||
for user, tenant_name in db.execute(stmt).all():
|
||||
user.tenant_name = tenant_name
|
||||
rows.append(user)
|
||||
return rows
|
||||
|
||||
def list_all_branches(db: Session):
|
||||
stmt = (
|
||||
select(
|
||||
Branch,
|
||||
Tenant.name.label("tenant_name"),
|
||||
)
|
||||
.join(Tenant, Tenant.id == Branch.tenant_id)
|
||||
.where(Branch.is_active.is_(True))
|
||||
.order_by(Tenant.name.asc(), Branch.name.asc())
|
||||
)
|
||||
|
||||
rows = []
|
||||
for branch, tenant_name in db.execute(stmt).all():
|
||||
branch.tenant_name = tenant_name
|
||||
rows.append(branch)
|
||||
return rows
|
||||
|
||||
|
||||
def list_all_partners(db: Session):
|
||||
stmt = (
|
||||
select(
|
||||
User,
|
||||
Tenant.name.label("tenant_name"),
|
||||
)
|
||||
.join(UserRole, UserRole.user_id == User.id)
|
||||
.join(Role, Role.id == UserRole.role_id)
|
||||
.join(Tenant, Tenant.id == User.tenant_id, isouter=True)
|
||||
.where(
|
||||
Role.name == "Partner",
|
||||
User.is_active.is_(True),
|
||||
User.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(Tenant.name.asc(), User.full_name.asc(), User.email.asc())
|
||||
)
|
||||
|
||||
rows = []
|
||||
for user, tenant_name in db.execute(stmt).all():
|
||||
user.tenant_name = tenant_name
|
||||
rows.append(user)
|
||||
return rows
|
||||
|
||||
|
||||
def get_portal_client_for_user(db: Session, *, user: User):
|
||||
email = (getattr(user, "email", "") or "").strip().lower()
|
||||
tenant_id = getattr(user, "tenant_id", None)
|
||||
if not email or not tenant_id:
|
||||
return None
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
Client,
|
||||
User.full_name.label("partner_name"),
|
||||
Branch.name.label("branch_name"),
|
||||
Tenant.name.label("tenant_name"),
|
||||
)
|
||||
.join(User, User.id == Client.partner_id, isouter=True)
|
||||
.join(Branch, Branch.id == Client.branch_id, isouter=True)
|
||||
.join(Tenant, Tenant.id == Client.tenant_id, isouter=True)
|
||||
.where(
|
||||
Client.tenant_id == tenant_id,
|
||||
Client.is_archived.is_(False),
|
||||
or_(Client.email.ilike(email), Client.alternate_email.ilike(email)),
|
||||
)
|
||||
.order_by(
|
||||
case((Client.status == "active", 0), else_=1),
|
||||
Client.client_name.asc(),
|
||||
Client.id.asc(),
|
||||
)
|
||||
)
|
||||
result = db.execute(stmt).first()
|
||||
if not result:
|
||||
return None
|
||||
|
||||
client, partner_name, branch_name, tenant_name = result
|
||||
row = {**client.__dict__}
|
||||
row.pop("_sa_instance_state", None)
|
||||
row.update(
|
||||
{
|
||||
"partner_name": partner_name,
|
||||
"branch_name": branch_name,
|
||||
"tenant_name": tenant_name,
|
||||
}
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def get_user_by_email(db: Session, *, email: str, exclude_user_id: int | None = None):
|
||||
email_clean = (email or "").strip().lower()
|
||||
if not email_clean:
|
||||
return None
|
||||
stmt = select(User).where(User.email.ilike(email_clean), User.deleted_at.is_(None))
|
||||
if exclude_user_id:
|
||||
stmt = stmt.where(User.id != exclude_user_id)
|
||||
return db.execute(stmt).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_tenant(db: Session, tenant_id: int):
|
||||
return db.execute(select(Tenant).where(Tenant.id == tenant_id, Tenant.is_active.is_(True))).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_partner_for_tenant(db: Session, *, partner_user_id: int, tenant_id: int):
|
||||
stmt = (
|
||||
select(User)
|
||||
.join(UserRole, UserRole.user_id == User.id)
|
||||
.join(Role, Role.id == UserRole.role_id)
|
||||
.where(
|
||||
User.id == partner_user_id,
|
||||
User.tenant_id == tenant_id,
|
||||
User.is_active.is_(True),
|
||||
User.deleted_at.is_(None),
|
||||
Role.name == "Partner",
|
||||
)
|
||||
)
|
||||
return db.execute(stmt).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_role_by_name(db: Session, role_name: str):
|
||||
return db.execute(select(Role).where(Role.name == role_name, Role.is_active.is_(True))).scalar_one_or_none()
|
||||
|
||||
|
||||
def create_portal_user(db: Session, *, email: str, full_name: str, tenant_id: int, branch_id: int, password: str):
|
||||
row = User(
|
||||
email=(email or '').strip().lower(),
|
||||
full_name=(full_name or '').strip(),
|
||||
password_hash=hash_password(password),
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
is_active=True,
|
||||
allow_login=True,
|
||||
is_locked=False,
|
||||
must_change_password=False,
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
def ensure_user_role(db: Session, *, user_id: int, role_id: int):
|
||||
existing = db.execute(select(UserRole).where(UserRole.user_id == user_id, UserRole.role_id == role_id)).scalar_one_or_none()
|
||||
if existing:
|
||||
return existing
|
||||
row = UserRole(user_id=user_id, role_id=role_id)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
@@ -0,0 +1,386 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, field_validator, model_validator
|
||||
|
||||
from app.modules.clients.constants import (
|
||||
CLIENT_CATEGORY_OPTIONS,
|
||||
CLIENT_SORT_FIELDS,
|
||||
CLIENT_STATUS,
|
||||
CLIENT_TYPES,
|
||||
ENGAGEMENT_MODES,
|
||||
RISK_CATEGORIES,
|
||||
)
|
||||
from app.modules.clients.utils import GSTIN_RE, MOBILE_RE, PAN_RE, PIN_RE, TAN_RE, normalize_text, normalize_upper
|
||||
|
||||
|
||||
class ClientBase(BaseModel):
|
||||
tenant_id: int
|
||||
branch_id: int
|
||||
partner_id: Optional[int] = None
|
||||
default_review_partner_user_id: Optional[int] = None
|
||||
engagement_mode: str = "internal_managed"
|
||||
client_code: str
|
||||
client_name: str
|
||||
trade_name: Optional[str] = None
|
||||
client_type: str = "Other"
|
||||
pan: Optional[str] = None
|
||||
gstin: Optional[str] = None
|
||||
tan: Optional[str] = None
|
||||
cin_llpin: Optional[str] = None
|
||||
msme_no: Optional[str] = None
|
||||
iec_code: Optional[str] = None
|
||||
contact_person_name: Optional[str] = None
|
||||
contact_person_designation: Optional[str] = None
|
||||
mobile: Optional[str] = None
|
||||
alternate_mobile: Optional[str] = None
|
||||
email: Optional[EmailStr] = None
|
||||
alternate_email: Optional[EmailStr] = None
|
||||
address_line_1: Optional[str] = None
|
||||
address_line_2: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
pincode: Optional[str] = None
|
||||
country: Optional[str] = "India"
|
||||
status: str = "active"
|
||||
client_category: Optional[str] = None
|
||||
risk_category: Optional[str] = None
|
||||
onboarding_date: Optional[date] = None
|
||||
closing_date: Optional[date] = None
|
||||
notes: Optional[str] = None
|
||||
gst_applicable: bool = False
|
||||
income_tax_applicable: bool = False
|
||||
tds_applicable: bool = False
|
||||
roc_applicable: bool = False
|
||||
audit_applicable: bool = False
|
||||
pf_applicable: bool = False
|
||||
esi_applicable: bool = False
|
||||
professional_tax_applicable: bool = False
|
||||
payroll_applicable: bool = False
|
||||
msme_applicable: bool = False
|
||||
import_export_applicable: bool = False
|
||||
|
||||
@field_validator("client_code", "client_name", mode="before")
|
||||
@classmethod
|
||||
def required_text(cls, value):
|
||||
value = normalize_text(value)
|
||||
if not value:
|
||||
raise ValueError("This field is required.")
|
||||
return value
|
||||
|
||||
@field_validator(
|
||||
"trade_name", "cin_llpin", "msme_no", "iec_code", "contact_person_name", "contact_person_designation",
|
||||
"address_line_1", "address_line_2", "city", "state", "country", "client_category", "risk_category", "notes",
|
||||
mode="before",
|
||||
)
|
||||
@classmethod
|
||||
def clean_text(cls, value):
|
||||
return normalize_text(value)
|
||||
|
||||
@field_validator("pan", "gstin", "tan", mode="before")
|
||||
@classmethod
|
||||
def uppercase_codes(cls, value):
|
||||
return normalize_upper(value)
|
||||
|
||||
@field_validator("engagement_mode", mode="before")
|
||||
@classmethod
|
||||
def clean_engagement_mode(cls, value):
|
||||
value = normalize_text(value) or "internal_managed"
|
||||
return value.lower()
|
||||
|
||||
@field_validator("engagement_mode")
|
||||
@classmethod
|
||||
def validate_engagement_mode(cls, value):
|
||||
if value not in ENGAGEMENT_MODES:
|
||||
raise ValueError("Invalid engagement mode.")
|
||||
return value
|
||||
|
||||
@field_validator("mobile", "alternate_mobile", mode="before")
|
||||
@classmethod
|
||||
def clean_mobile(cls, value):
|
||||
value = normalize_text(value)
|
||||
if value is None:
|
||||
return None
|
||||
value = value.replace(" ", "").replace("-", "")
|
||||
if value.startswith("+91"):
|
||||
value = value[3:]
|
||||
return value
|
||||
|
||||
@field_validator("pan")
|
||||
@classmethod
|
||||
def validate_pan(cls, value):
|
||||
if value and not PAN_RE.match(value):
|
||||
raise ValueError("Invalid PAN format.")
|
||||
return value
|
||||
|
||||
@field_validator("gstin")
|
||||
@classmethod
|
||||
def validate_gstin(cls, value):
|
||||
if value and not GSTIN_RE.match(value):
|
||||
raise ValueError("Invalid GSTIN format.")
|
||||
return value
|
||||
|
||||
@field_validator("tan")
|
||||
@classmethod
|
||||
def validate_tan(cls, value):
|
||||
if value and not TAN_RE.match(value):
|
||||
raise ValueError("Invalid TAN format.")
|
||||
return value
|
||||
|
||||
@field_validator("mobile", "alternate_mobile")
|
||||
@classmethod
|
||||
def validate_mobile(cls, value):
|
||||
if value and not MOBILE_RE.match(value):
|
||||
raise ValueError("Mobile number must be a valid 10-digit Indian mobile.")
|
||||
return value
|
||||
|
||||
@field_validator("pincode", mode="before")
|
||||
@classmethod
|
||||
def clean_pincode(cls, value):
|
||||
return normalize_text(value)
|
||||
|
||||
@field_validator("pincode")
|
||||
@classmethod
|
||||
def validate_pincode(cls, value):
|
||||
if value and not PIN_RE.match(value):
|
||||
raise ValueError("Pincode must be a valid 6-digit code.")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_dates_and_assignment(self):
|
||||
if self.onboarding_date and self.closing_date and self.closing_date < self.onboarding_date:
|
||||
raise ValueError("Closing date cannot be earlier than onboarding date.")
|
||||
if self.engagement_mode == "internal_managed" and not self.partner_id:
|
||||
raise ValueError("Partner is required for internal managed clients.")
|
||||
return self
|
||||
|
||||
|
||||
class ClientCreate(ClientBase):
|
||||
pass
|
||||
|
||||
|
||||
class ClientUpdate(BaseModel):
|
||||
tenant_id: Optional[int] = None
|
||||
branch_id: Optional[int] = None
|
||||
partner_id: Optional[int] = None
|
||||
default_review_partner_user_id: Optional[int] = None
|
||||
engagement_mode: Optional[str] = None
|
||||
client_name: Optional[str] = None
|
||||
trade_name: Optional[str] = None
|
||||
client_type: Optional[str] = None
|
||||
pan: Optional[str] = None
|
||||
gstin: Optional[str] = None
|
||||
tan: Optional[str] = None
|
||||
cin_llpin: Optional[str] = None
|
||||
msme_no: Optional[str] = None
|
||||
iec_code: Optional[str] = None
|
||||
contact_person_name: Optional[str] = None
|
||||
contact_person_designation: Optional[str] = None
|
||||
mobile: Optional[str] = None
|
||||
alternate_mobile: Optional[str] = None
|
||||
email: Optional[EmailStr] = None
|
||||
alternate_email: Optional[EmailStr] = None
|
||||
address_line_1: Optional[str] = None
|
||||
address_line_2: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
pincode: Optional[str] = None
|
||||
country: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
client_category: Optional[str] = None
|
||||
risk_category: Optional[str] = None
|
||||
onboarding_date: Optional[date] = None
|
||||
closing_date: Optional[date] = None
|
||||
notes: Optional[str] = None
|
||||
gst_applicable: Optional[bool] = None
|
||||
income_tax_applicable: Optional[bool] = None
|
||||
tds_applicable: Optional[bool] = None
|
||||
roc_applicable: Optional[bool] = None
|
||||
audit_applicable: Optional[bool] = None
|
||||
pf_applicable: Optional[bool] = None
|
||||
esi_applicable: Optional[bool] = None
|
||||
professional_tax_applicable: Optional[bool] = None
|
||||
payroll_applicable: Optional[bool] = None
|
||||
msme_applicable: Optional[bool] = None
|
||||
import_export_applicable: Optional[bool] = None
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@field_validator(
|
||||
"client_name", "trade_name", "cin_llpin", "msme_no", "iec_code", "contact_person_name", "contact_person_designation",
|
||||
"address_line_1", "address_line_2", "city", "state", "country", "client_category", "risk_category", "notes",
|
||||
mode="before",
|
||||
)
|
||||
@classmethod
|
||||
def clean_text(cls, value):
|
||||
return normalize_text(value)
|
||||
|
||||
@field_validator("pan", "gstin", "tan", mode="before")
|
||||
@classmethod
|
||||
def uppercase_codes(cls, value):
|
||||
return normalize_upper(value)
|
||||
|
||||
@field_validator("engagement_mode", mode="before")
|
||||
@classmethod
|
||||
def clean_engagement_mode(cls, value):
|
||||
if value is None:
|
||||
return None
|
||||
value = normalize_text(value) or None
|
||||
return value.lower() if value else None
|
||||
|
||||
@field_validator("engagement_mode")
|
||||
@classmethod
|
||||
def validate_engagement_mode(cls, value):
|
||||
if value is not None and value not in ENGAGEMENT_MODES:
|
||||
raise ValueError("Invalid engagement mode.")
|
||||
return value
|
||||
|
||||
@field_validator("mobile", "alternate_mobile", mode="before")
|
||||
@classmethod
|
||||
def clean_mobile(cls, value):
|
||||
value = normalize_text(value)
|
||||
if value is None:
|
||||
return None
|
||||
value = value.replace(" ", "").replace("-", "")
|
||||
if value.startswith("+91"):
|
||||
value = value[3:]
|
||||
return value
|
||||
|
||||
@field_validator("pan")
|
||||
@classmethod
|
||||
def validate_pan(cls, value):
|
||||
if value and not PAN_RE.match(value):
|
||||
raise ValueError("Invalid PAN format.")
|
||||
return value
|
||||
|
||||
@field_validator("gstin")
|
||||
@classmethod
|
||||
def validate_gstin(cls, value):
|
||||
if value and not GSTIN_RE.match(value):
|
||||
raise ValueError("Invalid GSTIN format.")
|
||||
return value
|
||||
|
||||
@field_validator("tan")
|
||||
@classmethod
|
||||
def validate_tan(cls, value):
|
||||
if value and not TAN_RE.match(value):
|
||||
raise ValueError("Invalid TAN format.")
|
||||
return value
|
||||
|
||||
@field_validator("mobile", "alternate_mobile")
|
||||
@classmethod
|
||||
def validate_mobile(cls, value):
|
||||
if value and not MOBILE_RE.match(value):
|
||||
raise ValueError("Mobile number must be a valid 10-digit Indian mobile.")
|
||||
return value
|
||||
|
||||
@field_validator("pincode", mode="before")
|
||||
@classmethod
|
||||
def clean_pincode(cls, value):
|
||||
return normalize_text(value)
|
||||
|
||||
@field_validator("pincode")
|
||||
@classmethod
|
||||
def validate_pincode(cls, value):
|
||||
if value and not PIN_RE.match(value):
|
||||
raise ValueError("Pincode must be a valid 6-digit code.")
|
||||
return value
|
||||
|
||||
|
||||
class ClientOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
tenant_id: int
|
||||
branch_id: int
|
||||
partner_id: Optional[int] = None
|
||||
default_review_partner_user_id: Optional[int] = None
|
||||
engagement_mode: str
|
||||
client_code: str
|
||||
client_name: str
|
||||
trade_name: Optional[str] = None
|
||||
client_type: str
|
||||
pan: Optional[str] = None
|
||||
gstin: Optional[str] = None
|
||||
tan: Optional[str] = None
|
||||
cin_llpin: Optional[str] = None
|
||||
msme_no: Optional[str] = None
|
||||
iec_code: Optional[str] = None
|
||||
contact_person_name: Optional[str] = None
|
||||
contact_person_designation: Optional[str] = None
|
||||
mobile: Optional[str] = None
|
||||
alternate_mobile: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
alternate_email: Optional[str] = None
|
||||
address_line_1: Optional[str] = None
|
||||
address_line_2: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
pincode: Optional[str] = None
|
||||
country: Optional[str] = None
|
||||
status: str
|
||||
client_category: Optional[str] = None
|
||||
risk_category: Optional[str] = None
|
||||
onboarding_date: Optional[date] = None
|
||||
closing_date: Optional[date] = None
|
||||
notes: Optional[str] = None
|
||||
gst_applicable: bool
|
||||
income_tax_applicable: bool
|
||||
tds_applicable: bool
|
||||
roc_applicable: bool
|
||||
audit_applicable: bool
|
||||
pf_applicable: bool
|
||||
esi_applicable: bool
|
||||
professional_tax_applicable: bool
|
||||
payroll_applicable: bool
|
||||
msme_applicable: bool
|
||||
import_export_applicable: bool
|
||||
is_active: bool
|
||||
is_archived: bool
|
||||
created_at_utc: datetime
|
||||
updated_at_utc: datetime
|
||||
|
||||
|
||||
class ClientListRow(ClientOut):
|
||||
partner_name: Optional[str] = None
|
||||
branch_name: Optional[str] = None
|
||||
tenant_name: Optional[str] = None
|
||||
|
||||
|
||||
class ClientAuditLogOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
client_id: int
|
||||
tenant_id: int
|
||||
branch_id: int
|
||||
actor_user_id: Optional[int] = None
|
||||
action: str
|
||||
summary: str
|
||||
payload_json: Optional[dict] = None
|
||||
created_at_utc: datetime
|
||||
|
||||
|
||||
class PaginationMeta(BaseModel):
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
|
||||
class ClientFilterOptions(BaseModel):
|
||||
client_types: list[str]
|
||||
client_statuses: list[str]
|
||||
client_categories: list[str]
|
||||
risk_categories: list[str]
|
||||
|
||||
|
||||
class ClientListStats(BaseModel):
|
||||
total: int = 0
|
||||
active: int = 0
|
||||
inactive: int = 0
|
||||
archived: int = 0
|
||||
|
||||
|
||||
class ClientListResponse(BaseModel):
|
||||
rows: list[ClientOut]
|
||||
meta: PaginationMeta
|
||||
stats: ClientListStats | None = None
|
||||
filter_options: ClientFilterOptions | None = None
|
||||
@@ -0,0 +1,452 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.modules.clients import repository
|
||||
from app.core.security.passwords import hash_password
|
||||
from app.modules.clients.association_admin_service import (
|
||||
ensure_active_association,
|
||||
update_association_fields,
|
||||
)
|
||||
from app.modules.clients.constants import (
|
||||
CLIENT_CATEGORY_OPTIONS,
|
||||
CLIENT_STATUS,
|
||||
CLIENT_TYPES,
|
||||
RISK_CATEGORIES,
|
||||
)
|
||||
|
||||
|
||||
def _payload_from_schema(data):
|
||||
return data.model_dump(exclude_none=True) if hasattr(data, "model_dump") else data.dict(exclude_none=True)
|
||||
|
||||
|
||||
|
||||
|
||||
def _ensure_portal_passwords(email: str | None, portal_password: str | None, portal_password_confirm: str | None, *, required: bool):
|
||||
email_clean = (email or '').strip().lower()
|
||||
pw = (portal_password or '').strip()
|
||||
pw2 = (portal_password_confirm or '').strip()
|
||||
if not required and not pw and not pw2:
|
||||
return email_clean, None
|
||||
if not email_clean:
|
||||
raise HTTPException(status_code=400, detail="Email is required to create the client frontend login.")
|
||||
if len(pw) < 8:
|
||||
raise HTTPException(status_code=400, detail="Portal password must be at least 8 characters.")
|
||||
if pw != pw2:
|
||||
raise HTTPException(status_code=400, detail="Portal password and confirm password do not match.")
|
||||
return email_clean, pw
|
||||
|
||||
|
||||
def _sync_client_portal_user(db, *, row, portal_password: str | None = None, portal_password_confirm: str | None = None):
|
||||
email_clean, pw = _ensure_portal_passwords(
|
||||
getattr(row, 'email', None),
|
||||
portal_password,
|
||||
portal_password_confirm,
|
||||
required=bool(portal_password or portal_password_confirm or not getattr(row, 'portal_user_id', None)),
|
||||
)
|
||||
|
||||
if not pw:
|
||||
return row
|
||||
|
||||
existing_user = repository.get_user_by_email(db, email=email_clean, exclude_user_id=getattr(row, 'portal_user_id', None))
|
||||
if existing_user:
|
||||
raise HTTPException(status_code=400, detail="That email is already used by another login.")
|
||||
|
||||
if row.portal_user_id:
|
||||
user = db.get(repository.User, int(row.portal_user_id))
|
||||
if not user:
|
||||
row = repository.update_client(db, row, {'portal_user_id': None})
|
||||
else:
|
||||
user.email = email_clean
|
||||
user.full_name = (row.client_name or '').strip()
|
||||
user.password_hash = hash_password(pw)
|
||||
user.tenant_id = row.tenant_id
|
||||
user.branch_id = row.branch_id
|
||||
user.is_active = True
|
||||
user.allow_login = True
|
||||
user.is_locked = False
|
||||
user.must_change_password = False
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return row
|
||||
|
||||
if not row.portal_user_id:
|
||||
user = repository.create_portal_user(
|
||||
db,
|
||||
email=email_clean,
|
||||
full_name=row.client_name,
|
||||
tenant_id=row.tenant_id,
|
||||
branch_id=row.branch_id,
|
||||
password=pw,
|
||||
)
|
||||
role = repository.get_role_by_name(db, 'Client')
|
||||
if not role:
|
||||
raise HTTPException(status_code=500, detail='Client role is not available.')
|
||||
repository.ensure_user_role(db, user_id=user.id, role_id=role.id)
|
||||
row = repository.update_client(db, row, {'portal_user_id': user.id})
|
||||
return row
|
||||
|
||||
|
||||
def _validate_scope_for_create(data, scope, actor_user_id):
|
||||
if scope.own_only:
|
||||
data.partner_id = scope.locked_partner_id or actor_user_id
|
||||
if not scope.allow_cross_tenant:
|
||||
data.tenant_id = scope.tenant_id
|
||||
if not scope.allow_cross_branch and scope.branch_id:
|
||||
data.branch_id = scope.branch_id
|
||||
return data
|
||||
|
||||
|
||||
def _validate_scope_for_edit(data, scope, actor_user_id, *, existing_row, current_user_roles):
|
||||
role_names = {str(r).lower() for r in current_user_roles}
|
||||
|
||||
if data.tenant_id is None:
|
||||
data.tenant_id = existing_row.tenant_id
|
||||
if data.branch_id is None:
|
||||
data.branch_id = existing_row.branch_id
|
||||
if data.partner_id is None:
|
||||
data.partner_id = existing_row.partner_id
|
||||
|
||||
if "partner" in role_names and data.partner_id and data.partner_id != actor_user_id:
|
||||
raise HTTPException(status_code=400, detail="Partner users cannot assign clients to another partner.")
|
||||
|
||||
if "consultant" in role_names and data.partner_id and data.partner_id != existing_row.partner_id:
|
||||
raise HTTPException(status_code=400, detail="Consultants cannot assign or change partner mapping.")
|
||||
|
||||
if "firm admin" in role_names:
|
||||
if existing_row.tenant_id != scope.tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Firm Admin can only manage clients within own firm.")
|
||||
data.tenant_id = scope.tenant_id
|
||||
if not scope.allow_cross_branch and data.branch_id != scope.branch_id:
|
||||
raise HTTPException(status_code=403, detail="Branch change is not allowed in current scope.")
|
||||
return data
|
||||
|
||||
if "system admin" in role_names:
|
||||
return data
|
||||
|
||||
if scope.own_only:
|
||||
data.partner_id = scope.locked_partner_id or actor_user_id
|
||||
if existing_row.partner_id != actor_user_id:
|
||||
raise HTTPException(status_code=403, detail="You can only edit your own associated clients.")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _write_association_from_client(db, client_row, *, actor_user_id, current_user_roles):
|
||||
roles = {str(r).lower() for r in current_user_roles}
|
||||
ensure_active_association(db, client_row.id)
|
||||
|
||||
if "system admin" in roles:
|
||||
return update_association_fields(
|
||||
db,
|
||||
client_row.id,
|
||||
association_type="firm" if client_row.tenant_id else "self_service_unassigned",
|
||||
firm_tenant_id=client_row.tenant_id,
|
||||
partner_user_id=client_row.partner_id,
|
||||
created_source="system_admin",
|
||||
)
|
||||
|
||||
if "firm admin" in roles:
|
||||
return update_association_fields(
|
||||
db,
|
||||
client_row.id,
|
||||
association_type="firm",
|
||||
firm_tenant_id=client_row.tenant_id,
|
||||
partner_user_id=client_row.partner_id,
|
||||
created_source="firm_admin",
|
||||
)
|
||||
|
||||
if "partner" in roles:
|
||||
return update_association_fields(
|
||||
db,
|
||||
client_row.id,
|
||||
association_type="firm",
|
||||
firm_tenant_id=client_row.tenant_id,
|
||||
partner_user_id=actor_user_id,
|
||||
created_source="partner",
|
||||
)
|
||||
|
||||
if "consultant" in roles:
|
||||
return update_association_fields(
|
||||
db,
|
||||
client_row.id,
|
||||
association_type="consultant",
|
||||
consultant_id=actor_user_id,
|
||||
created_source="consultant",
|
||||
)
|
||||
|
||||
return update_association_fields(
|
||||
db,
|
||||
client_row.id,
|
||||
association_type="self_service_unassigned",
|
||||
created_source="self_service",
|
||||
)
|
||||
|
||||
|
||||
def create_client_service(db, *, data, actor_user_id: int, scope, current_user_roles=None, portal_password: str | None = None, portal_password_confirm: str | None = None):
|
||||
current_user_roles = current_user_roles or []
|
||||
data = _validate_scope_for_create(data, scope, actor_user_id)
|
||||
|
||||
existing = repository.get_client_by_code(db, tenant_id=data.tenant_id, client_code=data.client_code)
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Client code already exists.")
|
||||
|
||||
if data.pan:
|
||||
existing_pan = repository.get_client_by_pan(db, tenant_id=data.tenant_id, pan=data.pan)
|
||||
if existing_pan:
|
||||
raise HTTPException(status_code=400, detail="PAN already exists for another client in this tenant.")
|
||||
|
||||
if data.gstin:
|
||||
existing_gstin = repository.get_client_by_gstin(db, tenant_id=data.tenant_id, gstin=data.gstin)
|
||||
if existing_gstin:
|
||||
raise HTTPException(status_code=400, detail="GSTIN already exists for another client in this tenant.")
|
||||
|
||||
payload = _payload_from_schema(data)
|
||||
row = repository.create_client(db, payload)
|
||||
row = _sync_client_portal_user(db, row=row, portal_password=portal_password, portal_password_confirm=portal_password_confirm)
|
||||
_write_association_from_client(db, row, actor_user_id=actor_user_id, current_user_roles=current_user_roles)
|
||||
|
||||
repository.write_audit_log(
|
||||
db,
|
||||
client_id=row.id,
|
||||
tenant_id=row.tenant_id,
|
||||
branch_id=row.branch_id,
|
||||
actor_user_id=actor_user_id,
|
||||
action="created",
|
||||
summary="Client created with association sync.",
|
||||
payload_json={"client_id": row.id, "partner_id": row.partner_id},
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def update_client_service(db, *, row, data, actor_user_id: int, scope, current_user_roles=None, portal_password: str | None = None, portal_password_confirm: str | None = None):
|
||||
current_user_roles = current_user_roles or []
|
||||
data = _validate_scope_for_edit(
|
||||
data,
|
||||
scope,
|
||||
actor_user_id,
|
||||
existing_row=row,
|
||||
current_user_roles=current_user_roles,
|
||||
)
|
||||
|
||||
payload = _payload_from_schema(data)
|
||||
|
||||
if payload.get("pan"):
|
||||
existing_pan = repository.get_client_by_pan(db, tenant_id=payload["tenant_id"], pan=payload["pan"])
|
||||
if existing_pan and existing_pan.id != row.id:
|
||||
raise HTTPException(status_code=400, detail="PAN already exists for another client in this tenant.")
|
||||
|
||||
if payload.get("gstin"):
|
||||
existing_gstin = repository.get_client_by_gstin(db, tenant_id=payload["tenant_id"], gstin=payload["gstin"])
|
||||
if existing_gstin and existing_gstin.id != row.id:
|
||||
raise HTTPException(status_code=400, detail="GSTIN already exists for another client in this tenant.")
|
||||
|
||||
updated = repository.update_client(db, row, payload)
|
||||
updated = _sync_client_portal_user(db, row=updated, portal_password=portal_password, portal_password_confirm=portal_password_confirm)
|
||||
_write_association_from_client(db, updated, actor_user_id=actor_user_id, current_user_roles=current_user_roles)
|
||||
|
||||
repository.write_audit_log(
|
||||
db,
|
||||
client_id=updated.id,
|
||||
tenant_id=updated.tenant_id,
|
||||
branch_id=updated.branch_id,
|
||||
actor_user_id=actor_user_id,
|
||||
action="updated",
|
||||
summary="Client updated with association sync.",
|
||||
payload_json={"client_id": updated.id, "partner_id": updated.partner_id},
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
def get_client_or_404(
|
||||
db,
|
||||
*,
|
||||
client_id: int,
|
||||
tenant_id: int,
|
||||
branch_id: int | None,
|
||||
allow_cross_branch: bool,
|
||||
allow_all_clients: bool = False,
|
||||
):
|
||||
row = repository.get_client_by_id(db, client_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Client not found.")
|
||||
if not allow_all_clients and row.tenant_id != tenant_id:
|
||||
raise HTTPException(status_code=404, detail="Client not found in current tenant.")
|
||||
if not allow_all_clients and not allow_cross_branch and branch_id and row.branch_id != branch_id:
|
||||
raise HTTPException(status_code=404, detail="Client not found in current branch.")
|
||||
return row
|
||||
|
||||
|
||||
def list_clients_payload(db, **kwargs):
|
||||
return repository.list_clients(db, **kwargs)
|
||||
|
||||
|
||||
def list_client_audit_logs(db, *, row, limit: int = 50):
|
||||
return repository.list_audit_logs(db, client_id=row.id, limit=limit)
|
||||
|
||||
|
||||
def deactivate_client_service(db, *, row, actor_user_id: int):
|
||||
row = repository.update_client(db, row, {"status": "inactive"})
|
||||
repository.write_audit_log(
|
||||
db,
|
||||
client_id=row.id,
|
||||
tenant_id=row.tenant_id,
|
||||
branch_id=row.branch_id,
|
||||
actor_user_id=actor_user_id,
|
||||
action="deactivated",
|
||||
summary="Client deactivated.",
|
||||
payload_json=None,
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def activate_client_service(db, *, row, actor_user_id: int):
|
||||
row = repository.update_client(db, row, {"status": "active"})
|
||||
repository.write_audit_log(
|
||||
db,
|
||||
client_id=row.id,
|
||||
tenant_id=row.tenant_id,
|
||||
branch_id=row.branch_id,
|
||||
actor_user_id=actor_user_id,
|
||||
action="activated",
|
||||
summary="Client activated.",
|
||||
payload_json=None,
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def archive_client_service(db, *, row, actor_user_id: int):
|
||||
row = repository.update_client(db, row, {"status": "archived", "is_archived": True})
|
||||
repository.write_audit_log(
|
||||
db,
|
||||
client_id=row.id,
|
||||
tenant_id=row.tenant_id,
|
||||
branch_id=row.branch_id,
|
||||
actor_user_id=actor_user_id,
|
||||
action="archived",
|
||||
summary="Client archived.",
|
||||
payload_json=None,
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def restore_client_service(db, *, row, actor_user_id: int):
|
||||
row = repository.update_client(db, row, {"status": "active", "is_archived": False})
|
||||
repository.write_audit_log(
|
||||
db,
|
||||
client_id=row.id,
|
||||
tenant_id=row.tenant_id,
|
||||
branch_id=row.branch_id,
|
||||
actor_user_id=actor_user_id,
|
||||
action="restored",
|
||||
summary="Client restored from archive.",
|
||||
payload_json=None,
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def export_clients_csv(payload: dict) -> str:
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(
|
||||
[
|
||||
"client_code",
|
||||
"client_name",
|
||||
"client_type",
|
||||
"status",
|
||||
"pan",
|
||||
"gstin",
|
||||
"partner",
|
||||
"association_type",
|
||||
"association_source",
|
||||
]
|
||||
)
|
||||
for row in payload.get("rows", []):
|
||||
writer.writerow(
|
||||
[
|
||||
row.get("client_code"),
|
||||
row.get("client_name"),
|
||||
row.get("client_type"),
|
||||
row.get("status"),
|
||||
row.get("pan"),
|
||||
row.get("gstin"),
|
||||
row.get("partner_name") or row.get("effective_partner_id"),
|
||||
row.get("association_type"),
|
||||
row.get("assoc_created_source"),
|
||||
]
|
||||
)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def get_filter_options():
|
||||
return {
|
||||
"client_types": CLIENT_TYPES,
|
||||
"client_statuses": CLIENT_STATUS,
|
||||
"client_categories": CLIENT_CATEGORY_OPTIONS,
|
||||
"risk_categories": RISK_CATEGORIES,
|
||||
}
|
||||
|
||||
|
||||
SELF_SERVICE_EDITABLE_FIELDS = {
|
||||
"client_name",
|
||||
"trade_name",
|
||||
"contact_person_name",
|
||||
"contact_person_designation",
|
||||
"mobile",
|
||||
"alternate_mobile",
|
||||
"email",
|
||||
"alternate_email",
|
||||
"address_line_1",
|
||||
"address_line_2",
|
||||
"city",
|
||||
"state",
|
||||
"pincode",
|
||||
"country",
|
||||
"notes",
|
||||
}
|
||||
|
||||
|
||||
def update_client_self_profile_service(db, *, row, data, current_user):
|
||||
payload = _payload_from_schema(data)
|
||||
payload = {key: value for key, value in payload.items() if key in SELF_SERVICE_EDITABLE_FIELDS}
|
||||
|
||||
new_email = (payload.get("email") or "").strip().lower()
|
||||
if new_email:
|
||||
existing_user = repository.get_user_by_email(db, email=new_email, exclude_user_id=int(current_user.id))
|
||||
if existing_user:
|
||||
raise HTTPException(status_code=400, detail="That email is already used by another login.")
|
||||
|
||||
updated = repository.update_client(db, row, payload)
|
||||
|
||||
if new_email and new_email != (getattr(current_user, "email", "") or "").strip().lower():
|
||||
current_user.email = new_email
|
||||
db.add(current_user)
|
||||
db.commit()
|
||||
db.refresh(current_user)
|
||||
|
||||
repository.write_audit_log(
|
||||
db,
|
||||
client_id=updated.id,
|
||||
tenant_id=updated.tenant_id,
|
||||
branch_id=updated.branch_id,
|
||||
actor_user_id=current_user.id,
|
||||
action="client_self_profile_updated",
|
||||
summary="Client updated own contact profile.",
|
||||
payload_json={"fields": sorted(payload.keys())},
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
def reset_client_portal_password_service(db, *, current_user, new_password: str):
|
||||
if len((new_password or "").strip()) < 8:
|
||||
raise HTTPException(status_code=400, detail="New password must be at least 8 characters.")
|
||||
current_user.password_hash = hash_password(new_password.strip())
|
||||
current_user.must_change_password = False
|
||||
db.add(current_user)
|
||||
db.commit()
|
||||
db.refresh(current_user)
|
||||
return current_user
|
||||
@@ -0,0 +1,12 @@
|
||||
<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 items-center gap-2">
|
||||
{% set path = request.url.path %}
|
||||
<a href="/client/dashboard" class="rounded-xl px-4 py-2 text-sm font-semibold transition {{ 'bg-brand-600 text-white' if path == '/client/dashboard' else 'text-slate-700 hover:bg-slate-100' }}">Overview</a>
|
||||
<a href="/client/compliance" class="rounded-xl px-4 py-2 text-sm font-semibold transition {{ 'bg-brand-600 text-white' if path.startswith('/client/compliance') or path.startswith('/client/engagements') else 'text-slate-700 hover:bg-slate-100' }}">My Compliance</a>
|
||||
<a href="/client/documents" class="rounded-xl px-4 py-2 text-sm font-semibold transition {{ 'bg-brand-600 text-white' if path.startswith('/client/documents') else 'text-slate-700 hover:bg-slate-100' }}">My Documents</a>
|
||||
<a href="/client/messages" class="rounded-xl px-4 py-2 text-sm font-semibold transition {{ 'bg-brand-600 text-white' if path.startswith('/client/messages') else 'text-slate-700 hover:bg-slate-100' }}">My Messages</a>
|
||||
<a href="/client/billing" class="rounded-xl px-4 py-2 text-sm font-semibold transition {{ 'bg-brand-600 text-white' if path.startswith('/client/billing') else 'text-slate-700 hover:bg-slate-100' }}">My Bills</a>
|
||||
<a href="/client/profile" class="rounded-xl px-4 py-2 text-sm font-semibold transition {{ 'bg-brand-600 text-white' if path.startswith('/client/profile') else 'text-slate-700 hover:bg-slate-100' }}">My Profile</a>
|
||||
<a href="/alerts" class="rounded-xl px-4 py-2 text-sm font-semibold transition {{ 'bg-brand-600 text-white' if path == '/alerts' else 'text-slate-700 hover:bg-slate-100' }}">My Alert</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1 @@
|
||||
{% extends "ui/templates/base/layout.html" %}{% block content %}<div class="space-y-6"><div><h2 class="text-2xl font-semibold text-slate-900">Add Client</h2><p class="text-sm text-slate-500">Create a validated client master with ownership and branch-safe rules.</p></div>{% if form_errors %}<div class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-900 shadow-soft">{% for err in form_errors %}<div>{{ err }}</div>{% endfor %}</div>{% endif %}<form method="post" action="/clients" class="space-y-6"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">{% include "modules/clients/templates/clients/partials/form.html" %}<div class="flex justify-end gap-3"><a href="/clients" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</a><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Save Client</button></div></form></div>{% endblock %}
|
||||
@@ -0,0 +1,23 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/clients/templates/clients/_client_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div><h2 class="text-2xl font-semibold text-slate-900">My Compliance</h2><p class="text-sm text-slate-500">Service-wise status and pending actions visible to you.</p></div>
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-sm text-slate-500">Pending from Client</div><div class="mt-2 text-2xl font-semibold">{{ pending_from_client or 0 }}</div></div>
|
||||
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-sm text-slate-500">With Firm</div><div class="mt-2 text-2xl font-semibold">{{ with_firm or 0 }}</div></div>
|
||||
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-sm text-slate-500">Clarification Required</div><div class="mt-2 text-2xl font-semibold">{{ clarification_required or 0 }}</div></div>
|
||||
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-sm text-slate-500">Completed</div><div class="mt-2 text-2xl font-semibold">{{ completed_engagements or 0 }}</div></div>
|
||||
</div>
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
{% for row in engagements %}
|
||||
<a href="/work/engagements/{{ row.id }}" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft hover:bg-slate-50">
|
||||
<div class="flex items-start justify-between gap-3"><div><h3 class="font-semibold text-slate-900">{{ row.catalogue.service_name if row.catalogue else 'Service' }}</h3><p class="text-xs text-slate-500">FY {{ row.financial_year }}{% if row.assessment_year %} • AY {{ row.assessment_year }}{% endif %}</p></div><span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700">{{ row.status|replace('_',' ')|title }}</span></div>
|
||||
<div class="mt-4 grid gap-3 text-sm md:grid-cols-3"><div><div class="text-xs text-slate-500">Due Date</div><div>{{ row.current_due_date.strftime('%d-%m-%Y') if row.current_due_date else '-' }}</div></div><div><div class="text-xs text-slate-500">Firm Contact</div><div>{{ row.assigned_manager.full_name if row.assigned_manager else (row.assigned_partner.full_name if row.assigned_partner else 'Firm team') }}</div></div><div><div class="text-xs text-slate-500">Status</div><div>{{ row.status|replace('_',' ')|title }}</div></div></div>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-dashed border-slate-300 bg-white p-8 text-sm text-slate-500 lg:col-span-2">No active compliance services are assigned yet.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,92 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-slate-900">{{ row.client_name }}</h2>
|
||||
<p class="text-sm text-slate-500">{{ row.client_code }} • {{ row.client_type }} • {{ row.status|title }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
{% if can_edit %}
|
||||
<a href="/clients/{{ row.id }}/edit"
|
||||
class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
||||
Edit
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if can_activate and row.status != 'active' and row.status != 'archived' %}
|
||||
<form method="post" action="/clients/{{ row.id }}/activate">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="rounded-xl border border-emerald-300 px-4 py-2 text-sm font-medium text-emerald-700 hover:bg-emerald-50">
|
||||
Activate
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if can_deactivate and row.status == 'active' %}
|
||||
<form method="post" action="/clients/{{ row.id }}/deactivate">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="rounded-xl border border-rose-300 px-4 py-2 text-sm font-medium text-rose-700 hover:bg-rose-50">
|
||||
Deactivate
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if can_archive and row.status != 'archived' %}
|
||||
<form method="post" action="/clients/{{ row.id }}/archive">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="rounded-xl border border-amber-300 px-4 py-2 text-sm font-medium text-amber-800 hover:bg-amber-50">
|
||||
Archive
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if can_restore and row.status == 'archived' %}
|
||||
<form method="post" action="/clients/{{ row.id }}/restore">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="rounded-xl border border-sky-300 px-4 py-2 text-sm font-medium text-sky-700 hover:bg-sky-50">
|
||||
Restore
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-3">
|
||||
<section class="rounded-2xl bg-white p-6 shadow-soft xl:col-span-2">
|
||||
<h3 class="text-base font-semibold text-slate-900">Profile</h3>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
{% for label, value in [
|
||||
('Trade Name', row.trade_name),
|
||||
('PAN', row.pan),
|
||||
('GSTIN', row.gstin),
|
||||
('TAN', row.tan),
|
||||
('Contact Person', row.contact_person_name),
|
||||
('Designation', row.contact_person_designation),
|
||||
('Mobile', row.mobile),
|
||||
('Email', row.email)
|
||||
] %}
|
||||
<div class="rounded-xl border border-slate-200 px-4 py-3">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">{{ label }}</div>
|
||||
<div class="mt-1 text-sm text-slate-800">{{ value or '-' }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl bg-white p-6 shadow-soft">
|
||||
<h3 class="text-base font-semibold text-slate-900">Association</h3>
|
||||
<div class="mt-4 space-y-2 text-sm text-slate-700">
|
||||
<div><span class="font-medium">Type:</span> {{ row.association_type or 'legacy_firm' }}</div>
|
||||
<div><span class="font-medium">Source:</span> {{ row.assoc_created_source or 'legacy' }}</div>
|
||||
<div><span class="font-medium">Audit Firm:</span> {{ row.assoc_firm_tenant_id or row.tenant_id or '-' }}</div>
|
||||
<div><span class="font-medium">Branch:</span> {{ row.branch_id or '-' }}</div>
|
||||
<div><span class="font-medium">Partner:</span> {{ row.assoc_partner_user_id or row.partner_id or '-' }}</div>
|
||||
<div><span class="font-medium">Default Review Partner:</span> {{ row.default_review_partner_user_id or '-' }}</div>
|
||||
<div><span class="font-medium">Consultant:</span> {{ row.assoc_consultant_id or '-' }}</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,9 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/clients/templates/clients/_client_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div><h2 class="text-2xl font-semibold text-slate-900">My Documents</h2><p class="text-sm text-slate-500">View documents shared by your audit firm.</p></div>
|
||||
<section class="rounded-2xl bg-white p-6 shadow-soft"><h3 class="font-semibold text-slate-900">Engagement Documents</h3><div class="mt-4 overflow-hidden rounded-2xl border border-slate-200"><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">Document</th><th class="px-4 py-3">Service</th><th class="px-4 py-3">Type</th><th class="px-4 py-3">Version</th><th class="px-4 py-3 text-right">Action</th></tr></thead><tbody class="divide-y divide-slate-100">{% for doc in engagement_documents %}{% set ver = doc.versions[0] if doc.versions else None %}<tr><td class="px-4 py-3 font-medium text-slate-900">{{ doc.title }}</td><td class="px-4 py-3 text-slate-600">{{ doc.engagement.catalogue.service_name if doc.engagement and doc.engagement.catalogue else '-' }}</td><td class="px-4 py-3 text-slate-600">{{ doc.document_type }}</td><td class="px-4 py-3 text-slate-600">v{{ doc.current_version_no }}</td><td class="px-4 py-3 text-right">{% if ver %}<a href="/client/documents/engagement-versions/{{ ver.id }}/download" class="font-semibold text-brand-700 hover:underline">Download</a>{% endif %}</td></tr>{% else %}<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">No engagement documents shared yet.</td></tr>{% endfor %}</tbody></table></div></section>
|
||||
<section class="rounded-2xl bg-white p-6 shadow-soft"><h3 class="font-semibold text-slate-900">Permanent Documents</h3><div class="mt-4 overflow-hidden rounded-2xl border border-slate-200"><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">Document</th><th class="px-4 py-3">Category</th><th class="px-4 py-3">Version</th><th class="px-4 py-3 text-right">Action</th></tr></thead><tbody class="divide-y divide-slate-100">{% for doc in permanent_documents %}{% set ver = doc.versions[0] if doc.versions else None %}<tr><td class="px-4 py-3 font-medium text-slate-900">{{ doc.title }}</td><td class="px-4 py-3 text-slate-600">{{ doc.category }}</td><td class="px-4 py-3 text-slate-600">v{{ doc.current_version_no }}</td><td class="px-4 py-3 text-right">{% if ver %}<a href="/client/documents/permanent-versions/{{ ver.id }}/download" class="font-semibold text-brand-700 hover:underline">Download</a>{% endif %}</td></tr>{% else %}<tr><td colspan="4" class="px-4 py-8 text-center text-slate-500">No permanent documents shared yet.</td></tr>{% endfor %}</tbody></table></div></section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,26 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-slate-900">Edit Client</h2>
|
||||
<p class="text-sm text-slate-500">B5 role-aware edit flow.</p>
|
||||
</div>
|
||||
|
||||
{% if form_errors %}
|
||||
<div class="rounded-2xl border border-rose-200 bg-rose-50 p-4">
|
||||
<ul class="list-disc pl-5 text-sm text-rose-700">
|
||||
{% for err in form_errors %}<li>{{ err }}</li>{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/clients/{{ row.id }}/edit" class="space-y-6">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
{% include "modules/clients/templates/clients/partials/form.html" %}
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<a href="/clients/{{ row.id }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</a>
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,14 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/clients/templates/clients/_client_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between"><div><h2 class="text-2xl font-semibold text-slate-900">{{ engagement.catalogue.service_name if engagement.catalogue else 'Engagement' }}</h2><p class="text-sm text-slate-500">FY {{ engagement.financial_year }}{% if engagement.assessment_year %} • AY {{ engagement.assessment_year }}{% endif %} • Due {{ engagement.current_due_date.strftime('%d-%m-%Y') if engagement.current_due_date else '-' }}</p></div><a href="/client/compliance" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back to My Compliance</a></div>
|
||||
<div class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<section class="space-y-4">
|
||||
<div class="rounded-2xl bg-white p-6 shadow-soft"><h3 class="font-semibold text-slate-900">Task / Action Status</h3><div class="mt-4 space-y-3">{% for task in tasks %}<details class="rounded-2xl border border-slate-200 p-4" {% if loop.first %}open{% endif %}><summary class="cursor-pointer list-none"><div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between"><div><div class="font-semibold text-slate-900">{{ task.task_name }}</div><div class="text-xs text-slate-500">{{ task.description or '' }}</div></div><span class="w-fit rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700">{{ task.status|replace('_',' ')|title }}</span></div></summary><div class="mt-4 border-t border-slate-100 pt-4"><form method="post" action="/client/tasks/{{ task.id }}/reply" class="space-y-3"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><label class="block text-sm font-medium text-slate-700">Reply / clarification for firm</label><textarea name="message" rows="3" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" placeholder="Type your clarification, confirmation or query for the firm..."></textarea><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Send Reply</button></form></div></details>{% else %}<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500">No task details available.</div>{% endfor %}</div></div>
|
||||
<div class="rounded-2xl bg-white p-6 shadow-soft"><h3 class="font-semibold text-slate-900">Communication Timeline</h3><div class="mt-4 space-y-3">{% for note in comments %}<div class="rounded-2xl border border-slate-200 p-4 text-sm"><div class="flex justify-between gap-3"><div class="font-semibold text-slate-900">{{ note.task.task_name if note.task else 'Message' }}</div><div class="text-xs text-slate-500">{{ note.created_at_utc.strftime('%d-%m-%Y %I:%M %p') if note.created_at_utc else '' }}</div></div><p class="mt-2 whitespace-pre-line text-slate-700">{{ note.message }}</p></div>{% else %}<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500">No visible communication yet.</div>{% endfor %}</div></div>
|
||||
</section>
|
||||
<aside class="rounded-2xl bg-white p-6 shadow-soft"><h3 class="font-semibold text-slate-900">Engagement Documents</h3><div class="mt-4 space-y-3">{% for doc in documents %}{% set ver = doc.versions[0] if doc.versions else None %}<div class="rounded-2xl border border-slate-200 p-4"><div class="font-semibold text-slate-900">{{ doc.title }}</div><div class="text-xs text-slate-500">{{ doc.document_type }} • v{{ doc.current_version_no }}</div>{% if ver %}<a href="/client/documents/engagement-versions/{{ ver.id }}/download" 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">Download</a>{% endif %}</div>{% else %}<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500">No documents uploaded for this engagement yet.</div>{% endfor %}</div></aside>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,59 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-slate-900">Import Clients</h2>
|
||||
<p class="text-sm text-slate-500">Bulk upload clients for the active audit firm with partner validation.</p>
|
||||
</div>
|
||||
<a href="/clients/import/template" class="inline-flex rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Download Template</a>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl bg-white p-6 shadow-soft space-y-4">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
|
||||
<div class="font-semibold text-slate-900">Active Audit Firm</div>
|
||||
<div class="mt-1">{{ current_tenant.name if current_tenant else scope.tenant_id }} (ID: {{ scope.tenant_id }})</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
|
||||
<div class="font-semibold text-slate-900">Logged-in uploader</div>
|
||||
<div class="mt-1">{{ current_user.full_name or current_user.email }} — User ID {{ current_user.id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-sky-200 bg-sky-50 p-4 text-sm text-sky-900">
|
||||
Template includes <strong>uploader_user_id</strong>, <strong>firm_tenant_id</strong>, and <strong>partner_user_id</strong>.
|
||||
Validation checks that uploader_user_id matches the logged-in user, firm_tenant_id matches the active audit firm, and partner_user_id belongs to an active Partner in that same audit firm.
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 p-4">
|
||||
<div class="text-sm font-semibold text-slate-900">Partners available in this audit firm</div>
|
||||
<div class="mt-2 text-sm text-slate-600">
|
||||
{% for p in partners %}
|
||||
<div>{{ p.id }} — {{ p.full_name or p.email }}</div>
|
||||
{% else %}
|
||||
<div>No active partners found for this audit firm.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if import_errors %}
|
||||
<div class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-900">
|
||||
{% for err in import_errors %}<div>{{ err }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/clients/import/preview" enctype="multipart/form-data" class="space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Excel file</label>
|
||||
<input type="file" name="excel_file" accept=".xlsx" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" required>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3">
|
||||
<a href="/clients" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back</a>
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Validate File</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,71 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-slate-900">Import Clients Preview</h2>
|
||||
<p class="text-sm text-slate-500">Review validation result before final import.</p>
|
||||
</div>
|
||||
|
||||
{% if import_result is defined and import_result %}
|
||||
<div class="rounded-2xl bg-white p-6 shadow-soft space-y-4">
|
||||
<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900">Created {{ import_result.created|length }} clients.</div>
|
||||
{% if import_result.created %}
|
||||
<div class="rounded-xl border border-slate-200 p-4 text-sm">
|
||||
{% for row in import_result.created %}<div>{{ row.client_code }} — {{ row.client_name }} (ID {{ row.id }})</div>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if import_result.failures %}
|
||||
<div class="rounded-xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900">
|
||||
{% for err in import_result.failures %}<div>Row {{ err.row_number }}: {{ err.message }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex justify-end"><a href="/clients" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Back to Clients</a></div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<section class="rounded-2xl bg-white p-6 shadow-soft">
|
||||
<h3 class="text-base font-semibold text-slate-900">Valid rows</h3>
|
||||
<div class="mt-3 text-sm text-slate-600">{{ preview.valid_rows|length }} of {{ preview.total_rows }} rows are ready to import.</div>
|
||||
<div class="mt-4 max-h-[28rem] overflow-auto rounded-xl border border-slate-200">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50"><tr><th class="px-4 py-2 text-left">Row</th><th class="px-4 py-2 text-left">Audit Firm ID</th><th class="px-4 py-2 text-left">Partner</th><th class="px-4 py-2 text-left">Client Code</th><th class="px-4 py-2 text-left">Client Name</th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-100 bg-white">
|
||||
{% for item in preview.valid_rows %}
|
||||
<tr><td class="px-4 py-2">{{ item.row_number }}</td><td class="px-4 py-2">{{ item.tenant_id }}</td><td class="px-4 py-2">{{ item.partner_id }}</td><td class="px-4 py-2">{{ item.client_payload.client_code }}</td><td class="px-4 py-2">{{ item.client_payload.client_name }}</td></tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="px-4 py-6 text-center text-slate-500">No valid rows found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl bg-white p-6 shadow-soft">
|
||||
<h3 class="text-base font-semibold text-slate-900">Validation errors</h3>
|
||||
<div class="mt-3 text-sm text-slate-600">{{ preview.errors|length }} rows have issues.</div>
|
||||
<div class="mt-4 max-h-[28rem] space-y-3 overflow-auto">
|
||||
{% for err in preview.errors %}
|
||||
<div class="rounded-xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900">
|
||||
<div class="font-semibold">Row {{ err.row_number }}</div>
|
||||
<ul class="mt-2 list-disc space-y-1 pl-5">{% for msg in err.messages %}<li>{{ msg }}</li>{% endfor %}</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900">No validation errors found.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<a href="/clients/import" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back</a>
|
||||
{% if preview.valid_rows %}
|
||||
<form method="post" action="/clients/import/commit">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<textarea name="preview_payload" hidden>{{ preview_payload }}</textarea>
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Import {{ preview.valid_rows|length }} Valid Rows</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,36 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-slate-900">Clients</h2>
|
||||
<p class="text-sm text-slate-500">Association-aware list view.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
{% if can_export %}
|
||||
<a href="/clients/export?q={{ q }}&status={{ status }}&client_type={{ client_type }}&include_archived={{ include_archived }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}"
|
||||
class="inline-flex rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
||||
Export CSV
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if can_import %}
|
||||
<a href="/clients/import"
|
||||
class="inline-flex rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
||||
Import Clients
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if can_create %}
|
||||
<a href="/clients/new"
|
||||
class="inline-flex rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">
|
||||
Add Client
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include "modules/clients/templates/clients/partials/table.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,5 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/clients/templates/clients/_client_tabs.html" %}
|
||||
<div class="space-y-6"><div><h2 class="text-2xl font-semibold text-slate-900">My Messages</h2><p class="text-sm text-slate-500">Client-visible communications and clarifications from your firm.</p></div><section class="rounded-2xl bg-white p-6 shadow-soft"><div class="space-y-4">{% for note in comments %}<article class="rounded-2xl border border-slate-200 p-4"><div class="flex flex-col gap-2 md:flex-row md:items-start md:justify-between"><div><div class="text-xs font-semibold uppercase tracking-wide text-brand-700">{{ note.comment_type|replace('_',' ')|title }}</div><h3 class="mt-1 font-semibold text-slate-900">{{ note.task.task_name if note.task else 'Message' }}</h3><div class="text-xs text-slate-500">{{ note.subscription.catalogue.service_name if note.subscription and note.subscription.catalogue else '' }}</div></div><div class="text-xs text-slate-500">{{ note.created_at_utc.strftime('%d-%m-%Y %I:%M %p') if note.created_at_utc else '' }}</div></div><p class="mt-3 whitespace-pre-line text-sm leading-6 text-slate-700">{{ note.message }}</p><div class="mt-3 text-xs text-slate-500">From: {% if note.created_by %}{{ note.created_by.full_name or note.created_by.email }}{% else %}Firm team{% endif %}</div>{% if note.task %}<a href="/client/engagements/{{ note.task.subscription_id }}" class="mt-3 inline-flex text-sm font-semibold text-brand-700 hover:underline">Open related work</a>{% endif %}</article>{% else %}<div class="rounded-2xl border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500">No messages found.</div>{% endfor %}</div></section></div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1 @@
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200"><table class="min-w-full divide-y divide-slate-200"><thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">When</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Action</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Summary</th></tr></thead><tbody class="divide-y divide-slate-100">{% for log in audit_logs %}<tr><td class="px-4 py-3 text-sm text-slate-700">{{ log.created_at_utc }}</td><td class="px-4 py-3 text-sm text-slate-700">{{ log.action }}</td><td class="px-4 py-3 text-sm text-slate-700">{{ log.summary }}</td></tr>{% else %}<tr><td colspan="3" class="px-4 py-6 text-center text-sm text-slate-500">No audit entries yet.</td></tr>{% endfor %}</tbody></table></div>
|
||||
@@ -0,0 +1 @@
|
||||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">{% for label, value in [('GST', row.gst_applicable),('Income Tax', row.income_tax_applicable),('TDS', row.tds_applicable),('ROC', row.roc_applicable),('Audit', row.audit_applicable),('PF', row.pf_applicable),('ESI', row.esi_applicable),('Professional Tax', row.professional_tax_applicable),('Payroll', row.payroll_applicable),('MSME', row.msme_applicable),('Import / Export', row.import_export_applicable)] %}<div class="rounded-xl border border-slate-200 px-3 py-3 text-sm"><div class="font-medium text-slate-700">{{ label }}</div><div class="mt-1 {% if value %}text-emerald-700{% else %}text-slate-500{% endif %}">{% if value %}Applicable{% else %}Not Applicable{% endif %}</div></div>{% endfor %}</div>
|
||||
@@ -0,0 +1,372 @@
|
||||
{% set is_edit = row is defined and row %}
|
||||
<div class="grid gap-6 xl:grid-cols-3">
|
||||
<section class="rounded-2xl bg-white p-6 shadow-soft xl:col-span-2">
|
||||
<h3 class="text-base font-semibold text-slate-900">Basic Profile</h3>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Client Code</label>
|
||||
<input name="client_code" value="{{ form_data.client_code or (row.client_code if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" {% if is_edit %}readonly{% endif %}>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Client Name</label>
|
||||
<input name="client_name" value="{{ form_data.client_name or (row.client_name if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Trade Name</label>
|
||||
<input name="trade_name" value="{{ form_data.trade_name or (row.trade_name if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Client Type</label>
|
||||
<select name="client_type" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
{% for opt in client_types %}
|
||||
<option value="{{ opt }}" {% if (form_data.client_type or (row.client_type if is_edit else 'Other')) == opt %}selected{% endif %}>{{ opt }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">PAN</label>
|
||||
<input name="pan" value="{{ form_data.pan or (row.pan if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">GSTIN</label>
|
||||
<input name="gstin" value="{{ form_data.gstin or (row.gstin if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">TAN</label>
|
||||
<input name="tan" value="{{ form_data.tan or (row.tan if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">CIN / LLPIN</label>
|
||||
<input name="cin_llpin" value="{{ form_data.cin_llpin or (row.cin_llpin if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">MSME Number</label>
|
||||
<input name="msme_no" value="{{ form_data.msme_no or (row.msme_no if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">IEC Code</label>
|
||||
<input name="iec_code" value="{{ form_data.iec_code or (row.iec_code if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Mobile</label>
|
||||
<input name="mobile" value="{{ form_data.mobile or (row.mobile if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Alternate Mobile</label>
|
||||
<input name="alternate_mobile" value="{{ form_data.alternate_mobile or (row.alternate_mobile if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Email (used for frontend login)</label>
|
||||
<input name="email" value="{{ form_data.email or (row.email if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Alternate Email</label>
|
||||
<input name="alternate_email" value="{{ form_data.alternate_email or (row.alternate_email if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Contact Person</label>
|
||||
<input name="contact_person_name" value="{{ form_data.contact_person_name or (row.contact_person_name if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Designation</label>
|
||||
<input name="contact_person_designation" value="{{ form_data.contact_person_designation or (row.contact_person_designation if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Client Category</label>
|
||||
<select name="client_category" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<option value="">-- Select --</option>
|
||||
{% for opt in client_categories %}
|
||||
<option value="{{ opt }}" {% if (form_data.client_category or (row.client_category if is_edit else '')) == opt %}selected{% endif %}>{{ opt }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Risk Category</label>
|
||||
<select name="risk_category" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<option value="">-- Select --</option>
|
||||
{% for opt in risk_categories %}
|
||||
<option value="{{ opt }}" {% if (form_data.risk_category or (row.risk_category if is_edit else '')) == opt %}selected{% endif %}>{{ opt }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Onboarding Date</label>
|
||||
<input type="date" name="onboarding_date" value="{{ form_data.onboarding_date or (row.onboarding_date if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Closing Date</label>
|
||||
<input type="date" name="closing_date" value="{{ form_data.closing_date or (row.closing_date if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-slate-700">Address Line 1</label>
|
||||
<input name="address_line_1" value="{{ form_data.address_line_1 or (row.address_line_1 if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-slate-700">Address Line 2</label>
|
||||
<input name="address_line_2" value="{{ form_data.address_line_2 or (row.address_line_2 if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">City</label>
|
||||
<input name="city" value="{{ form_data.city or (row.city if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">State</label>
|
||||
<input name="state" value="{{ form_data.state or (row.state if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Pincode</label>
|
||||
<input name="pincode" value="{{ form_data.pincode or (row.pincode if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Country</label>
|
||||
<input name="country" value="{{ form_data.country or (row.country if is_edit else 'India') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-slate-700">Notes</label>
|
||||
<textarea name="notes" rows="4" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">{{ form_data.notes or (row.notes if is_edit else '') }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl bg-white p-6 shadow-soft">
|
||||
<h3 class="text-base font-semibold text-slate-900">Assignment & Scope</h3>
|
||||
<div class="mt-4 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Engagement Mode</label>
|
||||
<select name="engagement_mode" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
{% for opt in ['internal_managed','self_tracked','hybrid'] %}
|
||||
<option value="{{ opt }}" {% if (form_data.engagement_mode or (row.engagement_mode if is_edit else 'internal_managed')) == opt %}selected{% endif %}>{{ opt }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{% if form_mode == 'firm_admin' %}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Audit Firm</label>
|
||||
<div class="mt-1 rounded-xl border border-slate-300 bg-slate-50 px-4 py-2 text-sm text-slate-700">
|
||||
{% for t in form_options.tenants %}
|
||||
{{ t.name }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<input type="hidden" name="tenant_id" value="{{ form_data.tenant_id or (row.tenant_id if is_edit else form_options.active_tenant_id) }}">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Branch</label>
|
||||
<select name="branch_id" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
{% for b in form_options.branches %}
|
||||
<option value="{{ b.id }}" {% if (form_data.branch_id or (row.branch_id if is_edit else form_options.active_branch_id)) == b.id %}selected{% endif %}>{{ b.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Partner</label>
|
||||
<select name="partner_id" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<option value="">-- Select Partner --</option>
|
||||
{% for p in form_options.partners %}
|
||||
<option value="{{ p.id }}" {% if (form_data.partner_id or (row.partner_id if is_edit else None)) == p.id %}selected{% endif %}>{{ p.full_name or p.email }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{% elif form_mode == 'system_admin' %}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Audit Firm</label>
|
||||
<select name="tenant_id" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
{% for t in form_options.tenants %}
|
||||
<option value="{{ t.id }}" {% if (form_data.tenant_id or (row.tenant_id if is_edit else form_options.active_tenant_id)) == t.id %}selected{% endif %}>{{ t.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Branch</label>
|
||||
<select name="branch_id" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<option value="">-- Select Branch --</option>
|
||||
{% for b in form_options.branches %}
|
||||
<option value="{{ b.id }}" {% if (form_data.branch_id or (row.branch_id if is_edit else form_options.active_branch_id)) == b.id %}selected{% endif %}>
|
||||
{{ b.name }}{% if b.tenant_name %} ({{ b.tenant_name }}){% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Partner</label>
|
||||
<select name="partner_id" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<option value="">-- Select Partner --</option>
|
||||
{% for p in form_options.partners %}
|
||||
<option value="{{ p.id }}" {% if (form_data.partner_id or (row.partner_id if is_edit else None)) == p.id %}selected{% endif %}>
|
||||
{{ p.full_name or p.email }}{% if p.tenant_name %} ({{ p.tenant_name }}){% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{% elif form_mode == 'partner' %}
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
|
||||
Partner assignment is locked to your own user.
|
||||
<input type="hidden" name="tenant_id" value="{{ form_data.tenant_id or (row.tenant_id if is_edit else form_options.active_tenant_id) }}">
|
||||
<input type="hidden" name="branch_id" value="{{ form_data.branch_id or (row.branch_id if is_edit else form_options.active_branch_id) }}">
|
||||
<input type="hidden" name="partner_id" value="{{ current_user.id }}">
|
||||
</div>
|
||||
|
||||
{% elif form_mode == 'consultant' %}
|
||||
<div class="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
|
||||
Consultant users cannot assign or reassign partner mappings.
|
||||
<input type="hidden" name="tenant_id" value="{{ form_data.tenant_id or (row.tenant_id if is_edit else form_options.active_tenant_id) }}">
|
||||
<input type="hidden" name="branch_id" value="{{ form_data.branch_id or (row.branch_id if is_edit else form_options.active_branch_id) }}">
|
||||
<input type="hidden" name="partner_id" value="{{ form_data.partner_id or (row.partner_id if is_edit else '') }}">
|
||||
</div>
|
||||
|
||||
{% elif form_mode == 'self_service' %}
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
|
||||
This client is currently unassigned. Initial association to firm, branch, and partner must be done by System Admin.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Default Review Partner</label>
|
||||
<select name="default_review_partner_user_id" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<option value="">-- Not required / Select later --</option>
|
||||
{% for p in form_options.review_partners or [] %}
|
||||
<option value="{{ p.id }}" {% if (form_data.default_review_partner_user_id or (row.default_review_partner_user_id if is_edit else None)) == p.id %}selected{% endif %}>
|
||||
{{ p.full_name or p.email }}{% if p.tenant_name %} ({{ p.tenant_name }}){% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-slate-500">Used automatically for assurance engagements only when the audit firm is a partnership firm.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Status</label>
|
||||
<select name="status" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
{% for opt in client_statuses %}
|
||||
<option value="{{ opt }}" {% if (form_data.status or (row.status if is_edit else 'active')) == opt %}selected{% endif %}>{{ opt|title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-slate-200 pt-4">
|
||||
<h4 class="mb-3 text-sm font-semibold text-slate-900">Client Frontend Login</h4>
|
||||
<div class="rounded-xl border border-sky-200 bg-sky-50 p-3 text-xs text-sky-800">
|
||||
{% if is_edit and row.portal_user_id %}
|
||||
Linked portal user already exists. Leave password blank to keep the current password, or enter a new password to reset it.
|
||||
{% elif is_edit %}
|
||||
This existing client does not yet have a linked login. Enter email and password below to create the client login now.
|
||||
{% else %}
|
||||
Creating a client will also create a frontend login using the client email and password below.
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{% if is_edit and row.portal_user_id %}New Password{% else %}Password{% endif %}</label>
|
||||
<input name="portal_password" type="password" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" {% if not is_edit %}required{% endif %}>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{% if is_edit and row.portal_user_id %}Confirm New Password{% else %}Confirm Password{% endif %}</label>
|
||||
<input name="portal_password_confirm" type="password" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" {% if not is_edit %}required{% endif %}>
|
||||
</div>
|
||||
|
||||
{% if is_edit and row.portal_user_id %}
|
||||
<div class="text-xs text-slate-500">
|
||||
Portal user id linked: {{ row.portal_user_id }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-slate-200 pt-4">
|
||||
<h4 class="mb-3 text-sm font-semibold text-slate-900">Compliance Applicability</h4>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="gst_applicable" value="1" {% if form_data.gst_applicable or (row.gst_applicable if is_edit else false) %}checked{% endif %}>
|
||||
GST Applicable
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="income_tax_applicable" value="1" {% if form_data.income_tax_applicable or (row.income_tax_applicable if is_edit else false) %}checked{% endif %}>
|
||||
Income Tax Applicable
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="tds_applicable" value="1" {% if form_data.tds_applicable or (row.tds_applicable if is_edit else false) %}checked{% endif %}>
|
||||
TDS Applicable
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="roc_applicable" value="1" {% if form_data.roc_applicable or (row.roc_applicable if is_edit else false) %}checked{% endif %}>
|
||||
ROC Applicable
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="audit_applicable" value="1" {% if form_data.audit_applicable or (row.audit_applicable if is_edit else false) %}checked{% endif %}>
|
||||
Audit Applicable
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="pf_applicable" value="1" {% if form_data.pf_applicable or (row.pf_applicable if is_edit else false) %}checked{% endif %}>
|
||||
PF Applicable
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="esi_applicable" value="1" {% if form_data.esi_applicable or (row.esi_applicable if is_edit else false) %}checked{% endif %}>
|
||||
ESI Applicable
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="professional_tax_applicable" value="1" {% if form_data.professional_tax_applicable or (row.professional_tax_applicable if is_edit else false) %}checked{% endif %}>
|
||||
Professional Tax Applicable
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="payroll_applicable" value="1" {% if form_data.payroll_applicable or (row.payroll_applicable if is_edit else false) %}checked{% endif %}>
|
||||
Payroll Applicable
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="msme_applicable" value="1" {% if form_data.msme_applicable or (row.msme_applicable if is_edit else false) %}checked{% endif %}>
|
||||
MSME Applicable
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="import_export_applicable" value="1" {% if form_data.import_export_applicable or (row.import_export_applicable if is_edit else false) %}checked{% endif %}>
|
||||
Import / Export Applicable
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,2 @@
|
||||
|
||||
<div class="overflow-hidden rounded-2xl bg-white shadow-soft"><table class="min-w-full divide-y divide-slate-200"><thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Code</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Client</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Association</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Partner</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Branch</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Status</th><th class="px-4 py-3"></th></tr></thead><tbody class="divide-y divide-slate-100">{% for row in rows %}<tr><td class="px-4 py-3 text-sm font-medium text-slate-900">{{ row.client_code }}</td><td class="px-4 py-3 text-sm text-slate-700"><div class="font-medium">{{ row.client_name }}</div><div class="text-xs text-slate-500">{{ row.pan or row.gstin or '-' }}</div></td><td class="px-4 py-3 text-sm text-slate-700"><div>{{ row.association_type or 'legacy_firm' }}</div><div class="text-xs text-slate-500">{{ row.assoc_created_source or 'legacy' }}</div></td><td class="px-4 py-3 text-sm text-slate-700">{{ row.partner_name or row.effective_partner_id or '-' }}</td><td class="px-4 py-3 text-sm text-slate-700">{{ row.branch_name or row.assoc_firm_branch_id or row.branch_id or '-' }}</td><td class="px-4 py-3 text-sm">{% if row.status == 'active' %}<span class="rounded-full bg-emerald-100 px-2 py-1 text-xs font-medium text-emerald-700">Active</span>{% elif row.status == 'archived' %}<span class="rounded-full bg-amber-100 px-2 py-1 text-xs font-medium text-amber-800">Archived</span>{% else %}<span class="rounded-full bg-slate-200 px-2 py-1 text-xs font-medium text-slate-700">Inactive</span>{% endif %}</td><td class="px-4 py-3 text-right"><a href="/clients/{{ row.id }}" class="text-sm font-medium text-brand-700 hover:underline">Open</a></td></tr>{% else %}<tr><td colspan="7" class="px-4 py-8 text-center text-sm text-slate-500">No clients found.</td></tr>{% endfor %}</tbody></table></div>
|
||||
@@ -0,0 +1,93 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
{% include "modules/clients/templates/clients/_client_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 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-brand-100">Client Portal</p>
|
||||
<h2 class="mt-2 text-2xl font-semibold">My Compliance & Firm Communication</h2>
|
||||
<p class="mt-2 max-w-3xl text-sm text-brand-100">Track your compliance status, pending actions, required documents, messages and firm updates.</p>
|
||||
</div>
|
||||
{% if client_row %}<a href="/client/profile" class="rounded-xl bg-white px-4 py-2 text-sm font-semibold text-brand-700 hover:bg-brand-50">Update My Profile</a>{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if not client_row %}
|
||||
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-5 text-sm text-amber-900 shadow-soft">
|
||||
We could not find a client master linked to your login email in the current audit firm. Please contact your firm admin to map this login to the correct client record.
|
||||
</div>
|
||||
{% else %}
|
||||
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||
<a href="/client/compliance" class="af-metric-card hover:border-brand-200"><div class="text-xs font-semibold uppercase text-slate-500">Active Compliance</div><div class="mt-2 text-3xl font-semibold text-slate-900">{{ total_engagements or 0 }}</div><div class="mt-1 text-xs text-slate-500">Services / filings</div></a>
|
||||
<a href="/client/compliance" 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 Action</div><div class="mt-2 text-3xl font-semibold text-amber-700">{{ pending_from_client or 0 }}</div><div class="mt-1 text-xs text-amber-700">Required from you</div></a>
|
||||
<a href="/client/compliance" class="af-metric-card hover:border-brand-200"><div class="text-xs font-semibold uppercase text-slate-500">With Firm</div><div class="mt-2 text-3xl font-semibold text-brand-700">{{ with_firm or 0 }}</div><div class="mt-1 text-xs text-slate-500">Being handled</div></a>
|
||||
<a href="/client/documents" class="af-metric-card hover:border-brand-200"><div class="text-xs font-semibold uppercase text-slate-500">Documents</div><div class="mt-2 text-3xl font-semibold text-slate-900">{{ recent_documents|length if recent_documents else 0 }}</div><div class="mt-1 text-xs text-slate-500">Recent uploads</div></a>
|
||||
<a href="/client/billing" class="af-metric-card border-amber-200 bg-amber-50 hover:border-amber-300"><div class="text-xs font-semibold uppercase text-amber-700">Outstanding Bills</div><div class="mt-2 text-3xl font-semibold text-amber-700">₹ {{ '%.2f'|format(billing_outstanding_amount or 0) }}</div><div class="mt-1 text-xs text-amber-700">{{ billing_open_count or 0 }} open bill(s)</div></a>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_380px]">
|
||||
<div 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">Compliance Status</h3><p class="mt-1 text-sm text-slate-500">Simple client-facing status of your active services.</p></div><a href="/client/compliance" class="af-btn af-btn-primary">View All</a></div>
|
||||
<div class="mt-5 grid gap-3 md:grid-cols-4 text-sm">
|
||||
<div class="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3"><div class="text-amber-700">Pending from You</div><div class="mt-1 text-2xl font-semibold text-amber-700">{{ pending_from_client or 0 }}</div></div>
|
||||
<div class="rounded-2xl border border-brand-200 bg-brand-50 px-4 py-3"><div class="text-brand-700">With Firm</div><div class="mt-1 text-2xl font-semibold text-brand-700">{{ with_firm or 0 }}</div></div>
|
||||
<div class="rounded-2xl border border-blue-200 bg-blue-50 px-4 py-3"><div class="text-blue-700">Clarification</div><div class="mt-1 text-2xl font-semibold text-blue-700">{{ clarification_required or 0 }}</div></div>
|
||||
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3"><div class="text-emerald-700">Completed</div><div class="mt-1 text-2xl font-semibold text-emerald-700">{{ completed_engagements or 0 }}</div></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 space-y-3">
|
||||
{% for row in due_soon_engagements[:6] %}
|
||||
<a href="/client/engagements/{{ row.id }}" class="block rounded-2xl border border-slate-200 p-4 hover:bg-slate-50">
|
||||
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div><div class="font-semibold text-slate-900">{{ row.catalogue.service_name if row.catalogue else 'Service' }}</div><div class="text-xs text-slate-500">FY {{ row.financial_year }}{% if row.assessment_year %} • AY {{ row.assessment_year }}{% endif %}</div></div>
|
||||
<div class="text-sm text-slate-600">Due: {{ row.current_due_date.strftime('%d-%m-%Y') if row.current_due_date else '-' }}</div>
|
||||
</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500">No active compliance items found.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="af-card">
|
||||
<div class="flex items-center justify-between gap-3"><h3 class="text-lg font-semibold text-slate-900">Latest Messages</h3><a href="/client/messages" class="text-sm font-semibold text-brand-700 hover:underline">View all</a></div>
|
||||
<div class="mt-4 space-y-3">
|
||||
{% for note in client_visible_comments[:5] %}
|
||||
<div class="rounded-2xl border border-slate-200 p-4 text-sm"><div class="font-semibold text-slate-900">{{ note.task.task_name if note.task else 'Message' }}</div><p class="mt-2 text-slate-700">{{ note.message }}</p><div class="mt-2 text-xs text-slate-500">{{ note.created_at_utc.strftime('%d-%m-%Y %I:%M %p') if note.created_at_utc else '' }}</div></div>
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500">No messages yet.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="space-y-6">
|
||||
<div class="af-card">
|
||||
<h3 class="text-base font-semibold text-slate-900">My Client Profile</h3>
|
||||
<div class="mt-4 space-y-3 text-sm">
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Client</div><div class="font-semibold">{{ client_row.client_name }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">PAN / GSTIN</div><div>{{ client_row.pan or '-' }}{% if client_row.gstin %} / {{ client_row.gstin }}{% endif %}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Firm Contact</div><div>{{ client_row.partner_name or 'Firm team' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Branch</div><div>{{ client_row.branch_name or '-' }}</div></div>
|
||||
</div>
|
||||
<a href="/client/profile" class="mt-5 inline-flex af-btn af-btn-secondary">Update Profile</a>
|
||||
</div>
|
||||
|
||||
<div class="af-card">
|
||||
<h3 class="font-semibold text-slate-900">Quick Actions</h3>
|
||||
<div class="mt-4 grid gap-2 text-sm">
|
||||
<a href="/client/compliance" class="rounded-xl border border-slate-200 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-50">View My Compliance</a>
|
||||
<a href="/client/documents" class="rounded-xl border border-slate-200 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-50">Upload / View Documents</a>
|
||||
<a href="/client/messages" class="rounded-xl border border-slate-200 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-50">Messages from Firm</a>
|
||||
<a href="/client/billing" class="rounded-xl border border-slate-200 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-50">View Bills & Receipts</a>
|
||||
{% if billing_latest_due_invoice %}<a href="/client/billing/{{ billing_latest_due_invoice.id }}/pay-now" class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 font-semibold text-amber-700 hover:bg-amber-100">Pay Latest Due</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,109 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
{% include "modules/clients/templates/clients/_client_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-slate-900">Edit My Profile</h2>
|
||||
<p class="text-sm text-slate-500">You can update contact and communication details here. PAN, GSTIN and other compliance identity fields stay read-only.</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<a href="/change-password" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Change Password</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if form_errors %}
|
||||
<div class="rounded-2xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-800">
|
||||
<ul class="list-disc space-y-1 pl-5">
|
||||
{% for error in form_errors %}<li>{{ error }}</li>{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/client/profile" class="space-y-6">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<section class="rounded-2xl bg-white p-6 shadow-soft">
|
||||
<h3 class="text-base font-semibold text-slate-900">Read-only compliance identity</h3>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4 text-sm">
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3"><div class="text-xs font-medium uppercase tracking-wide text-slate-500">PAN</div><div class="mt-1 text-slate-900">{{ client_row.pan or '-' }}</div></div>
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3"><div class="text-xs font-medium uppercase tracking-wide text-slate-500">GSTIN</div><div class="mt-1 text-slate-900">{{ client_row.gstin or '-' }}</div></div>
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3"><div class="text-xs font-medium uppercase tracking-wide text-slate-500">TAN</div><div class="mt-1 text-slate-900">{{ client_row.tan or '-' }}</div></div>
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3"><div class="text-xs font-medium uppercase tracking-wide text-slate-500">CIN / LLPIN</div><div class="mt-1 text-slate-900">{{ client_row.cin_llpin or '-' }}</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl bg-white p-6 shadow-soft">
|
||||
<h3 class="text-base font-semibold text-slate-900">Editable profile details</h3>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Client Name</label>
|
||||
<input name="client_name" value="{{ form_data.client_name or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Trade Name</label>
|
||||
<input name="trade_name" value="{{ form_data.trade_name or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Contact Person Name</label>
|
||||
<input name="contact_person_name" value="{{ form_data.contact_person_name or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Designation</label>
|
||||
<input name="contact_person_designation" value="{{ form_data.contact_person_designation or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Mobile</label>
|
||||
<input name="mobile" value="{{ form_data.mobile or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Alternate Mobile</label>
|
||||
<input name="alternate_mobile" value="{{ form_data.alternate_mobile or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Login Email</label>
|
||||
<input type="email" name="email" value="{{ form_data.email or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<p class="mt-1 text-xs text-slate-500">If you change this, your next login will use the new email.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Alternate Email</label>
|
||||
<input type="email" name="alternate_email" value="{{ form_data.alternate_email or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-slate-700">Address Line 1</label>
|
||||
<input name="address_line_1" value="{{ form_data.address_line_1 or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-slate-700">Address Line 2</label>
|
||||
<input name="address_line_2" value="{{ form_data.address_line_2 or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">City</label>
|
||||
<input name="city" value="{{ form_data.city or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">State</label>
|
||||
<input name="state" value="{{ form_data.state or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Pincode</label>
|
||||
<input name="pincode" value="{{ form_data.pincode or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Country</label>
|
||||
<input name="country" value="{{ form_data.country or 'India' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-slate-700">Notes</label>
|
||||
<textarea name="notes" rows="4" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">{{ form_data.notes or '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="rounded-xl bg-brand-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-brand-700">Save Profile</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
|
||||
PAN_RE = re.compile(r"^[A-Z]{5}[0-9]{4}[A-Z]$")
|
||||
GSTIN_RE = re.compile(r"^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$")
|
||||
TAN_RE = re.compile(r"^[A-Z]{4}[0-9]{5}[A-Z]$")
|
||||
MOBILE_RE = re.compile(r"^[6-9][0-9]{9}$")
|
||||
PIN_RE = re.compile(r"^[0-9]{6}$")
|
||||
|
||||
def normalize_text(value):
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
def normalize_upper(value):
|
||||
value = normalize_text(value)
|
||||
return value.upper() if value else None
|
||||
|
||||
def build_csv(rows, headers):
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(headers)
|
||||
for row in rows:
|
||||
writer.writerow(row)
|
||||
return output.getvalue()
|
||||
@@ -0,0 +1 @@
|
||||
"""Consultant portal foundation module."""
|
||||
@@ -0,0 +1,311 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
|
||||
class ConsultantProfile(CommonBase):
|
||||
"""Portal-enabled consultant / ecosystem partner profile.
|
||||
|
||||
The login/security account remains in users. This table stores consultant
|
||||
business/profile details and links the consultant user to firm clients.
|
||||
"""
|
||||
|
||||
__tablename__ = "consultant_profiles"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "user_id", name="uq_consultant_profiles_tenant_user"),
|
||||
UniqueConstraint("tenant_id", "email", name="uq_consultant_profiles_tenant_email"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True)
|
||||
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
consultant_type: Mapped[str] = mapped_column(String(50), nullable=False, default="external_consultant", index=True)
|
||||
firm_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
contact_person: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
mobile: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
specialisation: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
gstin: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
pan: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
address: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, default="active", index=True)
|
||||
onboarding_status: Mapped[str] = mapped_column(String(30), nullable=False, default="approved", index=True)
|
||||
|
||||
is_platform_partner: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_franchise_partner: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_saas_customer: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||
)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
user = relationship("User", foreign_keys=[user_id])
|
||||
links = relationship(
|
||||
"ClientConsultantLink",
|
||||
back_populates="consultant",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
workspace = relationship(
|
||||
"ConsultantWorkspace",
|
||||
back_populates="consultant",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
managed_clients = relationship(
|
||||
"ConsultantManagedClient",
|
||||
back_populates="consultant",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
service_requests = relationship(
|
||||
"ConsultantServiceRequest",
|
||||
back_populates="consultant",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
|
||||
class ConsultantWorkspace(CommonBase):
|
||||
"""SaaS/franchise workspace settings for a consultant portal account.
|
||||
|
||||
This is a foundation table only. It does not change firm-owned clients or
|
||||
consultant-managed clients. It records whether the consultant operates as a
|
||||
SaaS customer, franchise partner, platform partner, or a normal external
|
||||
consultant, along with soft limits used by later subscription/billing phases.
|
||||
"""
|
||||
|
||||
__tablename__ = "consultant_workspaces"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "consultant_id", name="uq_consultant_workspaces_tenant_consultant"),
|
||||
UniqueConstraint("tenant_id", "workspace_code", name="uq_consultant_workspaces_tenant_code"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True)
|
||||
consultant_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("consultant_profiles.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
|
||||
workspace_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
workspace_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
workspace_type: Mapped[str] = mapped_column(String(50), nullable=False, default="consultant_saas", index=True)
|
||||
plan_code: Mapped[str] = mapped_column(String(50), nullable=False, default="starter", index=True)
|
||||
billing_cycle: Mapped[str] = mapped_column(String(30), nullable=False, default="manual", index=True)
|
||||
subscription_status: Mapped[str] = mapped_column(String(30), nullable=False, default="trial", index=True)
|
||||
subscription_start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
subscription_end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
|
||||
max_managed_clients: Mapped[int] = mapped_column(Integer, nullable=False, default=25)
|
||||
max_user_accounts: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
allow_client_portal: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
allow_firm_referrals: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
allow_service_marketplace: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||
)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
consultant = relationship("ConsultantProfile", back_populates="workspace")
|
||||
|
||||
|
||||
class ClientConsultantLink(CommonBase):
|
||||
"""Explicit link between a firm client and a consultant portal profile."""
|
||||
|
||||
__tablename__ = "client_consultant_links"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "client_id", "consultant_id", name="uq_client_consultant_links_client_consultant"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True)
|
||||
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
consultant_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("consultant_profiles.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
service_catalogue_id: Mapped[int | None] = mapped_column(ForeignKey("service_catalogues.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
relationship_type: Mapped[str] = mapped_column(String(50), nullable=False, default="accounts_consultant", index=True)
|
||||
is_primary: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
can_view_client: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
can_view_services: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
can_view_due_dates: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
can_view_communications: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||
)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
consultant = relationship("ConsultantProfile", back_populates="links")
|
||||
client = relationship("Client")
|
||||
service_catalogue = relationship("ServiceCatalogue")
|
||||
|
||||
|
||||
class ConsultantManagedClient(CommonBase):
|
||||
"""Client/contact managed by a consultant inside the consultant portal.
|
||||
|
||||
This table is intentionally separate from the firm `clients` master. It lets
|
||||
consultants maintain their own client book without affecting audit-firm
|
||||
client records. A later phase can convert/link a managed client to the firm
|
||||
client master through an approval workflow.
|
||||
"""
|
||||
|
||||
__tablename__ = "consultant_managed_clients"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "consultant_id", "client_code", name="uq_consultant_managed_clients_code"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True)
|
||||
consultant_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("consultant_profiles.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
linked_firm_client_id: Mapped[int | None] = mapped_column(ForeignKey("clients.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
client_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
|
||||
client_name: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
|
||||
trade_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
client_type: Mapped[str] = mapped_column(String(100), nullable=False, default="Other")
|
||||
pan: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
gstin: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
tan: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
|
||||
contact_person_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
mobile: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
address_line_1: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
address_line_2: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
city: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
state: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
pincode: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
country: Mapped[str | None] = mapped_column(String(100), nullable=True, default="India")
|
||||
|
||||
service_interest: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
relationship_stage: Mapped[str] = mapped_column(String(30), nullable=False, default="managed", index=True)
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, default="active", index=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
conversion_status: Mapped[str] = mapped_column(String(30), nullable=False, default="not_requested", index=True)
|
||||
conversion_requested_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
conversion_requested_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
conversion_reviewed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
conversion_reviewed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
conversion_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
conversion_firm_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||
)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
consultant = relationship("ConsultantProfile", back_populates="managed_clients")
|
||||
linked_firm_client = relationship("Client")
|
||||
|
||||
|
||||
class ConsultantServiceRequest(CommonBase):
|
||||
"""Service request raised by a consultant to the audit firm.
|
||||
|
||||
A request can relate either to a consultant-managed client or to an already
|
||||
linked firm client. The request itself does not create engagements; firm
|
||||
users review it first and decide whether to accept, reject, or keep it under
|
||||
review.
|
||||
"""
|
||||
|
||||
__tablename__ = "consultant_service_requests"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True)
|
||||
consultant_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("consultant_profiles.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
managed_client_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("consultant_managed_clients.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
firm_client_id: Mapped[int | None] = mapped_column(ForeignKey("clients.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
service_catalogue_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("service_catalogues.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
|
||||
request_no: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
request_type: Mapped[str] = mapped_column(String(50), nullable=False, default="service_request", index=True)
|
||||
status: Mapped[str] = mapped_column(String(40), nullable=False, default="submitted", index=True)
|
||||
priority: Mapped[str] = mapped_column(String(30), nullable=False, default="normal", index=True)
|
||||
requested_service_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
requested_due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
subject: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
consultant_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
firm_response: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
reviewed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
reviewed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||
)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
|
||||
consultant = relationship("ConsultantProfile", back_populates="service_requests")
|
||||
managed_client = relationship("ConsultantManagedClient")
|
||||
firm_client = relationship("Client")
|
||||
service_catalogue = relationship("ServiceCatalogue")
|
||||
created_by = relationship("User", foreign_keys=[created_by_user_id])
|
||||
reviewed_by = relationship("User", foreign_keys=[reviewed_by_user_id])
|
||||
@@ -0,0 +1,245 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
from datetime import date, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.modules.clients.models import Client
|
||||
from app.modules.consultants.models import ClientConsultantLink, ConsultantProfile, ConsultantServiceRequest
|
||||
from app.modules.documents.models import EngagementDocument, PermanentClientDocument
|
||||
from app.modules.services.models import (
|
||||
ClientServiceSubscription,
|
||||
ClientServiceTaskInstance,
|
||||
ServiceCatalogue,
|
||||
ServiceTaskComment,
|
||||
)
|
||||
|
||||
CONSULTANT_BOARD_COLUMNS = OrderedDict(
|
||||
[
|
||||
("assigned", "Assigned"),
|
||||
("awaiting_documents", "Awaiting Documents"),
|
||||
("in_progress", "In Progress"),
|
||||
("submitted", "Submitted"),
|
||||
("accepted", "Accepted"),
|
||||
("closed", "Closed"),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _allowed_client_ids(db: Session, *, consultant: ConsultantProfile, require_communications: bool = False) -> list[int]:
|
||||
query = select(ClientConsultantLink.client_id).where(
|
||||
ClientConsultantLink.tenant_id == consultant.tenant_id,
|
||||
ClientConsultantLink.consultant_id == consultant.id,
|
||||
ClientConsultantLink.is_active.is_(True),
|
||||
)
|
||||
if require_communications:
|
||||
query = query.where(ClientConsultantLink.can_view_communications.is_(True))
|
||||
return [int(x) for x in db.execute(query).scalars().all()]
|
||||
|
||||
|
||||
def _board_key_for_status(status: str | None) -> str:
|
||||
value = (status or "pending").strip().lower()
|
||||
if value in {"blocked", "awaiting_documents", "document_pending", "clarification_required"}:
|
||||
return "awaiting_documents"
|
||||
if value in {"in_progress", "under_process", "processing", "started"}:
|
||||
return "in_progress"
|
||||
if value in {"pending_review", "ready_for_review", "submitted", "completed"}:
|
||||
return "submitted"
|
||||
if value in {"approved", "accepted"}:
|
||||
return "accepted"
|
||||
if value in {"closed", "locked", "cancelled", "inactive"}:
|
||||
return "closed"
|
||||
return "assigned"
|
||||
|
||||
|
||||
def _matches_search(*values: Any, q: str = "") -> bool:
|
||||
term = (q or "").strip().lower()
|
||||
if not term:
|
||||
return True
|
||||
return any(term in str(v or "").lower() for v in values)
|
||||
|
||||
|
||||
def get_consultant_work_board(db: Session, *, consultant: ConsultantProfile, q: str = "", status: str = "") -> dict:
|
||||
"""Build consultant work board from consultant-visible firm task communications.
|
||||
|
||||
The board intentionally uses existing task/comment visibility rules only. A consultant sees a task here only when
|
||||
the firm has linked the consultant to the client and has created a consultant-visible communication for that task.
|
||||
"""
|
||||
client_ids = _allowed_client_ids(db, consultant=consultant, require_communications=True)
|
||||
columns = {key: {"label": label, "items": []} for key, label in CONSULTANT_BOARD_COLUMNS.items()}
|
||||
latest_by_task: dict[int, dict] = {}
|
||||
|
||||
if client_ids:
|
||||
rows = db.execute(
|
||||
select(ServiceTaskComment, ClientServiceTaskInstance, ClientServiceSubscription, Client, ServiceCatalogue)
|
||||
.join(ClientServiceTaskInstance, ClientServiceTaskInstance.id == ServiceTaskComment.task_instance_id)
|
||||
.join(ClientServiceSubscription, ClientServiceSubscription.id == ServiceTaskComment.subscription_id)
|
||||
.join(Client, Client.id == ClientServiceTaskInstance.client_id)
|
||||
.join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceTaskInstance.service_catalogue_id)
|
||||
.where(
|
||||
ServiceTaskComment.tenant_id == consultant.tenant_id,
|
||||
ServiceTaskComment.visibility == "consultant",
|
||||
ServiceTaskComment.is_deleted.is_(False),
|
||||
ClientServiceTaskInstance.client_id.in_(client_ids),
|
||||
ClientServiceTaskInstance.tenant_id == consultant.tenant_id,
|
||||
ClientServiceTaskInstance.is_active.is_(True),
|
||||
)
|
||||
.order_by(ServiceTaskComment.created_at_utc.desc(), ServiceTaskComment.id.desc())
|
||||
.limit(300)
|
||||
).all()
|
||||
consultant_user_id = int(getattr(consultant, "user_id", 0) or 0)
|
||||
for comment, task, subscription, client, catalogue in rows:
|
||||
if int(task.id) in latest_by_task:
|
||||
continue
|
||||
if not _matches_search(client.client_name, getattr(client, "client_code", ""), catalogue.service_name, task.task_name, comment.message, q=q):
|
||||
continue
|
||||
key = _board_key_for_status(task.status)
|
||||
if status and key != status:
|
||||
continue
|
||||
latest_by_task[int(task.id)] = {
|
||||
"comment": comment,
|
||||
"task": task,
|
||||
"subscription": subscription,
|
||||
"client": client,
|
||||
"catalogue": catalogue,
|
||||
"board_key": key,
|
||||
"last_message_from_consultant": int(getattr(comment, "created_by_user_id", 0) or 0) == consultant_user_id,
|
||||
"is_overdue": bool(getattr(task, "internal_target_date", None) and task.internal_target_date < date.today()),
|
||||
}
|
||||
|
||||
for item in latest_by_task.values():
|
||||
columns[item["board_key"]]["items"].append(item)
|
||||
|
||||
service_requests = db.execute(
|
||||
select(ConsultantServiceRequest)
|
||||
.options(
|
||||
selectinload(ConsultantServiceRequest.managed_client),
|
||||
selectinload(ConsultantServiceRequest.firm_client),
|
||||
selectinload(ConsultantServiceRequest.service_catalogue),
|
||||
)
|
||||
.where(
|
||||
ConsultantServiceRequest.tenant_id == consultant.tenant_id,
|
||||
ConsultantServiceRequest.consultant_id == consultant.id,
|
||||
ConsultantServiceRequest.is_active.is_(True),
|
||||
)
|
||||
.order_by(ConsultantServiceRequest.created_at_utc.desc(), ConsultantServiceRequest.id.desc())
|
||||
.limit(50)
|
||||
).scalars().all()
|
||||
|
||||
return {
|
||||
"columns": columns,
|
||||
"column_options": list(CONSULTANT_BOARD_COLUMNS.items()),
|
||||
"total_tasks": len(latest_by_task),
|
||||
"service_requests": service_requests,
|
||||
}
|
||||
|
||||
|
||||
def get_consultant_assignment_detail(db: Session, *, consultant: ConsultantProfile, task_id: int) -> dict | None:
|
||||
client_ids = _allowed_client_ids(db, consultant=consultant, require_communications=True)
|
||||
if not client_ids:
|
||||
return None
|
||||
row = db.execute(
|
||||
select(ClientServiceTaskInstance, ClientServiceSubscription, Client, ServiceCatalogue)
|
||||
.join(ClientServiceSubscription, ClientServiceSubscription.id == ClientServiceTaskInstance.subscription_id)
|
||||
.join(Client, Client.id == ClientServiceTaskInstance.client_id)
|
||||
.join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceTaskInstance.service_catalogue_id)
|
||||
.where(
|
||||
ClientServiceTaskInstance.id == task_id,
|
||||
ClientServiceTaskInstance.tenant_id == consultant.tenant_id,
|
||||
ClientServiceTaskInstance.client_id.in_(client_ids),
|
||||
ClientServiceTaskInstance.is_active.is_(True),
|
||||
)
|
||||
).first()
|
||||
if not row:
|
||||
return None
|
||||
task, subscription, client, catalogue = row
|
||||
timeline = db.execute(
|
||||
select(ServiceTaskComment)
|
||||
.options(selectinload(ServiceTaskComment.created_by))
|
||||
.where(
|
||||
ServiceTaskComment.tenant_id == consultant.tenant_id,
|
||||
ServiceTaskComment.task_instance_id == task.id,
|
||||
ServiceTaskComment.visibility == "consultant",
|
||||
ServiceTaskComment.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(ServiceTaskComment.created_at_utc.asc(), ServiceTaskComment.id.asc())
|
||||
).scalars().all()
|
||||
engagement_documents = db.execute(
|
||||
select(EngagementDocument)
|
||||
.options(selectinload(EngagementDocument.versions))
|
||||
.where(
|
||||
EngagementDocument.tenant_id == consultant.tenant_id,
|
||||
EngagementDocument.client_id == client.id,
|
||||
EngagementDocument.engagement_id == subscription.id,
|
||||
EngagementDocument.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(EngagementDocument.document_type.asc(), EngagementDocument.title.asc())
|
||||
.limit(50)
|
||||
).scalars().all()
|
||||
permanent_documents = db.execute(
|
||||
select(PermanentClientDocument)
|
||||
.options(selectinload(PermanentClientDocument.versions))
|
||||
.where(
|
||||
PermanentClientDocument.tenant_id == consultant.tenant_id,
|
||||
PermanentClientDocument.client_id == client.id,
|
||||
PermanentClientDocument.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(PermanentClientDocument.category.asc(), PermanentClientDocument.title.asc())
|
||||
.limit(50)
|
||||
).scalars().all()
|
||||
return {
|
||||
"task": task,
|
||||
"subscription": subscription,
|
||||
"client": client,
|
||||
"catalogue": catalogue,
|
||||
"timeline": timeline,
|
||||
"engagement_documents": engagement_documents,
|
||||
"permanent_documents": permanent_documents,
|
||||
}
|
||||
|
||||
|
||||
def get_consultant_document_centre(db: Session, *, consultant: ConsultantProfile, q: str = "") -> dict:
|
||||
client_ids = _allowed_client_ids(db, consultant=consultant, require_communications=False)
|
||||
if not client_ids:
|
||||
return {"engagement_documents": [], "permanent_documents": [], "total": 0}
|
||||
|
||||
engagement_query = (
|
||||
select(EngagementDocument)
|
||||
.options(selectinload(EngagementDocument.client), selectinload(EngagementDocument.engagement), selectinload(EngagementDocument.versions))
|
||||
.where(
|
||||
EngagementDocument.tenant_id == consultant.tenant_id,
|
||||
EngagementDocument.client_id.in_(client_ids),
|
||||
EngagementDocument.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
permanent_query = (
|
||||
select(PermanentClientDocument)
|
||||
.options(selectinload(PermanentClientDocument.client), selectinload(PermanentClientDocument.versions))
|
||||
.where(
|
||||
PermanentClientDocument.tenant_id == consultant.tenant_id,
|
||||
PermanentClientDocument.client_id.in_(client_ids),
|
||||
PermanentClientDocument.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
if (q or "").strip():
|
||||
term = f"%{q.strip()}%"
|
||||
engagement_query = engagement_query.where(
|
||||
or_(EngagementDocument.title.ilike(term), EngagementDocument.document_type.ilike(term), EngagementDocument.document_code.ilike(term))
|
||||
)
|
||||
permanent_query = permanent_query.where(
|
||||
or_(PermanentClientDocument.title.ilike(term), PermanentClientDocument.category.ilike(term), PermanentClientDocument.document_code.ilike(term))
|
||||
)
|
||||
engagement_documents = db.execute(
|
||||
engagement_query.order_by(EngagementDocument.created_at_utc.desc(), EngagementDocument.id.desc()).limit(200)
|
||||
).scalars().all()
|
||||
permanent_documents = db.execute(
|
||||
permanent_query.order_by(PermanentClientDocument.created_at_utc.desc(), PermanentClientDocument.id.desc()).limit(200)
|
||||
).scalars().all()
|
||||
return {
|
||||
"engagement_documents": engagement_documents,
|
||||
"permanent_documents": permanent_documents,
|
||||
"total": len(engagement_documents) + len(permanent_documents),
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
{% set path = request.url.path %}
|
||||
<div class="mb-5 overflow-x-auto rounded-2xl border border-slate-200 bg-white p-2 shadow-soft">
|
||||
<nav class="flex min-w-max gap-2 text-sm font-semibold">
|
||||
<a href="/consultant/dashboard" class="rounded-xl px-3 py-2 {{ 'bg-slate-900 text-white' if path == '/consultant/dashboard' else 'text-slate-700 hover:bg-slate-100' }}">Overview</a>
|
||||
<a href="/consultant/work" class="rounded-xl px-3 py-2 {{ 'bg-slate-900 text-white' if path.startswith('/consultant/work') or path.startswith('/consultant/assignments') else 'text-slate-700 hover:bg-slate-100' }}">My Work Board</a>
|
||||
<a href="/consultant/communications" class="rounded-xl px-3 py-2 {{ 'bg-slate-900 text-white' if path.startswith('/consultant/communications') else 'text-slate-700 hover:bg-slate-100' }}">My Messages</a>
|
||||
<a href="/consultant/documents" class="rounded-xl px-3 py-2 {{ 'bg-slate-900 text-white' if path.startswith('/consultant/documents') else 'text-slate-700 hover:bg-slate-100' }}">Shared Documents</a>
|
||||
<a href="/consultant/service-requests" class="rounded-xl px-3 py-2 {{ 'bg-slate-900 text-white' if path.startswith('/consultant/service-requests') else 'text-slate-700 hover:bg-slate-100' }}">Service Requests</a>
|
||||
<a href="/consultant/managed-clients" class="rounded-xl px-3 py-2 {{ 'bg-slate-900 text-white' if path.startswith('/consultant/managed-clients') else 'text-slate-700 hover:bg-slate-100' }}">Managed Clients</a>
|
||||
<a href="/consultant/workspace" class="rounded-xl px-3 py-2 {{ 'bg-slate-900 text-white' if path.startswith('/consultant/workspace') else 'text-slate-700 hover:bg-slate-100' }}">Workspace</a>
|
||||
<a href="/consultant/profile" class="rounded-xl px-3 py-2 {{ 'bg-slate-900 text-white' if path.startswith('/consultant/profile') else 'text-slate-700 hover:bg-slate-100' }}">My Profile</a>
|
||||
<a href="/alerts" class="rounded-xl px-3 py-2 {{ 'bg-slate-900 text-white' if path.startswith('/alerts') else 'text-slate-700 hover:bg-slate-100' }}">My Alert</a>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -0,0 +1,78 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">{{ client.client_name }} — {{ catalogue.service_name }}</h2>
|
||||
<p class="text-sm text-slate-500">{{ task.task_name }}{% if task.internal_target_date %} • Target {{ task.internal_target_date.strftime('%d-%m-%Y') }}{% endif %}</p>
|
||||
</div>
|
||||
<a href="/consultant/work" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back to My Work Board</a>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[1fr_360px]">
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Task Status</div><div class="mt-1 font-semibold text-slate-900">{{ task.status.replace('_',' ').title() }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Priority</div><div class="mt-1 font-semibold text-slate-900">{{ task.priority.replace('_',' ').title() }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Financial Year</div><div class="mt-1 font-semibold text-slate-900">{{ task.financial_year }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Engagement Status</div><div class="mt-1 font-semibold text-slate-900">{{ subscription.status.replace('_',' ').title() }}</div></div>
|
||||
</div>
|
||||
{% if task.description %}<div class="mt-5 rounded-xl bg-slate-50 p-4 text-sm text-slate-700 whitespace-pre-line">{{ task.description }}</div>{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<div class="border-b border-slate-200 px-5 py-4"><h3 class="font-semibold text-slate-900">Consultant Communication Timeline</h3></div>
|
||||
<div class="divide-y divide-slate-100">
|
||||
{% for note in timeline %}
|
||||
<div class="p-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="text-sm font-semibold text-slate-900">{{ note.comment_type.replace('_',' ').title() }}</div>
|
||||
<div class="text-xs text-slate-500">{{ note.created_at_utc.strftime('%d-%m-%Y %H:%M') if note.created_at_utc else '' }}</div>
|
||||
</div>
|
||||
<div class="mt-2 whitespace-pre-line rounded-xl bg-slate-50 p-3 text-sm text-slate-700">{{ note.message }}</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-6 text-sm text-slate-500">No consultant-visible timeline yet.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/consultant/assignments/{{ task.id }}/reply" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<h3 class="font-semibold text-slate-900">Send Reply / Submit Update</h3>
|
||||
{% if errors %}<div class="mt-3 rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700">{{ errors|join(' ') }}</div>{% endif %}
|
||||
<textarea name="message" rows="5" required placeholder="Type clarification reply, submission note, or work update" class="mt-4 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"></textarea>
|
||||
<div class="mt-4 flex justify-end"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Send Update</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<aside class="space-y-6">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<div class="border-b border-slate-200 px-4 py-3"><h3 class="font-semibold text-slate-900">Engagement Documents</h3></div>
|
||||
<div class="divide-y divide-slate-100">
|
||||
{% for doc in engagement_documents %}
|
||||
<div class="p-4">
|
||||
<div class="font-semibold text-slate-900">{{ doc.title }}</div>
|
||||
<div class="text-xs text-slate-500">{{ doc.document_type }} • v{{ doc.current_version_no }}</div>
|
||||
{% if doc.versions %}<div class="mt-1 text-xs text-slate-500">Latest: {{ doc.versions[0].original_filename }}</div>{% endif %}
|
||||
</div>
|
||||
{% else %}<div class="p-4 text-sm text-slate-500">No shared engagement documents found.</div>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<div class="border-b border-slate-200 px-4 py-3"><h3 class="font-semibold text-slate-900">Permanent Documents</h3></div>
|
||||
<div class="divide-y divide-slate-100">
|
||||
{% for doc in permanent_documents %}
|
||||
<div class="p-4">
|
||||
<div class="font-semibold text-slate-900">{{ doc.title }}</div>
|
||||
<div class="text-xs text-slate-500">{{ doc.category }} • v{{ doc.current_version_no }}</div>
|
||||
</div>
|
||||
{% else %}<div class="p-4 text-sm text-slate-500">No shared permanent documents found.</div>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,59 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
|
||||
<div class="mx-auto max-w-4xl space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Communication Detail</h2>
|
||||
<p class="text-sm text-slate-500">{{ client.client_name }} • {{ catalogue.service_name }} • {{ task.task_name }}</p>
|
||||
</div>
|
||||
<a href="/consultant/communications" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back</a>
|
||||
</div>
|
||||
|
||||
{% if errors %}
|
||||
<div class="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||
{% for error in errors %}<div>{{ error }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="grid gap-4 text-sm md:grid-cols-3">
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Client</div><div class="font-medium text-slate-900">{{ client.client_name }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Service</div><div class="font-medium text-slate-900">{{ catalogue.service_name }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Task Status</div><div class="font-medium text-slate-900">{{ task.status.replace('_',' ').title() }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<h3 class="font-semibold text-slate-900">Consultant-visible Timeline</h3>
|
||||
<div class="mt-4 space-y-4">
|
||||
{% for item in timeline %}
|
||||
<div class="rounded-2xl border border-slate-200 p-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="rounded-full bg-slate-900 px-3 py-1 text-xs font-medium text-white">{{ item.comment_type.replace('_',' ').title() }}</span>
|
||||
<span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700">{{ item.visibility.replace('_',' ').title() }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-slate-500">{{ item.created_at_utc.strftime('%d-%m-%Y %H:%M') if item.created_at_utc else '-' }}</div>
|
||||
</div>
|
||||
<div class="mt-2 text-sm font-medium text-slate-700">{{ item.created_by.full_name or item.created_by.email if item.created_by else 'System' }}</div>
|
||||
<p class="mt-3 whitespace-pre-wrap text-sm leading-6 text-slate-700">{{ item.message }}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="rounded-xl border border-dashed border-slate-300 px-4 py-6 text-center text-sm text-slate-500">No consultant-visible timeline found.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not task.is_locked and not (task.subscription and task.subscription.is_locked) %}
|
||||
<form method="post" action="/consultant/communications/{{ comment.id }}/reply" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Reply to firm</label>
|
||||
<textarea name="message" rows="5" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" placeholder="Type consultant clarification reply..."></textarea>
|
||||
<div class="mt-4 flex justify-end"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Send Reply</button></div>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">This task/engagement is locked. Replies are disabled.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,41 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Consultant Communications</h2>
|
||||
<p class="text-sm text-slate-500">Only task messages marked with Visibility = Consultant are listed here.</p>
|
||||
</div>
|
||||
<a href="/consultant/dashboard" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back to Dashboard</a>
|
||||
</div>
|
||||
|
||||
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="grid gap-3 md:grid-cols-[1fr_auto]">
|
||||
<input name="q" value="{{ q or '' }}" placeholder="Search client, service, task or message" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<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 / Service</th><th class="px-4 py-3">Task</th><th class="px-4 py-3">Type</th><th class="px-4 py-3">Date</th><th class="px-4 py-3 text-right">Action</th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for comment, task, subscription, client, catalogue in rows %}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3"><div class="font-semibold text-slate-900">{{ client.client_name }}</div><div class="text-xs text-slate-500">{{ catalogue.service_name }}</div></td>
|
||||
<td class="px-4 py-3 text-slate-700">{{ task.task_name }}</td>
|
||||
<td class="px-4 py-3"><span class="rounded-full bg-purple-50 px-2 py-1 text-xs font-semibold text-purple-700">{{ comment.comment_type.replace('_',' ').title() }}</span></td>
|
||||
<td class="px-4 py-3 text-slate-500">{{ comment.created_at_utc.strftime('%d-%m-%Y %H:%M') if comment.created_at_utc else '-' }}</td>
|
||||
<td class="px-4 py-3 text-right"><a href="/consultant/communications/{{ comment.id }}" class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Open / Reply</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">No consultant-visible communication found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,12 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-2xl rounded-3xl bg-white p-6 shadow-soft">
|
||||
<h2 class="text-xl font-semibold text-slate-900">Consultant Invite Link Generated</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Share this link with {{ consultant.contact_person }} to set password and activate consultant portal login.</p>
|
||||
<div class="mt-5 rounded-2xl border border-brand-200 bg-brand-50 p-4 text-sm text-brand-900 break-all">{{ invite_url }}</div>
|
||||
<div class="mt-5 flex flex-wrap gap-3">
|
||||
<a href="/consultants/{{ consultant.id }}" class="rounded-xl bg-brand-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-brand-700">Open Consultant</a>
|
||||
<a href="/consultants" class="rounded-xl border border-slate-300 px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50">Back to Consultants</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,70 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Conversion Request: {{ managed_client.client_name }}</h2>
|
||||
<p class="text-sm text-slate-500">Consultant: {{ consultant.firm_name or consultant.contact_person if consultant else '-' }}</p>
|
||||
</div>
|
||||
<a href="/consultants/conversion-requests" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back</a>
|
||||
</div>
|
||||
|
||||
{% if errors %}
|
||||
<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-700 shadow-soft">
|
||||
{% for error in errors %}<div>{{ error }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<h3 class="font-semibold text-slate-900">Managed Client Details</h3>
|
||||
<div class="mt-4 grid gap-4 text-sm md:grid-cols-3">
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Client Code</div><div class="font-medium text-slate-900">{{ managed_client.client_code or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Client Type</div><div class="font-medium text-slate-900">{{ managed_client.client_type }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Conversion Status</div><div class="font-medium text-slate-900">{{ managed_client.conversion_status.replace('_',' ').title() }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">PAN</div><div class="font-medium text-slate-900">{{ managed_client.pan or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">GSTIN</div><div class="font-medium text-slate-900">{{ managed_client.gstin or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Mobile / Email</div><div class="font-medium text-slate-900">{{ managed_client.mobile or managed_client.email or '-' }}</div></div>
|
||||
</div>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<div class="rounded-xl bg-slate-50 p-3 text-sm text-slate-700"><strong>Consultant notes</strong><br>{{ managed_client.conversion_notes or '-' }}</div>
|
||||
<div class="rounded-xl bg-slate-50 p-3 text-sm text-slate-700"><strong>Firm notes</strong><br>{{ managed_client.conversion_firm_notes or '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if managed_client.linked_firm_client %}
|
||||
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-5 text-sm text-emerald-900 shadow-soft">
|
||||
Already converted and linked to firm client: <strong>{{ managed_client.linked_firm_client.client_code }} — {{ managed_client.linked_firm_client.client_name }}</strong>
|
||||
</div>
|
||||
{% else %}
|
||||
<form method="post" action="/consultants/conversion-requests/{{ managed_client.id }}/review" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<h3 class="font-semibold text-slate-900">Firm Review</h3>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<label class="text-sm font-semibold text-slate-700">Action</label>
|
||||
<select name="action" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" required>
|
||||
<option value="under_review">Mark Under Review</option>
|
||||
<option value="approve">Approve & Create Firm Client</option>
|
||||
<option value="reject">Reject</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-semibold text-slate-700">Firm Client Code</label>
|
||||
<input name="client_code" value="FC-{{ managed_client.client_code or managed_client.id }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<p class="mt-1 text-xs text-slate-500">Used only when approving.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-semibold text-slate-700">Partner User ID</label>
|
||||
<input name="partner_user_id" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Optional">
|
||||
<p class="mt-1 text-xs text-slate-500">Optional for now. You can assign partner later from client master.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<label class="text-sm font-semibold text-slate-700">Firm Notes / Reason</label>
|
||||
<textarea name="firm_notes" rows="4" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"></textarea>
|
||||
</div>
|
||||
<button class="mt-4 rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Save Review</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,111 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">{{ consultant.contact_person }}</h2>
|
||||
<p class="text-sm text-slate-500">{{ consultant.firm_name or 'Individual consultant' }}{% if consultant.specialisation %} • {{ consultant.specialisation }}{% endif %}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="/consultants" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back</a>
|
||||
{% if can_manage %}<a href="/consultants/{{ consultant.id }}/edit" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Edit</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft md:col-span-3">
|
||||
<div class="grid gap-4 text-sm md:grid-cols-3">
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Email</div><div class="font-medium text-slate-900">{{ consultant.email or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Mobile</div><div class="font-medium text-slate-900">{{ consultant.mobile or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Type</div><div class="font-medium text-slate-900">{{ consultant.consultant_type.replace('_',' ').title() }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">PAN</div><div class="font-medium text-slate-900">{{ consultant.pan or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">GSTIN</div><div class="font-medium text-slate-900">{{ consultant.gstin or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Login user</div><div class="font-medium text-slate-900">{{ consultant.user.email if consultant.user else '-' }}</div></div>
|
||||
</div>
|
||||
{% if consultant.address or consultant.remarks %}
|
||||
<div class="mt-4 grid gap-4 text-sm md:grid-cols-2">
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Address</div><div class="text-slate-700 whitespace-pre-line">{{ consultant.address or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Remarks</div><div class="text-slate-700 whitespace-pre-line">{{ consultant.remarks or '-' }}</div></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Status</div>
|
||||
<div class="mt-2"><span class="rounded-full px-2 py-1 text-xs font-semibold {% if consultant.is_active %}bg-emerald-50 text-emerald-700{% else %}bg-slate-100 text-slate-500{% endif %}">{{ consultant.status }}</span></div>
|
||||
<div class="mt-4 space-y-2 text-sm text-slate-700">
|
||||
<div>Platform partner: <b>{{ 'Yes' if consultant.is_platform_partner else 'No' }}</b></div>
|
||||
<div>Franchise partner: <b>{{ 'Yes' if consultant.is_franchise_partner else 'No' }}</b></div>
|
||||
<div>SaaS customer: <b>{{ 'Yes' if consultant.is_saas_customer else 'No' }}</b></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if can_link %}
|
||||
<form method="post" action="/consultants/{{ consultant.id }}/links" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<h3 class="mb-4 text-base font-semibold text-slate-900">Link client to consultant</h3>
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="lg:col-span-2">
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Client</label>
|
||||
<select name="client_id" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" required>
|
||||
<option value="">Select client</option>
|
||||
{% for client in clients %}<option value="{{ client.id }}">{{ client.client_name }} ({{ client.client_code }})</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Relationship</label>
|
||||
<select name="relationship_type" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
{% for code,label in relationship_types %}<option value="{{ code }}">{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Remarks</label>
|
||||
<input name="remarks" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-wrap gap-4 text-sm text-slate-700">
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="is_primary"> Primary consultant</label>
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="can_view_client" checked> Client details</label>
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="can_view_services" checked> Services</label>
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="can_view_due_dates" checked> Due dates</label>
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="can_view_communications" checked> Consultant communications</label>
|
||||
</div>
|
||||
<div class="mt-4"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Link Client</button></div>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<div class="border-b border-slate-200 px-4 py-3">
|
||||
<h3 class="font-semibold text-slate-900">Linked Clients</h3>
|
||||
</div>
|
||||
<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">Relationship</th><th class="px-4 py-3">Access</th><th class="px-4 py-3">Status</th><th class="px-4 py-3 text-right">Action</th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for link in links %}
|
||||
<tr>
|
||||
<td class="px-4 py-3"><div class="font-semibold text-slate-900">{{ link.client.client_name if link.client else '-' }}</div><div class="text-xs text-slate-500">{{ link.client.client_code if link.client else '' }}</div></td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ link.relationship_type.replace('_',' ').title() }}{% if link.is_primary %}<span class="ml-2 rounded-full bg-blue-50 px-2 py-1 text-xs font-semibold text-blue-700">Primary</span>{% endif %}</td>
|
||||
<td class="px-4 py-3 text-xs text-slate-600">
|
||||
Client {{ '✓' if link.can_view_client else '×' }} • Services {{ '✓' if link.can_view_services else '×' }} • Due {{ '✓' if link.can_view_due_dates else '×' }} • Comm {{ '✓' if link.can_view_communications else '×' }}
|
||||
</td>
|
||||
<td class="px-4 py-3"><span class="rounded-full px-2 py-1 text-xs font-semibold {% if link.is_active %}bg-emerald-50 text-emerald-700{% else %}bg-slate-100 text-slate-500{% endif %}">{{ 'Active' if link.is_active else 'Inactive' }}</span></td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
{% if can_link %}
|
||||
<form method="post" action="/consultants/links/{{ link.id }}/toggle" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="active" value="{{ '0' if link.is_active else '1' }}">
|
||||
<button class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">{{ 'Disable' if link.is_active else 'Enable' }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">No linked clients.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,57 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Shared Documents</h2>
|
||||
<p class="text-sm text-slate-500">Documents visible through your active client links. Internal firm-only documents are not listed.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="grid gap-3 md:grid-cols-[1fr_auto]">
|
||||
<input name="q" value="{{ q or '' }}" placeholder="Search document title, code, category or type" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-2">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<div class="border-b border-slate-200 px-5 py-4"><h3 class="font-semibold text-slate-900">Engagement Documents</h3></div>
|
||||
<div class="divide-y divide-slate-100">
|
||||
{% for doc in docs.engagement_documents %}
|
||||
<div class="p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="font-semibold text-slate-900">{{ doc.title }}</div>
|
||||
<div class="text-xs text-slate-500">{{ doc.client.client_name if doc.client else 'Client' }} • {{ doc.document_type }} • {{ doc.financial_year }}</div>
|
||||
</div>
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-600">v{{ doc.current_version_no }}</span>
|
||||
</div>
|
||||
{% if doc.versions %}<div class="mt-2 text-xs text-slate-500">Latest file: {{ doc.versions[0].original_filename }}</div>{% endif %}
|
||||
</div>
|
||||
{% else %}<div class="p-6 text-sm text-slate-500">No engagement documents found.</div>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<div class="border-b border-slate-200 px-5 py-4"><h3 class="font-semibold text-slate-900">Permanent Documents</h3></div>
|
||||
<div class="divide-y divide-slate-100">
|
||||
{% for doc in docs.permanent_documents %}
|
||||
<div class="p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="font-semibold text-slate-900">{{ doc.title }}</div>
|
||||
<div class="text-xs text-slate-500">{{ doc.client.client_name if doc.client else 'Client' }} • {{ doc.category }}</div>
|
||||
</div>
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-600">v{{ doc.current_version_no }}</span>
|
||||
</div>
|
||||
{% if doc.versions %}<div class="mt-2 text-xs text-slate-500">Latest file: {{ doc.versions[0].original_filename }}</div>{% endif %}
|
||||
</div>
|
||||
{% else %}<div class="p-6 text-sm text-slate-500">No permanent documents found.</div>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,136 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% set is_dict = consultant is mapping %}
|
||||
{% set is_edit = consultant and not is_dict and consultant.id %}
|
||||
<div class="mx-auto max-w-4xl space-y-6">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">{{ title }}</h2>
|
||||
<p class="text-sm text-slate-500">Create or update a consultant portal profile and optionally create the consultant login user from this page.</p>
|
||||
</div>
|
||||
<a href="/consultants" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back</a>
|
||||
</div>
|
||||
|
||||
{% if errors %}
|
||||
<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||
<ul class="list-disc pl-5">
|
||||
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" class="space-y-6 rounded-2xl border border-slate-200 bg-white p-6 shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<div class="rounded-2xl border border-brand-100 bg-brand-50 p-4">
|
||||
<h3 class="font-semibold text-slate-900">Consultant Login</h3>
|
||||
<p class="mt-1 text-sm text-slate-600">Link an existing Consultant-role user or create a new login for this consultant.</p>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Existing consultant user</label>
|
||||
{% set current_user_id = consultant.user_id if consultant and not is_dict else consultant.get('user_id') if consultant else None %}
|
||||
<select name="user_id" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="">No existing user selected</option>
|
||||
{% for u in consultant_users %}
|
||||
<option value="{{ u.id }}" {% if current_user_id == u.id %}selected{% endif %}>{{ u.full_name or u.email }} — {{ u.email }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-slate-500">Leave blank if you want to create a new login below.</p>
|
||||
</div>
|
||||
<div class="space-y-2 rounded-xl bg-white p-3">
|
||||
<label class="inline-flex items-center gap-2 text-sm font-medium text-slate-700">
|
||||
<input type="checkbox" name="create_login_user" class="h-4 w-4 rounded border-slate-300" {% if consultant and is_dict and consultant.get('create_login_user') %}checked{% endif %}>
|
||||
Create / enable consultant login
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="invite_login_user" class="h-4 w-4 rounded border-slate-300" {% if consultant and is_dict and consultant.get('invite_login_user') %}checked{% endif %}>
|
||||
Generate invite link instead of using temporary password
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Login ID / Email</label>
|
||||
<input type="email" name="login_email" value="{{ consultant.get('login_email','') if consultant and is_dict else consultant.email if consultant and not is_dict else '' }}" placeholder="consultant@example.com" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Temporary password</label>
|
||||
<input type="password" name="temporary_password" placeholder="Minimum 8 characters" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<p class="mt-1 text-xs text-slate-500">Used only when invite link is not selected. Consultant must change password after login.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Consultant type</label>
|
||||
{% set current_type = consultant.consultant_type if consultant and not is_dict else consultant.get('consultant_type') if consultant else 'external_consultant' %}
|
||||
<select name="consultant_type" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
{% for code,label in consultant_types %}<option value="{{ code }}" {% if current_type == code %}selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Contact person / Consultant name *</label>
|
||||
<input name="contact_person" value="{{ consultant.contact_person if consultant and not is_dict else consultant.get('contact_person','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Firm name</label>
|
||||
<input name="firm_name" value="{{ consultant.firm_name if consultant and not is_dict else consultant.get('firm_name','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Email</label>
|
||||
<input type="email" name="email" value="{{ consultant.email if consultant and not is_dict else consultant.get('email','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Mobile</label>
|
||||
<input name="mobile" value="{{ consultant.mobile if consultant and not is_dict else consultant.get('mobile','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">PAN</label>
|
||||
<input name="pan" value="{{ consultant.pan if consultant and not is_dict else consultant.get('pan','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">GSTIN</label>
|
||||
<input name="gstin" value="{{ consultant.gstin if consultant and not is_dict else consultant.get('gstin','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Specialisation</label>
|
||||
<input name="specialisation" value="{{ consultant.specialisation if consultant and not is_dict else consultant.get('specialisation','') if consultant else '' }}" placeholder="GST, ROC, Payroll, Accounts, Tax filing" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Address</label>
|
||||
<textarea name="address" rows="2" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ consultant.address if consultant and not is_dict else consultant.get('address','') if consultant else '' }}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Status</label>
|
||||
{% set st = consultant.status if consultant and not is_dict else consultant.get('status') if consultant else 'active' %}
|
||||
<select name="status" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
{% for code in ['active','inactive','on_hold','suspended'] %}<option value="{{ code }}" {% if st == code %}selected{% endif %}>{{ code.replace('_',' ').title() }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Onboarding status</label>
|
||||
{% set os = consultant.onboarding_status if consultant and not is_dict else consultant.get('onboarding_status') if consultant else 'active' %}
|
||||
<select name="onboarding_status" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
{% for code,label in onboarding_statuses %}<option value="{{ code }}" {% if os == code %}selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 rounded-2xl bg-slate-50 p-4 text-sm text-slate-700 md:grid-cols-4">
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="is_platform_partner" {% if consultant and ((not is_dict and consultant.is_platform_partner) or (is_dict and consultant.get('is_platform_partner'))) %}checked{% endif %}> Platform partner</label>
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="is_franchise_partner" {% if consultant and ((not is_dict and consultant.is_franchise_partner) or (is_dict and consultant.get('is_franchise_partner'))) %}checked{% endif %}> Franchise partner</label>
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="is_saas_customer" {% if consultant and ((not is_dict and consultant.is_saas_customer) or (is_dict and consultant.get('is_saas_customer'))) %}checked{% endif %}> SaaS customer</label>
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="is_active" {% if not consultant or (consultant and ((not is_dict and consultant.is_active) or (is_dict and consultant.get('is_active', True)))) %}checked{% endif %}> Active</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Remarks</label>
|
||||
<textarea name="remarks" rows="3" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ consultant.remarks if consultant and not is_dict else consultant.get('remarks','') if consultant else '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<a href="/consultants" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Cancel</a>
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Save Consultant</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,59 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Consultant Client Conversion Requests</h2>
|
||||
<p class="text-sm text-slate-500">Review consultant-managed clients requested for conversion into firm client master.</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="/consultants" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Consultants</a>
|
||||
<a href="/consultants/service-requests" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Service Requests</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="grid gap-3 md:grid-cols-[1fr_auto]">
|
||||
<select name="status" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="">All conversion statuses</option>
|
||||
{% for code, label in conversion_statuses %}
|
||||
<option value="{{ code }}" {% if status == code %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<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">Managed Client</th>
|
||||
<th class="px-4 py-3">Consultant</th>
|
||||
<th class="px-4 py-3">PAN / GSTIN</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Requested</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 '-' }}{% if row.linked_firm_client %} • Linked: {{ row.linked_firm_client.client_code }}{% endif %}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ row.consultant.firm_name or row.consultant.contact_person if row.consultant else '-' }}</td>
|
||||
<td class="px-4 py-3 text-slate-600"><div>{{ row.pan or '-' }}</div><div class="text-xs">{{ row.gstin or '-' }}</div></td>
|
||||
<td class="px-4 py-3"><span class="rounded-full bg-blue-50 px-2 py-1 text-xs font-semibold text-blue-700">{{ row.conversion_status.replace('_',' ').title() }}</span></td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ row.conversion_requested_at_utc.strftime('%d-%m-%Y') if row.conversion_requested_at_utc else '-' }}</td>
|
||||
<td class="px-4 py-3 text-right"><a href="/consultants/conversion-requests/{{ row.id }}" class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Review</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="px-4 py-8 text-center text-slate-500">No conversion requests found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,8 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3"><div><h2 class="text-xl font-semibold text-slate-900">Consultant Service Requests</h2><p class="text-sm text-slate-500">Review requests raised by consultants.</p></div><a href="/consultants" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Consultants</a></div>
|
||||
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="grid gap-3 md:grid-cols-[1fr_auto]"><select name="status" class="rounded-xl border border-slate-300 px-3 py-2 text-sm"><option value="">All statuses</option>{% for code,label in service_request_statuses %}<option value="{{ code }}" {% if status == code %}selected{% endif %}>{{ label }}</option>{% endfor %}</select><button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Filter</button></div></form>
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft"><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">Request</th><th class="px-4 py-3">Consultant</th><th class="px-4 py-3">Client</th><th class="px-4 py-3">Service</th><th class="px-4 py-3">Status</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.request_no }}</div><div class="text-xs text-slate-500">{{ row.subject }}</div></td><td class="px-4 py-3 text-slate-700">{{ row.consultant.contact_person if row.consultant else '-' }}</td><td class="px-4 py-3 text-slate-700">{{ row.managed_client.client_name if row.managed_client else (row.firm_client.client_name if row.firm_client else '-') }}</td><td class="px-4 py-3 text-slate-700">{{ row.requested_service_name }}</td><td class="px-4 py-3"><span class="rounded-full bg-blue-50 px-2 py-1 text-xs font-semibold text-blue-700">{{ row.status.replace('_',' ').title() }}</span></td><td class="px-4 py-3 text-right"><a href="/consultants/service-requests/{{ row.id }}" class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Open</a></td></tr>{% else %}<tr><td colspan="6" class="px-4 py-8 text-center text-slate-500">No consultant service requests found.</td></tr>{% endfor %}</tbody></table></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,71 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Consultants</h2>
|
||||
<p class="text-sm text-slate-500">Portal-enabled consultants, franchise partners, and external ecosystem collaborators.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% if can_manage_consultant_service_requests(current_user, current_user_permissions, current_user_roles) %}
|
||||
<a href="/consultants/service-requests" class="inline-flex rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Service Requests</a>
|
||||
{% endif %}
|
||||
{% if can_manage_consultant_conversions(current_user, current_user_permissions, current_user_roles) %}
|
||||
<a href="/consultants/conversion-requests" class="inline-flex rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Conversions</a>
|
||||
{% endif %}
|
||||
{% if can_manage %}
|
||||
<a href="/consultants/new" class="inline-flex rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-brand-700">Add Consultant</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="grid gap-3 md:grid-cols-[1fr_auto_auto]">
|
||||
<input name="q" value="{{ q or '' }}" placeholder="Search by name, firm, email, mobile, specialisation" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<label class="inline-flex items-center gap-2 rounded-xl border border-slate-300 px-3 py-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="include_inactive" value="1" {% if include_inactive %}checked{% endif %}> Include inactive
|
||||
</label>
|
||||
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<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">Consultant</th>
|
||||
<th class="px-4 py-3">Contact</th>
|
||||
<th class="px-4 py-3">Type</th>
|
||||
<th class="px-4 py-3">Specialisation</th>
|
||||
<th class="px-4 py-3">Status</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.contact_person }}</div>
|
||||
<div class="text-xs text-slate-500">{{ row.firm_name or 'Individual consultant' }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-600">
|
||||
<div>{{ row.email or '-' }}</div>
|
||||
<div class="text-xs">{{ row.mobile or '-' }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ row.consultant_type.replace('_', ' ').title() }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ row.specialisation or '-' }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="rounded-full px-2 py-1 text-xs font-semibold {% if row.is_active %}bg-emerald-50 text-emerald-700{% else %}bg-slate-100 text-slate-500{% endif %}">{{ row.status }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<a href="/consultants/{{ row.id }}" class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Open</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="px-4 py-8 text-center text-slate-500">No consultants found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,64 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">{{ managed_client.client_name }}</h2>
|
||||
<p class="text-sm text-slate-500">{{ managed_client.client_code or 'Managed client' }}{% if managed_client.trade_name %} • {{ managed_client.trade_name }}{% endif %}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="/consultant/managed-clients" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back</a>
|
||||
<a href="/consultant/managed-clients/{{ managed_client.id }}/edit" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Edit</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="grid gap-4 text-sm md:grid-cols-3">
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Client Type</div><div class="font-medium text-slate-900">{{ managed_client.client_type }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">PAN</div><div class="font-medium text-slate-900">{{ managed_client.pan or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">GSTIN</div><div class="font-medium text-slate-900">{{ managed_client.gstin or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Contact Person</div><div class="font-medium text-slate-900">{{ managed_client.contact_person_name or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Mobile</div><div class="font-medium text-slate-900">{{ managed_client.mobile or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Email</div><div class="font-medium text-slate-900">{{ managed_client.email or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Stage</div><div class="font-medium text-slate-900">{{ managed_client.relationship_stage.replace('_',' ').title() }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Status</div><div class="font-medium text-slate-900">{{ managed_client.status.replace('_',' ').title() }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Active</div><div class="font-medium text-slate-900">{{ 'Yes' if managed_client.is_active else 'No' }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<h3 class="font-semibold text-slate-900">Firm Client Conversion</h3>
|
||||
<div class="mt-3 grid gap-4 text-sm md:grid-cols-3">
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Conversion Status</div><div class="font-medium text-slate-900">{{ managed_client.conversion_status.replace('_',' ').title() if managed_client.conversion_status else 'Not Requested' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Requested On</div><div class="font-medium text-slate-900">{{ managed_client.conversion_requested_at_utc.strftime('%d-%m-%Y %H:%M') if managed_client.conversion_requested_at_utc else '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Linked Firm Client</div><div class="font-medium text-slate-900">{% if managed_client.linked_firm_client %}{{ managed_client.linked_firm_client.client_name }}{% else %}-{% endif %}</div></div>
|
||||
</div>
|
||||
{% if managed_client.conversion_notes %}<div class="mt-3 rounded-xl bg-slate-50 p-3 text-sm text-slate-700"><strong>Consultant notes:</strong><br>{{ managed_client.conversion_notes }}</div>{% endif %}
|
||||
{% if managed_client.conversion_firm_notes %}<div class="mt-3 rounded-xl bg-blue-50 p-3 text-sm text-blue-900"><strong>Firm response:</strong><br>{{ managed_client.conversion_firm_notes }}</div>{% endif %}
|
||||
{% if can_request_conversion %}
|
||||
<form method="post" action="/consultant/managed-clients/{{ managed_client.id }}/request-conversion" class="mt-4 space-y-3">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label class="block text-sm font-semibold text-slate-700">Request conversion to audit firm client</label>
|
||||
<textarea name="conversion_notes" rows="3" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Mention service requirement, preferred partner, urgency, or client background"></textarea>
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Request Conversion</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<h3 class="font-semibold text-slate-900">Address</h3>
|
||||
<p class="mt-3 whitespace-pre-line text-sm text-slate-700">{{ [managed_client.address_line_1, managed_client.address_line_2, managed_client.city, managed_client.state, managed_client.pincode, managed_client.country] | select | join('\n') or '-' }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<h3 class="font-semibold text-slate-900">Service Interest / Notes</h3>
|
||||
<div class="mt-3 space-y-3 text-sm text-slate-700">
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Service Interest</div><div class="whitespace-pre-line">{{ managed_client.service_interest or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Notes</div><div class="whitespace-pre-line">{{ managed_client.notes or '-' }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,127 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
|
||||
{% set is_dict = managed_client is mapping %}
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">{{ title }}</h2>
|
||||
<p class="text-sm text-slate-500">Maintain your own client record in the consultant portal. This does not change the audit firm's client master.</p>
|
||||
</div>
|
||||
|
||||
{% if errors %}
|
||||
<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||
<ul class="list-disc pl-5">{% for err in errors %}<li>{{ err }}</li>{% endfor %}</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" class="space-y-5 rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Client Code</label>
|
||||
<input name="client_code" value="{{ managed_client.client_code if managed_client and not is_dict else managed_client.get('client_code','') if managed_client else '' }}" placeholder="Auto if blank" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Client Name *</label>
|
||||
<input name="client_name" required value="{{ managed_client.client_name if managed_client and not is_dict else managed_client.get('client_name','') if managed_client else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Trade Name</label>
|
||||
<input name="trade_name" value="{{ managed_client.trade_name if managed_client and not is_dict else managed_client.get('trade_name','') if managed_client else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Client Type</label>
|
||||
{% set ct = managed_client.client_type if managed_client and not is_dict else managed_client.get('client_type','Other') if managed_client else 'Other' %}
|
||||
<select name="client_type" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
{% for option in client_types %}<option value="{{ option }}" {% if ct == option %}selected{% endif %}>{{ option }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">PAN</label>
|
||||
<input name="pan" value="{{ managed_client.pan if managed_client and not is_dict else managed_client.get('pan','') if managed_client else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm uppercase">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">GSTIN</label>
|
||||
<input name="gstin" value="{{ managed_client.gstin if managed_client and not is_dict else managed_client.get('gstin','') if managed_client else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm uppercase">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">TAN</label>
|
||||
<input name="tan" value="{{ managed_client.tan if managed_client and not is_dict else managed_client.get('tan','') if managed_client else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm uppercase">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Contact Person</label>
|
||||
<input name="contact_person_name" value="{{ managed_client.contact_person_name if managed_client and not is_dict else managed_client.get('contact_person_name','') if managed_client else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Mobile *</label>
|
||||
<input name="mobile" value="{{ managed_client.mobile if managed_client and not is_dict else managed_client.get('mobile','') if managed_client else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Email *</label>
|
||||
<input name="email" type="email" value="{{ managed_client.email if managed_client and not is_dict else managed_client.get('email','') if managed_client else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Address Line 1</label>
|
||||
<input name="address_line_1" value="{{ managed_client.address_line_1 if managed_client and not is_dict else managed_client.get('address_line_1','') if managed_client else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Address Line 2</label>
|
||||
<input name="address_line_2" value="{{ managed_client.address_line_2 if managed_client and not is_dict else managed_client.get('address_line_2','') if managed_client else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">City</label>
|
||||
<input name="city" value="{{ managed_client.city if managed_client and not is_dict else managed_client.get('city','') if managed_client else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">State</label>
|
||||
<input name="state" value="{{ managed_client.state if managed_client and not is_dict else managed_client.get('state','') if managed_client else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Pincode</label>
|
||||
<input name="pincode" value="{{ managed_client.pincode if managed_client and not is_dict else managed_client.get('pincode','') if managed_client else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Country</label>
|
||||
<input name="country" value="{{ managed_client.country if managed_client and not is_dict else managed_client.get('country','India') if managed_client else 'India' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Relationship Stage</label>
|
||||
{% set stage = managed_client.relationship_stage if managed_client and not is_dict else managed_client.get('relationship_stage','managed') if managed_client else 'managed' %}
|
||||
<select name="relationship_stage" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
{% for code, label in managed_client_stages %}<option value="{{ code }}" {% if stage == code %}selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Status</label>
|
||||
{% set st = managed_client.status if managed_client and not is_dict else managed_client.get('status','active') if managed_client else 'active' %}
|
||||
<select name="status" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
{% for code, label in managed_client_statuses %}<option value="{{ code }}" {% if st == code %}selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<label class="mt-7 inline-flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="is_active" {% if not managed_client or (managed_client and ((not is_dict and managed_client.is_active) or (is_dict and managed_client.get('is_active', True)))) %}checked{% endif %}> Active
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Service Interest</label>
|
||||
<textarea name="service_interest" rows="3" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ managed_client.service_interest if managed_client and not is_dict else managed_client.get('service_interest','') if managed_client else '' }}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Notes</label>
|
||||
<textarea name="notes" rows="3" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ managed_client.notes if managed_client and not is_dict else managed_client.get('notes','') if managed_client else '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<a href="/consultant/managed-clients" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Cancel</a>
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Save Managed Client</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,88 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">My Managed Clients</h2>
|
||||
<p class="text-sm text-slate-500">Clients maintained by you in the consultant portal. These records do not modify the audit firm client master.</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="/consultant/dashboard" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Dashboard</a>
|
||||
{% if can_add_managed_client %}
|
||||
<a href="/consultant/managed-clients/new" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Add Managed Client</a>
|
||||
{% else %}
|
||||
<span class="rounded-xl bg-slate-200 px-4 py-2 text-sm font-semibold text-slate-500">Limit Reached</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if workspace_summary %}
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 text-sm text-slate-700 shadow-soft">
|
||||
Managed client usage: <strong>{{ workspace_summary.managed_clients_used }}</strong> / <strong>{{ workspace_summary.managed_clients_limit or 'No limit' }}</strong>
|
||||
{% if workspace_summary.managed_clients_remaining is not none %} • Remaining: <strong>{{ workspace_summary.managed_clients_remaining }}</strong>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="grid gap-3 md:grid-cols-[1fr_auto_auto_auto]">
|
||||
<input name="q" value="{{ q or '' }}" placeholder="Search name, PAN, GSTIN, email, mobile" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<select name="status" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="">All statuses</option>
|
||||
{% for code, label in managed_client_statuses %}<option value="{{ code }}" {% if status == code %}selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
<label class="inline-flex items-center gap-2 rounded-xl border border-slate-300 px-3 py-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="include_inactive" value="1" {% if include_inactive %}checked{% endif %}> Include inactive
|
||||
</label>
|
||||
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<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">Contact</th>
|
||||
<th class="px-4 py-3">PAN / GSTIN</th>
|
||||
<th class="px-4 py-3">Stage</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Conversion</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 '-' }}{% if row.trade_name %} • {{ row.trade_name }}{% endif %}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-600">
|
||||
<div>{{ row.contact_person_name or '-' }}</div>
|
||||
<div class="text-xs">{{ row.mobile or row.email or '-' }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-600">
|
||||
<div>{{ row.pan or '-' }}</div>
|
||||
<div class="text-xs">{{ row.gstin or '-' }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ row.relationship_stage.replace('_', ' ').title() }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="rounded-full px-2 py-1 text-xs font-semibold {% if row.is_active and row.status == 'active' %}bg-emerald-50 text-emerald-700{% elif row.status == 'prospect' %}bg-blue-50 text-blue-700{% else %}bg-slate-100 text-slate-600{% endif %}">{{ row.status.replace('_',' ').title() }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-600">
|
||||
{{ row.conversion_status.replace('_',' ').title() if row.conversion_status else 'Not Requested' }}
|
||||
{% if row.linked_firm_client_id %}<div class="text-xs text-emerald-700">Linked to firm client</div>{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<a href="/consultant/managed-clients/{{ row.id }}" class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Open</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">No managed clients found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,128 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
{% if not payload.workspace %}
|
||||
<section class="rounded-3xl bg-gradient-to-r from-brand-700 to-slate-900 p-6 text-white shadow-soft">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-brand-100">Consultant Portal</p>
|
||||
<h2 class="mt-2 text-2xl font-semibold">Set up your consultant workspace</h2>
|
||||
<p class="mt-2 max-w-3xl text-sm text-brand-100">Create your workspace to manage clients, communicate with firms and refer work.</p>
|
||||
</section>
|
||||
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-6 text-sm text-amber-900 shadow-soft">
|
||||
Your consultant workspace is not configured yet. Open profile/workspace settings and complete setup.
|
||||
<div class="mt-4"><a href="/consultant/workspace/edit" class="rounded-xl bg-amber-700 px-4 py-2 text-sm font-semibold text-white hover:bg-amber-800">Configure Workspace</a></div>
|
||||
</div>
|
||||
{% else %}
|
||||
<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-xs font-semibold uppercase tracking-[0.22em] text-brand-100">Consultant Portal</p>
|
||||
<h2 class="mt-2 text-2xl font-semibold">Managed clients, firm referrals and consultant assignments</h2>
|
||||
<p class="mt-2 max-w-3xl text-sm text-brand-100">Track bookkeeping clients, clarifications, due dates, service requests and conversion requests with audit firms.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/consultant/work" class="rounded-xl bg-white px-4 py-2 text-sm font-semibold text-brand-700 hover:bg-brand-50">Open Work Board</a>
|
||||
<a href="/consultant/service-requests" class="rounded-xl border border-white/40 px-4 py-2 text-sm font-semibold text-white hover:bg-white/10">Service Requests</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
|
||||
<a href="/consultant/communications" class="af-metric-card border-purple-200 bg-purple-50 hover:border-purple-300"><div class="text-xs font-semibold uppercase text-purple-700">Clarifications</div><div class="mt-2 text-3xl font-semibold text-purple-700">{{ payload.stats.pending_clarifications or 0 }}</div><div class="mt-1 text-xs text-purple-700">Firm messages</div></a>
|
||||
<a href="/consultant/service-requests" class="af-metric-card hover:border-brand-200"><div class="text-xs font-semibold uppercase text-slate-500">Service Requests</div><div class="mt-2 text-3xl font-semibold text-slate-900">{{ payload.stats.open_service_requests or 0 }}</div><div class="mt-1 text-xs text-slate-500">Open requests</div></a>
|
||||
<a href="/consultant/managed-clients" class="af-metric-card hover:border-brand-200"><div class="text-xs font-semibold uppercase text-slate-500">Managed Clients</div><div class="mt-2 text-3xl font-semibold text-slate-900">{{ payload.stats.managed_clients or 0 }}</div><div class="mt-1 text-xs text-slate-500">Bookkeeping / support</div></a>
|
||||
<a href="/consultant/managed-clients" class="af-metric-card hover:border-brand-200"><div class="text-xs font-semibold uppercase text-slate-500">Prospects</div><div class="mt-2 text-3xl font-semibold text-slate-900">{{ payload.stats.prospects or 0 }}</div><div class="mt-1 text-xs text-slate-500">Lead pipeline</div></a>
|
||||
<a href="/consultant/work" class="af-metric-card border-amber-200 bg-amber-50 hover:border-amber-300"><div class="text-xs font-semibold uppercase text-amber-700">Due Soon</div><div class="mt-2 text-3xl font-semibold text-amber-700">{{ payload.stats.upcoming_due or 0 }}</div><div class="mt-1 text-xs text-amber-700">Next 30 days</div></a>
|
||||
<a href="/consultant/work" 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.stats.overdue or 0 }}</div><div class="mt-1 text-xs text-red-700">Linked firm work</div></a>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-6 xl:grid-cols-[380px_minmax(0,1fr)]">
|
||||
<aside class="space-y-6">
|
||||
<div class="af-card">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-900">Workspace & Plan</h3>
|
||||
<p class="mt-1 text-sm text-slate-600">{{ payload.workspace.workspace_name }}</p>
|
||||
<p class="text-xs text-slate-500">{{ payload.workspace.workspace_type.replace('_',' ').title() }} • {{ payload.workspace.plan_code.replace('_',' ').title() }} • {{ payload.workspace.subscription_status.replace('_',' ').title() }}</p>
|
||||
</div>
|
||||
<a href="/consultant/workspace/edit" class="rounded-xl border border-slate-300 px-3 py-2 text-xs font-semibold text-slate-700 hover:bg-slate-50">Edit</a>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<div class="flex items-center justify-between text-xs text-slate-500">
|
||||
<span>Managed client usage</span>
|
||||
<span>{{ payload.workspace_summary.managed_clients_used or 0 }} / {{ payload.workspace_summary.managed_clients_limit or 'No limit' }}</span>
|
||||
</div>
|
||||
<div class="mt-2 h-2 rounded-full bg-slate-100"><div class="h-2 rounded-full bg-slate-900" style="width: {{ 100 if (payload.workspace_summary.usage_percent or 0) > 100 else (payload.workspace_summary.usage_percent or 0) }}%"></div></div>
|
||||
<div class="mt-2 text-xs text-slate-500">{% if payload.workspace_summary.managed_clients_remaining is none %}No client limit configured.{% else %}{{ payload.workspace_summary.managed_clients_remaining }} client slots remaining.{% endif %}</div>
|
||||
</div>
|
||||
<div class="mt-5 grid gap-2 text-xs text-slate-600">
|
||||
<div class="flex justify-between"><span>Client portal</span><span>{{ 'Enabled' if payload.workspace.allow_client_portal else 'Disabled' }}</span></div>
|
||||
<div class="flex justify-between"><span>Firm referrals</span><span>{{ 'Enabled' if payload.workspace.allow_firm_referrals else 'Disabled' }}</span></div>
|
||||
<div class="flex justify-between"><span>Marketplace</span><span>{{ 'Enabled' if payload.workspace.allow_service_marketplace else 'Disabled' }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="af-card">
|
||||
<h3 class="font-semibold text-slate-900">Quick Actions</h3>
|
||||
<div class="mt-4 grid gap-2 text-sm">
|
||||
<a href="/consultant/managed-clients" class="rounded-xl border border-slate-200 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-50">Manage Clients</a>
|
||||
{% if payload.workspace_summary.managed_clients_remaining is none or payload.workspace_summary.managed_clients_remaining > 0 %}
|
||||
<a href="/consultant/managed-clients/new" class="rounded-xl border border-slate-200 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-50">Add Managed Client</a>
|
||||
{% endif %}
|
||||
<a href="/consultant/communications" class="rounded-xl border border-slate-200 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-50">Reply Clarifications</a>
|
||||
<a href="/consultant/service-requests" class="rounded-xl border border-slate-200 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-50">Service Requests / Leads</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div 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">Pending Consultant Clarifications</h3><p class="mt-1 text-sm text-slate-500">Firm messages visible to consultant. Internal and client-only notes are hidden.</p></div>
|
||||
<a href="/consultant/communications" class="text-sm font-semibold text-brand-700 hover:underline">View all</a>
|
||||
</div>
|
||||
<div class="mt-4 space-y-3">
|
||||
{% for comment, task, subscription, client, catalogue in payload.recent_firm_messages %}
|
||||
<a href="/consultant/communications/{{ comment.id }}" class="block rounded-2xl border border-slate-200 p-4 hover:bg-slate-50">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div><div class="font-semibold text-slate-900">{{ client.client_name }} — {{ catalogue.service_name }}</div><div class="text-xs text-slate-500">Task: {{ task.task_name }} • {{ comment.created_at_utc.strftime('%d-%m-%Y %H:%M') if comment.created_at_utc else '' }}</div></div>
|
||||
<span class="rounded-full bg-purple-50 px-2 py-1 text-xs font-semibold text-purple-700">{{ comment.comment_type.replace('_',' ').title() }}</span>
|
||||
</div>
|
||||
<div class="mt-3 line-clamp-3 whitespace-pre-line rounded-xl bg-slate-50 p-3 text-sm text-slate-700">{{ comment.message }}</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500">No pending consultant-visible firm messages.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="af-card">
|
||||
<div class="flex items-center justify-between"><h3 class="font-semibold text-slate-900">Pending Conversions</h3><a href="/consultant/managed-clients" class="text-xs font-semibold text-brand-700 hover:underline">View</a></div>
|
||||
<div class="mt-4 space-y-3">{% for client in payload.pending_conversions %}<a href="/consultant/managed-clients/{{ client.id }}" class="block rounded-xl border border-slate-200 p-3 hover:bg-slate-50"><div class="font-semibold text-slate-900">{{ client.client_name }}</div><div class="text-xs text-slate-500">{{ client.client_code or '-' }} • {{ client.conversion_status.replace('_',' ').title() }}</div></a>{% else %}<div class="text-sm text-slate-500">No pending firm-client conversion requests.</div>{% endfor %}</div>
|
||||
</div>
|
||||
<div class="af-card">
|
||||
<div class="flex items-center justify-between"><h3 class="font-semibold text-slate-900">Upcoming Due Dates</h3><span class="text-xs text-slate-500">30 days</span></div>
|
||||
<div class="mt-4 space-y-3">{% for subscription, client, catalogue in payload.due_items %}<div class="rounded-xl border border-slate-200 p-3"><div class="font-semibold text-slate-900">{{ client.client_name }}</div><div class="text-xs text-slate-500">{{ catalogue.service_name }} • Due {{ subscription.current_due_date.strftime('%d-%m-%Y') if subscription.current_due_date else '-' }}</div></div>{% else %}<div class="text-sm text-slate-500">No upcoming due dates.</div>{% endfor %}</div>
|
||||
</div>
|
||||
<div class="af-card border-red-200">
|
||||
<div class="flex items-center justify-between"><h3 class="font-semibold text-slate-900">Overdue Work</h3><span class="text-xs text-red-500">Attention</span></div>
|
||||
<div class="mt-4 space-y-3">{% for subscription, client, catalogue in payload.overdue_items %}<div class="rounded-xl border border-red-100 bg-red-50 p-3"><div class="font-semibold text-slate-900">{{ client.client_name }}</div><div class="text-xs text-red-600">{{ catalogue.service_name }} • Due {{ subscription.current_due_date.strftime('%d-%m-%Y') if subscription.current_due_date else '-' }}</div></div>{% else %}<div class="text-sm text-slate-500">No overdue linked firm engagements.</div>{% endfor %}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<div class="af-card">
|
||||
<div class="flex items-center justify-between"><h3 class="font-semibold text-slate-900">My Managed Clients</h3><a href="/consultant/managed-clients" class="text-xs font-semibold text-brand-700 hover:underline">View all</a></div>
|
||||
<div class="mt-4 space-y-3">{% for client in payload.managed_clients %}<a href="/consultant/managed-clients/{{ client.id }}" class="block rounded-xl border border-slate-200 p-3 hover:bg-slate-50"><div class="font-semibold text-slate-900">{{ client.client_name }}</div><div class="text-xs text-slate-500">{{ client.client_code or '-' }} • {{ client.relationship_stage.replace('_',' ').title() }} • {{ client.status.replace('_',' ').title() }}</div></a>{% else %}<div class="text-sm text-slate-500">No managed clients yet.</div>{% endfor %}</div>
|
||||
</div>
|
||||
<div class="af-card">
|
||||
<h3 class="font-semibold text-slate-900">Linked Firm Clients</h3><p class="mt-1 text-xs text-slate-500">Only explicitly linked clients are visible here.</p>
|
||||
<div class="mt-4 space-y-3">{% for link in payload.active_links %}<div class="rounded-xl border border-slate-200 p-3"><div class="font-semibold text-slate-900">{{ link.client.client_name if link.client else '-' }}</div><div class="text-xs text-slate-500">{{ link.client.client_code if link.client else '' }} • {{ link.relationship_type.replace('_',' ').title() }}</div><div class="mt-2 text-xs text-slate-600">Due dates {{ 'enabled' if link.can_view_due_dates else 'hidden' }} • Communications {{ 'enabled' if link.can_view_communications else 'hidden' }}</div></div>{% else %}<div class="text-sm text-slate-500">No firm clients are linked yet.</div>{% endfor %}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,92 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
|
||||
{% set is_dict = consultant is mapping %}
|
||||
<div class="mx-auto max-w-3xl space-y-6">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">My Consultant Profile</h2>
|
||||
<p class="text-sm text-slate-500">Update your visible consultant contact and business details.</p>
|
||||
</div>
|
||||
<a href="/consultant/dashboard" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back</a>
|
||||
</div>
|
||||
|
||||
{% if errors %}
|
||||
<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||
<ul class="list-disc pl-5">{% for error in errors %}<li>{{ error }}</li>{% endfor %}</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" enctype="multipart/form-data" class="space-y-5 rounded-2xl border border-slate-200 bg-white p-6 shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="rounded-2xl bg-slate-50 p-4">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
{% set profile_photo_url = get_user_profile_photo_url(current_user) %}
|
||||
{% if profile_photo_url %}
|
||||
<img src="{{ profile_photo_url }}" alt="Profile photo" class="h-20 w-20 rounded-2xl object-cover shadow-soft">
|
||||
{% else %}
|
||||
<div class="flex h-20 w-20 items-center justify-center rounded-2xl bg-brand-600 text-xl font-bold text-white shadow-soft">{{ get_user_initials(current_user) }}</div>
|
||||
{% endif %}
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-semibold text-slate-900">Public profile</div>
|
||||
<p class="text-sm text-slate-500">Photo, qualification and bio will be used in consultant workspace and future lead pages.</p>
|
||||
<input type="file" name="profile_photo" accept="image/png,image/jpeg,image/gif,image/webp" class="mt-3 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Qualification</label>
|
||||
<input name="qualification" value="{{ current_user.qualification or '' }}" placeholder="CA, MBA, GST Practitioner" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Display Designation</label>
|
||||
<input name="public_designation" value="{{ current_user.designation or 'Consultant' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Short Bio</label>
|
||||
<textarea name="bio" rows="3" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ current_user.bio or '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Contact person / Consultant name *</label>
|
||||
<input name="contact_person" value="{{ consultant.contact_person if consultant and not is_dict else consultant.get('contact_person','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Firm name</label>
|
||||
<input name="firm_name" value="{{ consultant.firm_name if consultant and not is_dict else consultant.get('firm_name','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Email</label>
|
||||
<input type="email" name="email" value="{{ consultant.email if consultant and not is_dict else consultant.get('email','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Mobile</label>
|
||||
<input name="mobile" value="{{ consultant.mobile if consultant and not is_dict else consultant.get('mobile','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">PAN</label>
|
||||
<input name="pan" value="{{ consultant.pan if consultant and not is_dict else consultant.get('pan','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">GSTIN</label>
|
||||
<input name="gstin" value="{{ consultant.gstin if consultant and not is_dict else consultant.get('gstin','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Specialisation</label>
|
||||
<input name="specialisation" value="{{ consultant.specialisation if consultant and not is_dict else consultant.get('specialisation','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Address</label>
|
||||
<textarea name="address" rows="3" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ consultant.address if consultant and not is_dict else consultant.get('address','') if consultant else '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3">
|
||||
<a href="/consultant/dashboard" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Cancel</a>
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Save Profile</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,30 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
|
||||
<div class="mx-auto max-w-4xl space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3"><div><h2 class="text-xl font-semibold text-slate-900">{{ request_row.request_no }}</h2><p class="text-sm text-slate-500">{{ request_row.subject }}</p></div><a href="{{ '/consultants/service-requests' if internal_view else '/consultant/service-requests' }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back</a></div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="grid gap-4 text-sm md:grid-cols-3">
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Status</div><div class="font-medium text-slate-900">{{ request_row.status.replace('_',' ').title() }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Priority</div><div class="font-medium text-slate-900">{{ request_row.priority.replace('_',' ').title() }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Requested Date</div><div class="font-medium text-slate-900">{{ request_row.requested_due_date or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Consultant</div><div class="font-medium text-slate-900">{{ request_row.consultant.contact_person if request_row.consultant else '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Client</div><div class="font-medium text-slate-900">{{ request_row.managed_client.client_name if request_row.managed_client else (request_row.firm_client.client_name if request_row.firm_client else '-') }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Service</div><div class="font-medium text-slate-900">{{ request_row.requested_service_name }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft"><h3 class="font-semibold text-slate-900">Description</h3><p class="mt-3 whitespace-pre-line text-sm text-slate-700">{{ request_row.description or '-' }}</p></div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft"><h3 class="font-semibold text-slate-900">Consultant Notes</h3><p class="mt-3 whitespace-pre-line text-sm text-slate-700">{{ request_row.consultant_notes or '-' }}</p></div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft"><h3 class="font-semibold text-slate-900">Firm Response</h3><p class="mt-3 whitespace-pre-line text-sm text-slate-700">{{ request_row.firm_response or 'No response yet.' }}</p>{% if request_row.reviewed_by %}<p class="mt-2 text-xs text-slate-500">Reviewed by {{ request_row.reviewed_by.full_name or request_row.reviewed_by.email }} on {{ request_row.reviewed_at_utc.strftime('%d-%m-%Y %H:%M') if request_row.reviewed_at_utc else '-' }}</p>{% endif %}</div>
|
||||
{% if internal_view %}
|
||||
<form method="post" action="/consultants/service-requests/{{ request_row.id }}/status" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<h3 class="font-semibold text-slate-900">Update Firm Decision</h3>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2"><div><label class="mb-2 block text-sm font-medium text-slate-700">Status</label><select name="status" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{% for code,label in service_request_statuses %}<option value="{{ code }}" {% if request_row.status == code %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></div><div><label class="mb-2 block text-sm font-medium text-slate-700">Action Note</label><input name="firm_response" value="{{ request_row.firm_response or '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"></div></div>
|
||||
<div class="mt-4 flex justify-end"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Save Decision</button></div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,23 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
|
||||
<div class="mx-auto max-w-4xl space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3"><div><h2 class="text-xl font-semibold text-slate-900">New Service Request</h2><p class="text-sm text-slate-500">Raise a request to the audit firm for review and acceptance.</p></div><a href="/consultant/service-requests" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back</a></div>
|
||||
{% if errors %}<div class="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">{% for error in errors %}<div>{{ error }}</div>{% endfor %}</div>{% endif %}
|
||||
<form method="post" class="rounded-2xl border border-slate-200 bg-white p-6 shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="grid gap-5 md:grid-cols-2">
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Managed Client</label><select name="managed_client_id" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"><option value="">-- Select managed client --</option>{% for client in managed_clients %}<option value="{{ client.id }}">{{ client.client_name }} ({{ client.client_code or 'Managed' }})</option>{% endfor %}</select><p class="mt-1 text-xs text-slate-500">Use this for clients maintained in your consultant portal.</p></div>
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Linked Firm Client</label><select name="firm_client_id" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"><option value="">-- Select linked firm client --</option>{% for link in firm_links %}{% if link.client %}<option value="{{ link.client.id }}">{{ link.client.client_name }} ({{ link.client.client_code }})</option>{% endif %}{% endfor %}</select><p class="mt-1 text-xs text-slate-500">Select either managed client or linked firm client, not both.</p></div>
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Service Catalogue</label><select name="service_catalogue_id" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"><option value="">-- Select service --</option>{% for service in services %}<option value="{{ service.id }}">{{ service.service_name }}</option>{% endfor %}</select></div>
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Other / Custom Service Name</label><input name="requested_service_name" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Required only if service not selected"></div>
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Priority</label><select name="priority" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{% for code,label in service_request_priorities %}<option value="{{ code }}" {% if code == 'normal' %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></div>
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Expected / Requested Date</label><input type="date" name="requested_due_date" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"></div>
|
||||
<div class="md:col-span-2"><label class="mb-2 block text-sm font-medium text-slate-700">Subject</label><input name="subject" required class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Example: Tax audit request for ABC Pvt Ltd"></div>
|
||||
<div class="md:col-span-2"><label class="mb-2 block text-sm font-medium text-slate-700">Description</label><textarea name="description" rows="4" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Describe scope, period, documents available, urgency, etc."></textarea></div>
|
||||
<div class="md:col-span-2"><label class="mb-2 block text-sm font-medium text-slate-700">Consultant Notes</label><textarea name="consultant_notes" rows="3" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Internal notes from your side for the firm."></textarea></div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Submit Request</button></div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,20 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div><h2 class="text-xl font-semibold text-slate-900">My Service Requests</h2><p class="text-sm text-slate-500">Request audit firm services for your managed or linked clients.</p></div>
|
||||
<div class="flex gap-2"><a href="/consultant/dashboard" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Dashboard</a><a href="/consultant/service-requests/new" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">New Request</a></div>
|
||||
</div>
|
||||
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="grid gap-3 md:grid-cols-[1fr_auto]">
|
||||
<select name="status" class="rounded-xl border border-slate-300 px-3 py-2 text-sm"><option value="">All statuses</option>{% for code,label in service_request_statuses %}<option value="{{ code }}" {% if status == code %}selected{% endif %}>{{ label }}</option>{% endfor %}</select>
|
||||
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<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">Request</th><th class="px-4 py-3">Client</th><th class="px-4 py-3">Service</th><th class="px-4 py-3">Status</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.request_no }}</div><div class="text-xs text-slate-500">{{ row.subject }}</div></td><td class="px-4 py-3 text-slate-700">{{ row.managed_client.client_name if row.managed_client else (row.firm_client.client_name if row.firm_client else '-') }}</td><td class="px-4 py-3 text-slate-700">{{ row.requested_service_name }}</td><td class="px-4 py-3"><span class="rounded-full bg-blue-50 px-2 py-1 text-xs font-semibold text-blue-700">{{ row.status.replace('_',' ').title() }}</span></td><td class="px-4 py-3 text-right"><a href="/consultant/service-requests/{{ row.id }}" class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Open</a></td></tr>{% else %}<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">No service requests yet.</td></tr>{% endfor %}</tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,85 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">My Consultant Work Board</h2>
|
||||
<p class="text-sm text-slate-500">Consultant-visible work shared by the firm. Internal firm tasks and private notes are not shown here.</p>
|
||||
</div>
|
||||
<a href="/consultant/service-requests/new" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Raise Service Request</a>
|
||||
</div>
|
||||
|
||||
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="grid gap-3 md:grid-cols-[1fr_auto_auto]">
|
||||
<input name="q" value="{{ q or '' }}" placeholder="Search client, service, task, message" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<select name="status" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="">All columns</option>
|
||||
{% for code, label in board.column_options %}<option value="{{ code }}" {% if status == code %}selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="overflow-x-auto pb-2">
|
||||
<div class="grid min-w-[1180px] gap-4 lg:grid-cols-6">
|
||||
{% for key, column in board.columns.items() %}
|
||||
<section class="rounded-2xl border border-slate-200 bg-slate-50 p-3">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-slate-800">{{ column["label"] }}</h3>
|
||||
<span class="rounded-full bg-white px-2 py-0.5 text-xs font-semibold text-slate-600">{{ column["items"]|length }}</span>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
{% for item in column["items"] %}
|
||||
{% set task = item.task %}
|
||||
{% set client = item.client %}
|
||||
{% set catalogue = item.catalogue %}
|
||||
<a href="/work/engagements/{{ task.subscription_id }}" class="block rounded-2xl border border-slate-200 bg-white p-4 shadow-sm hover:border-brand-300 hover:shadow-soft">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-slate-900">{{ client.client_name }}</div>
|
||||
<div class="text-xs text-slate-500">{{ catalogue.service_name }}</div>
|
||||
</div>
|
||||
{% if item.is_overdue %}<span class="rounded-full bg-red-50 px-2 py-1 text-[11px] font-semibold text-red-700">Overdue</span>{% endif %}
|
||||
</div>
|
||||
<div class="mt-3 text-sm font-medium text-slate-800">{{ task.task_name }}</div>
|
||||
<div class="mt-2 flex flex-wrap gap-1 text-[11px] font-semibold">
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-600">{{ task.status.replace('_',' ').title() }}</span>
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-600">{{ task.priority.replace('_',' ').title() }}</span>
|
||||
{% if task.internal_target_date %}<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-600">Due {{ task.internal_target_date.strftime('%d-%m-%Y') }}</span>{% endif %}
|
||||
</div>
|
||||
<div class="mt-3 line-clamp-3 rounded-xl bg-slate-50 p-2 text-xs text-slate-600">{{ item.comment.message }}</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="rounded-xl border border-dashed border-slate-300 bg-white p-4 text-center text-xs text-slate-500">No items</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<div class="flex items-center justify-between border-b border-slate-200 px-4 py-3">
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-900">My Service Requests</h3>
|
||||
<p class="text-xs text-slate-500">Requests raised by you to the firm.</p>
|
||||
</div>
|
||||
<a href="/consultant/service-requests" class="text-xs font-semibold text-brand-700 hover:underline">View all</a>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-100">
|
||||
{% for row in board.service_requests[:8] %}
|
||||
<a href="/consultant/service-requests/{{ row.id }}" class="flex flex-wrap items-center justify-between gap-3 p-4 hover:bg-slate-50">
|
||||
<div>
|
||||
<div class="font-semibold text-slate-900">{{ row.subject }}</div>
|
||||
<div class="text-xs text-slate-500">{{ row.request_no }} • {{ row.requested_service_name }}{% if row.requested_due_date %} • Due {{ row.requested_due_date.strftime('%d-%m-%Y') }}{% endif %}</div>
|
||||
</div>
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-700">{{ row.status.replace('_',' ').title() }}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="p-6 text-sm text-slate-500">No service requests yet.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,73 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Consultant Workspace</h2>
|
||||
<p class="text-sm text-slate-500">SaaS / franchise workspace foundation for your consultant portal.</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="/consultant/dashboard" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back to Dashboard</a>
|
||||
<a href="/consultant/workspace/edit" class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800">Edit Workspace</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Workspace Code</div>
|
||||
<div class="mt-2 text-lg font-semibold text-slate-900">{{ workspace.workspace_code }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Plan</div>
|
||||
<div class="mt-2 text-lg font-semibold text-slate-900">{{ workspace.plan_code.replace('_',' ').title() }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Status</div>
|
||||
<div class="mt-2 text-lg font-semibold text-slate-900">{{ workspace.subscription_status.replace('_',' ').title() }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Billing</div>
|
||||
<div class="mt-2 text-lg font-semibold text-slate-900">{{ workspace.billing_cycle.replace('_',' ').title() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<div class="border-b border-slate-200 px-5 py-4">
|
||||
<h3 class="font-semibold text-slate-900">Workspace Details</h3>
|
||||
</div>
|
||||
<div class="grid gap-4 p-5 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Workspace Name</div>
|
||||
<div class="mt-1 font-medium text-slate-900">{{ workspace.workspace_name }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Workspace Type</div>
|
||||
<div class="mt-1 font-medium text-slate-900">{{ workspace.workspace_type.replace('_',' ').title() }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Subscription Period</div>
|
||||
<div class="mt-1 font-medium text-slate-900">{{ workspace.subscription_start_date or '-' }} to {{ workspace.subscription_end_date or '-' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Limits</div>
|
||||
<div class="mt-1 font-medium text-slate-900">{{ workspace.max_managed_clients }} managed clients • {{ workspace.max_user_accounts }} user account(s)</div>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Enabled Features</div>
|
||||
<div class="mt-2 flex flex-wrap gap-2 text-xs font-semibold">
|
||||
<span class="rounded-full {{ 'bg-emerald-50 text-emerald-700' if workspace.allow_client_portal else 'bg-slate-100 text-slate-600' }} px-2 py-1">Client Portal {{ 'On' if workspace.allow_client_portal else 'Off' }}</span>
|
||||
<span class="rounded-full {{ 'bg-emerald-50 text-emerald-700' if workspace.allow_firm_referrals else 'bg-slate-100 text-slate-600' }} px-2 py-1">Firm Referrals {{ 'On' if workspace.allow_firm_referrals else 'Off' }}</span>
|
||||
<span class="rounded-full {{ 'bg-emerald-50 text-emerald-700' if workspace.allow_service_marketplace else 'bg-slate-100 text-slate-600' }} px-2 py-1">Marketplace {{ 'On' if workspace.allow_service_marketplace else 'Off' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% if workspace.remarks %}
|
||||
<div class="md:col-span-2">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Remarks</div>
|
||||
<div class="mt-1 whitespace-pre-line rounded-xl bg-slate-50 p-3 text-sm text-slate-700">{{ workspace.remarks }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,70 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Edit Consultant Workspace</h2>
|
||||
<p class="text-sm text-slate-500">Update your workspace display details. Plan, billing, subscription and limits are controlled by the firm/admin.</p>
|
||||
</div>
|
||||
<a href="/consultant/workspace" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back to Workspace</a>
|
||||
</div>
|
||||
|
||||
{% if errors %}
|
||||
<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||
<ul class="list-disc pl-5">
|
||||
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Plan</div>
|
||||
<div class="mt-2 text-lg font-semibold text-slate-900">{{ workspace.plan_code.replace('_',' ').title() if workspace and workspace.plan_code else 'Starter' }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Subscription</div>
|
||||
<div class="mt-2 text-lg font-semibold text-slate-900">{{ workspace.subscription_status.replace('_',' ').title() if workspace and workspace.subscription_status else 'Trial' }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Billing</div>
|
||||
<div class="mt-2 text-lg font-semibold text-slate-900">{{ workspace.billing_cycle.replace('_',' ').title() if workspace and workspace.billing_cycle else 'Manual' }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Client Limit</div>
|
||||
<div class="mt-2 text-lg font-semibold text-slate-900">{{ workspace.max_managed_clients if workspace else 25 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-slate-700">Workspace Name</label>
|
||||
<input name="workspace_name" value="{{ workspace.workspace_name if workspace else '' }}" required class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-slate-900 focus:outline-none">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-slate-700">Workspace Code</label>
|
||||
<input value="{{ workspace.workspace_code if workspace else '-' }}" disabled class="mt-1 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
|
||||
<div class="font-semibold">Admin-controlled fields</div>
|
||||
<p class="mt-1">Workspace type, plan, subscription status, billing cycle, client/user limits, feature switches and active status cannot be changed from the consultant portal.</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<label class="text-sm font-medium text-slate-700">Remarks</label>
|
||||
<textarea name="remarks" rows="4" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-slate-900 focus:outline-none">{{ workspace.remarks if workspace and workspace.remarks else '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex gap-2">
|
||||
<button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800">Save Workspace</button>
|
||||
<a href="/consultant/workspace" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
|
||||
class AuditLog(CommonBase):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
|
||||
actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True, nullable=True)
|
||||
actor_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
actor_tenant_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
actor_branch_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
|
||||
action: Mapped[str] = mapped_column(String(120), index=True)
|
||||
entity_type: Mapped[str] = mapped_column(String(120), index=True)
|
||||
entity_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
entity_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(30), default="success", index=True)
|
||||
|
||||
target_tenant_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
target_branch_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
|
||||
ip_address: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
user_agent: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
details_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
@@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.core.audit.models import AuditLog
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.core.iam.scope import UserScope
|
||||
|
||||
|
||||
def _safe_json(value: Any) -> str:
|
||||
return json.dumps(value or {}, ensure_ascii=False, default=_json_default, sort_keys=True)
|
||||
|
||||
|
||||
def _json_default(value: Any):
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
if hasattr(value, "isoformat"):
|
||||
try:
|
||||
return value.isoformat()
|
||||
except Exception:
|
||||
pass
|
||||
return str(value)
|
||||
|
||||
|
||||
def _request_meta(request: Request | None) -> tuple[str | None, str | None]:
|
||||
if not request:
|
||||
return None, None
|
||||
ip = request.client.host if request.client else None
|
||||
user_agent = request.headers.get("user-agent")
|
||||
return ip, user_agent
|
||||
|
||||
|
||||
def write_audit_log(
|
||||
db: Session,
|
||||
*,
|
||||
action: str,
|
||||
entity_type: str,
|
||||
actor: User | None = None,
|
||||
request: Request | None = None,
|
||||
entity_id: str | int | None = None,
|
||||
entity_name: str | None = None,
|
||||
status: str = "success",
|
||||
target_tenant_id: int | None = None,
|
||||
target_branch_id: int | None = None,
|
||||
details: dict[str, Any] | None = None,
|
||||
actor_email: str | None = None,
|
||||
) -> AuditLog:
|
||||
ip_address, user_agent = _request_meta(request)
|
||||
log = AuditLog(
|
||||
actor_user_id=actor.id if actor else None,
|
||||
actor_email=(actor.email if actor else actor_email),
|
||||
actor_tenant_id=(actor.tenant_id if actor else None),
|
||||
actor_branch_id=(actor.branch_id if actor else None),
|
||||
action=action,
|
||||
entity_type=entity_type,
|
||||
entity_id=str(entity_id) if entity_id is not None else None,
|
||||
entity_name=entity_name,
|
||||
status=status,
|
||||
target_tenant_id=target_tenant_id,
|
||||
target_branch_id=target_branch_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
details_json=_safe_json(details),
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
db.refresh(log)
|
||||
return log
|
||||
|
||||
|
||||
def model_snapshot(obj: Any, fields: list[str]) -> dict[str, Any]:
|
||||
return {field: getattr(obj, field, None) for field in fields}
|
||||
|
||||
|
||||
def pair_before_after(before: dict[str, Any], after: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"before": before, "after": after}
|
||||
|
||||
|
||||
def list_audit_logs(db: Session, scope: UserScope, limit: int = 200) -> list[AuditLog]:
|
||||
q = select(AuditLog)
|
||||
if not scope.is_system_admin:
|
||||
q = q.where(AuditLog.target_tenant_id == scope.actor.tenant_id)
|
||||
if scope.branch_scoped:
|
||||
q = q.where(AuditLog.target_branch_id == scope.actor.branch_id)
|
||||
return db.execute(q.order_by(AuditLog.created_at_utc.desc(), AuditLog.id.desc()).limit(limit)).scalars().all()
|
||||
|
||||
|
||||
def parse_details(log: AuditLog) -> dict[str, Any]:
|
||||
try:
|
||||
return json.loads(log.details_json or "{}")
|
||||
except Exception:
|
||||
return {"raw": log.details_json}
|
||||
|
||||
|
||||
|
||||
def search_audit_logs(db: Session, scope: UserScope, q: str | None = None) -> list[AuditLog]:
|
||||
rows = list_audit_logs(db, scope, limit=1000)
|
||||
query = (q or "").strip().lower()
|
||||
if not query:
|
||||
return rows
|
||||
result = []
|
||||
for row in rows:
|
||||
hay = " ".join([str(row.action or ""), str(row.entity_type or ""), str(row.entity_name or ""), str(row.actor_email or ""), str(row.details_json or "")]).lower()
|
||||
if query in hay:
|
||||
result.append(row)
|
||||
return result
|
||||
@@ -0,0 +1,14 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% import "ui/templates/components/macros.html" as ui %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
{{ ui.page_shell('Audit Logs', 'Latest security and admin changes captured from IAM, RBAC, login, audit firm and branch operations.') }}
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
{{ ui.search_bar('/system-settings/audit-logs', filters.q, filters.per_page) }}
|
||||
{% if logs %}
|
||||
<div class="overflow-x-auto"><table class="min-w-full divide-y divide-slate-200 text-sm"><thead class="bg-slate-50 text-slate-600"><tr><th class="px-4 py-3 text-left font-semibold">When</th><th class="px-4 py-3 text-left font-semibold">Action</th><th class="px-4 py-3 text-left font-semibold">Entity</th><th class="px-4 py-3 text-left font-semibold">Actor</th><th class="px-4 py-3 text-left font-semibold">Target Scope</th><th class="px-4 py-3 text-left font-semibold">Details</th><th class="px-4 py-3 text-left font-semibold">Status</th></tr></thead><tbody class="divide-y divide-slate-100">{% for row in logs %}<tr class="align-top"><td class="px-4 py-3 whitespace-nowrap text-slate-600">{{ row.created_at_utc }}</td><td class="px-4 py-3"><div class="font-medium text-slate-900">{{ row.action }}</div><div class="text-xs text-slate-500">IP {{ row.ip_address or '-' }}</div></td><td class="px-4 py-3"><div class="font-medium text-slate-900">{{ row.entity_type }}</div><div class="text-xs text-slate-500">{{ row.entity_name or row.entity_id or '-' }}</div></td><td class="px-4 py-3"><div class="font-medium text-slate-900">{{ row.actor_email or 'System' }}</div><div class="text-xs text-slate-500">Audit Firm {{ row.actor_tenant_id or '-' }} • Branch {{ row.actor_branch_id or '-' }}</div></td><td class="px-4 py-3 text-slate-600">Audit Firm {{ row.target_tenant_id or '-' }} • Branch {{ row.target_branch_id or '-' }}</td><td class="px-4 py-3 max-w-[28rem]"><pre class="whitespace-pre-wrap break-words rounded-xl bg-slate-50 p-3 text-xs text-slate-700">{{ row.pretty_details }}</pre></td><td class="px-4 py-3">{% if row.status == 'success' %}{{ ui.badge(row.status, 'emerald') }}{% elif row.status == 'denied' %}{{ ui.badge(row.status, 'amber') }}{% else %}{{ ui.badge(row.status, 'rose') }}{% endif %}</td></tr>{% endfor %}</tbody></table></div>
|
||||
{{ ui.pagination(logs_page, '/system-settings/audit-logs', request.url.query) }}
|
||||
{% else %}<div class="p-6">{{ ui.empty_state('No audit entries found for the current filter.') }}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from app.core.templating import templates
|
||||
from app.core.db.common import CommonSessionLocal
|
||||
from app.core.security.csrf import get_or_create_csrf_token
|
||||
from app.core.security.session_auth import get_current_user
|
||||
from app.modules.core.audit.service import parse_details, search_audit_logs
|
||||
from app.modules.core.iam.scope import build_scope
|
||||
from app.modules.core.iam.services import paginate_list
|
||||
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
||||
from app.modules.core.rbac.permission_guard import require_permission
|
||||
|
||||
router = APIRouter(prefix="/system-settings/audit-logs", tags=["audit-ui"])
|
||||
|
||||
|
||||
def _is_system_admin(db, user) -> bool:
|
||||
return "System Admin" in get_user_roles(db, user.id)
|
||||
|
||||
|
||||
def _is_firm_admin(db, user) -> bool:
|
||||
return "Firm Admin" in get_user_roles(db, user.id)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def logs(request: Request, q: str = "", page: int = 1, per_page: int = 20):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
|
||||
try:
|
||||
require_permission(db, user, "audit.view")
|
||||
except Exception:
|
||||
return RedirectResponse(url="/system-settings", status_code=303)
|
||||
|
||||
if not _is_system_admin(db, user) and not _is_firm_admin(db, user):
|
||||
return RedirectResponse(url="/system-settings", status_code=303)
|
||||
|
||||
scope = build_scope(db, user)
|
||||
rows = search_audit_logs(db, scope, q=q)
|
||||
|
||||
# Enforce final matrix explicitly:
|
||||
# - System Admin: all logs
|
||||
# - Firm Admin: own tenant logs only
|
||||
# - others: none
|
||||
if _is_firm_admin(db, user) and not _is_system_admin(db, user):
|
||||
rows = [r for r in rows if getattr(r, "target_tenant_id", None) == user.tenant_id]
|
||||
|
||||
paged = paginate_list(rows, page=page, per_page=per_page)
|
||||
decorated = []
|
||||
for row in paged.items:
|
||||
pretty = json.dumps(parse_details(row), indent=2, ensure_ascii=False, default=str)
|
||||
setattr(row, "pretty_details", pretty)
|
||||
decorated.append(row)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"modules/core/audit/templates/logs.html",
|
||||
{
|
||||
"request": request,
|
||||
"current_user": user,
|
||||
"current_user_roles": get_user_roles(db, user.id),
|
||||
"current_user_permissions": get_user_permissions(db, user.id),
|
||||
"csrf_token": get_or_create_csrf_token(request),
|
||||
"title": "Audit Logs",
|
||||
"logs": decorated,
|
||||
"logs_page": paged,
|
||||
"filters": {"q": (q or "").strip(), "per_page": paged.per_page},
|
||||
},
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,363 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db.deps import get_common_db
|
||||
from app.core.security.passwords import hash_password
|
||||
from app.core.security.session_auth import require_login
|
||||
from app.modules.core.audit.service import model_snapshot, pair_before_after, write_audit_log
|
||||
from app.modules.core.iam.lifecycle import activate_user, deactivate_user, disable_login, enable_login, ensure_manageable_lifecycle, lock_user, restore_user, soft_delete_user, unlock_user, LifecycleError
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.core.iam.scope import (
|
||||
build_scope,
|
||||
ensure_assignable_roles,
|
||||
ensure_manageable_existing_user,
|
||||
ensure_users_manage_scope,
|
||||
ensure_users_view_scope,
|
||||
list_scoped_users,
|
||||
resolve_target_tenant_branch,
|
||||
scope_to_http,
|
||||
)
|
||||
from app.modules.core.rbac.deps import require_permission
|
||||
from app.modules.core.rbac.models import UserRole
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
class UserCreateRequest(BaseModel):
|
||||
email: EmailStr
|
||||
full_name: str
|
||||
password: str
|
||||
tenant_id: int | None = None
|
||||
branch_id: int | None = None
|
||||
role_ids: list[int] = []
|
||||
is_active: bool = True
|
||||
allow_login: bool = True
|
||||
|
||||
|
||||
class UserUpdateRequest(BaseModel):
|
||||
full_name: str
|
||||
tenant_id: int | None = None
|
||||
branch_id: int | None = None
|
||||
role_ids: list[int] = []
|
||||
is_active: bool = True
|
||||
allow_login: bool = True
|
||||
password: str | None = None
|
||||
|
||||
|
||||
@router.get("", dependencies=[Depends(require_permission("users.view"))])
|
||||
def list_users(current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
|
||||
scope = build_scope(db, current_user)
|
||||
try:
|
||||
ensure_users_view_scope(scope)
|
||||
except Exception as exc:
|
||||
raise scope_to_http(exc)
|
||||
|
||||
users = list_scoped_users(db, scope)
|
||||
return [
|
||||
{
|
||||
"id": u.id,
|
||||
"email": u.email,
|
||||
"full_name": u.full_name,
|
||||
"tenant_id": u.tenant_id,
|
||||
"branch_id": u.branch_id,
|
||||
"is_active": u.is_active,
|
||||
"allow_login": getattr(u, "allow_login", True),
|
||||
"is_locked": getattr(u, "is_locked", False),
|
||||
"deleted_at": (u.deleted_at.isoformat() if getattr(u, "deleted_at", None) else None),
|
||||
}
|
||||
for u in users
|
||||
]
|
||||
|
||||
|
||||
@router.post("", dependencies=[Depends(require_permission("users.manage"))])
|
||||
def create_user(
|
||||
payload: UserCreateRequest,
|
||||
current_user: User = Depends(require_login),
|
||||
db: Session = Depends(get_common_db),
|
||||
):
|
||||
scope = build_scope(db, current_user)
|
||||
try:
|
||||
ensure_users_manage_scope(scope)
|
||||
tenant_id, branch_id = resolve_target_tenant_branch(db, scope, payload.tenant_id, payload.branch_id)
|
||||
roles = ensure_assignable_roles(db, scope, payload.role_ids)
|
||||
except Exception as exc:
|
||||
raise scope_to_http(exc)
|
||||
|
||||
email = payload.email.lower().strip()
|
||||
exists = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||
if exists:
|
||||
raise HTTPException(status_code=400, detail="Email already exists")
|
||||
|
||||
user = User(
|
||||
email=email,
|
||||
full_name=payload.full_name.strip(),
|
||||
password_hash=hash_password(payload.password),
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
is_active=payload.is_active,
|
||||
allow_login=payload.allow_login,
|
||||
is_locked=False,
|
||||
deleted_at=None,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
for role in roles:
|
||||
db.add(UserRole(user_id=user.id, role_id=role.id))
|
||||
db.commit()
|
||||
write_audit_log(
|
||||
db,
|
||||
action="user.create.api",
|
||||
entity_type="user",
|
||||
actor=current_user,
|
||||
entity_id=user.id,
|
||||
entity_name=user.email,
|
||||
target_tenant_id=user.tenant_id,
|
||||
target_branch_id=user.branch_id,
|
||||
details={"after": model_snapshot(user, ["email", "full_name", "tenant_id", "branch_id", "is_active", "allow_login", "is_locked", "deleted_at"]), "role_ids": [role.id for role in roles]},
|
||||
)
|
||||
return {"status": "ok", "id": user.id}
|
||||
|
||||
|
||||
@router.get("/{user_id}", dependencies=[Depends(require_permission("users.view"))])
|
||||
def get_user(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
|
||||
scope = build_scope(db, current_user)
|
||||
try:
|
||||
ensure_users_view_scope(scope)
|
||||
except Exception as exc:
|
||||
raise scope_to_http(exc)
|
||||
|
||||
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
try:
|
||||
ensure_manageable_existing_user(db, scope, user)
|
||||
except Exception as exc:
|
||||
raise scope_to_http(exc)
|
||||
|
||||
role_ids = db.execute(select(UserRole.role_id).where(UserRole.user_id == user.id)).scalars().all()
|
||||
return {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
"tenant_id": user.tenant_id,
|
||||
"branch_id": user.branch_id,
|
||||
"is_active": user.is_active,
|
||||
"allow_login": getattr(user, "allow_login", True),
|
||||
"is_locked": getattr(user, "is_locked", False),
|
||||
"deleted_at": (user.deleted_at.isoformat() if getattr(user, "deleted_at", None) else None),
|
||||
"role_ids": list(role_ids),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{user_id}", dependencies=[Depends(require_permission("users.manage"))])
|
||||
def update_user(
|
||||
user_id: int,
|
||||
payload: UserUpdateRequest,
|
||||
current_user: User = Depends(require_login),
|
||||
db: Session = Depends(get_common_db),
|
||||
):
|
||||
scope = build_scope(db, current_user)
|
||||
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
try:
|
||||
ensure_users_manage_scope(scope)
|
||||
ensure_manageable_existing_user(db, scope, user)
|
||||
tenant_id, branch_id = resolve_target_tenant_branch(db, scope, payload.tenant_id, payload.branch_id)
|
||||
roles = ensure_assignable_roles(db, scope, payload.role_ids)
|
||||
except Exception as exc:
|
||||
raise scope_to_http(exc)
|
||||
|
||||
before_snapshot = model_snapshot(user, ["email", "full_name", "tenant_id", "branch_id", "is_active", "allow_login", "is_locked", "deleted_at"])
|
||||
old_role_ids = list(db.execute(select(UserRole.role_id).where(UserRole.user_id == user.id)).scalars().all())
|
||||
|
||||
user.full_name = payload.full_name.strip()
|
||||
user.tenant_id = tenant_id
|
||||
user.branch_id = branch_id
|
||||
user.is_active = payload.is_active
|
||||
user.allow_login = payload.allow_login
|
||||
if payload.password:
|
||||
user.password_hash = hash_password(payload.password)
|
||||
|
||||
db.execute(UserRole.__table__.delete().where(UserRole.user_id == user.id))
|
||||
for role in roles:
|
||||
db.add(UserRole(user_id=user.id, role_id=role.id))
|
||||
db.commit()
|
||||
write_audit_log(
|
||||
db,
|
||||
action="user.update.api",
|
||||
entity_type="user",
|
||||
actor=current_user,
|
||||
entity_id=user.id,
|
||||
entity_name=user.email,
|
||||
target_tenant_id=user.tenant_id,
|
||||
target_branch_id=user.branch_id,
|
||||
details={**pair_before_after(before_snapshot, model_snapshot(user, ["email", "full_name", "tenant_id", "branch_id", "is_active", "allow_login", "is_locked", "deleted_at"])), "old_role_ids": old_role_ids, "new_role_ids": [role.id for role in roles]},
|
||||
)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
def _lifecycle_response(user: User) -> dict:
|
||||
return {
|
||||
"status": "ok",
|
||||
"user_id": user.id,
|
||||
"is_active": user.is_active,
|
||||
"allow_login": getattr(user, "allow_login", True),
|
||||
"is_locked": getattr(user, "is_locked", False),
|
||||
"deleted_at": (user.deleted_at.isoformat() if getattr(user, "deleted_at", None) else None),
|
||||
}
|
||||
|
||||
|
||||
def _apply_lifecycle_action(db: Session, current_user: User, user: User, action: str):
|
||||
before = model_snapshot(user, ["email", "full_name", "tenant_id", "branch_id", "is_active", "allow_login", "is_locked", "deleted_at"])
|
||||
if action == "activate":
|
||||
activate_user(user)
|
||||
audit_action = "user.activate.api"
|
||||
elif action == "deactivate":
|
||||
deactivate_user(user)
|
||||
audit_action = "user.deactivate.api"
|
||||
elif action == "enable-login":
|
||||
enable_login(user)
|
||||
audit_action = "user.enable_login.api"
|
||||
elif action == "disable-login":
|
||||
disable_login(user)
|
||||
audit_action = "user.disable_login.api"
|
||||
elif action == "lock":
|
||||
lock_user(user)
|
||||
audit_action = "user.lock.api"
|
||||
elif action == "unlock":
|
||||
unlock_user(user)
|
||||
audit_action = "user.unlock.api"
|
||||
elif action == "delete":
|
||||
soft_delete_user(user)
|
||||
audit_action = "user.soft_delete.api"
|
||||
elif action == "restore":
|
||||
restore_user(user)
|
||||
audit_action = "user.restore.api"
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Unknown action")
|
||||
db.commit()
|
||||
write_audit_log(
|
||||
db,
|
||||
action=audit_action,
|
||||
entity_type="user",
|
||||
actor=current_user,
|
||||
entity_id=user.id,
|
||||
entity_name=user.email,
|
||||
target_tenant_id=user.tenant_id,
|
||||
target_branch_id=user.branch_id,
|
||||
details=pair_before_after(before, model_snapshot(user, ["email", "full_name", "tenant_id", "branch_id", "is_active", "allow_login", "is_locked", "deleted_at"])),
|
||||
)
|
||||
return _lifecycle_response(user)
|
||||
|
||||
|
||||
@router.post("/{user_id}/activate", dependencies=[Depends(require_permission("users.manage"))])
|
||||
def activate_user_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
|
||||
scope = build_scope(db, current_user)
|
||||
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
try:
|
||||
ensure_manageable_lifecycle(scope, db, current_user, user, "activate")
|
||||
except (Exception, LifecycleError) as exc:
|
||||
raise scope_to_http(exc)
|
||||
return _apply_lifecycle_action(db, current_user, user, "activate")
|
||||
|
||||
|
||||
@router.post("/{user_id}/deactivate", dependencies=[Depends(require_permission("users.manage"))])
|
||||
def deactivate_user_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
|
||||
scope = build_scope(db, current_user)
|
||||
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
try:
|
||||
ensure_manageable_lifecycle(scope, db, current_user, user, "deactivate")
|
||||
except (Exception, LifecycleError) as exc:
|
||||
raise scope_to_http(exc)
|
||||
return _apply_lifecycle_action(db, current_user, user, "deactivate")
|
||||
|
||||
|
||||
@router.post("/{user_id}/enable-login", dependencies=[Depends(require_permission("users.manage"))])
|
||||
def enable_login_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
|
||||
scope = build_scope(db, current_user)
|
||||
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
try:
|
||||
ensure_manageable_lifecycle(scope, db, current_user, user, "enable login for")
|
||||
except (Exception, LifecycleError) as exc:
|
||||
raise scope_to_http(exc)
|
||||
return _apply_lifecycle_action(db, current_user, user, "enable-login")
|
||||
|
||||
|
||||
@router.post("/{user_id}/disable-login", dependencies=[Depends(require_permission("users.manage"))])
|
||||
def disable_login_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
|
||||
scope = build_scope(db, current_user)
|
||||
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
try:
|
||||
ensure_manageable_lifecycle(scope, db, current_user, user, "disable login for")
|
||||
except (Exception, LifecycleError) as exc:
|
||||
raise scope_to_http(exc)
|
||||
return _apply_lifecycle_action(db, current_user, user, "disable-login")
|
||||
|
||||
|
||||
@router.post("/{user_id}/lock", dependencies=[Depends(require_permission("users.manage"))])
|
||||
def lock_user_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
|
||||
scope = build_scope(db, current_user)
|
||||
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
try:
|
||||
ensure_manageable_lifecycle(scope, db, current_user, user, "lock")
|
||||
except (Exception, LifecycleError) as exc:
|
||||
raise scope_to_http(exc)
|
||||
return _apply_lifecycle_action(db, current_user, user, "lock")
|
||||
|
||||
|
||||
@router.post("/{user_id}/unlock", dependencies=[Depends(require_permission("users.manage"))])
|
||||
def unlock_user_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
|
||||
scope = build_scope(db, current_user)
|
||||
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
try:
|
||||
ensure_manageable_lifecycle(scope, db, current_user, user, "unlock")
|
||||
except (Exception, LifecycleError) as exc:
|
||||
raise scope_to_http(exc)
|
||||
return _apply_lifecycle_action(db, current_user, user, "unlock")
|
||||
|
||||
|
||||
@router.post("/{user_id}/delete", dependencies=[Depends(require_permission("users.manage"))])
|
||||
def delete_user_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
|
||||
scope = build_scope(db, current_user)
|
||||
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
try:
|
||||
ensure_manageable_lifecycle(scope, db, current_user, user, "delete")
|
||||
except (Exception, LifecycleError) as exc:
|
||||
raise scope_to_http(exc)
|
||||
return _apply_lifecycle_action(db, current_user, user, "delete")
|
||||
|
||||
|
||||
@router.post("/{user_id}/restore", dependencies=[Depends(require_permission("users.manage"))])
|
||||
def restore_user_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
|
||||
scope = build_scope(db, current_user)
|
||||
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
try:
|
||||
ensure_manageable_lifecycle(scope, db, current_user, user, "restore")
|
||||
except (Exception, LifecycleError) as exc:
|
||||
raise scope_to_http(exc)
|
||||
return _apply_lifecycle_action(db, current_user, user, "restore")
|
||||
@@ -0,0 +1,173 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta, timezone
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db.deps import get_common_db
|
||||
from app.core.settings import get_settings
|
||||
from app.core.security.passwords import verify_password
|
||||
from app.core.security.jwt_tokens import encode_access_token, decode_token, utcnow
|
||||
from app.modules.core.audit.service import write_audit_log
|
||||
from app.modules.core.iam.invite_service import accept_invite, issue_password_reset_token, reset_password_with_token
|
||||
from app.modules.email_integration.services import send_password_reset_link_email, send_password_changed_email
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.core.iam.tokens_models import RefreshToken
|
||||
from app.modules.core.rbac.models import Role, UserRole
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
class TokenRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
refresh_token: str
|
||||
|
||||
class ForgotPasswordRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
token: str
|
||||
new_password: str
|
||||
|
||||
class AcceptInviteRequest(BaseModel):
|
||||
token: str
|
||||
password: str
|
||||
|
||||
def _hash_refresh(rt: str) -> str:
|
||||
return hashlib.sha256(rt.encode("utf-8")).hexdigest()
|
||||
|
||||
def _roles(db: Session, user_id: int) -> list[str]:
|
||||
q = select(Role.name).join(UserRole, UserRole.role_id == Role.id).where(UserRole.user_id == user_id)
|
||||
return [r for (r,) in db.execute(q).all()]
|
||||
|
||||
def _issue_tokens(db: Session, user: User) -> TokenResponse:
|
||||
s = get_settings()
|
||||
roles = _roles(db, user.id)
|
||||
payload = {
|
||||
"sub": str(user.id),
|
||||
"email": user.email,
|
||||
"tenant_id": user.tenant_id,
|
||||
"branch_id": user.branch_id,
|
||||
"roles": roles,
|
||||
}
|
||||
access = encode_access_token(payload, expires_minutes=s.JWT_ACCESS_MINUTES)
|
||||
|
||||
refresh_plain = secrets.token_urlsafe(48)
|
||||
now = utcnow()
|
||||
exp = now + timedelta(days=s.JWT_REFRESH_DAYS)
|
||||
|
||||
rt = RefreshToken(
|
||||
user_id=user.id,
|
||||
token_hash=_hash_refresh(refresh_plain),
|
||||
created_at_utc=now,
|
||||
expires_at_utc=exp,
|
||||
revoked=False,
|
||||
rotated_from_id=None,
|
||||
)
|
||||
db.add(rt)
|
||||
db.commit()
|
||||
|
||||
return TokenResponse(access_token=access, expires_in=s.JWT_ACCESS_MINUTES * 60, refresh_token=refresh_plain)
|
||||
|
||||
@router.post("/token", response_model=TokenResponse)
|
||||
def token(req: TokenRequest, db: Session = Depends(get_common_db)):
|
||||
user = db.execute(select(User).where(User.email == req.email.lower().strip())).scalar_one_or_none()
|
||||
if (not user or not user.is_active or not getattr(user, "allow_login", True) or getattr(user, "is_locked", False) or getattr(user, "deleted_at", None) is not None or not verify_password(req.password, user.password_hash)):
|
||||
write_audit_log(db, action="auth.token.failed", entity_type="api_session", actor=user, actor_email=req.email.lower().strip(), status="error", target_tenant_id=(user.tenant_id if user else None), target_branch_id=(user.branch_id if user else None), details={"reason": "invalid credentials"})
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
if getattr(user, "must_change_password", False):
|
||||
raise HTTPException(status_code=403, detail="Password setup/change required before API login")
|
||||
token_response = _issue_tokens(db, user)
|
||||
write_audit_log(db, action="auth.token.success", entity_type="api_session", actor=user, entity_name=user.email, target_tenant_id=user.tenant_id, target_branch_id=user.branch_id)
|
||||
return token_response
|
||||
|
||||
@router.post('/forgot-password')
|
||||
def forgot_password(req: ForgotPasswordRequest, db: Session = Depends(get_common_db)):
|
||||
user = db.execute(select(User).where(User.email == req.email.lower().strip())).scalar_one_or_none()
|
||||
if user and user.is_active and getattr(user, "deleted_at", None) is None:
|
||||
reset_token = issue_password_reset_token(db, user)
|
||||
try:
|
||||
send_password_reset_link_email(db, user=user, reset_token=reset_token)
|
||||
except Exception as exc:
|
||||
write_audit_log(db, action="auth.password_reset.email_failed", entity_type="user", actor=user, entity_id=user.id, entity_name=user.email, status="error", target_tenant_id=user.tenant_id, target_branch_id=user.branch_id, details={"error": str(exc)})
|
||||
write_audit_log(db, action="auth.password_reset.requested", entity_type="user", actor=user, entity_id=user.id, entity_name=user.email, target_tenant_id=user.tenant_id, target_branch_id=user.branch_id)
|
||||
db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
@router.post('/reset-password')
|
||||
def reset_password(req: ResetPasswordRequest, db: Session = Depends(get_common_db)):
|
||||
try:
|
||||
user = reset_password_with_token(db, req.token, req.new_password)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="Invalid or expired reset token")
|
||||
try:
|
||||
send_password_changed_email(db, user=user)
|
||||
except Exception as exc:
|
||||
write_audit_log(db, action="auth.password_changed.email_failed", entity_type="user", actor=user, entity_id=user.id, entity_name=user.email, status="error", target_tenant_id=user.tenant_id, target_branch_id=user.branch_id, details={"error": str(exc)})
|
||||
write_audit_log(db, action="auth.password_reset.completed", entity_type="user", actor=user, entity_id=user.id, entity_name=user.email, target_tenant_id=user.tenant_id, target_branch_id=user.branch_id)
|
||||
db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
@router.post('/invite/accept')
|
||||
def invite_accept(req: AcceptInviteRequest, db: Session = Depends(get_common_db)):
|
||||
try:
|
||||
user = accept_invite(db, req.token, req.password)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="Invalid or expired invite token")
|
||||
write_audit_log(db, action="auth.invite.accepted", entity_type="user", actor=user, entity_id=user.id, entity_name=user.email, target_tenant_id=user.tenant_id, target_branch_id=user.branch_id)
|
||||
return {"status": "ok"}
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
def refresh(req: RefreshRequest, db: Session = Depends(get_common_db)):
|
||||
h = _hash_refresh(req.refresh_token)
|
||||
rt = db.execute(select(RefreshToken).where(RefreshToken.token_hash == h)).scalar_one_or_none()
|
||||
if not rt or rt.revoked:
|
||||
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
||||
now = utcnow()
|
||||
if rt.expires_at_utc.replace(tzinfo=timezone.utc) < now:
|
||||
raise HTTPException(status_code=401, detail="Refresh token expired")
|
||||
|
||||
user = db.execute(select(User).where(User.id == rt.user_id)).scalar_one_or_none()
|
||||
if not user or not user.is_active or not getattr(user, "allow_login", True) or getattr(user, "is_locked", False) or getattr(user, "deleted_at", None) is not None:
|
||||
raise HTTPException(status_code=401, detail="User inactive")
|
||||
|
||||
rt.revoked = True
|
||||
db.commit()
|
||||
token_response = _issue_tokens(db, user)
|
||||
write_audit_log(db, action="auth.token.refresh", entity_type="api_session", actor=user, entity_name=user.email, target_tenant_id=user.tenant_id, target_branch_id=user.branch_id)
|
||||
return token_response
|
||||
|
||||
class LogoutRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(req: LogoutRequest, db: Session = Depends(get_common_db)):
|
||||
h = _hash_refresh(req.refresh_token)
|
||||
rt = db.execute(select(RefreshToken).where(RefreshToken.token_hash == h)).scalar_one_or_none()
|
||||
if rt:
|
||||
rt.revoked = True
|
||||
db.commit()
|
||||
user = db.execute(select(User).where(User.id == rt.user_id)).scalar_one_or_none()
|
||||
write_audit_log(db, action="auth.token.logout", entity_type="api_session", actor=user, entity_name=(user.email if user else None), target_tenant_id=(user.tenant_id if user else None), target_branch_id=(user.branch_id if user else None))
|
||||
return {"status": "ok"}
|
||||
|
||||
@router.get("/me")
|
||||
def me(token: str):
|
||||
return {"token": decode_token(token)}
|
||||
@@ -0,0 +1,118 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta, timezone
|
||||
import hashlib
|
||||
import re
|
||||
import secrets
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security.jwt_tokens import utcnow
|
||||
from app.core.security.passwords import hash_password
|
||||
from app.core.settings import get_settings
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.core.iam.password_flows_models import InviteToken, PasswordResetToken
|
||||
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def validate_password_policy(password: str) -> str | None:
|
||||
s = get_settings()
|
||||
if len(password or "") < s.PASSWORD_MIN_LENGTH:
|
||||
return f"Password must be at least {s.PASSWORD_MIN_LENGTH} characters long."
|
||||
if not re.search(r"[A-Za-z]", password or ""):
|
||||
return "Password must include at least one letter."
|
||||
if not re.search(r"\d", password or ""):
|
||||
return "Password must include at least one number."
|
||||
return None
|
||||
|
||||
|
||||
def issue_invite_token(db: Session, user: User) -> str:
|
||||
plain = secrets.token_urlsafe(32)
|
||||
now = utcnow()
|
||||
token = InviteToken(
|
||||
user_id=user.id,
|
||||
token_hash=_hash_token(plain),
|
||||
created_at_utc=now,
|
||||
expires_at_utc=now + timedelta(hours=get_settings().INVITE_TOKEN_HOURS),
|
||||
used_at_utc=None,
|
||||
)
|
||||
db.add(token)
|
||||
user.must_change_password = True
|
||||
db.commit()
|
||||
return plain
|
||||
|
||||
|
||||
def issue_password_reset_token(db: Session, user: User) -> str:
|
||||
plain = secrets.token_urlsafe(32)
|
||||
now = utcnow()
|
||||
token = PasswordResetToken(
|
||||
user_id=user.id,
|
||||
token_hash=_hash_token(plain),
|
||||
created_at_utc=now,
|
||||
expires_at_utc=now + timedelta(hours=get_settings().PASSWORD_RESET_HOURS),
|
||||
used_at_utc=None,
|
||||
)
|
||||
db.add(token)
|
||||
db.commit()
|
||||
return plain
|
||||
|
||||
|
||||
def _validate_unused(record) -> bool:
|
||||
if not record or record.used_at_utc is not None:
|
||||
return False
|
||||
now = utcnow()
|
||||
exp = record.expires_at_utc
|
||||
if getattr(exp, "tzinfo", None) is None:
|
||||
exp = exp.replace(tzinfo=timezone.utc)
|
||||
return exp >= now
|
||||
|
||||
|
||||
def accept_invite(db: Session, token: str, password: str) -> User | None:
|
||||
err = validate_password_policy(password)
|
||||
if err:
|
||||
raise ValueError(err)
|
||||
record = db.execute(select(InviteToken).where(InviteToken.token_hash == _hash_token(token))).scalar_one_or_none()
|
||||
if not _validate_unused(record):
|
||||
return None
|
||||
user = db.execute(select(User).where(User.id == record.user_id)).scalar_one_or_none()
|
||||
if not user:
|
||||
return None
|
||||
user.password_hash = hash_password(password)
|
||||
user.must_change_password = False
|
||||
user.password_changed_at_utc = utcnow().replace(tzinfo=None)
|
||||
user.allow_login = True
|
||||
user.is_active = True
|
||||
record.used_at_utc = utcnow().replace(tzinfo=None)
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
def reset_password_with_token(db: Session, token: str, password: str) -> User | None:
|
||||
err = validate_password_policy(password)
|
||||
if err:
|
||||
raise ValueError(err)
|
||||
record = db.execute(select(PasswordResetToken).where(PasswordResetToken.token_hash == _hash_token(token))).scalar_one_or_none()
|
||||
if not _validate_unused(record):
|
||||
return None
|
||||
user = db.execute(select(User).where(User.id == record.user_id)).scalar_one_or_none()
|
||||
if not user:
|
||||
return None
|
||||
user.password_hash = hash_password(password)
|
||||
user.must_change_password = False
|
||||
user.password_changed_at_utc = utcnow().replace(tzinfo=None)
|
||||
record.used_at_utc = utcnow().replace(tzinfo=None)
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
def force_change_password(db: Session, user: User, new_password: str) -> None:
|
||||
err = validate_password_policy(new_password)
|
||||
if err:
|
||||
raise ValueError(err)
|
||||
user.password_hash = hash_password(new_password)
|
||||
user.must_change_password = False
|
||||
user.password_changed_at_utc = utcnow().replace(tzinfo=None)
|
||||
db.commit()
|
||||
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.core.iam.scope import UserScope, ensure_manageable_existing_user
|
||||
|
||||
|
||||
class LifecycleError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def utcnow_naive() -> datetime:
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def ensure_not_self(actor: User, target: User, action_label: str) -> None:
|
||||
if actor.id == target.id:
|
||||
raise LifecycleError(f"You cannot {action_label} your own account.")
|
||||
|
||||
|
||||
def ensure_not_bootstrap_admin(target: User) -> None:
|
||||
if (target.email or '').strip().lower() == 'admin@auditfirm.local':
|
||||
raise LifecycleError('Bootstrap system admin cannot be modified by this action.')
|
||||
|
||||
|
||||
def ensure_manageable_lifecycle(scope: UserScope, db: Session, actor: User, target: User, action_label: str) -> None:
|
||||
ensure_manageable_existing_user(db, scope, target)
|
||||
ensure_not_self(actor, target, action_label)
|
||||
|
||||
|
||||
def activate_user(user: User) -> None:
|
||||
user.is_active = True
|
||||
if user.deleted_at is not None:
|
||||
user.deleted_at = None
|
||||
|
||||
|
||||
def deactivate_user(user: User) -> None:
|
||||
user.is_active = False
|
||||
|
||||
|
||||
def enable_login(user: User) -> None:
|
||||
user.allow_login = True
|
||||
|
||||
|
||||
def disable_login(user: User) -> None:
|
||||
user.allow_login = False
|
||||
|
||||
|
||||
def lock_user(user: User) -> None:
|
||||
user.is_locked = True
|
||||
user.locked_at_utc = utcnow_naive()
|
||||
|
||||
|
||||
def unlock_user(user: User) -> None:
|
||||
user.is_locked = False
|
||||
user.locked_at_utc = None
|
||||
|
||||
|
||||
def soft_delete_user(user: User) -> None:
|
||||
user.deleted_at = utcnow_naive()
|
||||
user.is_active = False
|
||||
user.allow_login = False
|
||||
user.is_locked = True
|
||||
if user.locked_at_utc is None:
|
||||
user.locked_at_utc = utcnow_naive()
|
||||
|
||||
|
||||
def restore_user(user: User) -> None:
|
||||
user.deleted_at = None
|
||||
user.is_active = True
|
||||
user.allow_login = True
|
||||
user.is_locked = False
|
||||
user.locked_at_utc = None
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Boolean, Integer, ForeignKey, UniqueConstraint, DateTime, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
class User(CommonBase):
|
||||
__tablename__ = "users"
|
||||
__table_args__ = (UniqueConstraint("email", name="uq_user_email"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
email: Mapped[str] = mapped_column(String(255), index=True)
|
||||
full_name: Mapped[str] = mapped_column(String(255), default="")
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), index=True)
|
||||
branch_id: Mapped[int] = mapped_column(ForeignKey("branches.id"), index=True)
|
||||
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
allow_login: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
is_locked: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
locked_at_utc: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
must_change_password: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
password_changed_at_utc: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Phase 7Q.3 - common user profile/personalisation fields.
|
||||
# Employee/consultant/client master records remain the source for official data;
|
||||
# these fields are used for display, dashboards, client-facing contact cards and branding.
|
||||
profile_photo_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
qualification: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
designation: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
mobile: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
bio: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
signature_image_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
class LoginAttempt(CommonBase):
|
||||
__tablename__ = "login_attempts"
|
||||
__table_args__ = (UniqueConstraint("key", name="uq_login_attempt_key"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
key: Mapped[str] = mapped_column(String(255), index=True) # email|ip
|
||||
attempts: Mapped[int] = mapped_column(Integer, default=0)
|
||||
locked_until_utc: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
updated_at_utc: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user