Add consultant onboarding for firm administrators
This commit is contained in:
@@ -18,6 +18,7 @@ from app.modules.core.rbac.models import Role, UserRole
|
||||
from app.modules.core.tenancy.models import Branch, FinancialYear, Tenant
|
||||
from app.modules.clients.models import Client
|
||||
from app.modules.employees.models import Employee
|
||||
from app.modules.consultants.models import ConsultantProfile
|
||||
from app.modules.services.models import (
|
||||
FirmServiceSelection,
|
||||
FirmServiceTaskTemplate,
|
||||
@@ -268,6 +269,7 @@ ONBOARDING_ROLE_CONFIG: dict[str, dict[str, str]] = {
|
||||
"default_designation": "Partner",
|
||||
"default_department": "Management",
|
||||
"default_employment_type": "full_time",
|
||||
"requires_employee": True,
|
||||
},
|
||||
"manager": {
|
||||
"role_name": "Branch Manager",
|
||||
@@ -275,6 +277,7 @@ ONBOARDING_ROLE_CONFIG: dict[str, dict[str, str]] = {
|
||||
"default_designation": "Branch Manager",
|
||||
"default_department": "Operations",
|
||||
"default_employment_type": "full_time",
|
||||
"requires_employee": True,
|
||||
},
|
||||
"staff": {
|
||||
"role_name": "Staff",
|
||||
@@ -282,6 +285,15 @@ ONBOARDING_ROLE_CONFIG: dict[str, dict[str, str]] = {
|
||||
"default_designation": "Staff",
|
||||
"default_department": "Operations",
|
||||
"default_employment_type": "full_time",
|
||||
"requires_employee": True,
|
||||
},
|
||||
"consultant": {
|
||||
"role_name": "Consultant",
|
||||
"label": "Consultant",
|
||||
"default_designation": "Consultant",
|
||||
"default_department": "Consulting",
|
||||
"default_employment_type": "consultant",
|
||||
"requires_employee": False,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -447,6 +459,13 @@ def create_firm_internal_user(
|
||||
designation: str | None = None,
|
||||
date_of_joining: str | None = None,
|
||||
employment_type: str | None = None,
|
||||
consultant_type: str | None = None,
|
||||
firm_name: str | None = None,
|
||||
specialisation: str | None = None,
|
||||
gstin: str | None = None,
|
||||
pan: str | None = None,
|
||||
address: str | None = None,
|
||||
remarks: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
actor_roles = set(get_user_role_names(db, actor.id))
|
||||
if not actor_roles.intersection(FIRM_ADMIN_ROLES):
|
||||
@@ -501,17 +520,60 @@ def create_firm_internal_user(
|
||||
db.flush()
|
||||
db.add(UserRole(user_id=user.id, role_id=role.id))
|
||||
|
||||
employee_link_result = _ensure_employee_for_onboarded_user(
|
||||
db,
|
||||
actor=actor,
|
||||
user_obj=user,
|
||||
employee_code=employee_code,
|
||||
mobile=mobile,
|
||||
department=(department or config.get("default_department") or ""),
|
||||
designation=(designation or config.get("default_designation") or ""),
|
||||
date_of_joining=date_of_joining,
|
||||
employment_type=(employment_type or config.get("default_employment_type") or "full_time"),
|
||||
)
|
||||
consultant_profile = None
|
||||
if role_key == "consultant":
|
||||
duplicate_profile = db.execute(
|
||||
select(ConsultantProfile).where(
|
||||
ConsultantProfile.tenant_id == int(tenant_id),
|
||||
ConsultantProfile.email == email_clean,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if duplicate_profile:
|
||||
raise ValueError("A consultant profile with this email already exists in this firm.")
|
||||
|
||||
consultant_profile = ConsultantProfile(
|
||||
tenant_id=int(tenant_id),
|
||||
branch_id=int(branch.id),
|
||||
user_id=int(user.id),
|
||||
consultant_type=(consultant_type or "external_consultant").strip() or "external_consultant",
|
||||
firm_name=(firm_name or "").strip() or None,
|
||||
contact_person=name_clean,
|
||||
email=email_clean,
|
||||
mobile=(mobile or "").strip() or None,
|
||||
specialisation=(specialisation or "").strip() or None,
|
||||
gstin=(gstin or "").strip().upper() or None,
|
||||
pan=(pan or "").strip().upper() or None,
|
||||
address=(address or "").strip() or None,
|
||||
status="active",
|
||||
onboarding_status="invited",
|
||||
is_platform_partner=False,
|
||||
is_franchise_partner=False,
|
||||
is_saas_customer=False,
|
||||
is_active=True,
|
||||
remarks=(remarks or "").strip() or None,
|
||||
created_by_user_id=actor.id,
|
||||
updated_by_user_id=actor.id,
|
||||
)
|
||||
db.add(consultant_profile)
|
||||
db.flush()
|
||||
employee_link_result = {
|
||||
"required": False,
|
||||
"created": False,
|
||||
"linked_existing": False,
|
||||
"employee_id": None,
|
||||
}
|
||||
else:
|
||||
employee_link_result = _ensure_employee_for_onboarded_user(
|
||||
db,
|
||||
actor=actor,
|
||||
user_obj=user,
|
||||
employee_code=employee_code,
|
||||
mobile=mobile,
|
||||
department=(department or config.get("default_department") or ""),
|
||||
designation=(designation or config.get("default_designation") or ""),
|
||||
date_of_joining=date_of_joining,
|
||||
employment_type=(employment_type or config.get("default_employment_type") or "full_time"),
|
||||
)
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
@@ -540,6 +602,7 @@ def create_firm_internal_user(
|
||||
"role_label": config["label"],
|
||||
"branch": branch,
|
||||
"employee_link_result": employee_link_result,
|
||||
"consultant_profile": consultant_profile,
|
||||
"invite_url": invite_url,
|
||||
"email_status": email_status,
|
||||
"email_error": email_error,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<a href="/firm-admin/users/new?role=partner" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">+ Add Partner</a>
|
||||
<a href="/firm-admin/users/new?role=manager" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">+ Add Manager</a>
|
||||
<a href="/firm-admin/users/new?role=staff" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">+ Add Staff</a>
|
||||
<a href="/firm-admin/users/new?role=consultant" class="rounded-xl bg-violet-600 px-4 py-2 text-sm font-semibold text-white hover:bg-violet-700">+ Add Consultant</a>
|
||||
<a href="/firm-admin/users/import" class="rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700">Import Users</a>
|
||||
<a href="/firm-admin/users/import/template" class="rounded-xl border border-emerald-200 px-4 py-2 text-sm font-semibold text-emerald-700 hover:bg-emerald-50">Template</a>
|
||||
<a href="/system-settings/users" class="rounded-xl border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Manage Users</a>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<a href="/firm-admin/users/new?role=partner" class="rounded-2xl border border-brand-200 bg-brand-50 p-4 hover:bg-brand-100"><div class="font-semibold text-brand-900">Add Partner</div><p class="mt-1 text-sm text-brand-700">Create partner user, link employee master and generate invite.</p><div class="mt-3 text-xs font-semibold text-brand-700">Open</div></a>
|
||||
<a href="/firm-admin/users/new?role=manager" class="rounded-2xl border border-brand-200 bg-brand-50 p-4 hover:bg-brand-100"><div class="font-semibold text-brand-900">Add Manager</div><p class="mt-1 text-sm text-brand-700">Creates user with Branch Manager role.</p><div class="mt-3 text-xs font-semibold text-brand-700">Open</div></a>
|
||||
<a href="/firm-admin/users/new?role=staff" class="rounded-2xl border border-brand-200 bg-brand-50 p-4 hover:bg-brand-100"><div class="font-semibold text-brand-900">Add Staff</div><p class="mt-1 text-sm text-brand-700">Create staff user, employee master and invite link.</p><div class="mt-3 text-xs font-semibold text-brand-700">Open</div></a>
|
||||
<a href="/firm-admin/users/new?role=consultant" class="rounded-2xl border border-violet-200 bg-violet-50 p-4 hover:bg-violet-100"><div class="font-semibold text-violet-900">Add Consultant</div><p class="mt-1 text-sm text-violet-700">Create consultant login, consultant profile and invite link without creating an employee record.</p><div class="mt-3 text-xs font-semibold text-violet-700">Open</div></a>
|
||||
{% for wizard in wizards %}
|
||||
<a href="{{ wizard.href }}" class="rounded-2xl border border-slate-200 p-4 hover:border-brand-200 hover:bg-brand-50/40"><div class="font-semibold text-slate-900">{{ wizard.title }}</div><p class="mt-1 text-sm text-slate-500">{{ wizard.desc }}</p><div class="mt-3 text-xs font-semibold text-brand-700">Open</div></a>
|
||||
{% endfor %}
|
||||
|
||||
+39
-3
@@ -8,7 +8,7 @@
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600">Firm Admin User Onboarding</p>
|
||||
<h1 class="mt-2 text-2xl font-bold text-slate-900">Add {{ role_config.label if role_config else 'Firm User' }}</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Create the user account, assign branch and role, create/link employee master, generate invite link and attempt invite email.</p>
|
||||
<p class="mt-1 text-sm text-slate-500">Create the user account, assign branch and role, create the appropriate profile, generate invite link and attempt invite email.</p>
|
||||
</div>
|
||||
<a href="/firm-admin/dashboard?tab=users" class="rounded-xl border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back to Users</a>
|
||||
</div>
|
||||
@@ -20,7 +20,7 @@
|
||||
</div>
|
||||
{% elif not branches %}
|
||||
<div class="rounded-3xl border border-amber-200 bg-amber-50 p-5 text-sm font-semibold text-amber-900 shadow-soft">
|
||||
No active branch is available for this firm. Create a branch first, then add Partner / Manager / Staff.
|
||||
No active branch is available for this firm. Create a branch first, then add Partner / Manager / Staff / Consultant.
|
||||
</div>
|
||||
{% elif not role_config %}
|
||||
<div class="rounded-3xl border border-red-200 bg-red-50 p-5 text-sm font-semibold text-red-800 shadow-soft">
|
||||
@@ -67,6 +67,7 @@
|
||||
<span class="text-sm font-semibold text-slate-700">Mobile</span>
|
||||
<input name="mobile" value="{{ data.mobile or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100" />
|
||||
</label>
|
||||
{% if role_key != 'consultant' %}
|
||||
<label class="block">
|
||||
<span class="text-sm font-semibold text-slate-700">Employee Code</span>
|
||||
<input name="employee_code" value="{{ data.employee_code or employee_code_suggestion }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100" />
|
||||
@@ -93,10 +94,45 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
{% else %}
|
||||
<label class="block">
|
||||
<span class="text-sm font-semibold text-slate-700">Consultant Type</span>
|
||||
{% set selected_type = data.consultant_type or 'external_consultant' %}
|
||||
<select name="consultant_type" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100">
|
||||
{% for code, label in [('external_consultant','External Consultant'),('gst_consultant','GST Consultant'),('tax_consultant','Tax Consultant'),('roc_consultant','ROC Consultant'),('payroll_consultant','Payroll Consultant'),('franchise_partner','Franchise Partner'),('saas_customer','SaaS Customer')] %}
|
||||
<option value="{{ code }}" {% if selected_type == code %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-semibold text-slate-700">Consulting Firm / Trade Name</span>
|
||||
<input name="firm_name" value="{{ data.firm_name or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100" />
|
||||
</label>
|
||||
<label class="block md:col-span-2">
|
||||
<span class="text-sm font-semibold text-slate-700">Specialisation</span>
|
||||
<input name="specialisation" value="{{ data.specialisation or '' }}" placeholder="Example: GST, Income Tax, ROC, Payroll" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-semibold text-slate-700">PAN</span>
|
||||
<input name="pan" value="{{ data.pan or '' }}" maxlength="10" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm uppercase focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-semibold text-slate-700">GSTIN</span>
|
||||
<input name="gstin" value="{{ data.gstin or '' }}" maxlength="15" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm uppercase focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100" />
|
||||
</label>
|
||||
<label class="block md:col-span-2">
|
||||
<span class="text-sm font-semibold text-slate-700">Address</span>
|
||||
<textarea name="address" rows="3" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100">{{ data.address or '' }}</textarea>
|
||||
</label>
|
||||
<label class="block md:col-span-2">
|
||||
<span class="text-sm font-semibold text-slate-700">Remarks</span>
|
||||
<textarea name="remarks" rows="2" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100">{{ data.remarks or '' }}</textarea>
|
||||
</label>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-brand-100 bg-brand-50 p-4 text-sm text-brand-900">
|
||||
The system will generate an invite token and show the fallback invite link after saving. The user will set their own password from that link.
|
||||
The system will create the user and role mapping, generate an invite token and show the fallback invite link after saving. Consultants are created in the Consultant Profile master and are not added to the Employee/payroll master.
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex flex-wrap justify-end gap-3">
|
||||
|
||||
+5
-1
@@ -6,7 +6,7 @@
|
||||
<div class="rounded-3xl border border-emerald-200 bg-emerald-50 p-6 shadow-soft">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-emerald-700">User Created</p>
|
||||
<h1 class="mt-2 text-2xl font-bold text-emerald-950">{{ result.role_label }} invite is ready</h1>
|
||||
<p class="mt-1 text-sm text-emerald-800">The user account, role mapping, branch assignment and employee master link have been completed.</p>
|
||||
<p class="mt-1 text-sm text-emerald-800">The user account, role mapping and branch assignment have been completed. The appropriate employee or consultant profile has also been created.</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-3xl border border-slate-200 bg-white p-6 shadow-soft">
|
||||
@@ -14,7 +14,11 @@
|
||||
<div><div class="text-xs font-semibold uppercase tracking-wide text-slate-400">User</div><div class="mt-1 font-semibold text-slate-900">{{ result.user.full_name or result.user.email }}</div><div class="text-slate-500">{{ result.user.email }}</div></div>
|
||||
<div><div class="text-xs font-semibold uppercase tracking-wide text-slate-400">Role</div><div class="mt-1 font-semibold text-slate-900">{{ result.role_name }}</div></div>
|
||||
<div><div class="text-xs font-semibold uppercase tracking-wide text-slate-400">Branch</div><div class="mt-1 font-semibold text-slate-900">{{ result.branch.name }}</div></div>
|
||||
{% if result.consultant_profile %}
|
||||
<div><div class="text-xs font-semibold uppercase tracking-wide text-slate-400">Consultant Profile</div><div class="mt-1 font-semibold text-slate-900">Created #{{ result.consultant_profile.id }}</div><div class="text-xs text-slate-500">{{ result.consultant_profile.specialisation or result.consultant_profile.consultant_type }}</div></div>
|
||||
{% else %}
|
||||
<div><div class="text-xs font-semibold uppercase tracking-wide text-slate-400">Employee Link</div><div class="mt-1 font-semibold text-slate-900">{% if result.employee_link_result.employee_id %}Linked #{{ result.employee_link_result.employee_id }}{% else %}Not linked{% endif %}</div></div>
|
||||
{% endif %}
|
||||
<div class="md:col-span-2"><div class="text-xs font-semibold uppercase tracking-wide text-slate-400">Invite Email</div><div class="mt-1 font-semibold text-slate-900">{{ result.email_status }}</div>{% if result.email_error %}<div class="mt-1 text-xs text-amber-700">{{ result.email_error }}</div>{% endif %}</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -136,6 +136,13 @@ def user_onboarding_submit(
|
||||
designation: str = Form(""),
|
||||
date_of_joining: str = Form(""),
|
||||
employment_type: str = Form("full_time"),
|
||||
consultant_type: str = Form("external_consultant"),
|
||||
firm_name: str = Form(""),
|
||||
specialisation: str = Form(""),
|
||||
gstin: str = Form(""),
|
||||
pan: str = Form(""),
|
||||
address: str = Form(""),
|
||||
remarks: str = Form(""),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
@@ -164,6 +171,13 @@ def user_onboarding_submit(
|
||||
designation=designation,
|
||||
date_of_joining=date_of_joining,
|
||||
employment_type=employment_type,
|
||||
consultant_type=consultant_type,
|
||||
firm_name=firm_name,
|
||||
specialisation=specialisation,
|
||||
gstin=gstin,
|
||||
pan=pan,
|
||||
address=address,
|
||||
remarks=remarks,
|
||||
)
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
@@ -185,6 +199,13 @@ def user_onboarding_submit(
|
||||
"designation": designation,
|
||||
"date_of_joining": date_of_joining,
|
||||
"employment_type": employment_type,
|
||||
"consultant_type": consultant_type,
|
||||
"firm_name": firm_name,
|
||||
"specialisation": specialisation,
|
||||
"gstin": gstin,
|
||||
"pan": pan,
|
||||
"address": address,
|
||||
"remarks": remarks,
|
||||
},
|
||||
),
|
||||
status_code=400,
|
||||
|
||||
Reference in New Issue
Block a user