Prepare ERP source for Gitea deployment
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Consultant portal foundation module."""
|
||||
@@ -0,0 +1,311 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
|
||||
class ConsultantProfile(CommonBase):
|
||||
"""Portal-enabled consultant / ecosystem partner profile.
|
||||
|
||||
The login/security account remains in users. This table stores consultant
|
||||
business/profile details and links the consultant user to firm clients.
|
||||
"""
|
||||
|
||||
__tablename__ = "consultant_profiles"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "user_id", name="uq_consultant_profiles_tenant_user"),
|
||||
UniqueConstraint("tenant_id", "email", name="uq_consultant_profiles_tenant_email"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True)
|
||||
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
consultant_type: Mapped[str] = mapped_column(String(50), nullable=False, default="external_consultant", index=True)
|
||||
firm_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
contact_person: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
mobile: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
specialisation: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
gstin: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
pan: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
address: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, default="active", index=True)
|
||||
onboarding_status: Mapped[str] = mapped_column(String(30), nullable=False, default="approved", index=True)
|
||||
|
||||
is_platform_partner: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_franchise_partner: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_saas_customer: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||
)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
user = relationship("User", foreign_keys=[user_id])
|
||||
links = relationship(
|
||||
"ClientConsultantLink",
|
||||
back_populates="consultant",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
workspace = relationship(
|
||||
"ConsultantWorkspace",
|
||||
back_populates="consultant",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
managed_clients = relationship(
|
||||
"ConsultantManagedClient",
|
||||
back_populates="consultant",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
service_requests = relationship(
|
||||
"ConsultantServiceRequest",
|
||||
back_populates="consultant",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
|
||||
class ConsultantWorkspace(CommonBase):
|
||||
"""SaaS/franchise workspace settings for a consultant portal account.
|
||||
|
||||
This is a foundation table only. It does not change firm-owned clients or
|
||||
consultant-managed clients. It records whether the consultant operates as a
|
||||
SaaS customer, franchise partner, platform partner, or a normal external
|
||||
consultant, along with soft limits used by later subscription/billing phases.
|
||||
"""
|
||||
|
||||
__tablename__ = "consultant_workspaces"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "consultant_id", name="uq_consultant_workspaces_tenant_consultant"),
|
||||
UniqueConstraint("tenant_id", "workspace_code", name="uq_consultant_workspaces_tenant_code"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True)
|
||||
consultant_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("consultant_profiles.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
|
||||
workspace_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
workspace_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
workspace_type: Mapped[str] = mapped_column(String(50), nullable=False, default="consultant_saas", index=True)
|
||||
plan_code: Mapped[str] = mapped_column(String(50), nullable=False, default="starter", index=True)
|
||||
billing_cycle: Mapped[str] = mapped_column(String(30), nullable=False, default="manual", index=True)
|
||||
subscription_status: Mapped[str] = mapped_column(String(30), nullable=False, default="trial", index=True)
|
||||
subscription_start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
subscription_end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
|
||||
max_managed_clients: Mapped[int] = mapped_column(Integer, nullable=False, default=25)
|
||||
max_user_accounts: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
allow_client_portal: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
allow_firm_referrals: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
allow_service_marketplace: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||
)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
consultant = relationship("ConsultantProfile", back_populates="workspace")
|
||||
|
||||
|
||||
class ClientConsultantLink(CommonBase):
|
||||
"""Explicit link between a firm client and a consultant portal profile."""
|
||||
|
||||
__tablename__ = "client_consultant_links"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "client_id", "consultant_id", name="uq_client_consultant_links_client_consultant"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True)
|
||||
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
consultant_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("consultant_profiles.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
service_catalogue_id: Mapped[int | None] = mapped_column(ForeignKey("service_catalogues.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
relationship_type: Mapped[str] = mapped_column(String(50), nullable=False, default="accounts_consultant", index=True)
|
||||
is_primary: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
can_view_client: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
can_view_services: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
can_view_due_dates: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
can_view_communications: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||
)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
consultant = relationship("ConsultantProfile", back_populates="links")
|
||||
client = relationship("Client")
|
||||
service_catalogue = relationship("ServiceCatalogue")
|
||||
|
||||
|
||||
class ConsultantManagedClient(CommonBase):
|
||||
"""Client/contact managed by a consultant inside the consultant portal.
|
||||
|
||||
This table is intentionally separate from the firm `clients` master. It lets
|
||||
consultants maintain their own client book without affecting audit-firm
|
||||
client records. A later phase can convert/link a managed client to the firm
|
||||
client master through an approval workflow.
|
||||
"""
|
||||
|
||||
__tablename__ = "consultant_managed_clients"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "consultant_id", "client_code", name="uq_consultant_managed_clients_code"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True)
|
||||
consultant_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("consultant_profiles.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
linked_firm_client_id: Mapped[int | None] = mapped_column(ForeignKey("clients.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
client_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
|
||||
client_name: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
|
||||
trade_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
client_type: Mapped[str] = mapped_column(String(100), nullable=False, default="Other")
|
||||
pan: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
gstin: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
tan: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
|
||||
contact_person_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
mobile: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
address_line_1: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
address_line_2: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
city: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
state: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
pincode: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
country: Mapped[str | None] = mapped_column(String(100), nullable=True, default="India")
|
||||
|
||||
service_interest: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
relationship_stage: Mapped[str] = mapped_column(String(30), nullable=False, default="managed", index=True)
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, default="active", index=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
conversion_status: Mapped[str] = mapped_column(String(30), nullable=False, default="not_requested", index=True)
|
||||
conversion_requested_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
conversion_requested_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
conversion_reviewed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
conversion_reviewed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
conversion_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
conversion_firm_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||
)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
consultant = relationship("ConsultantProfile", back_populates="managed_clients")
|
||||
linked_firm_client = relationship("Client")
|
||||
|
||||
|
||||
class ConsultantServiceRequest(CommonBase):
|
||||
"""Service request raised by a consultant to the audit firm.
|
||||
|
||||
A request can relate either to a consultant-managed client or to an already
|
||||
linked firm client. The request itself does not create engagements; firm
|
||||
users review it first and decide whether to accept, reject, or keep it under
|
||||
review.
|
||||
"""
|
||||
|
||||
__tablename__ = "consultant_service_requests"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True)
|
||||
consultant_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("consultant_profiles.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
managed_client_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("consultant_managed_clients.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
firm_client_id: Mapped[int | None] = mapped_column(ForeignKey("clients.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
service_catalogue_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("service_catalogues.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
|
||||
request_no: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
request_type: Mapped[str] = mapped_column(String(50), nullable=False, default="service_request", index=True)
|
||||
status: Mapped[str] = mapped_column(String(40), nullable=False, default="submitted", index=True)
|
||||
priority: Mapped[str] = mapped_column(String(30), nullable=False, default="normal", index=True)
|
||||
requested_service_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
requested_due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
subject: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
consultant_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
firm_response: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
reviewed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
reviewed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||
)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
|
||||
consultant = relationship("ConsultantProfile", back_populates="service_requests")
|
||||
managed_client = relationship("ConsultantManagedClient")
|
||||
firm_client = relationship("Client")
|
||||
service_catalogue = relationship("ServiceCatalogue")
|
||||
created_by = relationship("User", foreign_keys=[created_by_user_id])
|
||||
reviewed_by = relationship("User", foreign_keys=[reviewed_by_user_id])
|
||||
@@ -0,0 +1,245 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
from datetime import date, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.modules.clients.models import Client
|
||||
from app.modules.consultants.models import ClientConsultantLink, ConsultantProfile, ConsultantServiceRequest
|
||||
from app.modules.documents.models import EngagementDocument, PermanentClientDocument
|
||||
from app.modules.services.models import (
|
||||
ClientServiceSubscription,
|
||||
ClientServiceTaskInstance,
|
||||
ServiceCatalogue,
|
||||
ServiceTaskComment,
|
||||
)
|
||||
|
||||
CONSULTANT_BOARD_COLUMNS = OrderedDict(
|
||||
[
|
||||
("assigned", "Assigned"),
|
||||
("awaiting_documents", "Awaiting Documents"),
|
||||
("in_progress", "In Progress"),
|
||||
("submitted", "Submitted"),
|
||||
("accepted", "Accepted"),
|
||||
("closed", "Closed"),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _allowed_client_ids(db: Session, *, consultant: ConsultantProfile, require_communications: bool = False) -> list[int]:
|
||||
query = select(ClientConsultantLink.client_id).where(
|
||||
ClientConsultantLink.tenant_id == consultant.tenant_id,
|
||||
ClientConsultantLink.consultant_id == consultant.id,
|
||||
ClientConsultantLink.is_active.is_(True),
|
||||
)
|
||||
if require_communications:
|
||||
query = query.where(ClientConsultantLink.can_view_communications.is_(True))
|
||||
return [int(x) for x in db.execute(query).scalars().all()]
|
||||
|
||||
|
||||
def _board_key_for_status(status: str | None) -> str:
|
||||
value = (status or "pending").strip().lower()
|
||||
if value in {"blocked", "awaiting_documents", "document_pending", "clarification_required"}:
|
||||
return "awaiting_documents"
|
||||
if value in {"in_progress", "under_process", "processing", "started"}:
|
||||
return "in_progress"
|
||||
if value in {"pending_review", "ready_for_review", "submitted", "completed"}:
|
||||
return "submitted"
|
||||
if value in {"approved", "accepted"}:
|
||||
return "accepted"
|
||||
if value in {"closed", "locked", "cancelled", "inactive"}:
|
||||
return "closed"
|
||||
return "assigned"
|
||||
|
||||
|
||||
def _matches_search(*values: Any, q: str = "") -> bool:
|
||||
term = (q or "").strip().lower()
|
||||
if not term:
|
||||
return True
|
||||
return any(term in str(v or "").lower() for v in values)
|
||||
|
||||
|
||||
def get_consultant_work_board(db: Session, *, consultant: ConsultantProfile, q: str = "", status: str = "") -> dict:
|
||||
"""Build consultant work board from consultant-visible firm task communications.
|
||||
|
||||
The board intentionally uses existing task/comment visibility rules only. A consultant sees a task here only when
|
||||
the firm has linked the consultant to the client and has created a consultant-visible communication for that task.
|
||||
"""
|
||||
client_ids = _allowed_client_ids(db, consultant=consultant, require_communications=True)
|
||||
columns = {key: {"label": label, "items": []} for key, label in CONSULTANT_BOARD_COLUMNS.items()}
|
||||
latest_by_task: dict[int, dict] = {}
|
||||
|
||||
if client_ids:
|
||||
rows = db.execute(
|
||||
select(ServiceTaskComment, ClientServiceTaskInstance, ClientServiceSubscription, Client, ServiceCatalogue)
|
||||
.join(ClientServiceTaskInstance, ClientServiceTaskInstance.id == ServiceTaskComment.task_instance_id)
|
||||
.join(ClientServiceSubscription, ClientServiceSubscription.id == ServiceTaskComment.subscription_id)
|
||||
.join(Client, Client.id == ClientServiceTaskInstance.client_id)
|
||||
.join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceTaskInstance.service_catalogue_id)
|
||||
.where(
|
||||
ServiceTaskComment.tenant_id == consultant.tenant_id,
|
||||
ServiceTaskComment.visibility == "consultant",
|
||||
ServiceTaskComment.is_deleted.is_(False),
|
||||
ClientServiceTaskInstance.client_id.in_(client_ids),
|
||||
ClientServiceTaskInstance.tenant_id == consultant.tenant_id,
|
||||
ClientServiceTaskInstance.is_active.is_(True),
|
||||
)
|
||||
.order_by(ServiceTaskComment.created_at_utc.desc(), ServiceTaskComment.id.desc())
|
||||
.limit(300)
|
||||
).all()
|
||||
consultant_user_id = int(getattr(consultant, "user_id", 0) or 0)
|
||||
for comment, task, subscription, client, catalogue in rows:
|
||||
if int(task.id) in latest_by_task:
|
||||
continue
|
||||
if not _matches_search(client.client_name, getattr(client, "client_code", ""), catalogue.service_name, task.task_name, comment.message, q=q):
|
||||
continue
|
||||
key = _board_key_for_status(task.status)
|
||||
if status and key != status:
|
||||
continue
|
||||
latest_by_task[int(task.id)] = {
|
||||
"comment": comment,
|
||||
"task": task,
|
||||
"subscription": subscription,
|
||||
"client": client,
|
||||
"catalogue": catalogue,
|
||||
"board_key": key,
|
||||
"last_message_from_consultant": int(getattr(comment, "created_by_user_id", 0) or 0) == consultant_user_id,
|
||||
"is_overdue": bool(getattr(task, "internal_target_date", None) and task.internal_target_date < date.today()),
|
||||
}
|
||||
|
||||
for item in latest_by_task.values():
|
||||
columns[item["board_key"]]["items"].append(item)
|
||||
|
||||
service_requests = db.execute(
|
||||
select(ConsultantServiceRequest)
|
||||
.options(
|
||||
selectinload(ConsultantServiceRequest.managed_client),
|
||||
selectinload(ConsultantServiceRequest.firm_client),
|
||||
selectinload(ConsultantServiceRequest.service_catalogue),
|
||||
)
|
||||
.where(
|
||||
ConsultantServiceRequest.tenant_id == consultant.tenant_id,
|
||||
ConsultantServiceRequest.consultant_id == consultant.id,
|
||||
ConsultantServiceRequest.is_active.is_(True),
|
||||
)
|
||||
.order_by(ConsultantServiceRequest.created_at_utc.desc(), ConsultantServiceRequest.id.desc())
|
||||
.limit(50)
|
||||
).scalars().all()
|
||||
|
||||
return {
|
||||
"columns": columns,
|
||||
"column_options": list(CONSULTANT_BOARD_COLUMNS.items()),
|
||||
"total_tasks": len(latest_by_task),
|
||||
"service_requests": service_requests,
|
||||
}
|
||||
|
||||
|
||||
def get_consultant_assignment_detail(db: Session, *, consultant: ConsultantProfile, task_id: int) -> dict | None:
|
||||
client_ids = _allowed_client_ids(db, consultant=consultant, require_communications=True)
|
||||
if not client_ids:
|
||||
return None
|
||||
row = db.execute(
|
||||
select(ClientServiceTaskInstance, ClientServiceSubscription, Client, ServiceCatalogue)
|
||||
.join(ClientServiceSubscription, ClientServiceSubscription.id == ClientServiceTaskInstance.subscription_id)
|
||||
.join(Client, Client.id == ClientServiceTaskInstance.client_id)
|
||||
.join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceTaskInstance.service_catalogue_id)
|
||||
.where(
|
||||
ClientServiceTaskInstance.id == task_id,
|
||||
ClientServiceTaskInstance.tenant_id == consultant.tenant_id,
|
||||
ClientServiceTaskInstance.client_id.in_(client_ids),
|
||||
ClientServiceTaskInstance.is_active.is_(True),
|
||||
)
|
||||
).first()
|
||||
if not row:
|
||||
return None
|
||||
task, subscription, client, catalogue = row
|
||||
timeline = db.execute(
|
||||
select(ServiceTaskComment)
|
||||
.options(selectinload(ServiceTaskComment.created_by))
|
||||
.where(
|
||||
ServiceTaskComment.tenant_id == consultant.tenant_id,
|
||||
ServiceTaskComment.task_instance_id == task.id,
|
||||
ServiceTaskComment.visibility == "consultant",
|
||||
ServiceTaskComment.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(ServiceTaskComment.created_at_utc.asc(), ServiceTaskComment.id.asc())
|
||||
).scalars().all()
|
||||
engagement_documents = db.execute(
|
||||
select(EngagementDocument)
|
||||
.options(selectinload(EngagementDocument.versions))
|
||||
.where(
|
||||
EngagementDocument.tenant_id == consultant.tenant_id,
|
||||
EngagementDocument.client_id == client.id,
|
||||
EngagementDocument.engagement_id == subscription.id,
|
||||
EngagementDocument.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(EngagementDocument.document_type.asc(), EngagementDocument.title.asc())
|
||||
.limit(50)
|
||||
).scalars().all()
|
||||
permanent_documents = db.execute(
|
||||
select(PermanentClientDocument)
|
||||
.options(selectinload(PermanentClientDocument.versions))
|
||||
.where(
|
||||
PermanentClientDocument.tenant_id == consultant.tenant_id,
|
||||
PermanentClientDocument.client_id == client.id,
|
||||
PermanentClientDocument.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(PermanentClientDocument.category.asc(), PermanentClientDocument.title.asc())
|
||||
.limit(50)
|
||||
).scalars().all()
|
||||
return {
|
||||
"task": task,
|
||||
"subscription": subscription,
|
||||
"client": client,
|
||||
"catalogue": catalogue,
|
||||
"timeline": timeline,
|
||||
"engagement_documents": engagement_documents,
|
||||
"permanent_documents": permanent_documents,
|
||||
}
|
||||
|
||||
|
||||
def get_consultant_document_centre(db: Session, *, consultant: ConsultantProfile, q: str = "") -> dict:
|
||||
client_ids = _allowed_client_ids(db, consultant=consultant, require_communications=False)
|
||||
if not client_ids:
|
||||
return {"engagement_documents": [], "permanent_documents": [], "total": 0}
|
||||
|
||||
engagement_query = (
|
||||
select(EngagementDocument)
|
||||
.options(selectinload(EngagementDocument.client), selectinload(EngagementDocument.engagement), selectinload(EngagementDocument.versions))
|
||||
.where(
|
||||
EngagementDocument.tenant_id == consultant.tenant_id,
|
||||
EngagementDocument.client_id.in_(client_ids),
|
||||
EngagementDocument.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
permanent_query = (
|
||||
select(PermanentClientDocument)
|
||||
.options(selectinload(PermanentClientDocument.client), selectinload(PermanentClientDocument.versions))
|
||||
.where(
|
||||
PermanentClientDocument.tenant_id == consultant.tenant_id,
|
||||
PermanentClientDocument.client_id.in_(client_ids),
|
||||
PermanentClientDocument.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
if (q or "").strip():
|
||||
term = f"%{q.strip()}%"
|
||||
engagement_query = engagement_query.where(
|
||||
or_(EngagementDocument.title.ilike(term), EngagementDocument.document_type.ilike(term), EngagementDocument.document_code.ilike(term))
|
||||
)
|
||||
permanent_query = permanent_query.where(
|
||||
or_(PermanentClientDocument.title.ilike(term), PermanentClientDocument.category.ilike(term), PermanentClientDocument.document_code.ilike(term))
|
||||
)
|
||||
engagement_documents = db.execute(
|
||||
engagement_query.order_by(EngagementDocument.created_at_utc.desc(), EngagementDocument.id.desc()).limit(200)
|
||||
).scalars().all()
|
||||
permanent_documents = db.execute(
|
||||
permanent_query.order_by(PermanentClientDocument.created_at_utc.desc(), PermanentClientDocument.id.desc()).limit(200)
|
||||
).scalars().all()
|
||||
return {
|
||||
"engagement_documents": engagement_documents,
|
||||
"permanent_documents": permanent_documents,
|
||||
"total": len(engagement_documents) + len(permanent_documents),
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
{% set path = request.url.path %}
|
||||
<div class="mb-5 overflow-x-auto rounded-2xl border border-slate-200 bg-white p-2 shadow-soft">
|
||||
<nav class="flex min-w-max gap-2 text-sm font-semibold">
|
||||
<a href="/consultant/dashboard" class="rounded-xl px-3 py-2 {{ 'bg-slate-900 text-white' if path == '/consultant/dashboard' else 'text-slate-700 hover:bg-slate-100' }}">Overview</a>
|
||||
<a href="/consultant/work" class="rounded-xl px-3 py-2 {{ 'bg-slate-900 text-white' if path.startswith('/consultant/work') or path.startswith('/consultant/assignments') else 'text-slate-700 hover:bg-slate-100' }}">My Work Board</a>
|
||||
<a href="/consultant/communications" class="rounded-xl px-3 py-2 {{ 'bg-slate-900 text-white' if path.startswith('/consultant/communications') else 'text-slate-700 hover:bg-slate-100' }}">My Messages</a>
|
||||
<a href="/consultant/documents" class="rounded-xl px-3 py-2 {{ 'bg-slate-900 text-white' if path.startswith('/consultant/documents') else 'text-slate-700 hover:bg-slate-100' }}">Shared Documents</a>
|
||||
<a href="/consultant/service-requests" class="rounded-xl px-3 py-2 {{ 'bg-slate-900 text-white' if path.startswith('/consultant/service-requests') else 'text-slate-700 hover:bg-slate-100' }}">Service Requests</a>
|
||||
<a href="/consultant/managed-clients" class="rounded-xl px-3 py-2 {{ 'bg-slate-900 text-white' if path.startswith('/consultant/managed-clients') else 'text-slate-700 hover:bg-slate-100' }}">Managed Clients</a>
|
||||
<a href="/consultant/workspace" class="rounded-xl px-3 py-2 {{ 'bg-slate-900 text-white' if path.startswith('/consultant/workspace') else 'text-slate-700 hover:bg-slate-100' }}">Workspace</a>
|
||||
<a href="/consultant/profile" class="rounded-xl px-3 py-2 {{ 'bg-slate-900 text-white' if path.startswith('/consultant/profile') else 'text-slate-700 hover:bg-slate-100' }}">My Profile</a>
|
||||
<a href="/alerts" class="rounded-xl px-3 py-2 {{ 'bg-slate-900 text-white' if path.startswith('/alerts') else 'text-slate-700 hover:bg-slate-100' }}">My Alert</a>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -0,0 +1,78 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">{{ client.client_name }} — {{ catalogue.service_name }}</h2>
|
||||
<p class="text-sm text-slate-500">{{ task.task_name }}{% if task.internal_target_date %} • Target {{ task.internal_target_date.strftime('%d-%m-%Y') }}{% endif %}</p>
|
||||
</div>
|
||||
<a href="/consultant/work" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back to My Work Board</a>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[1fr_360px]">
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Task Status</div><div class="mt-1 font-semibold text-slate-900">{{ task.status.replace('_',' ').title() }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Priority</div><div class="mt-1 font-semibold text-slate-900">{{ task.priority.replace('_',' ').title() }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Financial Year</div><div class="mt-1 font-semibold text-slate-900">{{ task.financial_year }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Engagement Status</div><div class="mt-1 font-semibold text-slate-900">{{ subscription.status.replace('_',' ').title() }}</div></div>
|
||||
</div>
|
||||
{% if task.description %}<div class="mt-5 rounded-xl bg-slate-50 p-4 text-sm text-slate-700 whitespace-pre-line">{{ task.description }}</div>{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<div class="border-b border-slate-200 px-5 py-4"><h3 class="font-semibold text-slate-900">Consultant Communication Timeline</h3></div>
|
||||
<div class="divide-y divide-slate-100">
|
||||
{% for note in timeline %}
|
||||
<div class="p-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="text-sm font-semibold text-slate-900">{{ note.comment_type.replace('_',' ').title() }}</div>
|
||||
<div class="text-xs text-slate-500">{{ note.created_at_utc.strftime('%d-%m-%Y %H:%M') if note.created_at_utc else '' }}</div>
|
||||
</div>
|
||||
<div class="mt-2 whitespace-pre-line rounded-xl bg-slate-50 p-3 text-sm text-slate-700">{{ note.message }}</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-6 text-sm text-slate-500">No consultant-visible timeline yet.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/consultant/assignments/{{ task.id }}/reply" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<h3 class="font-semibold text-slate-900">Send Reply / Submit Update</h3>
|
||||
{% if errors %}<div class="mt-3 rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700">{{ errors|join(' ') }}</div>{% endif %}
|
||||
<textarea name="message" rows="5" required placeholder="Type clarification reply, submission note, or work update" class="mt-4 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"></textarea>
|
||||
<div class="mt-4 flex justify-end"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Send Update</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<aside class="space-y-6">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<div class="border-b border-slate-200 px-4 py-3"><h3 class="font-semibold text-slate-900">Engagement Documents</h3></div>
|
||||
<div class="divide-y divide-slate-100">
|
||||
{% for doc in engagement_documents %}
|
||||
<div class="p-4">
|
||||
<div class="font-semibold text-slate-900">{{ doc.title }}</div>
|
||||
<div class="text-xs text-slate-500">{{ doc.document_type }} • v{{ doc.current_version_no }}</div>
|
||||
{% if doc.versions %}<div class="mt-1 text-xs text-slate-500">Latest: {{ doc.versions[0].original_filename }}</div>{% endif %}
|
||||
</div>
|
||||
{% else %}<div class="p-4 text-sm text-slate-500">No shared engagement documents found.</div>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<div class="border-b border-slate-200 px-4 py-3"><h3 class="font-semibold text-slate-900">Permanent Documents</h3></div>
|
||||
<div class="divide-y divide-slate-100">
|
||||
{% for doc in permanent_documents %}
|
||||
<div class="p-4">
|
||||
<div class="font-semibold text-slate-900">{{ doc.title }}</div>
|
||||
<div class="text-xs text-slate-500">{{ doc.category }} • v{{ doc.current_version_no }}</div>
|
||||
</div>
|
||||
{% else %}<div class="p-4 text-sm text-slate-500">No shared permanent documents found.</div>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,59 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
|
||||
<div class="mx-auto max-w-4xl space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Communication Detail</h2>
|
||||
<p class="text-sm text-slate-500">{{ client.client_name }} • {{ catalogue.service_name }} • {{ task.task_name }}</p>
|
||||
</div>
|
||||
<a href="/consultant/communications" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back</a>
|
||||
</div>
|
||||
|
||||
{% if errors %}
|
||||
<div class="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||
{% for error in errors %}<div>{{ error }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="grid gap-4 text-sm md:grid-cols-3">
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Client</div><div class="font-medium text-slate-900">{{ client.client_name }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Service</div><div class="font-medium text-slate-900">{{ catalogue.service_name }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Task Status</div><div class="font-medium text-slate-900">{{ task.status.replace('_',' ').title() }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<h3 class="font-semibold text-slate-900">Consultant-visible Timeline</h3>
|
||||
<div class="mt-4 space-y-4">
|
||||
{% for item in timeline %}
|
||||
<div class="rounded-2xl border border-slate-200 p-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="rounded-full bg-slate-900 px-3 py-1 text-xs font-medium text-white">{{ item.comment_type.replace('_',' ').title() }}</span>
|
||||
<span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700">{{ item.visibility.replace('_',' ').title() }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-slate-500">{{ item.created_at_utc.strftime('%d-%m-%Y %H:%M') if item.created_at_utc else '-' }}</div>
|
||||
</div>
|
||||
<div class="mt-2 text-sm font-medium text-slate-700">{{ item.created_by.full_name or item.created_by.email if item.created_by else 'System' }}</div>
|
||||
<p class="mt-3 whitespace-pre-wrap text-sm leading-6 text-slate-700">{{ item.message }}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="rounded-xl border border-dashed border-slate-300 px-4 py-6 text-center text-sm text-slate-500">No consultant-visible timeline found.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not task.is_locked and not (task.subscription and task.subscription.is_locked) %}
|
||||
<form method="post" action="/consultant/communications/{{ comment.id }}/reply" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Reply to firm</label>
|
||||
<textarea name="message" rows="5" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" placeholder="Type consultant clarification reply..."></textarea>
|
||||
<div class="mt-4 flex justify-end"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Send Reply</button></div>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">This task/engagement is locked. Replies are disabled.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,41 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Consultant Communications</h2>
|
||||
<p class="text-sm text-slate-500">Only task messages marked with Visibility = Consultant are listed here.</p>
|
||||
</div>
|
||||
<a href="/consultant/dashboard" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back to Dashboard</a>
|
||||
</div>
|
||||
|
||||
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="grid gap-3 md:grid-cols-[1fr_auto]">
|
||||
<input name="q" value="{{ q or '' }}" placeholder="Search client, service, task or message" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
<tr><th class="px-4 py-3">Client / Service</th><th class="px-4 py-3">Task</th><th class="px-4 py-3">Type</th><th class="px-4 py-3">Date</th><th class="px-4 py-3 text-right">Action</th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for comment, task, subscription, client, catalogue in rows %}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3"><div class="font-semibold text-slate-900">{{ client.client_name }}</div><div class="text-xs text-slate-500">{{ catalogue.service_name }}</div></td>
|
||||
<td class="px-4 py-3 text-slate-700">{{ task.task_name }}</td>
|
||||
<td class="px-4 py-3"><span class="rounded-full bg-purple-50 px-2 py-1 text-xs font-semibold text-purple-700">{{ comment.comment_type.replace('_',' ').title() }}</span></td>
|
||||
<td class="px-4 py-3 text-slate-500">{{ comment.created_at_utc.strftime('%d-%m-%Y %H:%M') if comment.created_at_utc else '-' }}</td>
|
||||
<td class="px-4 py-3 text-right"><a href="/consultant/communications/{{ comment.id }}" class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Open / Reply</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">No consultant-visible communication found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,12 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-2xl rounded-3xl bg-white p-6 shadow-soft">
|
||||
<h2 class="text-xl font-semibold text-slate-900">Consultant Invite Link Generated</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Share this link with {{ consultant.contact_person }} to set password and activate consultant portal login.</p>
|
||||
<div class="mt-5 rounded-2xl border border-brand-200 bg-brand-50 p-4 text-sm text-brand-900 break-all">{{ invite_url }}</div>
|
||||
<div class="mt-5 flex flex-wrap gap-3">
|
||||
<a href="/consultants/{{ consultant.id }}" class="rounded-xl bg-brand-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-brand-700">Open Consultant</a>
|
||||
<a href="/consultants" class="rounded-xl border border-slate-300 px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50">Back to Consultants</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,70 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Conversion Request: {{ managed_client.client_name }}</h2>
|
||||
<p class="text-sm text-slate-500">Consultant: {{ consultant.firm_name or consultant.contact_person if consultant else '-' }}</p>
|
||||
</div>
|
||||
<a href="/consultants/conversion-requests" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back</a>
|
||||
</div>
|
||||
|
||||
{% if errors %}
|
||||
<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-700 shadow-soft">
|
||||
{% for error in errors %}<div>{{ error }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<h3 class="font-semibold text-slate-900">Managed Client Details</h3>
|
||||
<div class="mt-4 grid gap-4 text-sm md:grid-cols-3">
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Client Code</div><div class="font-medium text-slate-900">{{ managed_client.client_code or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Client Type</div><div class="font-medium text-slate-900">{{ managed_client.client_type }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Conversion Status</div><div class="font-medium text-slate-900">{{ managed_client.conversion_status.replace('_',' ').title() }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">PAN</div><div class="font-medium text-slate-900">{{ managed_client.pan or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">GSTIN</div><div class="font-medium text-slate-900">{{ managed_client.gstin or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Mobile / Email</div><div class="font-medium text-slate-900">{{ managed_client.mobile or managed_client.email or '-' }}</div></div>
|
||||
</div>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<div class="rounded-xl bg-slate-50 p-3 text-sm text-slate-700"><strong>Consultant notes</strong><br>{{ managed_client.conversion_notes or '-' }}</div>
|
||||
<div class="rounded-xl bg-slate-50 p-3 text-sm text-slate-700"><strong>Firm notes</strong><br>{{ managed_client.conversion_firm_notes or '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if managed_client.linked_firm_client %}
|
||||
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-5 text-sm text-emerald-900 shadow-soft">
|
||||
Already converted and linked to firm client: <strong>{{ managed_client.linked_firm_client.client_code }} — {{ managed_client.linked_firm_client.client_name }}</strong>
|
||||
</div>
|
||||
{% else %}
|
||||
<form method="post" action="/consultants/conversion-requests/{{ managed_client.id }}/review" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<h3 class="font-semibold text-slate-900">Firm Review</h3>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<label class="text-sm font-semibold text-slate-700">Action</label>
|
||||
<select name="action" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" required>
|
||||
<option value="under_review">Mark Under Review</option>
|
||||
<option value="approve">Approve & Create Firm Client</option>
|
||||
<option value="reject">Reject</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-semibold text-slate-700">Firm Client Code</label>
|
||||
<input name="client_code" value="FC-{{ managed_client.client_code or managed_client.id }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<p class="mt-1 text-xs text-slate-500">Used only when approving.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-semibold text-slate-700">Partner User ID</label>
|
||||
<input name="partner_user_id" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Optional">
|
||||
<p class="mt-1 text-xs text-slate-500">Optional for now. You can assign partner later from client master.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<label class="text-sm font-semibold text-slate-700">Firm Notes / Reason</label>
|
||||
<textarea name="firm_notes" rows="4" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"></textarea>
|
||||
</div>
|
||||
<button class="mt-4 rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Save Review</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,111 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">{{ consultant.contact_person }}</h2>
|
||||
<p class="text-sm text-slate-500">{{ consultant.firm_name or 'Individual consultant' }}{% if consultant.specialisation %} • {{ consultant.specialisation }}{% endif %}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="/consultants" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back</a>
|
||||
{% if can_manage %}<a href="/consultants/{{ consultant.id }}/edit" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Edit</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft md:col-span-3">
|
||||
<div class="grid gap-4 text-sm md:grid-cols-3">
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Email</div><div class="font-medium text-slate-900">{{ consultant.email or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Mobile</div><div class="font-medium text-slate-900">{{ consultant.mobile or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Type</div><div class="font-medium text-slate-900">{{ consultant.consultant_type.replace('_',' ').title() }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">PAN</div><div class="font-medium text-slate-900">{{ consultant.pan or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">GSTIN</div><div class="font-medium text-slate-900">{{ consultant.gstin or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Login user</div><div class="font-medium text-slate-900">{{ consultant.user.email if consultant.user else '-' }}</div></div>
|
||||
</div>
|
||||
{% if consultant.address or consultant.remarks %}
|
||||
<div class="mt-4 grid gap-4 text-sm md:grid-cols-2">
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Address</div><div class="text-slate-700 whitespace-pre-line">{{ consultant.address or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Remarks</div><div class="text-slate-700 whitespace-pre-line">{{ consultant.remarks or '-' }}</div></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Status</div>
|
||||
<div class="mt-2"><span class="rounded-full px-2 py-1 text-xs font-semibold {% if consultant.is_active %}bg-emerald-50 text-emerald-700{% else %}bg-slate-100 text-slate-500{% endif %}">{{ consultant.status }}</span></div>
|
||||
<div class="mt-4 space-y-2 text-sm text-slate-700">
|
||||
<div>Platform partner: <b>{{ 'Yes' if consultant.is_platform_partner else 'No' }}</b></div>
|
||||
<div>Franchise partner: <b>{{ 'Yes' if consultant.is_franchise_partner else 'No' }}</b></div>
|
||||
<div>SaaS customer: <b>{{ 'Yes' if consultant.is_saas_customer else 'No' }}</b></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if can_link %}
|
||||
<form method="post" action="/consultants/{{ consultant.id }}/links" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<h3 class="mb-4 text-base font-semibold text-slate-900">Link client to consultant</h3>
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="lg:col-span-2">
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Client</label>
|
||||
<select name="client_id" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" required>
|
||||
<option value="">Select client</option>
|
||||
{% for client in clients %}<option value="{{ client.id }}">{{ client.client_name }} ({{ client.client_code }})</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Relationship</label>
|
||||
<select name="relationship_type" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
{% for code,label in relationship_types %}<option value="{{ code }}">{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Remarks</label>
|
||||
<input name="remarks" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-wrap gap-4 text-sm text-slate-700">
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="is_primary"> Primary consultant</label>
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="can_view_client" checked> Client details</label>
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="can_view_services" checked> Services</label>
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="can_view_due_dates" checked> Due dates</label>
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="can_view_communications" checked> Consultant communications</label>
|
||||
</div>
|
||||
<div class="mt-4"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Link Client</button></div>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<div class="border-b border-slate-200 px-4 py-3">
|
||||
<h3 class="font-semibold text-slate-900">Linked Clients</h3>
|
||||
</div>
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
<tr><th class="px-4 py-3">Client</th><th class="px-4 py-3">Relationship</th><th class="px-4 py-3">Access</th><th class="px-4 py-3">Status</th><th class="px-4 py-3 text-right">Action</th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for link in links %}
|
||||
<tr>
|
||||
<td class="px-4 py-3"><div class="font-semibold text-slate-900">{{ link.client.client_name if link.client else '-' }}</div><div class="text-xs text-slate-500">{{ link.client.client_code if link.client else '' }}</div></td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ link.relationship_type.replace('_',' ').title() }}{% if link.is_primary %}<span class="ml-2 rounded-full bg-blue-50 px-2 py-1 text-xs font-semibold text-blue-700">Primary</span>{% endif %}</td>
|
||||
<td class="px-4 py-3 text-xs text-slate-600">
|
||||
Client {{ '✓' if link.can_view_client else '×' }} • Services {{ '✓' if link.can_view_services else '×' }} • Due {{ '✓' if link.can_view_due_dates else '×' }} • Comm {{ '✓' if link.can_view_communications else '×' }}
|
||||
</td>
|
||||
<td class="px-4 py-3"><span class="rounded-full px-2 py-1 text-xs font-semibold {% if link.is_active %}bg-emerald-50 text-emerald-700{% else %}bg-slate-100 text-slate-500{% endif %}">{{ 'Active' if link.is_active else 'Inactive' }}</span></td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
{% if can_link %}
|
||||
<form method="post" action="/consultants/links/{{ link.id }}/toggle" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="active" value="{{ '0' if link.is_active else '1' }}">
|
||||
<button class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">{{ 'Disable' if link.is_active else 'Enable' }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">No linked clients.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,57 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Shared Documents</h2>
|
||||
<p class="text-sm text-slate-500">Documents visible through your active client links. Internal firm-only documents are not listed.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="grid gap-3 md:grid-cols-[1fr_auto]">
|
||||
<input name="q" value="{{ q or '' }}" placeholder="Search document title, code, category or type" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-2">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<div class="border-b border-slate-200 px-5 py-4"><h3 class="font-semibold text-slate-900">Engagement Documents</h3></div>
|
||||
<div class="divide-y divide-slate-100">
|
||||
{% for doc in docs.engagement_documents %}
|
||||
<div class="p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="font-semibold text-slate-900">{{ doc.title }}</div>
|
||||
<div class="text-xs text-slate-500">{{ doc.client.client_name if doc.client else 'Client' }} • {{ doc.document_type }} • {{ doc.financial_year }}</div>
|
||||
</div>
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-600">v{{ doc.current_version_no }}</span>
|
||||
</div>
|
||||
{% if doc.versions %}<div class="mt-2 text-xs text-slate-500">Latest file: {{ doc.versions[0].original_filename }}</div>{% endif %}
|
||||
</div>
|
||||
{% else %}<div class="p-6 text-sm text-slate-500">No engagement documents found.</div>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<div class="border-b border-slate-200 px-5 py-4"><h3 class="font-semibold text-slate-900">Permanent Documents</h3></div>
|
||||
<div class="divide-y divide-slate-100">
|
||||
{% for doc in docs.permanent_documents %}
|
||||
<div class="p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="font-semibold text-slate-900">{{ doc.title }}</div>
|
||||
<div class="text-xs text-slate-500">{{ doc.client.client_name if doc.client else 'Client' }} • {{ doc.category }}</div>
|
||||
</div>
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-600">v{{ doc.current_version_no }}</span>
|
||||
</div>
|
||||
{% if doc.versions %}<div class="mt-2 text-xs text-slate-500">Latest file: {{ doc.versions[0].original_filename }}</div>{% endif %}
|
||||
</div>
|
||||
{% else %}<div class="p-6 text-sm text-slate-500">No permanent documents found.</div>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,136 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% set is_dict = consultant is mapping %}
|
||||
{% set is_edit = consultant and not is_dict and consultant.id %}
|
||||
<div class="mx-auto max-w-4xl space-y-6">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">{{ title }}</h2>
|
||||
<p class="text-sm text-slate-500">Create or update a consultant portal profile and optionally create the consultant login user from this page.</p>
|
||||
</div>
|
||||
<a href="/consultants" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back</a>
|
||||
</div>
|
||||
|
||||
{% if errors %}
|
||||
<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||
<ul class="list-disc pl-5">
|
||||
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" class="space-y-6 rounded-2xl border border-slate-200 bg-white p-6 shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<div class="rounded-2xl border border-brand-100 bg-brand-50 p-4">
|
||||
<h3 class="font-semibold text-slate-900">Consultant Login</h3>
|
||||
<p class="mt-1 text-sm text-slate-600">Link an existing Consultant-role user or create a new login for this consultant.</p>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Existing consultant user</label>
|
||||
{% set current_user_id = consultant.user_id if consultant and not is_dict else consultant.get('user_id') if consultant else None %}
|
||||
<select name="user_id" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="">No existing user selected</option>
|
||||
{% for u in consultant_users %}
|
||||
<option value="{{ u.id }}" {% if current_user_id == u.id %}selected{% endif %}>{{ u.full_name or u.email }} — {{ u.email }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-slate-500">Leave blank if you want to create a new login below.</p>
|
||||
</div>
|
||||
<div class="space-y-2 rounded-xl bg-white p-3">
|
||||
<label class="inline-flex items-center gap-2 text-sm font-medium text-slate-700">
|
||||
<input type="checkbox" name="create_login_user" class="h-4 w-4 rounded border-slate-300" {% if consultant and is_dict and consultant.get('create_login_user') %}checked{% endif %}>
|
||||
Create / enable consultant login
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="invite_login_user" class="h-4 w-4 rounded border-slate-300" {% if consultant and is_dict and consultant.get('invite_login_user') %}checked{% endif %}>
|
||||
Generate invite link instead of using temporary password
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Login ID / Email</label>
|
||||
<input type="email" name="login_email" value="{{ consultant.get('login_email','') if consultant and is_dict else consultant.email if consultant and not is_dict else '' }}" placeholder="consultant@example.com" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Temporary password</label>
|
||||
<input type="password" name="temporary_password" placeholder="Minimum 8 characters" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<p class="mt-1 text-xs text-slate-500">Used only when invite link is not selected. Consultant must change password after login.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Consultant type</label>
|
||||
{% set current_type = consultant.consultant_type if consultant and not is_dict else consultant.get('consultant_type') if consultant else 'external_consultant' %}
|
||||
<select name="consultant_type" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
{% for code,label in consultant_types %}<option value="{{ code }}" {% if current_type == code %}selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Contact person / Consultant name *</label>
|
||||
<input name="contact_person" value="{{ consultant.contact_person if consultant and not is_dict else consultant.get('contact_person','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Firm name</label>
|
||||
<input name="firm_name" value="{{ consultant.firm_name if consultant and not is_dict else consultant.get('firm_name','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Email</label>
|
||||
<input type="email" name="email" value="{{ consultant.email if consultant and not is_dict else consultant.get('email','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Mobile</label>
|
||||
<input name="mobile" value="{{ consultant.mobile if consultant and not is_dict else consultant.get('mobile','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">PAN</label>
|
||||
<input name="pan" value="{{ consultant.pan if consultant and not is_dict else consultant.get('pan','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">GSTIN</label>
|
||||
<input name="gstin" value="{{ consultant.gstin if consultant and not is_dict else consultant.get('gstin','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Specialisation</label>
|
||||
<input name="specialisation" value="{{ consultant.specialisation if consultant and not is_dict else consultant.get('specialisation','') if consultant else '' }}" placeholder="GST, ROC, Payroll, Accounts, Tax filing" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Address</label>
|
||||
<textarea name="address" rows="2" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ consultant.address if consultant and not is_dict else consultant.get('address','') if consultant else '' }}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Status</label>
|
||||
{% set st = consultant.status if consultant and not is_dict else consultant.get('status') if consultant else 'active' %}
|
||||
<select name="status" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
{% for code in ['active','inactive','on_hold','suspended'] %}<option value="{{ code }}" {% if st == code %}selected{% endif %}>{{ code.replace('_',' ').title() }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Onboarding status</label>
|
||||
{% set os = consultant.onboarding_status if consultant and not is_dict else consultant.get('onboarding_status') if consultant else 'active' %}
|
||||
<select name="onboarding_status" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
{% for code,label in onboarding_statuses %}<option value="{{ code }}" {% if os == code %}selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 rounded-2xl bg-slate-50 p-4 text-sm text-slate-700 md:grid-cols-4">
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="is_platform_partner" {% if consultant and ((not is_dict and consultant.is_platform_partner) or (is_dict and consultant.get('is_platform_partner'))) %}checked{% endif %}> Platform partner</label>
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="is_franchise_partner" {% if consultant and ((not is_dict and consultant.is_franchise_partner) or (is_dict and consultant.get('is_franchise_partner'))) %}checked{% endif %}> Franchise partner</label>
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="is_saas_customer" {% if consultant and ((not is_dict and consultant.is_saas_customer) or (is_dict and consultant.get('is_saas_customer'))) %}checked{% endif %}> SaaS customer</label>
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="is_active" {% if not consultant or (consultant and ((not is_dict and consultant.is_active) or (is_dict and consultant.get('is_active', True)))) %}checked{% endif %}> Active</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Remarks</label>
|
||||
<textarea name="remarks" rows="3" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ consultant.remarks if consultant and not is_dict else consultant.get('remarks','') if consultant else '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<a href="/consultants" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Cancel</a>
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Save Consultant</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,59 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Consultant Client Conversion Requests</h2>
|
||||
<p class="text-sm text-slate-500">Review consultant-managed clients requested for conversion into firm client master.</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="/consultants" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Consultants</a>
|
||||
<a href="/consultants/service-requests" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Service Requests</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="grid gap-3 md:grid-cols-[1fr_auto]">
|
||||
<select name="status" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="">All conversion statuses</option>
|
||||
{% for code, label in conversion_statuses %}
|
||||
<option value="{{ code }}" {% if status == code %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Managed Client</th>
|
||||
<th class="px-4 py-3">Consultant</th>
|
||||
<th class="px-4 py-3">PAN / GSTIN</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Requested</th>
|
||||
<th class="px-4 py-3 text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for row in rows %}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3">
|
||||
<div class="font-semibold text-slate-900">{{ row.client_name }}</div>
|
||||
<div class="text-xs text-slate-500">{{ row.client_code or '-' }}{% if row.linked_firm_client %} • Linked: {{ row.linked_firm_client.client_code }}{% endif %}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ row.consultant.firm_name or row.consultant.contact_person if row.consultant else '-' }}</td>
|
||||
<td class="px-4 py-3 text-slate-600"><div>{{ row.pan or '-' }}</div><div class="text-xs">{{ row.gstin or '-' }}</div></td>
|
||||
<td class="px-4 py-3"><span class="rounded-full bg-blue-50 px-2 py-1 text-xs font-semibold text-blue-700">{{ row.conversion_status.replace('_',' ').title() }}</span></td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ row.conversion_requested_at_utc.strftime('%d-%m-%Y') if row.conversion_requested_at_utc else '-' }}</td>
|
||||
<td class="px-4 py-3 text-right"><a href="/consultants/conversion-requests/{{ row.id }}" class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Review</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="px-4 py-8 text-center text-slate-500">No conversion requests found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,8 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3"><div><h2 class="text-xl font-semibold text-slate-900">Consultant Service Requests</h2><p class="text-sm text-slate-500">Review requests raised by consultants.</p></div><a href="/consultants" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Consultants</a></div>
|
||||
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="grid gap-3 md:grid-cols-[1fr_auto]"><select name="status" class="rounded-xl border border-slate-300 px-3 py-2 text-sm"><option value="">All statuses</option>{% for code,label in service_request_statuses %}<option value="{{ code }}" {% if status == code %}selected{% endif %}>{{ label }}</option>{% endfor %}</select><button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Filter</button></div></form>
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft"><table class="min-w-full divide-y divide-slate-200 text-sm"><thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500"><tr><th class="px-4 py-3">Request</th><th class="px-4 py-3">Consultant</th><th class="px-4 py-3">Client</th><th class="px-4 py-3">Service</th><th class="px-4 py-3">Status</th><th class="px-4 py-3 text-right">Action</th></tr></thead><tbody class="divide-y divide-slate-100">{% for row in rows %}<tr class="hover:bg-slate-50"><td class="px-4 py-3"><div class="font-semibold text-slate-900">{{ row.request_no }}</div><div class="text-xs text-slate-500">{{ row.subject }}</div></td><td class="px-4 py-3 text-slate-700">{{ row.consultant.contact_person if row.consultant else '-' }}</td><td class="px-4 py-3 text-slate-700">{{ row.managed_client.client_name if row.managed_client else (row.firm_client.client_name if row.firm_client else '-') }}</td><td class="px-4 py-3 text-slate-700">{{ row.requested_service_name }}</td><td class="px-4 py-3"><span class="rounded-full bg-blue-50 px-2 py-1 text-xs font-semibold text-blue-700">{{ row.status.replace('_',' ').title() }}</span></td><td class="px-4 py-3 text-right"><a href="/consultants/service-requests/{{ row.id }}" class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Open</a></td></tr>{% else %}<tr><td colspan="6" class="px-4 py-8 text-center text-slate-500">No consultant service requests found.</td></tr>{% endfor %}</tbody></table></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,71 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Consultants</h2>
|
||||
<p class="text-sm text-slate-500">Portal-enabled consultants, franchise partners, and external ecosystem collaborators.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% if can_manage_consultant_service_requests(current_user, current_user_permissions, current_user_roles) %}
|
||||
<a href="/consultants/service-requests" class="inline-flex rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Service Requests</a>
|
||||
{% endif %}
|
||||
{% if can_manage_consultant_conversions(current_user, current_user_permissions, current_user_roles) %}
|
||||
<a href="/consultants/conversion-requests" class="inline-flex rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Conversions</a>
|
||||
{% endif %}
|
||||
{% if can_manage %}
|
||||
<a href="/consultants/new" class="inline-flex rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-brand-700">Add Consultant</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="grid gap-3 md:grid-cols-[1fr_auto_auto]">
|
||||
<input name="q" value="{{ q or '' }}" placeholder="Search by name, firm, email, mobile, specialisation" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<label class="inline-flex items-center gap-2 rounded-xl border border-slate-300 px-3 py-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="include_inactive" value="1" {% if include_inactive %}checked{% endif %}> Include inactive
|
||||
</label>
|
||||
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Consultant</th>
|
||||
<th class="px-4 py-3">Contact</th>
|
||||
<th class="px-4 py-3">Type</th>
|
||||
<th class="px-4 py-3">Specialisation</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3 text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for row in rows %}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3">
|
||||
<div class="font-semibold text-slate-900">{{ row.contact_person }}</div>
|
||||
<div class="text-xs text-slate-500">{{ row.firm_name or 'Individual consultant' }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-600">
|
||||
<div>{{ row.email or '-' }}</div>
|
||||
<div class="text-xs">{{ row.mobile or '-' }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ row.consultant_type.replace('_', ' ').title() }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ row.specialisation or '-' }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="rounded-full px-2 py-1 text-xs font-semibold {% if row.is_active %}bg-emerald-50 text-emerald-700{% else %}bg-slate-100 text-slate-500{% endif %}">{{ row.status }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<a href="/consultants/{{ row.id }}" class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Open</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="px-4 py-8 text-center text-slate-500">No consultants found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,64 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">{{ managed_client.client_name }}</h2>
|
||||
<p class="text-sm text-slate-500">{{ managed_client.client_code or 'Managed client' }}{% if managed_client.trade_name %} • {{ managed_client.trade_name }}{% endif %}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="/consultant/managed-clients" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back</a>
|
||||
<a href="/consultant/managed-clients/{{ managed_client.id }}/edit" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Edit</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="grid gap-4 text-sm md:grid-cols-3">
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Client Type</div><div class="font-medium text-slate-900">{{ managed_client.client_type }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">PAN</div><div class="font-medium text-slate-900">{{ managed_client.pan or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">GSTIN</div><div class="font-medium text-slate-900">{{ managed_client.gstin or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Contact Person</div><div class="font-medium text-slate-900">{{ managed_client.contact_person_name or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Mobile</div><div class="font-medium text-slate-900">{{ managed_client.mobile or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Email</div><div class="font-medium text-slate-900">{{ managed_client.email or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Stage</div><div class="font-medium text-slate-900">{{ managed_client.relationship_stage.replace('_',' ').title() }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Status</div><div class="font-medium text-slate-900">{{ managed_client.status.replace('_',' ').title() }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Active</div><div class="font-medium text-slate-900">{{ 'Yes' if managed_client.is_active else 'No' }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<h3 class="font-semibold text-slate-900">Firm Client Conversion</h3>
|
||||
<div class="mt-3 grid gap-4 text-sm md:grid-cols-3">
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Conversion Status</div><div class="font-medium text-slate-900">{{ managed_client.conversion_status.replace('_',' ').title() if managed_client.conversion_status else 'Not Requested' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Requested On</div><div class="font-medium text-slate-900">{{ managed_client.conversion_requested_at_utc.strftime('%d-%m-%Y %H:%M') if managed_client.conversion_requested_at_utc else '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Linked Firm Client</div><div class="font-medium text-slate-900">{% if managed_client.linked_firm_client %}{{ managed_client.linked_firm_client.client_name }}{% else %}-{% endif %}</div></div>
|
||||
</div>
|
||||
{% if managed_client.conversion_notes %}<div class="mt-3 rounded-xl bg-slate-50 p-3 text-sm text-slate-700"><strong>Consultant notes:</strong><br>{{ managed_client.conversion_notes }}</div>{% endif %}
|
||||
{% if managed_client.conversion_firm_notes %}<div class="mt-3 rounded-xl bg-blue-50 p-3 text-sm text-blue-900"><strong>Firm response:</strong><br>{{ managed_client.conversion_firm_notes }}</div>{% endif %}
|
||||
{% if can_request_conversion %}
|
||||
<form method="post" action="/consultant/managed-clients/{{ managed_client.id }}/request-conversion" class="mt-4 space-y-3">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label class="block text-sm font-semibold text-slate-700">Request conversion to audit firm client</label>
|
||||
<textarea name="conversion_notes" rows="3" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Mention service requirement, preferred partner, urgency, or client background"></textarea>
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Request Conversion</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<h3 class="font-semibold text-slate-900">Address</h3>
|
||||
<p class="mt-3 whitespace-pre-line text-sm text-slate-700">{{ [managed_client.address_line_1, managed_client.address_line_2, managed_client.city, managed_client.state, managed_client.pincode, managed_client.country] | select | join('\n') or '-' }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<h3 class="font-semibold text-slate-900">Service Interest / Notes</h3>
|
||||
<div class="mt-3 space-y-3 text-sm text-slate-700">
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Service Interest</div><div class="whitespace-pre-line">{{ managed_client.service_interest or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Notes</div><div class="whitespace-pre-line">{{ managed_client.notes or '-' }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,127 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
|
||||
{% set is_dict = managed_client is mapping %}
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">{{ title }}</h2>
|
||||
<p class="text-sm text-slate-500">Maintain your own client record in the consultant portal. This does not change the audit firm's client master.</p>
|
||||
</div>
|
||||
|
||||
{% if errors %}
|
||||
<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||
<ul class="list-disc pl-5">{% for err in errors %}<li>{{ err }}</li>{% endfor %}</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" class="space-y-5 rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Client Code</label>
|
||||
<input name="client_code" value="{{ managed_client.client_code if managed_client and not is_dict else managed_client.get('client_code','') if managed_client else '' }}" placeholder="Auto if blank" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Client Name *</label>
|
||||
<input name="client_name" required value="{{ managed_client.client_name if managed_client and not is_dict else managed_client.get('client_name','') if managed_client else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Trade Name</label>
|
||||
<input name="trade_name" value="{{ managed_client.trade_name if managed_client and not is_dict else managed_client.get('trade_name','') if managed_client else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Client Type</label>
|
||||
{% set ct = managed_client.client_type if managed_client and not is_dict else managed_client.get('client_type','Other') if managed_client else 'Other' %}
|
||||
<select name="client_type" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
{% for option in client_types %}<option value="{{ option }}" {% if ct == option %}selected{% endif %}>{{ option }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">PAN</label>
|
||||
<input name="pan" value="{{ managed_client.pan if managed_client and not is_dict else managed_client.get('pan','') if managed_client else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm uppercase">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">GSTIN</label>
|
||||
<input name="gstin" value="{{ managed_client.gstin if managed_client and not is_dict else managed_client.get('gstin','') if managed_client else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm uppercase">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">TAN</label>
|
||||
<input name="tan" value="{{ managed_client.tan if managed_client and not is_dict else managed_client.get('tan','') if managed_client else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm uppercase">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Contact Person</label>
|
||||
<input name="contact_person_name" value="{{ managed_client.contact_person_name if managed_client and not is_dict else managed_client.get('contact_person_name','') if managed_client else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Mobile *</label>
|
||||
<input name="mobile" value="{{ managed_client.mobile if managed_client and not is_dict else managed_client.get('mobile','') if managed_client else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Email *</label>
|
||||
<input name="email" type="email" value="{{ managed_client.email if managed_client and not is_dict else managed_client.get('email','') if managed_client else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Address Line 1</label>
|
||||
<input name="address_line_1" value="{{ managed_client.address_line_1 if managed_client and not is_dict else managed_client.get('address_line_1','') if managed_client else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Address Line 2</label>
|
||||
<input name="address_line_2" value="{{ managed_client.address_line_2 if managed_client and not is_dict else managed_client.get('address_line_2','') if managed_client else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">City</label>
|
||||
<input name="city" value="{{ managed_client.city if managed_client and not is_dict else managed_client.get('city','') if managed_client else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">State</label>
|
||||
<input name="state" value="{{ managed_client.state if managed_client and not is_dict else managed_client.get('state','') if managed_client else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Pincode</label>
|
||||
<input name="pincode" value="{{ managed_client.pincode if managed_client and not is_dict else managed_client.get('pincode','') if managed_client else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Country</label>
|
||||
<input name="country" value="{{ managed_client.country if managed_client and not is_dict else managed_client.get('country','India') if managed_client else 'India' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Relationship Stage</label>
|
||||
{% set stage = managed_client.relationship_stage if managed_client and not is_dict else managed_client.get('relationship_stage','managed') if managed_client else 'managed' %}
|
||||
<select name="relationship_stage" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
{% for code, label in managed_client_stages %}<option value="{{ code }}" {% if stage == code %}selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Status</label>
|
||||
{% set st = managed_client.status if managed_client and not is_dict else managed_client.get('status','active') if managed_client else 'active' %}
|
||||
<select name="status" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
{% for code, label in managed_client_statuses %}<option value="{{ code }}" {% if st == code %}selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<label class="mt-7 inline-flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="is_active" {% if not managed_client or (managed_client and ((not is_dict and managed_client.is_active) or (is_dict and managed_client.get('is_active', True)))) %}checked{% endif %}> Active
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Service Interest</label>
|
||||
<textarea name="service_interest" rows="3" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ managed_client.service_interest if managed_client and not is_dict else managed_client.get('service_interest','') if managed_client else '' }}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Notes</label>
|
||||
<textarea name="notes" rows="3" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ managed_client.notes if managed_client and not is_dict else managed_client.get('notes','') if managed_client else '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<a href="/consultant/managed-clients" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Cancel</a>
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Save Managed Client</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,88 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">My Managed Clients</h2>
|
||||
<p class="text-sm text-slate-500">Clients maintained by you in the consultant portal. These records do not modify the audit firm client master.</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="/consultant/dashboard" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Dashboard</a>
|
||||
{% if can_add_managed_client %}
|
||||
<a href="/consultant/managed-clients/new" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Add Managed Client</a>
|
||||
{% else %}
|
||||
<span class="rounded-xl bg-slate-200 px-4 py-2 text-sm font-semibold text-slate-500">Limit Reached</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if workspace_summary %}
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 text-sm text-slate-700 shadow-soft">
|
||||
Managed client usage: <strong>{{ workspace_summary.managed_clients_used }}</strong> / <strong>{{ workspace_summary.managed_clients_limit or 'No limit' }}</strong>
|
||||
{% if workspace_summary.managed_clients_remaining is not none %} • Remaining: <strong>{{ workspace_summary.managed_clients_remaining }}</strong>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="grid gap-3 md:grid-cols-[1fr_auto_auto_auto]">
|
||||
<input name="q" value="{{ q or '' }}" placeholder="Search name, PAN, GSTIN, email, mobile" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<select name="status" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="">All statuses</option>
|
||||
{% for code, label in managed_client_statuses %}<option value="{{ code }}" {% if status == code %}selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
<label class="inline-flex items-center gap-2 rounded-xl border border-slate-300 px-3 py-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="include_inactive" value="1" {% if include_inactive %}checked{% endif %}> Include inactive
|
||||
</label>
|
||||
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Client</th>
|
||||
<th class="px-4 py-3">Contact</th>
|
||||
<th class="px-4 py-3">PAN / GSTIN</th>
|
||||
<th class="px-4 py-3">Stage</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Conversion</th>
|
||||
<th class="px-4 py-3 text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for row in rows %}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3">
|
||||
<div class="font-semibold text-slate-900">{{ row.client_name }}</div>
|
||||
<div class="text-xs text-slate-500">{{ row.client_code or '-' }}{% if row.trade_name %} • {{ row.trade_name }}{% endif %}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-600">
|
||||
<div>{{ row.contact_person_name or '-' }}</div>
|
||||
<div class="text-xs">{{ row.mobile or row.email or '-' }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-600">
|
||||
<div>{{ row.pan or '-' }}</div>
|
||||
<div class="text-xs">{{ row.gstin or '-' }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ row.relationship_stage.replace('_', ' ').title() }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="rounded-full px-2 py-1 text-xs font-semibold {% if row.is_active and row.status == 'active' %}bg-emerald-50 text-emerald-700{% elif row.status == 'prospect' %}bg-blue-50 text-blue-700{% else %}bg-slate-100 text-slate-600{% endif %}">{{ row.status.replace('_',' ').title() }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-600">
|
||||
{{ row.conversion_status.replace('_',' ').title() if row.conversion_status else 'Not Requested' }}
|
||||
{% if row.linked_firm_client_id %}<div class="text-xs text-emerald-700">Linked to firm client</div>{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<a href="/consultant/managed-clients/{{ row.id }}" class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Open</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">No managed clients found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,128 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
{% if not payload.workspace %}
|
||||
<section class="rounded-3xl bg-gradient-to-r from-brand-700 to-slate-900 p-6 text-white shadow-soft">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-brand-100">Consultant Portal</p>
|
||||
<h2 class="mt-2 text-2xl font-semibold">Set up your consultant workspace</h2>
|
||||
<p class="mt-2 max-w-3xl text-sm text-brand-100">Create your workspace to manage clients, communicate with firms and refer work.</p>
|
||||
</section>
|
||||
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-6 text-sm text-amber-900 shadow-soft">
|
||||
Your consultant workspace is not configured yet. Open profile/workspace settings and complete setup.
|
||||
<div class="mt-4"><a href="/consultant/workspace/edit" class="rounded-xl bg-amber-700 px-4 py-2 text-sm font-semibold text-white hover:bg-amber-800">Configure Workspace</a></div>
|
||||
</div>
|
||||
{% else %}
|
||||
<section class="rounded-3xl bg-gradient-to-r from-brand-700 to-slate-900 p-6 text-white shadow-soft">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-brand-100">Consultant Portal</p>
|
||||
<h2 class="mt-2 text-2xl font-semibold">Managed clients, firm referrals and consultant assignments</h2>
|
||||
<p class="mt-2 max-w-3xl text-sm text-brand-100">Track bookkeeping clients, clarifications, due dates, service requests and conversion requests with audit firms.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/consultant/work" class="rounded-xl bg-white px-4 py-2 text-sm font-semibold text-brand-700 hover:bg-brand-50">Open Work Board</a>
|
||||
<a href="/consultant/service-requests" class="rounded-xl border border-white/40 px-4 py-2 text-sm font-semibold text-white hover:bg-white/10">Service Requests</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
|
||||
<a href="/consultant/communications" class="af-metric-card border-purple-200 bg-purple-50 hover:border-purple-300"><div class="text-xs font-semibold uppercase text-purple-700">Clarifications</div><div class="mt-2 text-3xl font-semibold text-purple-700">{{ payload.stats.pending_clarifications or 0 }}</div><div class="mt-1 text-xs text-purple-700">Firm messages</div></a>
|
||||
<a href="/consultant/service-requests" class="af-metric-card hover:border-brand-200"><div class="text-xs font-semibold uppercase text-slate-500">Service Requests</div><div class="mt-2 text-3xl font-semibold text-slate-900">{{ payload.stats.open_service_requests or 0 }}</div><div class="mt-1 text-xs text-slate-500">Open requests</div></a>
|
||||
<a href="/consultant/managed-clients" class="af-metric-card hover:border-brand-200"><div class="text-xs font-semibold uppercase text-slate-500">Managed Clients</div><div class="mt-2 text-3xl font-semibold text-slate-900">{{ payload.stats.managed_clients or 0 }}</div><div class="mt-1 text-xs text-slate-500">Bookkeeping / support</div></a>
|
||||
<a href="/consultant/managed-clients" class="af-metric-card hover:border-brand-200"><div class="text-xs font-semibold uppercase text-slate-500">Prospects</div><div class="mt-2 text-3xl font-semibold text-slate-900">{{ payload.stats.prospects or 0 }}</div><div class="mt-1 text-xs text-slate-500">Lead pipeline</div></a>
|
||||
<a href="/consultant/work" class="af-metric-card border-amber-200 bg-amber-50 hover:border-amber-300"><div class="text-xs font-semibold uppercase text-amber-700">Due Soon</div><div class="mt-2 text-3xl font-semibold text-amber-700">{{ payload.stats.upcoming_due or 0 }}</div><div class="mt-1 text-xs text-amber-700">Next 30 days</div></a>
|
||||
<a href="/consultant/work" class="af-metric-card border-red-200 bg-red-50 hover:border-red-300"><div class="text-xs font-semibold uppercase text-red-700">Overdue</div><div class="mt-2 text-3xl font-semibold text-red-700">{{ payload.stats.overdue or 0 }}</div><div class="mt-1 text-xs text-red-700">Linked firm work</div></a>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-6 xl:grid-cols-[380px_minmax(0,1fr)]">
|
||||
<aside class="space-y-6">
|
||||
<div class="af-card">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-900">Workspace & Plan</h3>
|
||||
<p class="mt-1 text-sm text-slate-600">{{ payload.workspace.workspace_name }}</p>
|
||||
<p class="text-xs text-slate-500">{{ payload.workspace.workspace_type.replace('_',' ').title() }} • {{ payload.workspace.plan_code.replace('_',' ').title() }} • {{ payload.workspace.subscription_status.replace('_',' ').title() }}</p>
|
||||
</div>
|
||||
<a href="/consultant/workspace/edit" class="rounded-xl border border-slate-300 px-3 py-2 text-xs font-semibold text-slate-700 hover:bg-slate-50">Edit</a>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<div class="flex items-center justify-between text-xs text-slate-500">
|
||||
<span>Managed client usage</span>
|
||||
<span>{{ payload.workspace_summary.managed_clients_used or 0 }} / {{ payload.workspace_summary.managed_clients_limit or 'No limit' }}</span>
|
||||
</div>
|
||||
<div class="mt-2 h-2 rounded-full bg-slate-100"><div class="h-2 rounded-full bg-slate-900" style="width: {{ 100 if (payload.workspace_summary.usage_percent or 0) > 100 else (payload.workspace_summary.usage_percent or 0) }}%"></div></div>
|
||||
<div class="mt-2 text-xs text-slate-500">{% if payload.workspace_summary.managed_clients_remaining is none %}No client limit configured.{% else %}{{ payload.workspace_summary.managed_clients_remaining }} client slots remaining.{% endif %}</div>
|
||||
</div>
|
||||
<div class="mt-5 grid gap-2 text-xs text-slate-600">
|
||||
<div class="flex justify-between"><span>Client portal</span><span>{{ 'Enabled' if payload.workspace.allow_client_portal else 'Disabled' }}</span></div>
|
||||
<div class="flex justify-between"><span>Firm referrals</span><span>{{ 'Enabled' if payload.workspace.allow_firm_referrals else 'Disabled' }}</span></div>
|
||||
<div class="flex justify-between"><span>Marketplace</span><span>{{ 'Enabled' if payload.workspace.allow_service_marketplace else 'Disabled' }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="af-card">
|
||||
<h3 class="font-semibold text-slate-900">Quick Actions</h3>
|
||||
<div class="mt-4 grid gap-2 text-sm">
|
||||
<a href="/consultant/managed-clients" class="rounded-xl border border-slate-200 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-50">Manage Clients</a>
|
||||
{% if payload.workspace_summary.managed_clients_remaining is none or payload.workspace_summary.managed_clients_remaining > 0 %}
|
||||
<a href="/consultant/managed-clients/new" class="rounded-xl border border-slate-200 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-50">Add Managed Client</a>
|
||||
{% endif %}
|
||||
<a href="/consultant/communications" class="rounded-xl border border-slate-200 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-50">Reply Clarifications</a>
|
||||
<a href="/consultant/service-requests" class="rounded-xl border border-slate-200 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-50">Service Requests / Leads</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="af-card">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div><h3 class="text-lg font-semibold text-slate-900">Pending Consultant Clarifications</h3><p class="mt-1 text-sm text-slate-500">Firm messages visible to consultant. Internal and client-only notes are hidden.</p></div>
|
||||
<a href="/consultant/communications" class="text-sm font-semibold text-brand-700 hover:underline">View all</a>
|
||||
</div>
|
||||
<div class="mt-4 space-y-3">
|
||||
{% for comment, task, subscription, client, catalogue in payload.recent_firm_messages %}
|
||||
<a href="/consultant/communications/{{ comment.id }}" class="block rounded-2xl border border-slate-200 p-4 hover:bg-slate-50">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div><div class="font-semibold text-slate-900">{{ client.client_name }} — {{ catalogue.service_name }}</div><div class="text-xs text-slate-500">Task: {{ task.task_name }} • {{ comment.created_at_utc.strftime('%d-%m-%Y %H:%M') if comment.created_at_utc else '' }}</div></div>
|
||||
<span class="rounded-full bg-purple-50 px-2 py-1 text-xs font-semibold text-purple-700">{{ comment.comment_type.replace('_',' ').title() }}</span>
|
||||
</div>
|
||||
<div class="mt-3 line-clamp-3 whitespace-pre-line rounded-xl bg-slate-50 p-3 text-sm text-slate-700">{{ comment.message }}</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500">No pending consultant-visible firm messages.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="af-card">
|
||||
<div class="flex items-center justify-between"><h3 class="font-semibold text-slate-900">Pending Conversions</h3><a href="/consultant/managed-clients" class="text-xs font-semibold text-brand-700 hover:underline">View</a></div>
|
||||
<div class="mt-4 space-y-3">{% for client in payload.pending_conversions %}<a href="/consultant/managed-clients/{{ client.id }}" class="block rounded-xl border border-slate-200 p-3 hover:bg-slate-50"><div class="font-semibold text-slate-900">{{ client.client_name }}</div><div class="text-xs text-slate-500">{{ client.client_code or '-' }} • {{ client.conversion_status.replace('_',' ').title() }}</div></a>{% else %}<div class="text-sm text-slate-500">No pending firm-client conversion requests.</div>{% endfor %}</div>
|
||||
</div>
|
||||
<div class="af-card">
|
||||
<div class="flex items-center justify-between"><h3 class="font-semibold text-slate-900">Upcoming Due Dates</h3><span class="text-xs text-slate-500">30 days</span></div>
|
||||
<div class="mt-4 space-y-3">{% for subscription, client, catalogue in payload.due_items %}<div class="rounded-xl border border-slate-200 p-3"><div class="font-semibold text-slate-900">{{ client.client_name }}</div><div class="text-xs text-slate-500">{{ catalogue.service_name }} • Due {{ subscription.current_due_date.strftime('%d-%m-%Y') if subscription.current_due_date else '-' }}</div></div>{% else %}<div class="text-sm text-slate-500">No upcoming due dates.</div>{% endfor %}</div>
|
||||
</div>
|
||||
<div class="af-card border-red-200">
|
||||
<div class="flex items-center justify-between"><h3 class="font-semibold text-slate-900">Overdue Work</h3><span class="text-xs text-red-500">Attention</span></div>
|
||||
<div class="mt-4 space-y-3">{% for subscription, client, catalogue in payload.overdue_items %}<div class="rounded-xl border border-red-100 bg-red-50 p-3"><div class="font-semibold text-slate-900">{{ client.client_name }}</div><div class="text-xs text-red-600">{{ catalogue.service_name }} • Due {{ subscription.current_due_date.strftime('%d-%m-%Y') if subscription.current_due_date else '-' }}</div></div>{% else %}<div class="text-sm text-slate-500">No overdue linked firm engagements.</div>{% endfor %}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<div class="af-card">
|
||||
<div class="flex items-center justify-between"><h3 class="font-semibold text-slate-900">My Managed Clients</h3><a href="/consultant/managed-clients" class="text-xs font-semibold text-brand-700 hover:underline">View all</a></div>
|
||||
<div class="mt-4 space-y-3">{% for client in payload.managed_clients %}<a href="/consultant/managed-clients/{{ client.id }}" class="block rounded-xl border border-slate-200 p-3 hover:bg-slate-50"><div class="font-semibold text-slate-900">{{ client.client_name }}</div><div class="text-xs text-slate-500">{{ client.client_code or '-' }} • {{ client.relationship_stage.replace('_',' ').title() }} • {{ client.status.replace('_',' ').title() }}</div></a>{% else %}<div class="text-sm text-slate-500">No managed clients yet.</div>{% endfor %}</div>
|
||||
</div>
|
||||
<div class="af-card">
|
||||
<h3 class="font-semibold text-slate-900">Linked Firm Clients</h3><p class="mt-1 text-xs text-slate-500">Only explicitly linked clients are visible here.</p>
|
||||
<div class="mt-4 space-y-3">{% for link in payload.active_links %}<div class="rounded-xl border border-slate-200 p-3"><div class="font-semibold text-slate-900">{{ link.client.client_name if link.client else '-' }}</div><div class="text-xs text-slate-500">{{ link.client.client_code if link.client else '' }} • {{ link.relationship_type.replace('_',' ').title() }}</div><div class="mt-2 text-xs text-slate-600">Due dates {{ 'enabled' if link.can_view_due_dates else 'hidden' }} • Communications {{ 'enabled' if link.can_view_communications else 'hidden' }}</div></div>{% else %}<div class="text-sm text-slate-500">No firm clients are linked yet.</div>{% endfor %}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,92 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
|
||||
{% set is_dict = consultant is mapping %}
|
||||
<div class="mx-auto max-w-3xl space-y-6">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">My Consultant Profile</h2>
|
||||
<p class="text-sm text-slate-500">Update your visible consultant contact and business details.</p>
|
||||
</div>
|
||||
<a href="/consultant/dashboard" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back</a>
|
||||
</div>
|
||||
|
||||
{% if errors %}
|
||||
<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||
<ul class="list-disc pl-5">{% for error in errors %}<li>{{ error }}</li>{% endfor %}</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" enctype="multipart/form-data" class="space-y-5 rounded-2xl border border-slate-200 bg-white p-6 shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="rounded-2xl bg-slate-50 p-4">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
{% set profile_photo_url = get_user_profile_photo_url(current_user) %}
|
||||
{% if profile_photo_url %}
|
||||
<img src="{{ profile_photo_url }}" alt="Profile photo" class="h-20 w-20 rounded-2xl object-cover shadow-soft">
|
||||
{% else %}
|
||||
<div class="flex h-20 w-20 items-center justify-center rounded-2xl bg-brand-600 text-xl font-bold text-white shadow-soft">{{ get_user_initials(current_user) }}</div>
|
||||
{% endif %}
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-semibold text-slate-900">Public profile</div>
|
||||
<p class="text-sm text-slate-500">Photo, qualification and bio will be used in consultant workspace and future lead pages.</p>
|
||||
<input type="file" name="profile_photo" accept="image/png,image/jpeg,image/gif,image/webp" class="mt-3 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Qualification</label>
|
||||
<input name="qualification" value="{{ current_user.qualification or '' }}" placeholder="CA, MBA, GST Practitioner" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Display Designation</label>
|
||||
<input name="public_designation" value="{{ current_user.designation or 'Consultant' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Short Bio</label>
|
||||
<textarea name="bio" rows="3" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ current_user.bio or '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Contact person / Consultant name *</label>
|
||||
<input name="contact_person" value="{{ consultant.contact_person if consultant and not is_dict else consultant.get('contact_person','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Firm name</label>
|
||||
<input name="firm_name" value="{{ consultant.firm_name if consultant and not is_dict else consultant.get('firm_name','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Email</label>
|
||||
<input type="email" name="email" value="{{ consultant.email if consultant and not is_dict else consultant.get('email','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Mobile</label>
|
||||
<input name="mobile" value="{{ consultant.mobile if consultant and not is_dict else consultant.get('mobile','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">PAN</label>
|
||||
<input name="pan" value="{{ consultant.pan if consultant and not is_dict else consultant.get('pan','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">GSTIN</label>
|
||||
<input name="gstin" value="{{ consultant.gstin if consultant and not is_dict else consultant.get('gstin','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Specialisation</label>
|
||||
<input name="specialisation" value="{{ consultant.specialisation if consultant and not is_dict else consultant.get('specialisation','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Address</label>
|
||||
<textarea name="address" rows="3" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ consultant.address if consultant and not is_dict else consultant.get('address','') if consultant else '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3">
|
||||
<a href="/consultant/dashboard" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Cancel</a>
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Save Profile</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,30 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
|
||||
<div class="mx-auto max-w-4xl space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3"><div><h2 class="text-xl font-semibold text-slate-900">{{ request_row.request_no }}</h2><p class="text-sm text-slate-500">{{ request_row.subject }}</p></div><a href="{{ '/consultants/service-requests' if internal_view else '/consultant/service-requests' }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back</a></div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="grid gap-4 text-sm md:grid-cols-3">
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Status</div><div class="font-medium text-slate-900">{{ request_row.status.replace('_',' ').title() }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Priority</div><div class="font-medium text-slate-900">{{ request_row.priority.replace('_',' ').title() }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Requested Date</div><div class="font-medium text-slate-900">{{ request_row.requested_due_date or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Consultant</div><div class="font-medium text-slate-900">{{ request_row.consultant.contact_person if request_row.consultant else '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Client</div><div class="font-medium text-slate-900">{{ request_row.managed_client.client_name if request_row.managed_client else (request_row.firm_client.client_name if request_row.firm_client else '-') }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Service</div><div class="font-medium text-slate-900">{{ request_row.requested_service_name }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft"><h3 class="font-semibold text-slate-900">Description</h3><p class="mt-3 whitespace-pre-line text-sm text-slate-700">{{ request_row.description or '-' }}</p></div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft"><h3 class="font-semibold text-slate-900">Consultant Notes</h3><p class="mt-3 whitespace-pre-line text-sm text-slate-700">{{ request_row.consultant_notes or '-' }}</p></div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft"><h3 class="font-semibold text-slate-900">Firm Response</h3><p class="mt-3 whitespace-pre-line text-sm text-slate-700">{{ request_row.firm_response or 'No response yet.' }}</p>{% if request_row.reviewed_by %}<p class="mt-2 text-xs text-slate-500">Reviewed by {{ request_row.reviewed_by.full_name or request_row.reviewed_by.email }} on {{ request_row.reviewed_at_utc.strftime('%d-%m-%Y %H:%M') if request_row.reviewed_at_utc else '-' }}</p>{% endif %}</div>
|
||||
{% if internal_view %}
|
||||
<form method="post" action="/consultants/service-requests/{{ request_row.id }}/status" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<h3 class="font-semibold text-slate-900">Update Firm Decision</h3>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2"><div><label class="mb-2 block text-sm font-medium text-slate-700">Status</label><select name="status" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{% for code,label in service_request_statuses %}<option value="{{ code }}" {% if request_row.status == code %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></div><div><label class="mb-2 block text-sm font-medium text-slate-700">Action Note</label><input name="firm_response" value="{{ request_row.firm_response or '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"></div></div>
|
||||
<div class="mt-4 flex justify-end"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Save Decision</button></div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,23 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
|
||||
<div class="mx-auto max-w-4xl space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3"><div><h2 class="text-xl font-semibold text-slate-900">New Service Request</h2><p class="text-sm text-slate-500">Raise a request to the audit firm for review and acceptance.</p></div><a href="/consultant/service-requests" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back</a></div>
|
||||
{% if errors %}<div class="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">{% for error in errors %}<div>{{ error }}</div>{% endfor %}</div>{% endif %}
|
||||
<form method="post" class="rounded-2xl border border-slate-200 bg-white p-6 shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="grid gap-5 md:grid-cols-2">
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Managed Client</label><select name="managed_client_id" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"><option value="">-- Select managed client --</option>{% for client in managed_clients %}<option value="{{ client.id }}">{{ client.client_name }} ({{ client.client_code or 'Managed' }})</option>{% endfor %}</select><p class="mt-1 text-xs text-slate-500">Use this for clients maintained in your consultant portal.</p></div>
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Linked Firm Client</label><select name="firm_client_id" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"><option value="">-- Select linked firm client --</option>{% for link in firm_links %}{% if link.client %}<option value="{{ link.client.id }}">{{ link.client.client_name }} ({{ link.client.client_code }})</option>{% endif %}{% endfor %}</select><p class="mt-1 text-xs text-slate-500">Select either managed client or linked firm client, not both.</p></div>
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Service Catalogue</label><select name="service_catalogue_id" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"><option value="">-- Select service --</option>{% for service in services %}<option value="{{ service.id }}">{{ service.service_name }}</option>{% endfor %}</select></div>
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Other / Custom Service Name</label><input name="requested_service_name" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Required only if service not selected"></div>
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Priority</label><select name="priority" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{% for code,label in service_request_priorities %}<option value="{{ code }}" {% if code == 'normal' %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></div>
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Expected / Requested Date</label><input type="date" name="requested_due_date" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"></div>
|
||||
<div class="md:col-span-2"><label class="mb-2 block text-sm font-medium text-slate-700">Subject</label><input name="subject" required class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Example: Tax audit request for ABC Pvt Ltd"></div>
|
||||
<div class="md:col-span-2"><label class="mb-2 block text-sm font-medium text-slate-700">Description</label><textarea name="description" rows="4" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Describe scope, period, documents available, urgency, etc."></textarea></div>
|
||||
<div class="md:col-span-2"><label class="mb-2 block text-sm font-medium text-slate-700">Consultant Notes</label><textarea name="consultant_notes" rows="3" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Internal notes from your side for the firm."></textarea></div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Submit Request</button></div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,20 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div><h2 class="text-xl font-semibold text-slate-900">My Service Requests</h2><p class="text-sm text-slate-500">Request audit firm services for your managed or linked clients.</p></div>
|
||||
<div class="flex gap-2"><a href="/consultant/dashboard" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Dashboard</a><a href="/consultant/service-requests/new" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">New Request</a></div>
|
||||
</div>
|
||||
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="grid gap-3 md:grid-cols-[1fr_auto]">
|
||||
<select name="status" class="rounded-xl border border-slate-300 px-3 py-2 text-sm"><option value="">All statuses</option>{% for code,label in service_request_statuses %}<option value="{{ code }}" {% if status == code %}selected{% endif %}>{{ label }}</option>{% endfor %}</select>
|
||||
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm"><thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500"><tr><th class="px-4 py-3">Request</th><th class="px-4 py-3">Client</th><th class="px-4 py-3">Service</th><th class="px-4 py-3">Status</th><th class="px-4 py-3 text-right">Action</th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-100">{% for row in rows %}<tr class="hover:bg-slate-50"><td class="px-4 py-3"><div class="font-semibold text-slate-900">{{ row.request_no }}</div><div class="text-xs text-slate-500">{{ row.subject }}</div></td><td class="px-4 py-3 text-slate-700">{{ row.managed_client.client_name if row.managed_client else (row.firm_client.client_name if row.firm_client else '-') }}</td><td class="px-4 py-3 text-slate-700">{{ row.requested_service_name }}</td><td class="px-4 py-3"><span class="rounded-full bg-blue-50 px-2 py-1 text-xs font-semibold text-blue-700">{{ row.status.replace('_',' ').title() }}</span></td><td class="px-4 py-3 text-right"><a href="/consultant/service-requests/{{ row.id }}" class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Open</a></td></tr>{% else %}<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">No service requests yet.</td></tr>{% endfor %}</tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,85 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">My Consultant Work Board</h2>
|
||||
<p class="text-sm text-slate-500">Consultant-visible work shared by the firm. Internal firm tasks and private notes are not shown here.</p>
|
||||
</div>
|
||||
<a href="/consultant/service-requests/new" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Raise Service Request</a>
|
||||
</div>
|
||||
|
||||
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="grid gap-3 md:grid-cols-[1fr_auto_auto]">
|
||||
<input name="q" value="{{ q or '' }}" placeholder="Search client, service, task, message" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<select name="status" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="">All columns</option>
|
||||
{% for code, label in board.column_options %}<option value="{{ code }}" {% if status == code %}selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="overflow-x-auto pb-2">
|
||||
<div class="grid min-w-[1180px] gap-4 lg:grid-cols-6">
|
||||
{% for key, column in board.columns.items() %}
|
||||
<section class="rounded-2xl border border-slate-200 bg-slate-50 p-3">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-slate-800">{{ column["label"] }}</h3>
|
||||
<span class="rounded-full bg-white px-2 py-0.5 text-xs font-semibold text-slate-600">{{ column["items"]|length }}</span>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
{% for item in column["items"] %}
|
||||
{% set task = item.task %}
|
||||
{% set client = item.client %}
|
||||
{% set catalogue = item.catalogue %}
|
||||
<a href="/work/engagements/{{ task.subscription_id }}" class="block rounded-2xl border border-slate-200 bg-white p-4 shadow-sm hover:border-brand-300 hover:shadow-soft">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-slate-900">{{ client.client_name }}</div>
|
||||
<div class="text-xs text-slate-500">{{ catalogue.service_name }}</div>
|
||||
</div>
|
||||
{% if item.is_overdue %}<span class="rounded-full bg-red-50 px-2 py-1 text-[11px] font-semibold text-red-700">Overdue</span>{% endif %}
|
||||
</div>
|
||||
<div class="mt-3 text-sm font-medium text-slate-800">{{ task.task_name }}</div>
|
||||
<div class="mt-2 flex flex-wrap gap-1 text-[11px] font-semibold">
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-600">{{ task.status.replace('_',' ').title() }}</span>
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-600">{{ task.priority.replace('_',' ').title() }}</span>
|
||||
{% if task.internal_target_date %}<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-600">Due {{ task.internal_target_date.strftime('%d-%m-%Y') }}</span>{% endif %}
|
||||
</div>
|
||||
<div class="mt-3 line-clamp-3 rounded-xl bg-slate-50 p-2 text-xs text-slate-600">{{ item.comment.message }}</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="rounded-xl border border-dashed border-slate-300 bg-white p-4 text-center text-xs text-slate-500">No items</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<div class="flex items-center justify-between border-b border-slate-200 px-4 py-3">
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-900">My Service Requests</h3>
|
||||
<p class="text-xs text-slate-500">Requests raised by you to the firm.</p>
|
||||
</div>
|
||||
<a href="/consultant/service-requests" class="text-xs font-semibold text-brand-700 hover:underline">View all</a>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-100">
|
||||
{% for row in board.service_requests[:8] %}
|
||||
<a href="/consultant/service-requests/{{ row.id }}" class="flex flex-wrap items-center justify-between gap-3 p-4 hover:bg-slate-50">
|
||||
<div>
|
||||
<div class="font-semibold text-slate-900">{{ row.subject }}</div>
|
||||
<div class="text-xs text-slate-500">{{ row.request_no }} • {{ row.requested_service_name }}{% if row.requested_due_date %} • Due {{ row.requested_due_date.strftime('%d-%m-%Y') }}{% endif %}</div>
|
||||
</div>
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-700">{{ row.status.replace('_',' ').title() }}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="p-6 text-sm text-slate-500">No service requests yet.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,73 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Consultant Workspace</h2>
|
||||
<p class="text-sm text-slate-500">SaaS / franchise workspace foundation for your consultant portal.</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="/consultant/dashboard" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back to Dashboard</a>
|
||||
<a href="/consultant/workspace/edit" class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800">Edit Workspace</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Workspace Code</div>
|
||||
<div class="mt-2 text-lg font-semibold text-slate-900">{{ workspace.workspace_code }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Plan</div>
|
||||
<div class="mt-2 text-lg font-semibold text-slate-900">{{ workspace.plan_code.replace('_',' ').title() }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Status</div>
|
||||
<div class="mt-2 text-lg font-semibold text-slate-900">{{ workspace.subscription_status.replace('_',' ').title() }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Billing</div>
|
||||
<div class="mt-2 text-lg font-semibold text-slate-900">{{ workspace.billing_cycle.replace('_',' ').title() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<div class="border-b border-slate-200 px-5 py-4">
|
||||
<h3 class="font-semibold text-slate-900">Workspace Details</h3>
|
||||
</div>
|
||||
<div class="grid gap-4 p-5 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Workspace Name</div>
|
||||
<div class="mt-1 font-medium text-slate-900">{{ workspace.workspace_name }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Workspace Type</div>
|
||||
<div class="mt-1 font-medium text-slate-900">{{ workspace.workspace_type.replace('_',' ').title() }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Subscription Period</div>
|
||||
<div class="mt-1 font-medium text-slate-900">{{ workspace.subscription_start_date or '-' }} to {{ workspace.subscription_end_date or '-' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Limits</div>
|
||||
<div class="mt-1 font-medium text-slate-900">{{ workspace.max_managed_clients }} managed clients • {{ workspace.max_user_accounts }} user account(s)</div>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Enabled Features</div>
|
||||
<div class="mt-2 flex flex-wrap gap-2 text-xs font-semibold">
|
||||
<span class="rounded-full {{ 'bg-emerald-50 text-emerald-700' if workspace.allow_client_portal else 'bg-slate-100 text-slate-600' }} px-2 py-1">Client Portal {{ 'On' if workspace.allow_client_portal else 'Off' }}</span>
|
||||
<span class="rounded-full {{ 'bg-emerald-50 text-emerald-700' if workspace.allow_firm_referrals else 'bg-slate-100 text-slate-600' }} px-2 py-1">Firm Referrals {{ 'On' if workspace.allow_firm_referrals else 'Off' }}</span>
|
||||
<span class="rounded-full {{ 'bg-emerald-50 text-emerald-700' if workspace.allow_service_marketplace else 'bg-slate-100 text-slate-600' }} px-2 py-1">Marketplace {{ 'On' if workspace.allow_service_marketplace else 'Off' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% if workspace.remarks %}
|
||||
<div class="md:col-span-2">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Remarks</div>
|
||||
<div class="mt-1 whitespace-pre-line rounded-xl bg-slate-50 p-3 text-sm text-slate-700">{{ workspace.remarks }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,70 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Edit Consultant Workspace</h2>
|
||||
<p class="text-sm text-slate-500">Update your workspace display details. Plan, billing, subscription and limits are controlled by the firm/admin.</p>
|
||||
</div>
|
||||
<a href="/consultant/workspace" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back to Workspace</a>
|
||||
</div>
|
||||
|
||||
{% if errors %}
|
||||
<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||
<ul class="list-disc pl-5">
|
||||
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Plan</div>
|
||||
<div class="mt-2 text-lg font-semibold text-slate-900">{{ workspace.plan_code.replace('_',' ').title() if workspace and workspace.plan_code else 'Starter' }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Subscription</div>
|
||||
<div class="mt-2 text-lg font-semibold text-slate-900">{{ workspace.subscription_status.replace('_',' ').title() if workspace and workspace.subscription_status else 'Trial' }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Billing</div>
|
||||
<div class="mt-2 text-lg font-semibold text-slate-900">{{ workspace.billing_cycle.replace('_',' ').title() if workspace and workspace.billing_cycle else 'Manual' }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Client Limit</div>
|
||||
<div class="mt-2 text-lg font-semibold text-slate-900">{{ workspace.max_managed_clients if workspace else 25 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-slate-700">Workspace Name</label>
|
||||
<input name="workspace_name" value="{{ workspace.workspace_name if workspace else '' }}" required class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-slate-900 focus:outline-none">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-slate-700">Workspace Code</label>
|
||||
<input value="{{ workspace.workspace_code if workspace else '-' }}" disabled class="mt-1 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
|
||||
<div class="font-semibold">Admin-controlled fields</div>
|
||||
<p class="mt-1">Workspace type, plan, subscription status, billing cycle, client/user limits, feature switches and active status cannot be changed from the consultant portal.</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<label class="text-sm font-medium text-slate-700">Remarks</label>
|
||||
<textarea name="remarks" rows="4" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-slate-900 focus:outline-none">{{ workspace.remarks if workspace and workspace.remarks else '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex gap-2">
|
||||
<button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800">Save Workspace</button>
|
||||
<a href="/consultant/workspace" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user