Prepare ERP source for Gitea deployment
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.modules.clients.api import router as clients_api
|
||||
from app.modules.core.iam.api import router as users_api
|
||||
from app.modules.core.iam.auth_api import router as auth_api
|
||||
from app.modules.core.rbac.api import router as rbac_api
|
||||
from app.modules.core.tenancy.api import router as tenancy_api
|
||||
from app.modules.system.health.api import router as health_api
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(health_api)
|
||||
api_router.include_router(tenancy_api)
|
||||
api_router.include_router(rbac_api)
|
||||
api_router.include_router(auth_api)
|
||||
api_router.include_router(users_api)
|
||||
api_router.include_router(clients_api)
|
||||
@@ -0,0 +1,9 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
||||
from app.core.db.urls import get_common_db_url
|
||||
|
||||
class CommonBase(DeclarativeBase):
|
||||
pass
|
||||
|
||||
CommonEngine = create_engine(get_common_db_url(), pool_pre_ping=True, future=True)
|
||||
CommonSessionLocal = sessionmaker(bind=CommonEngine, autocommit=False, autoflush=False, future=True)
|
||||
@@ -0,0 +1,10 @@
|
||||
from typing import Generator
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.db.common import CommonSessionLocal
|
||||
|
||||
def get_common_db() -> Generator[Session, None, None]:
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,30 @@
|
||||
import os
|
||||
|
||||
from sqlalchemy.engine import URL
|
||||
|
||||
from app.core.settings import get_settings
|
||||
|
||||
|
||||
def sqlite_url(path: str) -> str:
|
||||
parent = os.path.dirname(path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
return f"sqlite+pysqlite:///{path}"
|
||||
|
||||
|
||||
def postgres_url(user: str, password: str, host: str, port: int, db: str) -> str:
|
||||
return URL.create(
|
||||
drivername="postgresql+psycopg",
|
||||
username=user,
|
||||
password=password,
|
||||
host=host,
|
||||
port=port,
|
||||
database=db,
|
||||
).render_as_string(hide_password=False)
|
||||
|
||||
|
||||
def get_common_db_url() -> str:
|
||||
s = get_settings()
|
||||
if s.DB_BACKEND.lower() == "sqlite":
|
||||
return sqlite_url(s.SQLITE_COMMON_PATH)
|
||||
return postgres_url(s.PG_USER, s.PG_PASSWORD, s.PG_HOST, s.PG_PORT, s.PG_DB_COMMON)
|
||||
@@ -0,0 +1,133 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ipaddress import ip_address, ip_network
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
from app.core.settings import get_settings
|
||||
|
||||
|
||||
_CONTEXT_SECRET_HEADER = "X-AuditFirm-Context-Secret"
|
||||
_TENANT_HEADER = "X-Tenant-Code"
|
||||
_BRANCH_HEADER = "X-Branch-Code"
|
||||
_YEAR_HEADER = "X-Year-Code"
|
||||
|
||||
|
||||
def _csv_values(value: str | None) -> list[str]:
|
||||
return [item.strip() for item in (value or "").split(",") if item.strip()]
|
||||
|
||||
|
||||
def _safe_env(value: str | None) -> str:
|
||||
return (value or "").strip().lower()
|
||||
|
||||
|
||||
def _host_matches_trusted_entry(client_host: str, trusted_entry: str) -> bool:
|
||||
"""Return True when client_host matches a trusted host/IP/CIDR entry.
|
||||
|
||||
Deliberately does not support '*' wildcard. For Docker/Coolify internal
|
||||
networks, use an explicit CIDR such as 172.16.0.0/12.
|
||||
"""
|
||||
client_host = (client_host or "").strip().lower()
|
||||
trusted_entry = (trusted_entry or "").strip().lower()
|
||||
if not client_host or not trusted_entry:
|
||||
return False
|
||||
|
||||
if client_host == trusted_entry:
|
||||
return True
|
||||
|
||||
try:
|
||||
client_ip = ip_address(client_host)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
try:
|
||||
if "/" in trusted_entry:
|
||||
return client_ip in ip_network(trusted_entry, strict=False)
|
||||
return client_ip == ip_address(trusted_entry)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _normalise_session_int(value):
|
||||
if value in (None, "", 0, "0"):
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
class ContextResolveMiddleware(BaseHTTPMiddleware):
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
super().__init__(app)
|
||||
self.s = get_settings()
|
||||
|
||||
def _context_headers_are_trusted(self, request: Request) -> bool:
|
||||
"""Permit context headers only from trusted internal callers.
|
||||
|
||||
Public users must not be able to switch tenant/branch/FY by adding
|
||||
X-Tenant-Code, X-Branch-Code or X-Year-Code headers. The production-safe
|
||||
default is TRUST_CONTEXT_HEADERS=false.
|
||||
"""
|
||||
if not bool(getattr(self.s, "TRUST_CONTEXT_HEADERS", False)):
|
||||
return False
|
||||
|
||||
required_secret = (getattr(self.s, "CONTEXT_HEADER_SECRET", "") or "").strip()
|
||||
if required_secret:
|
||||
supplied_secret = (request.headers.get(_CONTEXT_SECRET_HEADER) or "").strip()
|
||||
if supplied_secret != required_secret:
|
||||
return False
|
||||
elif _safe_env(getattr(self.s, "ENV", "")) in {"prod", "production"}:
|
||||
return False
|
||||
|
||||
client_host = request.client.host if request.client else ""
|
||||
trusted_entries = _csv_values(getattr(self.s, "TRUST_CONTEXT_HEADER_HOSTS", ""))
|
||||
return any(_host_matches_trusted_entry(client_host, item) for item in trusted_entries)
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Trusted production context priority:
|
||||
# 1) Authenticated UI session selected tenant/branch/FY.
|
||||
# 2) Domain resolver mapping for pre-login/domain-routed requests.
|
||||
# 3) Trusted internal headers only when explicitly enabled with secret/host.
|
||||
# 4) Application defaults.
|
||||
session = request.scope.get("session") or {}
|
||||
trust_headers = self._context_headers_are_trusted(request)
|
||||
|
||||
session_tenant_id = _normalise_session_int(session.get("active_tenant_id") or session.get("tenant_id"))
|
||||
session_branch_id = _normalise_session_int(session.get("active_branch_id") or session.get("branch_id"))
|
||||
|
||||
session_tenant_code = (session.get("active_tenant_code") or session.get("tenant_code") or "").strip() or None
|
||||
session_branch_code = (session.get("active_branch_code") or session.get("branch_code") or "").strip() or None
|
||||
|
||||
domain_tenant_code = getattr(request.state, "domain_tenant_code", None)
|
||||
domain_branch_code = getattr(request.state, "domain_branch_code", None)
|
||||
|
||||
tenant_code = (
|
||||
session_tenant_code
|
||||
or domain_tenant_code
|
||||
or (request.headers.get(_TENANT_HEADER) if trust_headers else None)
|
||||
or self.s.DEFAULT_TENANT_CODE
|
||||
)
|
||||
|
||||
branch_code = (
|
||||
session_branch_code
|
||||
or domain_branch_code
|
||||
or (request.headers.get(_BRANCH_HEADER) if trust_headers else None)
|
||||
or self.s.DEFAULT_BRANCH_CODE
|
||||
)
|
||||
|
||||
year_code = (
|
||||
session.get("active_financial_year")
|
||||
or (request.headers.get(_YEAR_HEADER) if trust_headers else None)
|
||||
or self.s.DEFAULT_YEAR_CODE
|
||||
)
|
||||
|
||||
request.state.active_tenant_id = session_tenant_id
|
||||
request.state.active_branch_id = session_branch_id
|
||||
request.state.tenant_code = tenant_code
|
||||
request.state.branch_code = branch_code
|
||||
request.state.year_code = year_code
|
||||
request.state.context_headers_trusted = trust_headers
|
||||
return await call_next(request)
|
||||
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
from app.core.db.common import CommonSessionLocal
|
||||
from app.modules.domain_management.services import normalize_request_host, resolve_domain_context
|
||||
|
||||
|
||||
class DomainResolverMiddleware(BaseHTTPMiddleware):
|
||||
"""Resolve request host to platform / tenant / consultant context.
|
||||
|
||||
Phase 7T.2 is intentionally read-only:
|
||||
- It does not redirect users.
|
||||
- It does not change database records.
|
||||
- It does not override logged-in user permissions.
|
||||
- It only exposes a trusted runtime context on request.state.
|
||||
|
||||
Later phases use this context for branding, marketplace mode, tenant subdomains,
|
||||
consultant domains, and custom domain verification.
|
||||
"""
|
||||
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
super().__init__(app)
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
host_header = request.headers.get("x-forwarded-host") or request.headers.get("host")
|
||||
host = normalize_request_host(host_header)
|
||||
|
||||
# Safe defaults; every template/route can read these without checking existence.
|
||||
request.state.request_host = host
|
||||
request.state.domain_resolved = False
|
||||
request.state.domain_mapping_id = None
|
||||
request.state.domain_name = host
|
||||
request.state.domain_type = None
|
||||
request.state.domain_tenant_id = None
|
||||
request.state.domain_tenant_code = None
|
||||
request.state.domain_branch_id = None
|
||||
request.state.domain_branch_code = None
|
||||
request.state.domain_consultant_id = None
|
||||
request.state.domain_parent_tenant_id = None
|
||||
request.state.domain_is_verified = False
|
||||
request.state.domain_status = None
|
||||
request.state.domain_context = {
|
||||
"is_resolved": False,
|
||||
"host": host,
|
||||
"mapping_id": None,
|
||||
"domain_name": host,
|
||||
"domain_type": None,
|
||||
"tenant_id": None,
|
||||
"tenant_code": None,
|
||||
"branch_id": None,
|
||||
"branch_code": None,
|
||||
"consultant_id": None,
|
||||
"parent_tenant_id": None,
|
||||
"is_verified": False,
|
||||
"status": None,
|
||||
}
|
||||
|
||||
# Static files and empty/invalid host can proceed without DB lookup.
|
||||
if host and not request.url.path.startswith("/static/"):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
resolved = resolve_domain_context(db, host)
|
||||
if resolved.is_resolved:
|
||||
request.state.domain_resolved = True
|
||||
request.state.domain_mapping_id = resolved.mapping_id
|
||||
request.state.domain_name = resolved.domain_name
|
||||
request.state.domain_type = resolved.domain_type
|
||||
request.state.domain_tenant_id = resolved.tenant_id
|
||||
request.state.domain_tenant_code = resolved.tenant_code
|
||||
request.state.domain_branch_id = resolved.branch_id
|
||||
request.state.domain_branch_code = resolved.branch_code
|
||||
request.state.domain_consultant_id = resolved.consultant_id
|
||||
request.state.domain_parent_tenant_id = resolved.parent_tenant_id
|
||||
request.state.domain_is_verified = resolved.is_verified
|
||||
request.state.domain_status = resolved.status
|
||||
request.state.domain_context = {
|
||||
"is_resolved": True,
|
||||
"host": resolved.host,
|
||||
"mapping_id": resolved.mapping_id,
|
||||
"domain_name": resolved.domain_name,
|
||||
"domain_type": resolved.domain_type,
|
||||
"tenant_id": resolved.tenant_id,
|
||||
"tenant_code": resolved.tenant_code,
|
||||
"branch_id": resolved.branch_id,
|
||||
"branch_code": resolved.branch_code,
|
||||
"consultant_id": resolved.consultant_id,
|
||||
"parent_tenant_id": resolved.parent_tenant_id,
|
||||
"is_verified": resolved.is_verified,
|
||||
"status": resolved.status,
|
||||
}
|
||||
except Exception:
|
||||
# Domain resolution must never take the ERP down. If the domain table is
|
||||
# missing during deployment or DB is temporarily unavailable, continue
|
||||
# with the normal default context.
|
||||
pass
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
response = await call_next(request)
|
||||
if getattr(request.state, "domain_resolved", False):
|
||||
response.headers["X-AuditFirm-Domain-Resolved"] = "1"
|
||||
response.headers["X-AuditFirm-Domain-Type"] = str(getattr(request.state, "domain_type", "") or "")
|
||||
return response
|
||||
@@ -0,0 +1,21 @@
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
|
||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
resp = await call_next(request)
|
||||
resp.headers["X-Content-Type-Options"] = "nosniff"
|
||||
resp.headers["X-Frame-Options"] = "DENY"
|
||||
resp.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
resp.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
|
||||
|
||||
# CSP: allow Tailwind CDN only
|
||||
resp.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; "
|
||||
"style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; "
|
||||
"script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; "
|
||||
"img-src 'self' data:; "
|
||||
"connect-src 'self'; "
|
||||
"frame-ancestors 'none';"
|
||||
)
|
||||
return resp
|
||||
@@ -0,0 +1,16 @@
|
||||
import secrets
|
||||
from fastapi import Request
|
||||
|
||||
CSRF_KEY = "csrf_token"
|
||||
|
||||
def get_or_create_csrf_token(request: Request) -> str:
|
||||
token = request.session.get(CSRF_KEY)
|
||||
if not token:
|
||||
token = secrets.token_urlsafe(32)
|
||||
request.session[CSRF_KEY] = token
|
||||
return token
|
||||
|
||||
def validate_csrf(request: Request, form_token: str | None) -> None:
|
||||
token = request.session.get(CSRF_KEY)
|
||||
if not token or not form_token or token != form_token:
|
||||
raise PermissionError("CSRF validation failed")
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db.deps import get_common_db
|
||||
from app.core.security.jwt_tokens import decode_token
|
||||
from app.modules.core.iam.models import User
|
||||
|
||||
bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
def get_current_user_jwt(
|
||||
creds: HTTPAuthorizationCredentials | None = Depends(bearer),
|
||||
db: Session = Depends(get_common_db),
|
||||
) -> User | None:
|
||||
if not creds or not creds.credentials:
|
||||
return None
|
||||
data = decode_token(creds.credentials)
|
||||
if data.get("typ") != "access":
|
||||
return None
|
||||
|
||||
user_id = int(data.get("sub", 0) or 0)
|
||||
if not user_id:
|
||||
return None
|
||||
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
||||
if not user or not user.is_active:
|
||||
return None
|
||||
return user
|
||||
|
||||
def require_jwt_user(user: User | None = Depends(get_current_user_jwt)) -> User:
|
||||
if not user:
|
||||
raise PermissionError("Not authenticated (JWT)")
|
||||
return user
|
||||
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
from jwt import PyJWTError
|
||||
|
||||
from app.core.settings import get_settings
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
def encode_access_token(payload: dict[str, Any], expires_minutes: int) -> str:
|
||||
s = get_settings()
|
||||
now = utcnow()
|
||||
exp = now + timedelta(minutes=expires_minutes)
|
||||
token_payload = {
|
||||
**payload,
|
||||
"iss": s.JWT_ISSUER,
|
||||
"aud": s.JWT_AUDIENCE,
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int(exp.timestamp()),
|
||||
"typ": "access",
|
||||
}
|
||||
return jwt.encode(token_payload, s.SECRET_KEY, algorithm="HS256")
|
||||
|
||||
def decode_token(token: str) -> dict[str, Any]:
|
||||
s = get_settings()
|
||||
try:
|
||||
data = jwt.decode(
|
||||
token,
|
||||
s.SECRET_KEY,
|
||||
algorithms=["HS256"],
|
||||
audience=s.JWT_AUDIENCE,
|
||||
issuer=s.JWT_ISSUER,
|
||||
options={"require": ["exp", "iat", "iss", "aud"]},
|
||||
)
|
||||
return data
|
||||
except PyJWTError as e:
|
||||
raise PermissionError("Invalid token") from e
|
||||
@@ -0,0 +1,23 @@
|
||||
import secrets
|
||||
from fastapi import Request
|
||||
|
||||
OTP_CODE_KEY = "otp_code"
|
||||
OTP_VERIFIED_KEY = "otp_verified"
|
||||
|
||||
def start_otp(request: Request) -> str:
|
||||
# 6-digit numeric code
|
||||
code = str(secrets.randbelow(900000) + 100000)
|
||||
request.session[OTP_CODE_KEY] = code
|
||||
request.session[OTP_VERIFIED_KEY] = False
|
||||
return code
|
||||
|
||||
def verify_otp(request: Request, code: str) -> bool:
|
||||
expected = request.session.get(OTP_CODE_KEY)
|
||||
if expected and code and code.strip() == expected:
|
||||
request.session[OTP_VERIFIED_KEY] = True
|
||||
request.session.pop(OTP_CODE_KEY, None)
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_otp_verified(request: Request) -> bool:
|
||||
return bool(request.session.get(OTP_VERIFIED_KEY))
|
||||
@@ -0,0 +1,8 @@
|
||||
from passlib.context import CryptContext
|
||||
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
def hash_password(p: str) -> str:
|
||||
return _pwd.hash(p)
|
||||
|
||||
def verify_password(p: str, h: str) -> bool:
|
||||
return _pwd.verify(p, h)
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import Request, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db.deps import get_common_db
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.core.tenancy.models import Branch
|
||||
from app.modules.core.tenancy.settings_models import BranchSettings
|
||||
|
||||
SESSION_USER_ID_KEY = "user_id"
|
||||
SESSION_LOGIN_AT_KEY = "login_at"
|
||||
|
||||
def _now_utc() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
def get_current_user(request: Request, db: Session = Depends(get_common_db)) -> User | None:
|
||||
user_id = request.session.get(SESSION_USER_ID_KEY)
|
||||
if not user_id:
|
||||
return None
|
||||
|
||||
user = db.execute(select(User).where(User.id == int(user_id))).scalar_one_or_none()
|
||||
if not user or not user.is_active or not getattr(user, "allow_login", True) or getattr(user, "is_locked", False) or getattr(user, "deleted_at", None) is not None:
|
||||
return None
|
||||
|
||||
# Enforce session duration from BranchSettings
|
||||
login_at = request.session.get(SESSION_LOGIN_AT_KEY)
|
||||
if login_at:
|
||||
try:
|
||||
login_at_dt = datetime.fromisoformat(login_at)
|
||||
except Exception:
|
||||
login_at_dt = None
|
||||
else:
|
||||
login_at_dt = None
|
||||
|
||||
bs = db.execute(select(BranchSettings).where(BranchSettings.branch_id == user.branch_id)).scalar_one_or_none()
|
||||
max_minutes = bs.session_duration_minutes if bs else 480
|
||||
|
||||
if login_at_dt:
|
||||
if _now_utc() - login_at_dt > timedelta(minutes=max_minutes):
|
||||
# expire session
|
||||
request.session.pop(SESSION_USER_ID_KEY, None)
|
||||
request.session.pop(SESSION_LOGIN_AT_KEY, None)
|
||||
return None
|
||||
|
||||
return user
|
||||
|
||||
def require_login(user: User | None = Depends(get_current_user)) -> User:
|
||||
if not user:
|
||||
raise PermissionError("Not authenticated")
|
||||
return user
|
||||
@@ -0,0 +1,76 @@
|
||||
from functools import lru_cache
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from pydantic import Field
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore"
|
||||
)
|
||||
|
||||
APP_NAME: str = "Audit_Firm_v2.0.3.6"
|
||||
ENV: str = "dev"
|
||||
DEBUG: bool = True
|
||||
SECRET_KEY: str = "change-me-to-a-long-random-string"
|
||||
|
||||
COOKIE_SECURE: bool = False
|
||||
COOKIE_SAMESITE: str = "lax"
|
||||
COOKIE_SESSION_NAME: str = "af2sid"
|
||||
|
||||
DB_BACKEND: str = Field(default="sqlite", description="sqlite|postgres")
|
||||
|
||||
SQLITE_COMMON_PATH: str = "./data/common.db"
|
||||
|
||||
PG_HOST: str = "127.0.0.1"
|
||||
PG_PORT: int = 5432
|
||||
PG_USER: str = "postgres"
|
||||
PG_PASSWORD: str = "postgres"
|
||||
PG_DB_COMMON: str = "audit_common"
|
||||
|
||||
DEFAULT_TENANT_CODE: str = "default"
|
||||
DEFAULT_BRANCH_CODE: str = "main"
|
||||
DEFAULT_YEAR_CODE: str = "2025-26"
|
||||
DEFAULT_TIMEZONE: str = "Asia/Kolkata"
|
||||
|
||||
# Public base URL used for email links such as invite and password reset.
|
||||
# In Coolify production set this to https://your-erp-domain.
|
||||
ERP_PUBLIC_BASE_URL: str = "http://localhost:8000"
|
||||
|
||||
# Print OTP to server logs only in local/dev troubleshooting. Keep false in UAT/production.
|
||||
DEV_AUTH_OTP_PRINT: bool = False
|
||||
|
||||
# Context headers are disabled by default for public deployments.
|
||||
# When disabled, browser/client supplied X-Tenant-Code, X-Branch-Code,
|
||||
# and X-Year-Code are ignored. Enable only for trusted internal runners
|
||||
# or reverse proxies that also restrict/strip external request headers.
|
||||
TRUST_CONTEXT_HEADERS: bool = False
|
||||
TRUST_CONTEXT_HEADER_HOSTS: str = "127.0.0.1,localhost,::1"
|
||||
CONTEXT_HEADER_SECRET: str = ""
|
||||
|
||||
# JWT Configuration
|
||||
JWT_ISSUER: str = "Audit_Firm_v2.0.3.6"
|
||||
JWT_AUDIENCE: str = "audit_firm_clients"
|
||||
JWT_ACCESS_MINUTES: int = 15
|
||||
JWT_REFRESH_DAYS: int = 30
|
||||
|
||||
# Bootstrap Admin
|
||||
BOOTSTRAP_ADMIN_EMAIL: str = "admin@auditfirm.local"
|
||||
BOOTSTRAP_ADMIN_PASSWORD: str = "ChangeMe@123"
|
||||
|
||||
INVITE_TOKEN_HOURS: int = 72
|
||||
PASSWORD_RESET_HOURS: int = 2
|
||||
PASSWORD_MIN_LENGTH: int = 8
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
s = Settings()
|
||||
|
||||
# Normalize cookie values
|
||||
s.COOKIE_SAMESITE = (s.COOKIE_SAMESITE or "lax").lower()
|
||||
if s.COOKIE_SAMESITE not in {"lax", "strict", "none"}:
|
||||
s.COOKIE_SAMESITE = "lax"
|
||||
|
||||
return s
|
||||
@@ -0,0 +1,951 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import inspect, select, text
|
||||
|
||||
from app.core.db.common import CommonBase, CommonEngine, CommonSessionLocal
|
||||
from app.core.security.passwords import hash_password
|
||||
from app.core.settings import get_settings
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.core.iam.password_flows_models import InviteToken, PasswordResetToken
|
||||
from app.modules.core.audit.models import AuditLog
|
||||
from app.modules.core.rbac.models import Permission, Role, RolePermission, UserRole
|
||||
from app.modules.core.rbac.permissions_registry import PERMISSIONS
|
||||
from app.modules.core.tenancy.models import Branch, FinancialYear, Tenant
|
||||
from app.modules.core.tenancy.settings_models import BranchSettings
|
||||
from app.modules.employees.models import Employee, EmployeeAttendance, EmployeeRegistrationRequest, EmployeeLeaveType, EmployeeLeaveBalance, EmployeeLeaveRequest, EmployeeDocumentType, EmployeeDocument, EmployeeOnboardingChecklistItem, EmployeeOnboardingTask, EmployeeOffboardingRequest, EmployeeOffboardingTask, EmployeeSalaryStructure, EmployeePayrollRun, EmployeePayslip
|
||||
from app.modules.consultants.models import ClientConsultantLink, ConsultantManagedClient, ConsultantProfile, ConsultantWorkspace, ConsultantServiceRequest
|
||||
from app.modules.services.models import FirmServiceSelection, FirmServiceTaskTemplate, ServiceCatalogue, ServiceCategory, FirmTaskDocumentRequirement, FirmTaskDocumentTemplate
|
||||
from app.modules.billing.models import BillingSettings, BillingInvoice, BillingInvoiceLine, BillingFeeGroup, BillingFeeGroupService
|
||||
from app.modules.platform_billing.models import PlatformBillingAccount, PlatformInvoice, PlatformInvoiceLine, PlatformPayment, PlatformPlan, PlatformPlanFeature, PlatformSubscription
|
||||
from app.modules.marketplace.models import MarketplaceLead, MarketplaceLeadAssignment
|
||||
from app.modules.documents.models import EngagementDocument, EngagementDocumentVersion, DocumentAccessLog
|
||||
from app.modules.alerts.models import UserAlert
|
||||
from app.modules.notice_cases.models import NoticeCase, NoticeCaseEvent, NoticeCaseHearing, NoticeCaseOrder, NoticeCaseDocument
|
||||
from app.modules.notifications.automation import start_notification_scheduler
|
||||
|
||||
DEFAULT_ROLES = [
|
||||
"System Admin",
|
||||
"Firm Admin",
|
||||
"Partner",
|
||||
"Branch Manager",
|
||||
"Staff",
|
||||
"Client",
|
||||
"Consultant",
|
||||
]
|
||||
|
||||
LEGACY_ROLE_RENAMES = {
|
||||
"SystemAdmin": "System Admin",
|
||||
"Manager": "Branch Manager",
|
||||
}
|
||||
|
||||
DEFAULT_PERMISSIONS = list(PERMISSIONS.items())
|
||||
|
||||
|
||||
def _ensure_user_lifecycle_columns() -> None:
|
||||
inspector = inspect(CommonEngine)
|
||||
existing = {c["name"] for c in inspector.get_columns("users")} if "users" in inspector.get_table_names() else set()
|
||||
dialect = CommonEngine.dialect.name
|
||||
ddl_map = {
|
||||
"allow_login": "BOOLEAN DEFAULT TRUE",
|
||||
"is_locked": "BOOLEAN DEFAULT FALSE",
|
||||
"locked_at_utc": "TIMESTAMP NULL",
|
||||
"deleted_at": "TIMESTAMP NULL",
|
||||
"must_change_password": "BOOLEAN DEFAULT FALSE",
|
||||
"password_changed_at_utc": "TIMESTAMP NULL",
|
||||
}
|
||||
for col, ddl in ddl_map.items():
|
||||
if col in existing:
|
||||
continue
|
||||
with CommonEngine.begin() as conn:
|
||||
conn.execute(text(f"ALTER TABLE users ADD COLUMN {col} {ddl}"))
|
||||
if dialect == "postgres" and col in {"allow_login", "is_locked"}:
|
||||
default_value = "TRUE" if col == "allow_login" else "FALSE"
|
||||
conn.execute(text(f"UPDATE users SET {col} = {default_value} WHERE {col} IS NULL"))
|
||||
|
||||
|
||||
ROLE_PERMISSION_MAP = {
|
||||
"System Admin": [
|
||||
"system.settings.view",
|
||||
"system.settings.edit",
|
||||
"system.settings.manage",
|
||||
"users.view",
|
||||
"users.manage",
|
||||
"users.invite",
|
||||
"users.reset_password",
|
||||
"rbac.view",
|
||||
"rbac.manage",
|
||||
"audit.view",
|
||||
"alerts.view_self",
|
||||
"alerts.manage",
|
||||
"services.view",
|
||||
"services.create",
|
||||
"services.edit",
|
||||
"services.selection.manage",
|
||||
"services.deactivate",
|
||||
"services.cross_branch",
|
||||
"services.cross_tenant",
|
||||
"services.catalogue.manage",
|
||||
"service_tasks.view",
|
||||
"service_tasks.create",
|
||||
"service_tasks.edit",
|
||||
"service_tasks.deactivate",
|
||||
"clients.view",
|
||||
"clients.create",
|
||||
"clients.import",
|
||||
"clients.edit",
|
||||
"clients.deactivate",
|
||||
"clients.activate",
|
||||
"clients.archive",
|
||||
"clients.restore",
|
||||
"clients.assign_partner",
|
||||
"clients.cross_branch",
|
||||
"clients.cross_tenant",
|
||||
"clients.export",
|
||||
"clients.audit_log.view",
|
||||
"employees.dashboard.view",
|
||||
"employees.view",
|
||||
"employees.create",
|
||||
"employees.edit",
|
||||
"employees.status",
|
||||
"employees.cross_branch",
|
||||
"employees.cross_tenant",
|
||||
"consultants.view",
|
||||
"consultants.manage",
|
||||
"consultants.link_clients",
|
||||
"consultants.cross_branch",
|
||||
"consultants.managed_clients.manage",
|
||||
"consultants.workspace.manage",
|
||||
"consultants.service_requests.manage",
|
||||
"consultants.conversions.manage",
|
||||
|
||||
# System Admin has billing support/view access only.
|
||||
# System Admin must not create, import, generate, approve, post, cancel,
|
||||
# or record firm-level client bills.
|
||||
"billing.view",
|
||||
"billing.payment.view",
|
||||
"billing.reports",
|
||||
"billing.cross_branch",
|
||||
"billing.cross_tenant",
|
||||
"billing_fee_structure.view",
|
||||
|
||||
# Platform/SaaS billing is System Admin revenue layer.
|
||||
"platform_billing.view",
|
||||
"platform_billing.create",
|
||||
"platform_billing.edit",
|
||||
"platform_billing.generate",
|
||||
"platform_billing.post",
|
||||
"platform_billing.cancel",
|
||||
"platform_billing.payment.create",
|
||||
"platform_billing.payment.view",
|
||||
"platform_billing.reports",
|
||||
"platform_plans.manage",
|
||||
"platform_subscriptions.manage",
|
||||
|
||||
# Marketplace / public lead management.
|
||||
"marketplace_leads.view",
|
||||
"marketplace_leads.create",
|
||||
"marketplace_leads.assign",
|
||||
"marketplace_leads.update",
|
||||
"marketplace_leads.convert",
|
||||
"marketplace_leads.reports",
|
||||
"marketplace_leads.view_assigned",
|
||||
|
||||
"alerts.view_self",
|
||||
"employees.ess.view", "employees.ess.profile.edit",
|
||||
"employees.work.view_self",
|
||||
"employees.work.manage",
|
||||
"employees.progress.view",
|
||||
"employees.registration.request",
|
||||
"employees.registration.approve",
|
||||
"employees.attendance.punch",
|
||||
"employees.attendance.view_self",
|
||||
"employees.attendance.view_all",
|
||||
"employees.attendance.approve",
|
||||
"employees.leave.apply",
|
||||
"employees.leave.view_self",
|
||||
"employees.leave.view_all",
|
||||
"employees.leave.approve",
|
||||
"employees.leave_type.manage",
|
||||
"employees.leave_balance.manage",
|
||||
"employees.documents.view_self",
|
||||
"employees.documents.upload_self",
|
||||
"employees.documents.view_all",
|
||||
"employees.documents.manage",
|
||||
"employees.documents.verify",
|
||||
"employees.documents.delete",
|
||||
"employees.document_type.manage",
|
||||
"employees.onboarding.view",
|
||||
"employees.onboarding.manage",
|
||||
"employees.onboarding.approve",
|
||||
"employees.offboarding.view",
|
||||
"employees.offboarding.manage",
|
||||
"employees.offboarding.approve",
|
||||
"employees.offboarding.request_self",
|
||||
"employees.payroll.payout",
|
||||
"employees.payroll.view_self",
|
||||
"employees.payroll.view",
|
||||
"employees.payroll.run",
|
||||
"employees.payroll.structure.manage",
|
||||
"employees.import",
|
||||
"employees.import.employee",
|
||||
"employees.import.leave_type",
|
||||
"employees.import.leave_balance",
|
||||
"employees.import.salary_structure",
|
||||
],
|
||||
"Firm Admin": [
|
||||
"system.settings.view",
|
||||
"system.settings.edit",
|
||||
"users.view",
|
||||
"users.manage",
|
||||
"users.invite",
|
||||
"users.reset_password",
|
||||
"audit.view",
|
||||
"alerts.view_self",
|
||||
"alerts.manage",
|
||||
"services.view",
|
||||
"services.create",
|
||||
"services.edit",
|
||||
"services.selection.manage",
|
||||
"services.deactivate",
|
||||
"services.cross_branch",
|
||||
"service_tasks.view",
|
||||
"service_tasks.create",
|
||||
"service_tasks.edit",
|
||||
"service_tasks.deactivate",
|
||||
"clients.view",
|
||||
"clients.create",
|
||||
"clients.import",
|
||||
"clients.edit",
|
||||
"clients.deactivate",
|
||||
"clients.activate",
|
||||
"clients.archive",
|
||||
"clients.restore",
|
||||
"clients.assign_partner",
|
||||
"clients.cross_branch",
|
||||
"clients.export",
|
||||
"clients.audit_log.view",
|
||||
"documents.view",
|
||||
"documents.upload",
|
||||
"documents.download",
|
||||
"documents.delete",
|
||||
"documents.audit.view",
|
||||
"employees.dashboard.view",
|
||||
"employees.view",
|
||||
"employees.create",
|
||||
"employees.edit",
|
||||
"employees.status",
|
||||
"employees.cross_branch",
|
||||
"consultants.view",
|
||||
"consultants.manage",
|
||||
"consultants.link_clients",
|
||||
"consultants.cross_branch",
|
||||
"consultants.managed_clients.manage",
|
||||
"consultants.workspace.manage",
|
||||
"consultants.service_requests.manage",
|
||||
"consultants.conversions.manage",
|
||||
|
||||
"billing.view",
|
||||
"billing.create",
|
||||
"billing.edit",
|
||||
"billing.approve",
|
||||
"billing.post",
|
||||
"billing.cancel",
|
||||
"billing.payment.create",
|
||||
"billing.payment.view",
|
||||
"billing.reports",
|
||||
"billing.cross_branch",
|
||||
"billing_fee_structure.view",
|
||||
"billing_fee_structure.import",
|
||||
"billing_fee_structure.edit",
|
||||
"billing_fee_structure.delete",
|
||||
"billing_invoice.generate",
|
||||
"billing_invoice.bulk_generate",
|
||||
|
||||
# Audit Firm can work on leads assigned to its audit firm.
|
||||
"marketplace_leads.view_assigned",
|
||||
"marketplace_leads.update",
|
||||
"marketplace_leads.convert",
|
||||
|
||||
"employees.ess.view", "employees.ess.profile.edit",
|
||||
"employees.work.view_self",
|
||||
"employees.work.manage",
|
||||
"employees.progress.view",
|
||||
"employees.registration.request",
|
||||
"employees.registration.approve",
|
||||
"employees.attendance.punch",
|
||||
"employees.attendance.view_self",
|
||||
"employees.attendance.view_all",
|
||||
"employees.attendance.approve",
|
||||
"employees.leave.apply",
|
||||
"employees.leave.view_self",
|
||||
"employees.leave.view_all",
|
||||
"employees.leave.approve",
|
||||
"employees.leave_type.manage",
|
||||
"employees.leave_balance.manage",
|
||||
"employees.documents.view_self",
|
||||
"employees.documents.upload_self",
|
||||
"employees.documents.view_all",
|
||||
"employees.documents.manage",
|
||||
"employees.documents.verify",
|
||||
"employees.documents.delete",
|
||||
"employees.document_type.manage",
|
||||
"employees.onboarding.view",
|
||||
"employees.onboarding.manage",
|
||||
"employees.onboarding.approve",
|
||||
"employees.offboarding.view",
|
||||
"employees.offboarding.manage",
|
||||
"employees.offboarding.approve",
|
||||
"employees.offboarding.request_self",
|
||||
"employees.payroll.payout",
|
||||
"employees.payroll.view_self",
|
||||
"employees.payroll.view",
|
||||
"employees.payroll.run",
|
||||
"employees.payroll.structure.manage",
|
||||
"employees.import",
|
||||
"employees.import.employee",
|
||||
"employees.import.leave_type",
|
||||
"employees.import.leave_balance",
|
||||
"employees.import.salary_structure",
|
||||
],
|
||||
"Partner": [
|
||||
"users.view",
|
||||
"system.settings.view",
|
||||
"services.view",
|
||||
"services.cross_branch",
|
||||
"service_tasks.view",
|
||||
"clients.view",
|
||||
"clients.create",
|
||||
"clients.import",
|
||||
"clients.edit",
|
||||
"clients.deactivate",
|
||||
"clients.activate",
|
||||
"clients.archive",
|
||||
"clients.restore",
|
||||
"clients.export",
|
||||
"clients.audit_log.view",
|
||||
"documents.view",
|
||||
"documents.upload",
|
||||
"documents.download",
|
||||
"documents.delete",
|
||||
"clients.view.own_only",
|
||||
"employees.dashboard.view",
|
||||
"employees.view",
|
||||
"employees.create",
|
||||
"employees.edit",
|
||||
"employees.status",
|
||||
"consultants.view",
|
||||
"consultants.link_clients",
|
||||
"consultants.cross_branch",
|
||||
"consultants.managed_clients.manage",
|
||||
"consultants.workspace.manage",
|
||||
|
||||
# Partner has almost the same firm-billing privileges as Firm Admin,
|
||||
# but is intentionally scoped to own clients through billing.view_own.
|
||||
"billing.view",
|
||||
"billing.create",
|
||||
"billing.edit",
|
||||
"billing.approve",
|
||||
"billing.post",
|
||||
"billing.cancel",
|
||||
"billing.payment.create",
|
||||
"billing.payment.view",
|
||||
"billing.reports",
|
||||
"billing.view_own",
|
||||
"billing_fee_structure.view",
|
||||
"billing_fee_structure.import",
|
||||
"billing_fee_structure.edit",
|
||||
"billing_fee_structure.delete",
|
||||
"billing_invoice.generate",
|
||||
"billing_invoice.bulk_generate",
|
||||
|
||||
# Partner can handle assigned marketplace leads for own clients/work.
|
||||
"marketplace_leads.view_assigned",
|
||||
"marketplace_leads.update",
|
||||
"marketplace_leads.convert",
|
||||
|
||||
"employees.ess.view", "employees.ess.profile.edit",
|
||||
"employees.work.view_self",
|
||||
"employees.work.manage",
|
||||
"employees.progress.view",
|
||||
"employees.registration.request",
|
||||
"employees.registration.approve",
|
||||
"employees.attendance.punch",
|
||||
"employees.attendance.view_self",
|
||||
"employees.attendance.view_all",
|
||||
"employees.attendance.approve",
|
||||
"employees.leave.apply",
|
||||
"employees.leave.view_self",
|
||||
"employees.leave.view_all",
|
||||
"employees.leave.approve",
|
||||
"employees.leave_type.manage",
|
||||
"employees.leave_balance.manage",
|
||||
"employees.documents.view_self",
|
||||
"employees.documents.upload_self",
|
||||
"employees.documents.view_all",
|
||||
"employees.documents.manage",
|
||||
"employees.documents.verify",
|
||||
"employees.documents.delete",
|
||||
"employees.document_type.manage",
|
||||
"employees.onboarding.view",
|
||||
"employees.onboarding.manage",
|
||||
"employees.onboarding.approve",
|
||||
"employees.offboarding.view",
|
||||
"employees.offboarding.manage",
|
||||
"employees.offboarding.approve",
|
||||
"employees.offboarding.request_self",
|
||||
"employees.payroll.payout",
|
||||
"employees.payroll.view_self",
|
||||
"employees.payroll.view",
|
||||
"employees.payroll.run",
|
||||
"employees.payroll.structure.manage",
|
||||
"employees.import",
|
||||
"employees.import.employee",
|
||||
"employees.import.leave_type",
|
||||
"employees.import.leave_balance",
|
||||
"employees.import.salary_structure",
|
||||
],
|
||||
"Branch Manager": [
|
||||
"users.view",
|
||||
"services.view",
|
||||
"services.create",
|
||||
"services.edit",
|
||||
"service_tasks.view",
|
||||
"service_tasks.create",
|
||||
"service_tasks.edit",
|
||||
"clients.view",
|
||||
"clients.create",
|
||||
"clients.edit",
|
||||
"clients.deactivate",
|
||||
"clients.activate",
|
||||
"clients.export",
|
||||
"clients.audit_log.view",
|
||||
"documents.view",
|
||||
"documents.upload",
|
||||
"documents.download",
|
||||
"employees.dashboard.view",
|
||||
"employees.view",
|
||||
"employees.create",
|
||||
"employees.edit",
|
||||
"employees.status",
|
||||
"consultants.view",
|
||||
|
||||
"billing.view",
|
||||
"billing.create",
|
||||
"billing_fee_structure.view",
|
||||
"employees.ess.view", "employees.ess.profile.edit",
|
||||
"employees.work.view_self",
|
||||
"employees.work.manage",
|
||||
"employees.progress.view",
|
||||
"employees.registration.request",
|
||||
"employees.registration.approve",
|
||||
"employees.attendance.punch",
|
||||
"employees.attendance.view_self",
|
||||
"employees.attendance.view_all",
|
||||
"employees.attendance.approve",
|
||||
"employees.leave.apply",
|
||||
"employees.leave.view_self",
|
||||
"employees.leave.view_all",
|
||||
"employees.leave.approve",
|
||||
"employees.leave_type.manage",
|
||||
"employees.leave_balance.manage",
|
||||
"employees.documents.view_self",
|
||||
"employees.documents.upload_self",
|
||||
"employees.documents.view_all",
|
||||
"employees.documents.manage",
|
||||
"employees.documents.verify",
|
||||
"employees.documents.delete",
|
||||
"employees.document_type.manage",
|
||||
"employees.onboarding.view",
|
||||
"employees.onboarding.manage",
|
||||
"employees.onboarding.approve",
|
||||
"employees.offboarding.view",
|
||||
"employees.offboarding.manage",
|
||||
"employees.offboarding.approve",
|
||||
"employees.offboarding.request_self",
|
||||
"employees.payroll.view_self",
|
||||
"employees.payroll.view",
|
||||
"employees.payroll.run",
|
||||
"employees.payroll.structure.manage",
|
||||
"employees.import",
|
||||
"employees.import.employee",
|
||||
"employees.import.leave_type",
|
||||
"employees.import.leave_balance",
|
||||
"employees.import.salary_structure",
|
||||
],
|
||||
"Staff": [
|
||||
"alerts.view_self",
|
||||
"employees.ess.view",
|
||||
"employees.ess.profile.edit",
|
||||
"employees.work.view_self",
|
||||
"employees.registration.request",
|
||||
"employees.attendance.punch",
|
||||
"employees.attendance.view_self",
|
||||
"employees.leave.apply",
|
||||
"employees.leave.view_self",
|
||||
"employees.documents.view_self",
|
||||
"employees.documents.upload_self",
|
||||
"employees.offboarding.request_self",
|
||||
"employees.payroll.view_self",
|
||||
"documents.view",
|
||||
"documents.upload",
|
||||
"documents.download",
|
||||
],
|
||||
"Client": [],
|
||||
"Consultant": [
|
||||
"alerts.view_self",
|
||||
"consultants.portal.view",
|
||||
"consultants.managed_clients.manage",
|
||||
"consultants.workspace.manage",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# Keep existing databases aligned with the billing permission policy.
|
||||
# The normal startup seed only adds missing permissions; it does not remove
|
||||
# permissions that were granted in an earlier patch. This sync is limited to
|
||||
# billing permissions for these default roles so existing non-billing features
|
||||
# and custom modules are not touched.
|
||||
BILLING_PERMISSION_CODES = {
|
||||
"billing.view",
|
||||
"billing.create",
|
||||
"billing.edit",
|
||||
"billing.approve",
|
||||
"billing.post",
|
||||
"billing.cancel",
|
||||
"billing.payment.create",
|
||||
"billing.payment.view",
|
||||
"billing.reports",
|
||||
"billing.cross_branch",
|
||||
"billing.cross_tenant",
|
||||
"billing.view_own",
|
||||
"billing_fee_structure.view",
|
||||
"billing_fee_structure.import",
|
||||
"billing_fee_structure.edit",
|
||||
"billing_fee_structure.delete",
|
||||
"billing_invoice.generate",
|
||||
"billing_invoice.bulk_generate",
|
||||
}
|
||||
|
||||
BILLING_ROLE_PERMISSION_SYNC = {
|
||||
"System Admin": {
|
||||
"billing.view",
|
||||
"billing.payment.view",
|
||||
"billing.reports",
|
||||
"billing.cross_branch",
|
||||
"billing.cross_tenant",
|
||||
"billing_fee_structure.view",
|
||||
},
|
||||
"Firm Admin": {
|
||||
"billing.view",
|
||||
"billing.create",
|
||||
"billing.edit",
|
||||
"billing.approve",
|
||||
"billing.post",
|
||||
"billing.cancel",
|
||||
"billing.payment.create",
|
||||
"billing.payment.view",
|
||||
"billing.reports",
|
||||
"billing.cross_branch",
|
||||
"billing_fee_structure.view",
|
||||
"billing_fee_structure.import",
|
||||
"billing_fee_structure.edit",
|
||||
"billing_fee_structure.delete",
|
||||
"billing_invoice.generate",
|
||||
"billing_invoice.bulk_generate",
|
||||
},
|
||||
"Partner": {
|
||||
"billing.view",
|
||||
"billing.create",
|
||||
"billing.edit",
|
||||
"billing.approve",
|
||||
"billing.post",
|
||||
"billing.cancel",
|
||||
"billing.payment.create",
|
||||
"billing.payment.view",
|
||||
"billing.reports",
|
||||
"billing.view_own",
|
||||
"billing_fee_structure.view",
|
||||
"billing_fee_structure.import",
|
||||
"billing_fee_structure.edit",
|
||||
"billing_fee_structure.delete",
|
||||
"billing_invoice.generate",
|
||||
"billing_invoice.bulk_generate",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
NOTICE_CASE_ROLE_PERMISSIONS = {
|
||||
"System Admin": [
|
||||
"notice_cases.view", "notice_cases.create", "notice_cases.edit",
|
||||
"notice_cases.events.manage", "notice_cases.hearings.manage", "notice_cases.orders.manage",
|
||||
"notice_cases.documents.upload", "notice_cases.documents.download", "notice_cases.documents.delete",
|
||||
"notice_cases.cross_branch", "notice_cases.cross_tenant",
|
||||
],
|
||||
"Firm Admin": [
|
||||
"notice_cases.view", "notice_cases.create", "notice_cases.edit",
|
||||
"notice_cases.events.manage", "notice_cases.hearings.manage", "notice_cases.orders.manage",
|
||||
"notice_cases.documents.upload", "notice_cases.documents.download", "notice_cases.documents.delete",
|
||||
"notice_cases.cross_branch",
|
||||
],
|
||||
"Partner": [
|
||||
"notice_cases.view", "notice_cases.create", "notice_cases.edit",
|
||||
"notice_cases.events.manage", "notice_cases.hearings.manage", "notice_cases.orders.manage",
|
||||
"notice_cases.documents.upload", "notice_cases.documents.download",
|
||||
],
|
||||
"Branch Manager": [
|
||||
"notice_cases.view", "notice_cases.create", "notice_cases.edit",
|
||||
"notice_cases.events.manage", "notice_cases.hearings.manage", "notice_cases.orders.manage",
|
||||
"notice_cases.documents.upload", "notice_cases.documents.download",
|
||||
"notice_cases.cross_branch",
|
||||
],
|
||||
"Staff": [
|
||||
"notice_cases.view", "notice_cases.events.manage",
|
||||
"notice_cases.documents.upload", "notice_cases.documents.download",
|
||||
],
|
||||
}
|
||||
|
||||
for _role_name, _codes in NOTICE_CASE_ROLE_PERMISSIONS.items():
|
||||
_target = ROLE_PERMISSION_MAP.setdefault(_role_name, [])
|
||||
for _code in _codes:
|
||||
if isinstance(_target, set):
|
||||
_target.add(_code)
|
||||
elif _code not in _target:
|
||||
_target.append(_code)
|
||||
|
||||
|
||||
|
||||
|
||||
def _fy_dates_from_code(year_code: str) -> tuple[date, date, str]:
|
||||
parts = (year_code or "").split("-", 1)
|
||||
try:
|
||||
start_year = int(parts[0])
|
||||
except Exception:
|
||||
start_year = 2025
|
||||
end_year = start_year + 1
|
||||
assessment_year = f"{end_year}-{str(end_year + 1)[-2:]}"
|
||||
return date(start_year, 4, 1), date(end_year, 3, 31), assessment_year
|
||||
|
||||
|
||||
def _ensure_financial_year(db, tenant_id: int, year_code: str) -> FinancialYear:
|
||||
fy = db.execute(
|
||||
select(FinancialYear).where(
|
||||
FinancialYear.tenant_id == tenant_id,
|
||||
FinancialYear.year_code == year_code,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if fy:
|
||||
return fy
|
||||
|
||||
start_date, end_date, assessment_year = _fy_dates_from_code(year_code)
|
||||
current_exists = db.execute(
|
||||
select(FinancialYear.id).where(
|
||||
FinancialYear.tenant_id == tenant_id,
|
||||
FinancialYear.is_current.is_(True),
|
||||
)
|
||||
).first()
|
||||
now = datetime.now(timezone.utc)
|
||||
fy = FinancialYear(
|
||||
tenant_id=tenant_id,
|
||||
year_code=year_code,
|
||||
assessment_year=assessment_year,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
is_current=current_exists is None,
|
||||
is_locked=False,
|
||||
created_at_utc=now,
|
||||
updated_at_utc=now,
|
||||
)
|
||||
db.add(fy)
|
||||
db.commit()
|
||||
db.refresh(fy)
|
||||
return fy
|
||||
|
||||
|
||||
def _ensure_financial_years_for_all_tenants(db, default_year_code: str) -> None:
|
||||
tenant_ids = db.execute(select(Tenant.id)).scalars().all()
|
||||
for tenant_id in tenant_ids:
|
||||
_ensure_financial_year(db, int(tenant_id), default_year_code)
|
||||
|
||||
def on_startup(app: FastAPI) -> None:
|
||||
s = get_settings()
|
||||
inspector = inspect(CommonEngine)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
if "audit_logs" not in existing_tables:
|
||||
CommonBase.metadata.create_all(bind=CommonEngine, tables=[AuditLog.__table__])
|
||||
existing_tables = set(inspect(CommonEngine).get_table_names())
|
||||
|
||||
required_tables = {
|
||||
"tenants",
|
||||
"branches",
|
||||
"branch_settings",
|
||||
"users",
|
||||
"roles",
|
||||
"permissions",
|
||||
"role_permissions",
|
||||
"user_roles",
|
||||
"audit_logs",
|
||||
}
|
||||
|
||||
missing_optional_tables = []
|
||||
if "invite_tokens" not in existing_tables:
|
||||
missing_optional_tables.append(InviteToken.__table__)
|
||||
if "password_reset_tokens" not in existing_tables:
|
||||
missing_optional_tables.append(PasswordResetToken.__table__)
|
||||
if "service_categories" not in existing_tables:
|
||||
missing_optional_tables.append(ServiceCategory.__table__)
|
||||
if "service_catalogues" not in existing_tables:
|
||||
missing_optional_tables.append(ServiceCatalogue.__table__)
|
||||
if "firm_service_selections" not in existing_tables:
|
||||
missing_optional_tables.append(FirmServiceSelection.__table__)
|
||||
if "firm_service_task_templates" not in existing_tables:
|
||||
missing_optional_tables.append(FirmServiceTaskTemplate.__table__)
|
||||
billing_tables = [
|
||||
("billing_settings", BillingSettings.__table__),
|
||||
("billing_fee_groups", BillingFeeGroup.__table__),
|
||||
("billing_fee_group_services", BillingFeeGroupService.__table__),
|
||||
("billing_invoices", BillingInvoice.__table__),
|
||||
("billing_invoice_lines", BillingInvoiceLine.__table__),
|
||||
]
|
||||
for table_name, table in billing_tables:
|
||||
if table_name not in existing_tables:
|
||||
missing_optional_tables.append(table)
|
||||
|
||||
platform_billing_tables = [
|
||||
("platform_plans", PlatformPlan.__table__),
|
||||
("platform_plan_features", PlatformPlanFeature.__table__),
|
||||
("platform_billing_accounts", PlatformBillingAccount.__table__),
|
||||
("platform_subscriptions", PlatformSubscription.__table__),
|
||||
("platform_invoices", PlatformInvoice.__table__),
|
||||
("platform_invoice_lines", PlatformInvoiceLine.__table__),
|
||||
("platform_payments", PlatformPayment.__table__),
|
||||
]
|
||||
marketplace_tables = [
|
||||
("marketplace_leads", MarketplaceLead.__table__),
|
||||
("marketplace_lead_assignments", MarketplaceLeadAssignment.__table__),
|
||||
]
|
||||
for table_name, table in platform_billing_tables:
|
||||
if table_name not in existing_tables:
|
||||
missing_optional_tables.append(table)
|
||||
for table_name, table in marketplace_tables:
|
||||
if table_name not in existing_tables:
|
||||
missing_optional_tables.append(table)
|
||||
|
||||
documents_tables = [
|
||||
("engagement_documents", EngagementDocument.__table__),
|
||||
("engagement_document_versions", EngagementDocumentVersion.__table__),
|
||||
("document_access_logs", DocumentAccessLog.__table__),
|
||||
]
|
||||
for table_name, table in documents_tables:
|
||||
if table_name not in existing_tables:
|
||||
missing_optional_tables.append(table)
|
||||
if "user_alerts" not in existing_tables:
|
||||
missing_optional_tables.append(UserAlert.__table__)
|
||||
if "financial_years" not in existing_tables:
|
||||
missing_optional_tables.append(FinancialYear.__table__)
|
||||
|
||||
notice_case_tables = [
|
||||
("notice_cases", NoticeCase.__table__),
|
||||
("notice_case_events", NoticeCaseEvent.__table__),
|
||||
("notice_case_hearings", NoticeCaseHearing.__table__),
|
||||
("notice_case_orders", NoticeCaseOrder.__table__),
|
||||
("notice_case_documents", NoticeCaseDocument.__table__),
|
||||
]
|
||||
for table_name, table in notice_case_tables:
|
||||
if table_name not in existing_tables:
|
||||
missing_optional_tables.append(table)
|
||||
if "employees" not in existing_tables:
|
||||
missing_optional_tables.append(Employee.__table__)
|
||||
if "employee_registration_requests" not in existing_tables and "employees" in existing_tables:
|
||||
missing_optional_tables.append(EmployeeRegistrationRequest.__table__)
|
||||
if "employee_attendance" not in existing_tables and "employees" in existing_tables:
|
||||
missing_optional_tables.append(EmployeeAttendance.__table__)
|
||||
if "employee_onboarding_checklist_items" not in existing_tables and "employees" in existing_tables:
|
||||
missing_optional_tables.append(EmployeeOnboardingChecklistItem.__table__)
|
||||
if "employee_onboarding_tasks" not in existing_tables and "employees" in existing_tables:
|
||||
missing_optional_tables.append(EmployeeOnboardingTask.__table__)
|
||||
if "employee_offboarding_requests" not in existing_tables and "employees" in existing_tables:
|
||||
missing_optional_tables.append(EmployeeOffboardingRequest.__table__)
|
||||
if "employee_offboarding_tasks" not in existing_tables and "employees" in existing_tables:
|
||||
missing_optional_tables.append(EmployeeOffboardingTask.__table__)
|
||||
if "employee_salary_structures" not in existing_tables and "employees" in existing_tables:
|
||||
missing_optional_tables.append(EmployeeSalaryStructure.__table__)
|
||||
if "employee_payroll_runs" not in existing_tables and "employees" in existing_tables:
|
||||
missing_optional_tables.append(EmployeePayrollRun.__table__)
|
||||
if "employee_payslips" not in existing_tables and "employees" in existing_tables:
|
||||
missing_optional_tables.append(EmployeePayslip.__table__)
|
||||
if "consultant_workspaces" not in existing_tables and "consultant_profiles" in existing_tables:
|
||||
missing_optional_tables.append(ConsultantWorkspace.__table__)
|
||||
if "consultant_service_requests" not in existing_tables and "consultant_profiles" in existing_tables:
|
||||
missing_optional_tables.append(ConsultantServiceRequest.__table__)
|
||||
if missing_optional_tables:
|
||||
CommonBase.metadata.create_all(bind=CommonEngine, tables=missing_optional_tables)
|
||||
|
||||
if not required_tables.issubset(existing_tables):
|
||||
raise RuntimeError("Database schema is not initialized. Run 'alembic upgrade head' first.")
|
||||
|
||||
_ensure_user_lifecycle_columns()
|
||||
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
tenant = db.execute(select(Tenant).where(Tenant.code == s.DEFAULT_TENANT_CODE)).scalar_one_or_none()
|
||||
if not tenant:
|
||||
tenant = Tenant(code=s.DEFAULT_TENANT_CODE, name="Default Tenant", is_active=True)
|
||||
db.add(tenant)
|
||||
db.commit()
|
||||
db.refresh(tenant)
|
||||
|
||||
branch = db.execute(
|
||||
select(Branch).where(Branch.tenant_id == tenant.id, Branch.code == s.DEFAULT_BRANCH_CODE)
|
||||
).scalar_one_or_none()
|
||||
if not branch:
|
||||
branch = Branch(
|
||||
tenant_id=tenant.id,
|
||||
code=s.DEFAULT_BRANCH_CODE,
|
||||
name="Main Branch",
|
||||
timezone=s.DEFAULT_TIMEZONE,
|
||||
is_active=True,
|
||||
allow_login=True,
|
||||
)
|
||||
db.add(branch)
|
||||
db.commit()
|
||||
db.refresh(branch)
|
||||
|
||||
bs = db.execute(select(BranchSettings).where(BranchSettings.branch_id == branch.id)).scalar_one_or_none()
|
||||
if not bs:
|
||||
bs = BranchSettings(branch_id=branch.id)
|
||||
db.add(bs)
|
||||
db.commit()
|
||||
|
||||
_ensure_financial_years_for_all_tenants(db, s.DEFAULT_YEAR_CODE)
|
||||
|
||||
for legacy_name, new_name in LEGACY_ROLE_RENAMES.items():
|
||||
legacy_role = db.execute(select(Role).where(Role.name == legacy_name)).scalar_one_or_none()
|
||||
target_role = db.execute(select(Role).where(Role.name == new_name)).scalar_one_or_none()
|
||||
if legacy_role and not target_role:
|
||||
legacy_role.name = new_name
|
||||
elif legacy_role and target_role:
|
||||
for user_role in db.execute(
|
||||
select(UserRole).where(UserRole.role_id == legacy_role.id)
|
||||
).scalars().all():
|
||||
exists = db.execute(
|
||||
select(UserRole).where(
|
||||
UserRole.user_id == user_role.user_id,
|
||||
UserRole.role_id == target_role.id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not exists:
|
||||
db.add(UserRole(user_id=user_role.user_id, role_id=target_role.id))
|
||||
db.flush()
|
||||
db.delete(legacy_role)
|
||||
db.commit()
|
||||
|
||||
for role_name in DEFAULT_ROLES:
|
||||
exists = db.execute(select(Role).where(Role.name == role_name)).scalar_one_or_none()
|
||||
if not exists:
|
||||
db.add(Role(name=role_name, is_active=True))
|
||||
db.commit()
|
||||
|
||||
for code, name in DEFAULT_PERMISSIONS:
|
||||
exists = db.execute(select(Permission).where(Permission.code == code)).scalar_one_or_none()
|
||||
if not exists:
|
||||
db.add(Permission(code=code, name=name, is_active=True))
|
||||
db.commit()
|
||||
|
||||
roles = {r.name: r for r in db.execute(select(Role)).scalars().all()}
|
||||
permissions = {p.code: p for p in db.execute(select(Permission)).scalars().all()}
|
||||
|
||||
for role_name, permission_codes in ROLE_PERMISSION_MAP.items():
|
||||
role = roles.get(role_name)
|
||||
if not role:
|
||||
continue
|
||||
|
||||
permission_codes = list(dict.fromkeys(permission_codes))
|
||||
|
||||
for code in permission_codes:
|
||||
permission = permissions.get(code)
|
||||
if not permission:
|
||||
continue
|
||||
|
||||
exists = db.execute(
|
||||
select(RolePermission).where(
|
||||
RolePermission.role_id == role.id,
|
||||
RolePermission.permission_id == permission.id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if not exists:
|
||||
db.add(RolePermission(role_id=role.id, permission_id=permission.id))
|
||||
db.commit()
|
||||
|
||||
# Enforce the updated billing privilege matrix for existing databases.
|
||||
# This removes stale billing permissions from System Admin and grants
|
||||
# Partner own-client billing privileges without altering other modules.
|
||||
billing_permissions = {
|
||||
code: permissions[code]
|
||||
for code in BILLING_PERMISSION_CODES
|
||||
if code in permissions
|
||||
}
|
||||
for role_name, allowed_codes in BILLING_ROLE_PERMISSION_SYNC.items():
|
||||
role = roles.get(role_name)
|
||||
if not role:
|
||||
continue
|
||||
|
||||
allowed_permission_ids = {
|
||||
billing_permissions[code].id
|
||||
for code in allowed_codes
|
||||
if code in billing_permissions
|
||||
}
|
||||
billing_permission_ids = {permission.id for permission in billing_permissions.values()}
|
||||
|
||||
existing_links = db.execute(
|
||||
select(RolePermission).where(
|
||||
RolePermission.role_id == role.id,
|
||||
RolePermission.permission_id.in_(billing_permission_ids),
|
||||
)
|
||||
).scalars().all() if billing_permission_ids else []
|
||||
|
||||
existing_ids = {link.permission_id for link in existing_links}
|
||||
for link in existing_links:
|
||||
if link.permission_id not in allowed_permission_ids:
|
||||
db.delete(link)
|
||||
|
||||
for permission_id in allowed_permission_ids - existing_ids:
|
||||
db.add(RolePermission(role_id=role.id, permission_id=permission_id))
|
||||
db.commit()
|
||||
|
||||
any_user = db.execute(select(User.id)).first()
|
||||
if not any_user:
|
||||
admin = User(
|
||||
email=s.BOOTSTRAP_ADMIN_EMAIL,
|
||||
full_name="System Admin",
|
||||
password_hash=hash_password(s.BOOTSTRAP_ADMIN_PASSWORD),
|
||||
tenant_id=tenant.id,
|
||||
branch_id=branch.id,
|
||||
is_active=True,
|
||||
allow_login=True,
|
||||
is_locked=False,
|
||||
deleted_at=None,
|
||||
)
|
||||
db.add(admin)
|
||||
db.commit()
|
||||
db.refresh(admin)
|
||||
|
||||
if roles.get("System Admin"):
|
||||
exists = db.execute(
|
||||
select(UserRole).where(
|
||||
UserRole.user_id == admin.id,
|
||||
UserRole.role_id == roles["System Admin"].id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not exists:
|
||||
db.add(UserRole(user_id=admin.id, role_id=roles["System Admin"].id))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Phase 7O: start alert notification/escalation automation after schema and seed checks.
|
||||
start_notification_scheduler()
|
||||
@@ -0,0 +1,622 @@
|
||||
from urllib.parse import parse_qsl, urlencode
|
||||
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import select
|
||||
|
||||
templates = Jinja2Templates(directory="app")
|
||||
|
||||
from app.core.db.common import CommonSessionLocal
|
||||
from app.modules.core.iam.scope import build_scope, list_visible_branches, list_visible_tenants
|
||||
from app.modules.core.rbac.ui_permissions import (
|
||||
can_change_branch_tenant,
|
||||
can_export_clients,
|
||||
can_import_service_tasks,
|
||||
can_import_services,
|
||||
can_manage_branches,
|
||||
can_manage_clients,
|
||||
can_manage_rbac,
|
||||
can_manage_service_tasks,
|
||||
can_manage_services,
|
||||
can_manage_settings,
|
||||
can_manage_tenants,
|
||||
can_manage_users,
|
||||
can_view_employee_dashboard,
|
||||
can_view_employees,
|
||||
can_manage_employees,
|
||||
can_change_employee_status,
|
||||
can_switch_employee_tenant,
|
||||
can_switch_employee_branch,
|
||||
can_view_employee_portal,
|
||||
can_edit_own_employee_profile,
|
||||
can_view_own_employee_work,
|
||||
can_manage_employee_work,
|
||||
can_view_employee_progress,
|
||||
can_request_employee_registration,
|
||||
can_approve_employee_registrations,
|
||||
can_punch_employee_attendance,
|
||||
can_view_own_employee_attendance,
|
||||
can_view_all_employee_attendance,
|
||||
can_approve_employee_attendance,
|
||||
can_apply_employee_leave,
|
||||
can_view_own_employee_leave,
|
||||
can_view_all_employee_leave,
|
||||
can_approve_employee_leave,
|
||||
can_manage_employee_leave_types,
|
||||
can_manage_employee_leave_balances,
|
||||
can_view_own_employee_documents,
|
||||
can_upload_own_employee_documents,
|
||||
can_view_all_employee_documents,
|
||||
can_manage_employee_documents,
|
||||
can_verify_employee_documents,
|
||||
can_manage_employee_document_types,
|
||||
can_view_employee_onboarding,
|
||||
can_manage_employee_onboarding,
|
||||
can_approve_employee_onboarding,
|
||||
can_view_employee_offboarding,
|
||||
can_manage_employee_offboarding,
|
||||
can_approve_employee_offboarding,
|
||||
can_request_own_employee_offboarding,
|
||||
can_import_employee_hr,
|
||||
can_manage_employee_payroll_structures,
|
||||
can_run_employee_payroll,
|
||||
can_view_employee_payroll,
|
||||
can_view_own_employee_payslips,
|
||||
can_approve_employee_payroll,
|
||||
can_view_consultants,
|
||||
can_manage_consultants,
|
||||
can_link_consultant_clients,
|
||||
can_manage_consultant_service_requests,
|
||||
can_manage_consultant_conversions,
|
||||
can_view_consultant_portal,
|
||||
can_manage_own_consultant_workspace,
|
||||
can_switch_client_branch,
|
||||
can_switch_client_tenant,
|
||||
can_switch_service_branch,
|
||||
can_switch_service_tenant,
|
||||
can_view_audit,
|
||||
can_view_branches,
|
||||
can_view_clients,
|
||||
can_view_billing,
|
||||
can_create_billing,
|
||||
can_generate_billing_invoices,
|
||||
can_view_billing_fee_structure,
|
||||
can_import_billing_fee_structure,
|
||||
can_view_platform_billing,
|
||||
can_manage_platform_billing,
|
||||
can_generate_platform_billing,
|
||||
can_manage_platform_plans,
|
||||
can_manage_platform_subscriptions,
|
||||
can_view_marketplace_leads,
|
||||
can_create_marketplace_leads,
|
||||
can_assign_marketplace_leads,
|
||||
can_update_marketplace_leads,
|
||||
can_convert_marketplace_leads,
|
||||
can_view_documents,
|
||||
can_upload_documents,
|
||||
can_download_documents,
|
||||
can_delete_documents,
|
||||
can_view_rbac,
|
||||
can_view_services,
|
||||
can_view_settings,
|
||||
can_view_tenants,
|
||||
can_view_users,
|
||||
can_view_own_alerts,
|
||||
can_manage_alerts,
|
||||
can_view_notice_cases,
|
||||
can_manage_notice_cases,
|
||||
can_upload_notice_case_documents,
|
||||
can_download_notice_case_documents,
|
||||
can_delete_notice_case_documents,
|
||||
)
|
||||
|
||||
|
||||
def build_page_url(base_url: str, page: int, query: str | None = None) -> str:
|
||||
params = dict(parse_qsl((query or "").lstrip("?"), keep_blank_values=True))
|
||||
params["page"] = str(page)
|
||||
qs = urlencode(params)
|
||||
return f"{base_url}?{qs}" if qs else base_url
|
||||
|
||||
|
||||
def get_active_tenant_id(request, current_user=None):
|
||||
if not current_user:
|
||||
return None
|
||||
return request.session.get("active_tenant_id") or getattr(current_user, "tenant_id", None)
|
||||
|
||||
|
||||
def get_active_branch_id(request, current_user=None):
|
||||
if not current_user:
|
||||
return None
|
||||
# return None when "all branches" context is active
|
||||
val = request.session.get("active_branch_id")
|
||||
if val in (None, "", 0, "0"):
|
||||
return None
|
||||
return val
|
||||
|
||||
|
||||
def get_active_tenant_code(request, current_user=None):
|
||||
if not current_user:
|
||||
return None
|
||||
return request.session.get("active_tenant_code") or request.session.get("tenant_code") or getattr(request.state, "tenant_code", None)
|
||||
|
||||
|
||||
def get_active_branch_code(request, current_user=None):
|
||||
if not current_user:
|
||||
return None
|
||||
val = request.session.get("active_branch_code") or request.session.get("branch_code")
|
||||
return val or getattr(request.state, "branch_code", None)
|
||||
|
||||
|
||||
def get_active_financial_year(request, current_user=None):
|
||||
if not current_user:
|
||||
return None
|
||||
return request.session.get("active_financial_year") or getattr(request.state, "year_code", None)
|
||||
|
||||
|
||||
def get_active_assessment_year(request, current_user=None):
|
||||
if not current_user:
|
||||
return None
|
||||
fy_code = get_active_financial_year(request, current_user)
|
||||
if not fy_code:
|
||||
return None
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
from app.modules.core.tenancy.models import FinancialYear
|
||||
tenant_id = get_active_tenant_id(request, current_user) or getattr(current_user, "tenant_id", None)
|
||||
fy = db.execute(
|
||||
select(FinancialYear).where(
|
||||
FinancialYear.tenant_id == tenant_id,
|
||||
FinancialYear.year_code == fy_code,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return fy.assessment_year if fy else None
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_unread_alert_count(request, current_user=None):
|
||||
if not current_user:
|
||||
return 0
|
||||
try:
|
||||
from app.modules.alerts.service import count_unread_alerts
|
||||
except Exception:
|
||||
return 0
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
return count_unread_alerts(db, current_user)
|
||||
except Exception:
|
||||
return 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
|
||||
def _safe_static_path(path: str | None) -> str | None:
|
||||
path = (path or "").strip()
|
||||
if not path:
|
||||
return None
|
||||
if path.startswith("/static/"):
|
||||
return path
|
||||
if path.startswith("app/ui/static/"):
|
||||
return "/static/" + path.split("app/ui/static/", 1)[1]
|
||||
return path
|
||||
|
||||
|
||||
def get_domain_context(request) -> dict:
|
||||
"""Return safe domain context populated by Phase 7T.2 middleware."""
|
||||
try:
|
||||
ctx = getattr(request.state, "domain_context", None)
|
||||
return ctx if isinstance(ctx, dict) else {"is_resolved": False}
|
||||
except Exception:
|
||||
return {"is_resolved": False}
|
||||
|
||||
|
||||
def _branding_default() -> dict:
|
||||
return {
|
||||
"firm_name": "Audit Firm ERP",
|
||||
"branch_name": "",
|
||||
"logo_url": None,
|
||||
"favicon_url": None,
|
||||
"primary_color": "#2563eb",
|
||||
"accent_color": "#0f172a",
|
||||
"contact_email": None,
|
||||
"contact_mobile": None,
|
||||
"website_url": None,
|
||||
"domain_name": None,
|
||||
"domain_type": None,
|
||||
"domain_resolved": False,
|
||||
"is_marketplace_domain": False,
|
||||
"is_consultant_domain": False,
|
||||
"consultant_name": None,
|
||||
"consultant_firm_name": None,
|
||||
}
|
||||
|
||||
|
||||
def _tenant_branding_from_row(tenant, branch=None, default: dict | None = None) -> dict:
|
||||
default = default or _branding_default()
|
||||
if not tenant:
|
||||
return default.copy()
|
||||
return {
|
||||
**default,
|
||||
"firm_name": getattr(tenant, "display_name", None) or getattr(tenant, "name", None) or default["firm_name"],
|
||||
"branch_name": getattr(branch, "name", None) if branch else "All Branches",
|
||||
"logo_url": _safe_static_path(getattr(tenant, "logo_path", None)),
|
||||
"favicon_url": _safe_static_path(getattr(tenant, "favicon_path", None)),
|
||||
"primary_color": getattr(tenant, "primary_color", None) or default["primary_color"],
|
||||
"accent_color": getattr(tenant, "accent_color", None) or default["accent_color"],
|
||||
"contact_email": getattr(tenant, "contact_email", None),
|
||||
"contact_mobile": getattr(tenant, "contact_mobile", None),
|
||||
"website_url": getattr(tenant, "website_url", None),
|
||||
}
|
||||
|
||||
|
||||
def _domain_branding(request, default: dict | None = None) -> dict:
|
||||
default = default or _branding_default()
|
||||
ctx = get_domain_context(request)
|
||||
if not ctx.get("is_resolved"):
|
||||
return default.copy()
|
||||
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
from app.modules.core.tenancy.models import Branch, Tenant
|
||||
from app.modules.consultants.models import ConsultantProfile
|
||||
from app.modules.core.iam.models import User
|
||||
domain_type = ctx.get("domain_type")
|
||||
tenant_id = ctx.get("tenant_id") or ctx.get("parent_tenant_id")
|
||||
branch_id = ctx.get("branch_id")
|
||||
consultant_id = ctx.get("consultant_id")
|
||||
|
||||
tenant = db.execute(select(Tenant).where(Tenant.id == tenant_id)).scalar_one_or_none() if tenant_id else None
|
||||
branch = db.execute(select(Branch).where(Branch.id == branch_id)).scalar_one_or_none() if branch_id else None
|
||||
branding = _tenant_branding_from_row(tenant, branch, default)
|
||||
branding.update({
|
||||
"domain_name": ctx.get("domain_name") or ctx.get("host"),
|
||||
"domain_type": domain_type,
|
||||
"domain_resolved": True,
|
||||
"is_marketplace_domain": domain_type == "marketplace",
|
||||
"is_consultant_domain": str(domain_type or "").startswith("consultant_"),
|
||||
})
|
||||
|
||||
if domain_type == "marketplace":
|
||||
branding["firm_name"] = "FilingABC"
|
||||
branding["branch_name"] = "Marketplace"
|
||||
return branding
|
||||
|
||||
if consultant_id:
|
||||
consultant = db.execute(select(ConsultantProfile).where(ConsultantProfile.id == consultant_id)).scalar_one_or_none()
|
||||
if consultant:
|
||||
consultant_name = getattr(consultant, "contact_person", None) or getattr(consultant, "firm_name", None) or "Consultant"
|
||||
consultant_firm_name = getattr(consultant, "firm_name", None) or consultant_name
|
||||
branding["consultant_name"] = consultant_name
|
||||
branding["consultant_firm_name"] = consultant_firm_name
|
||||
branding["firm_name"] = consultant_firm_name
|
||||
branding["branch_name"] = "Consultant Workspace"
|
||||
branding["contact_email"] = getattr(consultant, "email", None) or branding.get("contact_email")
|
||||
branding["contact_mobile"] = getattr(consultant, "mobile", None) or branding.get("contact_mobile")
|
||||
|
||||
# If the consultant user has a profile photo, use it as the domain logo.
|
||||
user_id = getattr(consultant, "user_id", None)
|
||||
if user_id:
|
||||
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
||||
logo = _safe_static_path(getattr(user, "profile_photo_path", None))
|
||||
if logo:
|
||||
branding["logo_url"] = logo
|
||||
return branding
|
||||
except Exception:
|
||||
return default.copy()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_current_tenant_name(request, current_user=None):
|
||||
if not current_user:
|
||||
return _domain_branding(request).get("firm_name") or "Audit Firm"
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
from app.modules.core.tenancy.models import Tenant
|
||||
tenant_id = get_active_tenant_id(request, current_user)
|
||||
tenant = db.execute(select(Tenant).where(Tenant.id == tenant_id)).scalar_one_or_none()
|
||||
if not tenant:
|
||||
return _domain_branding(request).get("firm_name") or "Audit Firm"
|
||||
return getattr(tenant, "display_name", None) or tenant.name or "Audit Firm"
|
||||
except Exception:
|
||||
return _domain_branding(request).get("firm_name") or "Audit Firm"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_current_branch_name(request, current_user=None):
|
||||
if not current_user:
|
||||
return _domain_branding(request).get("branch_name") or "-"
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
from app.modules.core.tenancy.models import Branch
|
||||
branch_id = get_active_branch_id(request, current_user) or getattr(current_user, "branch_id", None)
|
||||
if not branch_id:
|
||||
return "All Branches"
|
||||
branch = db.execute(select(Branch).where(Branch.id == branch_id)).scalar_one_or_none()
|
||||
return branch.name if branch else "-"
|
||||
except Exception:
|
||||
return "-"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_current_firm_branding(request, current_user=None):
|
||||
default = _branding_default()
|
||||
|
||||
# Before login, domain branding is the only safe branding source. This supports
|
||||
# arrr.associates, auditfirm.filingabc.com, filingabc.com and consultant domains.
|
||||
if not current_user:
|
||||
return _domain_branding(request, default)
|
||||
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
from app.modules.core.tenancy.models import Branch, Tenant
|
||||
tenant_id = get_active_tenant_id(request, current_user)
|
||||
branch_id = get_active_branch_id(request, current_user) or getattr(current_user, "branch_id", None)
|
||||
tenant = db.execute(select(Tenant).where(Tenant.id == tenant_id)).scalar_one_or_none()
|
||||
branch = db.execute(select(Branch).where(Branch.id == branch_id)).scalar_one_or_none() if branch_id else None
|
||||
if not tenant:
|
||||
return _domain_branding(request, default)
|
||||
branding = _tenant_branding_from_row(tenant, branch, default)
|
||||
ctx = get_domain_context(request)
|
||||
if ctx.get("is_resolved"):
|
||||
branding.update({
|
||||
"domain_name": ctx.get("domain_name") or ctx.get("host"),
|
||||
"domain_type": ctx.get("domain_type"),
|
||||
"domain_resolved": True,
|
||||
"is_marketplace_domain": ctx.get("domain_type") == "marketplace",
|
||||
"is_consultant_domain": str(ctx.get("domain_type") or "").startswith("consultant_"),
|
||||
})
|
||||
return branding
|
||||
except Exception:
|
||||
return _domain_branding(request, default)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
|
||||
def get_user_profile_photo_url(current_user=None):
|
||||
if not current_user:
|
||||
return None
|
||||
try:
|
||||
from app.modules.core.iam.profile_service import profile_photo_url
|
||||
return profile_photo_url(current_user)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_user_initials(current_user=None):
|
||||
try:
|
||||
from app.modules.core.iam.profile_service import user_initials
|
||||
return user_initials(current_user)
|
||||
except Exception:
|
||||
return "U"
|
||||
|
||||
|
||||
def get_client_sidebar_auditor_card(request, current_user=None):
|
||||
"""Return the client-facing auditor card for the logged-in client user.
|
||||
|
||||
This is used only by the sidebar. It reuses Phase 7Q.5 auditor_service and
|
||||
does not create or alter any business workflow.
|
||||
"""
|
||||
if not current_user:
|
||||
return None
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
from app.modules.clients.auditor_service import build_client_auditor_card
|
||||
from app.modules.clients.models import Client
|
||||
from app.modules.core.tenancy.models import Branch, Tenant
|
||||
|
||||
tenant_id = get_active_tenant_id(request, current_user) or getattr(current_user, "tenant_id", None)
|
||||
email = (getattr(current_user, "email", None) or "").strip().lower()
|
||||
|
||||
stmt = (
|
||||
select(Client, Tenant.name.label("tenant_name"), Branch.name.label("branch_name"))
|
||||
.join(Tenant, Tenant.id == Client.tenant_id, isouter=True)
|
||||
.join(Branch, Branch.id == Client.branch_id, isouter=True)
|
||||
.where(Client.is_active.is_(True), Client.is_archived.is_(False))
|
||||
)
|
||||
if tenant_id:
|
||||
stmt = stmt.where(Client.tenant_id == int(tenant_id))
|
||||
if email:
|
||||
stmt = stmt.where((Client.portal_user_id == current_user.id) | (Client.email == email) | (Client.alternate_email == email))
|
||||
else:
|
||||
stmt = stmt.where(Client.portal_user_id == current_user.id)
|
||||
|
||||
result = db.execute(stmt.order_by(Client.id.desc())).first()
|
||||
if not result:
|
||||
return None
|
||||
|
||||
client, tenant_name, branch_name = result
|
||||
client_row = {
|
||||
"id": client.id,
|
||||
"tenant_id": client.tenant_id,
|
||||
"branch_id": client.branch_id,
|
||||
"tenant_name": tenant_name,
|
||||
"branch_name": branch_name,
|
||||
"partner_id": client.partner_id,
|
||||
"default_review_partner_user_id": client.default_review_partner_user_id,
|
||||
}
|
||||
return build_client_auditor_card(db, client_row)
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def get_context_tenants(request, current_user=None, permissions=None, role_names=None):
|
||||
if not current_user:
|
||||
return []
|
||||
|
||||
if not (
|
||||
can_switch_service_tenant(current_user, permissions, role_names)
|
||||
or can_switch_client_tenant(current_user, permissions, role_names)
|
||||
or can_switch_employee_tenant(current_user, permissions, role_names)
|
||||
):
|
||||
return []
|
||||
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
scope = build_scope(db, current_user)
|
||||
return list_visible_tenants(db, scope)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_context_branches(request, current_user=None, permissions=None, role_names=None):
|
||||
if not current_user:
|
||||
return []
|
||||
|
||||
if not (
|
||||
can_switch_service_branch(current_user, permissions, role_names)
|
||||
or can_switch_client_branch(current_user, permissions, role_names)
|
||||
or can_switch_employee_branch(current_user, permissions, role_names)
|
||||
):
|
||||
return []
|
||||
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
scope = build_scope(db, current_user)
|
||||
tenant_id = int(get_active_tenant_id(request, current_user) or current_user.tenant_id)
|
||||
return list_visible_branches(db, scope, tenant_id=tenant_id)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def get_context_financial_years(request, current_user=None, permissions=None, role_names=None):
|
||||
if not current_user:
|
||||
return []
|
||||
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
from app.modules.core.tenancy.models import FinancialYear
|
||||
tenant_id = int(get_active_tenant_id(request, current_user) or current_user.tenant_id)
|
||||
return db.execute(
|
||||
select(FinancialYear)
|
||||
.where(FinancialYear.tenant_id == tenant_id)
|
||||
.order_by(FinancialYear.start_date.desc(), FinancialYear.year_code.desc())
|
||||
).scalars().all()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
templates.env.globals.update(
|
||||
can_view_users=can_view_users,
|
||||
can_view_own_alerts=can_view_own_alerts,
|
||||
can_manage_alerts=can_manage_alerts,
|
||||
can_view_notice_cases=can_view_notice_cases,
|
||||
can_manage_notice_cases=can_manage_notice_cases,
|
||||
can_upload_notice_case_documents=can_upload_notice_case_documents,
|
||||
can_download_notice_case_documents=can_download_notice_case_documents,
|
||||
can_delete_notice_case_documents=can_delete_notice_case_documents,
|
||||
can_view_employee_dashboard=can_view_employee_dashboard,
|
||||
can_view_employees=can_view_employees,
|
||||
can_manage_employees=can_manage_employees,
|
||||
can_change_employee_status=can_change_employee_status,
|
||||
can_switch_employee_tenant=can_switch_employee_tenant,
|
||||
can_switch_employee_branch=can_switch_employee_branch,
|
||||
can_view_employee_portal=can_view_employee_portal,
|
||||
can_edit_own_employee_profile=can_edit_own_employee_profile,
|
||||
can_view_own_employee_work=can_view_own_employee_work,
|
||||
can_manage_employee_work=can_manage_employee_work,
|
||||
can_view_employee_progress=can_view_employee_progress,
|
||||
can_request_employee_registration=can_request_employee_registration,
|
||||
can_approve_employee_registrations=can_approve_employee_registrations,
|
||||
can_punch_employee_attendance=can_punch_employee_attendance,
|
||||
can_view_own_employee_attendance=can_view_own_employee_attendance,
|
||||
can_view_all_employee_attendance=can_view_all_employee_attendance,
|
||||
can_approve_employee_attendance=can_approve_employee_attendance,
|
||||
can_apply_employee_leave=can_apply_employee_leave,
|
||||
can_view_own_employee_leave=can_view_own_employee_leave,
|
||||
can_view_all_employee_leave=can_view_all_employee_leave,
|
||||
can_approve_employee_leave=can_approve_employee_leave,
|
||||
can_manage_employee_leave_types=can_manage_employee_leave_types,
|
||||
can_manage_employee_leave_balances=can_manage_employee_leave_balances,
|
||||
can_view_own_employee_documents=can_view_own_employee_documents,
|
||||
can_upload_own_employee_documents=can_upload_own_employee_documents,
|
||||
can_view_all_employee_documents=can_view_all_employee_documents,
|
||||
can_manage_employee_documents=can_manage_employee_documents,
|
||||
can_verify_employee_documents=can_verify_employee_documents,
|
||||
can_manage_employee_document_types=can_manage_employee_document_types,
|
||||
can_view_employee_onboarding=can_view_employee_onboarding,
|
||||
can_manage_employee_onboarding=can_manage_employee_onboarding,
|
||||
can_approve_employee_onboarding=can_approve_employee_onboarding,
|
||||
can_view_employee_offboarding=can_view_employee_offboarding,
|
||||
can_manage_employee_offboarding=can_manage_employee_offboarding,
|
||||
can_approve_employee_offboarding=can_approve_employee_offboarding,
|
||||
can_request_own_employee_offboarding=can_request_own_employee_offboarding,
|
||||
can_import_employee_hr=can_import_employee_hr,
|
||||
can_manage_employee_payroll_structures=can_manage_employee_payroll_structures,
|
||||
can_run_employee_payroll=can_run_employee_payroll,
|
||||
can_view_employee_payroll=can_view_employee_payroll,
|
||||
can_view_own_employee_payslips=can_view_own_employee_payslips,
|
||||
can_approve_employee_payroll=can_approve_employee_payroll,
|
||||
can_manage_users=can_manage_users,
|
||||
can_view_consultants=can_view_consultants,
|
||||
can_manage_consultants=can_manage_consultants,
|
||||
can_link_consultant_clients=can_link_consultant_clients,
|
||||
can_manage_consultant_service_requests=can_manage_consultant_service_requests,
|
||||
can_manage_consultant_conversions=can_manage_consultant_conversions,
|
||||
can_view_consultant_portal=can_view_consultant_portal,
|
||||
can_manage_own_consultant_workspace=can_manage_own_consultant_workspace,
|
||||
can_view_settings=can_view_settings,
|
||||
can_manage_settings=can_manage_settings,
|
||||
can_view_rbac=can_view_rbac,
|
||||
can_manage_rbac=can_manage_rbac,
|
||||
can_view_audit=can_view_audit,
|
||||
can_view_tenants=can_view_tenants,
|
||||
can_manage_tenants=can_manage_tenants,
|
||||
can_view_branches=can_view_branches,
|
||||
can_manage_branches=can_manage_branches,
|
||||
can_change_branch_tenant=can_change_branch_tenant,
|
||||
can_view_services=can_view_services,
|
||||
can_manage_services=can_manage_services,
|
||||
can_manage_service_tasks=can_manage_service_tasks,
|
||||
can_import_services=can_import_services,
|
||||
can_import_service_tasks=can_import_service_tasks,
|
||||
can_switch_service_tenant=can_switch_service_tenant,
|
||||
can_switch_service_branch=can_switch_service_branch,
|
||||
can_view_clients=can_view_clients,
|
||||
can_view_billing=can_view_billing,
|
||||
can_create_billing=can_create_billing,
|
||||
can_generate_billing_invoices=can_generate_billing_invoices,
|
||||
can_view_billing_fee_structure=can_view_billing_fee_structure,
|
||||
can_import_billing_fee_structure=can_import_billing_fee_structure,
|
||||
can_view_platform_billing=can_view_platform_billing,
|
||||
can_manage_platform_billing=can_manage_platform_billing,
|
||||
can_generate_platform_billing=can_generate_platform_billing,
|
||||
can_manage_platform_plans=can_manage_platform_plans,
|
||||
can_manage_platform_subscriptions=can_manage_platform_subscriptions,
|
||||
can_view_marketplace_leads=can_view_marketplace_leads,
|
||||
can_create_marketplace_leads=can_create_marketplace_leads,
|
||||
can_assign_marketplace_leads=can_assign_marketplace_leads,
|
||||
can_update_marketplace_leads=can_update_marketplace_leads,
|
||||
can_convert_marketplace_leads=can_convert_marketplace_leads,
|
||||
can_view_documents=can_view_documents,
|
||||
can_upload_documents=can_upload_documents,
|
||||
can_download_documents=can_download_documents,
|
||||
can_delete_documents=can_delete_documents,
|
||||
can_manage_clients=can_manage_clients,
|
||||
can_export_clients=can_export_clients,
|
||||
can_switch_client_tenant=can_switch_client_tenant,
|
||||
can_switch_client_branch=can_switch_client_branch,
|
||||
get_unread_alert_count=get_unread_alert_count,
|
||||
get_current_tenant_name=get_current_tenant_name,
|
||||
get_current_branch_name=get_current_branch_name,
|
||||
get_current_firm_branding=get_current_firm_branding,
|
||||
get_domain_context=get_domain_context,
|
||||
get_user_profile_photo_url=get_user_profile_photo_url,
|
||||
get_user_initials=get_user_initials,
|
||||
get_client_sidebar_auditor_card=get_client_sidebar_auditor_card,
|
||||
get_context_tenants=get_context_tenants,
|
||||
get_context_branches=get_context_branches,
|
||||
get_context_financial_years=get_context_financial_years,
|
||||
get_active_tenant_id=get_active_tenant_id,
|
||||
get_active_tenant_code=get_active_tenant_code,
|
||||
get_active_branch_id=get_active_branch_id,
|
||||
get_active_branch_code=get_active_branch_code,
|
||||
get_active_financial_year=get_active_financial_year,
|
||||
get_active_assessment_year=get_active_assessment_year,
|
||||
build_page_url=build_page_url,
|
||||
)
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
from fastapi import FastAPI
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app.core.settings import get_settings
|
||||
from app.core.middleware.context import ContextResolveMiddleware
|
||||
from app.core.middleware.domain_resolver import DomainResolverMiddleware
|
||||
from app.core.middleware.security_headers import SecurityHeadersMiddleware
|
||||
from app.core.startup import on_startup
|
||||
from app.core.api import api_router
|
||||
from app.ui.app import mount_ui
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
s = get_settings()
|
||||
app = FastAPI(title=s.APP_NAME, debug=s.DEBUG)
|
||||
|
||||
app.add_middleware(SecurityHeadersMiddleware)
|
||||
app.add_middleware(ContextResolveMiddleware)
|
||||
# Phase 7T.2: added after context so it resolves the request host before
|
||||
# context-aware middleware/routes need tenant/branch/domain state.
|
||||
app.add_middleware(DomainResolverMiddleware)
|
||||
# SessionMiddleware is added last so it is available to downstream
|
||||
# middleware/routes in Starlette's middleware execution order.
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=s.SECRET_KEY,
|
||||
session_cookie=s.COOKIE_SESSION_NAME,
|
||||
same_site=s.COOKIE_SAMESITE,
|
||||
https_only=s.COOKIE_SECURE,
|
||||
)
|
||||
|
||||
app.add_event_handler("startup", lambda: on_startup(app))
|
||||
|
||||
app.include_router(api_router, prefix="/api")
|
||||
mount_ui(app)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1 @@
|
||||
from __future__ import annotations
|
||||
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
|
||||
class UserAlert(CommonBase):
|
||||
"""Common role-aware alert table for all dashboards and portals.
|
||||
|
||||
Phase 7H foundation only stores and displays alerts. Later phases can call
|
||||
app.modules.alerts.service.create_alert() from task, document, attendance,
|
||||
client and consultant workflows without changing this schema.
|
||||
"""
|
||||
|
||||
__tablename__ = "user_alerts"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
role_context: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
|
||||
alert_type: Mapped[str] = mapped_column(String(80), nullable=False, default="general", index=True)
|
||||
priority: Mapped[str] = mapped_column(String(20), nullable=False, default="normal", index=True)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
target_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
is_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
|
||||
read_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False, index=True
|
||||
)
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
|
||||
user = relationship("User", foreign_keys=[user_id])
|
||||
created_by = relationship("User", foreign_keys=[created_by_user_id])
|
||||
@@ -0,0 +1,174 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Iterable
|
||||
|
||||
from sqlalchemy import Select, func, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.alerts.models import UserAlert
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.email_integration.event_service import send_alert_created_email
|
||||
|
||||
ALERT_PRIORITIES = ("low", "normal", "high", "critical")
|
||||
ALERT_TYPES = (
|
||||
"general",
|
||||
"task_assigned",
|
||||
"task_due",
|
||||
"task_overdue",
|
||||
"task_review",
|
||||
"document_uploaded",
|
||||
"clarification",
|
||||
"attendance",
|
||||
"leave",
|
||||
"payroll",
|
||||
"consultant",
|
||||
"client",
|
||||
)
|
||||
|
||||
|
||||
def normalize_priority(priority: str | None) -> str:
|
||||
value = (priority or "normal").strip().lower()
|
||||
return value if value in ALERT_PRIORITIES else "normal"
|
||||
|
||||
|
||||
def normalize_alert_type(alert_type: str | None) -> str:
|
||||
value = (alert_type or "general").strip().lower()
|
||||
return value or "general"
|
||||
|
||||
|
||||
def create_alert(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: int,
|
||||
title: str,
|
||||
message: str | None = None,
|
||||
tenant_id: int | None = None,
|
||||
branch_id: int | None = None,
|
||||
role_context: str | None = None,
|
||||
alert_type: str = "general",
|
||||
priority: str = "normal",
|
||||
target_url: str | None = None,
|
||||
created_by_user_id: int | None = None,
|
||||
commit: bool = True,
|
||||
) -> UserAlert:
|
||||
alert = UserAlert(
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
user_id=user_id,
|
||||
role_context=(role_context or None),
|
||||
alert_type=normalize_alert_type(alert_type),
|
||||
priority=normalize_priority(priority),
|
||||
title=(title or "Alert").strip()[:255],
|
||||
message=(message or None),
|
||||
target_url=(target_url or None),
|
||||
created_by_user_id=created_by_user_id,
|
||||
)
|
||||
db.add(alert)
|
||||
db.flush()
|
||||
try:
|
||||
send_alert_created_email(db, alert)
|
||||
except Exception:
|
||||
# Email notification must never block in-app alert creation.
|
||||
pass
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(alert)
|
||||
return alert
|
||||
|
||||
|
||||
def create_bulk_alerts(
|
||||
db: Session,
|
||||
*,
|
||||
user_ids: Iterable[int],
|
||||
title: str,
|
||||
message: str | None = None,
|
||||
tenant_id: int | None = None,
|
||||
branch_id: int | None = None,
|
||||
role_context: str | None = None,
|
||||
alert_type: str = "general",
|
||||
priority: str = "normal",
|
||||
target_url: str | None = None,
|
||||
created_by_user_id: int | None = None,
|
||||
) -> list[UserAlert]:
|
||||
rows: list[UserAlert] = []
|
||||
for user_id in sorted({int(uid) for uid in user_ids if uid}):
|
||||
rows.append(
|
||||
create_alert(
|
||||
db,
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
message=message,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
role_context=role_context,
|
||||
alert_type=alert_type,
|
||||
priority=priority,
|
||||
target_url=target_url,
|
||||
created_by_user_id=created_by_user_id,
|
||||
commit=False,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
for row in rows:
|
||||
db.refresh(row)
|
||||
return rows
|
||||
|
||||
|
||||
def _user_alert_query(current_user: User) -> Select:
|
||||
return select(UserAlert).where(UserAlert.user_id == current_user.id)
|
||||
|
||||
|
||||
def list_my_alerts(
|
||||
db: Session,
|
||||
current_user: User,
|
||||
*,
|
||||
status: str = "all",
|
||||
priority: str = "all",
|
||||
limit: int = 100,
|
||||
) -> list[UserAlert]:
|
||||
q = _user_alert_query(current_user)
|
||||
if status == "unread":
|
||||
q = q.where(UserAlert.is_read.is_(False))
|
||||
elif status == "read":
|
||||
q = q.where(UserAlert.is_read.is_(True))
|
||||
if priority in ALERT_PRIORITIES:
|
||||
q = q.where(UserAlert.priority == priority)
|
||||
q = q.order_by(UserAlert.is_read.asc(), UserAlert.created_at_utc.desc()).limit(max(1, min(limit, 500)))
|
||||
return list(db.execute(q).scalars().all())
|
||||
|
||||
|
||||
def count_unread_alerts(db: Session, current_user: User | None) -> int:
|
||||
if not current_user:
|
||||
return 0
|
||||
value = db.execute(
|
||||
select(func.count(UserAlert.id)).where(UserAlert.user_id == current_user.id, UserAlert.is_read.is_(False))
|
||||
).scalar_one()
|
||||
return int(value or 0)
|
||||
|
||||
|
||||
def get_my_alert_or_404(db: Session, current_user: User, alert_id: int) -> UserAlert | None:
|
||||
return db.execute(
|
||||
select(UserAlert).where(UserAlert.id == alert_id, UserAlert.user_id == current_user.id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def mark_alert_read(db: Session, current_user: User, alert_id: int) -> bool:
|
||||
alert = get_my_alert_or_404(db, current_user, alert_id)
|
||||
if not alert:
|
||||
return False
|
||||
if not alert.is_read:
|
||||
alert.is_read = True
|
||||
alert.read_at_utc = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
def mark_all_alerts_read(db: Session, current_user: User) -> int:
|
||||
result = db.execute(
|
||||
update(UserAlert)
|
||||
.where(UserAlert.user_id == current_user.id, UserAlert.is_read.is_(False))
|
||||
.values(is_read=True, read_at_utc=datetime.now(timezone.utc))
|
||||
)
|
||||
db.commit()
|
||||
return int(result.rowcount or 0)
|
||||
@@ -0,0 +1,73 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
{% set _role_text = (current_user_roles or [])|join('|')|lower %}
|
||||
{% if 'partner' in _role_text %}
|
||||
{% include "modules/partners/templates/partners/_partner_tabs.html" %}
|
||||
{% elif 'manager' in _role_text %}
|
||||
{% include "modules/managers/templates/managers/_manager_tabs.html" %}
|
||||
{% else %}
|
||||
{% include "modules/employees/templates/employees/_my_workspace_tabs.html" %}
|
||||
{% endif %}
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">My Alerts</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Role-wise alerts for tasks, documents, attendance, leave, payroll, client and consultant workflows.</p>
|
||||
</div>
|
||||
<form method="post" action="/alerts/read-all">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50" {% if unread_count == 0 %}disabled{% endif %}>Mark all as read</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Unread</div><div class="mt-1 text-2xl font-semibold">{{ unread_count }}</div></div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Showing</div><div class="mt-1 text-2xl font-semibold">{{ alerts|length }}</div></div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Filter</div><div class="mt-1 text-sm text-slate-600">{{ status.replace('_',' ').title() }} · {{ priority.title() }}</div></div>
|
||||
</div>
|
||||
|
||||
<form method="get" action="/alerts" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="grid gap-3 md:grid-cols-[220px_220px_auto]">
|
||||
<select name="status" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="all" {% if status == 'all' %}selected{% endif %}>All alerts</option>
|
||||
<option value="unread" {% if status == 'unread' %}selected{% endif %}>Unread only</option>
|
||||
<option value="read" {% if status == 'read' %}selected{% endif %}>Read only</option>
|
||||
</select>
|
||||
<select name="priority" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="all" {% if priority == 'all' %}selected{% endif %}>All priorities</option>
|
||||
{% for p in priorities %}<option value="{{ p }}" {% if priority == p %}selected{% endif %}>{{ p.title() }}</option>{% endfor %}
|
||||
</select>
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Apply Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="space-y-3">
|
||||
{% for alert in alerts %}
|
||||
<div class="rounded-2xl border {% if alert.is_read %}border-slate-200 bg-white{% else %}border-brand-100 bg-brand-50{% endif %} p-5 shadow-soft">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="font-semibold text-slate-900">{{ alert.title }}</h3>
|
||||
<span class="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-slate-600">{{ alert.priority }}</span>
|
||||
{% if not alert.is_read %}<span class="rounded-full bg-brand-600 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-white">Unread</span>{% endif %}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-slate-500">{{ alert.alert_type.replace('_',' ').title() }}{% if alert.role_context %} · {{ alert.role_context }}{% endif %} · {{ alert.created_at_utc.strftime('%d-%m-%Y %H:%M') if alert.created_at_utc else '-' }}</div>
|
||||
{% if alert.message %}<p class="mt-3 text-sm text-slate-700">{{ alert.message }}</p>{% endif %}
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap justify-end gap-2">
|
||||
{% if alert.target_url %}<a href="{{ alert.target_url }}" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Open</a>{% endif %}
|
||||
{% if not alert.is_read %}
|
||||
<form method="post" action="/alerts/{{ alert.id }}/read">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Mark read</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-8 text-center text-sm text-slate-500 shadow-soft">No alerts found for the selected filter.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
|
||||
from app.core.db.common import CommonSessionLocal
|
||||
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
|
||||
from app.core.security.session_auth import get_current_user
|
||||
from app.core.templating import templates
|
||||
from app.modules.alerts.service import ALERT_PRIORITIES, count_unread_alerts, list_my_alerts, mark_alert_read, mark_all_alerts_read
|
||||
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
||||
|
||||
router = APIRouter(prefix="/alerts", tags=["alerts-ui"])
|
||||
|
||||
|
||||
def _redirect_login():
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
|
||||
|
||||
def _base_ctx(request: Request, db, current_user, **ctx):
|
||||
base = {
|
||||
"request": request,
|
||||
"current_user": current_user,
|
||||
"current_user_roles": get_user_roles(db, current_user.id),
|
||||
"current_user_permissions": get_user_permissions(db, current_user.id),
|
||||
"csrf_token": get_or_create_csrf_token(request),
|
||||
}
|
||||
base.update(ctx)
|
||||
return base
|
||||
|
||||
|
||||
@router.get("/poll")
|
||||
def poll_unread_alerts(request: Request, limit: int = 5):
|
||||
"""Lightweight polling endpoint used by the base layout toast popup.
|
||||
|
||||
Returns a small list of unread alerts for the logged-in user. It does not
|
||||
mark alerts as read; the normal /alerts page and existing read actions
|
||||
continue to control read status.
|
||||
"""
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
current_user = get_current_user(request, db)
|
||||
if not current_user:
|
||||
return JSONResponse({"authenticated": False, "unread_count": 0, "alerts": []}, status_code=401)
|
||||
|
||||
safe_limit = max(1, min(int(limit or 5), 10))
|
||||
rows = list_my_alerts(db, current_user, status="unread", priority="all", limit=safe_limit)
|
||||
payload = []
|
||||
for row in rows:
|
||||
created_at = getattr(row, "created_at_utc", None)
|
||||
payload.append(
|
||||
{
|
||||
"id": row.id,
|
||||
"title": row.title or "Alert",
|
||||
"message": row.message or "",
|
||||
"priority": row.priority or "normal",
|
||||
"alert_type": row.alert_type or "general",
|
||||
"target_url": row.target_url or "/alerts",
|
||||
"created_at_utc": created_at.isoformat() if created_at else None,
|
||||
}
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"authenticated": True,
|
||||
"unread_count": count_unread_alerts(db, current_user),
|
||||
"alerts": payload,
|
||||
}
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("")
|
||||
def alerts_list(request: Request, status: str = "all", priority: str = "all"):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
current_user = get_current_user(request, db)
|
||||
if not current_user:
|
||||
return _redirect_login()
|
||||
status = status if status in {"all", "unread", "read"} else "all"
|
||||
priority = priority if priority in ALERT_PRIORITIES else "all"
|
||||
rows = list_my_alerts(db, current_user, status=status, priority=priority, limit=150)
|
||||
return templates.TemplateResponse(
|
||||
"modules/alerts/templates/alerts/list.html",
|
||||
_base_ctx(
|
||||
request,
|
||||
db,
|
||||
current_user,
|
||||
title="My Alerts",
|
||||
alerts=rows,
|
||||
status=status,
|
||||
priority=priority,
|
||||
priorities=ALERT_PRIORITIES,
|
||||
unread_count=count_unread_alerts(db, current_user),
|
||||
),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/{alert_id}/read")
|
||||
def mark_read(request: Request, alert_id: int, csrf_token: str = Form(...)):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
current_user = get_current_user(request, db)
|
||||
if not current_user:
|
||||
return _redirect_login()
|
||||
validate_csrf(request, csrf_token)
|
||||
mark_alert_read(db, current_user, alert_id)
|
||||
return RedirectResponse(url="/alerts", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/read-all")
|
||||
def mark_all_read(request: Request, csrf_token: str = Form(...)):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
current_user = get_current_user(request, db)
|
||||
if not current_user:
|
||||
return _redirect_login()
|
||||
validate_csrf(request, csrf_token)
|
||||
mark_all_alerts_read(db, current_user)
|
||||
return RedirectResponse(url="/alerts", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1 @@
|
||||
"""Billing module for firm-level invoices and fee structure imports."""
|
||||
@@ -0,0 +1,138 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.modules.billing.models import BillingInvoice, BillingInvoiceLine, BillingPayment
|
||||
from app.modules.billing.services import build_invoice_print_context, is_cashfree_ready, is_payumoney_ready, money
|
||||
|
||||
CLIENT_VISIBLE_INVOICE_STATUSES = {"ISSUED", "PARTLY_PAID", "PAID", "OVERDUE"}
|
||||
|
||||
|
||||
def _client_ids(client_row: Any) -> tuple[int, int]:
|
||||
"""Return (tenant_id, client_id) from dict/row/model style client payload."""
|
||||
if isinstance(client_row, dict):
|
||||
return int(client_row.get("tenant_id") or 0), int(client_row.get("id") or 0)
|
||||
return int(getattr(client_row, "tenant_id", 0) or 0), int(getattr(client_row, "id", 0) or 0)
|
||||
|
||||
|
||||
def list_client_portal_invoices(db: Session, client_row: Any, *, q: str = "", include_paid: bool = True, financial_year: str | None = None) -> list[BillingInvoice]:
|
||||
tenant_id, client_id = _client_ids(client_row)
|
||||
stmt = (
|
||||
select(BillingInvoice)
|
||||
.options(
|
||||
selectinload(BillingInvoice.lines).selectinload(BillingInvoiceLine.service),
|
||||
selectinload(BillingInvoice.payments),
|
||||
)
|
||||
.where(
|
||||
BillingInvoice.tenant_id == tenant_id,
|
||||
BillingInvoice.client_id == client_id,
|
||||
BillingInvoice.status.in_(CLIENT_VISIBLE_INVOICE_STATUSES),
|
||||
)
|
||||
)
|
||||
if not include_paid:
|
||||
stmt = stmt.where(BillingInvoice.status != "PAID")
|
||||
if financial_year and financial_year.upper() != "ALL":
|
||||
stmt = stmt.where(BillingInvoice.financial_year == financial_year)
|
||||
if q.strip():
|
||||
term = f"%{q.strip()}%"
|
||||
stmt = stmt.where(or_(BillingInvoice.invoice_no.ilike(term), BillingInvoice.invoice_title.ilike(term)))
|
||||
return db.execute(stmt.order_by(BillingInvoice.invoice_date.desc(), BillingInvoice.id.desc())).scalars().unique().all()
|
||||
|
||||
|
||||
def get_client_portal_invoice(db: Session, client_row: Any, invoice_id: int, *, financial_year: str | None = None) -> BillingInvoice | None:
|
||||
tenant_id, client_id = _client_ids(client_row)
|
||||
stmt = (
|
||||
select(BillingInvoice)
|
||||
.options(
|
||||
selectinload(BillingInvoice.client),
|
||||
selectinload(BillingInvoice.lines).selectinload(BillingInvoiceLine.service),
|
||||
selectinload(BillingInvoice.payments),
|
||||
)
|
||||
.where(
|
||||
BillingInvoice.id == invoice_id,
|
||||
BillingInvoice.tenant_id == tenant_id,
|
||||
BillingInvoice.client_id == client_id,
|
||||
BillingInvoice.status.in_(CLIENT_VISIBLE_INVOICE_STATUSES),
|
||||
)
|
||||
)
|
||||
if financial_year and financial_year.upper() != "ALL":
|
||||
stmt = stmt.where(BillingInvoice.financial_year == financial_year)
|
||||
return db.execute(stmt).scalars().unique().one_or_none()
|
||||
|
||||
|
||||
def get_client_portal_payment(db: Session, client_row: Any, payment_id: int, *, financial_year: str | None = None) -> BillingPayment | None:
|
||||
tenant_id, client_id = _client_ids(client_row)
|
||||
stmt = (
|
||||
select(BillingPayment)
|
||||
.options(
|
||||
selectinload(BillingPayment.invoice).selectinload(BillingInvoice.lines),
|
||||
selectinload(BillingPayment.client),
|
||||
)
|
||||
.where(
|
||||
BillingPayment.id == payment_id,
|
||||
BillingPayment.tenant_id == tenant_id,
|
||||
BillingPayment.client_id == client_id,
|
||||
BillingPayment.status == "RECEIVED",
|
||||
)
|
||||
)
|
||||
if financial_year and financial_year.upper() != "ALL":
|
||||
stmt = stmt.where(BillingPayment.financial_year == financial_year)
|
||||
return db.execute(stmt).scalars().unique().one_or_none()
|
||||
|
||||
|
||||
def build_client_billing_summary(db: Session, client_row: Any, *, financial_year: str | None = None) -> dict[str, Any]:
|
||||
invoices = list_client_portal_invoices(db, client_row, include_paid=True, financial_year=financial_year)
|
||||
open_invoices = [row for row in invoices if row.status in {"ISSUED", "PARTLY_PAID", "OVERDUE"} and money(row.balance_amount) > Decimal("0.00")]
|
||||
paid_invoices = [row for row in invoices if row.status == "PAID"]
|
||||
outstanding = sum((money(row.balance_amount) for row in open_invoices), Decimal("0.00"))
|
||||
latest_invoice = invoices[0] if invoices else None
|
||||
latest_due_invoice = open_invoices[0] if open_invoices else None
|
||||
return {
|
||||
"billing_invoices": invoices,
|
||||
"billing_open_invoices": open_invoices,
|
||||
"billing_paid_invoices": paid_invoices,
|
||||
"billing_outstanding_amount": money(outstanding),
|
||||
"billing_latest_invoice": latest_invoice,
|
||||
"billing_latest_due_invoice": latest_due_invoice,
|
||||
"billing_open_count": len(open_invoices),
|
||||
"billing_paid_count": len(paid_invoices),
|
||||
"billing_total_count": len(invoices),
|
||||
}
|
||||
|
||||
|
||||
def build_client_payment_context(db: Session, invoice: BillingInvoice) -> dict[str, Any]:
|
||||
invoice_ctx = build_invoice_print_context(db, invoice)
|
||||
settings = invoice_ctx.get("settings")
|
||||
amount_due = money(invoice.balance_amount)
|
||||
firm_name = invoice_ctx.get("firm_name") or "Audit Firm"
|
||||
upi_id = getattr(settings, "upi_id", None) if settings else None
|
||||
upi_link = None
|
||||
if upi_id and amount_due > Decimal("0.00"):
|
||||
upi_link = (
|
||||
"upi://pay?"
|
||||
f"pa={quote(str(upi_id))}"
|
||||
f"&pn={quote(str(firm_name))}"
|
||||
f"&am={quote(str(amount_due))}"
|
||||
"&cu=INR"
|
||||
f"&tn={quote('Invoice ' + str(invoice.invoice_no))}"
|
||||
)
|
||||
return {
|
||||
"invoice_ctx": invoice_ctx,
|
||||
"amount_due": amount_due,
|
||||
"upi_link": upi_link,
|
||||
"upi_id": upi_id,
|
||||
"bank_name": invoice_ctx.get("bank_name"),
|
||||
"bank_account_name": invoice_ctx.get("bank_account_name"),
|
||||
"bank_account_number": invoice_ctx.get("bank_account_number"),
|
||||
"bank_ifsc": invoice_ctx.get("bank_ifsc"),
|
||||
"payment_instructions": getattr(settings, "bank_details", None) if settings else None,
|
||||
"payumoney_enabled": is_payumoney_ready(settings),
|
||||
"payumoney_mode": getattr(settings, "payumoney_mode", "TEST") if settings else "TEST",
|
||||
"cashfree_enabled": is_cashfree_ready(settings),
|
||||
"cashfree_mode": getattr(settings, "cashfree_mode", "TEST") if settings else "TEST",
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
|
||||
class BillingSettings(CommonBase):
|
||||
__tablename__ = "billing_settings"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "branch_id", name="uq_billing_settings_tenant_branch"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
invoice_prefix: Mapped[str] = mapped_column(String(40), nullable=False, default="INV")
|
||||
next_invoice_no: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
padding: Mapped[int] = mapped_column(Integer, nullable=False, default=4)
|
||||
default_gst_rate: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=Decimal("18.00"))
|
||||
default_tax_type: Mapped[str] = mapped_column(String(20), nullable=False, default="CGST_SGST")
|
||||
|
||||
legal_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
gstin: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
pan: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
||||
state_code: Mapped[str | None] = mapped_column(String(2), nullable=True)
|
||||
billing_address: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
contact_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
contact_mobile: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
website_url: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
invoice_title: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
invoice_number_format: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
default_due_days: Mapped[int] = mapped_column(Integer, nullable=False, default=15)
|
||||
default_sac_code: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
|
||||
bank_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
bank_account_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
bank_account_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
bank_ifsc: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
upi_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
bank_details: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
payumoney_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
payumoney_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="TEST")
|
||||
payumoney_merchant_key: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
payumoney_merchant_salt: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
payumoney_merchant_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
payumoney_product_info: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
|
||||
cashfree_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
cashfree_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="TEST")
|
||||
cashfree_client_id: Mapped[str | None] = mapped_column(String(180), nullable=True)
|
||||
cashfree_client_secret: Mapped[str | None] = mapped_column(String(240), nullable=True)
|
||||
cashfree_api_version: Mapped[str] = mapped_column(String(20), nullable=False, default="2023-08-01")
|
||||
cashfree_order_note: Mapped[str | None] = mapped_column(String(250), nullable=True)
|
||||
|
||||
authorised_signatory_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
declaration: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
terms: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
footer_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
|
||||
|
||||
class BillingInvoiceGenerationBatch(CommonBase):
|
||||
__tablename__ = "billing_invoice_generation_batches"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
billing_period_from: Mapped[date] = mapped_column(Date, nullable=False, index=True)
|
||||
billing_period_to: Mapped[date] = mapped_column(Date, nullable=False, index=True)
|
||||
financial_year: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
frequency: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="DRAFT_CREATED", index=True)
|
||||
|
||||
selected_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
created_invoice_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
skipped_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
error_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
generated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
generated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
|
||||
invoices = relationship("BillingInvoice", back_populates="generation_batch")
|
||||
|
||||
|
||||
class BillingInvoice(CommonBase):
|
||||
__tablename__ = "billing_invoices"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "invoice_no", name="uq_billing_invoices_tenant_invoice_no"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||
engagement_id: Mapped[int | None] = mapped_column(ForeignKey("client_service_subscriptions.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
generation_batch_id: Mapped[int | None] = mapped_column(ForeignKey("billing_invoice_generation_batches.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
invoice_no: Mapped[str] = mapped_column(String(60), nullable=False, index=True)
|
||||
invoice_date: Mapped[date] = mapped_column(Date, nullable=False, default=date.today, index=True)
|
||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
|
||||
billing_period_from: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
billing_period_to: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
financial_year: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
|
||||
invoice_title: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
place_of_supply: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
reverse_charge: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
client_legal_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
client_trade_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
client_gstin: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
client_pan: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
client_billing_address: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
client_state: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
client_state_code: Mapped[str | None] = mapped_column(String(2), nullable=True)
|
||||
client_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
client_mobile: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
|
||||
tax_type: Mapped[str] = mapped_column(String(20), nullable=False, default="CGST_SGST")
|
||||
subtotal: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
discount_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
taxable_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
cgst_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
sgst_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
igst_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
round_off: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
total_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
amount_received: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
tds_deducted: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
bank_charges: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
balance_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
amount_in_words: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="DRAFT", index=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
terms: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
approved_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
posted_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
|
||||
client = relationship("Client")
|
||||
engagement = relationship("ClientServiceSubscription", foreign_keys=[engagement_id])
|
||||
generation_batch = relationship("BillingInvoiceGenerationBatch", back_populates="invoices")
|
||||
lines = relationship("BillingInvoiceLine", back_populates="invoice", cascade="all, delete-orphan", order_by="BillingInvoiceLine.sort_order.asc()")
|
||||
payments = relationship("BillingPayment", back_populates="invoice", cascade="all, delete-orphan", order_by="BillingPayment.payment_date.desc(), BillingPayment.id.desc()")
|
||||
online_transactions = relationship("BillingOnlinePaymentTransaction", back_populates="invoice", cascade="all, delete-orphan", order_by="BillingOnlinePaymentTransaction.created_at_utc.desc(), BillingOnlinePaymentTransaction.id.desc()")
|
||||
|
||||
|
||||
class BillingPayment(CommonBase):
|
||||
__tablename__ = "billing_payments"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "receipt_no", name="uq_billing_payments_tenant_receipt_no"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
invoice_id: Mapped[int] = mapped_column(ForeignKey("billing_invoices.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||
|
||||
receipt_no: Mapped[str] = mapped_column(String(60), nullable=False, index=True)
|
||||
receipt_date: Mapped[date] = mapped_column(Date, nullable=False, default=date.today, index=True)
|
||||
payment_date: Mapped[date] = mapped_column(Date, nullable=False, default=date.today, index=True)
|
||||
financial_year: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
amount_received: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
tds_deducted: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
bank_charges: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
mode: Mapped[str] = mapped_column(String(30), nullable=False, default="BANK")
|
||||
reference_no: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
payment_gateway: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
gateway_transaction_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="RECEIVED", index=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
|
||||
invoice = relationship("BillingInvoice", back_populates="payments")
|
||||
client = relationship("Client")
|
||||
|
||||
|
||||
class BillingOnlinePaymentTransaction(CommonBase):
|
||||
__tablename__ = "billing_online_payment_transactions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "txnid", name="uq_billing_online_payment_tenant_txnid"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
invoice_id: Mapped[int] = mapped_column(ForeignKey("billing_invoices.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||
|
||||
provider: Mapped[str] = mapped_column(String(40), nullable=False, default="PAYUMONEY", index=True)
|
||||
mode: Mapped[str] = mapped_column(String(20), nullable=False, default="TEST")
|
||||
txnid: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||
amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
productinfo: Mapped[str | None] = mapped_column(String(250), nullable=True)
|
||||
firstname: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
phone: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
|
||||
payu_payment_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
|
||||
cashfree_order_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
|
||||
cashfree_cf_order_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
|
||||
cashfree_payment_session_id: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
cashfree_payment_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
|
||||
webhook_event_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
|
||||
bank_ref_num: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
mihpayid: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, default="INITIATED", index=True)
|
||||
gateway_status: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
response_hash: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
raw_response: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
receipt_payment_id: Mapped[int | None] = mapped_column(ForeignKey("billing_payments.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
completed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
invoice = relationship("BillingInvoice", back_populates="online_transactions")
|
||||
client = relationship("Client")
|
||||
receipt_payment = relationship("BillingPayment", foreign_keys=[receipt_payment_id])
|
||||
|
||||
|
||||
class BillingInvoiceLine(CommonBase):
|
||||
__tablename__ = "billing_invoice_lines"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
invoice_id: Mapped[int] = mapped_column(ForeignKey("billing_invoices.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
service_id: Mapped[int | None] = mapped_column(ForeignKey("service_catalogues.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
engagement_id: Mapped[int | None] = mapped_column(ForeignKey("client_service_subscriptions.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
fee_group_id: Mapped[int | None] = mapped_column(ForeignKey("billing_fee_groups.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
description: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
sac_code: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
billing_period_from: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
billing_period_to: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
quantity: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("1.00"))
|
||||
rate: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
discount_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
taxable_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
gst_rate: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=Decimal("18.00"))
|
||||
cgst_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
sgst_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
igst_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
line_total: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
|
||||
invoice = relationship("BillingInvoice", back_populates="lines")
|
||||
service = relationship("ServiceCatalogue")
|
||||
|
||||
|
||||
class BillingFeeGroup(CommonBase):
|
||||
__tablename__ = "billing_fee_groups"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "group_code", name="uq_billing_fee_groups_tenant_code"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
partner_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
group_code: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||
group_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
billing_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="PACKAGE")
|
||||
frequency: Mapped[str] = mapped_column(String(20), nullable=False, default="Monthly")
|
||||
fee_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
gst_rate: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=Decimal("18.00"))
|
||||
tax_type: Mapped[str] = mapped_column(String(20), nullable=False, default="CGST_SGST")
|
||||
effective_from: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
effective_to: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
auto_generate: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
|
||||
client = relationship("Client")
|
||||
services = relationship("BillingFeeGroupService", back_populates="fee_group", cascade="all, delete-orphan", order_by="BillingFeeGroupService.sort_order.asc()")
|
||||
|
||||
|
||||
class BillingFeeGroupService(CommonBase):
|
||||
__tablename__ = "billing_fee_group_services"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("fee_group_id", "service_id", name="uq_billing_fee_group_services_group_service"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
fee_group_id: Mapped[int] = mapped_column(ForeignKey("billing_fee_groups.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
service_id: Mapped[int] = mapped_column(ForeignKey("service_catalogues.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||
line_description: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
allocation_type: Mapped[str] = mapped_column(String(20), nullable=False, default="Included")
|
||||
line_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
percentage: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), nullable=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
|
||||
fee_group = relationship("BillingFeeGroup", back_populates="services")
|
||||
service = relationship("ServiceCatalogue")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/clients/templates/clients/_client_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-slate-900">Invoice {{ invoice.invoice_no }}</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Issued on {{ invoice.invoice_date.strftime('%d-%m-%Y') if invoice.invoice_date else '-' }}{% if invoice.due_date %} • Due {{ invoice.due_date.strftime('%d-%m-%Y') }}{% endif %}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/client/billing" class="af-btn af-btn-secondary">Back to Bills</a>
|
||||
<a href="/client/billing/{{ invoice.id }}/print" class="af-btn af-btn-secondary">Print / Save PDF</a>
|
||||
{% if invoice.balance_amount and invoice.balance_amount > 0 %}<a href="/client/billing/{{ invoice.id }}/pay-now" class="af-btn af-btn-primary">Pay Now</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="grid gap-4 md:grid-cols-4">
|
||||
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Invoice Total</div><div class="mt-2 text-2xl font-semibold">₹ {{ '%.2f'|format(invoice.total_amount or 0) }}</div></div>
|
||||
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Received</div><div class="mt-2 text-2xl font-semibold text-emerald-700">₹ {{ '%.2f'|format(invoice.amount_received or 0) }}</div></div>
|
||||
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">TDS</div><div class="mt-2 text-2xl font-semibold text-slate-900">₹ {{ '%.2f'|format(invoice.tds_deducted or 0) }}</div></div>
|
||||
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Balance</div><div class="mt-2 text-2xl font-semibold text-amber-700">₹ {{ '%.2f'|format(invoice.balance_amount or 0) }}</div></div>
|
||||
</section>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div class="af-card">
|
||||
<div class="flex items-center justify-between gap-3"><h3 class="text-lg font-semibold text-slate-900">Invoice Lines</h3><span class="af-badge {% if invoice.status == 'PAID' %}af-badge-success{% else %}af-badge-warning{% endif %}">{{ invoice.status.replace('_', ' ') }}</span></div>
|
||||
<div class="mt-5 overflow-x-auto rounded-2xl border border-slate-200">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500"><tr><th class="px-4 py-3">Description</th><th class="px-4 py-3">SAC</th><th class="px-4 py-3 text-right">Taxable</th><th class="px-4 py-3 text-right">GST</th><th class="px-4 py-3 text-right">Total</th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-100 bg-white">
|
||||
{% for line in invoice.lines %}
|
||||
<tr><td class="px-4 py-3 font-medium text-slate-900 whitespace-pre-line">{{ line.description }}</td><td class="px-4 py-3 text-slate-600">{{ line.sac_code or '-' }}</td><td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(line.taxable_amount or 0) }}</td><td class="px-4 py-3 text-right">₹ {{ '%.2f'|format((line.cgst_amount or 0) + (line.sgst_amount or 0) + (line.igst_amount or 0)) }}</td><td class="px-4 py-3 text-right font-semibold">₹ {{ '%.2f'|format(line.line_total or 0) }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="space-y-6">
|
||||
<div class="af-card">
|
||||
<h3 class="text-base font-semibold text-slate-900">Payment Status</h3>
|
||||
<div class="mt-4 space-y-3 text-sm">
|
||||
<div class="flex justify-between"><span class="text-slate-500">Status</span><span class="font-semibold">{{ invoice.status.replace('_', ' ') }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-slate-500">Due Amount</span><span class="font-semibold text-amber-700">₹ {{ '%.2f'|format(invoice.balance_amount or 0) }}</span></div>
|
||||
{% if invoice.balance_amount and invoice.balance_amount > 0 %}<a href="/client/billing/{{ invoice.id }}/pay-now" class="mt-2 w-full justify-center af-btn af-btn-primary">Pay Now</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="af-card">
|
||||
<h3 class="text-base font-semibold text-slate-900">Receipts</h3>
|
||||
<div class="mt-4 space-y-3 text-sm">
|
||||
{% for p in invoice.payments %}
|
||||
{% if p.status == 'RECEIVED' %}
|
||||
<a href="/client/billing/receipts/{{ p.id }}" class="block rounded-2xl border border-slate-200 p-3 hover:bg-slate-50"><div class="font-semibold text-brand-700">{{ p.receipt_no }}</div><div class="mt-1 text-xs text-slate-500">{{ p.payment_date.strftime('%d-%m-%Y') if p.payment_date else '-' }} • ₹ {{ '%.2f'|format(p.amount_received or 0) }}</div></a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-dashed border-slate-300 p-4 text-slate-500">No receipts recorded yet.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,75 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/clients/templates/clients/_client_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<section class="rounded-3xl bg-gradient-to-r from-brand-700 to-slate-900 p-6 text-white shadow-soft">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-brand-100">Client Portal</p>
|
||||
<h2 class="mt-2 text-2xl font-semibold">My Bills & Payments</h2>
|
||||
<p class="mt-2 max-w-3xl text-sm text-brand-100">View invoices issued by your audit firm, download receipts and use Pay Now for pending bills.</p>
|
||||
<p class="mt-1 text-xs text-brand-100">Active FY: {{ active_financial_year or 'All Years' }}</p>
|
||||
</div>
|
||||
{% if billing_latest_due_invoice %}
|
||||
<a href="/client/billing/{{ billing_latest_due_invoice.id }}/pay-now" class="rounded-xl bg-white px-4 py-2 text-sm font-semibold text-brand-700 hover:bg-brand-50">Pay Latest Due</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-4 md:grid-cols-3">
|
||||
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Outstanding</div><div class="mt-2 text-3xl font-semibold text-amber-700">₹ {{ '%.2f'|format(billing_outstanding_amount or 0) }}</div><div class="mt-1 text-xs text-slate-500">{{ billing_open_count or 0 }} open bill(s)</div></div>
|
||||
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Total Invoices</div><div class="mt-2 text-3xl font-semibold text-slate-900">{{ billing_total_count or 0 }}</div><div class="mt-1 text-xs text-slate-500">Issued by firm</div></div>
|
||||
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Paid</div><div class="mt-2 text-3xl font-semibold text-emerald-700">{{ billing_paid_count or 0 }}</div><div class="mt-1 text-xs text-slate-500">Completed payments</div></div>
|
||||
</section>
|
||||
|
||||
<div class="af-card">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-slate-900">Invoices</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Draft and cancelled invoices are not shown in the client portal.</p>
|
||||
</div>
|
||||
<form method="get" action="/client/billing" class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<input name="q" value="{{ q or '' }}" placeholder="Search invoice no" class="rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
<select name="include_paid" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="yes" {% if include_paid != 'no' %}selected{% endif %}>All invoices</option>
|
||||
<option value="no" {% if include_paid == 'no' %}selected{% endif %}>Only pending</option>
|
||||
</select>
|
||||
<button class="af-btn af-btn-secondary" type="submit">Filter</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 overflow-x-auto rounded-2xl border border-slate-200">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Invoice</th>
|
||||
<th class="px-4 py-3">Date</th><th class="px-4 py-3">FY</th>
|
||||
<th class="px-4 py-3">Due Date</th>
|
||||
<th class="px-4 py-3 text-right">Total</th>
|
||||
<th class="px-4 py-3 text-right">Balance</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3 text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 bg-white">
|
||||
{% for row in rows %}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3 font-semibold text-slate-900"><a class="text-brand-700 hover:underline" href="/client/billing/{{ row.id }}">{{ row.invoice_no }}</a></td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ row.invoice_date.strftime('%d-%m-%Y') if row.invoice_date else '-' }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ row.due_date.strftime('%d-%m-%Y') if row.due_date else '-' }}</td>
|
||||
<td class="px-4 py-3 text-right font-medium">₹ {{ '%.2f'|format(row.total_amount or 0) }}</td>
|
||||
<td class="px-4 py-3 text-right font-medium {% if row.balance_amount and row.balance_amount > 0 %}text-amber-700{% else %}text-emerald-700{% endif %}">₹ {{ '%.2f'|format(row.balance_amount or 0) }}</td>
|
||||
<td class="px-4 py-3"><span class="af-badge {% if row.status == 'PAID' %}af-badge-success{% elif row.status == 'OVERDUE' %}af-badge-danger{% else %}af-badge-warning{% endif %}">{{ row.status.replace('_', ' ') }}</span></td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
{% if row.balance_amount and row.balance_amount > 0 %}<a href="/client/billing/{{ row.id }}/pay-now" class="af-btn af-btn-primary">Pay Now</a>{% else %}<a href="/client/billing/{{ row.id }}" class="af-btn af-btn-secondary">View</a>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">No invoices found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,94 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/clients/templates/clients/_client_tabs.html" %}
|
||||
<div class="mx-auto max-w-4xl space-y-6">
|
||||
<section class="rounded-3xl bg-gradient-to-r from-brand-700 to-slate-900 p-6 text-white shadow-soft">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-brand-100">Pay Now</p>
|
||||
<h2 class="mt-2 text-2xl font-semibold">Invoice {{ invoice.invoice_no }}</h2>
|
||||
<p class="mt-2 text-sm text-brand-100">Pay the outstanding amount using online gateway, UPI or bank transfer. Online gateway receipts are created automatically after successful verification.</p>
|
||||
</section>
|
||||
|
||||
<div class="grid gap-6 md:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div class="af-card">
|
||||
<h3 class="text-lg font-semibold text-slate-900">Payment Options</h3>
|
||||
<div class="mt-5 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||
<div class="font-semibold">Amount payable: ₹ {{ '%.2f'|format(amount_due or 0) }}</div>
|
||||
<div class="mt-1">Invoice balance only is shown here. TDS or bank charges will be adjusted by the firm while recording receipt.</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% if payumoney_enabled %}
|
||||
<div class="mt-5 rounded-2xl border border-emerald-200 bg-emerald-50 p-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-emerald-900">Online Payment Gateway</div>
|
||||
<p class="mt-1 text-sm text-emerald-800">Pay securely through PayUMoney / PayU. Receipt will be created automatically after successful confirmation.</p>
|
||||
{% if payumoney_mode != 'LIVE' %}<p class="mt-1 text-xs font-semibold text-amber-700">Currently running in TEST mode.</p>{% endif %}
|
||||
</div>
|
||||
<form method="post" action="/client/billing/{{ invoice.id }}/payumoney/start">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<button type="submit" class="af-btn af-btn-primary whitespace-nowrap">Pay Online</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if cashfree_enabled %}
|
||||
<div class="mt-5 rounded-2xl border border-sky-200 bg-sky-50 p-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-sky-900">Cashfree Payment Gateway</div>
|
||||
<p class="mt-1 text-sm text-sky-800">Pay securely through Cashfree checkout. Receipt will be created automatically after successful confirmation.</p>
|
||||
{% if cashfree_mode != 'LIVE' %}<p class="mt-1 text-xs font-semibold text-amber-700">Currently running in TEST / Sandbox mode.</p>{% endif %}
|
||||
</div>
|
||||
<form method="post" action="/client/billing/{{ invoice.id }}/cashfree/start">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<button type="submit" class="af-btn af-btn-primary whitespace-nowrap">Pay with Cashfree</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if upi_link %}
|
||||
<div class="mt-5 rounded-2xl border border-brand-200 bg-brand-50 p-4">
|
||||
<div class="text-sm font-semibold text-brand-800">UPI Payment</div>
|
||||
<div class="mt-2 text-sm text-slate-700">UPI ID: <span class="font-semibold">{{ upi_id }}</span></div>
|
||||
<a href="{{ upi_link }}" class="mt-4 inline-flex af-btn af-btn-primary">Open UPI App</a>
|
||||
<p class="mt-3 text-xs text-slate-500">This opens a UPI app on supported devices. After payment, share the UTR/reference number with the firm if requested.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-5 rounded-2xl border border-slate-200 p-4">
|
||||
<div class="text-sm font-semibold text-slate-900">Bank Transfer</div>
|
||||
<dl class="mt-3 grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div><dt class="text-xs uppercase text-slate-500">Bank</dt><dd class="font-medium">{{ bank_name or '-' }}</dd></div>
|
||||
<div><dt class="text-xs uppercase text-slate-500">Account Name</dt><dd class="font-medium">{{ bank_account_name or '-' }}</dd></div>
|
||||
<div><dt class="text-xs uppercase text-slate-500">Account No.</dt><dd class="font-medium">{{ bank_account_number or '-' }}</dd></div>
|
||||
<div><dt class="text-xs uppercase text-slate-500">IFSC</dt><dd class="font-medium">{{ bank_ifsc or '-' }}</dd></div>
|
||||
</dl>
|
||||
{% if payment_instructions %}<div class="mt-4 whitespace-pre-line rounded-xl bg-slate-50 p-3 text-sm text-slate-700">{{ payment_instructions }}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="space-y-6">
|
||||
<div class="af-card">
|
||||
<h3 class="text-base font-semibold text-slate-900">Invoice Summary</h3>
|
||||
<div class="mt-4 space-y-3 text-sm">
|
||||
<div class="flex justify-between"><span class="text-slate-500">Invoice Total</span><span class="font-semibold">₹ {{ '%.2f'|format(invoice.total_amount or 0) }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-slate-500">Received</span><span class="font-semibold">₹ {{ '%.2f'|format(invoice.amount_received or 0) }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-slate-500">TDS</span><span class="font-semibold">₹ {{ '%.2f'|format(invoice.tds_deducted or 0) }}</span></div>
|
||||
<div class="border-t border-slate-200 pt-3 flex justify-between"><span class="text-slate-500">Balance</span><span class="font-semibold text-amber-700">₹ {{ '%.2f'|format(invoice.balance_amount or 0) }}</span></div>
|
||||
</div>
|
||||
<div class="mt-5 grid gap-2">
|
||||
<a href="/client/billing/{{ invoice.id }}" class="af-btn af-btn-secondary justify-center">View Invoice</a>
|
||||
<a href="/client/billing/{{ invoice.id }}/print" class="af-btn af-btn-secondary justify-center">Print / Save PDF</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 text-xs leading-5 text-slate-500 shadow-soft">
|
||||
{% if payumoney_enabled or cashfree_enabled %}Online gateway confirmation is enabled. UPI/bank transfer can still be used when the client prefers manual payment.{% else %}Online gateway is not enabled yet. This page helps the client pay through UPI/bank details and the firm records receipt manually.{% endif %}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,27 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/clients/templates/clients/_client_tabs.html" %}
|
||||
<div class="mx-auto max-w-3xl space-y-6">
|
||||
<section class="af-card p-6">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] {% if result == 'success' %}text-emerald-700{% else %}text-rose-700{% endif %}">PayUMoney Payment</p>
|
||||
<h1 class="mt-2 text-2xl font-bold text-slate-900">{{ heading }}</h1>
|
||||
<p class="mt-2 text-sm text-slate-600">{{ message }}</p>
|
||||
|
||||
{% if transaction %}
|
||||
<dl class="mt-5 grid gap-3 rounded-2xl bg-slate-50 p-4 text-sm sm:grid-cols-2">
|
||||
<div><dt class="text-xs uppercase text-slate-500">Invoice</dt><dd class="font-semibold">{{ transaction.invoice.invoice_no }}</dd></div>
|
||||
<div><dt class="text-xs uppercase text-slate-500">Amount</dt><dd class="font-semibold">₹ {{ '%.2f'|format(transaction.amount or 0) }}</dd></div>
|
||||
<div><dt class="text-xs uppercase text-slate-500">Txn ID</dt><dd class="font-mono text-xs font-semibold">{{ transaction.txnid }}</dd></div>
|
||||
<div><dt class="text-xs uppercase text-slate-500">Gateway Status</dt><dd class="font-semibold">{{ transaction.gateway_status or transaction.status }}</dd></div>
|
||||
{% if transaction.bank_ref_num %}<div><dt class="text-xs uppercase text-slate-500">Bank Ref.</dt><dd class="font-semibold">{{ transaction.bank_ref_num }}</dd></div>{% endif %}
|
||||
{% if transaction.mihpayid %}<div><dt class="text-xs uppercase text-slate-500">PayU ID</dt><dd class="font-semibold">{{ transaction.mihpayid }}</dd></div>{% endif %}
|
||||
</dl>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-6 flex flex-wrap gap-3">
|
||||
{% if transaction %}<a href="/client/billing/{{ transaction.invoice_id }}" class="af-btn af-btn-primary">View Invoice</a>{% endif %}
|
||||
<a href="/client/billing" class="af-btn af-btn-secondary">Back to My Bills</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,124 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-slate-900">Create GST Invoice</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Prepare a professional tax invoice with SAC, GST breakup, place of supply and firm billing defaults.</p>
|
||||
<p class="mt-1 text-xs text-slate-400">Invoice will be tagged to active FY: <span class="font-semibold text-slate-600">{{ active_financial_year or 'Current FY' }}</span></p>
|
||||
</div>
|
||||
<a href="/billing/settings" class="af-btn af-btn-secondary">Billing Settings</a>
|
||||
</div>
|
||||
|
||||
<form method="post" class="space-y-6 af-card">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
|
||||
<section class="space-y-4">
|
||||
<div class="af-panel-header">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-slate-900">Invoice Header</h2>
|
||||
<p class="text-xs text-slate-500">Client, date, GST treatment and billing period.</p>
|
||||
</div>
|
||||
<span class="af-badge af-badge-info">{{ settings.invoice_title or 'Tax Invoice' }}</span>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-slate-700">Client</span>
|
||||
<select name="client_id" required class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="">Select client</option>
|
||||
{% for client in clients %}
|
||||
<option value="{{ client.id }}">{{ client.client_code }} - {{ client.client_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-slate-700">Invoice Date</span>
|
||||
<input type="date" name="invoice_date" value="{{ today }}" required class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-slate-700">Due Date</span>
|
||||
<input type="date" name="due_date" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-slate-700">Billing Period From</span>
|
||||
<input type="date" name="billing_period_from" value="{{ default_billing_period_from or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-slate-700">Billing Period To</span>
|
||||
<input type="date" name="billing_period_to" value="{{ default_billing_period_to or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-slate-700">Tax Type</span>
|
||||
<select name="tax_type" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
{% for tax_type in tax_types %}<option value="{{ tax_type }}" {% if settings.default_tax_type == tax_type %}selected{% endif %}>{{ tax_type }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-slate-700">Place of Supply</span>
|
||||
<input name="place_of_supply" placeholder="State / Union Territory" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-slate-700">Client State Code</span>
|
||||
<input name="client_state_code" maxlength="2" placeholder="e.g. 33" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="mt-7 inline-flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="reverse_charge" value="yes" class="rounded border-slate-300" />
|
||||
Reverse charge applicable
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<div class="af-panel-header">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-slate-900">Invoice Lines</h2>
|
||||
<p class="text-xs text-slate-500">SAC defaults to billing settings if left blank. Blank description rows are ignored.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto rounded-xl border border-slate-200">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase text-slate-500">
|
||||
<tr>
|
||||
<th class="px-3 py-2">Service</th>
|
||||
<th class="px-3 py-2">Description</th>
|
||||
<th class="px-3 py-2">SAC</th>
|
||||
<th class="px-3 py-2">Qty</th>
|
||||
<th class="px-3 py-2">Rate</th>
|
||||
<th class="px-3 py-2">Discount</th>
|
||||
<th class="px-3 py-2">GST %</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for i in range(1, 8) %}
|
||||
<tr>
|
||||
<td class="px-3 py-2">
|
||||
<select name="line_service_id" class="w-48 rounded-lg border border-slate-300 px-2 py-1.5">
|
||||
<option value="">No service</option>
|
||||
{% for service in services %}<option value="{{ service.id }}">{{ service.service_code }} - {{ service.service_name }}</option>{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td class="px-3 py-2"><input name="line_description" class="w-80 rounded-lg border border-slate-300 px-2 py-1.5" placeholder="Professional fees / service description" /></td>
|
||||
<td class="px-3 py-2"><input name="line_sac_code" value="{{ settings.default_sac_code or '' }}" class="w-24 rounded-lg border border-slate-300 px-2 py-1.5" /></td>
|
||||
<td class="px-3 py-2"><input name="line_quantity" value="1" class="w-20 rounded-lg border border-slate-300 px-2 py-1.5" /></td>
|
||||
<td class="px-3 py-2"><input name="line_rate" value="0" class="w-28 rounded-lg border border-slate-300 px-2 py-1.5" /></td>
|
||||
<td class="px-3 py-2"><input name="line_discount" value="0" class="w-28 rounded-lg border border-slate-300 px-2 py-1.5" /></td>
|
||||
<td class="px-3 py-2"><input name="line_gst_rate" value="{{ settings.default_gst_rate or 18 }}" class="w-20 rounded-lg border border-slate-300 px-2 py-1.5" /></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<label class="block"><span class="text-sm font-medium text-slate-700">Declaration / Notes</span><textarea name="notes" rows="3" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ settings.declaration or '' }}</textarea></label>
|
||||
<label class="block"><span class="text-sm font-medium text-slate-700">Terms & Conditions</span><textarea name="terms" rows="3" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ settings.terms or '' }}</textarea></label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<a href="/billing" class="af-btn af-btn-secondary">Cancel</a>
|
||||
<button class="af-btn af-btn-primary">Save Draft Invoice</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,120 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-slate-900">{{ invoice_ctx.invoice_title }} {{ invoice.invoice_no }}</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ invoice.client_legal_name or (invoice.client.client_name if invoice.client else '') }} • {{ invoice.invoice_date }}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% if invoice.status == 'DRAFT' %}
|
||||
<form method="post" action="/billing/{{ invoice.id }}/issue">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<button class="af-btn af-btn-primary">Issue Invoice</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if can_record_payment and invoice.status not in ['DRAFT','CANCELLED','PAID'] %}
|
||||
<a href="/billing/{{ invoice.id }}/payments/new" class="af-btn af-btn-primary">Record Payment</a>
|
||||
{% endif %}
|
||||
<a href="/billing/{{ invoice.id }}/print" target="_blank" class="af-btn af-btn-secondary">Print / PDF</a>
|
||||
<a href="/billing" class="af-btn af-btn-secondary">Back</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="af-card space-y-6">
|
||||
<div class="grid gap-4 md:grid-cols-4 lg:grid-cols-7">
|
||||
<div><div class="text-xs uppercase text-slate-500">Status</div><div class="font-semibold">{{ invoice.status }}</div></div>
|
||||
<div><div class="text-xs uppercase text-slate-500">Due Date</div><div class="font-semibold">{{ invoice.due_date or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase text-slate-500">Place of Supply</div><div class="font-semibold">{{ invoice.place_of_supply or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase text-slate-500">Total</div><div class="font-semibold">₹ {{ '%.2f'|format(invoice.total_amount or 0) }}</div></div>
|
||||
<div><div class="text-xs uppercase text-slate-500">Amount Received</div><div class="font-semibold text-emerald-700">₹ {{ '%.2f'|format(invoice.amount_received or 0) }}</div></div>
|
||||
<div><div class="text-xs uppercase text-slate-500">TDS Deducted</div><div class="font-semibold text-blue-700">₹ {{ '%.2f'|format(invoice.tds_deducted or 0) }}</div></div>
|
||||
<div><div class="text-xs uppercase text-slate-500">Balance</div><div class="font-semibold {% if invoice.balance_amount and invoice.balance_amount > 0 %}text-amber-700{% else %}text-emerald-700{% endif %}">₹ {{ '%.2f'|format(invoice.balance_amount or 0) }}</div></div>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="rounded-xl border border-slate-200 p-4">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Supplier</h2>
|
||||
<div class="mt-2 font-semibold text-slate-900">{{ invoice_ctx.firm_name }}</div>
|
||||
<div class="text-sm text-slate-600 whitespace-pre-line">{{ invoice_ctx.firm_address or '-' }}</div>
|
||||
<div class="mt-2 text-sm text-slate-600">GSTIN: {{ invoice_ctx.firm_gstin or '-' }} • PAN: {{ invoice_ctx.firm_pan or '-' }}</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-200 p-4">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Bill To</h2>
|
||||
<div class="mt-2 font-semibold text-slate-900">{{ invoice.client_legal_name or '-' }}</div>
|
||||
<div class="text-sm text-slate-600">{{ invoice.client_billing_address or '-' }}</div>
|
||||
<div class="mt-2 text-sm text-slate-600">GSTIN: {{ invoice.client_gstin or '-' }} • PAN: {{ invoice.client_pan or '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase text-slate-500">
|
||||
<tr><th class="px-4 py-3">Description</th><th class="px-4 py-3">SAC</th><th class="px-4 py-3 text-right">Qty</th><th class="px-4 py-3 text-right">Rate</th><th class="px-4 py-3 text-right">Taxable</th><th class="px-4 py-3 text-right">GST</th><th class="px-4 py-3 text-right">Total</th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for line in invoice.lines %}
|
||||
<tr>
|
||||
<td class="px-4 py-3"><div class="font-medium text-slate-900">{{ line.description }}</div><div class="text-xs text-slate-500">{{ line.service.service_name if line.service else '' }}</div></td>
|
||||
<td class="px-4 py-3">{{ line.sac_code or '-' }}</td>
|
||||
<td class="px-4 py-3 text-right">{{ line.quantity }}</td>
|
||||
<td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(line.rate or 0) }}</td>
|
||||
<td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(line.taxable_amount or 0) }}</td>
|
||||
<td class="px-4 py-3 text-right">{{ line.gst_rate }}%</td>
|
||||
<td class="px-4 py-3 text-right font-semibold">₹ {{ '%.2f'|format(line.line_total or 0) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot class="bg-slate-50 text-sm font-semibold">
|
||||
<tr><td colspan="6" class="px-4 py-3 text-right">Subtotal</td><td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(invoice.subtotal or 0) }}</td></tr>
|
||||
<tr><td colspan="6" class="px-4 py-3 text-right">Discount</td><td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(invoice.discount_amount or 0) }}</td></tr>
|
||||
<tr><td colspan="6" class="px-4 py-3 text-right">Taxable Value</td><td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(invoice.taxable_amount or 0) }}</td></tr>
|
||||
<tr><td colspan="6" class="px-4 py-3 text-right">CGST</td><td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(invoice.cgst_amount or 0) }}</td></tr>
|
||||
<tr><td colspan="6" class="px-4 py-3 text-right">SGST</td><td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(invoice.sgst_amount or 0) }}</td></tr>
|
||||
<tr><td colspan="6" class="px-4 py-3 text-right">IGST</td><td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(invoice.igst_amount or 0) }}</td></tr>
|
||||
<tr><td colspan="6" class="px-4 py-3 text-right text-base">Grand Total</td><td class="px-4 py-3 text-right text-base">₹ {{ '%.2f'|format(invoice.total_amount or 0) }}</td></tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="af-card space-y-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="font-semibold text-slate-900">Payment History</h2>
|
||||
<p class="text-sm text-slate-500">Receipts, TDS deductions and outstanding balance for this invoice.</p>
|
||||
</div>
|
||||
{% if can_record_payment and invoice.status not in ['DRAFT','CANCELLED','PAID'] %}
|
||||
<a href="/billing/{{ invoice.id }}/payments/new" class="af-btn af-btn-primary">Record Payment</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-xl border border-slate-200">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase text-slate-500">
|
||||
<tr><th class="px-4 py-3">Receipt</th><th class="px-4 py-3">Date</th><th class="px-4 py-3">Mode</th><th class="px-4 py-3">Reference</th><th class="px-4 py-3 text-right">Received</th><th class="px-4 py-3 text-right">TDS</th><th class="px-4 py-3"></th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for payment in invoice.payments %}
|
||||
<tr>
|
||||
<td class="px-4 py-3 font-medium text-slate-900">{{ payment.receipt_no }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ payment.payment_date }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ payment.mode }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ payment.reference_no or '-' }}</td>
|
||||
<td class="px-4 py-3 text-right font-semibold">₹ {{ '%.2f'|format(payment.amount_received or 0) }}</td>
|
||||
<td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(payment.tds_deducted or 0) }}</td>
|
||||
<td class="px-4 py-3 text-right"><a href="/billing/payments/{{ payment.id }}/receipt" target="_blank" class="text-brand-600 hover:underline">Receipt</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="px-4 py-6 text-center text-slate-500">No payments recorded yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="af-card"><h2 class="font-semibold text-slate-900">Amount in Words</h2><p class="mt-2 text-sm text-slate-600">{{ invoice.amount_in_words or '-' }}</p></div>
|
||||
<div class="af-card"><h2 class="font-semibold text-slate-900">Bank / UPI Details</h2><p class="mt-2 text-sm text-slate-600 whitespace-pre-line">{% if invoice_ctx.bank_name %}{{ invoice_ctx.bank_name }}{% endif %}{% if invoice_ctx.bank_account_number %}\nA/c: {{ invoice_ctx.bank_account_number }}{% endif %}{% if invoice_ctx.bank_ifsc %}\nIFSC: {{ invoice_ctx.bank_ifsc }}{% endif %}{% if invoice_ctx.upi_id %}\nUPI: {{ invoice_ctx.upi_id }}{% endif %}</p></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,53 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-slate-900">Import Fee Structure</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Upload Excel with Fee_Structure and Fee_Services sheets.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/billing/fee-structures/template" class="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-2 text-sm font-medium text-emerald-800 shadow-sm hover:bg-emerald-100">Download Excel Template</a>
|
||||
<a href="/billing/fee-structures/list" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Fee Structure List</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if result %}
|
||||
<div class="rounded-2xl border {{ 'border-emerald-200 bg-emerald-50 text-emerald-900' if result.success else 'border-red-200 bg-red-50 text-red-900' }} p-4">
|
||||
{% if result.success %}
|
||||
<div class="font-semibold">Import completed</div>
|
||||
<div class="mt-1 text-sm">Created: {{ result.created }} | Updated: {{ result.updated }}</div>
|
||||
{% else %}
|
||||
<div class="font-semibold">Import failed</div>
|
||||
<ul class="mt-2 list-disc pl-5 text-sm">
|
||||
{% for error in result.errors %}<li>{{ error }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" enctype="multipart/form-data" class="space-y-5 rounded-2xl border border-slate-200 bg-white p-6 shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-slate-700">Excel File</span>
|
||||
<input type="file" name="import_file" accept=".xlsx,.xlsm" required class="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<div class="rounded-xl bg-slate-50 p-4 text-sm text-slate-600">
|
||||
<div class="font-semibold text-slate-800">How to import using template</div>
|
||||
<ol class="mt-2 list-decimal space-y-1 pl-5">
|
||||
<li>Click <strong>Download Excel Template</strong>.</li>
|
||||
<li>Fill <strong>Fee_Structure</strong> for client-wise package/header details.</li>
|
||||
<li>Fill <strong>Fee_Services</strong> for services included in each package.</li>
|
||||
<li>Upload the completed file here. Imported fee structures can then be used in <strong>Generate Bills</strong>.</li>
|
||||
</ol>
|
||||
<div class="mt-4 font-semibold text-slate-800">Required sheets</div>
|
||||
<div class="mt-1">Fee_Structure: client, billing group, mode, frequency, fee and tax details.</div>
|
||||
<div>Fee_Services: services included in each billing group.</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3">
|
||||
<a href="/billing/fee-structures/list" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700">Back</a>
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Import Fee Structure</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,63 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-slate-900">Fee Structure</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Client-wise billing packages with multiple services grouped for future invoice generation.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/billing/generate" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Generate Bills</a>
|
||||
<a href="/billing" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Invoices</a>
|
||||
{% if can_import %}
|
||||
<a href="/billing/fee-structures/template" class="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-2 text-sm font-medium text-emerald-800 shadow-sm hover:bg-emerald-100">Download Template</a>
|
||||
<a href="/billing/fee-structures/import" class="rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-emerald-700">Import Using Template</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="flex gap-3">
|
||||
<input name="q" value="{{ q or '' }}" placeholder="Search group, client code or client name" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none" />
|
||||
<button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Group Code</th>
|
||||
<th class="px-4 py-3">Client</th>
|
||||
<th class="px-4 py-3">Package</th>
|
||||
<th class="px-4 py-3">Mode</th>
|
||||
<th class="px-4 py-3">Frequency</th>
|
||||
<th class="px-4 py-3 text-right">Fee</th>
|
||||
<th class="px-4 py-3">Services</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for row in rows %}
|
||||
<tr class="align-top hover:bg-slate-50">
|
||||
<td class="px-4 py-3 font-medium text-slate-900">{{ row.group_code }}</td>
|
||||
<td class="px-4 py-3 text-slate-700">{{ row.client.client_name if row.client else row.client_id }}</td>
|
||||
<td class="px-4 py-3 text-slate-700">{{ row.group_name }}</td>
|
||||
<td class="px-4 py-3"><span class="rounded-full bg-slate-100 px-2 py-1 text-xs">{{ row.billing_mode }}</span></td>
|
||||
<td class="px-4 py-3">{{ row.frequency }}</td>
|
||||
<td class="px-4 py-3 text-right font-semibold">₹ {{ '%.2f'|format(row.fee_amount or 0) }}</td>
|
||||
<td class="px-4 py-3 text-xs text-slate-600">
|
||||
{% for item in row.services %}
|
||||
<div>{{ item.service.service_code if item.service else item.service_id }} - {{ item.line_description or (item.service.service_name if item.service else '') }}</div>
|
||||
{% else %}
|
||||
<span class="text-slate-400">No services mapped</span>
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">No fee structures found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,182 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-slate-900">Generate Draft Invoices</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Create draft GST invoices from fee structures and automatically link matching client service subscriptions / engagements for the selected financial year. Existing invoices for the same fee group and period are skipped by default.</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="/billing" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Invoices</a>
|
||||
<a href="/billing/fee-structures/list" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Fee Structure</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if result %}
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<h2 class="text-lg font-semibold text-slate-900">Generation Result</h2>
|
||||
<div class="mt-3 grid gap-3 sm:grid-cols-3">
|
||||
<div class="rounded-xl bg-emerald-50 p-3 text-sm text-emerald-800"><div class="text-xs uppercase tracking-wide">Draft invoices created</div><div class="mt-1 text-2xl font-bold">{{ result.created|length }}</div></div>
|
||||
<div class="rounded-xl bg-amber-50 p-3 text-sm text-amber-800"><div class="text-xs uppercase tracking-wide">Skipped</div><div class="mt-1 text-2xl font-bold">{{ result.skipped|length }}</div></div>
|
||||
<div class="rounded-xl bg-rose-50 p-3 text-sm text-rose-800"><div class="text-xs uppercase tracking-wide">Errors</div><div class="mt-1 text-2xl font-bold">{{ result.errors|length }}</div></div>
|
||||
</div>
|
||||
|
||||
{% if result.created %}
|
||||
<div class="mt-4">
|
||||
<div class="text-sm font-semibold text-slate-700">Created Draft Invoices</div>
|
||||
<div class="mt-2 overflow-hidden rounded-xl border border-slate-200">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500"><tr><th class="px-3 py-2">Invoice</th><th class="px-3 py-2">Client</th><th class="px-3 py-2 text-right">Amount</th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for invoice in result.created %}
|
||||
<tr>
|
||||
<td class="px-3 py-2"><a href="/billing/{{ invoice.id }}" class="font-semibold text-brand-700 hover:underline">{{ invoice.invoice_no }}</a></td>
|
||||
<td class="px-3 py-2">{{ invoice.client.client_name if invoice.client else invoice.client_id }}</td>
|
||||
<td class="px-3 py-2 text-right">₹ {{ '%.2f'|format(invoice.total_amount or 0) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if result.skipped %}
|
||||
<div class="mt-4 rounded-xl bg-amber-50 p-3 text-sm text-amber-800">
|
||||
<div class="font-semibold">Skipped rows</div>
|
||||
<ul class="mt-1 list-disc space-y-1 pl-5">
|
||||
{% for item in result.skipped %}<li>{{ item }}</li>{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if result.errors %}
|
||||
<div class="mt-4 rounded-xl bg-rose-50 p-3 text-sm text-rose-800">
|
||||
<div class="font-semibold">Errors</div>
|
||||
<ul class="mt-1 list-disc space-y-1 pl-5">
|
||||
{% for item in result.errors %}<li>{{ item }}</li>{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="rounded-2xl border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900">
|
||||
<div class="font-semibold">Engagement-to-invoice refinement</div>
|
||||
<div class="mt-1">This screen continues to use your existing fee-structure billing logic. During generation, the system checks the client, service and active financial year ({{ active_financial_year or 'current FY' }}) and links the invoice / invoice lines to the matching client service subscription wherever available. No duplicate module is created.</div>
|
||||
</div>
|
||||
|
||||
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="grid gap-3 lg:grid-cols-6">
|
||||
<div>
|
||||
<label class="text-xs font-semibold uppercase tracking-wide text-slate-500">Frequency</label>
|
||||
<select name="frequency" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none">
|
||||
<option value="">All</option>
|
||||
{% for f in frequencies %}<option value="{{ f }}" {% if frequency == f %}selected{% endif %}>{{ f }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold uppercase tracking-wide text-slate-500">Period From</label>
|
||||
<input type="date" name="billing_period_from" value="{{ billing_period_from }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold uppercase tracking-wide text-slate-500">Period To</label>
|
||||
<input type="date" name="billing_period_to" value="{{ billing_period_to }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold uppercase tracking-wide text-slate-500">Auto Generate</label>
|
||||
<select name="auto_generate_only" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none">
|
||||
<option value="yes" {% if auto_generate_only != 'no' %}selected{% endif %}>Only Yes</option>
|
||||
<option value="no" {% if auto_generate_only == 'no' %}selected{% endif %}>All Active</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="lg:col-span-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-wide text-slate-500">Search</label>
|
||||
<div class="mt-1 flex gap-2">
|
||||
<input name="q" value="{{ q or '' }}" placeholder="Client / group code / package" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none" />
|
||||
<button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white">Filter</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form method="post" class="rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<input type="hidden" name="frequency" value="{{ frequency or '' }}" />
|
||||
<input type="hidden" name="billing_period_from" value="{{ billing_period_from }}" />
|
||||
<input type="hidden" name="billing_period_to" value="{{ billing_period_to }}" />
|
||||
<input type="hidden" name="auto_generate_only" value="{{ auto_generate_only }}" />
|
||||
<input type="hidden" name="q" value="{{ q or '' }}" />
|
||||
|
||||
<div class="flex flex-col gap-3 border-b border-slate-200 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div class="font-semibold text-slate-900">Eligible Fee Structures</div>
|
||||
<div class="text-sm text-slate-500">Select packages and create draft invoices for {{ billing_period_from }} to {{ billing_period_to }}.</div>
|
||||
</div>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-slate-600">
|
||||
<input type="checkbox" name="skip_duplicates" value="yes" checked class="rounded border-slate-300 text-brand-600" />
|
||||
Skip duplicates
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3"><input type="checkbox" onclick="document.querySelectorAll('.fee-check').forEach(cb => cb.checked = this.checked && !cb.disabled)" /></th>
|
||||
<th class="px-4 py-3">Group Code</th>
|
||||
<th class="px-4 py-3">Client</th>
|
||||
<th class="px-4 py-3">Package</th>
|
||||
<th class="px-4 py-3">Services / Engagement Source</th>
|
||||
<th class="px-4 py-3">Mode</th>
|
||||
<th class="px-4 py-3 text-right">Fee</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for row in rows %}
|
||||
{% set duplicate = duplicate_map.get(row.id) %}
|
||||
<tr class="align-top hover:bg-slate-50">
|
||||
<td class="px-4 py-3"><input class="fee-check rounded border-slate-300 text-brand-600" type="checkbox" name="fee_group_ids" value="{{ row.id }}" {% if duplicate %}disabled{% endif %} /></td>
|
||||
<td class="px-4 py-3 font-medium text-slate-900">{{ row.group_code }}</td>
|
||||
<td class="px-4 py-3 text-slate-700">{{ row.client.client_name if row.client else row.client_id }}</td>
|
||||
<td class="px-4 py-3 text-slate-700">
|
||||
<div class="font-medium text-slate-900">{{ row.group_name }}</div>
|
||||
<div class="mt-1 text-xs text-slate-500">{{ row.frequency }} billing</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-slate-600">
|
||||
{% if row.services %}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{% for item in row.services[:4] %}
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1">{{ item.service.service_name if item.service else item.service_id }}</span>
|
||||
{% endfor %}
|
||||
{% if row.services|length > 4 %}<span class="rounded-full bg-slate-100 px-2 py-1">+{{ row.services|length - 4 }}</span>{% endif %}
|
||||
</div>
|
||||
<div class="mt-1 text-[11px] text-slate-400">Matching active subscriptions are linked during generation.</div>
|
||||
{% else %}
|
||||
<span class="text-slate-400">Package line only</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3"><span class="rounded-full bg-slate-100 px-2 py-1 text-xs">{{ row.billing_mode }}</span></td>
|
||||
<td class="px-4 py-3 text-right font-semibold">₹ {{ '%.2f'|format(row.fee_amount or 0) }}</td>
|
||||
<td class="px-4 py-3 text-xs">
|
||||
{% if duplicate %}
|
||||
<span class="rounded-full bg-amber-100 px-2 py-1 font-medium text-amber-800">Already billed: {{ duplicate.invoice_no }}</span>
|
||||
{% else %}
|
||||
<span class="rounded-full bg-emerald-100 px-2 py-1 font-medium text-emerald-800">Ready</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8" class="px-4 py-8 text-center text-slate-500">No eligible fee structures found for the selected filter.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end border-t border-slate-200 p-4">
|
||||
<button class="rounded-xl bg-brand-600 px-5 py-2 text-sm font-semibold text-white shadow-soft hover:bg-brand-700">Generate Draft Invoices</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,120 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{{ invoice_ctx.invoice_title }} {{ invoice.invoice_no }}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
@page { size: A4; margin: 14mm; }
|
||||
@media print { .no-print { display: none !important; } body { background: white !important; } }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-slate-100 text-slate-900">
|
||||
<div class="no-print mx-auto my-4 flex max-w-5xl justify-end gap-2">
|
||||
<button onclick="window.print()" class="rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white">Print / Save PDF</button>
|
||||
<a href="/billing/{{ invoice.id }}" class="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700">Back</a>
|
||||
</div>
|
||||
<main class="mx-auto max-w-5xl bg-white p-8 shadow print:shadow-none">
|
||||
<header class="border-b-2 border-slate-900 pb-4">
|
||||
<div class="flex items-start justify-between gap-6">
|
||||
<div>
|
||||
<div class="text-2xl font-bold">{{ invoice_ctx.firm_name }}</div>
|
||||
<div class="mt-1 whitespace-pre-line text-sm text-slate-600">{{ invoice_ctx.firm_address or '' }}</div>
|
||||
<div class="mt-2 text-sm text-slate-700">GSTIN: <b>{{ invoice_ctx.firm_gstin or '-' }}</b> | PAN: <b>{{ invoice_ctx.firm_pan or '-' }}</b></div>
|
||||
<div class="text-sm text-slate-700">Email: {{ invoice_ctx.firm_contact_email or '-' }} | Mobile: {{ invoice_ctx.firm_contact_mobile or '-' }}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-2xl font-bold uppercase">{{ invoice_ctx.invoice_title }}</div>
|
||||
<div class="mt-2 text-sm">Invoice No: <b>{{ invoice.invoice_no }}</b></div>
|
||||
<div class="text-sm">Invoice Date: <b>{{ invoice.invoice_date }}</b></div>
|
||||
<div class="text-sm">Due Date: <b>{{ invoice.due_date or '-' }}</b></div>
|
||||
<div class="text-sm">Status: <b>{{ invoice.status }}</b></div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="mt-5 grid grid-cols-2 gap-4 text-sm">
|
||||
<div class="rounded-lg border border-slate-300 p-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Bill To</div>
|
||||
<div class="mt-2 text-base font-bold">{{ invoice.client_legal_name or '-' }}</div>
|
||||
<div class="mt-1 text-slate-700">{{ invoice.client_billing_address or '-' }}</div>
|
||||
<div class="mt-2">GSTIN: <b>{{ invoice.client_gstin or '-' }}</b></div>
|
||||
<div>PAN: <b>{{ invoice.client_pan or '-' }}</b></div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-300 p-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Tax Particulars</div>
|
||||
<div class="mt-2">Place of Supply: <b>{{ invoice.place_of_supply or '-' }}</b></div>
|
||||
<div>Tax Type: <b>{{ invoice.tax_type }}</b></div>
|
||||
<div>Reverse Charge: <b>{{ 'Yes' if invoice.reverse_charge else 'No' }}</b></div>
|
||||
<div>Client State Code: <b>{{ invoice.client_state_code or '-' }}</b></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<table class="mt-5 w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr class="bg-slate-100">
|
||||
<th class="border border-slate-300 px-2 py-2 text-left">#</th>
|
||||
<th class="border border-slate-300 px-2 py-2 text-left">Description</th>
|
||||
<th class="border border-slate-300 px-2 py-2 text-left">SAC</th>
|
||||
<th class="border border-slate-300 px-2 py-2 text-right">Qty</th>
|
||||
<th class="border border-slate-300 px-2 py-2 text-right">Rate</th>
|
||||
<th class="border border-slate-300 px-2 py-2 text-right">Taxable</th>
|
||||
<th class="border border-slate-300 px-2 py-2 text-right">GST %</th>
|
||||
<th class="border border-slate-300 px-2 py-2 text-right">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for line in invoice.lines %}
|
||||
<tr>
|
||||
<td class="border border-slate-300 px-2 py-2">{{ loop.index }}</td>
|
||||
<td class="border border-slate-300 px-2 py-2">{{ line.description }}</td>
|
||||
<td class="border border-slate-300 px-2 py-2">{{ line.sac_code or '-' }}</td>
|
||||
<td class="border border-slate-300 px-2 py-2 text-right">{{ line.quantity }}</td>
|
||||
<td class="border border-slate-300 px-2 py-2 text-right">{{ '%.2f'|format(line.rate or 0) }}</td>
|
||||
<td class="border border-slate-300 px-2 py-2 text-right">{{ '%.2f'|format(line.taxable_amount or 0) }}</td>
|
||||
<td class="border border-slate-300 px-2 py-2 text-right">{{ line.gst_rate }}</td>
|
||||
<td class="border border-slate-300 px-2 py-2 text-right">{{ '%.2f'|format(line.line_total or 0) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr><td colspan="7" class="border border-slate-300 px-2 py-2 text-right font-semibold">Subtotal</td><td class="border border-slate-300 px-2 py-2 text-right font-semibold">{{ '%.2f'|format(invoice.subtotal or 0) }}</td></tr>
|
||||
<tr><td colspan="7" class="border border-slate-300 px-2 py-2 text-right font-semibold">Discount</td><td class="border border-slate-300 px-2 py-2 text-right font-semibold">{{ '%.2f'|format(invoice.discount_amount or 0) }}</td></tr>
|
||||
<tr><td colspan="7" class="border border-slate-300 px-2 py-2 text-right font-semibold">Taxable Value</td><td class="border border-slate-300 px-2 py-2 text-right font-semibold">{{ '%.2f'|format(invoice.taxable_amount or 0) }}</td></tr>
|
||||
<tr><td colspan="7" class="border border-slate-300 px-2 py-2 text-right font-semibold">CGST</td><td class="border border-slate-300 px-2 py-2 text-right font-semibold">{{ '%.2f'|format(invoice.cgst_amount or 0) }}</td></tr>
|
||||
<tr><td colspan="7" class="border border-slate-300 px-2 py-2 text-right font-semibold">SGST</td><td class="border border-slate-300 px-2 py-2 text-right font-semibold">{{ '%.2f'|format(invoice.sgst_amount or 0) }}</td></tr>
|
||||
<tr><td colspan="7" class="border border-slate-300 px-2 py-2 text-right font-semibold">IGST</td><td class="border border-slate-300 px-2 py-2 text-right font-semibold">{{ '%.2f'|format(invoice.igst_amount or 0) }}</td></tr>
|
||||
<tr class="bg-slate-100"><td colspan="7" class="border border-slate-300 px-2 py-2 text-right text-base font-bold">Grand Total</td><td class="border border-slate-300 px-2 py-2 text-right text-base font-bold">₹ {{ '%.2f'|format(invoice.total_amount or 0) }}</td></tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<section class="mt-5 grid grid-cols-2 gap-4 text-sm">
|
||||
<div class="rounded-lg border border-slate-300 p-4">
|
||||
<div class="font-semibold">Amount in Words</div>
|
||||
<div class="mt-1">{{ invoice.amount_in_words or '-' }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-300 p-4">
|
||||
<div class="font-semibold">Payment Details</div>
|
||||
<div class="mt-1">Bank: {{ invoice_ctx.bank_name or '-' }}</div>
|
||||
<div>A/c: {{ invoice_ctx.bank_account_number or '-' }}</div>
|
||||
<div>IFSC: {{ invoice_ctx.bank_ifsc or '-' }}</div>
|
||||
<div>UPI: {{ invoice_ctx.upi_id or '-' }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mt-5 text-sm">
|
||||
{% if invoice_ctx.terms %}<div><b>Terms:</b> {{ invoice_ctx.terms }}</div>{% endif %}
|
||||
{% if invoice_ctx.declaration %}<div class="mt-2"><b>Declaration:</b> {{ invoice_ctx.declaration }}</div>{% endif %}
|
||||
</section>
|
||||
|
||||
<footer class="mt-12 flex items-end justify-between text-sm">
|
||||
<div>{{ invoice_ctx.footer_note or '' }}</div>
|
||||
<div class="text-center">
|
||||
<div class="mb-10">For {{ invoice_ctx.firm_name }}</div>
|
||||
<div class="border-t border-slate-500 px-8 pt-2">{{ invoice_ctx.authorised_signatory_name or 'Authorised Signatory' }}</div>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,81 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-slate-900">Billing Invoices</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Create, issue and print GST-ready client invoices with SAC and tax breakup.</p>
|
||||
<p class="mt-1 text-xs text-slate-400">Showing billing records for active FY: <span class="font-semibold text-slate-600">{{ active_financial_year or 'All Years' }}</span></p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% if can_generate %}
|
||||
<a href="/billing/generate" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Generate Bills</a>
|
||||
{% endif %}
|
||||
{% if can_view_fee_structure %}
|
||||
<a href="/billing/fee-structures/list" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Fee Structure</a>
|
||||
{% endif %}
|
||||
{% if can_import_fee_structure %}
|
||||
<a href="/billing/fee-structures/template" class="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-2 text-sm font-medium text-emerald-800 shadow-sm hover:bg-emerald-100">Download Fee Template</a>
|
||||
<a href="/billing/fee-structures/import" class="rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-emerald-700">Import Fee Excel</a>
|
||||
{% endif %}
|
||||
<a href="/billing/payments" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Payments</a>
|
||||
<a href="/billing/settings" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Billing Settings</a>
|
||||
{% if can_create %}
|
||||
<a href="/billing/new" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-brand-700">New Invoice</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="flex gap-3">
|
||||
<input name="q" value="{{ q or '' }}" placeholder="Search invoice no, client code or client name" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none" />
|
||||
<button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
{% if report_summary %}
|
||||
<section class="grid gap-4 md:grid-cols-4">
|
||||
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Billed</div><div class="mt-2 text-2xl font-semibold text-slate-900">₹ {{ '%.2f'|format(report_summary.total_billed or 0) }}</div><div class="mt-1 text-xs text-slate-500">{{ report_summary.invoice_count }} invoice(s)</div></div>
|
||||
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Collected + TDS</div><div class="mt-2 text-2xl font-semibold text-emerald-700">₹ {{ '%.2f'|format(report_summary.total_collected_with_tds or 0) }}</div><div class="mt-1 text-xs text-slate-500">{{ report_summary.payment_count }} receipt(s)</div></div>
|
||||
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Outstanding</div><div class="mt-2 text-2xl font-semibold text-amber-700">₹ {{ '%.2f'|format(report_summary.outstanding or 0) }}</div><div class="mt-1 text-xs text-slate-500">Active issued bills</div></div>
|
||||
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Status</div><div class="mt-2 text-sm font-semibold text-slate-800">Draft {{ report_summary.draft_count }} · Open {{ report_summary.issued_count }} · Paid {{ report_summary.paid_count }}</div><div class="mt-1 text-xs text-slate-500">FY-filtered billing report</div></div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Invoice No</th>
|
||||
<th class="px-4 py-3">Date</th>
|
||||
<th class="px-4 py-3">FY</th>
|
||||
<th class="px-4 py-3">Client</th>
|
||||
<th class="px-4 py-3 text-right">Amount</th>
|
||||
<th class="px-4 py-3 text-right">Received/TDS</th>
|
||||
<th class="px-4 py-3 text-right">Balance</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for row in rows %}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3 font-medium text-slate-900">{{ row.invoice_no }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ row.invoice_date }}</td>
|
||||
<td class="px-4 py-3 text-xs text-slate-500">{{ row.financial_year or '-' }}</td>
|
||||
<td class="px-4 py-3 text-slate-700">{{ row.client.client_name if row.client else row.client_id }}</td>
|
||||
<td class="px-4 py-3 text-right font-semibold">₹ {{ '%.2f'|format(row.total_amount or 0) }}</td>
|
||||
<td class="px-4 py-3 text-right text-slate-700">₹ {{ '%.2f'|format((row.amount_received or 0) + (row.tds_deducted or 0)) }}</td>
|
||||
<td class="px-4 py-3 text-right font-semibold {% if row.balance_amount and row.balance_amount > 0 %}text-amber-700{% else %}text-emerald-700{% endif %}">₹ {{ '%.2f'|format(row.balance_amount or row.total_amount or 0) }}</td>
|
||||
<td class="px-4 py-3"><span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-medium text-slate-700">{{ row.status }}</span></td>
|
||||
<td class="px-4 py-3 text-right"><div class="flex justify-end gap-3"><a href="/billing/{{ row.id }}" class="text-brand-600 hover:underline">View</a>{% if can_record_payment and row.status not in ['DRAFT','CANCELLED','PAID'] %}<a href="/billing/{{ row.id }}/payments/new" class="text-emerald-700 hover:underline">Payment</a>{% endif %}<a href="/billing/{{ row.id }}/print" target="_blank" class="text-slate-600 hover:underline">Print</a></div></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="9" class="px-4 py-8 text-center text-slate-500">No invoices found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div><h1 class="text-2xl font-semibold text-slate-900">Payments & Receipts</h1><p class="mt-1 text-sm text-slate-500">Track invoice collections, TDS deductions and receipt printouts.</p><p class="mt-1 text-xs text-slate-400">Showing receipts for active FY: <span class="font-semibold text-slate-600">{{ active_financial_year or 'All Years' }}</span></p></div>
|
||||
<a href="/billing" class="af-btn af-btn-secondary">Invoices</a>
|
||||
</div>
|
||||
<form method="get" class="af-card"><div class="flex gap-3"><input name="q" value="{{ q or '' }}" placeholder="Search receipt, invoice or client" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" /><button class="af-btn af-btn-primary">Search</button></div></form>
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase text-slate-500"><tr><th class="px-4 py-3">Receipt</th><th class="px-4 py-3">Invoice</th><th class="px-4 py-3">Client</th><th class="px-4 py-3">Date</th><th class="px-4 py-3">FY</th><th class="px-4 py-3">Mode</th><th class="px-4 py-3 text-right">Received</th><th class="px-4 py-3 text-right">TDS</th><th class="px-4 py-3"></th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for row in rows %}
|
||||
<tr><td class="px-4 py-3 font-medium">{{ row.receipt_no }}</td><td class="px-4 py-3">{{ row.invoice.invoice_no if row.invoice else row.invoice_id }}</td><td class="px-4 py-3">{{ row.client.client_name if row.client else row.client_id }}</td><td class="px-4 py-3">{{ row.payment_date }}</td><td class="px-4 py-3 text-xs text-slate-500">{{ row.financial_year or '-' }}</td><td class="px-4 py-3">{{ row.mode }}</td><td class="px-4 py-3 text-right font-semibold">₹ {{ '%.2f'|format(row.amount_received or 0) }}</td><td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(row.tds_deducted or 0) }}</td><td class="px-4 py-3 text-right"><a href="/billing/payments/{{ row.id }}/receipt" target="_blank" class="text-brand-600 hover:underline">Receipt</a></td></tr>
|
||||
{% else %}
|
||||
<tr><td colspan="9" class="px-4 py-8 text-center text-slate-500">No payments recorded.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,24 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6 max-w-4xl">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-slate-900">Record Payment</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Invoice {{ invoice.invoice_no }} • Balance ₹ {{ '%.2f'|format(invoice.balance_amount or invoice.total_amount or 0) }}</p>
|
||||
</div>
|
||||
<form method="post" class="af-card space-y-5">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<div><label class="text-sm font-medium text-slate-700">Payment date</label><input type="date" name="payment_date" value="{{ today }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" required /></div>
|
||||
<div><label class="text-sm font-medium text-slate-700">Amount received</label><input type="number" step="0.01" name="amount_received" value="{{ '%.2f'|format(invoice.balance_amount or invoice.total_amount or 0) }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" /></div>
|
||||
<div><label class="text-sm font-medium text-slate-700">TDS deducted</label><input type="number" step="0.01" name="tds_deducted" value="0.00" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" /></div>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<div><label class="text-sm font-medium text-slate-700">Bank charges</label><input type="number" step="0.01" name="bank_charges" value="0.00" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" /></div>
|
||||
<div><label class="text-sm font-medium text-slate-700">Mode</label><select name="mode" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{% for mode in payment_modes %}<option value="{{ mode }}">{{ mode }}</option>{% endfor %}</select></div>
|
||||
<div><label class="text-sm font-medium text-slate-700">Reference no.</label><input name="reference_no" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="UTR / cheque / transaction id" /></div>
|
||||
</div>
|
||||
<div><label class="text-sm font-medium text-slate-700">Remarks</label><textarea name="remarks" rows="3" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"></textarea></div>
|
||||
<div class="flex justify-end gap-2"><a href="/billing/{{ invoice.id }}" class="af-btn af-btn-secondary">Cancel</a><button class="af-btn af-btn-primary">Save & Print Receipt</button></div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,19 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-4xl bg-white p-8 print:p-0">
|
||||
<div class="mb-4 flex justify-end print:hidden"><button onclick="window.print()" class="af-btn af-btn-primary">Print Receipt</button></div>
|
||||
<div class="rounded-2xl border border-slate-300 p-8">
|
||||
<div class="flex items-start justify-between border-b border-slate-200 pb-5">
|
||||
<div><h1 class="text-2xl font-bold text-slate-900">{{ invoice_ctx.firm_name }}</h1><p class="mt-1 whitespace-pre-line text-sm text-slate-600">{{ invoice_ctx.firm_address or '' }}</p><p class="mt-1 text-sm text-slate-600">GSTIN: {{ invoice_ctx.firm_gstin or '-' }} • PAN: {{ invoice_ctx.firm_pan or '-' }}</p></div>
|
||||
<div class="text-right"><div class="text-xl font-bold text-slate-900">Receipt</div><div class="mt-1 text-sm text-slate-600">{{ payment.receipt_no }}</div><div class="text-sm text-slate-600">{{ payment.receipt_date }}</div></div>
|
||||
</div>
|
||||
<div class="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<div><div class="text-xs font-semibold uppercase text-slate-500">Received From</div><div class="mt-1 font-semibold text-slate-900">{{ payment.client.client_name if payment.client else invoice.client_legal_name }}</div><div class="text-sm text-slate-600">Invoice: {{ invoice.invoice_no }}</div></div>
|
||||
<div class="rounded-xl bg-slate-50 p-4"><div class="grid gap-2 text-sm"><div class="flex justify-between"><span>Amount Received</span><strong>₹ {{ '%.2f'|format(payment.amount_received or 0) }}</strong></div><div class="flex justify-between"><span>TDS Deducted</span><strong>₹ {{ '%.2f'|format(payment.tds_deducted or 0) }}</strong></div><div class="flex justify-between"><span>Bank Charges</span><strong>₹ {{ '%.2f'|format(payment.bank_charges or 0) }}</strong></div></div></div>
|
||||
</div>
|
||||
<div class="mt-6 grid gap-4 md:grid-cols-3 text-sm"><div><span class="text-slate-500">Mode</span><div class="font-semibold">{{ payment.mode }}</div></div><div><span class="text-slate-500">Payment Date</span><div class="font-semibold">{{ payment.payment_date }}</div></div><div><span class="text-slate-500">Reference</span><div class="font-semibold">{{ payment.reference_no or '-' }}</div></div></div>
|
||||
{% if payment.remarks %}<div class="mt-6 rounded-xl border border-slate-200 p-4 text-sm text-slate-600">{{ payment.remarks }}</div>{% endif %}
|
||||
<div class="mt-10 flex justify-end"><div class="text-center"><div class="h-12"></div><div class="border-t border-slate-400 px-8 pt-2 text-sm font-semibold">Authorised Signatory</div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,244 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-brand-600">Phase 7R.1</p>
|
||||
<h1 class="text-2xl font-bold text-slate-900">Firm Billing Settings</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Configure firm GST, invoice numbering, payment details and invoice footer defaults.</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm shadow-sm">
|
||||
<div class="font-semibold text-slate-900">{{ tenant_name }}</div>
|
||||
<div class="text-xs text-slate-500">{% if branch_name %}Branch: {{ branch_name }}{% else %}Firm-wide default{% endif %}</div>
|
||||
<div class="mt-2 text-xs text-slate-500">Next invoice preview</div>
|
||||
<div class="font-mono text-sm font-semibold text-brand-700">{{ preview_invoice_no }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<form method="post" action="/billing/settings" class="space-y-6">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
|
||||
<section class="af-card p-5">
|
||||
<div class="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-slate-900">Scope</h2>
|
||||
<p class="text-sm text-slate-500">Keep branch-specific settings for branch-wise invoice series, or use firm-wide default if you are working across branches.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<label class="rounded-2xl border border-slate-200 p-4 text-sm">
|
||||
<input type="radio" name="branch_scope" value="active" class="mr-2" {% if branch_scope != 'firm' %}checked{% endif %} />
|
||||
Active branch settings
|
||||
<div class="mt-1 text-xs text-slate-500">Recommended for branch-wise invoice numbering.</div>
|
||||
</label>
|
||||
<label class="rounded-2xl border border-slate-200 p-4 text-sm">
|
||||
<input type="radio" name="branch_scope" value="firm" class="mr-2" {% if branch_scope == 'firm' %}checked{% endif %} />
|
||||
Firm-wide default
|
||||
<div class="mt-1 text-xs text-slate-500">Available when cross-branch billing permission is active.</div>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="af-card p-5">
|
||||
<h2 class="text-lg font-semibold text-slate-900">Firm GST & Contact Details</h2>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<label class="text-sm font-medium text-slate-700">Legal / Billing Name
|
||||
<input name="legal_name" value="{{ settings.legal_name or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">GSTIN
|
||||
<input name="gstin" value="{{ settings.gstin or '' }}" maxlength="15" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm uppercase" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">PAN
|
||||
<input name="pan" value="{{ settings.pan or '' }}" maxlength="10" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm uppercase" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">State Code
|
||||
<input name="state_code" value="{{ settings.state_code or '' }}" maxlength="2" placeholder="33" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Contact Email
|
||||
<input name="contact_email" value="{{ settings.contact_email or '' }}" type="email" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Contact Mobile
|
||||
<input name="contact_mobile" value="{{ settings.contact_mobile or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700 md:col-span-2">Website
|
||||
<input name="website_url" value="{{ settings.website_url or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700 md:col-span-2">Billing Address
|
||||
<textarea name="billing_address" rows="3" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ settings.billing_address or '' }}</textarea>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="af-card p-5">
|
||||
<h2 class="text-lg font-semibold text-slate-900">Invoice Numbering & Tax Defaults</h2>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-3">
|
||||
<label class="text-sm font-medium text-slate-700">Invoice Title
|
||||
<input name="invoice_title" value="{{ settings.invoice_title or 'Tax Invoice' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Prefix
|
||||
<input name="invoice_prefix" value="{{ settings.invoice_prefix or 'INV' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm uppercase" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Next Number
|
||||
<input name="next_invoice_no" value="{{ settings.next_invoice_no or 1 }}" type="number" min="1" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Padding
|
||||
<input name="padding" value="{{ settings.padding or 4 }}" type="number" min="1" max="10" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700 md:col-span-2">Number Format
|
||||
<input name="invoice_number_format" value="{{ settings.invoice_number_format or '{prefix}/{fy}/{number}' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 font-mono text-sm" />
|
||||
<span class="mt-1 block text-xs text-slate-500">Tokens: {prefix}, {fy}, {number}, {branch_id}</span>
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Default Due Days
|
||||
<input name="default_due_days" value="{{ settings.default_due_days or 15 }}" type="number" min="0" max="365" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Default GST Rate %
|
||||
<input name="default_gst_rate" value="{{ settings.default_gst_rate or '18.00' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Default Tax Type
|
||||
<select name="default_tax_type" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
{% for tax in tax_types %}<option value="{{ tax }}" {% if settings.default_tax_type == tax %}selected{% endif %}>{{ tax }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Default SAC Code
|
||||
<input name="default_sac_code" value="{{ settings.default_sac_code or '' }}" placeholder="9982" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="af-card p-5">
|
||||
<h2 class="text-lg font-semibold text-slate-900">Bank, UPI & Payment Details</h2>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<label class="text-sm font-medium text-slate-700">Bank Name
|
||||
<input name="bank_name" value="{{ settings.bank_name or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Account Name
|
||||
<input name="bank_account_name" value="{{ settings.bank_account_name or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Account Number
|
||||
<input name="bank_account_number" value="{{ settings.bank_account_number or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">IFSC
|
||||
<input name="bank_ifsc" value="{{ settings.bank_ifsc or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm uppercase" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700 md:col-span-2">UPI ID
|
||||
<input name="upi_id" value="{{ settings.upi_id or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700 md:col-span-2">Additional Bank Details / Payment Instructions
|
||||
<textarea name="bank_details" rows="3" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ settings.bank_details or '' }}</textarea>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<section class="af-card p-5">
|
||||
<h2 class="text-lg font-semibold text-slate-900">PayUMoney / PayU Online Payment Gateway</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Enable this only after entering valid PayU/PayUMoney merchant credentials. Test mode posts to PayU test checkout.</p>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<label class="flex items-center gap-3 rounded-2xl border border-slate-200 p-4 text-sm font-medium text-slate-700 md:col-span-2">
|
||||
<input type="checkbox" name="payumoney_enabled" value="1" {% if settings.payumoney_enabled %}checked{% endif %} />
|
||||
Enable PayUMoney / PayU Pay Now for client portal
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Mode
|
||||
<select name="payumoney_mode" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="TEST" {% if settings.payumoney_mode != 'LIVE' %}selected{% endif %}>TEST / Sandbox</option>
|
||||
<option value="LIVE" {% if settings.payumoney_mode == 'LIVE' %}selected{% endif %}>LIVE / Production</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Merchant ID, optional
|
||||
<input name="payumoney_merchant_id" value="{{ settings.payumoney_merchant_id or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Merchant Key
|
||||
<input name="payumoney_merchant_key" value="{{ settings.payumoney_merchant_key or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" autocomplete="off" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Merchant Salt
|
||||
<input name="payumoney_merchant_salt" value="{{ settings.payumoney_merchant_salt or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" autocomplete="off" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700 md:col-span-2">Product Info Label
|
||||
<input name="payumoney_product_info" value="{{ settings.payumoney_product_info or 'Professional Services Invoice' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-4 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-xs leading-5 text-amber-900">
|
||||
Store separate test and live credentials carefully. Do not enable LIVE until callback testing is completed from an accessible public URL.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<section class="af-card p-5">
|
||||
<h2 class="text-lg font-semibold text-slate-900">Cashfree Online Payment Gateway</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Enable Cashfree only after adding valid Cashfree PG credentials. Sandbox mode uses Cashfree sandbox APIs.</p>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<label class="flex items-center gap-3 rounded-2xl border border-slate-200 p-4 text-sm font-medium text-slate-700 md:col-span-2">
|
||||
<input type="checkbox" name="cashfree_enabled" value="1" {% if settings.cashfree_enabled %}checked{% endif %} />
|
||||
Enable Cashfree Pay Now for client portal
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Mode
|
||||
<select name="cashfree_mode" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="TEST" {% if settings.cashfree_mode != 'LIVE' %}selected{% endif %}>TEST / Sandbox</option>
|
||||
<option value="LIVE" {% if settings.cashfree_mode == 'LIVE' %}selected{% endif %}>LIVE / Production</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">API Version
|
||||
<input name="cashfree_api_version" value="{{ settings.cashfree_api_version or '2023-08-01' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Client ID / App ID
|
||||
<input name="cashfree_client_id" value="{{ settings.cashfree_client_id or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" autocomplete="off" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Client Secret
|
||||
<input name="cashfree_client_secret" value="{{ settings.cashfree_client_secret or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" autocomplete="off" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700 md:col-span-2">Order Note
|
||||
<input name="cashfree_order_note" value="{{ settings.cashfree_order_note or 'Professional Services Invoice' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-4 rounded-2xl border border-sky-200 bg-sky-50 p-4 text-xs leading-5 text-sky-900">
|
||||
Cashfree checkout creates an order from the server and uses payment_session_id for hosted checkout. Webhook URL: <span class="font-mono">/client/billing/cashfree/webhook</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="af-card p-5">
|
||||
<h2 class="text-lg font-semibold text-slate-900">Invoice Notes, Terms & Signatory</h2>
|
||||
<div class="mt-4 grid gap-4">
|
||||
<label class="text-sm font-medium text-slate-700">Default Terms
|
||||
<textarea name="terms" rows="3" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ settings.terms or '' }}</textarea>
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Declaration
|
||||
<textarea name="declaration" rows="3" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ settings.declaration or '' }}</textarea>
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Invoice Footer Note
|
||||
<textarea name="footer_note" rows="2" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ settings.footer_note or '' }}</textarea>
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Authorised Signatory Name
|
||||
<input name="authorised_signatory_name" value="{{ settings.authorised_signatory_name or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<a href="/billing" class="af-btn af-btn-secondary">Back to Invoices</a>
|
||||
{% if can_edit_settings %}
|
||||
<button type="submit" class="af-btn af-btn-primary">Save Billing Settings</button>
|
||||
{% else %}
|
||||
<span class="text-sm text-slate-500">View-only access</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<aside class="space-y-4">
|
||||
<div class="af-card p-5">
|
||||
<h3 class="font-semibold text-slate-900">Why this matters</h3>
|
||||
<ul class="mt-3 space-y-2 text-sm text-slate-600">
|
||||
<li>• GST invoice format will use these details in Phase 7R.2.</li>
|
||||
<li>• Payment and receipt tracking will use bank/UPI details in Phase 7R.4.</li>
|
||||
<li>• Client portal Pay Now uses UPI, PayUMoney and Cashfree settings from Phase 7R.5 / 7R.6 / 7R.6A.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="af-card p-5">
|
||||
<h3 class="font-semibold text-slate-900">Recommended invoice format</h3>
|
||||
<p class="mt-2 rounded-xl bg-slate-50 px-3 py-2 font-mono text-sm text-slate-700">{prefix}/{fy}/{number}</p>
|
||||
<p class="mt-2 text-xs text-slate-500">Example: INV/2026-27/0001</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,880 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import APIRouter, File, Form, Request, UploadFile
|
||||
from fastapi.responses import RedirectResponse, StreamingResponse
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db.common import CommonSessionLocal
|
||||
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
|
||||
from app.core.security.session_auth import get_current_user
|
||||
from app.core.templating import templates
|
||||
from app.modules.billing.models import BillingFeeGroup, BillingSettings
|
||||
from app.modules.billing.services import (
|
||||
BILLING_MODES,
|
||||
FREQUENCIES,
|
||||
PAYMENT_MODES,
|
||||
TAX_TYPES,
|
||||
build_fee_structure_template,
|
||||
build_invoice_print_context,
|
||||
build_billing_report_summary,
|
||||
billing_financial_year,
|
||||
create_invoice,
|
||||
fee_group_already_billed,
|
||||
generate_draft_invoices_from_fee_groups,
|
||||
get_invoice,
|
||||
import_fee_structure_excel,
|
||||
issue_invoice,
|
||||
list_clients_for_billing,
|
||||
list_fee_groups,
|
||||
list_fee_groups_for_generation,
|
||||
list_invoices,
|
||||
list_payments,
|
||||
list_services_for_billing,
|
||||
parse_date,
|
||||
preview_invoice_number,
|
||||
record_invoice_payment,
|
||||
get_payment,
|
||||
)
|
||||
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
||||
from app.modules.core.rbac.permission_guard import require_permission
|
||||
from app.modules.core.tenancy.models import Branch, Tenant
|
||||
from app.modules.core.tenancy.year_control import redirect_if_financial_year_locked, is_row_financial_year_locked
|
||||
|
||||
router = APIRouter(prefix="/billing", tags=["billing-ui"])
|
||||
|
||||
|
||||
def _base_ctx(request: Request, user, db, **ctx):
|
||||
base = {
|
||||
"request": request,
|
||||
"current_user": user,
|
||||
"current_user_roles": get_user_roles(db, user.id),
|
||||
"current_user_permissions": get_user_permissions(db, user.id),
|
||||
"csrf_token": get_or_create_csrf_token(request),
|
||||
"tax_types": TAX_TYPES,
|
||||
"billing_modes": BILLING_MODES,
|
||||
"frequencies": FREQUENCIES,
|
||||
"payment_modes": PAYMENT_MODES,
|
||||
}
|
||||
base.update(ctx)
|
||||
return base
|
||||
|
||||
|
||||
def _render(request: Request, template: str, db, user, **ctx):
|
||||
return templates.TemplateResponse(template, _base_ctx(request, user, db, **ctx))
|
||||
|
||||
|
||||
def _redirect_denied():
|
||||
return RedirectResponse(url="/system-settings", status_code=303)
|
||||
|
||||
|
||||
def _has_perm(db, user, code: str) -> bool:
|
||||
try:
|
||||
require_permission(db, user, code)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _role_names(db, user) -> set[str]:
|
||||
return {str(r or "").strip() for r in get_user_roles(db, user.id)}
|
||||
|
||||
|
||||
def _can_manage_billing_settings(db, user) -> bool:
|
||||
roles = _role_names(db, user)
|
||||
return bool({"System Admin", "Firm Admin", "Partner"}.intersection(roles)) or _has_perm(db, user, "billing.edit")
|
||||
|
||||
|
||||
def _get_or_create_billing_settings(db, *, tenant_id: int, branch_id: int | None) -> BillingSettings:
|
||||
row = db.execute(
|
||||
select(BillingSettings).where(BillingSettings.tenant_id == tenant_id, BillingSettings.branch_id == branch_id)
|
||||
).scalar_one_or_none()
|
||||
if row:
|
||||
return row
|
||||
row = BillingSettings(tenant_id=tenant_id, branch_id=branch_id)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
return row
|
||||
|
||||
|
||||
def _decimal_form(value: str | None, default: str = "0.00") -> Decimal:
|
||||
try:
|
||||
return Decimal(str(value or default)).quantize(Decimal("0.01"))
|
||||
except Exception:
|
||||
return Decimal(default).quantize(Decimal("0.01"))
|
||||
|
||||
|
||||
def _int_form(value: str | int | None, default: int, minimum: int | None = None, maximum: int | None = None) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except Exception:
|
||||
parsed = default
|
||||
if minimum is not None:
|
||||
parsed = max(minimum, parsed)
|
||||
if maximum is not None:
|
||||
parsed = min(maximum, parsed)
|
||||
return parsed
|
||||
|
||||
|
||||
def _billing_context_names(db, *, tenant_id: int, branch_id: int | None) -> tuple[str, str | None]:
|
||||
tenant = db.get(Tenant, tenant_id)
|
||||
branch = db.get(Branch, branch_id) if branch_id else None
|
||||
return (getattr(tenant, "name", None) or f"Audit Firm {tenant_id}", getattr(branch, "name", None) if branch else None)
|
||||
|
||||
|
||||
def _active_tenant_id(request: Request, user) -> int:
|
||||
return int(request.session.get("active_tenant_id") or request.session.get("selected_tenant_id") or request.session.get("tenant_id") or user.tenant_id)
|
||||
|
||||
|
||||
def _active_branch_id(request: Request, user, db) -> int | None:
|
||||
value = request.session.get("active_branch_id")
|
||||
if value in (None, "", 0, "0"):
|
||||
if _has_perm(db, user, "billing.cross_branch"):
|
||||
return None
|
||||
return int(getattr(user, "branch_id", 0) or 0) or None
|
||||
return int(value)
|
||||
|
||||
|
||||
def _active_financial_year(request: Request) -> str | None:
|
||||
value = request.session.get("active_financial_year") or getattr(request.state, "year_code", None)
|
||||
value = (value or "").strip()
|
||||
if not value or value.upper() == "ALL":
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _period_start_for_fy(financial_year: str | None) -> date:
|
||||
try:
|
||||
start_year = int(str(financial_year or "").split("-")[0])
|
||||
return date(start_year, 4, 1)
|
||||
except Exception:
|
||||
today = date.today()
|
||||
return date(today.year if today.month >= 4 else today.year - 1, 4, 1)
|
||||
|
||||
|
||||
def _period_end_for_fy(financial_year: str | None) -> date:
|
||||
start = _period_start_for_fy(financial_year)
|
||||
return date(start.year + 1, 3, 31)
|
||||
|
||||
|
||||
def _locked_partner_id(db, user) -> int | None:
|
||||
return int(user.id) if _has_perm(db, user, "billing.view_own") else None
|
||||
|
||||
|
||||
def _require_billing_user(request: Request, db, permission_code: str):
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return None, RedirectResponse(url="/login", status_code=303)
|
||||
try:
|
||||
require_permission(db, user, permission_code)
|
||||
except Exception:
|
||||
return user, _redirect_denied()
|
||||
return user, None
|
||||
|
||||
|
||||
@router.get("")
|
||||
def invoice_list(request: Request, q: str = ""):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_billing_user(request, db, "billing.view")
|
||||
if response:
|
||||
return response
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
partner_id = _locked_partner_id(db, user)
|
||||
financial_year = _active_financial_year(request)
|
||||
rows = list_invoices(db, tenant_id=tenant_id, branch_id=branch_id, partner_id=partner_id, financial_year=financial_year, q=q)
|
||||
report_summary = build_billing_report_summary(db, tenant_id=tenant_id, branch_id=branch_id, partner_id=partner_id, financial_year=financial_year)
|
||||
return _render(
|
||||
request,
|
||||
"modules/billing/templates/billing/list.html",
|
||||
db,
|
||||
user,
|
||||
title="Billing - Invoices",
|
||||
q=q,
|
||||
active_financial_year=financial_year,
|
||||
rows=rows,
|
||||
report_summary=report_summary,
|
||||
can_create=_has_perm(db, user, "billing.create"),
|
||||
can_import_fee_structure=_has_perm(db, user, "billing_fee_structure.import"),
|
||||
can_generate=_has_perm(db, user, "billing_invoice.generate"),
|
||||
can_view_fee_structure=_has_perm(db, user, "billing_fee_structure.view"),
|
||||
can_record_payment=_has_perm(db, user, "billing_payment.record") or _has_perm(db, user, "billing.create"),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/payments")
|
||||
def payment_list(request: Request, q: str = ""):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_billing_user(request, db, "billing.view")
|
||||
if response:
|
||||
return response
|
||||
financial_year = _active_financial_year(request)
|
||||
rows = list_payments(
|
||||
db,
|
||||
tenant_id=_active_tenant_id(request, user),
|
||||
branch_id=_active_branch_id(request, user, db),
|
||||
partner_id=_locked_partner_id(db, user),
|
||||
financial_year=financial_year,
|
||||
q=q,
|
||||
)
|
||||
return _render(request, "modules/billing/templates/billing/payments/list.html", db, user, title="Payments & Receipts", rows=rows, q=q, active_financial_year=financial_year)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/payments/{payment_id}/receipt")
|
||||
def payment_receipt_print(request: Request, payment_id: int):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_billing_user(request, db, "billing.view")
|
||||
if response:
|
||||
return response
|
||||
payment = get_payment(db, payment_id=payment_id, tenant_id=_active_tenant_id(request, user), partner_id=_locked_partner_id(db, user), financial_year=_active_financial_year(request))
|
||||
if not payment:
|
||||
return _redirect_denied()
|
||||
invoice_ctx = build_invoice_print_context(db, payment.invoice)
|
||||
return _render(request, "modules/billing/templates/billing/payments/receipt_print.html", db, user, title=f"Receipt {payment.receipt_no}", payment=payment, invoice=payment.invoice, invoice_ctx=invoice_ctx)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
def billing_settings_page(request: Request, branch_scope: str = "active"):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_billing_user(request, db, "billing.view")
|
||||
if response:
|
||||
return response
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
active_branch_id = _active_branch_id(request, user, db)
|
||||
branch_id = None if branch_scope == "firm" and _has_perm(db, user, "billing.cross_branch") else active_branch_id
|
||||
settings = _get_or_create_billing_settings(db, tenant_id=tenant_id, branch_id=branch_id)
|
||||
tenant_name, branch_name = _billing_context_names(db, tenant_id=tenant_id, branch_id=branch_id)
|
||||
return _render(
|
||||
request,
|
||||
"modules/billing/templates/billing/settings.html",
|
||||
db,
|
||||
user,
|
||||
title="Billing Settings",
|
||||
settings=settings,
|
||||
preview_invoice_no=preview_invoice_number(settings, branch_id=branch_id, financial_year=_active_financial_year(request)),
|
||||
tenant_name=tenant_name,
|
||||
branch_name=branch_name,
|
||||
branch_scope="firm" if branch_id is None else "active",
|
||||
can_edit_settings=_can_manage_billing_settings(db, user),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/settings")
|
||||
def billing_settings_submit(
|
||||
request: Request,
|
||||
branch_scope: str = Form("active"),
|
||||
legal_name: str | None = Form(None),
|
||||
gstin: str | None = Form(None),
|
||||
pan: str | None = Form(None),
|
||||
state_code: str | None = Form(None),
|
||||
billing_address: str | None = Form(None),
|
||||
contact_email: str | None = Form(None),
|
||||
contact_mobile: str | None = Form(None),
|
||||
website_url: str | None = Form(None),
|
||||
invoice_title: str | None = Form(None),
|
||||
invoice_prefix: str = Form("INV"),
|
||||
invoice_number_format: str | None = Form("{prefix}/{fy}/{number}"),
|
||||
next_invoice_no: int = Form(1),
|
||||
padding: int = Form(4),
|
||||
default_due_days: int = Form(15),
|
||||
default_gst_rate: str = Form("18.00"),
|
||||
default_tax_type: str = Form("CGST_SGST"),
|
||||
default_sac_code: str | None = Form(None),
|
||||
bank_name: str | None = Form(None),
|
||||
bank_account_name: str | None = Form(None),
|
||||
bank_account_number: str | None = Form(None),
|
||||
bank_ifsc: str | None = Form(None),
|
||||
upi_id: str | None = Form(None),
|
||||
bank_details: str | None = Form(None),
|
||||
terms: str | None = Form(None),
|
||||
footer_note: str | None = Form(None),
|
||||
declaration: str | None = Form(None),
|
||||
authorised_signatory_name: str | None = Form(None),
|
||||
payumoney_enabled: str | None = Form(None),
|
||||
payumoney_mode: str = Form("TEST"),
|
||||
payumoney_merchant_key: str | None = Form(None),
|
||||
payumoney_merchant_salt: str | None = Form(None),
|
||||
payumoney_merchant_id: str | None = Form(None),
|
||||
payumoney_product_info: str | None = Form(None),
|
||||
cashfree_enabled: str | None = Form(None),
|
||||
cashfree_mode: str = Form("TEST"),
|
||||
cashfree_client_id: str | None = Form(None),
|
||||
cashfree_client_secret: str | None = Form(None),
|
||||
cashfree_api_version: str | None = Form("2023-08-01"),
|
||||
cashfree_order_note: str | None = Form(None),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_billing_user(request, db, "billing.view")
|
||||
if response:
|
||||
return response
|
||||
if not _can_manage_billing_settings(db, user):
|
||||
return _redirect_denied()
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
active_branch_id = _active_branch_id(request, user, db)
|
||||
branch_id = None if branch_scope == "firm" and _has_perm(db, user, "billing.cross_branch") else active_branch_id
|
||||
settings = _get_or_create_billing_settings(db, tenant_id=tenant_id, branch_id=branch_id)
|
||||
|
||||
settings.legal_name = (legal_name or "").strip() or None
|
||||
settings.gstin = (gstin or "").strip().upper() or None
|
||||
settings.pan = (pan or "").strip().upper() or None
|
||||
settings.state_code = (state_code or "").strip()[:2] or None
|
||||
settings.billing_address = (billing_address or "").strip() or None
|
||||
settings.contact_email = (contact_email or "").strip() or None
|
||||
settings.contact_mobile = (contact_mobile or "").strip() or None
|
||||
settings.website_url = (website_url or "").strip() or None
|
||||
|
||||
settings.invoice_title = (invoice_title or "").strip() or None
|
||||
settings.invoice_prefix = (invoice_prefix or "INV").strip().upper()[:40] or "INV"
|
||||
settings.invoice_number_format = (invoice_number_format or "{prefix}/{fy}/{number}").strip()[:120] or "{prefix}/{fy}/{number}"
|
||||
settings.next_invoice_no = _int_form(next_invoice_no, 1, minimum=1)
|
||||
settings.padding = _int_form(padding, 4, minimum=1, maximum=10)
|
||||
settings.default_due_days = _int_form(default_due_days, 15, minimum=0, maximum=365)
|
||||
settings.default_gst_rate = _decimal_form(default_gst_rate, "18.00")
|
||||
settings.default_tax_type = default_tax_type if default_tax_type in TAX_TYPES else "CGST_SGST"
|
||||
settings.default_sac_code = (default_sac_code or "").strip()[:20] or None
|
||||
|
||||
settings.bank_name = (bank_name or "").strip() or None
|
||||
settings.bank_account_name = (bank_account_name or "").strip() or None
|
||||
settings.bank_account_number = (bank_account_number or "").strip() or None
|
||||
settings.bank_ifsc = (bank_ifsc or "").strip().upper() or None
|
||||
settings.upi_id = (upi_id or "").strip() or None
|
||||
settings.bank_details = (bank_details or "").strip() or None
|
||||
settings.terms = (terms or "").strip() or None
|
||||
settings.footer_note = (footer_note or "").strip() or None
|
||||
settings.declaration = (declaration or "").strip() or None
|
||||
settings.authorised_signatory_name = (authorised_signatory_name or "").strip() or None
|
||||
|
||||
settings.payumoney_enabled = bool(payumoney_enabled)
|
||||
settings.payumoney_mode = (payumoney_mode or "TEST").strip().upper() if (payumoney_mode or "TEST").strip().upper() in {"TEST", "LIVE"} else "TEST"
|
||||
settings.payumoney_merchant_key = (payumoney_merchant_key or "").strip() or None
|
||||
settings.payumoney_merchant_salt = (payumoney_merchant_salt or "").strip() or None
|
||||
settings.payumoney_merchant_id = (payumoney_merchant_id or "").strip() or None
|
||||
settings.payumoney_product_info = (payumoney_product_info or "").strip() or None
|
||||
db.commit()
|
||||
suffix = "?branch_scope=firm" if branch_id is None else ""
|
||||
return RedirectResponse(url=f"/billing/settings{suffix}", status_code=303)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/new")
|
||||
def invoice_create_page(request: Request):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_billing_user(request, db, "billing.create")
|
||||
if response:
|
||||
return response
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
partner_id = _locked_partner_id(db, user)
|
||||
clients = list_clients_for_billing(db, tenant_id=tenant_id, branch_id=branch_id, partner_id=partner_id)
|
||||
services = list_services_for_billing(db)
|
||||
settings = _get_or_create_billing_settings(db, tenant_id=tenant_id, branch_id=branch_id)
|
||||
return _render(
|
||||
request,
|
||||
"modules/billing/templates/billing/create.html",
|
||||
db,
|
||||
user,
|
||||
title="Create Invoice",
|
||||
active_financial_year=financial_year,
|
||||
default_billing_period_from=_period_start_for_fy(_active_financial_year(request)).isoformat(),
|
||||
default_billing_period_to=_period_end_for_fy(_active_financial_year(request)).isoformat(),
|
||||
clients=clients,
|
||||
services=services,
|
||||
settings=settings,
|
||||
today=date.today().isoformat(),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/new")
|
||||
def invoice_create_submit(
|
||||
request: Request,
|
||||
client_id: int = Form(...),
|
||||
invoice_date: str = Form(...),
|
||||
due_date: str | None = Form(None),
|
||||
billing_period_from: str | None = Form(None),
|
||||
billing_period_to: str | None = Form(None),
|
||||
tax_type: str = Form("CGST_SGST"),
|
||||
place_of_supply: str | None = Form(None),
|
||||
client_state_code: str | None = Form(None),
|
||||
reverse_charge: str | None = Form(None),
|
||||
notes: str | None = Form(None),
|
||||
terms: str | None = Form(None),
|
||||
line_description: list[str] = Form(default=[]),
|
||||
line_service_id: list[str] = Form(default=[]),
|
||||
line_quantity: list[str] = Form(default=[]),
|
||||
line_rate: list[str] = Form(default=[]),
|
||||
line_discount: list[str] = Form(default=[]),
|
||||
line_gst_rate: list[str] = Form(default=[]),
|
||||
line_sac_code: list[str] = Form(default=[]),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_billing_user(request, db, "billing.create")
|
||||
if response:
|
||||
return response
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
partner_id = _locked_partner_id(db, user)
|
||||
financial_year = _active_financial_year(request)
|
||||
locked_response = redirect_if_financial_year_locked(db, tenant_id=tenant_id, year_code=financial_year, redirect_url=f"/billing?financial_year={financial_year or ''}")
|
||||
if locked_response:
|
||||
return locked_response
|
||||
|
||||
allowed_clients = {c.id for c in list_clients_for_billing(db, tenant_id=tenant_id, branch_id=branch_id, partner_id=partner_id)}
|
||||
if client_id not in allowed_clients:
|
||||
return _redirect_denied()
|
||||
|
||||
raw_lines = []
|
||||
max_len = max(len(line_description), len(line_service_id), len(line_quantity), len(line_rate), len(line_discount), len(line_gst_rate), len(line_sac_code), 0)
|
||||
for idx in range(max_len):
|
||||
raw_lines.append({
|
||||
"description": line_description[idx] if idx < len(line_description) else "",
|
||||
"service_id": line_service_id[idx] if idx < len(line_service_id) else "",
|
||||
"quantity": line_quantity[idx] if idx < len(line_quantity) else "1",
|
||||
"rate": line_rate[idx] if idx < len(line_rate) else "0",
|
||||
"discount_amount": line_discount[idx] if idx < len(line_discount) else "0",
|
||||
"gst_rate": line_gst_rate[idx] if idx < len(line_gst_rate) else "18",
|
||||
"sac_code": line_sac_code[idx] if idx < len(line_sac_code) else "",
|
||||
})
|
||||
|
||||
invoice = create_invoice(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
client_id=client_id,
|
||||
invoice_date=parse_date(invoice_date) or date.today(),
|
||||
due_date=parse_date(due_date),
|
||||
billing_period_from=parse_date(billing_period_from),
|
||||
billing_period_to=parse_date(billing_period_to),
|
||||
tax_type=tax_type,
|
||||
notes=notes,
|
||||
terms=terms,
|
||||
place_of_supply=place_of_supply,
|
||||
client_state_code=client_state_code,
|
||||
reverse_charge=(reverse_charge == "yes"),
|
||||
created_by_user_id=user.id,
|
||||
raw_lines=raw_lines,
|
||||
financial_year=_active_financial_year(request),
|
||||
)
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/billing/{invoice.id}", status_code=303)
|
||||
except ValueError:
|
||||
db.rollback()
|
||||
return RedirectResponse(url="/billing/new", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/fee-structures/list")
|
||||
def fee_structure_list(request: Request, q: str = ""):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_billing_user(request, db, "billing_fee_structure.view")
|
||||
if response:
|
||||
return response
|
||||
rows = list_fee_groups(
|
||||
db,
|
||||
tenant_id=_active_tenant_id(request, user),
|
||||
branch_id=_active_branch_id(request, user, db),
|
||||
partner_id=_locked_partner_id(db, user),
|
||||
q=q,
|
||||
)
|
||||
return _render(
|
||||
request,
|
||||
"modules/billing/templates/billing/fee_structures/list.html",
|
||||
db,
|
||||
user,
|
||||
title="Fee Structure",
|
||||
q=q,
|
||||
active_financial_year=financial_year,
|
||||
rows=rows,
|
||||
can_import=_has_perm(db, user, "billing_fee_structure.import"),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/fee-structures/import")
|
||||
def fee_structure_import_page(request: Request):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_billing_user(request, db, "billing_fee_structure.import")
|
||||
if response:
|
||||
return response
|
||||
return _render(request, "modules/billing/templates/billing/fee_structures/import.html", db, user, title="Import Fee Structure", result=None)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/fee-structures/template")
|
||||
def fee_structure_template_download(request: Request):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_billing_user(request, db, "billing_fee_structure.import")
|
||||
if response:
|
||||
return response
|
||||
data = build_fee_structure_template()
|
||||
return StreamingResponse(
|
||||
iter([data]),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": "attachment; filename=billing_fee_structure_template.xlsx"},
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/fee-structures/import")
|
||||
async def fee_structure_import_submit(request: Request, import_file: UploadFile = File(...), csrf_token: str = Form(...)):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_billing_user(request, db, "billing_fee_structure.import")
|
||||
if response:
|
||||
return response
|
||||
filename = (import_file.filename or "").lower()
|
||||
if not filename.endswith((".xlsx", ".xlsm")):
|
||||
result = {"success": False, "created": 0, "updated": 0, "errors": ["Please upload an .xlsx file."]}
|
||||
else:
|
||||
content = await import_file.read()
|
||||
if len(content) > 5 * 1024 * 1024:
|
||||
result = {"success": False, "created": 0, "updated": 0, "errors": ["File size must be 5 MB or less."]}
|
||||
else:
|
||||
result = import_fee_structure_excel(
|
||||
db,
|
||||
tenant_id=_active_tenant_id(request, user),
|
||||
branch_id=_active_branch_id(request, user, db),
|
||||
created_by_user_id=user.id,
|
||||
file_bytes=content,
|
||||
)
|
||||
return _render(request, "modules/billing/templates/billing/fee_structures/import.html", db, user, title="Import Fee Structure", result=result)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/generate")
|
||||
def generate_invoices_page(
|
||||
request: Request,
|
||||
frequency: str = "Monthly",
|
||||
billing_period_from: str | None = None,
|
||||
billing_period_to: str | None = None,
|
||||
auto_generate_only: str = "yes",
|
||||
q: str = "",
|
||||
):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_billing_user(request, db, "billing_invoice.generate")
|
||||
if response:
|
||||
return response
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
partner_id = _locked_partner_id(db, user)
|
||||
financial_year = _active_financial_year(request)
|
||||
locked_response = redirect_if_financial_year_locked(db, tenant_id=tenant_id, year_code=financial_year, redirect_url=f"/billing?financial_year={financial_year or ''}")
|
||||
if locked_response:
|
||||
return locked_response
|
||||
period_from = parse_date(billing_period_from) or _period_start_for_fy(financial_year)
|
||||
period_to = parse_date(billing_period_to) or _period_end_for_fy(financial_year)
|
||||
rows = list_fee_groups_for_generation(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
partner_id=partner_id,
|
||||
frequency=frequency or None,
|
||||
auto_generate_only=(auto_generate_only != "no"),
|
||||
q=q,
|
||||
)
|
||||
duplicate_map = {
|
||||
row.id: fee_group_already_billed(db, tenant_id=tenant_id, fee_group_id=row.id, period_from=period_from, period_to=period_to)
|
||||
for row in rows
|
||||
}
|
||||
return _render(
|
||||
request,
|
||||
"modules/billing/templates/billing/generate.html",
|
||||
db,
|
||||
user,
|
||||
title="Generate Draft Invoices",
|
||||
rows=rows,
|
||||
duplicate_map=duplicate_map,
|
||||
frequencies=FREQUENCIES,
|
||||
frequency=frequency,
|
||||
billing_period_from=period_from.isoformat(),
|
||||
billing_period_to=period_to.isoformat(),
|
||||
auto_generate_only=auto_generate_only,
|
||||
q=q,
|
||||
active_financial_year=financial_year,
|
||||
result=None,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/generate")
|
||||
def generate_invoices_submit(
|
||||
request: Request,
|
||||
frequency: str = Form("Monthly"),
|
||||
billing_period_from: str = Form(...),
|
||||
billing_period_to: str = Form(...),
|
||||
auto_generate_only: str = Form("yes"),
|
||||
q: str = Form(""),
|
||||
fee_group_ids: list[int] = Form(default=[]),
|
||||
skip_duplicates: str = Form("yes"),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_billing_user(request, db, "billing_invoice.generate")
|
||||
if response:
|
||||
return response
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
partner_id = _locked_partner_id(db, user)
|
||||
financial_year = _active_financial_year(request)
|
||||
locked_response = redirect_if_financial_year_locked(db, tenant_id=tenant_id, year_code=financial_year, redirect_url=f"/billing/generate?year_locked=1")
|
||||
if locked_response:
|
||||
return locked_response
|
||||
period_from = parse_date(billing_period_from)
|
||||
period_to = parse_date(billing_period_to)
|
||||
if financial_year and period_from and billing_financial_year(billing_period_from=period_from) != financial_year:
|
||||
result = {"created": [], "skipped": [], "errors": [f"Billing period must fall within active FY {financial_year}."], "batch": None}
|
||||
elif period_from is None or period_to is None:
|
||||
result = {"created": [], "skipped": [], "errors": ["Billing period From and To are required."], "batch": None}
|
||||
else:
|
||||
result = generate_draft_invoices_from_fee_groups(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
partner_id=partner_id,
|
||||
generated_by_user_id=user.id,
|
||||
billing_period_from=period_from,
|
||||
billing_period_to=period_to,
|
||||
frequency=frequency or None,
|
||||
fee_group_ids=fee_group_ids,
|
||||
skip_duplicates=(skip_duplicates != "no"),
|
||||
)
|
||||
db.commit()
|
||||
rows = list_fee_groups_for_generation(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
partner_id=partner_id,
|
||||
frequency=frequency or None,
|
||||
auto_generate_only=(auto_generate_only != "no"),
|
||||
q=q,
|
||||
)
|
||||
duplicate_map = {
|
||||
row.id: fee_group_already_billed(db, tenant_id=tenant_id, fee_group_id=row.id, period_from=period_from or date.today(), period_to=period_to or date.today())
|
||||
for row in rows
|
||||
}
|
||||
return _render(
|
||||
request,
|
||||
"modules/billing/templates/billing/generate.html",
|
||||
db,
|
||||
user,
|
||||
title="Generate Draft Invoices",
|
||||
rows=rows,
|
||||
duplicate_map=duplicate_map,
|
||||
frequencies=FREQUENCIES,
|
||||
frequency=frequency,
|
||||
billing_period_from=(period_from or date.today()).isoformat(),
|
||||
billing_period_to=(period_to or date.today()).isoformat(),
|
||||
auto_generate_only=auto_generate_only,
|
||||
q=q,
|
||||
active_financial_year=financial_year,
|
||||
result=result,
|
||||
)
|
||||
except ValueError as exc:
|
||||
db.rollback()
|
||||
rows = []
|
||||
result = {"created": [], "skipped": [], "errors": [str(exc)], "batch": None}
|
||||
return _render(
|
||||
request,
|
||||
"modules/billing/templates/billing/generate.html",
|
||||
db,
|
||||
user,
|
||||
title="Generate Draft Invoices",
|
||||
rows=rows,
|
||||
duplicate_map={},
|
||||
frequencies=FREQUENCIES,
|
||||
frequency=frequency,
|
||||
billing_period_from=billing_period_from,
|
||||
billing_period_to=billing_period_to,
|
||||
auto_generate_only=auto_generate_only,
|
||||
q=q,
|
||||
active_financial_year=_active_financial_year(request),
|
||||
result=result,
|
||||
)
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
result = {"created": [], "skipped": [], "errors": [f"Generation failed: {exc}"], "batch": None}
|
||||
return _render(
|
||||
request,
|
||||
"modules/billing/templates/billing/generate.html",
|
||||
db,
|
||||
user,
|
||||
title="Generate Draft Invoices",
|
||||
rows=[],
|
||||
duplicate_map={},
|
||||
frequencies=FREQUENCIES,
|
||||
frequency=frequency,
|
||||
billing_period_from=billing_period_from,
|
||||
billing_period_to=billing_period_to,
|
||||
auto_generate_only=auto_generate_only,
|
||||
q=q,
|
||||
active_financial_year=_active_financial_year(request),
|
||||
result=result,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/{invoice_id}/payments/new")
|
||||
def invoice_payment_page(request: Request, invoice_id: int):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_billing_user(request, db, "billing.view")
|
||||
if response:
|
||||
return response
|
||||
invoice = get_invoice(db, invoice_id=invoice_id, tenant_id=_active_tenant_id(request, user), partner_id=_locked_partner_id(db, user), financial_year=_active_financial_year(request))
|
||||
if not invoice:
|
||||
return _redirect_denied()
|
||||
if invoice.status in {"DRAFT", "CANCELLED"}:
|
||||
return RedirectResponse(url=f"/billing/{invoice.id}", status_code=303)
|
||||
can_record = _has_perm(db, user, "billing_payment.record") or _has_perm(db, user, "billing.create")
|
||||
if not can_record:
|
||||
return _redirect_denied()
|
||||
return _render(request, "modules/billing/templates/billing/payments/new.html", db, user, title=f"Record Payment - {invoice.invoice_no}", invoice=invoice, today=date.today().isoformat())
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/{invoice_id}/payments/new")
|
||||
def invoice_payment_submit(
|
||||
request: Request,
|
||||
invoice_id: int,
|
||||
payment_date: str = Form(...),
|
||||
amount_received: str = Form("0.00"),
|
||||
tds_deducted: str = Form("0.00"),
|
||||
bank_charges: str = Form("0.00"),
|
||||
mode: str = Form("BANK"),
|
||||
reference_no: str | None = Form(None),
|
||||
remarks: str | None = Form(None),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_billing_user(request, db, "billing.view")
|
||||
if response:
|
||||
return response
|
||||
can_record = _has_perm(db, user, "billing_payment.record") or _has_perm(db, user, "billing.create")
|
||||
if not can_record:
|
||||
return _redirect_denied()
|
||||
invoice = get_invoice(db, invoice_id=invoice_id, tenant_id=_active_tenant_id(request, user), partner_id=_locked_partner_id(db, user), financial_year=_active_financial_year(request))
|
||||
if not invoice:
|
||||
return _redirect_denied()
|
||||
if is_row_financial_year_locked(db, invoice):
|
||||
return RedirectResponse(url=f"/billing/{invoice.id}?year_locked=1", status_code=303)
|
||||
payment = record_invoice_payment(
|
||||
db,
|
||||
invoice=invoice,
|
||||
payment_date=parse_date(payment_date) or date.today(),
|
||||
amount_received=_decimal_form(amount_received, "0.00"),
|
||||
tds_deducted=_decimal_form(tds_deducted, "0.00"),
|
||||
bank_charges=_decimal_form(bank_charges, "0.00"),
|
||||
mode=mode,
|
||||
reference_no=reference_no,
|
||||
remarks=remarks,
|
||||
created_by_user_id=user.id,
|
||||
)
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/billing/payments/{payment.id}/receipt", status_code=303)
|
||||
except ValueError:
|
||||
db.rollback()
|
||||
return RedirectResponse(url=f"/billing/{invoice_id}", status_code=303)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/{invoice_id}/print")
|
||||
def invoice_print(request: Request, invoice_id: int):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_billing_user(request, db, "billing.view")
|
||||
if response:
|
||||
return response
|
||||
invoice = get_invoice(db, invoice_id=invoice_id, tenant_id=_active_tenant_id(request, user), partner_id=_locked_partner_id(db, user), financial_year=_active_financial_year(request))
|
||||
if not invoice:
|
||||
return _redirect_denied()
|
||||
invoice_ctx = build_invoice_print_context(db, invoice)
|
||||
return _render(request, "modules/billing/templates/billing/invoice_print.html", db, user, title=f"Print Invoice {invoice.invoice_no}", invoice=invoice, invoice_ctx=invoice_ctx)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/{invoice_id}/issue")
|
||||
def invoice_issue_submit(request: Request, invoice_id: int, csrf_token: str = Form(...)):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_billing_user(request, db, "billing.create")
|
||||
if response:
|
||||
return response
|
||||
invoice = get_invoice(db, invoice_id=invoice_id, tenant_id=_active_tenant_id(request, user), partner_id=_locked_partner_id(db, user), financial_year=_active_financial_year(request))
|
||||
if not invoice:
|
||||
return _redirect_denied()
|
||||
if is_row_financial_year_locked(db, invoice):
|
||||
return RedirectResponse(url=f"/billing/{invoice.id}?year_locked=1", status_code=303)
|
||||
issue_invoice(db, invoice, user_id=user.id)
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/billing/{invoice.id}", status_code=303)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/{invoice_id}")
|
||||
def invoice_detail(request: Request, invoice_id: int):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_billing_user(request, db, "billing.view")
|
||||
if response:
|
||||
return response
|
||||
invoice = get_invoice(db, invoice_id=invoice_id, tenant_id=_active_tenant_id(request, user), partner_id=_locked_partner_id(db, user), financial_year=_active_financial_year(request))
|
||||
if not invoice:
|
||||
return _redirect_denied()
|
||||
invoice_ctx = build_invoice_print_context(db, invoice)
|
||||
return _render(request, "modules/billing/templates/billing/detail.html", db, user, title=f"Invoice {invoice.invoice_no}", invoice=invoice, invoice_ctx=invoice_ctx, can_record_payment=_has_perm(db, user, "billing_payment.record") or _has_perm(db, user, "billing.create"))
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,4 @@
|
||||
from .api import router as api_router
|
||||
from .ui import router as ui_router
|
||||
|
||||
__all__ = ["api_router", "ui_router"]
|
||||
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClientAccessScope:
|
||||
tenant_id: int
|
||||
branch_id: int | None
|
||||
allow_cross_branch: bool
|
||||
allow_cross_tenant: bool
|
||||
allow_all_clients: bool
|
||||
own_only: bool
|
||||
locked_partner_id: int | None
|
||||
can_assign_partner: bool
|
||||
can_change_branch: bool
|
||||
can_change_tenant: bool
|
||||
|
||||
|
||||
def build_scope(request, user, permission_checker):
|
||||
active_tenant_id = int(request.session.get("active_tenant_id") or user.tenant_id)
|
||||
active_branch_value = request.session.get("active_branch_id")
|
||||
active_branch_id = None if active_branch_value in (None, "", 0, "0") else int(active_branch_value)
|
||||
|
||||
allow_cross_branch = permission_checker("clients.cross_branch")
|
||||
allow_cross_tenant = permission_checker("clients.cross_tenant")
|
||||
can_assign_partner = permission_checker("clients.assign_partner")
|
||||
own_only = permission_checker("clients.view.own_only")
|
||||
|
||||
allow_all_clients = bool((allow_cross_tenant and allow_cross_branch and not own_only) or permission_checker("clients.view.all"))
|
||||
|
||||
locked_partner_id = user.id if own_only else None
|
||||
can_change_branch = allow_cross_branch
|
||||
can_change_tenant = allow_cross_tenant
|
||||
|
||||
return ClientAccessScope(
|
||||
tenant_id=active_tenant_id,
|
||||
branch_id=active_branch_id or getattr(user, "branch_id", None),
|
||||
allow_cross_branch=allow_cross_branch,
|
||||
allow_cross_tenant=allow_cross_tenant,
|
||||
allow_all_clients=allow_all_clients,
|
||||
own_only=own_only,
|
||||
locked_partner_id=locked_partner_id,
|
||||
can_assign_partner=can_assign_partner,
|
||||
can_change_branch=can_change_branch,
|
||||
can_change_tenant=can_change_tenant,
|
||||
)
|
||||
|
||||
|
||||
def effective_partner_id(row: dict):
|
||||
return row.get("assoc_partner_user_id") or row.get("partner_id")
|
||||
|
||||
|
||||
def effective_tenant_id(row: dict):
|
||||
return row.get("assoc_firm_tenant_id") or row.get("tenant_id")
|
||||
|
||||
|
||||
def effective_branch_id(row: dict):
|
||||
return row.get("branch_id")
|
||||
|
||||
|
||||
def can_view_client_row(scope: ClientAccessScope, row: dict, *, user_id: int) -> bool:
|
||||
if scope.allow_all_clients:
|
||||
return True
|
||||
|
||||
if not scope.allow_cross_tenant and effective_tenant_id(row) != scope.tenant_id:
|
||||
return False
|
||||
|
||||
branch_id = effective_branch_id(row)
|
||||
if not scope.allow_cross_branch and scope.branch_id and branch_id and branch_id != scope.branch_id:
|
||||
return False
|
||||
|
||||
if scope.own_only and scope.locked_partner_id and effective_partner_id(row) != scope.locked_partner_id:
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,178 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db.deps import get_common_db
|
||||
from app.core.security.session_auth import require_login
|
||||
from app.modules.clients.access import ClientAccessScope
|
||||
from app.modules.clients.schemas import ClientAuditLogOut, ClientFilterOptions, ClientListResponse, ClientOut, ClientUpdate, ClientCreate
|
||||
from app.modules.clients.service import (
|
||||
activate_client_service,
|
||||
archive_client_service,
|
||||
create_client_service,
|
||||
deactivate_client_service,
|
||||
export_clients_csv,
|
||||
get_client_or_404,
|
||||
get_filter_options,
|
||||
list_client_audit_logs,
|
||||
list_clients_payload,
|
||||
restore_client_service,
|
||||
update_client_service,
|
||||
)
|
||||
from app.modules.core.rbac.permission_guard import require_permission
|
||||
|
||||
router = APIRouter(prefix="/api/v1/clients", tags=["clients-api"])
|
||||
|
||||
def _api_scope_from_user(db, user):
|
||||
def has(code: str):
|
||||
try:
|
||||
require_permission(db, user, code)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
own_only = has("clients.view.own_only") or not has("clients.assign_partner")
|
||||
return ClientAccessScope(
|
||||
tenant_id=user.tenant_id,
|
||||
branch_id=user.branch_id,
|
||||
allow_cross_branch=has("clients.cross_branch"),
|
||||
allow_cross_tenant=has("clients.cross_tenant"),
|
||||
own_only=own_only,
|
||||
locked_partner_id=user.id if own_only else None,
|
||||
can_assign_partner=has("clients.assign_partner"),
|
||||
can_change_branch=has("clients.cross_branch"),
|
||||
can_change_tenant=has("clients.cross_tenant"),
|
||||
)
|
||||
|
||||
@router.get("/filters", response_model=ClientFilterOptions)
|
||||
def api_client_filters():
|
||||
return get_filter_options()
|
||||
|
||||
@router.get("", response_model=ClientListResponse)
|
||||
def api_list_clients(
|
||||
q: str = Query("", max_length=100),
|
||||
status: str = Query("", max_length=20),
|
||||
client_type: str = Query("", max_length=100),
|
||||
partner_id: int | None = Query(None),
|
||||
include_archived: bool = Query(False),
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(10, ge=1, le=100),
|
||||
sort_by: str = Query("client_name"),
|
||||
sort_order: str = Query("asc"),
|
||||
db: Session = Depends(get_common_db),
|
||||
user=Depends(require_login),
|
||||
):
|
||||
require_permission(db, user, "clients.view")
|
||||
scope = _api_scope_from_user(db, user)
|
||||
if scope.own_only:
|
||||
partner_id = scope.locked_partner_id
|
||||
return list_clients_payload(
|
||||
db,
|
||||
tenant_id=scope.tenant_id,
|
||||
branch_id=scope.branch_id,
|
||||
allow_cross_branch=scope.allow_cross_branch,
|
||||
partner_id=partner_id,
|
||||
q=q,
|
||||
status=status,
|
||||
client_type=client_type,
|
||||
include_archived=include_archived,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
|
||||
@router.get("/export")
|
||||
def api_export_clients(
|
||||
q: str = Query("", max_length=100),
|
||||
status: str = Query("", max_length=20),
|
||||
client_type: str = Query("", max_length=100),
|
||||
partner_id: int | None = Query(None),
|
||||
include_archived: bool = Query(False),
|
||||
sort_by: str = Query("client_name"),
|
||||
sort_order: str = Query("asc"),
|
||||
db: Session = Depends(get_common_db),
|
||||
user=Depends(require_login),
|
||||
):
|
||||
require_permission(db, user, "clients.export")
|
||||
scope = _api_scope_from_user(db, user)
|
||||
if scope.own_only:
|
||||
partner_id = scope.locked_partner_id
|
||||
payload = list_clients_payload(
|
||||
db,
|
||||
tenant_id=scope.tenant_id,
|
||||
branch_id=scope.branch_id,
|
||||
allow_cross_branch=scope.allow_cross_branch,
|
||||
partner_id=partner_id,
|
||||
q=q,
|
||||
status=status,
|
||||
client_type=client_type,
|
||||
include_archived=include_archived,
|
||||
page=1,
|
||||
per_page=10000,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
csv_text = export_clients_csv(payload)
|
||||
return Response(content=csv_text, media_type="text/csv", headers={"Content-Disposition": "attachment; filename=clients_export.csv"})
|
||||
|
||||
@router.get("/{client_id}", response_model=ClientOut)
|
||||
def api_get_client(client_id: int, db: Session = Depends(get_common_db), user=Depends(require_login)):
|
||||
require_permission(db, user, "clients.view")
|
||||
scope = _api_scope_from_user(db, user)
|
||||
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch)
|
||||
if scope.own_only and row.partner_id != scope.locked_partner_id:
|
||||
raise HTTPException(status_code=404, detail="Client not found.")
|
||||
return row
|
||||
|
||||
@router.post("", response_model=ClientOut, status_code=201)
|
||||
def api_create_client(data: ClientCreate, db: Session = Depends(get_common_db), user=Depends(require_login)):
|
||||
require_permission(db, user, "clients.create")
|
||||
scope = _api_scope_from_user(db, user)
|
||||
return create_client_service(db, data=data, actor_user_id=user.id, scope=scope)
|
||||
|
||||
@router.put("/{client_id}", response_model=ClientOut)
|
||||
def api_update_client(client_id: int, data: ClientUpdate, db: Session = Depends(get_common_db), user=Depends(require_login)):
|
||||
require_permission(db, user, "clients.edit")
|
||||
scope = _api_scope_from_user(db, user)
|
||||
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch)
|
||||
if scope.own_only and row.partner_id != scope.locked_partner_id:
|
||||
raise HTTPException(status_code=404, detail="Client not found.")
|
||||
return update_client_service(db, row=row, data=data, actor_user_id=user.id, scope=scope)
|
||||
|
||||
@router.post("/{client_id}/deactivate", response_model=ClientOut)
|
||||
def api_deactivate_client(client_id: int, db: Session = Depends(get_common_db), user=Depends(require_login)):
|
||||
require_permission(db, user, "clients.deactivate")
|
||||
scope = _api_scope_from_user(db, user)
|
||||
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch)
|
||||
return deactivate_client_service(db, row=row, actor_user_id=user.id)
|
||||
|
||||
@router.post("/{client_id}/activate", response_model=ClientOut)
|
||||
def api_activate_client(client_id: int, db: Session = Depends(get_common_db), user=Depends(require_login)):
|
||||
require_permission(db, user, "clients.activate")
|
||||
scope = _api_scope_from_user(db, user)
|
||||
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch)
|
||||
return activate_client_service(db, row=row, actor_user_id=user.id)
|
||||
|
||||
@router.post("/{client_id}/archive", response_model=ClientOut)
|
||||
def api_archive_client(client_id: int, db: Session = Depends(get_common_db), user=Depends(require_login)):
|
||||
require_permission(db, user, "clients.archive")
|
||||
scope = _api_scope_from_user(db, user)
|
||||
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch)
|
||||
return archive_client_service(db, row=row, actor_user_id=user.id)
|
||||
|
||||
@router.post("/{client_id}/restore", response_model=ClientOut)
|
||||
def api_restore_client(client_id: int, db: Session = Depends(get_common_db), user=Depends(require_login)):
|
||||
require_permission(db, user, "clients.restore")
|
||||
scope = _api_scope_from_user(db, user)
|
||||
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch)
|
||||
return restore_client_service(db, row=row, actor_user_id=user.id)
|
||||
|
||||
@router.get("/{client_id}/audit-logs", response_model=list[ClientAuditLogOut])
|
||||
def api_client_audit_logs(client_id: int, limit: int = Query(50, ge=1, le=200), db: Session = Depends(get_common_db), user=Depends(require_login)):
|
||||
require_permission(db, user, "clients.audit_log.view")
|
||||
scope = _api_scope_from_user(db, user)
|
||||
row = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch)
|
||||
return list_client_audit_logs(db, row=row, limit=limit)
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.clients.association_models import ClientAssociation
|
||||
|
||||
|
||||
def get_active_association(db: Session, client_id: int):
|
||||
stmt = (
|
||||
select(ClientAssociation)
|
||||
.where(ClientAssociation.client_id == client_id)
|
||||
.limit(1)
|
||||
)
|
||||
return db.execute(stmt).scalar_one_or_none()
|
||||
|
||||
|
||||
def ensure_active_association(db: Session, client_id: int):
|
||||
row = get_active_association(db, client_id)
|
||||
if row:
|
||||
return row
|
||||
|
||||
row = ClientAssociation(
|
||||
client_id=client_id,
|
||||
association_type="firm",
|
||||
created_source="system_admin",
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
def update_association_fields(db: Session, client_id: int, **fields):
|
||||
row = ensure_active_association(db, client_id)
|
||||
for key, value in fields.items():
|
||||
if hasattr(row, key):
|
||||
setattr(row, key, value)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
|
||||
class ClientAssociation(CommonBase):
|
||||
__tablename__ = "client_associations"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
client_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||
association_type: Mapped[str] = mapped_column(String(50), nullable=False, default="firm")
|
||||
firm_tenant_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
consultant_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
partner_user_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
created_source: Mapped[str] = mapped_column(String(50), nullable=False, default="system_admin")
|
||||
@@ -0,0 +1,27 @@
|
||||
|
||||
def build_client_association(current_user, role_name, selected_partner_id=None):
|
||||
role = (role_name or '').lower()
|
||||
if role in ('system admin', 'firm admin'):
|
||||
return {
|
||||
'association_type': 'firm',
|
||||
'firm_tenant_id': getattr(current_user, 'tenant_id', None),
|
||||
'partner_user_id': selected_partner_id,
|
||||
'created_source': 'firm_admin',
|
||||
}
|
||||
if role == 'partner':
|
||||
return {
|
||||
'association_type': 'firm',
|
||||
'firm_tenant_id': getattr(current_user, 'tenant_id', None),
|
||||
'partner_user_id': current_user.id,
|
||||
'created_source': 'partner',
|
||||
}
|
||||
if role == 'consultant':
|
||||
return {
|
||||
'association_type': 'consultant',
|
||||
'consultant_id': current_user.id,
|
||||
'created_source': 'consultant',
|
||||
}
|
||||
return {
|
||||
'association_type': 'self_service_unassigned',
|
||||
'created_source': 'self_service',
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.core.iam.profile_service import profile_photo_url
|
||||
from app.modules.core.tenancy.models import Branch, Tenant
|
||||
|
||||
|
||||
def _initials(name: str | None, email: str | None = None) -> str:
|
||||
source = (name or email or "Auditor").strip()
|
||||
parts = [p for p in source.replace("@", " ").replace(".", " ").split() if p]
|
||||
if not parts:
|
||||
return "AU"
|
||||
if len(parts) == 1:
|
||||
return parts[0][:2].upper()
|
||||
return (parts[0][:1] + parts[-1][:1]).upper()
|
||||
|
||||
|
||||
def _contact_card_from_user(
|
||||
*,
|
||||
user: User | None,
|
||||
tenant_name: str | None,
|
||||
branch_name: str | None,
|
||||
source_label: str,
|
||||
) -> dict:
|
||||
if not user:
|
||||
return {
|
||||
"available": False,
|
||||
"name": "Firm team",
|
||||
"designation": "Audit support team",
|
||||
"qualification": None,
|
||||
"email": None,
|
||||
"mobile": None,
|
||||
"photo_url": None,
|
||||
"initials": "FT",
|
||||
"firm_name": tenant_name,
|
||||
"branch_name": branch_name,
|
||||
"source_label": source_label,
|
||||
}
|
||||
|
||||
name = getattr(user, "full_name", None) or getattr(user, "email", None) or "Firm team"
|
||||
designation = getattr(user, "designation", None) or source_label or "Auditor"
|
||||
return {
|
||||
"available": True,
|
||||
"name": name,
|
||||
"designation": designation,
|
||||
"qualification": getattr(user, "qualification", None),
|
||||
"email": getattr(user, "email", None),
|
||||
"mobile": getattr(user, "mobile", None),
|
||||
"photo_url": profile_photo_url(user),
|
||||
"initials": _initials(name, getattr(user, "email", None)),
|
||||
"firm_name": tenant_name,
|
||||
"branch_name": branch_name,
|
||||
"source_label": source_label,
|
||||
}
|
||||
|
||||
|
||||
def build_client_auditor_card(db: Session, client_row: dict | None) -> dict:
|
||||
"""Return a client-facing contact card for the assigned auditor/partner.
|
||||
|
||||
Priority:
|
||||
1. Client assigned partner (`partner_id`).
|
||||
2. Default review partner, if no assigned partner exists.
|
||||
3. Firm team fallback using tenant/branch names.
|
||||
|
||||
This reuses Phase 7Q.3 user profile fields and does not create new tables.
|
||||
"""
|
||||
if not client_row:
|
||||
return _contact_card_from_user(
|
||||
user=None,
|
||||
tenant_name=None,
|
||||
branch_name=None,
|
||||
source_label="Firm team",
|
||||
)
|
||||
|
||||
tenant_name = client_row.get("tenant_name")
|
||||
branch_name = client_row.get("branch_name")
|
||||
tenant_id = client_row.get("tenant_id")
|
||||
branch_id = client_row.get("branch_id")
|
||||
|
||||
partner_id = client_row.get("partner_id") or client_row.get("assoc_partner_user_id")
|
||||
review_partner_id = client_row.get("default_review_partner_user_id")
|
||||
|
||||
target_user_id = partner_id or review_partner_id
|
||||
source_label = "Assigned Auditor" if partner_id else "Review Partner"
|
||||
|
||||
if not target_user_id:
|
||||
return _contact_card_from_user(
|
||||
user=None,
|
||||
tenant_name=tenant_name,
|
||||
branch_name=branch_name,
|
||||
source_label="Firm team",
|
||||
)
|
||||
|
||||
stmt = (
|
||||
select(User, Tenant.name.label("tenant_name"), Branch.name.label("branch_name"))
|
||||
.join(Tenant, Tenant.id == User.tenant_id, isouter=True)
|
||||
.join(Branch, Branch.id == User.branch_id, isouter=True)
|
||||
.where(User.id == int(target_user_id), User.deleted_at.is_(None))
|
||||
)
|
||||
if tenant_id:
|
||||
stmt = stmt.where(User.tenant_id == int(tenant_id))
|
||||
result = db.execute(stmt).first()
|
||||
if not result:
|
||||
return _contact_card_from_user(
|
||||
user=None,
|
||||
tenant_name=tenant_name,
|
||||
branch_name=branch_name,
|
||||
source_label="Firm team",
|
||||
)
|
||||
|
||||
user, resolved_tenant_name, resolved_branch_name = result
|
||||
return _contact_card_from_user(
|
||||
user=user,
|
||||
tenant_name=resolved_tenant_name or tenant_name,
|
||||
branch_name=resolved_branch_name or branch_name,
|
||||
source_label=source_label,
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
ENGAGEMENT_MODES = [
|
||||
"internal_managed",
|
||||
"self_tracked",
|
||||
"hybrid",
|
||||
]
|
||||
|
||||
ASSOCIATION_TYPES = [
|
||||
"firm",
|
||||
"consultant",
|
||||
"firm_consultant",
|
||||
"self_service_unassigned",
|
||||
]
|
||||
|
||||
CLIENT_TYPES = [
|
||||
"Proprietorship",
|
||||
"Partnership",
|
||||
"LLP",
|
||||
"Private Limited Company",
|
||||
"Public Limited Company",
|
||||
"Trust",
|
||||
"Society",
|
||||
"AOP",
|
||||
"HUF",
|
||||
"NRI",
|
||||
"Other",
|
||||
]
|
||||
|
||||
CLIENT_STATUS = ["active", "inactive", "archived"]
|
||||
|
||||
CLIENT_CATEGORY_OPTIONS = [
|
||||
"Audit", "Tax", "GST", "Compliance", "Payroll", "Advisory", "Litigation", "Internal", "Other",
|
||||
]
|
||||
|
||||
RISK_CATEGORIES = ["low", "medium", "high", "critical"]
|
||||
|
||||
CLIENT_SORT_FIELDS = {
|
||||
"client_code": "client_code",
|
||||
"client_name": "client_name",
|
||||
"client_type": "client_type",
|
||||
"status": "status",
|
||||
"created_at_utc": "created_at_utc",
|
||||
"updated_at_utc": "updated_at_utc",
|
||||
"onboarding_date": "onboarding_date",
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class ClientListFilters:
|
||||
q: str = ""
|
||||
status: str = ""
|
||||
client_type: str = ""
|
||||
partner_id: int | None = None
|
||||
include_archived: bool = False
|
||||
page: int = 1
|
||||
per_page: int = 10
|
||||
sort_by: str = "client_name"
|
||||
sort_order: str = "asc"
|
||||
|
||||
@classmethod
|
||||
def from_params(cls, **kwargs):
|
||||
partner_id = kwargs.get("partner_id")
|
||||
if partner_id in ("", None):
|
||||
partner_id = None
|
||||
elif not isinstance(partner_id, int):
|
||||
partner_id = int(partner_id)
|
||||
include_archived = kwargs.get("include_archived", False)
|
||||
if isinstance(include_archived, str):
|
||||
include_archived = include_archived.lower() in ("1", "true", "yes", "on")
|
||||
return cls(
|
||||
q=kwargs.get("q", "") or "",
|
||||
status=kwargs.get("status", "") or "",
|
||||
client_type=kwargs.get("client_type", "") or "",
|
||||
partner_id=partner_id,
|
||||
include_archived=include_archived,
|
||||
page=max(int(kwargs.get("page", 1) or 1), 1),
|
||||
per_page=min(max(int(kwargs.get("per_page", 10) or 10), 1), 100),
|
||||
sort_by=kwargs.get("sort_by", "client_name") or "client_name",
|
||||
sort_order=kwargs.get("sort_order", "asc") or "asc",
|
||||
)
|
||||
@@ -0,0 +1,281 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.clients import repository
|
||||
from app.modules.clients.schemas import ClientCreate
|
||||
from app.modules.clients.service import create_client_service
|
||||
|
||||
TEMPLATE_COLUMNS = [
|
||||
"uploader_user_id",
|
||||
"firm_tenant_id",
|
||||
"partner_user_id",
|
||||
"branch_id",
|
||||
"client_code",
|
||||
"client_name",
|
||||
"client_type",
|
||||
"engagement_mode",
|
||||
"email",
|
||||
"portal_password",
|
||||
"portal_password_confirm",
|
||||
"mobile",
|
||||
"pan",
|
||||
"gstin",
|
||||
"tan",
|
||||
"cin_llpin",
|
||||
"msme_no",
|
||||
"iec_code",
|
||||
"contact_person_name",
|
||||
"contact_person_designation",
|
||||
"alternate_mobile",
|
||||
"alternate_email",
|
||||
"address_line_1",
|
||||
"address_line_2",
|
||||
"city",
|
||||
"state",
|
||||
"pincode",
|
||||
"country",
|
||||
"client_category",
|
||||
"risk_category",
|
||||
"onboarding_date",
|
||||
"closing_date",
|
||||
"notes",
|
||||
"status",
|
||||
"gst_applicable",
|
||||
"income_tax_applicable",
|
||||
"tds_applicable",
|
||||
"roc_applicable",
|
||||
"audit_applicable",
|
||||
"pf_applicable",
|
||||
"esi_applicable",
|
||||
"professional_tax_applicable",
|
||||
"payroll_applicable",
|
||||
"msme_applicable",
|
||||
"import_export_applicable",
|
||||
]
|
||||
|
||||
BOOL_FIELDS = {
|
||||
"gst_applicable", "income_tax_applicable", "tds_applicable", "roc_applicable",
|
||||
"audit_applicable", "pf_applicable", "esi_applicable", "professional_tax_applicable",
|
||||
"payroll_applicable", "msme_applicable", "import_export_applicable",
|
||||
}
|
||||
|
||||
@dataclass
|
||||
class ImportPreview:
|
||||
valid_rows: list[dict]
|
||||
errors: list[dict]
|
||||
total_rows: int
|
||||
|
||||
|
||||
def _clean(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
txt = str(value).strip()
|
||||
return txt or None
|
||||
|
||||
|
||||
def _to_bool(value: Any) -> bool:
|
||||
txt = str(value or '').strip().lower()
|
||||
return txt in {'1','true','yes','y','on'}
|
||||
|
||||
|
||||
def build_client_import_template_bytes(*, current_user, tenant_id: int, partner_id: int | None) -> bytes:
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = 'clients_import'
|
||||
ws.append(TEMPLATE_COLUMNS)
|
||||
sample = [
|
||||
current_user.id, tenant_id, partner_id or current_user.id, getattr(current_user, 'branch_id', '') or '',
|
||||
'CLT-001', 'Sample Client', 'Other', 'internal_managed', 'client@example.com', 'ChangeMe@123', 'ChangeMe@123',
|
||||
'9876543210', '', '', '', '', '', '', 'Client Contact', 'Proprietor', '', '', 'Address line 1', '', 'Chennai', 'Tamil Nadu', '600001', 'India', '', '', '', '', '', 'active',
|
||||
'yes', 'yes', 'no', 'no', 'no', 'no', 'no', 'no', 'no', 'no', 'no'
|
||||
]
|
||||
ws.append(sample)
|
||||
ref = wb.create_sheet('instructions')
|
||||
ref.append(['Field', 'Notes'])
|
||||
ref.append(['uploader_user_id', 'Must match the logged-in uploader user id exactly.'])
|
||||
ref.append(['firm_tenant_id', 'Must match the active firm/tenant context of the upload.'])
|
||||
ref.append(['partner_user_id', 'Must be an active Partner user mapped to the same firm.'])
|
||||
ref.append(['branch_id', 'Optional. If blank, uploader branch or partner branch will be used.'])
|
||||
ref.append(['email', 'Used as the client frontend login email.'])
|
||||
ref.append(['portal_password', 'Minimum 8 characters.'])
|
||||
ref.append(['portal_password_confirm', 'Must match portal_password.'])
|
||||
bio = io.BytesIO()
|
||||
wb.save(bio)
|
||||
return bio.getvalue()
|
||||
|
||||
|
||||
def _row_dict(ws, row_idx: int) -> dict[str, Any]:
|
||||
headers = [str(c.value).strip() if c.value is not None else '' for c in ws[1]]
|
||||
values = [c.value for c in ws[row_idx]]
|
||||
return {headers[i]: values[i] if i < len(values) else None for i in range(len(headers)) if headers[i]}
|
||||
|
||||
|
||||
def build_preview(db: Session, *, current_user, scope, role_names: set[str], upload_bytes: bytes) -> ImportPreview:
|
||||
wb = load_workbook(io.BytesIO(upload_bytes), data_only=True)
|
||||
ws = wb[wb.sheetnames[0]]
|
||||
headers = [str(c.value).strip() if c.value is not None else '' for c in ws[1]]
|
||||
missing = [c for c in TEMPLATE_COLUMNS if c not in headers]
|
||||
if missing:
|
||||
return ImportPreview(valid_rows=[], errors=[{'row_number': 1, 'messages': [f'Missing required columns: {", ".join(missing)}']}], total_rows=0)
|
||||
|
||||
valid_rows = []
|
||||
errors = []
|
||||
active_tenant_id = int(scope.tenant_id)
|
||||
active_branch_id = int(scope.branch_id or getattr(current_user, 'branch_id', 0) or 0)
|
||||
|
||||
for row_idx in range(2, ws.max_row + 1):
|
||||
raw = _row_dict(ws, row_idx)
|
||||
if not any(v not in (None, '') for v in raw.values()):
|
||||
continue
|
||||
msgs: list[str] = []
|
||||
cleaned = {k: (_to_bool(v) if k in BOOL_FIELDS else _clean(v)) for k, v in raw.items()}
|
||||
|
||||
try:
|
||||
uploader_user_id = int(cleaned.get('uploader_user_id') or 0)
|
||||
except Exception:
|
||||
uploader_user_id = 0
|
||||
try:
|
||||
firm_tenant_id = int(cleaned.get('firm_tenant_id') or 0)
|
||||
except Exception:
|
||||
firm_tenant_id = 0
|
||||
try:
|
||||
partner_user_id = int(cleaned.get('partner_user_id') or 0)
|
||||
except Exception:
|
||||
partner_user_id = 0
|
||||
try:
|
||||
branch_id = int(cleaned.get('branch_id') or 0)
|
||||
except Exception:
|
||||
branch_id = 0
|
||||
|
||||
if uploader_user_id != int(current_user.id):
|
||||
msgs.append('uploader_user_id must match the currently logged-in user id.')
|
||||
if firm_tenant_id != active_tenant_id:
|
||||
msgs.append('firm_tenant_id must match the active firm/tenant context of the uploader.')
|
||||
partner = repository.get_partner_for_tenant(db, partner_user_id=partner_user_id, tenant_id=firm_tenant_id) if partner_user_id else None
|
||||
if not partner:
|
||||
msgs.append('partner_user_id must belong to an active Partner user in the same firm.')
|
||||
if 'partner' in role_names and partner_user_id != int(current_user.id):
|
||||
msgs.append('Partner uploader can import only for their own partner_user_id.')
|
||||
|
||||
if branch_id:
|
||||
branch = repository.get_branch(db, branch_id)
|
||||
if not branch or int(branch.tenant_id) != firm_tenant_id:
|
||||
msgs.append('branch_id must belong to the same firm/tenant.')
|
||||
else:
|
||||
branch_id = int(getattr(partner, 'branch_id', None) or active_branch_id or getattr(current_user, 'branch_id', 0) or 0)
|
||||
if not branch_id:
|
||||
msgs.append('branch_id is required when uploader and partner have no branch mapped.')
|
||||
|
||||
payload = {
|
||||
'tenant_id': firm_tenant_id,
|
||||
'branch_id': branch_id,
|
||||
'partner_id': partner_user_id or None,
|
||||
'engagement_mode': cleaned.get('engagement_mode') or 'internal_managed',
|
||||
'client_code': cleaned.get('client_code') or '',
|
||||
'client_name': cleaned.get('client_name') or '',
|
||||
'trade_name': None,
|
||||
'client_type': cleaned.get('client_type') or 'Other',
|
||||
'pan': cleaned.get('pan'),
|
||||
'gstin': cleaned.get('gstin'),
|
||||
'tan': cleaned.get('tan'),
|
||||
'cin_llpin': cleaned.get('cin_llpin'),
|
||||
'msme_no': cleaned.get('msme_no'),
|
||||
'iec_code': cleaned.get('iec_code'),
|
||||
'contact_person_name': cleaned.get('contact_person_name'),
|
||||
'contact_person_designation': cleaned.get('contact_person_designation'),
|
||||
'mobile': cleaned.get('mobile'),
|
||||
'alternate_mobile': cleaned.get('alternate_mobile'),
|
||||
'email': cleaned.get('email'),
|
||||
'alternate_email': cleaned.get('alternate_email'),
|
||||
'address_line_1': cleaned.get('address_line_1'),
|
||||
'address_line_2': cleaned.get('address_line_2'),
|
||||
'city': cleaned.get('city'),
|
||||
'state': cleaned.get('state'),
|
||||
'pincode': cleaned.get('pincode'),
|
||||
'country': cleaned.get('country') or 'India',
|
||||
'status': cleaned.get('status') or 'active',
|
||||
'client_category': cleaned.get('client_category'),
|
||||
'risk_category': cleaned.get('risk_category'),
|
||||
'onboarding_date': cleaned.get('onboarding_date'),
|
||||
'closing_date': cleaned.get('closing_date'),
|
||||
'notes': cleaned.get('notes'),
|
||||
'gst_applicable': cleaned.get('gst_applicable') or False,
|
||||
'income_tax_applicable': cleaned.get('income_tax_applicable') or False,
|
||||
'tds_applicable': cleaned.get('tds_applicable') or False,
|
||||
'roc_applicable': cleaned.get('roc_applicable') or False,
|
||||
'audit_applicable': cleaned.get('audit_applicable') or False,
|
||||
'pf_applicable': cleaned.get('pf_applicable') or False,
|
||||
'esi_applicable': cleaned.get('esi_applicable') or False,
|
||||
'professional_tax_applicable': cleaned.get('professional_tax_applicable') or False,
|
||||
'payroll_applicable': cleaned.get('payroll_applicable') or False,
|
||||
'msme_applicable': cleaned.get('msme_applicable') or False,
|
||||
'import_export_applicable': cleaned.get('import_export_applicable') or False,
|
||||
}
|
||||
|
||||
try:
|
||||
ClientCreate(**payload)
|
||||
except Exception as exc:
|
||||
msgs.append(str(exc))
|
||||
|
||||
if not cleaned.get('portal_password'):
|
||||
msgs.append('portal_password is required for imported clients.')
|
||||
if cleaned.get('portal_password') != cleaned.get('portal_password_confirm'):
|
||||
msgs.append('portal_password and portal_password_confirm must match.')
|
||||
|
||||
# intra-file duplicate client codes
|
||||
if any(v.get('client_code') == payload['client_code'] and v.get('tenant_id') == firm_tenant_id for v in valid_rows):
|
||||
msgs.append('Duplicate client_code found within the same upload file.')
|
||||
|
||||
if msgs:
|
||||
errors.append({'row_number': row_idx, 'messages': msgs, 'row': cleaned})
|
||||
continue
|
||||
|
||||
valid_rows.append({
|
||||
'row_number': row_idx,
|
||||
'tenant_id': firm_tenant_id,
|
||||
'branch_id': branch_id,
|
||||
'partner_id': partner_user_id,
|
||||
'client_payload': payload,
|
||||
'portal_password': cleaned.get('portal_password'),
|
||||
'portal_password_confirm': cleaned.get('portal_password_confirm'),
|
||||
})
|
||||
|
||||
return ImportPreview(valid_rows=valid_rows, errors=errors, total_rows=len(valid_rows) + len(errors))
|
||||
|
||||
|
||||
def serialize_preview_rows(valid_rows: list[dict]) -> str:
|
||||
return json.dumps(valid_rows, default=str)
|
||||
|
||||
|
||||
def deserialize_preview_rows(raw: str) -> list[dict]:
|
||||
rows = json.loads(raw or '[]')
|
||||
return rows if isinstance(rows, list) else []
|
||||
|
||||
|
||||
def commit_import(db: Session, *, current_user, scope, current_user_roles: list[str], preview_rows: list[dict]) -> dict:
|
||||
created = []
|
||||
failures = []
|
||||
for item in preview_rows:
|
||||
try:
|
||||
data = ClientCreate(**item['client_payload'])
|
||||
row = create_client_service(
|
||||
db,
|
||||
data=data,
|
||||
actor_user_id=current_user.id,
|
||||
scope=scope,
|
||||
current_user_roles=current_user_roles,
|
||||
portal_password=item.get('portal_password'),
|
||||
portal_password_confirm=item.get('portal_password_confirm'),
|
||||
)
|
||||
created.append({'id': row.id, 'client_code': row.client_code, 'client_name': row.client_name})
|
||||
except Exception as exc:
|
||||
failures.append({'row_number': item.get('row_number'), 'message': str(getattr(exc, 'detail', exc))})
|
||||
return {'created': created, 'failures': failures}
|
||||
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, JSON, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
|
||||
class Client(CommonBase):
|
||||
__tablename__ = "clients"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "client_code", name="uq_clients_tenant_code"),
|
||||
UniqueConstraint("tenant_id", "pan", name="uq_clients_tenant_pan"),
|
||||
UniqueConstraint("tenant_id", "gstin", name="uq_clients_tenant_gstin"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), nullable=False, index=True)
|
||||
branch_id: Mapped[int] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True)
|
||||
partner_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
default_review_partner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
engagement_mode: Mapped[str] = mapped_column(String(30), nullable=False, default="internal_managed", index=True)
|
||||
client_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
client_name: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
|
||||
trade_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
client_type: Mapped[str] = mapped_column(String(100), nullable=False, default="Other")
|
||||
pan: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
gstin: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
tan: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
cin_llpin: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
msme_no: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
iec_code: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
contact_person_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
contact_person_designation: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
mobile: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
alternate_mobile: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
alternate_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
address_line_1: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
address_line_2: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
city: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
state: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
pincode: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
country: Mapped[str | None] = mapped_column(String(100), nullable=True, default="India")
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active", index=True)
|
||||
client_category: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
risk_category: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
onboarding_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
closing_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
gst_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
income_tax_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
tds_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
roc_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
audit_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
pf_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
esi_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
professional_tax_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
payroll_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
msme_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
import_export_applicable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
is_archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
archived_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
portal_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
|
||||
|
||||
class ClientAuditLog(CommonBase):
|
||||
__tablename__ = "client_audit_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id"), nullable=False, index=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), nullable=False, index=True)
|
||||
branch_id: Mapped[int] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True)
|
||||
actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
action: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
summary: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
payload_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
@@ -0,0 +1,15 @@
|
||||
CLIENT_PERMISSION_CODES = {
|
||||
"view": "clients.view",
|
||||
"create": "clients.create",
|
||||
"edit": "clients.edit",
|
||||
"deactivate": "clients.deactivate",
|
||||
"activate": "clients.activate",
|
||||
"archive": "clients.archive",
|
||||
"restore": "clients.restore",
|
||||
"assign_partner": "clients.assign_partner",
|
||||
"cross_branch": "clients.cross_branch",
|
||||
"cross_tenant": "clients.cross_tenant",
|
||||
"export": "clients.export",
|
||||
"audit_log_view": "clients.audit_log.view",
|
||||
"view_own_only": "clients.view.own_only",
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.modules.documents.models import EngagementDocument, PermanentClientDocument
|
||||
from app.modules.services.models import (
|
||||
ClientServiceSubscription,
|
||||
ClientServiceTaskInstance,
|
||||
ServiceTaskComment,
|
||||
)
|
||||
|
||||
OPEN_TASK_STATUSES = {"pending", "in_progress", "blocked", "ready_for_review", "rework"}
|
||||
CLOSED_TASK_STATUSES = {"completed", "approved", "closed", "not_applicable"}
|
||||
|
||||
|
||||
def _client_id(client_row: dict[str, Any]) -> int:
|
||||
return int(client_row.get("id") or 0)
|
||||
|
||||
|
||||
def _tenant_id(client_row: dict[str, Any]) -> int:
|
||||
return int(client_row.get("tenant_id") or 0)
|
||||
|
||||
|
||||
def list_client_engagements(db: Session, client_row: dict[str, Any], *, limit: int = 200, financial_year: str | None = None) -> list[ClientServiceSubscription]:
|
||||
"""Return engagements/subscriptions visible to the logged-in client."""
|
||||
query = (
|
||||
select(ClientServiceSubscription)
|
||||
.options(
|
||||
selectinload(ClientServiceSubscription.catalogue),
|
||||
selectinload(ClientServiceSubscription.assigned_partner),
|
||||
selectinload(ClientServiceSubscription.assigned_manager),
|
||||
selectinload(ClientServiceSubscription.assigned_staff),
|
||||
)
|
||||
.where(
|
||||
ClientServiceSubscription.tenant_id == _tenant_id(client_row),
|
||||
ClientServiceSubscription.client_id == _client_id(client_row),
|
||||
ClientServiceSubscription.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
if financial_year:
|
||||
query = query.where(ClientServiceSubscription.financial_year == financial_year.strip())
|
||||
rows = db.execute(
|
||||
query.order_by(
|
||||
ClientServiceSubscription.current_due_date.asc().nulls_last(),
|
||||
ClientServiceSubscription.updated_at_utc.desc(),
|
||||
)
|
||||
.limit(max(1, min(int(limit or 200), 500)))
|
||||
).scalars().all()
|
||||
return rows
|
||||
|
||||
|
||||
def list_client_tasks_for_engagement(db: Session, client_row: dict[str, Any], engagement_id: int) -> list[ClientServiceTaskInstance]:
|
||||
return db.execute(
|
||||
select(ClientServiceTaskInstance)
|
||||
.options(
|
||||
selectinload(ClientServiceTaskInstance.catalogue),
|
||||
selectinload(ClientServiceTaskInstance.assigned_to),
|
||||
selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by),
|
||||
)
|
||||
.where(
|
||||
ClientServiceTaskInstance.tenant_id == _tenant_id(client_row),
|
||||
ClientServiceTaskInstance.client_id == _client_id(client_row),
|
||||
ClientServiceTaskInstance.subscription_id == int(engagement_id),
|
||||
ClientServiceTaskInstance.is_active.is_(True),
|
||||
)
|
||||
.order_by(ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.id.asc())
|
||||
).scalars().all()
|
||||
|
||||
|
||||
def get_client_engagement(db: Session, client_row: dict[str, Any], engagement_id: int, *, financial_year: str | None = None) -> ClientServiceSubscription | None:
|
||||
query = (
|
||||
select(ClientServiceSubscription)
|
||||
.options(
|
||||
selectinload(ClientServiceSubscription.catalogue),
|
||||
selectinload(ClientServiceSubscription.assigned_partner),
|
||||
selectinload(ClientServiceSubscription.assigned_manager),
|
||||
selectinload(ClientServiceSubscription.assigned_staff),
|
||||
)
|
||||
.where(
|
||||
ClientServiceSubscription.id == int(engagement_id),
|
||||
ClientServiceSubscription.tenant_id == _tenant_id(client_row),
|
||||
ClientServiceSubscription.client_id == _client_id(client_row),
|
||||
ClientServiceSubscription.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
if financial_year:
|
||||
query = query.where(ClientServiceSubscription.financial_year == financial_year.strip())
|
||||
return db.execute(query).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_client_task(db: Session, client_row: dict[str, Any], task_id: int, *, financial_year: str | None = None) -> ClientServiceTaskInstance | None:
|
||||
query = (
|
||||
select(ClientServiceTaskInstance)
|
||||
.where(
|
||||
ClientServiceTaskInstance.id == int(task_id),
|
||||
ClientServiceTaskInstance.tenant_id == _tenant_id(client_row),
|
||||
ClientServiceTaskInstance.client_id == _client_id(client_row),
|
||||
ClientServiceTaskInstance.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
if financial_year:
|
||||
query = query.where(ClientServiceTaskInstance.financial_year == financial_year.strip())
|
||||
return db.execute(query).scalar_one_or_none()
|
||||
|
||||
|
||||
def list_client_visible_comments(db: Session, client_row: dict[str, Any], *, limit: int = 100, financial_year: str | None = None) -> list[ServiceTaskComment]:
|
||||
query = (
|
||||
select(ServiceTaskComment)
|
||||
.options(
|
||||
selectinload(ServiceTaskComment.created_by),
|
||||
selectinload(ServiceTaskComment.task).selectinload(ClientServiceTaskInstance.catalogue),
|
||||
selectinload(ServiceTaskComment.subscription).selectinload(ClientServiceSubscription.catalogue),
|
||||
)
|
||||
.join(ClientServiceTaskInstance, ClientServiceTaskInstance.id == ServiceTaskComment.task_instance_id)
|
||||
.where(
|
||||
ServiceTaskComment.tenant_id == _tenant_id(client_row),
|
||||
ServiceTaskComment.visibility == "client",
|
||||
ServiceTaskComment.is_deleted.is_(False),
|
||||
ClientServiceTaskInstance.client_id == _client_id(client_row),
|
||||
ClientServiceTaskInstance.tenant_id == _tenant_id(client_row),
|
||||
ClientServiceTaskInstance.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
if financial_year:
|
||||
query = query.where(ClientServiceTaskInstance.financial_year == financial_year.strip())
|
||||
return db.execute(
|
||||
query.order_by(ServiceTaskComment.created_at_utc.desc(), ServiceTaskComment.id.desc())
|
||||
.limit(max(1, min(int(limit or 100), 300)))
|
||||
).scalars().all()
|
||||
|
||||
|
||||
def create_client_reply(db: Session, *, client_row: dict[str, Any], task: ClientServiceTaskInstance, message: str, user) -> ServiceTaskComment:
|
||||
clean_message = (message or "").strip()
|
||||
if not clean_message:
|
||||
raise ValueError("Reply message is required.")
|
||||
if len(clean_message) > 4000:
|
||||
raise ValueError("Reply message is too long. Please keep it within 4000 characters.")
|
||||
comment = ServiceTaskComment(
|
||||
tenant_id=task.tenant_id,
|
||||
branch_id=task.branch_id,
|
||||
subscription_id=task.subscription_id,
|
||||
task_instance_id=task.id,
|
||||
comment_type="client_clarification",
|
||||
visibility="client",
|
||||
message=clean_message,
|
||||
created_by_user_id=getattr(user, "id", None),
|
||||
)
|
||||
db.add(comment)
|
||||
db.flush()
|
||||
return comment
|
||||
|
||||
|
||||
def list_client_engagement_documents(db: Session, client_row: dict[str, Any], *, engagement_id: int | None = None, financial_year: str | None = None) -> list[EngagementDocument]:
|
||||
stmt = (
|
||||
select(EngagementDocument)
|
||||
.options(selectinload(EngagementDocument.versions), selectinload(EngagementDocument.engagement).selectinload(ClientServiceSubscription.catalogue))
|
||||
.where(
|
||||
EngagementDocument.tenant_id == _tenant_id(client_row),
|
||||
EngagementDocument.client_id == _client_id(client_row),
|
||||
EngagementDocument.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
if engagement_id is not None:
|
||||
stmt = stmt.where(EngagementDocument.engagement_id == int(engagement_id))
|
||||
if financial_year:
|
||||
stmt = stmt.where(EngagementDocument.financial_year == financial_year.strip())
|
||||
return db.execute(stmt.order_by(EngagementDocument.updated_at_utc.desc())).unique().scalars().all()
|
||||
|
||||
|
||||
def list_client_permanent_documents(db: Session, client_row: dict[str, Any]) -> list[PermanentClientDocument]:
|
||||
return db.execute(
|
||||
select(PermanentClientDocument)
|
||||
.options(selectinload(PermanentClientDocument.versions))
|
||||
.where(
|
||||
PermanentClientDocument.tenant_id == _tenant_id(client_row),
|
||||
PermanentClientDocument.client_id == _client_id(client_row),
|
||||
PermanentClientDocument.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(PermanentClientDocument.category.asc(), PermanentClientDocument.updated_at_utc.desc())
|
||||
).unique().scalars().all()
|
||||
|
||||
|
||||
def build_client_portal_summary(db: Session, client_row: dict[str, Any], *, financial_year: str | None = None) -> dict[str, Any]:
|
||||
engagements = list_client_engagements(db, client_row, limit=500, financial_year=financial_year)
|
||||
engagement_ids = [row.id for row in engagements]
|
||||
today = date.today()
|
||||
|
||||
task_rows: list[ClientServiceTaskInstance] = []
|
||||
if engagement_ids:
|
||||
task_rows = db.execute(
|
||||
select(ClientServiceTaskInstance).where(
|
||||
ClientServiceTaskInstance.tenant_id == _tenant_id(client_row),
|
||||
ClientServiceTaskInstance.client_id == _client_id(client_row),
|
||||
ClientServiceTaskInstance.subscription_id.in_(engagement_ids),
|
||||
ClientServiceTaskInstance.is_active.is_(True),
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
status_counter = Counter((task.status or "pending") for task in task_rows)
|
||||
open_tasks = [task for task in task_rows if (task.status or "pending") in OPEN_TASK_STATUSES]
|
||||
overdue_tasks = [
|
||||
task for task in open_tasks
|
||||
if task.internal_target_date is not None and task.internal_target_date < today
|
||||
]
|
||||
due_soon_engagements = [
|
||||
row for row in engagements
|
||||
if row.current_due_date is not None and row.current_due_date >= today
|
||||
][:10]
|
||||
|
||||
pending_from_client = 0
|
||||
with_firm = 0
|
||||
completed = 0
|
||||
clarification_required = 0
|
||||
for row in engagements:
|
||||
tasks_for_eng = [t for t in task_rows if t.subscription_id == row.id]
|
||||
statuses = {(t.status or "pending") for t in tasks_for_eng}
|
||||
if statuses & {"blocked", "client_pending", "clarification_required"}:
|
||||
clarification_required += 1
|
||||
elif tasks_for_eng and all((t.status or "pending") in CLOSED_TASK_STATUSES for t in tasks_for_eng):
|
||||
completed += 1
|
||||
elif statuses & {"pending"}:
|
||||
pending_from_client += 1
|
||||
else:
|
||||
with_firm += 1
|
||||
|
||||
return {
|
||||
"engagements": engagements,
|
||||
"task_rows": task_rows,
|
||||
"status_counter": status_counter,
|
||||
"total_engagements": len(engagements),
|
||||
"open_tasks": len(open_tasks),
|
||||
"overdue_tasks": len(overdue_tasks),
|
||||
"completed_tasks": status_counter.get("completed", 0) + status_counter.get("approved", 0) + status_counter.get("closed", 0),
|
||||
"due_soon_engagements": due_soon_engagements,
|
||||
"pending_from_client": pending_from_client,
|
||||
"with_firm": with_firm,
|
||||
"clarification_required": clarification_required,
|
||||
"completed_engagements": completed,
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from math import ceil
|
||||
|
||||
from sqlalchemy import asc, case, desc, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security.passwords import hash_password
|
||||
from app.modules.clients.association_models import ClientAssociation
|
||||
from app.modules.clients.constants import CLIENT_SORT_FIELDS
|
||||
from app.modules.clients.models import Client, ClientAuditLog
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.core.rbac.models import Role, UserRole
|
||||
from app.modules.core.tenancy.models import Branch, Tenant
|
||||
|
||||
|
||||
def _safe_sort(sort_by: str, sort_order: str):
|
||||
attr_name = CLIENT_SORT_FIELDS.get(sort_by, "client_name")
|
||||
column = getattr(Client, attr_name)
|
||||
return desc(column) if sort_order == "desc" else asc(column)
|
||||
|
||||
|
||||
def build_clients_query(
|
||||
*,
|
||||
tenant_id: int,
|
||||
branch_id: int | None = None,
|
||||
allow_cross_branch: bool = False,
|
||||
allow_all_clients: bool = False,
|
||||
partner_id: int | None = None,
|
||||
q: str = "",
|
||||
status: str = "",
|
||||
client_type: str = "",
|
||||
include_archived: bool = False,
|
||||
):
|
||||
assoc = ClientAssociation
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
Client,
|
||||
User.full_name.label("partner_name"),
|
||||
Branch.name.label("branch_name"),
|
||||
Tenant.name.label("tenant_name"),
|
||||
assoc.association_type.label("association_type"),
|
||||
assoc.firm_tenant_id.label("assoc_firm_tenant_id"),
|
||||
assoc.consultant_id.label("assoc_consultant_id"),
|
||||
assoc.partner_user_id.label("assoc_partner_user_id"),
|
||||
assoc.created_source.label("assoc_created_source"),
|
||||
)
|
||||
.outerjoin(assoc, assoc.client_id == Client.id)
|
||||
.join(User, User.id == Client.partner_id, isouter=True)
|
||||
.join(Branch, Branch.id == Client.branch_id, isouter=True)
|
||||
.join(Tenant, Tenant.id == Client.tenant_id, isouter=True)
|
||||
)
|
||||
|
||||
if not allow_all_clients:
|
||||
stmt = stmt.where(Client.tenant_id == tenant_id)
|
||||
|
||||
if not include_archived:
|
||||
stmt = stmt.where(Client.is_archived.is_(False))
|
||||
|
||||
if branch_id and not allow_all_clients and not allow_cross_branch:
|
||||
stmt = stmt.where(Client.branch_id == branch_id)
|
||||
|
||||
if partner_id:
|
||||
stmt = stmt.where(
|
||||
(Client.partner_id == partner_id) | (assoc.partner_user_id == partner_id)
|
||||
)
|
||||
|
||||
if status:
|
||||
stmt = stmt.where(Client.status == status)
|
||||
|
||||
if client_type:
|
||||
stmt = stmt.where(Client.client_type == client_type)
|
||||
|
||||
if q:
|
||||
like = f"%{q.strip()}%"
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Client.client_code.ilike(like),
|
||||
Client.client_name.ilike(like),
|
||||
Client.trade_name.ilike(like),
|
||||
Client.pan.ilike(like),
|
||||
Client.gstin.ilike(like),
|
||||
Client.mobile.ilike(like),
|
||||
Client.email.ilike(like),
|
||||
)
|
||||
)
|
||||
|
||||
return stmt
|
||||
|
||||
|
||||
def list_clients(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_id: int,
|
||||
branch_id: int | None = None,
|
||||
allow_cross_branch: bool = False,
|
||||
allow_all_clients: bool = False,
|
||||
partner_id: int | None = None,
|
||||
q: str = "",
|
||||
status: str = "",
|
||||
client_type: str = "",
|
||||
include_archived: bool = False,
|
||||
page: int = 1,
|
||||
per_page: int = 10,
|
||||
sort_by: str = "client_name",
|
||||
sort_order: str = "asc",
|
||||
) -> dict:
|
||||
stmt = build_clients_query(
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
allow_cross_branch=allow_cross_branch,
|
||||
allow_all_clients=allow_all_clients,
|
||||
partner_id=partner_id,
|
||||
q=q,
|
||||
status=status,
|
||||
client_type=client_type,
|
||||
include_archived=include_archived,
|
||||
)
|
||||
|
||||
total = db.execute(select(func.count()).select_from(stmt.subquery())).scalar_one()
|
||||
result = db.execute(
|
||||
stmt.order_by(_safe_sort(sort_by, sort_order))
|
||||
.offset((page - 1) * per_page)
|
||||
.limit(per_page)
|
||||
).all()
|
||||
|
||||
rows = []
|
||||
for (
|
||||
client,
|
||||
partner_name,
|
||||
branch_name,
|
||||
tenant_name,
|
||||
association_type,
|
||||
assoc_firm_tenant_id,
|
||||
assoc_consultant_id,
|
||||
assoc_partner_user_id,
|
||||
assoc_created_source,
|
||||
) in result:
|
||||
row = {**client.__dict__}
|
||||
row.pop("_sa_instance_state", None)
|
||||
row.update(
|
||||
{
|
||||
"partner_name": partner_name,
|
||||
"branch_name": branch_name,
|
||||
"tenant_name": tenant_name,
|
||||
"association_type": association_type,
|
||||
"assoc_firm_tenant_id": assoc_firm_tenant_id,
|
||||
"assoc_consultant_id": assoc_consultant_id,
|
||||
"assoc_partner_user_id": assoc_partner_user_id,
|
||||
"assoc_created_source": assoc_created_source,
|
||||
"effective_partner_id": assoc_partner_user_id or row.get("partner_id"),
|
||||
}
|
||||
)
|
||||
rows.append(row)
|
||||
|
||||
stats_stmt = select(
|
||||
func.count(Client.id),
|
||||
func.sum(case((Client.status == "active", 1), else_=0)),
|
||||
func.sum(case((Client.status == "inactive", 1), else_=0)),
|
||||
func.sum(case((Client.status == "archived", 1), else_=0)),
|
||||
)
|
||||
|
||||
if not allow_all_clients:
|
||||
stats_stmt = stats_stmt.where(Client.tenant_id == tenant_id)
|
||||
if branch_id and not allow_cross_branch:
|
||||
stats_stmt = stats_stmt.where(Client.branch_id == branch_id)
|
||||
|
||||
if partner_id:
|
||||
stats_stmt = stats_stmt.where(Client.partner_id == partner_id)
|
||||
|
||||
total_all, active, inactive, archived = db.execute(stats_stmt).one()
|
||||
|
||||
pages = ceil(total / per_page) if per_page else 1
|
||||
return {
|
||||
"rows": rows,
|
||||
"meta": {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"pages": max(pages, 1),
|
||||
},
|
||||
"stats": {
|
||||
"total": int(total_all or 0),
|
||||
"active": int(active or 0),
|
||||
"inactive": int(inactive or 0),
|
||||
"archived": int(archived or 0),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_client_detail_payload(db: Session, client_id: int):
|
||||
assoc = ClientAssociation
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
Client,
|
||||
assoc.association_type.label("association_type"),
|
||||
assoc.firm_tenant_id.label("assoc_firm_tenant_id"),
|
||||
assoc.consultant_id.label("assoc_consultant_id"),
|
||||
assoc.partner_user_id.label("assoc_partner_user_id"),
|
||||
assoc.created_source.label("assoc_created_source"),
|
||||
)
|
||||
.outerjoin(assoc, assoc.client_id == Client.id)
|
||||
.where(Client.id == client_id)
|
||||
)
|
||||
|
||||
result = db.execute(stmt).one_or_none()
|
||||
if not result:
|
||||
return None
|
||||
|
||||
(
|
||||
client,
|
||||
association_type,
|
||||
assoc_firm_tenant_id,
|
||||
assoc_consultant_id,
|
||||
assoc_partner_user_id,
|
||||
assoc_created_source,
|
||||
) = result
|
||||
|
||||
row = {**client.__dict__}
|
||||
row.pop("_sa_instance_state", None)
|
||||
row.update(
|
||||
{
|
||||
"association_type": association_type,
|
||||
"assoc_firm_tenant_id": assoc_firm_tenant_id,
|
||||
"assoc_consultant_id": assoc_consultant_id,
|
||||
"assoc_partner_user_id": assoc_partner_user_id,
|
||||
"assoc_created_source": assoc_created_source,
|
||||
"effective_partner_id": assoc_partner_user_id or row.get("partner_id"),
|
||||
}
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def get_client_by_id(db: Session, client_id: int):
|
||||
return db.get(Client, client_id)
|
||||
|
||||
|
||||
def get_client_by_code(db: Session, *, tenant_id: int, client_code: str):
|
||||
return db.execute(
|
||||
select(Client).where(Client.tenant_id == tenant_id, Client.client_code == client_code)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_client_by_pan(db: Session, *, tenant_id: int, pan: str):
|
||||
return db.execute(
|
||||
select(Client).where(Client.tenant_id == tenant_id, Client.pan == pan)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_client_by_gstin(db: Session, *, tenant_id: int, gstin: str):
|
||||
return db.execute(
|
||||
select(Client).where(Client.tenant_id == tenant_id, Client.gstin == gstin)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def create_client(db: Session, payload: dict):
|
||||
row = Client(**payload)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
def update_client(db: Session, row: Client, payload: dict):
|
||||
for key, value in payload.items():
|
||||
setattr(row, key, value)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
def write_audit_log(db: Session, **kwargs):
|
||||
row = ClientAuditLog(**kwargs)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
def list_audit_logs(db: Session, *, client_id: int, limit: int = 50):
|
||||
stmt = (
|
||||
select(ClientAuditLog)
|
||||
.where(ClientAuditLog.client_id == client_id)
|
||||
.order_by(ClientAuditLog.created_at_utc.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return db.execute(stmt).scalars().all()
|
||||
|
||||
|
||||
def list_tenants(db: Session):
|
||||
return db.execute(
|
||||
select(Tenant).where(Tenant.is_active.is_(True)).order_by(Tenant.name.asc())
|
||||
).scalars().all()
|
||||
|
||||
|
||||
def list_branches_for_tenant(db: Session, tenant_id: int):
|
||||
stmt = (
|
||||
select(Branch)
|
||||
.where(Branch.tenant_id == tenant_id, Branch.is_active.is_(True))
|
||||
.order_by(Branch.name.asc())
|
||||
)
|
||||
return db.execute(stmt).scalars().all()
|
||||
|
||||
|
||||
def list_partners_for_scope(db: Session, *, tenant_id: int, branch_id: int | None = None):
|
||||
stmt = (
|
||||
select(User)
|
||||
.join(UserRole, UserRole.user_id == User.id)
|
||||
.join(Role, Role.id == UserRole.role_id)
|
||||
.where(
|
||||
Role.name == "Partner",
|
||||
User.tenant_id == tenant_id,
|
||||
User.is_active.is_(True),
|
||||
User.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(User.full_name.asc(), User.email.asc())
|
||||
)
|
||||
if branch_id:
|
||||
stmt = stmt.where(User.branch_id == branch_id)
|
||||
return db.execute(stmt).scalars().all()
|
||||
|
||||
|
||||
def get_branch(db: Session, branch_id: int):
|
||||
return db.execute(
|
||||
select(Branch).where(Branch.id == branch_id, Branch.is_active.is_(True))
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_partner(db: Session, partner_id: int):
|
||||
return db.execute(
|
||||
select(User).where(User.id == partner_id, User.is_active.is_(True), User.deleted_at.is_(None))
|
||||
).scalar_one_or_none()
|
||||
|
||||
def list_all_branches(db: Session):
|
||||
stmt = (
|
||||
select(
|
||||
Branch,
|
||||
Tenant.name.label("tenant_name"),
|
||||
)
|
||||
.join(Tenant, Tenant.id == Branch.tenant_id)
|
||||
.where(Branch.is_active.is_(True))
|
||||
.order_by(Tenant.name.asc(), Branch.name.asc())
|
||||
)
|
||||
|
||||
rows = []
|
||||
for branch, tenant_name in db.execute(stmt).all():
|
||||
branch.tenant_name = tenant_name
|
||||
rows.append(branch)
|
||||
return rows
|
||||
|
||||
def list_all_partners(db: Session):
|
||||
stmt = (
|
||||
select(
|
||||
User,
|
||||
Tenant.name.label("tenant_name"),
|
||||
)
|
||||
.join(UserRole, UserRole.user_id == User.id)
|
||||
.join(Role, Role.id == UserRole.role_id)
|
||||
.join(Tenant, Tenant.id == User.tenant_id, isouter=True)
|
||||
.where(
|
||||
Role.name == "Partner",
|
||||
User.is_active.is_(True),
|
||||
User.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(Tenant.name.asc(), User.full_name.asc(), User.email.asc())
|
||||
)
|
||||
|
||||
rows = []
|
||||
for user, tenant_name in db.execute(stmt).all():
|
||||
user.tenant_name = tenant_name
|
||||
rows.append(user)
|
||||
return rows
|
||||
|
||||
def list_all_branches(db: Session):
|
||||
stmt = (
|
||||
select(
|
||||
Branch,
|
||||
Tenant.name.label("tenant_name"),
|
||||
)
|
||||
.join(Tenant, Tenant.id == Branch.tenant_id)
|
||||
.where(Branch.is_active.is_(True))
|
||||
.order_by(Tenant.name.asc(), Branch.name.asc())
|
||||
)
|
||||
|
||||
rows = []
|
||||
for branch, tenant_name in db.execute(stmt).all():
|
||||
branch.tenant_name = tenant_name
|
||||
rows.append(branch)
|
||||
return rows
|
||||
|
||||
|
||||
def list_all_partners(db: Session):
|
||||
stmt = (
|
||||
select(
|
||||
User,
|
||||
Tenant.name.label("tenant_name"),
|
||||
)
|
||||
.join(UserRole, UserRole.user_id == User.id)
|
||||
.join(Role, Role.id == UserRole.role_id)
|
||||
.join(Tenant, Tenant.id == User.tenant_id, isouter=True)
|
||||
.where(
|
||||
Role.name == "Partner",
|
||||
User.is_active.is_(True),
|
||||
User.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(Tenant.name.asc(), User.full_name.asc(), User.email.asc())
|
||||
)
|
||||
|
||||
rows = []
|
||||
for user, tenant_name in db.execute(stmt).all():
|
||||
user.tenant_name = tenant_name
|
||||
rows.append(user)
|
||||
return rows
|
||||
|
||||
|
||||
def get_portal_client_for_user(db: Session, *, user: User):
|
||||
email = (getattr(user, "email", "") or "").strip().lower()
|
||||
tenant_id = getattr(user, "tenant_id", None)
|
||||
if not email or not tenant_id:
|
||||
return None
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
Client,
|
||||
User.full_name.label("partner_name"),
|
||||
Branch.name.label("branch_name"),
|
||||
Tenant.name.label("tenant_name"),
|
||||
)
|
||||
.join(User, User.id == Client.partner_id, isouter=True)
|
||||
.join(Branch, Branch.id == Client.branch_id, isouter=True)
|
||||
.join(Tenant, Tenant.id == Client.tenant_id, isouter=True)
|
||||
.where(
|
||||
Client.tenant_id == tenant_id,
|
||||
Client.is_archived.is_(False),
|
||||
or_(Client.email.ilike(email), Client.alternate_email.ilike(email)),
|
||||
)
|
||||
.order_by(
|
||||
case((Client.status == "active", 0), else_=1),
|
||||
Client.client_name.asc(),
|
||||
Client.id.asc(),
|
||||
)
|
||||
)
|
||||
result = db.execute(stmt).first()
|
||||
if not result:
|
||||
return None
|
||||
|
||||
client, partner_name, branch_name, tenant_name = result
|
||||
row = {**client.__dict__}
|
||||
row.pop("_sa_instance_state", None)
|
||||
row.update(
|
||||
{
|
||||
"partner_name": partner_name,
|
||||
"branch_name": branch_name,
|
||||
"tenant_name": tenant_name,
|
||||
}
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def get_user_by_email(db: Session, *, email: str, exclude_user_id: int | None = None):
|
||||
email_clean = (email or "").strip().lower()
|
||||
if not email_clean:
|
||||
return None
|
||||
stmt = select(User).where(User.email.ilike(email_clean), User.deleted_at.is_(None))
|
||||
if exclude_user_id:
|
||||
stmt = stmt.where(User.id != exclude_user_id)
|
||||
return db.execute(stmt).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_tenant(db: Session, tenant_id: int):
|
||||
return db.execute(select(Tenant).where(Tenant.id == tenant_id, Tenant.is_active.is_(True))).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_partner_for_tenant(db: Session, *, partner_user_id: int, tenant_id: int):
|
||||
stmt = (
|
||||
select(User)
|
||||
.join(UserRole, UserRole.user_id == User.id)
|
||||
.join(Role, Role.id == UserRole.role_id)
|
||||
.where(
|
||||
User.id == partner_user_id,
|
||||
User.tenant_id == tenant_id,
|
||||
User.is_active.is_(True),
|
||||
User.deleted_at.is_(None),
|
||||
Role.name == "Partner",
|
||||
)
|
||||
)
|
||||
return db.execute(stmt).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_role_by_name(db: Session, role_name: str):
|
||||
return db.execute(select(Role).where(Role.name == role_name, Role.is_active.is_(True))).scalar_one_or_none()
|
||||
|
||||
|
||||
def create_portal_user(db: Session, *, email: str, full_name: str, tenant_id: int, branch_id: int, password: str):
|
||||
row = User(
|
||||
email=(email or '').strip().lower(),
|
||||
full_name=(full_name or '').strip(),
|
||||
password_hash=hash_password(password),
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
is_active=True,
|
||||
allow_login=True,
|
||||
is_locked=False,
|
||||
must_change_password=False,
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
def ensure_user_role(db: Session, *, user_id: int, role_id: int):
|
||||
existing = db.execute(select(UserRole).where(UserRole.user_id == user_id, UserRole.role_id == role_id)).scalar_one_or_none()
|
||||
if existing:
|
||||
return existing
|
||||
row = UserRole(user_id=user_id, role_id=role_id)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
@@ -0,0 +1,386 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, field_validator, model_validator
|
||||
|
||||
from app.modules.clients.constants import (
|
||||
CLIENT_CATEGORY_OPTIONS,
|
||||
CLIENT_SORT_FIELDS,
|
||||
CLIENT_STATUS,
|
||||
CLIENT_TYPES,
|
||||
ENGAGEMENT_MODES,
|
||||
RISK_CATEGORIES,
|
||||
)
|
||||
from app.modules.clients.utils import GSTIN_RE, MOBILE_RE, PAN_RE, PIN_RE, TAN_RE, normalize_text, normalize_upper
|
||||
|
||||
|
||||
class ClientBase(BaseModel):
|
||||
tenant_id: int
|
||||
branch_id: int
|
||||
partner_id: Optional[int] = None
|
||||
default_review_partner_user_id: Optional[int] = None
|
||||
engagement_mode: str = "internal_managed"
|
||||
client_code: str
|
||||
client_name: str
|
||||
trade_name: Optional[str] = None
|
||||
client_type: str = "Other"
|
||||
pan: Optional[str] = None
|
||||
gstin: Optional[str] = None
|
||||
tan: Optional[str] = None
|
||||
cin_llpin: Optional[str] = None
|
||||
msme_no: Optional[str] = None
|
||||
iec_code: Optional[str] = None
|
||||
contact_person_name: Optional[str] = None
|
||||
contact_person_designation: Optional[str] = None
|
||||
mobile: Optional[str] = None
|
||||
alternate_mobile: Optional[str] = None
|
||||
email: Optional[EmailStr] = None
|
||||
alternate_email: Optional[EmailStr] = None
|
||||
address_line_1: Optional[str] = None
|
||||
address_line_2: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
pincode: Optional[str] = None
|
||||
country: Optional[str] = "India"
|
||||
status: str = "active"
|
||||
client_category: Optional[str] = None
|
||||
risk_category: Optional[str] = None
|
||||
onboarding_date: Optional[date] = None
|
||||
closing_date: Optional[date] = None
|
||||
notes: Optional[str] = None
|
||||
gst_applicable: bool = False
|
||||
income_tax_applicable: bool = False
|
||||
tds_applicable: bool = False
|
||||
roc_applicable: bool = False
|
||||
audit_applicable: bool = False
|
||||
pf_applicable: bool = False
|
||||
esi_applicable: bool = False
|
||||
professional_tax_applicable: bool = False
|
||||
payroll_applicable: bool = False
|
||||
msme_applicable: bool = False
|
||||
import_export_applicable: bool = False
|
||||
|
||||
@field_validator("client_code", "client_name", mode="before")
|
||||
@classmethod
|
||||
def required_text(cls, value):
|
||||
value = normalize_text(value)
|
||||
if not value:
|
||||
raise ValueError("This field is required.")
|
||||
return value
|
||||
|
||||
@field_validator(
|
||||
"trade_name", "cin_llpin", "msme_no", "iec_code", "contact_person_name", "contact_person_designation",
|
||||
"address_line_1", "address_line_2", "city", "state", "country", "client_category", "risk_category", "notes",
|
||||
mode="before",
|
||||
)
|
||||
@classmethod
|
||||
def clean_text(cls, value):
|
||||
return normalize_text(value)
|
||||
|
||||
@field_validator("pan", "gstin", "tan", mode="before")
|
||||
@classmethod
|
||||
def uppercase_codes(cls, value):
|
||||
return normalize_upper(value)
|
||||
|
||||
@field_validator("engagement_mode", mode="before")
|
||||
@classmethod
|
||||
def clean_engagement_mode(cls, value):
|
||||
value = normalize_text(value) or "internal_managed"
|
||||
return value.lower()
|
||||
|
||||
@field_validator("engagement_mode")
|
||||
@classmethod
|
||||
def validate_engagement_mode(cls, value):
|
||||
if value not in ENGAGEMENT_MODES:
|
||||
raise ValueError("Invalid engagement mode.")
|
||||
return value
|
||||
|
||||
@field_validator("mobile", "alternate_mobile", mode="before")
|
||||
@classmethod
|
||||
def clean_mobile(cls, value):
|
||||
value = normalize_text(value)
|
||||
if value is None:
|
||||
return None
|
||||
value = value.replace(" ", "").replace("-", "")
|
||||
if value.startswith("+91"):
|
||||
value = value[3:]
|
||||
return value
|
||||
|
||||
@field_validator("pan")
|
||||
@classmethod
|
||||
def validate_pan(cls, value):
|
||||
if value and not PAN_RE.match(value):
|
||||
raise ValueError("Invalid PAN format.")
|
||||
return value
|
||||
|
||||
@field_validator("gstin")
|
||||
@classmethod
|
||||
def validate_gstin(cls, value):
|
||||
if value and not GSTIN_RE.match(value):
|
||||
raise ValueError("Invalid GSTIN format.")
|
||||
return value
|
||||
|
||||
@field_validator("tan")
|
||||
@classmethod
|
||||
def validate_tan(cls, value):
|
||||
if value and not TAN_RE.match(value):
|
||||
raise ValueError("Invalid TAN format.")
|
||||
return value
|
||||
|
||||
@field_validator("mobile", "alternate_mobile")
|
||||
@classmethod
|
||||
def validate_mobile(cls, value):
|
||||
if value and not MOBILE_RE.match(value):
|
||||
raise ValueError("Mobile number must be a valid 10-digit Indian mobile.")
|
||||
return value
|
||||
|
||||
@field_validator("pincode", mode="before")
|
||||
@classmethod
|
||||
def clean_pincode(cls, value):
|
||||
return normalize_text(value)
|
||||
|
||||
@field_validator("pincode")
|
||||
@classmethod
|
||||
def validate_pincode(cls, value):
|
||||
if value and not PIN_RE.match(value):
|
||||
raise ValueError("Pincode must be a valid 6-digit code.")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_dates_and_assignment(self):
|
||||
if self.onboarding_date and self.closing_date and self.closing_date < self.onboarding_date:
|
||||
raise ValueError("Closing date cannot be earlier than onboarding date.")
|
||||
if self.engagement_mode == "internal_managed" and not self.partner_id:
|
||||
raise ValueError("Partner is required for internal managed clients.")
|
||||
return self
|
||||
|
||||
|
||||
class ClientCreate(ClientBase):
|
||||
pass
|
||||
|
||||
|
||||
class ClientUpdate(BaseModel):
|
||||
tenant_id: Optional[int] = None
|
||||
branch_id: Optional[int] = None
|
||||
partner_id: Optional[int] = None
|
||||
default_review_partner_user_id: Optional[int] = None
|
||||
engagement_mode: Optional[str] = None
|
||||
client_name: Optional[str] = None
|
||||
trade_name: Optional[str] = None
|
||||
client_type: Optional[str] = None
|
||||
pan: Optional[str] = None
|
||||
gstin: Optional[str] = None
|
||||
tan: Optional[str] = None
|
||||
cin_llpin: Optional[str] = None
|
||||
msme_no: Optional[str] = None
|
||||
iec_code: Optional[str] = None
|
||||
contact_person_name: Optional[str] = None
|
||||
contact_person_designation: Optional[str] = None
|
||||
mobile: Optional[str] = None
|
||||
alternate_mobile: Optional[str] = None
|
||||
email: Optional[EmailStr] = None
|
||||
alternate_email: Optional[EmailStr] = None
|
||||
address_line_1: Optional[str] = None
|
||||
address_line_2: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
pincode: Optional[str] = None
|
||||
country: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
client_category: Optional[str] = None
|
||||
risk_category: Optional[str] = None
|
||||
onboarding_date: Optional[date] = None
|
||||
closing_date: Optional[date] = None
|
||||
notes: Optional[str] = None
|
||||
gst_applicable: Optional[bool] = None
|
||||
income_tax_applicable: Optional[bool] = None
|
||||
tds_applicable: Optional[bool] = None
|
||||
roc_applicable: Optional[bool] = None
|
||||
audit_applicable: Optional[bool] = None
|
||||
pf_applicable: Optional[bool] = None
|
||||
esi_applicable: Optional[bool] = None
|
||||
professional_tax_applicable: Optional[bool] = None
|
||||
payroll_applicable: Optional[bool] = None
|
||||
msme_applicable: Optional[bool] = None
|
||||
import_export_applicable: Optional[bool] = None
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@field_validator(
|
||||
"client_name", "trade_name", "cin_llpin", "msme_no", "iec_code", "contact_person_name", "contact_person_designation",
|
||||
"address_line_1", "address_line_2", "city", "state", "country", "client_category", "risk_category", "notes",
|
||||
mode="before",
|
||||
)
|
||||
@classmethod
|
||||
def clean_text(cls, value):
|
||||
return normalize_text(value)
|
||||
|
||||
@field_validator("pan", "gstin", "tan", mode="before")
|
||||
@classmethod
|
||||
def uppercase_codes(cls, value):
|
||||
return normalize_upper(value)
|
||||
|
||||
@field_validator("engagement_mode", mode="before")
|
||||
@classmethod
|
||||
def clean_engagement_mode(cls, value):
|
||||
if value is None:
|
||||
return None
|
||||
value = normalize_text(value) or None
|
||||
return value.lower() if value else None
|
||||
|
||||
@field_validator("engagement_mode")
|
||||
@classmethod
|
||||
def validate_engagement_mode(cls, value):
|
||||
if value is not None and value not in ENGAGEMENT_MODES:
|
||||
raise ValueError("Invalid engagement mode.")
|
||||
return value
|
||||
|
||||
@field_validator("mobile", "alternate_mobile", mode="before")
|
||||
@classmethod
|
||||
def clean_mobile(cls, value):
|
||||
value = normalize_text(value)
|
||||
if value is None:
|
||||
return None
|
||||
value = value.replace(" ", "").replace("-", "")
|
||||
if value.startswith("+91"):
|
||||
value = value[3:]
|
||||
return value
|
||||
|
||||
@field_validator("pan")
|
||||
@classmethod
|
||||
def validate_pan(cls, value):
|
||||
if value and not PAN_RE.match(value):
|
||||
raise ValueError("Invalid PAN format.")
|
||||
return value
|
||||
|
||||
@field_validator("gstin")
|
||||
@classmethod
|
||||
def validate_gstin(cls, value):
|
||||
if value and not GSTIN_RE.match(value):
|
||||
raise ValueError("Invalid GSTIN format.")
|
||||
return value
|
||||
|
||||
@field_validator("tan")
|
||||
@classmethod
|
||||
def validate_tan(cls, value):
|
||||
if value and not TAN_RE.match(value):
|
||||
raise ValueError("Invalid TAN format.")
|
||||
return value
|
||||
|
||||
@field_validator("mobile", "alternate_mobile")
|
||||
@classmethod
|
||||
def validate_mobile(cls, value):
|
||||
if value and not MOBILE_RE.match(value):
|
||||
raise ValueError("Mobile number must be a valid 10-digit Indian mobile.")
|
||||
return value
|
||||
|
||||
@field_validator("pincode", mode="before")
|
||||
@classmethod
|
||||
def clean_pincode(cls, value):
|
||||
return normalize_text(value)
|
||||
|
||||
@field_validator("pincode")
|
||||
@classmethod
|
||||
def validate_pincode(cls, value):
|
||||
if value and not PIN_RE.match(value):
|
||||
raise ValueError("Pincode must be a valid 6-digit code.")
|
||||
return value
|
||||
|
||||
|
||||
class ClientOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
tenant_id: int
|
||||
branch_id: int
|
||||
partner_id: Optional[int] = None
|
||||
default_review_partner_user_id: Optional[int] = None
|
||||
engagement_mode: str
|
||||
client_code: str
|
||||
client_name: str
|
||||
trade_name: Optional[str] = None
|
||||
client_type: str
|
||||
pan: Optional[str] = None
|
||||
gstin: Optional[str] = None
|
||||
tan: Optional[str] = None
|
||||
cin_llpin: Optional[str] = None
|
||||
msme_no: Optional[str] = None
|
||||
iec_code: Optional[str] = None
|
||||
contact_person_name: Optional[str] = None
|
||||
contact_person_designation: Optional[str] = None
|
||||
mobile: Optional[str] = None
|
||||
alternate_mobile: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
alternate_email: Optional[str] = None
|
||||
address_line_1: Optional[str] = None
|
||||
address_line_2: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
pincode: Optional[str] = None
|
||||
country: Optional[str] = None
|
||||
status: str
|
||||
client_category: Optional[str] = None
|
||||
risk_category: Optional[str] = None
|
||||
onboarding_date: Optional[date] = None
|
||||
closing_date: Optional[date] = None
|
||||
notes: Optional[str] = None
|
||||
gst_applicable: bool
|
||||
income_tax_applicable: bool
|
||||
tds_applicable: bool
|
||||
roc_applicable: bool
|
||||
audit_applicable: bool
|
||||
pf_applicable: bool
|
||||
esi_applicable: bool
|
||||
professional_tax_applicable: bool
|
||||
payroll_applicable: bool
|
||||
msme_applicable: bool
|
||||
import_export_applicable: bool
|
||||
is_active: bool
|
||||
is_archived: bool
|
||||
created_at_utc: datetime
|
||||
updated_at_utc: datetime
|
||||
|
||||
|
||||
class ClientListRow(ClientOut):
|
||||
partner_name: Optional[str] = None
|
||||
branch_name: Optional[str] = None
|
||||
tenant_name: Optional[str] = None
|
||||
|
||||
|
||||
class ClientAuditLogOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
client_id: int
|
||||
tenant_id: int
|
||||
branch_id: int
|
||||
actor_user_id: Optional[int] = None
|
||||
action: str
|
||||
summary: str
|
||||
payload_json: Optional[dict] = None
|
||||
created_at_utc: datetime
|
||||
|
||||
|
||||
class PaginationMeta(BaseModel):
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
|
||||
class ClientFilterOptions(BaseModel):
|
||||
client_types: list[str]
|
||||
client_statuses: list[str]
|
||||
client_categories: list[str]
|
||||
risk_categories: list[str]
|
||||
|
||||
|
||||
class ClientListStats(BaseModel):
|
||||
total: int = 0
|
||||
active: int = 0
|
||||
inactive: int = 0
|
||||
archived: int = 0
|
||||
|
||||
|
||||
class ClientListResponse(BaseModel):
|
||||
rows: list[ClientOut]
|
||||
meta: PaginationMeta
|
||||
stats: ClientListStats | None = None
|
||||
filter_options: ClientFilterOptions | None = None
|
||||
@@ -0,0 +1,452 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.modules.clients import repository
|
||||
from app.core.security.passwords import hash_password
|
||||
from app.modules.clients.association_admin_service import (
|
||||
ensure_active_association,
|
||||
update_association_fields,
|
||||
)
|
||||
from app.modules.clients.constants import (
|
||||
CLIENT_CATEGORY_OPTIONS,
|
||||
CLIENT_STATUS,
|
||||
CLIENT_TYPES,
|
||||
RISK_CATEGORIES,
|
||||
)
|
||||
|
||||
|
||||
def _payload_from_schema(data):
|
||||
return data.model_dump(exclude_none=True) if hasattr(data, "model_dump") else data.dict(exclude_none=True)
|
||||
|
||||
|
||||
|
||||
|
||||
def _ensure_portal_passwords(email: str | None, portal_password: str | None, portal_password_confirm: str | None, *, required: bool):
|
||||
email_clean = (email or '').strip().lower()
|
||||
pw = (portal_password or '').strip()
|
||||
pw2 = (portal_password_confirm or '').strip()
|
||||
if not required and not pw and not pw2:
|
||||
return email_clean, None
|
||||
if not email_clean:
|
||||
raise HTTPException(status_code=400, detail="Email is required to create the client frontend login.")
|
||||
if len(pw) < 8:
|
||||
raise HTTPException(status_code=400, detail="Portal password must be at least 8 characters.")
|
||||
if pw != pw2:
|
||||
raise HTTPException(status_code=400, detail="Portal password and confirm password do not match.")
|
||||
return email_clean, pw
|
||||
|
||||
|
||||
def _sync_client_portal_user(db, *, row, portal_password: str | None = None, portal_password_confirm: str | None = None):
|
||||
email_clean, pw = _ensure_portal_passwords(
|
||||
getattr(row, 'email', None),
|
||||
portal_password,
|
||||
portal_password_confirm,
|
||||
required=bool(portal_password or portal_password_confirm or not getattr(row, 'portal_user_id', None)),
|
||||
)
|
||||
|
||||
if not pw:
|
||||
return row
|
||||
|
||||
existing_user = repository.get_user_by_email(db, email=email_clean, exclude_user_id=getattr(row, 'portal_user_id', None))
|
||||
if existing_user:
|
||||
raise HTTPException(status_code=400, detail="That email is already used by another login.")
|
||||
|
||||
if row.portal_user_id:
|
||||
user = db.get(repository.User, int(row.portal_user_id))
|
||||
if not user:
|
||||
row = repository.update_client(db, row, {'portal_user_id': None})
|
||||
else:
|
||||
user.email = email_clean
|
||||
user.full_name = (row.client_name or '').strip()
|
||||
user.password_hash = hash_password(pw)
|
||||
user.tenant_id = row.tenant_id
|
||||
user.branch_id = row.branch_id
|
||||
user.is_active = True
|
||||
user.allow_login = True
|
||||
user.is_locked = False
|
||||
user.must_change_password = False
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return row
|
||||
|
||||
if not row.portal_user_id:
|
||||
user = repository.create_portal_user(
|
||||
db,
|
||||
email=email_clean,
|
||||
full_name=row.client_name,
|
||||
tenant_id=row.tenant_id,
|
||||
branch_id=row.branch_id,
|
||||
password=pw,
|
||||
)
|
||||
role = repository.get_role_by_name(db, 'Client')
|
||||
if not role:
|
||||
raise HTTPException(status_code=500, detail='Client role is not available.')
|
||||
repository.ensure_user_role(db, user_id=user.id, role_id=role.id)
|
||||
row = repository.update_client(db, row, {'portal_user_id': user.id})
|
||||
return row
|
||||
|
||||
|
||||
def _validate_scope_for_create(data, scope, actor_user_id):
|
||||
if scope.own_only:
|
||||
data.partner_id = scope.locked_partner_id or actor_user_id
|
||||
if not scope.allow_cross_tenant:
|
||||
data.tenant_id = scope.tenant_id
|
||||
if not scope.allow_cross_branch and scope.branch_id:
|
||||
data.branch_id = scope.branch_id
|
||||
return data
|
||||
|
||||
|
||||
def _validate_scope_for_edit(data, scope, actor_user_id, *, existing_row, current_user_roles):
|
||||
role_names = {str(r).lower() for r in current_user_roles}
|
||||
|
||||
if data.tenant_id is None:
|
||||
data.tenant_id = existing_row.tenant_id
|
||||
if data.branch_id is None:
|
||||
data.branch_id = existing_row.branch_id
|
||||
if data.partner_id is None:
|
||||
data.partner_id = existing_row.partner_id
|
||||
|
||||
if "partner" in role_names and data.partner_id and data.partner_id != actor_user_id:
|
||||
raise HTTPException(status_code=400, detail="Partner users cannot assign clients to another partner.")
|
||||
|
||||
if "consultant" in role_names and data.partner_id and data.partner_id != existing_row.partner_id:
|
||||
raise HTTPException(status_code=400, detail="Consultants cannot assign or change partner mapping.")
|
||||
|
||||
if "firm admin" in role_names:
|
||||
if existing_row.tenant_id != scope.tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Firm Admin can only manage clients within own firm.")
|
||||
data.tenant_id = scope.tenant_id
|
||||
if not scope.allow_cross_branch and data.branch_id != scope.branch_id:
|
||||
raise HTTPException(status_code=403, detail="Branch change is not allowed in current scope.")
|
||||
return data
|
||||
|
||||
if "system admin" in role_names:
|
||||
return data
|
||||
|
||||
if scope.own_only:
|
||||
data.partner_id = scope.locked_partner_id or actor_user_id
|
||||
if existing_row.partner_id != actor_user_id:
|
||||
raise HTTPException(status_code=403, detail="You can only edit your own associated clients.")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _write_association_from_client(db, client_row, *, actor_user_id, current_user_roles):
|
||||
roles = {str(r).lower() for r in current_user_roles}
|
||||
ensure_active_association(db, client_row.id)
|
||||
|
||||
if "system admin" in roles:
|
||||
return update_association_fields(
|
||||
db,
|
||||
client_row.id,
|
||||
association_type="firm" if client_row.tenant_id else "self_service_unassigned",
|
||||
firm_tenant_id=client_row.tenant_id,
|
||||
partner_user_id=client_row.partner_id,
|
||||
created_source="system_admin",
|
||||
)
|
||||
|
||||
if "firm admin" in roles:
|
||||
return update_association_fields(
|
||||
db,
|
||||
client_row.id,
|
||||
association_type="firm",
|
||||
firm_tenant_id=client_row.tenant_id,
|
||||
partner_user_id=client_row.partner_id,
|
||||
created_source="firm_admin",
|
||||
)
|
||||
|
||||
if "partner" in roles:
|
||||
return update_association_fields(
|
||||
db,
|
||||
client_row.id,
|
||||
association_type="firm",
|
||||
firm_tenant_id=client_row.tenant_id,
|
||||
partner_user_id=actor_user_id,
|
||||
created_source="partner",
|
||||
)
|
||||
|
||||
if "consultant" in roles:
|
||||
return update_association_fields(
|
||||
db,
|
||||
client_row.id,
|
||||
association_type="consultant",
|
||||
consultant_id=actor_user_id,
|
||||
created_source="consultant",
|
||||
)
|
||||
|
||||
return update_association_fields(
|
||||
db,
|
||||
client_row.id,
|
||||
association_type="self_service_unassigned",
|
||||
created_source="self_service",
|
||||
)
|
||||
|
||||
|
||||
def create_client_service(db, *, data, actor_user_id: int, scope, current_user_roles=None, portal_password: str | None = None, portal_password_confirm: str | None = None):
|
||||
current_user_roles = current_user_roles or []
|
||||
data = _validate_scope_for_create(data, scope, actor_user_id)
|
||||
|
||||
existing = repository.get_client_by_code(db, tenant_id=data.tenant_id, client_code=data.client_code)
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Client code already exists.")
|
||||
|
||||
if data.pan:
|
||||
existing_pan = repository.get_client_by_pan(db, tenant_id=data.tenant_id, pan=data.pan)
|
||||
if existing_pan:
|
||||
raise HTTPException(status_code=400, detail="PAN already exists for another client in this tenant.")
|
||||
|
||||
if data.gstin:
|
||||
existing_gstin = repository.get_client_by_gstin(db, tenant_id=data.tenant_id, gstin=data.gstin)
|
||||
if existing_gstin:
|
||||
raise HTTPException(status_code=400, detail="GSTIN already exists for another client in this tenant.")
|
||||
|
||||
payload = _payload_from_schema(data)
|
||||
row = repository.create_client(db, payload)
|
||||
row = _sync_client_portal_user(db, row=row, portal_password=portal_password, portal_password_confirm=portal_password_confirm)
|
||||
_write_association_from_client(db, row, actor_user_id=actor_user_id, current_user_roles=current_user_roles)
|
||||
|
||||
repository.write_audit_log(
|
||||
db,
|
||||
client_id=row.id,
|
||||
tenant_id=row.tenant_id,
|
||||
branch_id=row.branch_id,
|
||||
actor_user_id=actor_user_id,
|
||||
action="created",
|
||||
summary="Client created with association sync.",
|
||||
payload_json={"client_id": row.id, "partner_id": row.partner_id},
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def update_client_service(db, *, row, data, actor_user_id: int, scope, current_user_roles=None, portal_password: str | None = None, portal_password_confirm: str | None = None):
|
||||
current_user_roles = current_user_roles or []
|
||||
data = _validate_scope_for_edit(
|
||||
data,
|
||||
scope,
|
||||
actor_user_id,
|
||||
existing_row=row,
|
||||
current_user_roles=current_user_roles,
|
||||
)
|
||||
|
||||
payload = _payload_from_schema(data)
|
||||
|
||||
if payload.get("pan"):
|
||||
existing_pan = repository.get_client_by_pan(db, tenant_id=payload["tenant_id"], pan=payload["pan"])
|
||||
if existing_pan and existing_pan.id != row.id:
|
||||
raise HTTPException(status_code=400, detail="PAN already exists for another client in this tenant.")
|
||||
|
||||
if payload.get("gstin"):
|
||||
existing_gstin = repository.get_client_by_gstin(db, tenant_id=payload["tenant_id"], gstin=payload["gstin"])
|
||||
if existing_gstin and existing_gstin.id != row.id:
|
||||
raise HTTPException(status_code=400, detail="GSTIN already exists for another client in this tenant.")
|
||||
|
||||
updated = repository.update_client(db, row, payload)
|
||||
updated = _sync_client_portal_user(db, row=updated, portal_password=portal_password, portal_password_confirm=portal_password_confirm)
|
||||
_write_association_from_client(db, updated, actor_user_id=actor_user_id, current_user_roles=current_user_roles)
|
||||
|
||||
repository.write_audit_log(
|
||||
db,
|
||||
client_id=updated.id,
|
||||
tenant_id=updated.tenant_id,
|
||||
branch_id=updated.branch_id,
|
||||
actor_user_id=actor_user_id,
|
||||
action="updated",
|
||||
summary="Client updated with association sync.",
|
||||
payload_json={"client_id": updated.id, "partner_id": updated.partner_id},
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
def get_client_or_404(
|
||||
db,
|
||||
*,
|
||||
client_id: int,
|
||||
tenant_id: int,
|
||||
branch_id: int | None,
|
||||
allow_cross_branch: bool,
|
||||
allow_all_clients: bool = False,
|
||||
):
|
||||
row = repository.get_client_by_id(db, client_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Client not found.")
|
||||
if not allow_all_clients and row.tenant_id != tenant_id:
|
||||
raise HTTPException(status_code=404, detail="Client not found in current tenant.")
|
||||
if not allow_all_clients and not allow_cross_branch and branch_id and row.branch_id != branch_id:
|
||||
raise HTTPException(status_code=404, detail="Client not found in current branch.")
|
||||
return row
|
||||
|
||||
|
||||
def list_clients_payload(db, **kwargs):
|
||||
return repository.list_clients(db, **kwargs)
|
||||
|
||||
|
||||
def list_client_audit_logs(db, *, row, limit: int = 50):
|
||||
return repository.list_audit_logs(db, client_id=row.id, limit=limit)
|
||||
|
||||
|
||||
def deactivate_client_service(db, *, row, actor_user_id: int):
|
||||
row = repository.update_client(db, row, {"status": "inactive"})
|
||||
repository.write_audit_log(
|
||||
db,
|
||||
client_id=row.id,
|
||||
tenant_id=row.tenant_id,
|
||||
branch_id=row.branch_id,
|
||||
actor_user_id=actor_user_id,
|
||||
action="deactivated",
|
||||
summary="Client deactivated.",
|
||||
payload_json=None,
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def activate_client_service(db, *, row, actor_user_id: int):
|
||||
row = repository.update_client(db, row, {"status": "active"})
|
||||
repository.write_audit_log(
|
||||
db,
|
||||
client_id=row.id,
|
||||
tenant_id=row.tenant_id,
|
||||
branch_id=row.branch_id,
|
||||
actor_user_id=actor_user_id,
|
||||
action="activated",
|
||||
summary="Client activated.",
|
||||
payload_json=None,
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def archive_client_service(db, *, row, actor_user_id: int):
|
||||
row = repository.update_client(db, row, {"status": "archived", "is_archived": True})
|
||||
repository.write_audit_log(
|
||||
db,
|
||||
client_id=row.id,
|
||||
tenant_id=row.tenant_id,
|
||||
branch_id=row.branch_id,
|
||||
actor_user_id=actor_user_id,
|
||||
action="archived",
|
||||
summary="Client archived.",
|
||||
payload_json=None,
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def restore_client_service(db, *, row, actor_user_id: int):
|
||||
row = repository.update_client(db, row, {"status": "active", "is_archived": False})
|
||||
repository.write_audit_log(
|
||||
db,
|
||||
client_id=row.id,
|
||||
tenant_id=row.tenant_id,
|
||||
branch_id=row.branch_id,
|
||||
actor_user_id=actor_user_id,
|
||||
action="restored",
|
||||
summary="Client restored from archive.",
|
||||
payload_json=None,
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def export_clients_csv(payload: dict) -> str:
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(
|
||||
[
|
||||
"client_code",
|
||||
"client_name",
|
||||
"client_type",
|
||||
"status",
|
||||
"pan",
|
||||
"gstin",
|
||||
"partner",
|
||||
"association_type",
|
||||
"association_source",
|
||||
]
|
||||
)
|
||||
for row in payload.get("rows", []):
|
||||
writer.writerow(
|
||||
[
|
||||
row.get("client_code"),
|
||||
row.get("client_name"),
|
||||
row.get("client_type"),
|
||||
row.get("status"),
|
||||
row.get("pan"),
|
||||
row.get("gstin"),
|
||||
row.get("partner_name") or row.get("effective_partner_id"),
|
||||
row.get("association_type"),
|
||||
row.get("assoc_created_source"),
|
||||
]
|
||||
)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def get_filter_options():
|
||||
return {
|
||||
"client_types": CLIENT_TYPES,
|
||||
"client_statuses": CLIENT_STATUS,
|
||||
"client_categories": CLIENT_CATEGORY_OPTIONS,
|
||||
"risk_categories": RISK_CATEGORIES,
|
||||
}
|
||||
|
||||
|
||||
SELF_SERVICE_EDITABLE_FIELDS = {
|
||||
"client_name",
|
||||
"trade_name",
|
||||
"contact_person_name",
|
||||
"contact_person_designation",
|
||||
"mobile",
|
||||
"alternate_mobile",
|
||||
"email",
|
||||
"alternate_email",
|
||||
"address_line_1",
|
||||
"address_line_2",
|
||||
"city",
|
||||
"state",
|
||||
"pincode",
|
||||
"country",
|
||||
"notes",
|
||||
}
|
||||
|
||||
|
||||
def update_client_self_profile_service(db, *, row, data, current_user):
|
||||
payload = _payload_from_schema(data)
|
||||
payload = {key: value for key, value in payload.items() if key in SELF_SERVICE_EDITABLE_FIELDS}
|
||||
|
||||
new_email = (payload.get("email") or "").strip().lower()
|
||||
if new_email:
|
||||
existing_user = repository.get_user_by_email(db, email=new_email, exclude_user_id=int(current_user.id))
|
||||
if existing_user:
|
||||
raise HTTPException(status_code=400, detail="That email is already used by another login.")
|
||||
|
||||
updated = repository.update_client(db, row, payload)
|
||||
|
||||
if new_email and new_email != (getattr(current_user, "email", "") or "").strip().lower():
|
||||
current_user.email = new_email
|
||||
db.add(current_user)
|
||||
db.commit()
|
||||
db.refresh(current_user)
|
||||
|
||||
repository.write_audit_log(
|
||||
db,
|
||||
client_id=updated.id,
|
||||
tenant_id=updated.tenant_id,
|
||||
branch_id=updated.branch_id,
|
||||
actor_user_id=current_user.id,
|
||||
action="client_self_profile_updated",
|
||||
summary="Client updated own contact profile.",
|
||||
payload_json={"fields": sorted(payload.keys())},
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
def reset_client_portal_password_service(db, *, current_user, new_password: str):
|
||||
if len((new_password or "").strip()) < 8:
|
||||
raise HTTPException(status_code=400, detail="New password must be at least 8 characters.")
|
||||
current_user.password_hash = hash_password(new_password.strip())
|
||||
current_user.must_change_password = False
|
||||
db.add(current_user)
|
||||
db.commit()
|
||||
db.refresh(current_user)
|
||||
return current_user
|
||||
@@ -0,0 +1,12 @@
|
||||
<div class="mb-6 overflow-x-auto rounded-2xl border border-slate-200 bg-white p-2 shadow-soft">
|
||||
<div class="flex min-w-max items-center gap-2">
|
||||
{% set path = request.url.path %}
|
||||
<a href="/client/dashboard" class="rounded-xl px-4 py-2 text-sm font-semibold transition {{ 'bg-brand-600 text-white' if path == '/client/dashboard' else 'text-slate-700 hover:bg-slate-100' }}">Overview</a>
|
||||
<a href="/client/compliance" class="rounded-xl px-4 py-2 text-sm font-semibold transition {{ 'bg-brand-600 text-white' if path.startswith('/client/compliance') or path.startswith('/client/engagements') else 'text-slate-700 hover:bg-slate-100' }}">My Compliance</a>
|
||||
<a href="/client/documents" class="rounded-xl px-4 py-2 text-sm font-semibold transition {{ 'bg-brand-600 text-white' if path.startswith('/client/documents') else 'text-slate-700 hover:bg-slate-100' }}">My Documents</a>
|
||||
<a href="/client/messages" class="rounded-xl px-4 py-2 text-sm font-semibold transition {{ 'bg-brand-600 text-white' if path.startswith('/client/messages') else 'text-slate-700 hover:bg-slate-100' }}">My Messages</a>
|
||||
<a href="/client/billing" class="rounded-xl px-4 py-2 text-sm font-semibold transition {{ 'bg-brand-600 text-white' if path.startswith('/client/billing') else 'text-slate-700 hover:bg-slate-100' }}">My Bills</a>
|
||||
<a href="/client/profile" class="rounded-xl px-4 py-2 text-sm font-semibold transition {{ 'bg-brand-600 text-white' if path.startswith('/client/profile') else 'text-slate-700 hover:bg-slate-100' }}">My Profile</a>
|
||||
<a href="/alerts" class="rounded-xl px-4 py-2 text-sm font-semibold transition {{ 'bg-brand-600 text-white' if path == '/alerts' else 'text-slate-700 hover:bg-slate-100' }}">My Alert</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1 @@
|
||||
{% extends "ui/templates/base/layout.html" %}{% block content %}<div class="space-y-6"><div><h2 class="text-2xl font-semibold text-slate-900">Add Client</h2><p class="text-sm text-slate-500">Create a validated client master with ownership and branch-safe rules.</p></div>{% if form_errors %}<div class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-900 shadow-soft">{% for err in form_errors %}<div>{{ err }}</div>{% endfor %}</div>{% endif %}<form method="post" action="/clients" class="space-y-6"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">{% include "modules/clients/templates/clients/partials/form.html" %}<div class="flex justify-end gap-3"><a href="/clients" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</a><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Save Client</button></div></form></div>{% endblock %}
|
||||
@@ -0,0 +1,23 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/clients/templates/clients/_client_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div><h2 class="text-2xl font-semibold text-slate-900">My Compliance</h2><p class="text-sm text-slate-500">Service-wise status and pending actions visible to you.</p></div>
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-sm text-slate-500">Pending from Client</div><div class="mt-2 text-2xl font-semibold">{{ pending_from_client or 0 }}</div></div>
|
||||
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-sm text-slate-500">With Firm</div><div class="mt-2 text-2xl font-semibold">{{ with_firm or 0 }}</div></div>
|
||||
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-sm text-slate-500">Clarification Required</div><div class="mt-2 text-2xl font-semibold">{{ clarification_required or 0 }}</div></div>
|
||||
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-sm text-slate-500">Completed</div><div class="mt-2 text-2xl font-semibold">{{ completed_engagements or 0 }}</div></div>
|
||||
</div>
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
{% for row in engagements %}
|
||||
<a href="/work/engagements/{{ row.id }}" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft hover:bg-slate-50">
|
||||
<div class="flex items-start justify-between gap-3"><div><h3 class="font-semibold text-slate-900">{{ row.catalogue.service_name if row.catalogue else 'Service' }}</h3><p class="text-xs text-slate-500">FY {{ row.financial_year }}{% if row.assessment_year %} • AY {{ row.assessment_year }}{% endif %}</p></div><span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700">{{ row.status|replace('_',' ')|title }}</span></div>
|
||||
<div class="mt-4 grid gap-3 text-sm md:grid-cols-3"><div><div class="text-xs text-slate-500">Due Date</div><div>{{ row.current_due_date.strftime('%d-%m-%Y') if row.current_due_date else '-' }}</div></div><div><div class="text-xs text-slate-500">Firm Contact</div><div>{{ row.assigned_manager.full_name if row.assigned_manager else (row.assigned_partner.full_name if row.assigned_partner else 'Firm team') }}</div></div><div><div class="text-xs text-slate-500">Status</div><div>{{ row.status|replace('_',' ')|title }}</div></div></div>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-dashed border-slate-300 bg-white p-8 text-sm text-slate-500 lg:col-span-2">No active compliance services are assigned yet.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,92 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-slate-900">{{ row.client_name }}</h2>
|
||||
<p class="text-sm text-slate-500">{{ row.client_code }} • {{ row.client_type }} • {{ row.status|title }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
{% if can_edit %}
|
||||
<a href="/clients/{{ row.id }}/edit"
|
||||
class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
||||
Edit
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if can_activate and row.status != 'active' and row.status != 'archived' %}
|
||||
<form method="post" action="/clients/{{ row.id }}/activate">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="rounded-xl border border-emerald-300 px-4 py-2 text-sm font-medium text-emerald-700 hover:bg-emerald-50">
|
||||
Activate
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if can_deactivate and row.status == 'active' %}
|
||||
<form method="post" action="/clients/{{ row.id }}/deactivate">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="rounded-xl border border-rose-300 px-4 py-2 text-sm font-medium text-rose-700 hover:bg-rose-50">
|
||||
Deactivate
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if can_archive and row.status != 'archived' %}
|
||||
<form method="post" action="/clients/{{ row.id }}/archive">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="rounded-xl border border-amber-300 px-4 py-2 text-sm font-medium text-amber-800 hover:bg-amber-50">
|
||||
Archive
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if can_restore and row.status == 'archived' %}
|
||||
<form method="post" action="/clients/{{ row.id }}/restore">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="rounded-xl border border-sky-300 px-4 py-2 text-sm font-medium text-sky-700 hover:bg-sky-50">
|
||||
Restore
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-3">
|
||||
<section class="rounded-2xl bg-white p-6 shadow-soft xl:col-span-2">
|
||||
<h3 class="text-base font-semibold text-slate-900">Profile</h3>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
{% for label, value in [
|
||||
('Trade Name', row.trade_name),
|
||||
('PAN', row.pan),
|
||||
('GSTIN', row.gstin),
|
||||
('TAN', row.tan),
|
||||
('Contact Person', row.contact_person_name),
|
||||
('Designation', row.contact_person_designation),
|
||||
('Mobile', row.mobile),
|
||||
('Email', row.email)
|
||||
] %}
|
||||
<div class="rounded-xl border border-slate-200 px-4 py-3">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">{{ label }}</div>
|
||||
<div class="mt-1 text-sm text-slate-800">{{ value or '-' }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl bg-white p-6 shadow-soft">
|
||||
<h3 class="text-base font-semibold text-slate-900">Association</h3>
|
||||
<div class="mt-4 space-y-2 text-sm text-slate-700">
|
||||
<div><span class="font-medium">Type:</span> {{ row.association_type or 'legacy_firm' }}</div>
|
||||
<div><span class="font-medium">Source:</span> {{ row.assoc_created_source or 'legacy' }}</div>
|
||||
<div><span class="font-medium">Audit Firm:</span> {{ row.assoc_firm_tenant_id or row.tenant_id or '-' }}</div>
|
||||
<div><span class="font-medium">Branch:</span> {{ row.branch_id or '-' }}</div>
|
||||
<div><span class="font-medium">Partner:</span> {{ row.assoc_partner_user_id or row.partner_id or '-' }}</div>
|
||||
<div><span class="font-medium">Default Review Partner:</span> {{ row.default_review_partner_user_id or '-' }}</div>
|
||||
<div><span class="font-medium">Consultant:</span> {{ row.assoc_consultant_id or '-' }}</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,9 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/clients/templates/clients/_client_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div><h2 class="text-2xl font-semibold text-slate-900">My Documents</h2><p class="text-sm text-slate-500">View documents shared by your audit firm.</p></div>
|
||||
<section class="rounded-2xl bg-white p-6 shadow-soft"><h3 class="font-semibold text-slate-900">Engagement Documents</h3><div class="mt-4 overflow-hidden rounded-2xl border border-slate-200"><table class="min-w-full divide-y divide-slate-200 text-sm"><thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500"><tr><th class="px-4 py-3">Document</th><th class="px-4 py-3">Service</th><th class="px-4 py-3">Type</th><th class="px-4 py-3">Version</th><th class="px-4 py-3 text-right">Action</th></tr></thead><tbody class="divide-y divide-slate-100">{% for doc in engagement_documents %}{% set ver = doc.versions[0] if doc.versions else None %}<tr><td class="px-4 py-3 font-medium text-slate-900">{{ doc.title }}</td><td class="px-4 py-3 text-slate-600">{{ doc.engagement.catalogue.service_name if doc.engagement and doc.engagement.catalogue else '-' }}</td><td class="px-4 py-3 text-slate-600">{{ doc.document_type }}</td><td class="px-4 py-3 text-slate-600">v{{ doc.current_version_no }}</td><td class="px-4 py-3 text-right">{% if ver %}<a href="/client/documents/engagement-versions/{{ ver.id }}/download" class="font-semibold text-brand-700 hover:underline">Download</a>{% endif %}</td></tr>{% else %}<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">No engagement documents shared yet.</td></tr>{% endfor %}</tbody></table></div></section>
|
||||
<section class="rounded-2xl bg-white p-6 shadow-soft"><h3 class="font-semibold text-slate-900">Permanent Documents</h3><div class="mt-4 overflow-hidden rounded-2xl border border-slate-200"><table class="min-w-full divide-y divide-slate-200 text-sm"><thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500"><tr><th class="px-4 py-3">Document</th><th class="px-4 py-3">Category</th><th class="px-4 py-3">Version</th><th class="px-4 py-3 text-right">Action</th></tr></thead><tbody class="divide-y divide-slate-100">{% for doc in permanent_documents %}{% set ver = doc.versions[0] if doc.versions else None %}<tr><td class="px-4 py-3 font-medium text-slate-900">{{ doc.title }}</td><td class="px-4 py-3 text-slate-600">{{ doc.category }}</td><td class="px-4 py-3 text-slate-600">v{{ doc.current_version_no }}</td><td class="px-4 py-3 text-right">{% if ver %}<a href="/client/documents/permanent-versions/{{ ver.id }}/download" class="font-semibold text-brand-700 hover:underline">Download</a>{% endif %}</td></tr>{% else %}<tr><td colspan="4" class="px-4 py-8 text-center text-slate-500">No permanent documents shared yet.</td></tr>{% endfor %}</tbody></table></div></section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,26 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-slate-900">Edit Client</h2>
|
||||
<p class="text-sm text-slate-500">B5 role-aware edit flow.</p>
|
||||
</div>
|
||||
|
||||
{% if form_errors %}
|
||||
<div class="rounded-2xl border border-rose-200 bg-rose-50 p-4">
|
||||
<ul class="list-disc pl-5 text-sm text-rose-700">
|
||||
{% for err in form_errors %}<li>{{ err }}</li>{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/clients/{{ row.id }}/edit" class="space-y-6">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
{% include "modules/clients/templates/clients/partials/form.html" %}
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<a href="/clients/{{ row.id }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</a>
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,14 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/clients/templates/clients/_client_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between"><div><h2 class="text-2xl font-semibold text-slate-900">{{ engagement.catalogue.service_name if engagement.catalogue else 'Engagement' }}</h2><p class="text-sm text-slate-500">FY {{ engagement.financial_year }}{% if engagement.assessment_year %} • AY {{ engagement.assessment_year }}{% endif %} • Due {{ engagement.current_due_date.strftime('%d-%m-%Y') if engagement.current_due_date else '-' }}</p></div><a href="/client/compliance" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back to My Compliance</a></div>
|
||||
<div class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<section class="space-y-4">
|
||||
<div class="rounded-2xl bg-white p-6 shadow-soft"><h3 class="font-semibold text-slate-900">Task / Action Status</h3><div class="mt-4 space-y-3">{% for task in tasks %}<details class="rounded-2xl border border-slate-200 p-4" {% if loop.first %}open{% endif %}><summary class="cursor-pointer list-none"><div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between"><div><div class="font-semibold text-slate-900">{{ task.task_name }}</div><div class="text-xs text-slate-500">{{ task.description or '' }}</div></div><span class="w-fit rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700">{{ task.status|replace('_',' ')|title }}</span></div></summary><div class="mt-4 border-t border-slate-100 pt-4"><form method="post" action="/client/tasks/{{ task.id }}/reply" class="space-y-3"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><label class="block text-sm font-medium text-slate-700">Reply / clarification for firm</label><textarea name="message" rows="3" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" placeholder="Type your clarification, confirmation or query for the firm..."></textarea><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Send Reply</button></form></div></details>{% else %}<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500">No task details available.</div>{% endfor %}</div></div>
|
||||
<div class="rounded-2xl bg-white p-6 shadow-soft"><h3 class="font-semibold text-slate-900">Communication Timeline</h3><div class="mt-4 space-y-3">{% for note in comments %}<div class="rounded-2xl border border-slate-200 p-4 text-sm"><div class="flex justify-between gap-3"><div class="font-semibold text-slate-900">{{ note.task.task_name if note.task else 'Message' }}</div><div class="text-xs text-slate-500">{{ note.created_at_utc.strftime('%d-%m-%Y %I:%M %p') if note.created_at_utc else '' }}</div></div><p class="mt-2 whitespace-pre-line text-slate-700">{{ note.message }}</p></div>{% else %}<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500">No visible communication yet.</div>{% endfor %}</div></div>
|
||||
</section>
|
||||
<aside class="rounded-2xl bg-white p-6 shadow-soft"><h3 class="font-semibold text-slate-900">Engagement Documents</h3><div class="mt-4 space-y-3">{% for doc in documents %}{% set ver = doc.versions[0] if doc.versions else None %}<div class="rounded-2xl border border-slate-200 p-4"><div class="font-semibold text-slate-900">{{ doc.title }}</div><div class="text-xs text-slate-500">{{ doc.document_type }} • v{{ doc.current_version_no }}</div>{% if ver %}<a href="/client/documents/engagement-versions/{{ ver.id }}/download" class="mt-3 inline-flex rounded-xl border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Download</a>{% endif %}</div>{% else %}<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500">No documents uploaded for this engagement yet.</div>{% endfor %}</div></aside>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,59 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-slate-900">Import Clients</h2>
|
||||
<p class="text-sm text-slate-500">Bulk upload clients for the active audit firm with partner validation.</p>
|
||||
</div>
|
||||
<a href="/clients/import/template" class="inline-flex rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Download Template</a>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl bg-white p-6 shadow-soft space-y-4">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
|
||||
<div class="font-semibold text-slate-900">Active Audit Firm</div>
|
||||
<div class="mt-1">{{ current_tenant.name if current_tenant else scope.tenant_id }} (ID: {{ scope.tenant_id }})</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
|
||||
<div class="font-semibold text-slate-900">Logged-in uploader</div>
|
||||
<div class="mt-1">{{ current_user.full_name or current_user.email }} — User ID {{ current_user.id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-sky-200 bg-sky-50 p-4 text-sm text-sky-900">
|
||||
Template includes <strong>uploader_user_id</strong>, <strong>firm_tenant_id</strong>, and <strong>partner_user_id</strong>.
|
||||
Validation checks that uploader_user_id matches the logged-in user, firm_tenant_id matches the active audit firm, and partner_user_id belongs to an active Partner in that same audit firm.
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 p-4">
|
||||
<div class="text-sm font-semibold text-slate-900">Partners available in this audit firm</div>
|
||||
<div class="mt-2 text-sm text-slate-600">
|
||||
{% for p in partners %}
|
||||
<div>{{ p.id }} — {{ p.full_name or p.email }}</div>
|
||||
{% else %}
|
||||
<div>No active partners found for this audit firm.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if import_errors %}
|
||||
<div class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-900">
|
||||
{% for err in import_errors %}<div>{{ err }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/clients/import/preview" enctype="multipart/form-data" class="space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Excel file</label>
|
||||
<input type="file" name="excel_file" accept=".xlsx" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" required>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3">
|
||||
<a href="/clients" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back</a>
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Validate File</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,71 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-slate-900">Import Clients Preview</h2>
|
||||
<p class="text-sm text-slate-500">Review validation result before final import.</p>
|
||||
</div>
|
||||
|
||||
{% if import_result is defined and import_result %}
|
||||
<div class="rounded-2xl bg-white p-6 shadow-soft space-y-4">
|
||||
<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900">Created {{ import_result.created|length }} clients.</div>
|
||||
{% if import_result.created %}
|
||||
<div class="rounded-xl border border-slate-200 p-4 text-sm">
|
||||
{% for row in import_result.created %}<div>{{ row.client_code }} — {{ row.client_name }} (ID {{ row.id }})</div>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if import_result.failures %}
|
||||
<div class="rounded-xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900">
|
||||
{% for err in import_result.failures %}<div>Row {{ err.row_number }}: {{ err.message }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex justify-end"><a href="/clients" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Back to Clients</a></div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<section class="rounded-2xl bg-white p-6 shadow-soft">
|
||||
<h3 class="text-base font-semibold text-slate-900">Valid rows</h3>
|
||||
<div class="mt-3 text-sm text-slate-600">{{ preview.valid_rows|length }} of {{ preview.total_rows }} rows are ready to import.</div>
|
||||
<div class="mt-4 max-h-[28rem] overflow-auto rounded-xl border border-slate-200">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50"><tr><th class="px-4 py-2 text-left">Row</th><th class="px-4 py-2 text-left">Audit Firm ID</th><th class="px-4 py-2 text-left">Partner</th><th class="px-4 py-2 text-left">Client Code</th><th class="px-4 py-2 text-left">Client Name</th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-100 bg-white">
|
||||
{% for item in preview.valid_rows %}
|
||||
<tr><td class="px-4 py-2">{{ item.row_number }}</td><td class="px-4 py-2">{{ item.tenant_id }}</td><td class="px-4 py-2">{{ item.partner_id }}</td><td class="px-4 py-2">{{ item.client_payload.client_code }}</td><td class="px-4 py-2">{{ item.client_payload.client_name }}</td></tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="px-4 py-6 text-center text-slate-500">No valid rows found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl bg-white p-6 shadow-soft">
|
||||
<h3 class="text-base font-semibold text-slate-900">Validation errors</h3>
|
||||
<div class="mt-3 text-sm text-slate-600">{{ preview.errors|length }} rows have issues.</div>
|
||||
<div class="mt-4 max-h-[28rem] space-y-3 overflow-auto">
|
||||
{% for err in preview.errors %}
|
||||
<div class="rounded-xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900">
|
||||
<div class="font-semibold">Row {{ err.row_number }}</div>
|
||||
<ul class="mt-2 list-disc space-y-1 pl-5">{% for msg in err.messages %}<li>{{ msg }}</li>{% endfor %}</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900">No validation errors found.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<a href="/clients/import" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back</a>
|
||||
{% if preview.valid_rows %}
|
||||
<form method="post" action="/clients/import/commit">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<textarea name="preview_payload" hidden>{{ preview_payload }}</textarea>
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Import {{ preview.valid_rows|length }} Valid Rows</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,36 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-slate-900">Clients</h2>
|
||||
<p class="text-sm text-slate-500">Association-aware list view.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
{% if can_export %}
|
||||
<a href="/clients/export?q={{ q }}&status={{ status }}&client_type={{ client_type }}&include_archived={{ include_archived }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}"
|
||||
class="inline-flex rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
||||
Export CSV
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if can_import %}
|
||||
<a href="/clients/import"
|
||||
class="inline-flex rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
||||
Import Clients
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if can_create %}
|
||||
<a href="/clients/new"
|
||||
class="inline-flex rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">
|
||||
Add Client
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include "modules/clients/templates/clients/partials/table.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,5 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/clients/templates/clients/_client_tabs.html" %}
|
||||
<div class="space-y-6"><div><h2 class="text-2xl font-semibold text-slate-900">My Messages</h2><p class="text-sm text-slate-500">Client-visible communications and clarifications from your firm.</p></div><section class="rounded-2xl bg-white p-6 shadow-soft"><div class="space-y-4">{% for note in comments %}<article class="rounded-2xl border border-slate-200 p-4"><div class="flex flex-col gap-2 md:flex-row md:items-start md:justify-between"><div><div class="text-xs font-semibold uppercase tracking-wide text-brand-700">{{ note.comment_type|replace('_',' ')|title }}</div><h3 class="mt-1 font-semibold text-slate-900">{{ note.task.task_name if note.task else 'Message' }}</h3><div class="text-xs text-slate-500">{{ note.subscription.catalogue.service_name if note.subscription and note.subscription.catalogue else '' }}</div></div><div class="text-xs text-slate-500">{{ note.created_at_utc.strftime('%d-%m-%Y %I:%M %p') if note.created_at_utc else '' }}</div></div><p class="mt-3 whitespace-pre-line text-sm leading-6 text-slate-700">{{ note.message }}</p><div class="mt-3 text-xs text-slate-500">From: {% if note.created_by %}{{ note.created_by.full_name or note.created_by.email }}{% else %}Firm team{% endif %}</div>{% if note.task %}<a href="/client/engagements/{{ note.task.subscription_id }}" class="mt-3 inline-flex text-sm font-semibold text-brand-700 hover:underline">Open related work</a>{% endif %}</article>{% else %}<div class="rounded-2xl border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500">No messages found.</div>{% endfor %}</div></section></div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1 @@
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200"><table class="min-w-full divide-y divide-slate-200"><thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">When</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Action</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Summary</th></tr></thead><tbody class="divide-y divide-slate-100">{% for log in audit_logs %}<tr><td class="px-4 py-3 text-sm text-slate-700">{{ log.created_at_utc }}</td><td class="px-4 py-3 text-sm text-slate-700">{{ log.action }}</td><td class="px-4 py-3 text-sm text-slate-700">{{ log.summary }}</td></tr>{% else %}<tr><td colspan="3" class="px-4 py-6 text-center text-sm text-slate-500">No audit entries yet.</td></tr>{% endfor %}</tbody></table></div>
|
||||
@@ -0,0 +1 @@
|
||||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">{% for label, value in [('GST', row.gst_applicable),('Income Tax', row.income_tax_applicable),('TDS', row.tds_applicable),('ROC', row.roc_applicable),('Audit', row.audit_applicable),('PF', row.pf_applicable),('ESI', row.esi_applicable),('Professional Tax', row.professional_tax_applicable),('Payroll', row.payroll_applicable),('MSME', row.msme_applicable),('Import / Export', row.import_export_applicable)] %}<div class="rounded-xl border border-slate-200 px-3 py-3 text-sm"><div class="font-medium text-slate-700">{{ label }}</div><div class="mt-1 {% if value %}text-emerald-700{% else %}text-slate-500{% endif %}">{% if value %}Applicable{% else %}Not Applicable{% endif %}</div></div>{% endfor %}</div>
|
||||
@@ -0,0 +1,372 @@
|
||||
{% set is_edit = row is defined and row %}
|
||||
<div class="grid gap-6 xl:grid-cols-3">
|
||||
<section class="rounded-2xl bg-white p-6 shadow-soft xl:col-span-2">
|
||||
<h3 class="text-base font-semibold text-slate-900">Basic Profile</h3>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Client Code</label>
|
||||
<input name="client_code" value="{{ form_data.client_code or (row.client_code if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" {% if is_edit %}readonly{% endif %}>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Client Name</label>
|
||||
<input name="client_name" value="{{ form_data.client_name or (row.client_name if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Trade Name</label>
|
||||
<input name="trade_name" value="{{ form_data.trade_name or (row.trade_name if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Client Type</label>
|
||||
<select name="client_type" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
{% for opt in client_types %}
|
||||
<option value="{{ opt }}" {% if (form_data.client_type or (row.client_type if is_edit else 'Other')) == opt %}selected{% endif %}>{{ opt }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">PAN</label>
|
||||
<input name="pan" value="{{ form_data.pan or (row.pan if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">GSTIN</label>
|
||||
<input name="gstin" value="{{ form_data.gstin or (row.gstin if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">TAN</label>
|
||||
<input name="tan" value="{{ form_data.tan or (row.tan if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">CIN / LLPIN</label>
|
||||
<input name="cin_llpin" value="{{ form_data.cin_llpin or (row.cin_llpin if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">MSME Number</label>
|
||||
<input name="msme_no" value="{{ form_data.msme_no or (row.msme_no if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">IEC Code</label>
|
||||
<input name="iec_code" value="{{ form_data.iec_code or (row.iec_code if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Mobile</label>
|
||||
<input name="mobile" value="{{ form_data.mobile or (row.mobile if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Alternate Mobile</label>
|
||||
<input name="alternate_mobile" value="{{ form_data.alternate_mobile or (row.alternate_mobile if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Email (used for frontend login)</label>
|
||||
<input name="email" value="{{ form_data.email or (row.email if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Alternate Email</label>
|
||||
<input name="alternate_email" value="{{ form_data.alternate_email or (row.alternate_email if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Contact Person</label>
|
||||
<input name="contact_person_name" value="{{ form_data.contact_person_name or (row.contact_person_name if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Designation</label>
|
||||
<input name="contact_person_designation" value="{{ form_data.contact_person_designation or (row.contact_person_designation if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Client Category</label>
|
||||
<select name="client_category" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<option value="">-- Select --</option>
|
||||
{% for opt in client_categories %}
|
||||
<option value="{{ opt }}" {% if (form_data.client_category or (row.client_category if is_edit else '')) == opt %}selected{% endif %}>{{ opt }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Risk Category</label>
|
||||
<select name="risk_category" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<option value="">-- Select --</option>
|
||||
{% for opt in risk_categories %}
|
||||
<option value="{{ opt }}" {% if (form_data.risk_category or (row.risk_category if is_edit else '')) == opt %}selected{% endif %}>{{ opt }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Onboarding Date</label>
|
||||
<input type="date" name="onboarding_date" value="{{ form_data.onboarding_date or (row.onboarding_date if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Closing Date</label>
|
||||
<input type="date" name="closing_date" value="{{ form_data.closing_date or (row.closing_date if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-slate-700">Address Line 1</label>
|
||||
<input name="address_line_1" value="{{ form_data.address_line_1 or (row.address_line_1 if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-slate-700">Address Line 2</label>
|
||||
<input name="address_line_2" value="{{ form_data.address_line_2 or (row.address_line_2 if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">City</label>
|
||||
<input name="city" value="{{ form_data.city or (row.city if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">State</label>
|
||||
<input name="state" value="{{ form_data.state or (row.state if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Pincode</label>
|
||||
<input name="pincode" value="{{ form_data.pincode or (row.pincode if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Country</label>
|
||||
<input name="country" value="{{ form_data.country or (row.country if is_edit else 'India') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-slate-700">Notes</label>
|
||||
<textarea name="notes" rows="4" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">{{ form_data.notes or (row.notes if is_edit else '') }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl bg-white p-6 shadow-soft">
|
||||
<h3 class="text-base font-semibold text-slate-900">Assignment & Scope</h3>
|
||||
<div class="mt-4 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Engagement Mode</label>
|
||||
<select name="engagement_mode" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
{% for opt in ['internal_managed','self_tracked','hybrid'] %}
|
||||
<option value="{{ opt }}" {% if (form_data.engagement_mode or (row.engagement_mode if is_edit else 'internal_managed')) == opt %}selected{% endif %}>{{ opt }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{% if form_mode == 'firm_admin' %}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Audit Firm</label>
|
||||
<div class="mt-1 rounded-xl border border-slate-300 bg-slate-50 px-4 py-2 text-sm text-slate-700">
|
||||
{% for t in form_options.tenants %}
|
||||
{{ t.name }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<input type="hidden" name="tenant_id" value="{{ form_data.tenant_id or (row.tenant_id if is_edit else form_options.active_tenant_id) }}">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Branch</label>
|
||||
<select name="branch_id" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
{% for b in form_options.branches %}
|
||||
<option value="{{ b.id }}" {% if (form_data.branch_id or (row.branch_id if is_edit else form_options.active_branch_id)) == b.id %}selected{% endif %}>{{ b.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Partner</label>
|
||||
<select name="partner_id" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<option value="">-- Select Partner --</option>
|
||||
{% for p in form_options.partners %}
|
||||
<option value="{{ p.id }}" {% if (form_data.partner_id or (row.partner_id if is_edit else None)) == p.id %}selected{% endif %}>{{ p.full_name or p.email }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{% elif form_mode == 'system_admin' %}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Audit Firm</label>
|
||||
<select name="tenant_id" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
{% for t in form_options.tenants %}
|
||||
<option value="{{ t.id }}" {% if (form_data.tenant_id or (row.tenant_id if is_edit else form_options.active_tenant_id)) == t.id %}selected{% endif %}>{{ t.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Branch</label>
|
||||
<select name="branch_id" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<option value="">-- Select Branch --</option>
|
||||
{% for b in form_options.branches %}
|
||||
<option value="{{ b.id }}" {% if (form_data.branch_id or (row.branch_id if is_edit else form_options.active_branch_id)) == b.id %}selected{% endif %}>
|
||||
{{ b.name }}{% if b.tenant_name %} ({{ b.tenant_name }}){% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Partner</label>
|
||||
<select name="partner_id" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<option value="">-- Select Partner --</option>
|
||||
{% for p in form_options.partners %}
|
||||
<option value="{{ p.id }}" {% if (form_data.partner_id or (row.partner_id if is_edit else None)) == p.id %}selected{% endif %}>
|
||||
{{ p.full_name or p.email }}{% if p.tenant_name %} ({{ p.tenant_name }}){% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{% elif form_mode == 'partner' %}
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
|
||||
Partner assignment is locked to your own user.
|
||||
<input type="hidden" name="tenant_id" value="{{ form_data.tenant_id or (row.tenant_id if is_edit else form_options.active_tenant_id) }}">
|
||||
<input type="hidden" name="branch_id" value="{{ form_data.branch_id or (row.branch_id if is_edit else form_options.active_branch_id) }}">
|
||||
<input type="hidden" name="partner_id" value="{{ current_user.id }}">
|
||||
</div>
|
||||
|
||||
{% elif form_mode == 'consultant' %}
|
||||
<div class="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
|
||||
Consultant users cannot assign or reassign partner mappings.
|
||||
<input type="hidden" name="tenant_id" value="{{ form_data.tenant_id or (row.tenant_id if is_edit else form_options.active_tenant_id) }}">
|
||||
<input type="hidden" name="branch_id" value="{{ form_data.branch_id or (row.branch_id if is_edit else form_options.active_branch_id) }}">
|
||||
<input type="hidden" name="partner_id" value="{{ form_data.partner_id or (row.partner_id if is_edit else '') }}">
|
||||
</div>
|
||||
|
||||
{% elif form_mode == 'self_service' %}
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
|
||||
This client is currently unassigned. Initial association to firm, branch, and partner must be done by System Admin.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Default Review Partner</label>
|
||||
<select name="default_review_partner_user_id" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<option value="">-- Not required / Select later --</option>
|
||||
{% for p in form_options.review_partners or [] %}
|
||||
<option value="{{ p.id }}" {% if (form_data.default_review_partner_user_id or (row.default_review_partner_user_id if is_edit else None)) == p.id %}selected{% endif %}>
|
||||
{{ p.full_name or p.email }}{% if p.tenant_name %} ({{ p.tenant_name }}){% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-slate-500">Used automatically for assurance engagements only when the audit firm is a partnership firm.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Status</label>
|
||||
<select name="status" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
{% for opt in client_statuses %}
|
||||
<option value="{{ opt }}" {% if (form_data.status or (row.status if is_edit else 'active')) == opt %}selected{% endif %}>{{ opt|title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-slate-200 pt-4">
|
||||
<h4 class="mb-3 text-sm font-semibold text-slate-900">Client Frontend Login</h4>
|
||||
<div class="rounded-xl border border-sky-200 bg-sky-50 p-3 text-xs text-sky-800">
|
||||
{% if is_edit and row.portal_user_id %}
|
||||
Linked portal user already exists. Leave password blank to keep the current password, or enter a new password to reset it.
|
||||
{% elif is_edit %}
|
||||
This existing client does not yet have a linked login. Enter email and password below to create the client login now.
|
||||
{% else %}
|
||||
Creating a client will also create a frontend login using the client email and password below.
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{% if is_edit and row.portal_user_id %}New Password{% else %}Password{% endif %}</label>
|
||||
<input name="portal_password" type="password" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" {% if not is_edit %}required{% endif %}>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">{% if is_edit and row.portal_user_id %}Confirm New Password{% else %}Confirm Password{% endif %}</label>
|
||||
<input name="portal_password_confirm" type="password" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" {% if not is_edit %}required{% endif %}>
|
||||
</div>
|
||||
|
||||
{% if is_edit and row.portal_user_id %}
|
||||
<div class="text-xs text-slate-500">
|
||||
Portal user id linked: {{ row.portal_user_id }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-slate-200 pt-4">
|
||||
<h4 class="mb-3 text-sm font-semibold text-slate-900">Compliance Applicability</h4>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="gst_applicable" value="1" {% if form_data.gst_applicable or (row.gst_applicable if is_edit else false) %}checked{% endif %}>
|
||||
GST Applicable
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="income_tax_applicable" value="1" {% if form_data.income_tax_applicable or (row.income_tax_applicable if is_edit else false) %}checked{% endif %}>
|
||||
Income Tax Applicable
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="tds_applicable" value="1" {% if form_data.tds_applicable or (row.tds_applicable if is_edit else false) %}checked{% endif %}>
|
||||
TDS Applicable
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="roc_applicable" value="1" {% if form_data.roc_applicable or (row.roc_applicable if is_edit else false) %}checked{% endif %}>
|
||||
ROC Applicable
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="audit_applicable" value="1" {% if form_data.audit_applicable or (row.audit_applicable if is_edit else false) %}checked{% endif %}>
|
||||
Audit Applicable
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="pf_applicable" value="1" {% if form_data.pf_applicable or (row.pf_applicable if is_edit else false) %}checked{% endif %}>
|
||||
PF Applicable
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="esi_applicable" value="1" {% if form_data.esi_applicable or (row.esi_applicable if is_edit else false) %}checked{% endif %}>
|
||||
ESI Applicable
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="professional_tax_applicable" value="1" {% if form_data.professional_tax_applicable or (row.professional_tax_applicable if is_edit else false) %}checked{% endif %}>
|
||||
Professional Tax Applicable
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="payroll_applicable" value="1" {% if form_data.payroll_applicable or (row.payroll_applicable if is_edit else false) %}checked{% endif %}>
|
||||
Payroll Applicable
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="msme_applicable" value="1" {% if form_data.msme_applicable or (row.msme_applicable if is_edit else false) %}checked{% endif %}>
|
||||
MSME Applicable
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="import_export_applicable" value="1" {% if form_data.import_export_applicable or (row.import_export_applicable if is_edit else false) %}checked{% endif %}>
|
||||
Import / Export Applicable
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,2 @@
|
||||
|
||||
<div class="overflow-hidden rounded-2xl bg-white shadow-soft"><table class="min-w-full divide-y divide-slate-200"><thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Code</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Client</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Association</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Partner</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Branch</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Status</th><th class="px-4 py-3"></th></tr></thead><tbody class="divide-y divide-slate-100">{% for row in rows %}<tr><td class="px-4 py-3 text-sm font-medium text-slate-900">{{ row.client_code }}</td><td class="px-4 py-3 text-sm text-slate-700"><div class="font-medium">{{ row.client_name }}</div><div class="text-xs text-slate-500">{{ row.pan or row.gstin or '-' }}</div></td><td class="px-4 py-3 text-sm text-slate-700"><div>{{ row.association_type or 'legacy_firm' }}</div><div class="text-xs text-slate-500">{{ row.assoc_created_source or 'legacy' }}</div></td><td class="px-4 py-3 text-sm text-slate-700">{{ row.partner_name or row.effective_partner_id or '-' }}</td><td class="px-4 py-3 text-sm text-slate-700">{{ row.branch_name or row.assoc_firm_branch_id or row.branch_id or '-' }}</td><td class="px-4 py-3 text-sm">{% if row.status == 'active' %}<span class="rounded-full bg-emerald-100 px-2 py-1 text-xs font-medium text-emerald-700">Active</span>{% elif row.status == 'archived' %}<span class="rounded-full bg-amber-100 px-2 py-1 text-xs font-medium text-amber-800">Archived</span>{% else %}<span class="rounded-full bg-slate-200 px-2 py-1 text-xs font-medium text-slate-700">Inactive</span>{% endif %}</td><td class="px-4 py-3 text-right"><a href="/clients/{{ row.id }}" class="text-sm font-medium text-brand-700 hover:underline">Open</a></td></tr>{% else %}<tr><td colspan="7" class="px-4 py-8 text-center text-sm text-slate-500">No clients found.</td></tr>{% endfor %}</tbody></table></div>
|
||||
@@ -0,0 +1,93 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
{% include "modules/clients/templates/clients/_client_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<section class="rounded-3xl bg-gradient-to-r from-brand-700 to-slate-900 p-6 text-white shadow-soft">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-brand-100">Client Portal</p>
|
||||
<h2 class="mt-2 text-2xl font-semibold">My Compliance & Firm Communication</h2>
|
||||
<p class="mt-2 max-w-3xl text-sm text-brand-100">Track your compliance status, pending actions, required documents, messages and firm updates.</p>
|
||||
</div>
|
||||
{% if client_row %}<a href="/client/profile" class="rounded-xl bg-white px-4 py-2 text-sm font-semibold text-brand-700 hover:bg-brand-50">Update My Profile</a>{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if not client_row %}
|
||||
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-5 text-sm text-amber-900 shadow-soft">
|
||||
We could not find a client master linked to your login email in the current audit firm. Please contact your firm admin to map this login to the correct client record.
|
||||
</div>
|
||||
{% else %}
|
||||
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||
<a href="/client/compliance" class="af-metric-card hover:border-brand-200"><div class="text-xs font-semibold uppercase text-slate-500">Active Compliance</div><div class="mt-2 text-3xl font-semibold text-slate-900">{{ total_engagements or 0 }}</div><div class="mt-1 text-xs text-slate-500">Services / filings</div></a>
|
||||
<a href="/client/compliance" class="af-metric-card border-amber-200 bg-amber-50 hover:border-amber-300"><div class="text-xs font-semibold uppercase text-amber-700">Pending Action</div><div class="mt-2 text-3xl font-semibold text-amber-700">{{ pending_from_client or 0 }}</div><div class="mt-1 text-xs text-amber-700">Required from you</div></a>
|
||||
<a href="/client/compliance" class="af-metric-card hover:border-brand-200"><div class="text-xs font-semibold uppercase text-slate-500">With Firm</div><div class="mt-2 text-3xl font-semibold text-brand-700">{{ with_firm or 0 }}</div><div class="mt-1 text-xs text-slate-500">Being handled</div></a>
|
||||
<a href="/client/documents" class="af-metric-card hover:border-brand-200"><div class="text-xs font-semibold uppercase text-slate-500">Documents</div><div class="mt-2 text-3xl font-semibold text-slate-900">{{ recent_documents|length if recent_documents else 0 }}</div><div class="mt-1 text-xs text-slate-500">Recent uploads</div></a>
|
||||
<a href="/client/billing" class="af-metric-card border-amber-200 bg-amber-50 hover:border-amber-300"><div class="text-xs font-semibold uppercase text-amber-700">Outstanding Bills</div><div class="mt-2 text-3xl font-semibold text-amber-700">₹ {{ '%.2f'|format(billing_outstanding_amount or 0) }}</div><div class="mt-1 text-xs text-amber-700">{{ billing_open_count or 0 }} open bill(s)</div></a>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_380px]">
|
||||
<div class="space-y-6">
|
||||
<div class="af-card">
|
||||
<div class="flex items-center justify-between gap-3"><div><h3 class="text-lg font-semibold text-slate-900">Compliance Status</h3><p class="mt-1 text-sm text-slate-500">Simple client-facing status of your active services.</p></div><a href="/client/compliance" class="af-btn af-btn-primary">View All</a></div>
|
||||
<div class="mt-5 grid gap-3 md:grid-cols-4 text-sm">
|
||||
<div class="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3"><div class="text-amber-700">Pending from You</div><div class="mt-1 text-2xl font-semibold text-amber-700">{{ pending_from_client or 0 }}</div></div>
|
||||
<div class="rounded-2xl border border-brand-200 bg-brand-50 px-4 py-3"><div class="text-brand-700">With Firm</div><div class="mt-1 text-2xl font-semibold text-brand-700">{{ with_firm or 0 }}</div></div>
|
||||
<div class="rounded-2xl border border-blue-200 bg-blue-50 px-4 py-3"><div class="text-blue-700">Clarification</div><div class="mt-1 text-2xl font-semibold text-blue-700">{{ clarification_required or 0 }}</div></div>
|
||||
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3"><div class="text-emerald-700">Completed</div><div class="mt-1 text-2xl font-semibold text-emerald-700">{{ completed_engagements or 0 }}</div></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 space-y-3">
|
||||
{% for row in due_soon_engagements[:6] %}
|
||||
<a href="/client/engagements/{{ row.id }}" class="block rounded-2xl border border-slate-200 p-4 hover:bg-slate-50">
|
||||
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div><div class="font-semibold text-slate-900">{{ row.catalogue.service_name if row.catalogue else 'Service' }}</div><div class="text-xs text-slate-500">FY {{ row.financial_year }}{% if row.assessment_year %} • AY {{ row.assessment_year }}{% endif %}</div></div>
|
||||
<div class="text-sm text-slate-600">Due: {{ row.current_due_date.strftime('%d-%m-%Y') if row.current_due_date else '-' }}</div>
|
||||
</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500">No active compliance items found.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="af-card">
|
||||
<div class="flex items-center justify-between gap-3"><h3 class="text-lg font-semibold text-slate-900">Latest Messages</h3><a href="/client/messages" class="text-sm font-semibold text-brand-700 hover:underline">View all</a></div>
|
||||
<div class="mt-4 space-y-3">
|
||||
{% for note in client_visible_comments[:5] %}
|
||||
<div class="rounded-2xl border border-slate-200 p-4 text-sm"><div class="font-semibold text-slate-900">{{ note.task.task_name if note.task else 'Message' }}</div><p class="mt-2 text-slate-700">{{ note.message }}</p><div class="mt-2 text-xs text-slate-500">{{ note.created_at_utc.strftime('%d-%m-%Y %I:%M %p') if note.created_at_utc else '' }}</div></div>
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500">No messages yet.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="space-y-6">
|
||||
<div class="af-card">
|
||||
<h3 class="text-base font-semibold text-slate-900">My Client Profile</h3>
|
||||
<div class="mt-4 space-y-3 text-sm">
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Client</div><div class="font-semibold">{{ client_row.client_name }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">PAN / GSTIN</div><div>{{ client_row.pan or '-' }}{% if client_row.gstin %} / {{ client_row.gstin }}{% endif %}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Firm Contact</div><div>{{ client_row.partner_name or 'Firm team' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Branch</div><div>{{ client_row.branch_name or '-' }}</div></div>
|
||||
</div>
|
||||
<a href="/client/profile" class="mt-5 inline-flex af-btn af-btn-secondary">Update Profile</a>
|
||||
</div>
|
||||
|
||||
<div class="af-card">
|
||||
<h3 class="font-semibold text-slate-900">Quick Actions</h3>
|
||||
<div class="mt-4 grid gap-2 text-sm">
|
||||
<a href="/client/compliance" class="rounded-xl border border-slate-200 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-50">View My Compliance</a>
|
||||
<a href="/client/documents" class="rounded-xl border border-slate-200 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-50">Upload / View Documents</a>
|
||||
<a href="/client/messages" class="rounded-xl border border-slate-200 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-50">Messages from Firm</a>
|
||||
<a href="/client/billing" class="rounded-xl border border-slate-200 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-50">View Bills & Receipts</a>
|
||||
{% if billing_latest_due_invoice %}<a href="/client/billing/{{ billing_latest_due_invoice.id }}/pay-now" class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 font-semibold text-amber-700 hover:bg-amber-100">Pay Latest Due</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,109 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
{% include "modules/clients/templates/clients/_client_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-slate-900">Edit My Profile</h2>
|
||||
<p class="text-sm text-slate-500">You can update contact and communication details here. PAN, GSTIN and other compliance identity fields stay read-only.</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<a href="/change-password" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Change Password</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if form_errors %}
|
||||
<div class="rounded-2xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-800">
|
||||
<ul class="list-disc space-y-1 pl-5">
|
||||
{% for error in form_errors %}<li>{{ error }}</li>{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/client/profile" class="space-y-6">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<section class="rounded-2xl bg-white p-6 shadow-soft">
|
||||
<h3 class="text-base font-semibold text-slate-900">Read-only compliance identity</h3>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4 text-sm">
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3"><div class="text-xs font-medium uppercase tracking-wide text-slate-500">PAN</div><div class="mt-1 text-slate-900">{{ client_row.pan or '-' }}</div></div>
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3"><div class="text-xs font-medium uppercase tracking-wide text-slate-500">GSTIN</div><div class="mt-1 text-slate-900">{{ client_row.gstin or '-' }}</div></div>
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3"><div class="text-xs font-medium uppercase tracking-wide text-slate-500">TAN</div><div class="mt-1 text-slate-900">{{ client_row.tan or '-' }}</div></div>
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3"><div class="text-xs font-medium uppercase tracking-wide text-slate-500">CIN / LLPIN</div><div class="mt-1 text-slate-900">{{ client_row.cin_llpin or '-' }}</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl bg-white p-6 shadow-soft">
|
||||
<h3 class="text-base font-semibold text-slate-900">Editable profile details</h3>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Client Name</label>
|
||||
<input name="client_name" value="{{ form_data.client_name or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Trade Name</label>
|
||||
<input name="trade_name" value="{{ form_data.trade_name or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Contact Person Name</label>
|
||||
<input name="contact_person_name" value="{{ form_data.contact_person_name or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Designation</label>
|
||||
<input name="contact_person_designation" value="{{ form_data.contact_person_designation or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Mobile</label>
|
||||
<input name="mobile" value="{{ form_data.mobile or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Alternate Mobile</label>
|
||||
<input name="alternate_mobile" value="{{ form_data.alternate_mobile or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Login Email</label>
|
||||
<input type="email" name="email" value="{{ form_data.email or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<p class="mt-1 text-xs text-slate-500">If you change this, your next login will use the new email.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Alternate Email</label>
|
||||
<input type="email" name="alternate_email" value="{{ form_data.alternate_email or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-slate-700">Address Line 1</label>
|
||||
<input name="address_line_1" value="{{ form_data.address_line_1 or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-slate-700">Address Line 2</label>
|
||||
<input name="address_line_2" value="{{ form_data.address_line_2 or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">City</label>
|
||||
<input name="city" value="{{ form_data.city or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">State</label>
|
||||
<input name="state" value="{{ form_data.state or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Pincode</label>
|
||||
<input name="pincode" value="{{ form_data.pincode or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Country</label>
|
||||
<input name="country" value="{{ form_data.country or 'India' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-slate-700">Notes</label>
|
||||
<textarea name="notes" rows="4" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">{{ form_data.notes or '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="rounded-xl bg-brand-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-brand-700">Save Profile</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
|
||||
PAN_RE = re.compile(r"^[A-Z]{5}[0-9]{4}[A-Z]$")
|
||||
GSTIN_RE = re.compile(r"^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$")
|
||||
TAN_RE = re.compile(r"^[A-Z]{4}[0-9]{5}[A-Z]$")
|
||||
MOBILE_RE = re.compile(r"^[6-9][0-9]{9}$")
|
||||
PIN_RE = re.compile(r"^[0-9]{6}$")
|
||||
|
||||
def normalize_text(value):
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
def normalize_upper(value):
|
||||
value = normalize_text(value)
|
||||
return value.upper() if value else None
|
||||
|
||||
def build_csv(rows, headers):
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(headers)
|
||||
for row in rows:
|
||||
writer.writerow(row)
|
||||
return output.getvalue()
|
||||
@@ -0,0 +1 @@
|
||||
"""Consultant portal foundation module."""
|
||||
@@ -0,0 +1,311 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
|
||||
class ConsultantProfile(CommonBase):
|
||||
"""Portal-enabled consultant / ecosystem partner profile.
|
||||
|
||||
The login/security account remains in users. This table stores consultant
|
||||
business/profile details and links the consultant user to firm clients.
|
||||
"""
|
||||
|
||||
__tablename__ = "consultant_profiles"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "user_id", name="uq_consultant_profiles_tenant_user"),
|
||||
UniqueConstraint("tenant_id", "email", name="uq_consultant_profiles_tenant_email"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True)
|
||||
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
consultant_type: Mapped[str] = mapped_column(String(50), nullable=False, default="external_consultant", index=True)
|
||||
firm_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
contact_person: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
mobile: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
specialisation: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
gstin: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
pan: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
address: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, default="active", index=True)
|
||||
onboarding_status: Mapped[str] = mapped_column(String(30), nullable=False, default="approved", index=True)
|
||||
|
||||
is_platform_partner: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_franchise_partner: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_saas_customer: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||
)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
user = relationship("User", foreign_keys=[user_id])
|
||||
links = relationship(
|
||||
"ClientConsultantLink",
|
||||
back_populates="consultant",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
workspace = relationship(
|
||||
"ConsultantWorkspace",
|
||||
back_populates="consultant",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
managed_clients = relationship(
|
||||
"ConsultantManagedClient",
|
||||
back_populates="consultant",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
service_requests = relationship(
|
||||
"ConsultantServiceRequest",
|
||||
back_populates="consultant",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
|
||||
class ConsultantWorkspace(CommonBase):
|
||||
"""SaaS/franchise workspace settings for a consultant portal account.
|
||||
|
||||
This is a foundation table only. It does not change firm-owned clients or
|
||||
consultant-managed clients. It records whether the consultant operates as a
|
||||
SaaS customer, franchise partner, platform partner, or a normal external
|
||||
consultant, along with soft limits used by later subscription/billing phases.
|
||||
"""
|
||||
|
||||
__tablename__ = "consultant_workspaces"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "consultant_id", name="uq_consultant_workspaces_tenant_consultant"),
|
||||
UniqueConstraint("tenant_id", "workspace_code", name="uq_consultant_workspaces_tenant_code"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True)
|
||||
consultant_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("consultant_profiles.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
|
||||
workspace_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
workspace_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
workspace_type: Mapped[str] = mapped_column(String(50), nullable=False, default="consultant_saas", index=True)
|
||||
plan_code: Mapped[str] = mapped_column(String(50), nullable=False, default="starter", index=True)
|
||||
billing_cycle: Mapped[str] = mapped_column(String(30), nullable=False, default="manual", index=True)
|
||||
subscription_status: Mapped[str] = mapped_column(String(30), nullable=False, default="trial", index=True)
|
||||
subscription_start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
subscription_end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
|
||||
max_managed_clients: Mapped[int] = mapped_column(Integer, nullable=False, default=25)
|
||||
max_user_accounts: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
allow_client_portal: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
allow_firm_referrals: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
allow_service_marketplace: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||
)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
consultant = relationship("ConsultantProfile", back_populates="workspace")
|
||||
|
||||
|
||||
class ClientConsultantLink(CommonBase):
|
||||
"""Explicit link between a firm client and a consultant portal profile."""
|
||||
|
||||
__tablename__ = "client_consultant_links"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "client_id", "consultant_id", name="uq_client_consultant_links_client_consultant"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True)
|
||||
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
consultant_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("consultant_profiles.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
service_catalogue_id: Mapped[int | None] = mapped_column(ForeignKey("service_catalogues.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
relationship_type: Mapped[str] = mapped_column(String(50), nullable=False, default="accounts_consultant", index=True)
|
||||
is_primary: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
can_view_client: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
can_view_services: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
can_view_due_dates: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
can_view_communications: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||
)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
consultant = relationship("ConsultantProfile", back_populates="links")
|
||||
client = relationship("Client")
|
||||
service_catalogue = relationship("ServiceCatalogue")
|
||||
|
||||
|
||||
class ConsultantManagedClient(CommonBase):
|
||||
"""Client/contact managed by a consultant inside the consultant portal.
|
||||
|
||||
This table is intentionally separate from the firm `clients` master. It lets
|
||||
consultants maintain their own client book without affecting audit-firm
|
||||
client records. A later phase can convert/link a managed client to the firm
|
||||
client master through an approval workflow.
|
||||
"""
|
||||
|
||||
__tablename__ = "consultant_managed_clients"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "consultant_id", "client_code", name="uq_consultant_managed_clients_code"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True)
|
||||
consultant_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("consultant_profiles.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
linked_firm_client_id: Mapped[int | None] = mapped_column(ForeignKey("clients.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
client_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
|
||||
client_name: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
|
||||
trade_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
client_type: Mapped[str] = mapped_column(String(100), nullable=False, default="Other")
|
||||
pan: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
gstin: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
tan: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
|
||||
contact_person_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
mobile: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
address_line_1: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
address_line_2: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
city: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
state: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
pincode: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
country: Mapped[str | None] = mapped_column(String(100), nullable=True, default="India")
|
||||
|
||||
service_interest: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
relationship_stage: Mapped[str] = mapped_column(String(30), nullable=False, default="managed", index=True)
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, default="active", index=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
conversion_status: Mapped[str] = mapped_column(String(30), nullable=False, default="not_requested", index=True)
|
||||
conversion_requested_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
conversion_requested_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
conversion_reviewed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
conversion_reviewed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
conversion_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
conversion_firm_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||
)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
consultant = relationship("ConsultantProfile", back_populates="managed_clients")
|
||||
linked_firm_client = relationship("Client")
|
||||
|
||||
|
||||
class ConsultantServiceRequest(CommonBase):
|
||||
"""Service request raised by a consultant to the audit firm.
|
||||
|
||||
A request can relate either to a consultant-managed client or to an already
|
||||
linked firm client. The request itself does not create engagements; firm
|
||||
users review it first and decide whether to accept, reject, or keep it under
|
||||
review.
|
||||
"""
|
||||
|
||||
__tablename__ = "consultant_service_requests"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True)
|
||||
consultant_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("consultant_profiles.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
managed_client_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("consultant_managed_clients.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
firm_client_id: Mapped[int | None] = mapped_column(ForeignKey("clients.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
service_catalogue_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("service_catalogues.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
|
||||
request_no: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
request_type: Mapped[str] = mapped_column(String(50), nullable=False, default="service_request", index=True)
|
||||
status: Mapped[str] = mapped_column(String(40), nullable=False, default="submitted", index=True)
|
||||
priority: Mapped[str] = mapped_column(String(30), nullable=False, default="normal", index=True)
|
||||
requested_service_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
requested_due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
subject: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
consultant_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
firm_response: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
reviewed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
reviewed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||
)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
|
||||
consultant = relationship("ConsultantProfile", back_populates="service_requests")
|
||||
managed_client = relationship("ConsultantManagedClient")
|
||||
firm_client = relationship("Client")
|
||||
service_catalogue = relationship("ServiceCatalogue")
|
||||
created_by = relationship("User", foreign_keys=[created_by_user_id])
|
||||
reviewed_by = relationship("User", foreign_keys=[reviewed_by_user_id])
|
||||
@@ -0,0 +1,245 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
from datetime import date, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.modules.clients.models import Client
|
||||
from app.modules.consultants.models import ClientConsultantLink, ConsultantProfile, ConsultantServiceRequest
|
||||
from app.modules.documents.models import EngagementDocument, PermanentClientDocument
|
||||
from app.modules.services.models import (
|
||||
ClientServiceSubscription,
|
||||
ClientServiceTaskInstance,
|
||||
ServiceCatalogue,
|
||||
ServiceTaskComment,
|
||||
)
|
||||
|
||||
CONSULTANT_BOARD_COLUMNS = OrderedDict(
|
||||
[
|
||||
("assigned", "Assigned"),
|
||||
("awaiting_documents", "Awaiting Documents"),
|
||||
("in_progress", "In Progress"),
|
||||
("submitted", "Submitted"),
|
||||
("accepted", "Accepted"),
|
||||
("closed", "Closed"),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _allowed_client_ids(db: Session, *, consultant: ConsultantProfile, require_communications: bool = False) -> list[int]:
|
||||
query = select(ClientConsultantLink.client_id).where(
|
||||
ClientConsultantLink.tenant_id == consultant.tenant_id,
|
||||
ClientConsultantLink.consultant_id == consultant.id,
|
||||
ClientConsultantLink.is_active.is_(True),
|
||||
)
|
||||
if require_communications:
|
||||
query = query.where(ClientConsultantLink.can_view_communications.is_(True))
|
||||
return [int(x) for x in db.execute(query).scalars().all()]
|
||||
|
||||
|
||||
def _board_key_for_status(status: str | None) -> str:
|
||||
value = (status or "pending").strip().lower()
|
||||
if value in {"blocked", "awaiting_documents", "document_pending", "clarification_required"}:
|
||||
return "awaiting_documents"
|
||||
if value in {"in_progress", "under_process", "processing", "started"}:
|
||||
return "in_progress"
|
||||
if value in {"pending_review", "ready_for_review", "submitted", "completed"}:
|
||||
return "submitted"
|
||||
if value in {"approved", "accepted"}:
|
||||
return "accepted"
|
||||
if value in {"closed", "locked", "cancelled", "inactive"}:
|
||||
return "closed"
|
||||
return "assigned"
|
||||
|
||||
|
||||
def _matches_search(*values: Any, q: str = "") -> bool:
|
||||
term = (q or "").strip().lower()
|
||||
if not term:
|
||||
return True
|
||||
return any(term in str(v or "").lower() for v in values)
|
||||
|
||||
|
||||
def get_consultant_work_board(db: Session, *, consultant: ConsultantProfile, q: str = "", status: str = "") -> dict:
|
||||
"""Build consultant work board from consultant-visible firm task communications.
|
||||
|
||||
The board intentionally uses existing task/comment visibility rules only. A consultant sees a task here only when
|
||||
the firm has linked the consultant to the client and has created a consultant-visible communication for that task.
|
||||
"""
|
||||
client_ids = _allowed_client_ids(db, consultant=consultant, require_communications=True)
|
||||
columns = {key: {"label": label, "items": []} for key, label in CONSULTANT_BOARD_COLUMNS.items()}
|
||||
latest_by_task: dict[int, dict] = {}
|
||||
|
||||
if client_ids:
|
||||
rows = db.execute(
|
||||
select(ServiceTaskComment, ClientServiceTaskInstance, ClientServiceSubscription, Client, ServiceCatalogue)
|
||||
.join(ClientServiceTaskInstance, ClientServiceTaskInstance.id == ServiceTaskComment.task_instance_id)
|
||||
.join(ClientServiceSubscription, ClientServiceSubscription.id == ServiceTaskComment.subscription_id)
|
||||
.join(Client, Client.id == ClientServiceTaskInstance.client_id)
|
||||
.join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceTaskInstance.service_catalogue_id)
|
||||
.where(
|
||||
ServiceTaskComment.tenant_id == consultant.tenant_id,
|
||||
ServiceTaskComment.visibility == "consultant",
|
||||
ServiceTaskComment.is_deleted.is_(False),
|
||||
ClientServiceTaskInstance.client_id.in_(client_ids),
|
||||
ClientServiceTaskInstance.tenant_id == consultant.tenant_id,
|
||||
ClientServiceTaskInstance.is_active.is_(True),
|
||||
)
|
||||
.order_by(ServiceTaskComment.created_at_utc.desc(), ServiceTaskComment.id.desc())
|
||||
.limit(300)
|
||||
).all()
|
||||
consultant_user_id = int(getattr(consultant, "user_id", 0) or 0)
|
||||
for comment, task, subscription, client, catalogue in rows:
|
||||
if int(task.id) in latest_by_task:
|
||||
continue
|
||||
if not _matches_search(client.client_name, getattr(client, "client_code", ""), catalogue.service_name, task.task_name, comment.message, q=q):
|
||||
continue
|
||||
key = _board_key_for_status(task.status)
|
||||
if status and key != status:
|
||||
continue
|
||||
latest_by_task[int(task.id)] = {
|
||||
"comment": comment,
|
||||
"task": task,
|
||||
"subscription": subscription,
|
||||
"client": client,
|
||||
"catalogue": catalogue,
|
||||
"board_key": key,
|
||||
"last_message_from_consultant": int(getattr(comment, "created_by_user_id", 0) or 0) == consultant_user_id,
|
||||
"is_overdue": bool(getattr(task, "internal_target_date", None) and task.internal_target_date < date.today()),
|
||||
}
|
||||
|
||||
for item in latest_by_task.values():
|
||||
columns[item["board_key"]]["items"].append(item)
|
||||
|
||||
service_requests = db.execute(
|
||||
select(ConsultantServiceRequest)
|
||||
.options(
|
||||
selectinload(ConsultantServiceRequest.managed_client),
|
||||
selectinload(ConsultantServiceRequest.firm_client),
|
||||
selectinload(ConsultantServiceRequest.service_catalogue),
|
||||
)
|
||||
.where(
|
||||
ConsultantServiceRequest.tenant_id == consultant.tenant_id,
|
||||
ConsultantServiceRequest.consultant_id == consultant.id,
|
||||
ConsultantServiceRequest.is_active.is_(True),
|
||||
)
|
||||
.order_by(ConsultantServiceRequest.created_at_utc.desc(), ConsultantServiceRequest.id.desc())
|
||||
.limit(50)
|
||||
).scalars().all()
|
||||
|
||||
return {
|
||||
"columns": columns,
|
||||
"column_options": list(CONSULTANT_BOARD_COLUMNS.items()),
|
||||
"total_tasks": len(latest_by_task),
|
||||
"service_requests": service_requests,
|
||||
}
|
||||
|
||||
|
||||
def get_consultant_assignment_detail(db: Session, *, consultant: ConsultantProfile, task_id: int) -> dict | None:
|
||||
client_ids = _allowed_client_ids(db, consultant=consultant, require_communications=True)
|
||||
if not client_ids:
|
||||
return None
|
||||
row = db.execute(
|
||||
select(ClientServiceTaskInstance, ClientServiceSubscription, Client, ServiceCatalogue)
|
||||
.join(ClientServiceSubscription, ClientServiceSubscription.id == ClientServiceTaskInstance.subscription_id)
|
||||
.join(Client, Client.id == ClientServiceTaskInstance.client_id)
|
||||
.join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceTaskInstance.service_catalogue_id)
|
||||
.where(
|
||||
ClientServiceTaskInstance.id == task_id,
|
||||
ClientServiceTaskInstance.tenant_id == consultant.tenant_id,
|
||||
ClientServiceTaskInstance.client_id.in_(client_ids),
|
||||
ClientServiceTaskInstance.is_active.is_(True),
|
||||
)
|
||||
).first()
|
||||
if not row:
|
||||
return None
|
||||
task, subscription, client, catalogue = row
|
||||
timeline = db.execute(
|
||||
select(ServiceTaskComment)
|
||||
.options(selectinload(ServiceTaskComment.created_by))
|
||||
.where(
|
||||
ServiceTaskComment.tenant_id == consultant.tenant_id,
|
||||
ServiceTaskComment.task_instance_id == task.id,
|
||||
ServiceTaskComment.visibility == "consultant",
|
||||
ServiceTaskComment.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(ServiceTaskComment.created_at_utc.asc(), ServiceTaskComment.id.asc())
|
||||
).scalars().all()
|
||||
engagement_documents = db.execute(
|
||||
select(EngagementDocument)
|
||||
.options(selectinload(EngagementDocument.versions))
|
||||
.where(
|
||||
EngagementDocument.tenant_id == consultant.tenant_id,
|
||||
EngagementDocument.client_id == client.id,
|
||||
EngagementDocument.engagement_id == subscription.id,
|
||||
EngagementDocument.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(EngagementDocument.document_type.asc(), EngagementDocument.title.asc())
|
||||
.limit(50)
|
||||
).scalars().all()
|
||||
permanent_documents = db.execute(
|
||||
select(PermanentClientDocument)
|
||||
.options(selectinload(PermanentClientDocument.versions))
|
||||
.where(
|
||||
PermanentClientDocument.tenant_id == consultant.tenant_id,
|
||||
PermanentClientDocument.client_id == client.id,
|
||||
PermanentClientDocument.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(PermanentClientDocument.category.asc(), PermanentClientDocument.title.asc())
|
||||
.limit(50)
|
||||
).scalars().all()
|
||||
return {
|
||||
"task": task,
|
||||
"subscription": subscription,
|
||||
"client": client,
|
||||
"catalogue": catalogue,
|
||||
"timeline": timeline,
|
||||
"engagement_documents": engagement_documents,
|
||||
"permanent_documents": permanent_documents,
|
||||
}
|
||||
|
||||
|
||||
def get_consultant_document_centre(db: Session, *, consultant: ConsultantProfile, q: str = "") -> dict:
|
||||
client_ids = _allowed_client_ids(db, consultant=consultant, require_communications=False)
|
||||
if not client_ids:
|
||||
return {"engagement_documents": [], "permanent_documents": [], "total": 0}
|
||||
|
||||
engagement_query = (
|
||||
select(EngagementDocument)
|
||||
.options(selectinload(EngagementDocument.client), selectinload(EngagementDocument.engagement), selectinload(EngagementDocument.versions))
|
||||
.where(
|
||||
EngagementDocument.tenant_id == consultant.tenant_id,
|
||||
EngagementDocument.client_id.in_(client_ids),
|
||||
EngagementDocument.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
permanent_query = (
|
||||
select(PermanentClientDocument)
|
||||
.options(selectinload(PermanentClientDocument.client), selectinload(PermanentClientDocument.versions))
|
||||
.where(
|
||||
PermanentClientDocument.tenant_id == consultant.tenant_id,
|
||||
PermanentClientDocument.client_id.in_(client_ids),
|
||||
PermanentClientDocument.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
if (q or "").strip():
|
||||
term = f"%{q.strip()}%"
|
||||
engagement_query = engagement_query.where(
|
||||
or_(EngagementDocument.title.ilike(term), EngagementDocument.document_type.ilike(term), EngagementDocument.document_code.ilike(term))
|
||||
)
|
||||
permanent_query = permanent_query.where(
|
||||
or_(PermanentClientDocument.title.ilike(term), PermanentClientDocument.category.ilike(term), PermanentClientDocument.document_code.ilike(term))
|
||||
)
|
||||
engagement_documents = db.execute(
|
||||
engagement_query.order_by(EngagementDocument.created_at_utc.desc(), EngagementDocument.id.desc()).limit(200)
|
||||
).scalars().all()
|
||||
permanent_documents = db.execute(
|
||||
permanent_query.order_by(PermanentClientDocument.created_at_utc.desc(), PermanentClientDocument.id.desc()).limit(200)
|
||||
).scalars().all()
|
||||
return {
|
||||
"engagement_documents": engagement_documents,
|
||||
"permanent_documents": permanent_documents,
|
||||
"total": len(engagement_documents) + len(permanent_documents),
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
{% set path = request.url.path %}
|
||||
<div class="mb-5 overflow-x-auto rounded-2xl border border-slate-200 bg-white p-2 shadow-soft">
|
||||
<nav class="flex min-w-max gap-2 text-sm font-semibold">
|
||||
<a href="/consultant/dashboard" class="rounded-xl px-3 py-2 {{ 'bg-slate-900 text-white' if path == '/consultant/dashboard' else 'text-slate-700 hover:bg-slate-100' }}">Overview</a>
|
||||
<a href="/consultant/work" class="rounded-xl px-3 py-2 {{ 'bg-slate-900 text-white' if path.startswith('/consultant/work') or path.startswith('/consultant/assignments') else 'text-slate-700 hover:bg-slate-100' }}">My Work Board</a>
|
||||
<a href="/consultant/communications" class="rounded-xl px-3 py-2 {{ 'bg-slate-900 text-white' if path.startswith('/consultant/communications') else 'text-slate-700 hover:bg-slate-100' }}">My Messages</a>
|
||||
<a href="/consultant/documents" class="rounded-xl px-3 py-2 {{ 'bg-slate-900 text-white' if path.startswith('/consultant/documents') else 'text-slate-700 hover:bg-slate-100' }}">Shared Documents</a>
|
||||
<a href="/consultant/service-requests" class="rounded-xl px-3 py-2 {{ 'bg-slate-900 text-white' if path.startswith('/consultant/service-requests') else 'text-slate-700 hover:bg-slate-100' }}">Service Requests</a>
|
||||
<a href="/consultant/managed-clients" class="rounded-xl px-3 py-2 {{ 'bg-slate-900 text-white' if path.startswith('/consultant/managed-clients') else 'text-slate-700 hover:bg-slate-100' }}">Managed Clients</a>
|
||||
<a href="/consultant/workspace" class="rounded-xl px-3 py-2 {{ 'bg-slate-900 text-white' if path.startswith('/consultant/workspace') else 'text-slate-700 hover:bg-slate-100' }}">Workspace</a>
|
||||
<a href="/consultant/profile" class="rounded-xl px-3 py-2 {{ 'bg-slate-900 text-white' if path.startswith('/consultant/profile') else 'text-slate-700 hover:bg-slate-100' }}">My Profile</a>
|
||||
<a href="/alerts" class="rounded-xl px-3 py-2 {{ 'bg-slate-900 text-white' if path.startswith('/alerts') else 'text-slate-700 hover:bg-slate-100' }}">My Alert</a>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -0,0 +1,78 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">{{ client.client_name }} — {{ catalogue.service_name }}</h2>
|
||||
<p class="text-sm text-slate-500">{{ task.task_name }}{% if task.internal_target_date %} • Target {{ task.internal_target_date.strftime('%d-%m-%Y') }}{% endif %}</p>
|
||||
</div>
|
||||
<a href="/consultant/work" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back to My Work Board</a>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[1fr_360px]">
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Task Status</div><div class="mt-1 font-semibold text-slate-900">{{ task.status.replace('_',' ').title() }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Priority</div><div class="mt-1 font-semibold text-slate-900">{{ task.priority.replace('_',' ').title() }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Financial Year</div><div class="mt-1 font-semibold text-slate-900">{{ task.financial_year }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Engagement Status</div><div class="mt-1 font-semibold text-slate-900">{{ subscription.status.replace('_',' ').title() }}</div></div>
|
||||
</div>
|
||||
{% if task.description %}<div class="mt-5 rounded-xl bg-slate-50 p-4 text-sm text-slate-700 whitespace-pre-line">{{ task.description }}</div>{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<div class="border-b border-slate-200 px-5 py-4"><h3 class="font-semibold text-slate-900">Consultant Communication Timeline</h3></div>
|
||||
<div class="divide-y divide-slate-100">
|
||||
{% for note in timeline %}
|
||||
<div class="p-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="text-sm font-semibold text-slate-900">{{ note.comment_type.replace('_',' ').title() }}</div>
|
||||
<div class="text-xs text-slate-500">{{ note.created_at_utc.strftime('%d-%m-%Y %H:%M') if note.created_at_utc else '' }}</div>
|
||||
</div>
|
||||
<div class="mt-2 whitespace-pre-line rounded-xl bg-slate-50 p-3 text-sm text-slate-700">{{ note.message }}</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-6 text-sm text-slate-500">No consultant-visible timeline yet.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/consultant/assignments/{{ task.id }}/reply" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<h3 class="font-semibold text-slate-900">Send Reply / Submit Update</h3>
|
||||
{% if errors %}<div class="mt-3 rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700">{{ errors|join(' ') }}</div>{% endif %}
|
||||
<textarea name="message" rows="5" required placeholder="Type clarification reply, submission note, or work update" class="mt-4 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"></textarea>
|
||||
<div class="mt-4 flex justify-end"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Send Update</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<aside class="space-y-6">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<div class="border-b border-slate-200 px-4 py-3"><h3 class="font-semibold text-slate-900">Engagement Documents</h3></div>
|
||||
<div class="divide-y divide-slate-100">
|
||||
{% for doc in engagement_documents %}
|
||||
<div class="p-4">
|
||||
<div class="font-semibold text-slate-900">{{ doc.title }}</div>
|
||||
<div class="text-xs text-slate-500">{{ doc.document_type }} • v{{ doc.current_version_no }}</div>
|
||||
{% if doc.versions %}<div class="mt-1 text-xs text-slate-500">Latest: {{ doc.versions[0].original_filename }}</div>{% endif %}
|
||||
</div>
|
||||
{% else %}<div class="p-4 text-sm text-slate-500">No shared engagement documents found.</div>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<div class="border-b border-slate-200 px-4 py-3"><h3 class="font-semibold text-slate-900">Permanent Documents</h3></div>
|
||||
<div class="divide-y divide-slate-100">
|
||||
{% for doc in permanent_documents %}
|
||||
<div class="p-4">
|
||||
<div class="font-semibold text-slate-900">{{ doc.title }}</div>
|
||||
<div class="text-xs text-slate-500">{{ doc.category }} • v{{ doc.current_version_no }}</div>
|
||||
</div>
|
||||
{% else %}<div class="p-4 text-sm text-slate-500">No shared permanent documents found.</div>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,59 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
|
||||
<div class="mx-auto max-w-4xl space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Communication Detail</h2>
|
||||
<p class="text-sm text-slate-500">{{ client.client_name }} • {{ catalogue.service_name }} • {{ task.task_name }}</p>
|
||||
</div>
|
||||
<a href="/consultant/communications" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back</a>
|
||||
</div>
|
||||
|
||||
{% if errors %}
|
||||
<div class="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||
{% for error in errors %}<div>{{ error }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="grid gap-4 text-sm md:grid-cols-3">
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Client</div><div class="font-medium text-slate-900">{{ client.client_name }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Service</div><div class="font-medium text-slate-900">{{ catalogue.service_name }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Task Status</div><div class="font-medium text-slate-900">{{ task.status.replace('_',' ').title() }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<h3 class="font-semibold text-slate-900">Consultant-visible Timeline</h3>
|
||||
<div class="mt-4 space-y-4">
|
||||
{% for item in timeline %}
|
||||
<div class="rounded-2xl border border-slate-200 p-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="rounded-full bg-slate-900 px-3 py-1 text-xs font-medium text-white">{{ item.comment_type.replace('_',' ').title() }}</span>
|
||||
<span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700">{{ item.visibility.replace('_',' ').title() }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-slate-500">{{ item.created_at_utc.strftime('%d-%m-%Y %H:%M') if item.created_at_utc else '-' }}</div>
|
||||
</div>
|
||||
<div class="mt-2 text-sm font-medium text-slate-700">{{ item.created_by.full_name or item.created_by.email if item.created_by else 'System' }}</div>
|
||||
<p class="mt-3 whitespace-pre-wrap text-sm leading-6 text-slate-700">{{ item.message }}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="rounded-xl border border-dashed border-slate-300 px-4 py-6 text-center text-sm text-slate-500">No consultant-visible timeline found.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not task.is_locked and not (task.subscription and task.subscription.is_locked) %}
|
||||
<form method="post" action="/consultant/communications/{{ comment.id }}/reply" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Reply to firm</label>
|
||||
<textarea name="message" rows="5" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" placeholder="Type consultant clarification reply..."></textarea>
|
||||
<div class="mt-4 flex justify-end"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Send Reply</button></div>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">This task/engagement is locked. Replies are disabled.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,41 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Consultant Communications</h2>
|
||||
<p class="text-sm text-slate-500">Only task messages marked with Visibility = Consultant are listed here.</p>
|
||||
</div>
|
||||
<a href="/consultant/dashboard" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back to Dashboard</a>
|
||||
</div>
|
||||
|
||||
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="grid gap-3 md:grid-cols-[1fr_auto]">
|
||||
<input name="q" value="{{ q or '' }}" placeholder="Search client, service, task or message" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
<tr><th class="px-4 py-3">Client / Service</th><th class="px-4 py-3">Task</th><th class="px-4 py-3">Type</th><th class="px-4 py-3">Date</th><th class="px-4 py-3 text-right">Action</th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for comment, task, subscription, client, catalogue in rows %}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3"><div class="font-semibold text-slate-900">{{ client.client_name }}</div><div class="text-xs text-slate-500">{{ catalogue.service_name }}</div></td>
|
||||
<td class="px-4 py-3 text-slate-700">{{ task.task_name }}</td>
|
||||
<td class="px-4 py-3"><span class="rounded-full bg-purple-50 px-2 py-1 text-xs font-semibold text-purple-700">{{ comment.comment_type.replace('_',' ').title() }}</span></td>
|
||||
<td class="px-4 py-3 text-slate-500">{{ comment.created_at_utc.strftime('%d-%m-%Y %H:%M') if comment.created_at_utc else '-' }}</td>
|
||||
<td class="px-4 py-3 text-right"><a href="/consultant/communications/{{ comment.id }}" class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Open / Reply</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">No consultant-visible communication found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,12 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-2xl rounded-3xl bg-white p-6 shadow-soft">
|
||||
<h2 class="text-xl font-semibold text-slate-900">Consultant Invite Link Generated</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Share this link with {{ consultant.contact_person }} to set password and activate consultant portal login.</p>
|
||||
<div class="mt-5 rounded-2xl border border-brand-200 bg-brand-50 p-4 text-sm text-brand-900 break-all">{{ invite_url }}</div>
|
||||
<div class="mt-5 flex flex-wrap gap-3">
|
||||
<a href="/consultants/{{ consultant.id }}" class="rounded-xl bg-brand-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-brand-700">Open Consultant</a>
|
||||
<a href="/consultants" class="rounded-xl border border-slate-300 px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50">Back to Consultants</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,70 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Conversion Request: {{ managed_client.client_name }}</h2>
|
||||
<p class="text-sm text-slate-500">Consultant: {{ consultant.firm_name or consultant.contact_person if consultant else '-' }}</p>
|
||||
</div>
|
||||
<a href="/consultants/conversion-requests" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back</a>
|
||||
</div>
|
||||
|
||||
{% if errors %}
|
||||
<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-700 shadow-soft">
|
||||
{% for error in errors %}<div>{{ error }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<h3 class="font-semibold text-slate-900">Managed Client Details</h3>
|
||||
<div class="mt-4 grid gap-4 text-sm md:grid-cols-3">
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Client Code</div><div class="font-medium text-slate-900">{{ managed_client.client_code or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Client Type</div><div class="font-medium text-slate-900">{{ managed_client.client_type }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Conversion Status</div><div class="font-medium text-slate-900">{{ managed_client.conversion_status.replace('_',' ').title() }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">PAN</div><div class="font-medium text-slate-900">{{ managed_client.pan or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">GSTIN</div><div class="font-medium text-slate-900">{{ managed_client.gstin or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Mobile / Email</div><div class="font-medium text-slate-900">{{ managed_client.mobile or managed_client.email or '-' }}</div></div>
|
||||
</div>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<div class="rounded-xl bg-slate-50 p-3 text-sm text-slate-700"><strong>Consultant notes</strong><br>{{ managed_client.conversion_notes or '-' }}</div>
|
||||
<div class="rounded-xl bg-slate-50 p-3 text-sm text-slate-700"><strong>Firm notes</strong><br>{{ managed_client.conversion_firm_notes or '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if managed_client.linked_firm_client %}
|
||||
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-5 text-sm text-emerald-900 shadow-soft">
|
||||
Already converted and linked to firm client: <strong>{{ managed_client.linked_firm_client.client_code }} — {{ managed_client.linked_firm_client.client_name }}</strong>
|
||||
</div>
|
||||
{% else %}
|
||||
<form method="post" action="/consultants/conversion-requests/{{ managed_client.id }}/review" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<h3 class="font-semibold text-slate-900">Firm Review</h3>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<label class="text-sm font-semibold text-slate-700">Action</label>
|
||||
<select name="action" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" required>
|
||||
<option value="under_review">Mark Under Review</option>
|
||||
<option value="approve">Approve & Create Firm Client</option>
|
||||
<option value="reject">Reject</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-semibold text-slate-700">Firm Client Code</label>
|
||||
<input name="client_code" value="FC-{{ managed_client.client_code or managed_client.id }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<p class="mt-1 text-xs text-slate-500">Used only when approving.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-semibold text-slate-700">Partner User ID</label>
|
||||
<input name="partner_user_id" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Optional">
|
||||
<p class="mt-1 text-xs text-slate-500">Optional for now. You can assign partner later from client master.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<label class="text-sm font-semibold text-slate-700">Firm Notes / Reason</label>
|
||||
<textarea name="firm_notes" rows="4" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"></textarea>
|
||||
</div>
|
||||
<button class="mt-4 rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Save Review</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,111 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">{{ consultant.contact_person }}</h2>
|
||||
<p class="text-sm text-slate-500">{{ consultant.firm_name or 'Individual consultant' }}{% if consultant.specialisation %} • {{ consultant.specialisation }}{% endif %}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="/consultants" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back</a>
|
||||
{% if can_manage %}<a href="/consultants/{{ consultant.id }}/edit" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Edit</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft md:col-span-3">
|
||||
<div class="grid gap-4 text-sm md:grid-cols-3">
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Email</div><div class="font-medium text-slate-900">{{ consultant.email or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Mobile</div><div class="font-medium text-slate-900">{{ consultant.mobile or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Type</div><div class="font-medium text-slate-900">{{ consultant.consultant_type.replace('_',' ').title() }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">PAN</div><div class="font-medium text-slate-900">{{ consultant.pan or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">GSTIN</div><div class="font-medium text-slate-900">{{ consultant.gstin or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Login user</div><div class="font-medium text-slate-900">{{ consultant.user.email if consultant.user else '-' }}</div></div>
|
||||
</div>
|
||||
{% if consultant.address or consultant.remarks %}
|
||||
<div class="mt-4 grid gap-4 text-sm md:grid-cols-2">
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Address</div><div class="text-slate-700 whitespace-pre-line">{{ consultant.address or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Remarks</div><div class="text-slate-700 whitespace-pre-line">{{ consultant.remarks or '-' }}</div></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Status</div>
|
||||
<div class="mt-2"><span class="rounded-full px-2 py-1 text-xs font-semibold {% if consultant.is_active %}bg-emerald-50 text-emerald-700{% else %}bg-slate-100 text-slate-500{% endif %}">{{ consultant.status }}</span></div>
|
||||
<div class="mt-4 space-y-2 text-sm text-slate-700">
|
||||
<div>Platform partner: <b>{{ 'Yes' if consultant.is_platform_partner else 'No' }}</b></div>
|
||||
<div>Franchise partner: <b>{{ 'Yes' if consultant.is_franchise_partner else 'No' }}</b></div>
|
||||
<div>SaaS customer: <b>{{ 'Yes' if consultant.is_saas_customer else 'No' }}</b></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if can_link %}
|
||||
<form method="post" action="/consultants/{{ consultant.id }}/links" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<h3 class="mb-4 text-base font-semibold text-slate-900">Link client to consultant</h3>
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="lg:col-span-2">
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Client</label>
|
||||
<select name="client_id" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" required>
|
||||
<option value="">Select client</option>
|
||||
{% for client in clients %}<option value="{{ client.id }}">{{ client.client_name }} ({{ client.client_code }})</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Relationship</label>
|
||||
<select name="relationship_type" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
{% for code,label in relationship_types %}<option value="{{ code }}">{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Remarks</label>
|
||||
<input name="remarks" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-wrap gap-4 text-sm text-slate-700">
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="is_primary"> Primary consultant</label>
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="can_view_client" checked> Client details</label>
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="can_view_services" checked> Services</label>
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="can_view_due_dates" checked> Due dates</label>
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="can_view_communications" checked> Consultant communications</label>
|
||||
</div>
|
||||
<div class="mt-4"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Link Client</button></div>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<div class="border-b border-slate-200 px-4 py-3">
|
||||
<h3 class="font-semibold text-slate-900">Linked Clients</h3>
|
||||
</div>
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
<tr><th class="px-4 py-3">Client</th><th class="px-4 py-3">Relationship</th><th class="px-4 py-3">Access</th><th class="px-4 py-3">Status</th><th class="px-4 py-3 text-right">Action</th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for link in links %}
|
||||
<tr>
|
||||
<td class="px-4 py-3"><div class="font-semibold text-slate-900">{{ link.client.client_name if link.client else '-' }}</div><div class="text-xs text-slate-500">{{ link.client.client_code if link.client else '' }}</div></td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ link.relationship_type.replace('_',' ').title() }}{% if link.is_primary %}<span class="ml-2 rounded-full bg-blue-50 px-2 py-1 text-xs font-semibold text-blue-700">Primary</span>{% endif %}</td>
|
||||
<td class="px-4 py-3 text-xs text-slate-600">
|
||||
Client {{ '✓' if link.can_view_client else '×' }} • Services {{ '✓' if link.can_view_services else '×' }} • Due {{ '✓' if link.can_view_due_dates else '×' }} • Comm {{ '✓' if link.can_view_communications else '×' }}
|
||||
</td>
|
||||
<td class="px-4 py-3"><span class="rounded-full px-2 py-1 text-xs font-semibold {% if link.is_active %}bg-emerald-50 text-emerald-700{% else %}bg-slate-100 text-slate-500{% endif %}">{{ 'Active' if link.is_active else 'Inactive' }}</span></td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
{% if can_link %}
|
||||
<form method="post" action="/consultants/links/{{ link.id }}/toggle" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="active" value="{{ '0' if link.is_active else '1' }}">
|
||||
<button class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">{{ 'Disable' if link.is_active else 'Enable' }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">No linked clients.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,57 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Shared Documents</h2>
|
||||
<p class="text-sm text-slate-500">Documents visible through your active client links. Internal firm-only documents are not listed.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="grid gap-3 md:grid-cols-[1fr_auto]">
|
||||
<input name="q" value="{{ q or '' }}" placeholder="Search document title, code, category or type" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-2">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<div class="border-b border-slate-200 px-5 py-4"><h3 class="font-semibold text-slate-900">Engagement Documents</h3></div>
|
||||
<div class="divide-y divide-slate-100">
|
||||
{% for doc in docs.engagement_documents %}
|
||||
<div class="p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="font-semibold text-slate-900">{{ doc.title }}</div>
|
||||
<div class="text-xs text-slate-500">{{ doc.client.client_name if doc.client else 'Client' }} • {{ doc.document_type }} • {{ doc.financial_year }}</div>
|
||||
</div>
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-600">v{{ doc.current_version_no }}</span>
|
||||
</div>
|
||||
{% if doc.versions %}<div class="mt-2 text-xs text-slate-500">Latest file: {{ doc.versions[0].original_filename }}</div>{% endif %}
|
||||
</div>
|
||||
{% else %}<div class="p-6 text-sm text-slate-500">No engagement documents found.</div>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<div class="border-b border-slate-200 px-5 py-4"><h3 class="font-semibold text-slate-900">Permanent Documents</h3></div>
|
||||
<div class="divide-y divide-slate-100">
|
||||
{% for doc in docs.permanent_documents %}
|
||||
<div class="p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="font-semibold text-slate-900">{{ doc.title }}</div>
|
||||
<div class="text-xs text-slate-500">{{ doc.client.client_name if doc.client else 'Client' }} • {{ doc.category }}</div>
|
||||
</div>
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-600">v{{ doc.current_version_no }}</span>
|
||||
</div>
|
||||
{% if doc.versions %}<div class="mt-2 text-xs text-slate-500">Latest file: {{ doc.versions[0].original_filename }}</div>{% endif %}
|
||||
</div>
|
||||
{% else %}<div class="p-6 text-sm text-slate-500">No permanent documents found.</div>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,136 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% set is_dict = consultant is mapping %}
|
||||
{% set is_edit = consultant and not is_dict and consultant.id %}
|
||||
<div class="mx-auto max-w-4xl space-y-6">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">{{ title }}</h2>
|
||||
<p class="text-sm text-slate-500">Create or update a consultant portal profile and optionally create the consultant login user from this page.</p>
|
||||
</div>
|
||||
<a href="/consultants" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back</a>
|
||||
</div>
|
||||
|
||||
{% if errors %}
|
||||
<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||
<ul class="list-disc pl-5">
|
||||
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" class="space-y-6 rounded-2xl border border-slate-200 bg-white p-6 shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<div class="rounded-2xl border border-brand-100 bg-brand-50 p-4">
|
||||
<h3 class="font-semibold text-slate-900">Consultant Login</h3>
|
||||
<p class="mt-1 text-sm text-slate-600">Link an existing Consultant-role user or create a new login for this consultant.</p>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Existing consultant user</label>
|
||||
{% set current_user_id = consultant.user_id if consultant and not is_dict else consultant.get('user_id') if consultant else None %}
|
||||
<select name="user_id" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="">No existing user selected</option>
|
||||
{% for u in consultant_users %}
|
||||
<option value="{{ u.id }}" {% if current_user_id == u.id %}selected{% endif %}>{{ u.full_name or u.email }} — {{ u.email }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-slate-500">Leave blank if you want to create a new login below.</p>
|
||||
</div>
|
||||
<div class="space-y-2 rounded-xl bg-white p-3">
|
||||
<label class="inline-flex items-center gap-2 text-sm font-medium text-slate-700">
|
||||
<input type="checkbox" name="create_login_user" class="h-4 w-4 rounded border-slate-300" {% if consultant and is_dict and consultant.get('create_login_user') %}checked{% endif %}>
|
||||
Create / enable consultant login
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="invite_login_user" class="h-4 w-4 rounded border-slate-300" {% if consultant and is_dict and consultant.get('invite_login_user') %}checked{% endif %}>
|
||||
Generate invite link instead of using temporary password
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Login ID / Email</label>
|
||||
<input type="email" name="login_email" value="{{ consultant.get('login_email','') if consultant and is_dict else consultant.email if consultant and not is_dict else '' }}" placeholder="consultant@example.com" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Temporary password</label>
|
||||
<input type="password" name="temporary_password" placeholder="Minimum 8 characters" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<p class="mt-1 text-xs text-slate-500">Used only when invite link is not selected. Consultant must change password after login.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Consultant type</label>
|
||||
{% set current_type = consultant.consultant_type if consultant and not is_dict else consultant.get('consultant_type') if consultant else 'external_consultant' %}
|
||||
<select name="consultant_type" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
{% for code,label in consultant_types %}<option value="{{ code }}" {% if current_type == code %}selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Contact person / Consultant name *</label>
|
||||
<input name="contact_person" value="{{ consultant.contact_person if consultant and not is_dict else consultant.get('contact_person','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Firm name</label>
|
||||
<input name="firm_name" value="{{ consultant.firm_name if consultant and not is_dict else consultant.get('firm_name','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Email</label>
|
||||
<input type="email" name="email" value="{{ consultant.email if consultant and not is_dict else consultant.get('email','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Mobile</label>
|
||||
<input name="mobile" value="{{ consultant.mobile if consultant and not is_dict else consultant.get('mobile','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">PAN</label>
|
||||
<input name="pan" value="{{ consultant.pan if consultant and not is_dict else consultant.get('pan','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">GSTIN</label>
|
||||
<input name="gstin" value="{{ consultant.gstin if consultant and not is_dict else consultant.get('gstin','') if consultant else '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Specialisation</label>
|
||||
<input name="specialisation" value="{{ consultant.specialisation if consultant and not is_dict else consultant.get('specialisation','') if consultant else '' }}" placeholder="GST, ROC, Payroll, Accounts, Tax filing" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Address</label>
|
||||
<textarea name="address" rows="2" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ consultant.address if consultant and not is_dict else consultant.get('address','') if consultant else '' }}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Status</label>
|
||||
{% set st = consultant.status if consultant and not is_dict else consultant.get('status') if consultant else 'active' %}
|
||||
<select name="status" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
{% for code in ['active','inactive','on_hold','suspended'] %}<option value="{{ code }}" {% if st == code %}selected{% endif %}>{{ code.replace('_',' ').title() }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Onboarding status</label>
|
||||
{% set os = consultant.onboarding_status if consultant and not is_dict else consultant.get('onboarding_status') if consultant else 'active' %}
|
||||
<select name="onboarding_status" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
{% for code,label in onboarding_statuses %}<option value="{{ code }}" {% if os == code %}selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 rounded-2xl bg-slate-50 p-4 text-sm text-slate-700 md:grid-cols-4">
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="is_platform_partner" {% if consultant and ((not is_dict and consultant.is_platform_partner) or (is_dict and consultant.get('is_platform_partner'))) %}checked{% endif %}> Platform partner</label>
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="is_franchise_partner" {% if consultant and ((not is_dict and consultant.is_franchise_partner) or (is_dict and consultant.get('is_franchise_partner'))) %}checked{% endif %}> Franchise partner</label>
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="is_saas_customer" {% if consultant and ((not is_dict and consultant.is_saas_customer) or (is_dict and consultant.get('is_saas_customer'))) %}checked{% endif %}> SaaS customer</label>
|
||||
<label class="inline-flex items-center gap-2"><input type="checkbox" name="is_active" {% if not consultant or (consultant and ((not is_dict and consultant.is_active) or (is_dict and consultant.get('is_active', True)))) %}checked{% endif %}> Active</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Remarks</label>
|
||||
<textarea name="remarks" rows="3" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ consultant.remarks if consultant and not is_dict else consultant.get('remarks','') if consultant else '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<a href="/consultants" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Cancel</a>
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Save Consultant</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,59 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Consultant Client Conversion Requests</h2>
|
||||
<p class="text-sm text-slate-500">Review consultant-managed clients requested for conversion into firm client master.</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="/consultants" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Consultants</a>
|
||||
<a href="/consultants/service-requests" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Service Requests</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="grid gap-3 md:grid-cols-[1fr_auto]">
|
||||
<select name="status" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="">All conversion statuses</option>
|
||||
{% for code, label in conversion_statuses %}
|
||||
<option value="{{ code }}" {% if status == code %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Managed Client</th>
|
||||
<th class="px-4 py-3">Consultant</th>
|
||||
<th class="px-4 py-3">PAN / GSTIN</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Requested</th>
|
||||
<th class="px-4 py-3 text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for row in rows %}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3">
|
||||
<div class="font-semibold text-slate-900">{{ row.client_name }}</div>
|
||||
<div class="text-xs text-slate-500">{{ row.client_code or '-' }}{% if row.linked_firm_client %} • Linked: {{ row.linked_firm_client.client_code }}{% endif %}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ row.consultant.firm_name or row.consultant.contact_person if row.consultant else '-' }}</td>
|
||||
<td class="px-4 py-3 text-slate-600"><div>{{ row.pan or '-' }}</div><div class="text-xs">{{ row.gstin or '-' }}</div></td>
|
||||
<td class="px-4 py-3"><span class="rounded-full bg-blue-50 px-2 py-1 text-xs font-semibold text-blue-700">{{ row.conversion_status.replace('_',' ').title() }}</span></td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ row.conversion_requested_at_utc.strftime('%d-%m-%Y') if row.conversion_requested_at_utc else '-' }}</td>
|
||||
<td class="px-4 py-3 text-right"><a href="/consultants/conversion-requests/{{ row.id }}" class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Review</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="px-4 py-8 text-center text-slate-500">No conversion requests found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,8 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3"><div><h2 class="text-xl font-semibold text-slate-900">Consultant Service Requests</h2><p class="text-sm text-slate-500">Review requests raised by consultants.</p></div><a href="/consultants" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Consultants</a></div>
|
||||
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="grid gap-3 md:grid-cols-[1fr_auto]"><select name="status" class="rounded-xl border border-slate-300 px-3 py-2 text-sm"><option value="">All statuses</option>{% for code,label in service_request_statuses %}<option value="{{ code }}" {% if status == code %}selected{% endif %}>{{ label }}</option>{% endfor %}</select><button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Filter</button></div></form>
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft"><table class="min-w-full divide-y divide-slate-200 text-sm"><thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500"><tr><th class="px-4 py-3">Request</th><th class="px-4 py-3">Consultant</th><th class="px-4 py-3">Client</th><th class="px-4 py-3">Service</th><th class="px-4 py-3">Status</th><th class="px-4 py-3 text-right">Action</th></tr></thead><tbody class="divide-y divide-slate-100">{% for row in rows %}<tr class="hover:bg-slate-50"><td class="px-4 py-3"><div class="font-semibold text-slate-900">{{ row.request_no }}</div><div class="text-xs text-slate-500">{{ row.subject }}</div></td><td class="px-4 py-3 text-slate-700">{{ row.consultant.contact_person if row.consultant else '-' }}</td><td class="px-4 py-3 text-slate-700">{{ row.managed_client.client_name if row.managed_client else (row.firm_client.client_name if row.firm_client else '-') }}</td><td class="px-4 py-3 text-slate-700">{{ row.requested_service_name }}</td><td class="px-4 py-3"><span class="rounded-full bg-blue-50 px-2 py-1 text-xs font-semibold text-blue-700">{{ row.status.replace('_',' ').title() }}</span></td><td class="px-4 py-3 text-right"><a href="/consultants/service-requests/{{ row.id }}" class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Open</a></td></tr>{% else %}<tr><td colspan="6" class="px-4 py-8 text-center text-slate-500">No consultant service requests found.</td></tr>{% endfor %}</tbody></table></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,71 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Consultants</h2>
|
||||
<p class="text-sm text-slate-500">Portal-enabled consultants, franchise partners, and external ecosystem collaborators.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% if can_manage_consultant_service_requests(current_user, current_user_permissions, current_user_roles) %}
|
||||
<a href="/consultants/service-requests" class="inline-flex rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Service Requests</a>
|
||||
{% endif %}
|
||||
{% if can_manage_consultant_conversions(current_user, current_user_permissions, current_user_roles) %}
|
||||
<a href="/consultants/conversion-requests" class="inline-flex rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Conversions</a>
|
||||
{% endif %}
|
||||
{% if can_manage %}
|
||||
<a href="/consultants/new" class="inline-flex rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-brand-700">Add Consultant</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="grid gap-3 md:grid-cols-[1fr_auto_auto]">
|
||||
<input name="q" value="{{ q or '' }}" placeholder="Search by name, firm, email, mobile, specialisation" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<label class="inline-flex items-center gap-2 rounded-xl border border-slate-300 px-3 py-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="include_inactive" value="1" {% if include_inactive %}checked{% endif %}> Include inactive
|
||||
</label>
|
||||
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Consultant</th>
|
||||
<th class="px-4 py-3">Contact</th>
|
||||
<th class="px-4 py-3">Type</th>
|
||||
<th class="px-4 py-3">Specialisation</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3 text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for row in rows %}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3">
|
||||
<div class="font-semibold text-slate-900">{{ row.contact_person }}</div>
|
||||
<div class="text-xs text-slate-500">{{ row.firm_name or 'Individual consultant' }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-600">
|
||||
<div>{{ row.email or '-' }}</div>
|
||||
<div class="text-xs">{{ row.mobile or '-' }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ row.consultant_type.replace('_', ' ').title() }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ row.specialisation or '-' }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="rounded-full px-2 py-1 text-xs font-semibold {% if row.is_active %}bg-emerald-50 text-emerald-700{% else %}bg-slate-100 text-slate-500{% endif %}">{{ row.status }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<a href="/consultants/{{ row.id }}" class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Open</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="px-4 py-8 text-center text-slate-500">No consultants found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,64 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/consultants/templates/consultants/_consultant_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">{{ managed_client.client_name }}</h2>
|
||||
<p class="text-sm text-slate-500">{{ managed_client.client_code or 'Managed client' }}{% if managed_client.trade_name %} • {{ managed_client.trade_name }}{% endif %}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="/consultant/managed-clients" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back</a>
|
||||
<a href="/consultant/managed-clients/{{ managed_client.id }}/edit" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Edit</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="grid gap-4 text-sm md:grid-cols-3">
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Client Type</div><div class="font-medium text-slate-900">{{ managed_client.client_type }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">PAN</div><div class="font-medium text-slate-900">{{ managed_client.pan or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">GSTIN</div><div class="font-medium text-slate-900">{{ managed_client.gstin or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Contact Person</div><div class="font-medium text-slate-900">{{ managed_client.contact_person_name or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Mobile</div><div class="font-medium text-slate-900">{{ managed_client.mobile or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Email</div><div class="font-medium text-slate-900">{{ managed_client.email or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Stage</div><div class="font-medium text-slate-900">{{ managed_client.relationship_stage.replace('_',' ').title() }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Status</div><div class="font-medium text-slate-900">{{ managed_client.status.replace('_',' ').title() }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Active</div><div class="font-medium text-slate-900">{{ 'Yes' if managed_client.is_active else 'No' }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<h3 class="font-semibold text-slate-900">Firm Client Conversion</h3>
|
||||
<div class="mt-3 grid gap-4 text-sm md:grid-cols-3">
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Conversion Status</div><div class="font-medium text-slate-900">{{ managed_client.conversion_status.replace('_',' ').title() if managed_client.conversion_status else 'Not Requested' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Requested On</div><div class="font-medium text-slate-900">{{ managed_client.conversion_requested_at_utc.strftime('%d-%m-%Y %H:%M') if managed_client.conversion_requested_at_utc else '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Linked Firm Client</div><div class="font-medium text-slate-900">{% if managed_client.linked_firm_client %}{{ managed_client.linked_firm_client.client_name }}{% else %}-{% endif %}</div></div>
|
||||
</div>
|
||||
{% if managed_client.conversion_notes %}<div class="mt-3 rounded-xl bg-slate-50 p-3 text-sm text-slate-700"><strong>Consultant notes:</strong><br>{{ managed_client.conversion_notes }}</div>{% endif %}
|
||||
{% if managed_client.conversion_firm_notes %}<div class="mt-3 rounded-xl bg-blue-50 p-3 text-sm text-blue-900"><strong>Firm response:</strong><br>{{ managed_client.conversion_firm_notes }}</div>{% endif %}
|
||||
{% if can_request_conversion %}
|
||||
<form method="post" action="/consultant/managed-clients/{{ managed_client.id }}/request-conversion" class="mt-4 space-y-3">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label class="block text-sm font-semibold text-slate-700">Request conversion to audit firm client</label>
|
||||
<textarea name="conversion_notes" rows="3" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Mention service requirement, preferred partner, urgency, or client background"></textarea>
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Request Conversion</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<h3 class="font-semibold text-slate-900">Address</h3>
|
||||
<p class="mt-3 whitespace-pre-line text-sm text-slate-700">{{ [managed_client.address_line_1, managed_client.address_line_2, managed_client.city, managed_client.state, managed_client.pincode, managed_client.country] | select | join('\n') or '-' }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<h3 class="font-semibold text-slate-900">Service Interest / Notes</h3>
|
||||
<div class="mt-3 space-y-3 text-sm text-slate-700">
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Service Interest</div><div class="whitespace-pre-line">{{ managed_client.service_interest or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase tracking-wide text-slate-500">Notes</div><div class="whitespace-pre-line">{{ managed_client.notes or '-' }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user