Prepare ERP source for Gitea deployment

This commit is contained in:
A R R R Associates
2026-06-20 15:01:44 +05:30
commit 5c75eb6bd9
450 changed files with 67698 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Consultant portal foundation module."""
+311
View File
@@ -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])
+245
View File
@@ -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