Prepare ERP source for Gitea deployment
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Marketplace / public lead foundation module."""
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
|
||||
class MarketplaceLead(CommonBase):
|
||||
"""Public/service marketplace lead captured for later assignment and conversion."""
|
||||
|
||||
__tablename__ = "marketplace_leads"
|
||||
__table_args__ = (UniqueConstraint("lead_no", name="uq_marketplace_leads_lead_no"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
lead_no: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
source: Mapped[str] = mapped_column(String(50), nullable=False, default="manual", index=True)
|
||||
service_category: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
|
||||
service_requested: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
|
||||
|
||||
lead_name: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
|
||||
business_name: Mapped[str | None] = mapped_column(String(200), nullable=True, index=True)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
mobile: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
city: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
state: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, default="NEW", index=True)
|
||||
priority: Mapped[str] = mapped_column(String(30), nullable=False, default="NORMAL", index=True)
|
||||
estimated_value: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("0.00"))
|
||||
|
||||
assigned_tenant_id: Mapped[int | None] = mapped_column(ForeignKey("tenants.id"), nullable=True, index=True)
|
||||
assigned_branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True)
|
||||
assigned_partner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
assigned_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
assigned_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
|
||||
converted_client_id: Mapped[int | None] = mapped_column(ForeignKey("clients.id"), nullable=True, index=True)
|
||||
converted_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
converted_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), 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)
|
||||
|
||||
|
||||
class MarketplaceLeadAssignment(CommonBase):
|
||||
"""Assignment history for marketplace leads."""
|
||||
|
||||
__tablename__ = "marketplace_lead_assignments"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
lead_id: Mapped[int] = mapped_column(ForeignKey("marketplace_leads.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True)
|
||||
partner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, default="ASSIGNED", index=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
assigned_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
assigned_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
@@ -0,0 +1,228 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
|
||||
from app.modules.clients.models import Client
|
||||
from app.modules.consultants.models import ConsultantProfile
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.core.tenancy.models import Branch, Tenant
|
||||
from app.modules.marketplace.models import MarketplaceLead, MarketplaceLeadAssignment
|
||||
|
||||
LEAD_STATUSES = ["NEW", "CONTACTED", "QUALIFIED", "ASSIGNED", "ACCEPTED", "REJECTED", "CONVERTED", "LOST"]
|
||||
LEAD_PRIORITIES = ["LOW", "NORMAL", "HIGH", "URGENT"]
|
||||
LEAD_SOURCES = ["manual", "public_website", "consultant", "referral", "campaign", "other"]
|
||||
SERVICE_CATEGORIES = ["GST", "Income Tax", "ROC", "Audit", "Accounting", "Payroll", "Registration", "Advisory", "Other"]
|
||||
|
||||
|
||||
def money(value) -> Decimal:
|
||||
if value in (None, ""):
|
||||
return Decimal("0.00")
|
||||
return Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def _role_set(role_names: list[str] | None) -> set[str]:
|
||||
return set(role_names or [])
|
||||
|
||||
|
||||
def is_system_admin(role_names: list[str] | None) -> bool:
|
||||
return "System Admin" in _role_set(role_names)
|
||||
|
||||
|
||||
def is_firm_admin(role_names: list[str] | None) -> bool:
|
||||
return "Firm Admin" in _role_set(role_names)
|
||||
|
||||
|
||||
def is_partner(role_names: list[str] | None) -> bool:
|
||||
return "Partner" in _role_set(role_names)
|
||||
|
||||
|
||||
def generate_lead_no(db) -> str:
|
||||
year = datetime.now().year
|
||||
count = db.execute(select(func.count(MarketplaceLead.id))).scalar_one() or 0
|
||||
return f"ML-{year}-{count + 1:05d}"
|
||||
|
||||
|
||||
def list_reference_audit_firms(db) -> list[Tenant]:
|
||||
return list(db.execute(select(Tenant).order_by(Tenant.name.asc())).scalars().all())
|
||||
|
||||
|
||||
def list_reference_branches(db, tenant_id: int | None = None) -> list[Branch]:
|
||||
stmt = select(Branch).order_by(Branch.name.asc())
|
||||
if tenant_id:
|
||||
stmt = stmt.where(Branch.tenant_id == tenant_id)
|
||||
return list(db.execute(stmt).scalars().all())
|
||||
|
||||
|
||||
def list_reference_partners(db, tenant_id: int | None = None) -> list[User]:
|
||||
stmt = select(User).order_by(User.full_name.asc(), User.email.asc())
|
||||
if tenant_id:
|
||||
stmt = stmt.where(User.tenant_id == tenant_id)
|
||||
return list(db.execute(stmt).scalars().all())
|
||||
|
||||
|
||||
def list_marketplace_leads(db, *, q: str = "", status: str = "", user=None, role_names: list[str] | None = None) -> list[MarketplaceLead]:
|
||||
stmt = select(MarketplaceLead).order_by(MarketplaceLead.created_at_utc.desc(), MarketplaceLead.id.desc())
|
||||
roles = _role_set(role_names)
|
||||
if not is_system_admin(role_names):
|
||||
tenant_id = getattr(user, "tenant_id", None)
|
||||
if tenant_id:
|
||||
stmt = stmt.where(MarketplaceLead.assigned_tenant_id == tenant_id)
|
||||
if is_partner(role_names):
|
||||
stmt = stmt.where(MarketplaceLead.assigned_partner_user_id == getattr(user, "id", None))
|
||||
if status:
|
||||
stmt = stmt.where(MarketplaceLead.status == status)
|
||||
if q:
|
||||
like = f"%{q}%"
|
||||
stmt = stmt.where(or_(MarketplaceLead.lead_no.ilike(like), MarketplaceLead.lead_name.ilike(like), MarketplaceLead.business_name.ilike(like), MarketplaceLead.mobile.ilike(like), MarketplaceLead.email.ilike(like), MarketplaceLead.service_requested.ilike(like)))
|
||||
return list(db.execute(stmt).scalars().all())
|
||||
|
||||
|
||||
def get_marketplace_lead(db, lead_id: int) -> MarketplaceLead | None:
|
||||
return db.get(MarketplaceLead, int(lead_id))
|
||||
|
||||
|
||||
def create_marketplace_lead(db, *, lead_name: str, business_name: str | None, email: str | None, mobile: str | None, city: str | None, state: str | None, service_category: str | None, service_requested: str, message: str | None, source: str = "manual", priority: str = "NORMAL", estimated_value=0, created_by_user_id: int | None = None) -> MarketplaceLead:
|
||||
lead = MarketplaceLead(
|
||||
lead_no=generate_lead_no(db),
|
||||
source=source or "manual",
|
||||
service_category=service_category or None,
|
||||
service_requested=(service_requested or "General enquiry").strip(),
|
||||
lead_name=lead_name.strip(),
|
||||
business_name=(business_name or None),
|
||||
email=(email or None),
|
||||
mobile=(mobile or None),
|
||||
city=(city or None),
|
||||
state=(state or None),
|
||||
message=(message or None),
|
||||
priority=priority if priority in LEAD_PRIORITIES else "NORMAL",
|
||||
estimated_value=money(estimated_value),
|
||||
created_by_user_id=created_by_user_id,
|
||||
)
|
||||
db.add(lead)
|
||||
db.commit()
|
||||
db.refresh(lead)
|
||||
return lead
|
||||
|
||||
|
||||
def assign_marketplace_lead(db, *, lead: MarketplaceLead, tenant_id: int, branch_id: int | None, partner_user_id: int | None, notes: str | None, assigned_by_user_id: int | None) -> MarketplaceLead:
|
||||
now = datetime.now(timezone.utc)
|
||||
lead.assigned_tenant_id = tenant_id
|
||||
lead.assigned_branch_id = branch_id or None
|
||||
lead.assigned_partner_user_id = partner_user_id or None
|
||||
lead.assigned_by_user_id = assigned_by_user_id
|
||||
lead.assigned_at_utc = now
|
||||
lead.status = "ASSIGNED"
|
||||
lead.updated_by_user_id = assigned_by_user_id
|
||||
db.add(MarketplaceLeadAssignment(lead_id=lead.id, tenant_id=tenant_id, branch_id=branch_id or None, partner_user_id=partner_user_id or None, notes=notes or None, assigned_by_user_id=assigned_by_user_id, assigned_at_utc=now))
|
||||
db.commit()
|
||||
db.refresh(lead)
|
||||
return lead
|
||||
|
||||
|
||||
def update_lead_status(db, *, lead: MarketplaceLead, status: str, user_id: int | None) -> MarketplaceLead:
|
||||
if status not in LEAD_STATUSES:
|
||||
raise ValueError("Invalid lead status")
|
||||
lead.status = status
|
||||
lead.updated_by_user_id = user_id
|
||||
db.commit()
|
||||
db.refresh(lead)
|
||||
return lead
|
||||
|
||||
|
||||
def convert_lead_to_client(db, *, lead: MarketplaceLead, tenant_id: int, branch_id: int, partner_user_id: int | None, client_code: str | None, user_id: int | None) -> Client:
|
||||
if lead.converted_client_id:
|
||||
existing = db.get(Client, lead.converted_client_id)
|
||||
if existing:
|
||||
return existing
|
||||
code = (client_code or f"LEAD-{lead.id:05d}").strip().upper()
|
||||
name = (lead.business_name or lead.lead_name).strip()
|
||||
client = Client(
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
partner_id=partner_user_id or None,
|
||||
engagement_mode="internal_managed",
|
||||
client_code=code,
|
||||
client_name=name,
|
||||
trade_name=lead.business_name or None,
|
||||
client_type="Other",
|
||||
contact_person_name=lead.lead_name,
|
||||
mobile=lead.mobile,
|
||||
email=lead.email,
|
||||
city=lead.city,
|
||||
state=lead.state,
|
||||
status="active",
|
||||
is_active=True,
|
||||
is_archived=False,
|
||||
notes=f"Converted from marketplace lead {lead.lead_no}.\n\n{lead.message or ''}".strip(),
|
||||
created_by_user_id=user_id,
|
||||
updated_by_user_id=user_id,
|
||||
)
|
||||
db.add(client)
|
||||
db.flush()
|
||||
lead.converted_client_id = client.id
|
||||
lead.converted_by_user_id = user_id
|
||||
lead.converted_at_utc = datetime.now(timezone.utc)
|
||||
lead.status = "CONVERTED"
|
||||
lead.updated_by_user_id = user_id
|
||||
db.commit()
|
||||
db.refresh(client)
|
||||
return client
|
||||
|
||||
|
||||
def get_marketplace_overview_counts(db) -> dict[str, int]:
|
||||
"""Return lightweight public marketplace counts.
|
||||
|
||||
This is intentionally read-only and tolerant of partially configured data so the
|
||||
public marketplace page never breaks the ERP login/runtime flow.
|
||||
"""
|
||||
try:
|
||||
firm_count = db.execute(select(func.count(Tenant.id)).where(Tenant.is_active.is_(True))).scalar_one() or 0
|
||||
except Exception:
|
||||
firm_count = 0
|
||||
try:
|
||||
consultant_count = db.execute(select(func.count(ConsultantProfile.id)).where(ConsultantProfile.is_active.is_(True))).scalar_one() or 0
|
||||
except Exception:
|
||||
consultant_count = 0
|
||||
try:
|
||||
lead_count = db.execute(select(func.count(MarketplaceLead.id))).scalar_one() or 0
|
||||
except Exception:
|
||||
lead_count = 0
|
||||
return {"audit_firms": int(firm_count), "consultants": int(consultant_count), "service_requests": int(lead_count)}
|
||||
|
||||
|
||||
def list_public_marketplace_audit_firms(db, limit: int = 12) -> list[Tenant]:
|
||||
"""List active audit firms for public marketplace display.
|
||||
|
||||
More advanced publication controls can be added later. For Phase 7T.4 this uses
|
||||
only active tenants and does not expose private firm records beyond basic name/type.
|
||||
"""
|
||||
try:
|
||||
stmt = select(Tenant).where(Tenant.is_active.is_(True)).order_by(Tenant.name.asc()).limit(int(limit or 12))
|
||||
return list(db.execute(stmt).scalars().all())
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def list_public_marketplace_consultants(db, limit: int = 12) -> list[ConsultantProfile]:
|
||||
"""List active consultants for public marketplace display."""
|
||||
try:
|
||||
stmt = (
|
||||
select(ConsultantProfile)
|
||||
.where(ConsultantProfile.is_active.is_(True))
|
||||
.order_by(ConsultantProfile.contact_person.asc(), ConsultantProfile.firm_name.asc())
|
||||
.limit(int(limit or 12))
|
||||
)
|
||||
return list(db.execute(stmt).scalars().all())
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def is_marketplace_domain_request(request) -> bool:
|
||||
"""True when Phase 7T.2 resolved the host as a marketplace domain."""
|
||||
try:
|
||||
return getattr(request.state, "domain_type", None) == "marketplace"
|
||||
except Exception:
|
||||
return False
|
||||
@@ -0,0 +1,35 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-slate-900">Marketplace Leads</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Public/service leads that can be assigned to Audit Firms and converted into clients.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/marketplace/leads" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm">View Leads</a>
|
||||
{% if can_create %}<a href="/marketplace/leads/new" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-soft">New Lead</a>{% endif %}
|
||||
<a href="/marketplace/public-lead" class="rounded-xl border border-brand-200 bg-white px-4 py-2 text-sm font-semibold text-brand-700 shadow-sm">Public Lead Form</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-sm text-slate-500">Visible Leads</div><div class="mt-2 text-3xl font-semibold">{{ total }}</div></div>
|
||||
<a href="/marketplace/leads?status=NEW" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft"><div class="text-sm text-slate-500">New</div><div class="mt-2 text-3xl font-semibold">{{ leads|selectattr('status','equalto','NEW')|list|length }}</div></a>
|
||||
<a href="/marketplace/leads?status=ASSIGNED" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft"><div class="text-sm text-slate-500">Assigned</div><div class="mt-2 text-3xl font-semibold">{{ leads|selectattr('status','equalto','ASSIGNED')|list|length }}</div></a>
|
||||
<a href="/marketplace/leads?status=CONVERTED" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft"><div class="text-sm text-slate-500">Converted</div><div class="mt-2 text-3xl font-semibold">{{ leads|selectattr('status','equalto','CONVERTED')|list|length }}</div></a>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<h2 class="font-semibold">Recent Leads</h2>
|
||||
<div class="mt-4 overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead><tr class="border-b text-left text-slate-500"><th class="py-2">Lead</th><th>Service</th><th>Status</th><th>Contact</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in leads %}
|
||||
<tr class="border-b last:border-0"><td class="py-2 font-medium">{{ row.lead_no }}<div class="text-xs text-slate-500">{{ row.lead_name }}</div></td><td>{{ row.service_requested }}</td><td>{{ row.status }}</td><td>{{ row.mobile or row.email or '-' }}</td><td><a href="/marketplace/leads/{{ row.id }}" class="text-brand-700">Open</a></td></tr>
|
||||
{% else %}<tr><td colspan="5" class="py-6 text-center text-slate-500">No leads found.</td></tr>{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,61 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div><h1 class="text-2xl font-semibold">{{ lead.lead_no }} - {{ lead.lead_name }}</h1><p class="text-sm text-slate-500">{{ lead.service_requested }} • {{ lead.status }}</p></div>
|
||||
<a href="/marketplace/leads" class="rounded-xl border px-4 py-2 text-sm">Back</a>
|
||||
</div>
|
||||
<div class="grid gap-5 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2 rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<h2 class="font-semibold">Lead Details</h2>
|
||||
<dl class="mt-4 grid gap-3 text-sm md:grid-cols-2">
|
||||
<div><dt class="text-slate-500">Business</dt><dd class="font-medium">{{ lead.business_name or '-' }}</dd></div>
|
||||
<div><dt class="text-slate-500">Contact</dt><dd>{{ lead.mobile or '-' }} / {{ lead.email or '-' }}</dd></div>
|
||||
<div><dt class="text-slate-500">Location</dt><dd>{{ lead.city or '-' }}, {{ lead.state or '-' }}</dd></div>
|
||||
<div><dt class="text-slate-500">Category</dt><dd>{{ lead.service_category or '-' }}</dd></div>
|
||||
<div><dt class="text-slate-500">Priority</dt><dd>{{ lead.priority }}</dd></div>
|
||||
<div><dt class="text-slate-500">Estimated Value</dt><dd>{{ lead.estimated_value }}</dd></div>
|
||||
<div><dt class="text-slate-500">Assigned Audit Firm ID</dt><dd>{{ lead.assigned_tenant_id or '-' }}</dd></div>
|
||||
<div><dt class="text-slate-500">Assigned Partner User ID</dt><dd>{{ lead.assigned_partner_user_id or '-' }}</dd></div>
|
||||
<div class="md:col-span-2"><dt class="text-slate-500">Message</dt><dd class="whitespace-pre-line">{{ lead.message or '-' }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
{% if can_update %}
|
||||
<form method="post" action="/marketplace/leads/{{ lead.id }}/status" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<h2 class="font-semibold">Update Status</h2>
|
||||
<select name="status" class="mt-3 w-full rounded-xl border px-3 py-2 text-sm">{% for s in lead_statuses %}<option value="{{ s }}" {% if lead.status==s %}selected{% endif %}>{{ s }}</option>{% endfor %}</select>
|
||||
<button class="mt-3 w-full rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Update</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if can_assign %}
|
||||
<form method="post" action="/marketplace/leads/{{ lead.id }}/assign" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<h2 class="font-semibold">Assign Lead</h2>
|
||||
<label class="mt-3 block text-sm">Audit Firm<select name="tenant_id" required class="mt-1 w-full rounded-xl border px-3 py-2"><option value="">Select</option>{% for t in audit_firms %}<option value="{{ t.id }}" {% if lead.assigned_tenant_id==t.id %}selected{% endif %}>{{ t.name }}</option>{% endfor %}</select></label>
|
||||
<label class="mt-3 block text-sm">Branch<select name="branch_id" class="mt-1 w-full rounded-xl border px-3 py-2"><option value="0">Optional</option>{% for b in branches %}<option value="{{ b.id }}" {% if lead.assigned_branch_id==b.id %}selected{% endif %}>{{ b.name }}</option>{% endfor %}</select></label>
|
||||
<label class="mt-3 block text-sm">Partner/User<select name="partner_user_id" class="mt-1 w-full rounded-xl border px-3 py-2"><option value="0">Optional</option>{% for p in partners %}<option value="{{ p.id }}" {% if lead.assigned_partner_user_id==p.id %}selected{% endif %}>{{ p.full_name or p.email }}</option>{% endfor %}</select></label>
|
||||
<label class="mt-3 block text-sm">Notes<textarea name="notes" rows="2" class="mt-1 w-full rounded-xl border px-3 py-2"></textarea></label>
|
||||
<button class="mt-3 w-full rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Assign</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if can_convert and not lead.converted_client_id and lead.assigned_tenant_id %}
|
||||
<form method="post" action="/marketplace/leads/{{ lead.id }}/convert-client" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<h2 class="font-semibold">Convert to Client</h2>
|
||||
<input type="hidden" name="tenant_id" value="{{ lead.assigned_tenant_id or '' }}">
|
||||
<label class="mt-3 block text-sm">Branch ID<input name="branch_id" value="{{ lead.assigned_branch_id or '' }}" required class="mt-1 w-full rounded-xl border px-3 py-2"></label>
|
||||
<label class="mt-3 block text-sm">Partner User ID<input name="partner_user_id" value="{{ lead.assigned_partner_user_id or '' }}" class="mt-1 w-full rounded-xl border px-3 py-2"></label>
|
||||
<label class="mt-3 block text-sm">Client Code<input name="client_code" value="LEAD-{{ '%05d'|format(lead.id) }}" class="mt-1 w-full rounded-xl border px-3 py-2"></label>
|
||||
<button class="mt-3 w-full rounded-xl border border-brand-200 bg-white px-4 py-2 text-sm font-semibold text-brand-700">Create Client</button>
|
||||
</form>
|
||||
{% elif can_convert and not lead.converted_client_id and not lead.assigned_tenant_id %}
|
||||
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-5 text-sm text-amber-800">Assign this lead to an Audit Firm before converting it to a client.</div>
|
||||
{% elif lead.converted_client_id %}
|
||||
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-5 text-sm text-emerald-800">Converted to Client ID {{ lead.converted_client_id }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-3xl rounded-2xl border border-slate-200 bg-white p-6 shadow-soft">
|
||||
<h1 class="text-xl font-semibold">New Marketplace Lead</h1>
|
||||
<form method="post" class="mt-5 grid gap-4 md:grid-cols-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label class="text-sm">Contact Name<input name="lead_name" required class="mt-1 w-full rounded-xl border px-3 py-2"></label>
|
||||
<label class="text-sm">Business Name<input name="business_name" class="mt-1 w-full rounded-xl border px-3 py-2"></label>
|
||||
<label class="text-sm">Email<input name="email" type="email" class="mt-1 w-full rounded-xl border px-3 py-2"></label>
|
||||
<label class="text-sm">Mobile<input name="mobile" class="mt-1 w-full rounded-xl border px-3 py-2"></label>
|
||||
<label class="text-sm">City<input name="city" class="mt-1 w-full rounded-xl border px-3 py-2"></label>
|
||||
<label class="text-sm">State<input name="state" class="mt-1 w-full rounded-xl border px-3 py-2"></label>
|
||||
<label class="text-sm">Category<select name="service_category" class="mt-1 w-full rounded-xl border px-3 py-2"><option value="">Select</option>{% for c in service_categories %}<option value="{{ c }}">{{ c }}</option>{% endfor %}</select></label>
|
||||
<label class="text-sm">Service Requested<input name="service_requested" required placeholder="GST registration, ITR filing, Tax audit..." class="mt-1 w-full rounded-xl border px-3 py-2"></label>
|
||||
<label class="text-sm">Source<select name="source" class="mt-1 w-full rounded-xl border px-3 py-2">{% for s in lead_sources %}<option value="{{ s }}">{{ s }}</option>{% endfor %}</select></label>
|
||||
<label class="text-sm">Priority<select name="priority" class="mt-1 w-full rounded-xl border px-3 py-2">{% for p in lead_priorities %}<option value="{{ p }}" {% if p=='NORMAL' %}selected{% endif %}>{{ p }}</option>{% endfor %}</select></label>
|
||||
<label class="text-sm">Estimated Value<input name="estimated_value" value="0" class="mt-1 w-full rounded-xl border px-3 py-2"></label>
|
||||
<label class="text-sm md:col-span-2">Message<textarea name="message" rows="4" class="mt-1 w-full rounded-xl border px-3 py-2"></textarea></label>
|
||||
<div class="md:col-span-2 flex gap-2"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Save Lead</button><a href="/marketplace/leads" class="rounded-xl border px-4 py-2 text-sm">Cancel</a></div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,24 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-5">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div><h1 class="text-2xl font-semibold">Marketplace Leads</h1><p class="text-sm text-slate-500">Manage public leads and lead assignments.</p></div>
|
||||
{% if can_create %}<a href="/marketplace/leads/new" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-soft">New Lead</a>{% endif %}
|
||||
</div>
|
||||
<form method="get" class="flex flex-wrap gap-2 rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<input name="q" value="{{ q }}" placeholder="Search lead, contact, service" class="min-w-64 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 Status</option>{% for s in lead_statuses %}<option value="{{ s }}" {% if selected_status == s %}selected{% endif %}>{{ s }}</option>{% endfor %}</select>
|
||||
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium">Filter</button>
|
||||
</form>
|
||||
<div class="overflow-x-auto rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead><tr class="border-b bg-slate-50 text-left text-slate-600"><th class="px-4 py-3">Lead No</th><th>Lead</th><th>Service</th><th>Status</th><th>Priority</th><th>Assigned Audit Firm ID</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr class="border-b last:border-0"><td class="px-4 py-3 font-medium">{{ row.lead_no }}</td><td>{{ row.lead_name }}<div class="text-xs text-slate-500">{{ row.business_name or '' }}</div></td><td>{{ row.service_requested }}</td><td>{{ row.status }}</td><td>{{ row.priority }}</td><td>{{ row.assigned_tenant_id or '-' }}</td><td><a href="/marketplace/leads/{{ row.id }}" class="text-brand-700">Open</a></td></tr>
|
||||
{% else %}<tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">No leads found.</td></tr>{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,20 @@
|
||||
{% extends "modules/marketplace/templates/marketplace/public_base.html" %}
|
||||
{% block public_content %}
|
||||
<section class="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div><h1 class="text-3xl font-bold">Audit Firms</h1><p class="mt-2 text-slate-600">Active audit firms available in the marketplace.</p></div>
|
||||
<a href="/request-service" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Request Service</a>
|
||||
</div>
|
||||
<div class="mt-8 grid gap-5 md:grid-cols-2 lg:grid-cols-3">
|
||||
{% for firm in public_audit_firms %}
|
||||
<article class="rounded-3xl border border-slate-200 bg-white p-6 shadow-soft">
|
||||
<div class="text-lg font-bold">{{ firm.name }}</div>
|
||||
<div class="mt-1 text-sm text-slate-500">{{ firm.firm_type|replace('_',' ')|title if firm.firm_type else 'Audit Firm' }}</div>
|
||||
<div class="mt-4 rounded-2xl bg-slate-50 p-3 text-xs text-slate-500">Service enquiry can be routed by the marketplace team.</div>
|
||||
</article>
|
||||
{% else %}
|
||||
<div class="col-span-full rounded-3xl border border-dashed border-slate-300 bg-white p-8 text-center text-slate-500">No public audit firm listing is available yet.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,68 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
{% set brand = get_current_firm_branding(request, none) %}
|
||||
<title>{{ title or 'Marketplace' }} | {{ brand.firm_name or 'FilingABC' }}</title>
|
||||
{% if brand.favicon_url %}<link rel="icon" href="{{ brand.favicon_url }}" />{% endif %}
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="/static/css/theme_tokens.css?v=7t4-marketplace" />
|
||||
<style>
|
||||
:root {
|
||||
--af-color-brand-500: {{ brand.primary_color or '#2563eb' }};
|
||||
--af-color-brand-600: {{ brand.primary_color or '#1d4ed8' }};
|
||||
--af-color-brand-700: {{ brand.accent_color or '#1e40af' }};
|
||||
--af-color-brand-800: {{ brand.accent_color or '#1e3a8a' }};
|
||||
--af-color-brand-900: {{ brand.accent_color or '#172554' }};
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-50 text-slate-900">
|
||||
<header class="sticky top-0 z-30 border-b border-slate-200 bg-white/90 backdrop-blur">
|
||||
<div class="mx-auto flex max-w-7xl items-center justify-between px-4 py-4 sm:px-6 lg:px-8">
|
||||
<a href="/" class="flex items-center gap-3">
|
||||
{% if brand.logo_url %}
|
||||
<img src="{{ brand.logo_url }}" alt="{{ brand.firm_name }}" class="h-10 w-10 rounded-2xl object-contain" />
|
||||
{% else %}
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-2xl bg-brand-600 text-sm font-bold text-white">FA</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<div class="text-sm font-bold tracking-wide text-slate-900">{{ brand.firm_name or 'FilingABC' }}</div>
|
||||
<div class="text-xs text-slate-500">Marketplace + SaaS</div>
|
||||
</div>
|
||||
</a>
|
||||
<nav class="hidden items-center gap-5 text-sm font-medium text-slate-600 md:flex">
|
||||
<a href="/audit-firms" class="hover:text-brand-700">Audit Firms</a>
|
||||
<a href="/consultants" class="hover:text-brand-700">Consultants</a>
|
||||
<a href="/request-service" class="hover:text-brand-700">Request Service</a>
|
||||
</nav>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="/login" class="rounded-xl border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Login</a>
|
||||
<a href="/request-service" class="hidden rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-brand-700 sm:inline-flex">Get Help</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{% block public_content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="border-t border-slate-200 bg-white">
|
||||
<div class="mx-auto grid max-w-7xl gap-6 px-4 py-8 text-sm text-slate-500 sm:px-6 md:grid-cols-3 lg:px-8">
|
||||
<div>
|
||||
<div class="font-semibold text-slate-800">{{ brand.firm_name or 'FilingABC' }}</div>
|
||||
<p class="mt-2">A marketplace layer for audit firms, consultants and clients.</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-slate-800">Quick Links</div>
|
||||
<div class="mt-2 space-y-1"><a href="/audit-firms" class="block hover:text-brand-700">Audit Firms</a><a href="/consultants" class="block hover:text-brand-700">Consultants</a><a href="/request-service" class="block hover:text-brand-700">Request a Service</a></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-slate-800">Contact</div>
|
||||
<div class="mt-2 space-y-1">{% if brand.contact_email %}<div>{{ brand.contact_email }}</div>{% endif %}{% if brand.contact_mobile %}<div>{{ brand.contact_mobile }}</div>{% endif %}<div>{{ brand.domain_name or request.url.hostname }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,20 @@
|
||||
{% extends "modules/marketplace/templates/marketplace/public_base.html" %}
|
||||
{% block public_content %}
|
||||
<section class="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div><h1 class="text-3xl font-bold">Consultants</h1><p class="mt-2 text-slate-600">Bookkeeping, filing, advisory and referral partners.</p></div>
|
||||
<a href="/request-service" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Request Service</a>
|
||||
</div>
|
||||
<div class="mt-8 grid gap-5 md:grid-cols-2 lg:grid-cols-3">
|
||||
{% for consultant in public_consultants %}
|
||||
<article class="rounded-3xl border border-slate-200 bg-white p-6 shadow-soft">
|
||||
<div class="text-lg font-bold">{{ consultant.firm_name or consultant.contact_person }}</div>
|
||||
<div class="mt-1 text-sm text-slate-500">{{ consultant.specialisation or consultant.consultant_type|replace('_',' ')|title }}</div>
|
||||
{% if consultant.mobile or consultant.email %}<div class="mt-4 space-y-1 text-xs text-slate-500">{% if consultant.mobile %}<div>{{ consultant.mobile }}</div>{% endif %}{% if consultant.email %}<div>{{ consultant.email }}</div>{% endif %}</div>{% endif %}
|
||||
</article>
|
||||
{% else %}
|
||||
<div class="col-span-full rounded-3xl border border-dashed border-slate-300 bg-white p-8 text-center text-slate-500">No public consultant listing is available yet.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,20 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-3xl rounded-2xl border border-slate-200 bg-white p-6 shadow-soft">
|
||||
<h1 class="text-2xl font-semibold">Request Professional Service</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Submit your requirement. The platform team will review and assign it to a suitable Audit Firm.</p>
|
||||
<form method="post" class="mt-5 grid gap-4 md:grid-cols-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label class="text-sm">Your Name<input name="lead_name" required class="mt-1 w-full rounded-xl border px-3 py-2"></label>
|
||||
<label class="text-sm">Business Name<input name="business_name" class="mt-1 w-full rounded-xl border px-3 py-2"></label>
|
||||
<label class="text-sm">Email<input name="email" type="email" class="mt-1 w-full rounded-xl border px-3 py-2"></label>
|
||||
<label class="text-sm">Mobile<input name="mobile" required class="mt-1 w-full rounded-xl border px-3 py-2"></label>
|
||||
<label class="text-sm">City<input name="city" class="mt-1 w-full rounded-xl border px-3 py-2"></label>
|
||||
<label class="text-sm">State<input name="state" class="mt-1 w-full rounded-xl border px-3 py-2"></label>
|
||||
<label class="text-sm">Category<select name="service_category" class="mt-1 w-full rounded-xl border px-3 py-2"><option value="">Select</option>{% for c in service_categories %}<option value="{{ c }}">{{ c }}</option>{% endfor %}</select></label>
|
||||
<label class="text-sm">Service Required<input name="service_requested" required class="mt-1 w-full rounded-xl border px-3 py-2"></label>
|
||||
<label class="text-sm md:col-span-2">Message<textarea name="message" rows="4" class="mt-1 w-full rounded-xl border px-3 py-2"></textarea></label>
|
||||
<div class="md:col-span-2"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Submit Request</button></div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,61 @@
|
||||
{% extends "modules/marketplace/templates/marketplace/public_base.html" %}
|
||||
{% block public_content %}
|
||||
<section class="bg-gradient-to-br from-slate-950 via-slate-900 to-brand-900 text-white">
|
||||
<div class="mx-auto grid max-w-7xl gap-10 px-4 py-16 sm:px-6 lg:grid-cols-[1.15fr_0.85fr] lg:px-8 lg:py-24">
|
||||
<div>
|
||||
<div class="inline-flex rounded-full border border-white/15 bg-white/10 px-4 py-1 text-xs font-semibold uppercase tracking-[0.24em] text-brand-100">FilingABC Marketplace</div>
|
||||
<h1 class="mt-6 max-w-3xl text-4xl font-bold tracking-tight sm:text-5xl">Find audit, GST, income tax, ROC and accounting support from trusted professionals.</h1>
|
||||
<p class="mt-5 max-w-2xl text-base leading-8 text-slate-200">Clients can request services, consultants can generate leads, and the platform team can route work to the right audit firm or partner.</p>
|
||||
<div class="mt-8 flex flex-wrap gap-3">
|
||||
<a href="/request-service" class="rounded-2xl bg-white px-5 py-3 text-sm font-bold text-slate-900 shadow-soft">Request Professional Service</a>
|
||||
<a href="/audit-firms" class="rounded-2xl border border-white/20 px-5 py-3 text-sm font-bold text-white hover:bg-white/10">Browse Audit Firms</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-3xl border border-white/10 bg-white/10 p-6 shadow-2xl backdrop-blur">
|
||||
<div class="grid gap-4 sm:grid-cols-3 lg:grid-cols-1">
|
||||
<div class="rounded-2xl bg-white/10 p-5"><div class="text-3xl font-bold">{{ marketplace_counts.audit_firms }}</div><div class="mt-1 text-sm text-slate-200">Audit firms</div></div>
|
||||
<div class="rounded-2xl bg-white/10 p-5"><div class="text-3xl font-bold">{{ marketplace_counts.consultants }}</div><div class="mt-1 text-sm text-slate-200">Consultants</div></div>
|
||||
<div class="rounded-2xl bg-white/10 p-5"><div class="text-3xl font-bold">{{ marketplace_counts.service_requests }}</div><div class="mt-1 text-sm text-slate-200">Service requests</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div class="grid gap-6 md:grid-cols-3">
|
||||
{% for item in [
|
||||
('GST & Tax Compliance', 'GSTR filing, notices, reconciliation, tax return support and advisory.'),
|
||||
('Audit & Assurance', 'Statutory audit, internal audit, tax audit and engagement tracking.'),
|
||||
('ROC & Business Services', 'Company filings, registrations, payroll, accounting and compliance calendars.')
|
||||
] %}
|
||||
<div class="rounded-3xl border border-slate-200 bg-white p-6 shadow-soft">
|
||||
<h2 class="text-lg font-bold text-slate-900">{{ item[0] }}</h2>
|
||||
<p class="mt-3 text-sm leading-6 text-slate-600">{{ item[1] }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mx-auto grid max-w-7xl gap-8 px-4 pb-16 sm:px-6 lg:grid-cols-2 lg:px-8">
|
||||
<div class="rounded-3xl border border-slate-200 bg-white p-6 shadow-soft">
|
||||
<div class="flex items-center justify-between"><h2 class="text-xl font-bold">Featured Audit Firms</h2><a href="/audit-firms" class="text-sm font-semibold text-brand-700">View all</a></div>
|
||||
<div class="mt-5 space-y-3">
|
||||
{% for firm in public_audit_firms %}
|
||||
<div class="rounded-2xl border border-slate-100 bg-slate-50 p-4"><div class="font-semibold">{{ firm.name }}</div><div class="text-xs text-slate-500">{{ firm.firm_type|replace('_',' ')|title if firm.firm_type else 'Audit Firm' }}</div></div>
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-dashed border-slate-300 p-4 text-sm text-slate-500">Audit firm listings will appear here after activation.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-3xl border border-slate-200 bg-white p-6 shadow-soft">
|
||||
<div class="flex items-center justify-between"><h2 class="text-xl font-bold">Consultant Network</h2><a href="/consultants" class="text-sm font-semibold text-brand-700">View all</a></div>
|
||||
<div class="mt-5 space-y-3">
|
||||
{% for consultant in public_consultants %}
|
||||
<div class="rounded-2xl border border-slate-100 bg-slate-50 p-4"><div class="font-semibold">{{ consultant.firm_name or consultant.contact_person }}</div><div class="text-xs text-slate-500">{{ consultant.specialisation or consultant.consultant_type|replace('_',' ')|title }}</div></div>
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-dashed border-slate-300 p-4 text-sm text-slate-500">Consultant listings will appear here after activation.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends "modules/marketplace/templates/marketplace/public_base.html" %}
|
||||
{% block public_content %}
|
||||
<section class="mx-auto max-w-4xl px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div class="rounded-3xl border border-slate-200 bg-white p-6 shadow-soft sm:p-8">
|
||||
<h1 class="text-3xl font-bold">Request Professional Service</h1>
|
||||
<p class="mt-2 text-sm text-slate-600">Submit your requirement. The marketplace team will review and route it to the right audit firm or consultant.</p>
|
||||
<form method="post" class="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label class="text-sm font-medium">Your Name<input name="lead_name" required class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></label>
|
||||
<label class="text-sm font-medium">Business Name<input name="business_name" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></label>
|
||||
<label class="text-sm font-medium">Email<input name="email" type="email" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></label>
|
||||
<label class="text-sm font-medium">Mobile<input name="mobile" required class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></label>
|
||||
<label class="text-sm font-medium">City<input name="city" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></label>
|
||||
<label class="text-sm font-medium">State<input name="state" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></label>
|
||||
<label class="text-sm font-medium">Category<select name="service_category" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"><option value="">Select</option>{% for c in service_categories %}<option value="{{ c }}">{{ c }}</option>{% endfor %}</select></label>
|
||||
<label class="text-sm font-medium">Service Required<input name="service_requested" required placeholder="GST notice reply, ITR filing, audit..." class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></label>
|
||||
<label class="text-sm font-medium md:col-span-2">Message<textarea name="message" rows="4" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></textarea></label>
|
||||
<div class="md:col-span-2"><button class="rounded-xl bg-brand-600 px-5 py-3 text-sm font-bold text-white shadow-soft">Submit Request</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,12 @@
|
||||
{% extends "modules/marketplace/templates/marketplace/public_base.html" %}
|
||||
{% block public_content %}
|
||||
<section class="mx-auto max-w-2xl px-4 py-16 sm:px-6 lg:px-8">
|
||||
<div class="rounded-3xl border border-emerald-200 bg-white p-8 text-center shadow-soft">
|
||||
<div class="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100 text-2xl text-emerald-700">✓</div>
|
||||
<h1 class="mt-5 text-3xl font-bold">Thank you</h1>
|
||||
<p class="mt-3 text-slate-600">Your service request has been received by the marketplace team.</p>
|
||||
<p class="mt-4 rounded-2xl bg-slate-50 px-4 py-3 text-sm text-slate-600">Reference: <span class="font-bold text-slate-900">{{ lead.lead_no }}</span></p>
|
||||
<div class="mt-6"><a href="/" class="rounded-xl bg-brand-600 px-5 py-3 text-sm font-bold text-white">Back to Home</a></div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,8 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-xl rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-soft">
|
||||
<h1 class="text-2xl font-semibold">Thank you</h1>
|
||||
<p class="mt-3 text-slate-600">Your service request has been received.</p>
|
||||
<p class="mt-2 text-sm text-slate-500">Reference: <span class="font-semibold text-slate-800">{{ lead.lead_no }}</span></p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,343 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from app.core.db.common import CommonSessionLocal
|
||||
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
|
||||
from app.core.security.session_auth import get_current_user
|
||||
from app.core.templating import templates
|
||||
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
||||
from app.modules.core.rbac.permission_guard import require_permission
|
||||
from app.modules.marketplace.services import (
|
||||
LEAD_PRIORITIES,
|
||||
LEAD_SOURCES,
|
||||
LEAD_STATUSES,
|
||||
SERVICE_CATEGORIES,
|
||||
assign_marketplace_lead,
|
||||
convert_lead_to_client,
|
||||
create_marketplace_lead,
|
||||
get_marketplace_lead,
|
||||
get_marketplace_overview_counts,
|
||||
is_marketplace_domain_request,
|
||||
list_marketplace_leads,
|
||||
list_public_marketplace_audit_firms,
|
||||
list_public_marketplace_consultants,
|
||||
list_reference_audit_firms,
|
||||
list_reference_branches,
|
||||
list_reference_partners,
|
||||
update_lead_status,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/marketplace", tags=["marketplace-ui"])
|
||||
public_router = APIRouter(tags=["marketplace-public-ui"])
|
||||
|
||||
|
||||
|
||||
|
||||
def _public_marketplace_ctx(request: Request, db, **ctx):
|
||||
base = {
|
||||
"request": request,
|
||||
"current_user": None,
|
||||
"current_user_roles": [],
|
||||
"current_user_permissions": [],
|
||||
"csrf_token": get_or_create_csrf_token(request),
|
||||
"service_categories": SERVICE_CATEGORIES,
|
||||
"lead_priorities": LEAD_PRIORITIES,
|
||||
"lead_sources": LEAD_SOURCES,
|
||||
"marketplace_counts": get_marketplace_overview_counts(db),
|
||||
"public_audit_firms": list_public_marketplace_audit_firms(db, limit=8),
|
||||
"public_consultants": list_public_marketplace_consultants(db, limit=8),
|
||||
}
|
||||
base.update(ctx)
|
||||
return base
|
||||
|
||||
|
||||
def _render_public_marketplace(request: Request, template: str, db, **ctx):
|
||||
return templates.TemplateResponse(template, _public_marketplace_ctx(request, db, **ctx))
|
||||
|
||||
|
||||
@public_router.get("/")
|
||||
def marketplace_domain_home(request: Request):
|
||||
"""Marketplace domain landing page.
|
||||
|
||||
Only marketplace-mapped domains use this as the public home page. Normal localhost,
|
||||
tenant domains and consultant domains continue to the normal login flow.
|
||||
"""
|
||||
if not is_marketplace_domain_request(request):
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
return _render_public_marketplace(
|
||||
request,
|
||||
"modules/marketplace/templates/marketplace/public_marketplace_home.html",
|
||||
db,
|
||||
title="FilingABC Marketplace",
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@public_router.get("/audit-firms")
|
||||
def marketplace_public_audit_firms(request: Request):
|
||||
if not is_marketplace_domain_request(request):
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
return _render_public_marketplace(
|
||||
request,
|
||||
"modules/marketplace/templates/marketplace/public_audit_firms.html",
|
||||
db,
|
||||
title="Audit Firms",
|
||||
public_audit_firms=list_public_marketplace_audit_firms(db, limit=50),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@public_router.get("/consultants")
|
||||
def marketplace_public_consultants(request: Request):
|
||||
if not is_marketplace_domain_request(request):
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
return _render_public_marketplace(
|
||||
request,
|
||||
"modules/marketplace/templates/marketplace/public_consultants.html",
|
||||
db,
|
||||
title="Consultants",
|
||||
public_consultants=list_public_marketplace_consultants(db, limit=50),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@public_router.get("/request-service")
|
||||
def marketplace_public_request_service(request: Request):
|
||||
if not is_marketplace_domain_request(request):
|
||||
return RedirectResponse(url="/marketplace/public-lead", status_code=303)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
return _render_public_marketplace(
|
||||
request,
|
||||
"modules/marketplace/templates/marketplace/public_marketplace_lead_form.html",
|
||||
db,
|
||||
title="Request a Service",
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@public_router.post("/request-service")
|
||||
def marketplace_public_request_service_submit(request: Request, csrf_token: str = Form(...), lead_name: str = Form(...), business_name: str = Form(""), email: str = Form(""), mobile: str = Form(""), city: str = Form(""), state: str = Form(""), service_category: str = Form(""), service_requested: str = Form(...), message: str = Form("")):
|
||||
if not is_marketplace_domain_request(request):
|
||||
return RedirectResponse(url="/marketplace/public-lead", status_code=303)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
validate_csrf(request, csrf_token)
|
||||
lead = create_marketplace_lead(
|
||||
db,
|
||||
lead_name=lead_name,
|
||||
business_name=business_name,
|
||||
email=email,
|
||||
mobile=mobile,
|
||||
city=city,
|
||||
state=state,
|
||||
service_category=service_category,
|
||||
service_requested=service_requested,
|
||||
message=message,
|
||||
source="public_website",
|
||||
priority="NORMAL",
|
||||
)
|
||||
return _render_public_marketplace(
|
||||
request,
|
||||
"modules/marketplace/templates/marketplace/public_marketplace_thank_you.html",
|
||||
db,
|
||||
title="Thank You",
|
||||
lead=lead,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _redirect_denied():
|
||||
return RedirectResponse(url="/system-settings", status_code=303)
|
||||
|
||||
|
||||
def _has_perm(db, user, code: str) -> bool:
|
||||
try:
|
||||
require_permission(db, user, code)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _require_user(request: Request, db, permission_code: str):
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return None, RedirectResponse(url="/login", status_code=303)
|
||||
try:
|
||||
require_permission(db, user, permission_code)
|
||||
except Exception:
|
||||
# Assigned lead users are allowed if they have assigned-lead permission.
|
||||
if permission_code == "marketplace_leads.view" and _has_perm(db, user, "marketplace_leads.view_assigned"):
|
||||
return user, None
|
||||
return user, _redirect_denied()
|
||||
return user, None
|
||||
|
||||
|
||||
def _base_ctx(request: Request, db, user=None, **ctx):
|
||||
base = {
|
||||
"request": request,
|
||||
"current_user": user,
|
||||
"current_user_roles": get_user_roles(db, user.id) if user else [],
|
||||
"current_user_permissions": get_user_permissions(db, user.id) if user else [],
|
||||
"csrf_token": get_or_create_csrf_token(request),
|
||||
"lead_statuses": LEAD_STATUSES,
|
||||
"lead_priorities": LEAD_PRIORITIES,
|
||||
"lead_sources": LEAD_SOURCES,
|
||||
"service_categories": SERVICE_CATEGORIES,
|
||||
}
|
||||
base.update(ctx)
|
||||
return base
|
||||
|
||||
|
||||
def _render(request: Request, template: str, db, user=None, **ctx):
|
||||
return templates.TemplateResponse(template, _base_ctx(request, db, user, **ctx))
|
||||
|
||||
|
||||
@router.get("")
|
||||
def dashboard(request: Request):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_user(request, db, "marketplace_leads.view")
|
||||
if response:
|
||||
return response
|
||||
roles = get_user_roles(db, user.id)
|
||||
leads = list_marketplace_leads(db, user=user, role_names=roles)
|
||||
return _render(request, "modules/marketplace/templates/marketplace/dashboard.html", db, user, title="Marketplace Leads", leads=leads[:8], total=len(leads), can_create=_has_perm(db, user, "marketplace_leads.create"), can_assign=_has_perm(db, user, "marketplace_leads.assign"))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/leads")
|
||||
def leads_list(request: Request, q: str = "", status: str = ""):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_user(request, db, "marketplace_leads.view")
|
||||
if response:
|
||||
return response
|
||||
roles = get_user_roles(db, user.id)
|
||||
return _render(request, "modules/marketplace/templates/marketplace/leads_list.html", db, user, title="Marketplace Leads", rows=list_marketplace_leads(db, q=q, status=status, user=user, role_names=roles), q=q, selected_status=status, can_create=_has_perm(db, user, "marketplace_leads.create"), can_assign=_has_perm(db, user, "marketplace_leads.assign"))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/leads/new")
|
||||
def lead_new(request: Request):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_user(request, db, "marketplace_leads.create")
|
||||
if response:
|
||||
return response
|
||||
return _render(request, "modules/marketplace/templates/marketplace/lead_form.html", db, user, title="New Marketplace Lead", public_mode=False)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/leads/new")
|
||||
def lead_create(request: Request, csrf_token: str = Form(...), lead_name: str = Form(...), business_name: str = Form(""), email: str = Form(""), mobile: str = Form(""), city: str = Form(""), state: str = Form(""), service_category: str = Form(""), service_requested: str = Form(...), message: str = Form(""), source: str = Form("manual"), priority: str = Form("NORMAL"), estimated_value: str = Form("0")):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_user(request, db, "marketplace_leads.create")
|
||||
if response:
|
||||
return response
|
||||
validate_csrf(request, csrf_token)
|
||||
lead = create_marketplace_lead(db, lead_name=lead_name, business_name=business_name, email=email, mobile=mobile, city=city, state=state, service_category=service_category, service_requested=service_requested, message=message, source=source, priority=priority, estimated_value=estimated_value, created_by_user_id=user.id)
|
||||
return RedirectResponse(url=f"/marketplace/leads/{lead.id}", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/public-lead")
|
||||
def public_lead_form(request: Request):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
return _render(request, "modules/marketplace/templates/marketplace/public_lead_form.html", db, None, title="Request a Service")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/public-lead")
|
||||
def public_lead_submit(request: Request, csrf_token: str = Form(...), lead_name: str = Form(...), business_name: str = Form(""), email: str = Form(""), mobile: str = Form(""), city: str = Form(""), state: str = Form(""), service_category: str = Form(""), service_requested: str = Form(...), message: str = Form("")):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
validate_csrf(request, csrf_token)
|
||||
lead = create_marketplace_lead(db, lead_name=lead_name, business_name=business_name, email=email, mobile=mobile, city=city, state=state, service_category=service_category, service_requested=service_requested, message=message, source="public_website", priority="NORMAL")
|
||||
return _render(request, "modules/marketplace/templates/marketplace/public_thank_you.html", db, None, title="Thank You", lead=lead)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/leads/{lead_id}")
|
||||
def lead_detail(request: Request, lead_id: int):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_user(request, db, "marketplace_leads.view")
|
||||
if response:
|
||||
return response
|
||||
lead = get_marketplace_lead(db, lead_id)
|
||||
if not lead:
|
||||
return RedirectResponse(url="/marketplace/leads", status_code=303)
|
||||
return _render(request, "modules/marketplace/templates/marketplace/lead_detail.html", db, user, title=f"Lead {lead.lead_no}", lead=lead, audit_firms=list_reference_audit_firms(db), branches=list_reference_branches(db, lead.assigned_tenant_id), partners=list_reference_partners(db, lead.assigned_tenant_id), can_assign=_has_perm(db, user, "marketplace_leads.assign"), can_update=_has_perm(db, user, "marketplace_leads.update"), can_convert=_has_perm(db, user, "marketplace_leads.convert"))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/leads/{lead_id}/assign")
|
||||
def lead_assign(request: Request, lead_id: int, csrf_token: str = Form(...), tenant_id: int = Form(...), branch_id: int = Form(0), partner_user_id: int = Form(0), notes: str = Form("")):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_user(request, db, "marketplace_leads.assign")
|
||||
if response:
|
||||
return response
|
||||
validate_csrf(request, csrf_token)
|
||||
lead = get_marketplace_lead(db, lead_id)
|
||||
if lead:
|
||||
assign_marketplace_lead(db, lead=lead, tenant_id=tenant_id, branch_id=branch_id or None, partner_user_id=partner_user_id or None, notes=notes, assigned_by_user_id=user.id)
|
||||
return RedirectResponse(url=f"/marketplace/leads/{lead_id}", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/leads/{lead_id}/status")
|
||||
def lead_status_update(request: Request, lead_id: int, csrf_token: str = Form(...), status: str = Form(...)):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_user(request, db, "marketplace_leads.update")
|
||||
if response:
|
||||
return response
|
||||
validate_csrf(request, csrf_token)
|
||||
lead = get_marketplace_lead(db, lead_id)
|
||||
if lead:
|
||||
update_lead_status(db, lead=lead, status=status, user_id=user.id)
|
||||
return RedirectResponse(url=f"/marketplace/leads/{lead_id}", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/leads/{lead_id}/convert-client")
|
||||
def lead_convert_client(request: Request, lead_id: int, csrf_token: str = Form(...), tenant_id: int = Form(...), branch_id: int = Form(...), partner_user_id: int = Form(0), client_code: str = Form("")):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_user(request, db, "marketplace_leads.convert")
|
||||
if response:
|
||||
return response
|
||||
validate_csrf(request, csrf_token)
|
||||
lead = get_marketplace_lead(db, lead_id)
|
||||
if lead:
|
||||
client = convert_lead_to_client(db, lead=lead, tenant_id=tenant_id, branch_id=branch_id, partner_user_id=partner_user_id or None, client_code=client_code, user_id=user.id)
|
||||
return RedirectResponse(url=f"/clients/{client.id}", status_code=303)
|
||||
return RedirectResponse(url="/marketplace/leads", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
Reference in New Issue
Block a user