Prepare ERP source for Gitea deployment
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Employee core module for Audit Firm v2."""
|
||||
@@ -0,0 +1,543 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security.passwords import hash_password
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.core.iam.scope import validate_branch_matches_tenant
|
||||
from app.modules.core.rbac.models import Role, UserRole
|
||||
from app.modules.employees.models import (
|
||||
Employee,
|
||||
EmployeeLeaveBalance,
|
||||
EmployeeLeaveType,
|
||||
EmployeeSalaryStructure,
|
||||
)
|
||||
from app.modules.employees.service import EMPLOYEE_ROLE_NAMES, EMPLOYEE_STATUS, EMPLOYMENT_TYPES, EmployeeScope
|
||||
|
||||
|
||||
IMPORT_TYPES = {
|
||||
"employees": "Employees / Staff",
|
||||
"leave_types": "Leave Types",
|
||||
"leave_balances": "Leave Balances",
|
||||
"salary_structures": "Salary Structures",
|
||||
}
|
||||
|
||||
TEMPLATE_HEADERS: dict[str, list[str]] = {
|
||||
"employees": [
|
||||
"employee_code", "full_name", "email", "mobile", "alternate_mobile", "department", "designation",
|
||||
"employment_type", "date_of_joining", "status", "pan", "uan", "esi_no", "pf_no", "aadhaar_last4",
|
||||
"bank_name", "bank_account_no", "bank_ifsc", "address", "emergency_contact_name",
|
||||
"emergency_contact_mobile", "branch_id", "reporting_manager_email", "create_user", "login_email",
|
||||
"temporary_password", "employee_role", "notes",
|
||||
],
|
||||
"leave_types": [
|
||||
"code", "name", "description", "annual_quota_days", "carry_forward_allowed", "allow_negative_balance",
|
||||
"requires_approval", "is_paid", "is_active", "branch_id",
|
||||
],
|
||||
"leave_balances": [
|
||||
"employee_code", "leave_code", "opening_days", "credited_days", "availed_days", "adjusted_days", "balance_days", "branch_id",
|
||||
],
|
||||
"salary_structures": [
|
||||
"employee_code", "effective_from", "effective_to", "pay_cycle", "monthly_ctc_amount", "basic_amount",
|
||||
"hra_amount", "allowance_amount", "employee_pf_amount", "employee_esi_amount", "professional_tax_amount",
|
||||
"tds_amount", "other_deduction_amount", "is_active", "remarks", "branch_id",
|
||||
],
|
||||
}
|
||||
|
||||
SAMPLE_ROWS: dict[str, list[Any]] = {
|
||||
"employees": [
|
||||
"EMP001", "Sample Staff", "staff@example.com", "9999999999", "", "Audit", "Associate",
|
||||
"full_time", "2026-04-01", "active", "ABCDE1234F", "", "", "", "1234", "Bank", "1234567890", "IFSC0000001",
|
||||
"Office address", "Emergency Contact", "9999999998", "", "", "no", "", "", "Staff", "Sample only - delete before import",
|
||||
],
|
||||
"leave_types": ["CL", "Casual Leave", "Casual leave", 12, "yes", "no", "yes", "yes", "yes", ""],
|
||||
"leave_balances": ["EMP001", "CL", 0, 12, 0, 0, 12, ""],
|
||||
"salary_structures": ["EMP001", "2026-04-01", "", "monthly", 30000, 15000, 6000, 9000, 0, 0, 0, 0, 0, "yes", "Initial structure", ""],
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImportRowResult:
|
||||
row_no: int
|
||||
status: str
|
||||
action: str
|
||||
data: dict[str, Any]
|
||||
messages: list[str]
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {"row_no": self.row_no, "status": self.status, "action": self.action, "data": self.data, "messages": self.messages}
|
||||
|
||||
|
||||
def supported_import_types() -> dict[str, str]:
|
||||
return IMPORT_TYPES.copy()
|
||||
|
||||
|
||||
def normalize_import_type(import_type: str) -> str:
|
||||
import_type = (import_type or "").strip().lower()
|
||||
if import_type not in IMPORT_TYPES:
|
||||
raise HTTPException(status_code=400, detail="Unsupported HR import type.")
|
||||
return import_type
|
||||
|
||||
|
||||
def _safe_excel_sheet_title(title: str) -> str:
|
||||
"""Return a valid Excel worksheet title for openpyxl."""
|
||||
invalid_chars = {"\\", "/", "?", "*", "[", "]", ":"}
|
||||
safe_title = "".join("-" if ch in invalid_chars else ch for ch in (title or "Sheet"))
|
||||
safe_title = safe_title.strip() or "Sheet"
|
||||
return safe_title[:31]
|
||||
|
||||
|
||||
def build_template_workbook(import_type: str) -> bytes:
|
||||
import_type = normalize_import_type(import_type)
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = _safe_excel_sheet_title(IMPORT_TYPES[import_type])
|
||||
headers = TEMPLATE_HEADERS[import_type]
|
||||
ws.append(headers)
|
||||
ws.append(SAMPLE_ROWS[import_type])
|
||||
for col_no, header in enumerate(headers, start=1):
|
||||
ws.cell(row=1, column=col_no).font = ws.cell(row=1, column=col_no).font.copy(bold=True)
|
||||
ws.column_dimensions[ws.cell(row=1, column=col_no).column_letter].width = max(14, min(28, len(header) + 4))
|
||||
bio = BytesIO()
|
||||
wb.save(bio)
|
||||
return bio.getvalue()
|
||||
|
||||
|
||||
def parse_workbook_rows(content: bytes) -> list[dict[str, Any]]:
|
||||
try:
|
||||
wb = load_workbook(BytesIO(content), data_only=True)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=f"Unable to read Excel file: {exc}")
|
||||
ws = wb.active
|
||||
raw_headers = [str(cell.value or "").strip().lower() for cell in ws[1]]
|
||||
headers = [h for h in raw_headers]
|
||||
if not any(headers):
|
||||
raise HTTPException(status_code=400, detail="Excel file has no header row.")
|
||||
rows: list[dict[str, Any]] = []
|
||||
for row_no, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2):
|
||||
if not any(cell not in (None, "") for cell in row):
|
||||
continue
|
||||
item = {headers[idx]: _cell_value(row[idx] if idx < len(row) else None) for idx in range(len(headers)) if headers[idx]}
|
||||
item["_row_no"] = row_no
|
||||
rows.append(item)
|
||||
return rows
|
||||
|
||||
|
||||
def preview_import(db: Session, scope: EmployeeScope, import_type: str, content: bytes) -> dict[str, Any]:
|
||||
import_type = normalize_import_type(import_type)
|
||||
rows = parse_workbook_rows(content)
|
||||
results: list[ImportRowResult] = []
|
||||
for row in rows:
|
||||
if import_type == "employees":
|
||||
results.append(_preview_employee(db, scope, row))
|
||||
elif import_type == "leave_types":
|
||||
results.append(_preview_leave_type(db, scope, row))
|
||||
elif import_type == "leave_balances":
|
||||
results.append(_preview_leave_balance(db, scope, row))
|
||||
elif import_type == "salary_structures":
|
||||
results.append(_preview_salary_structure(db, scope, row))
|
||||
valid = sum(1 for r in results if r.status == "valid")
|
||||
warning = sum(1 for r in results if r.status == "warning")
|
||||
error = sum(1 for r in results if r.status == "error")
|
||||
return {
|
||||
"import_type": import_type,
|
||||
"import_label": IMPORT_TYPES[import_type],
|
||||
"rows": [r.as_dict() for r in results],
|
||||
"summary": {"total": len(results), "valid": valid, "warning": warning, "error": error},
|
||||
}
|
||||
|
||||
|
||||
def commit_import(db: Session, actor: User, scope: EmployeeScope, preview: dict[str, Any]) -> dict[str, Any]:
|
||||
import_type = normalize_import_type(preview.get("import_type"))
|
||||
created = updated = skipped = failed = 0
|
||||
errors: list[str] = []
|
||||
for row in preview.get("rows", []):
|
||||
if row.get("status") == "error":
|
||||
skipped += 1
|
||||
continue
|
||||
try:
|
||||
action = row.get("action") or "create"
|
||||
data = row.get("data") or {}
|
||||
if import_type == "employees":
|
||||
action = _commit_employee(db, actor, scope, data)
|
||||
elif import_type == "leave_types":
|
||||
action = _commit_leave_type(db, actor, scope, data)
|
||||
elif import_type == "leave_balances":
|
||||
action = _commit_leave_balance(db, actor, scope, data)
|
||||
elif import_type == "salary_structures":
|
||||
action = _commit_salary_structure(db, actor, scope, data)
|
||||
if action == "updated":
|
||||
updated += 1
|
||||
else:
|
||||
created += 1
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
failed += 1
|
||||
errors.append(f"Row {row.get('row_no')}: {getattr(exc, 'detail', str(exc))}")
|
||||
return {"created": created, "updated": updated, "skipped": skipped, "failed": failed, "errors": errors}
|
||||
|
||||
|
||||
def _cell_value(value: Any) -> Any:
|
||||
if isinstance(value, datetime):
|
||||
return value.date().isoformat()
|
||||
if isinstance(value, date):
|
||||
return value.isoformat()
|
||||
if isinstance(value, str):
|
||||
return value.strip()
|
||||
return value
|
||||
|
||||
|
||||
def _blank(value: Any) -> Any:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str) and not value.strip():
|
||||
return None
|
||||
return value.strip() if isinstance(value, str) else value
|
||||
|
||||
|
||||
def _str(value: Any, default: str = "") -> str:
|
||||
value = _blank(value)
|
||||
return str(value).strip() if value is not None else default
|
||||
|
||||
|
||||
def _lower(value: Any, default: str = "") -> str:
|
||||
return _str(value, default).lower()
|
||||
|
||||
|
||||
def _upper(value: Any, default: str = "") -> str:
|
||||
return _str(value, default).upper()
|
||||
|
||||
|
||||
def _bool(value: Any, default: bool = False) -> bool:
|
||||
value = _blank(value)
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
return str(value).strip().lower() in {"1", "true", "yes", "y", "on", "active"}
|
||||
|
||||
|
||||
def _int(value: Any, default: int = 0) -> int:
|
||||
value = _blank(value)
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return int(round(float(value)))
|
||||
except Exception:
|
||||
raise ValueError(f"Invalid integer/amount value: {value}")
|
||||
|
||||
|
||||
def _date(value: Any) -> date | None:
|
||||
value = _blank(value)
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
text = str(value).strip()
|
||||
for fmt in ("%Y-%m-%d", "%d-%m-%Y", "%d/%m/%Y", "%m/%d/%Y"):
|
||||
try:
|
||||
return datetime.strptime(text, fmt).date()
|
||||
except ValueError:
|
||||
pass
|
||||
return date.fromisoformat(text)
|
||||
|
||||
|
||||
def _branch_id(scope: EmployeeScope, row: dict[str, Any]) -> int:
|
||||
raw = _blank(row.get("branch_id"))
|
||||
if raw is not None:
|
||||
branch_id = int(raw)
|
||||
elif scope.branch_id is not None:
|
||||
branch_id = int(scope.branch_id)
|
||||
else:
|
||||
raise ValueError("Branch is required. Select active branch or provide branch_id in Excel.")
|
||||
validate_branch_matches_tenant(_CURRENT_DB.get(), scope.tenant_id, branch_id)
|
||||
return branch_id
|
||||
|
||||
|
||||
class _DbHolder:
|
||||
def __init__(self):
|
||||
self.db = None
|
||||
def set(self, db):
|
||||
self.db = db
|
||||
def get(self):
|
||||
return self.db
|
||||
|
||||
_CURRENT_DB = _DbHolder()
|
||||
|
||||
|
||||
def _employee_by_code(db: Session, tenant_id: int, code: str) -> Employee | None:
|
||||
return db.execute(select(Employee).where(Employee.tenant_id == tenant_id, Employee.employee_code == code)).scalar_one_or_none()
|
||||
|
||||
|
||||
def _leave_type_by_code(db: Session, tenant_id: int, branch_id: int, code: str) -> EmployeeLeaveType | None:
|
||||
return db.execute(select(EmployeeLeaveType).where(EmployeeLeaveType.tenant_id == tenant_id, EmployeeLeaveType.branch_id == branch_id, EmployeeLeaveType.code == code)).scalar_one_or_none()
|
||||
|
||||
|
||||
def _preview_employee(db: Session, scope: EmployeeScope, row: dict[str, Any]) -> ImportRowResult:
|
||||
_CURRENT_DB.set(db)
|
||||
messages: list[str] = []
|
||||
data: dict[str, Any] = {"tenant_id": scope.tenant_id}
|
||||
try:
|
||||
data["branch_id"] = _branch_id(scope, row)
|
||||
data["employee_code"] = _upper(row.get("employee_code"))
|
||||
data["full_name"] = _str(row.get("full_name"))
|
||||
if not data["employee_code"] or not data["full_name"]:
|
||||
raise ValueError("employee_code and full_name are required.")
|
||||
data["email"] = _str(row.get("email")) or None
|
||||
data["mobile"] = _str(row.get("mobile")) or None
|
||||
data["alternate_mobile"] = _str(row.get("alternate_mobile")) or None
|
||||
data["department"] = _str(row.get("department")) or None
|
||||
data["designation"] = _str(row.get("designation")) or None
|
||||
data["employment_type"] = _lower(row.get("employment_type"), "full_time")
|
||||
if data["employment_type"] not in EMPLOYMENT_TYPES:
|
||||
raise ValueError(f"Invalid employment_type: {data['employment_type']}.")
|
||||
data["date_of_joining"] = _date(row.get("date_of_joining")).isoformat() if _date(row.get("date_of_joining")) else None
|
||||
data["status"] = _lower(row.get("status"), "active")
|
||||
if data["status"] not in EMPLOYEE_STATUS:
|
||||
raise ValueError(f"Invalid status: {data['status']}.")
|
||||
for key in ("pan", "uan", "esi_no", "pf_no", "aadhaar_last4", "bank_name", "bank_account_no", "bank_ifsc", "address", "emergency_contact_name", "emergency_contact_mobile", "notes"):
|
||||
data[key] = _str(row.get(key)) or None
|
||||
data["create_user"] = _bool(row.get("create_user"), False)
|
||||
data["login_email"] = (_str(row.get("login_email")) or data["email"] or "").lower() or None
|
||||
data["temporary_password"] = _str(row.get("temporary_password")) or None
|
||||
data["employee_role"] = _str(row.get("employee_role"), "Staff") or "Staff"
|
||||
mgr_email = _str(row.get("reporting_manager_email"))
|
||||
if mgr_email:
|
||||
mgr = db.execute(select(User).where(User.email == mgr_email.lower(), User.tenant_id == scope.tenant_id)).scalar_one_or_none()
|
||||
if not mgr:
|
||||
messages.append("Reporting manager email was not found; manager will be blank.")
|
||||
else:
|
||||
data["reporting_manager_user_id"] = mgr.id
|
||||
existing = _employee_by_code(db, scope.tenant_id, data["employee_code"])
|
||||
if existing and existing.branch_id != data["branch_id"]:
|
||||
raise ValueError("Employee code exists in another branch of this tenant.")
|
||||
if data["create_user"]:
|
||||
if not data["login_email"]:
|
||||
raise ValueError("login_email/email is required when create_user is yes.")
|
||||
if not existing and (not data["temporary_password"] or len(data["temporary_password"]) < 8):
|
||||
raise ValueError("temporary_password must be at least 8 characters when creating user.")
|
||||
if data["employee_role"] not in EMPLOYEE_ROLE_NAMES:
|
||||
messages.append("Invalid employee_role; Staff will be used.")
|
||||
data["employee_role"] = "Staff"
|
||||
return ImportRowResult(int(row.get("_row_no", 0)), "warning" if messages else "valid", "update" if existing else "create", data, messages)
|
||||
except Exception as exc:
|
||||
return ImportRowResult(int(row.get("_row_no", 0)), "error", "skip", data, [str(exc)])
|
||||
|
||||
|
||||
def _preview_leave_type(db: Session, scope: EmployeeScope, row: dict[str, Any]) -> ImportRowResult:
|
||||
_CURRENT_DB.set(db)
|
||||
data: dict[str, Any] = {"tenant_id": scope.tenant_id}
|
||||
try:
|
||||
data["branch_id"] = _branch_id(scope, row)
|
||||
data["code"] = _upper(row.get("code"))
|
||||
data["name"] = _str(row.get("name"))
|
||||
if not data["code"] or not data["name"]:
|
||||
raise ValueError("code and name are required.")
|
||||
data["description"] = _str(row.get("description")) or None
|
||||
data["annual_quota_days"] = _int(row.get("annual_quota_days"), 0)
|
||||
data["carry_forward_allowed"] = _bool(row.get("carry_forward_allowed"), False)
|
||||
data["allow_negative_balance"] = _bool(row.get("allow_negative_balance"), False)
|
||||
data["requires_approval"] = _bool(row.get("requires_approval"), True)
|
||||
data["is_paid"] = _bool(row.get("is_paid"), True)
|
||||
data["is_active"] = _bool(row.get("is_active"), True)
|
||||
existing = _leave_type_by_code(db, scope.tenant_id, data["branch_id"], data["code"])
|
||||
return ImportRowResult(int(row.get("_row_no", 0)), "valid", "update" if existing else "create", data, [])
|
||||
except Exception as exc:
|
||||
return ImportRowResult(int(row.get("_row_no", 0)), "error", "skip", data, [str(exc)])
|
||||
|
||||
|
||||
def _preview_leave_balance(db: Session, scope: EmployeeScope, row: dict[str, Any]) -> ImportRowResult:
|
||||
_CURRENT_DB.set(db)
|
||||
data: dict[str, Any] = {"tenant_id": scope.tenant_id}
|
||||
try:
|
||||
data["branch_id"] = _branch_id(scope, row)
|
||||
data["employee_code"] = _upper(row.get("employee_code"))
|
||||
data["leave_code"] = _upper(row.get("leave_code"))
|
||||
emp = _employee_by_code(db, scope.tenant_id, data["employee_code"])
|
||||
if not emp or emp.branch_id != data["branch_id"]:
|
||||
raise ValueError("Employee not found in selected/provided branch.")
|
||||
lt = _leave_type_by_code(db, scope.tenant_id, data["branch_id"], data["leave_code"])
|
||||
if not lt:
|
||||
raise ValueError("Leave type not found for selected/provided branch.")
|
||||
data["employee_id"] = emp.id
|
||||
data["leave_type_id"] = lt.id
|
||||
for key in ("opening_days", "credited_days", "availed_days", "adjusted_days"):
|
||||
data[key] = _int(row.get(key), 0)
|
||||
data["balance_days"] = _int(row.get("balance_days"), data["opening_days"] + data["credited_days"] + data["adjusted_days"] - data["availed_days"])
|
||||
existing = db.execute(select(EmployeeLeaveBalance).where(EmployeeLeaveBalance.tenant_id == scope.tenant_id, EmployeeLeaveBalance.employee_id == emp.id, EmployeeLeaveBalance.leave_type_id == lt.id)).scalar_one_or_none()
|
||||
return ImportRowResult(int(row.get("_row_no", 0)), "valid", "update" if existing else "create", data, [])
|
||||
except Exception as exc:
|
||||
return ImportRowResult(int(row.get("_row_no", 0)), "error", "skip", data, [str(exc)])
|
||||
|
||||
|
||||
def _preview_salary_structure(db: Session, scope: EmployeeScope, row: dict[str, Any]) -> ImportRowResult:
|
||||
_CURRENT_DB.set(db)
|
||||
data: dict[str, Any] = {"tenant_id": scope.tenant_id}
|
||||
try:
|
||||
data["branch_id"] = _branch_id(scope, row)
|
||||
data["employee_code"] = _upper(row.get("employee_code"))
|
||||
emp = _employee_by_code(db, scope.tenant_id, data["employee_code"])
|
||||
if not emp or emp.branch_id != data["branch_id"]:
|
||||
raise ValueError("Employee not found in selected/provided branch.")
|
||||
data["employee_id"] = emp.id
|
||||
eff = _date(row.get("effective_from"))
|
||||
if not eff:
|
||||
raise ValueError("effective_from is required.")
|
||||
data["effective_from"] = eff.isoformat()
|
||||
eff_to = _date(row.get("effective_to"))
|
||||
data["effective_to"] = eff_to.isoformat() if eff_to else None
|
||||
if eff_to and eff_to < eff:
|
||||
raise ValueError("effective_to cannot be before effective_from.")
|
||||
data["pay_cycle"] = _lower(row.get("pay_cycle"), "monthly")
|
||||
for key in ("monthly_ctc_amount", "basic_amount", "hra_amount", "allowance_amount", "employee_pf_amount", "employee_esi_amount", "professional_tax_amount", "tds_amount", "other_deduction_amount"):
|
||||
data[key] = _int(row.get(key), 0)
|
||||
data["is_active"] = _bool(row.get("is_active"), True)
|
||||
data["remarks"] = _str(row.get("remarks")) or None
|
||||
existing = db.execute(select(EmployeeSalaryStructure).where(EmployeeSalaryStructure.tenant_id == scope.tenant_id, EmployeeSalaryStructure.employee_id == emp.id, EmployeeSalaryStructure.effective_from == eff)).scalar_one_or_none()
|
||||
return ImportRowResult(int(row.get("_row_no", 0)), "valid", "update" if existing else "create", data, [])
|
||||
except Exception as exc:
|
||||
return ImportRowResult(int(row.get("_row_no", 0)), "error", "skip", data, [str(exc)])
|
||||
|
||||
|
||||
def _assign_role_if_needed(db: Session, user_id: int, role_name: str) -> None:
|
||||
role = db.execute(select(Role).where(Role.name == role_name, Role.is_active.is_(True))).scalar_one_or_none()
|
||||
if not role:
|
||||
return
|
||||
exists = db.execute(select(UserRole).where(UserRole.user_id == user_id, UserRole.role_id == role.id)).scalar_one_or_none()
|
||||
if not exists:
|
||||
db.add(UserRole(user_id=user_id, role_id=role.id))
|
||||
|
||||
|
||||
def _get_or_create_user(db: Session, data: dict[str, Any], actor: User) -> int | None:
|
||||
if not data.get("create_user"):
|
||||
return None
|
||||
email = (data.get("login_email") or data.get("email") or "").lower().strip()
|
||||
if not email:
|
||||
return None
|
||||
existing = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||
role_name = data.get("employee_role") or "Staff"
|
||||
if role_name not in EMPLOYEE_ROLE_NAMES:
|
||||
role_name = "Staff"
|
||||
if existing:
|
||||
_assign_role_if_needed(db, existing.id, role_name)
|
||||
return existing.id
|
||||
password = data.get("temporary_password") or ""
|
||||
if len(password) < 8:
|
||||
raise ValueError("temporary_password must be at least 8 characters.")
|
||||
user = User(
|
||||
email=email,
|
||||
full_name=data.get("full_name") or email,
|
||||
password_hash=hash_password(password),
|
||||
tenant_id=int(data["tenant_id"]),
|
||||
branch_id=int(data["branch_id"]),
|
||||
is_active=True,
|
||||
allow_login=True,
|
||||
is_locked=False,
|
||||
deleted_at=None,
|
||||
must_change_password=True,
|
||||
password_changed_at_utc=None,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
_assign_role_if_needed(db, user.id, role_name)
|
||||
return user.id
|
||||
|
||||
|
||||
def _commit_employee(db: Session, actor: User, scope: EmployeeScope, data: dict[str, Any]) -> str:
|
||||
emp = _employee_by_code(db, scope.tenant_id, data["employee_code"])
|
||||
user_id = _get_or_create_user(db, data, actor)
|
||||
payload = dict(data)
|
||||
payload["date_of_joining"] = _date(payload.get("date_of_joining"))
|
||||
payload["is_active"] = payload.get("status") == "active"
|
||||
payload.pop("create_user", None)
|
||||
payload.pop("login_email", None)
|
||||
payload.pop("temporary_password", None)
|
||||
payload.pop("employee_role", None)
|
||||
payload.pop("employee_code", None)
|
||||
payload.pop("full_name", None)
|
||||
payload.pop("tenant_id", None)
|
||||
payload.pop("branch_id", None)
|
||||
if emp:
|
||||
emp.full_name = data["full_name"]
|
||||
emp.email = data.get("email")
|
||||
if user_id and not emp.user_id:
|
||||
emp.user_id = user_id
|
||||
for key, value in payload.items():
|
||||
if hasattr(emp, key):
|
||||
setattr(emp, key, value)
|
||||
emp.updated_by_user_id = actor.id
|
||||
emp.updated_at_utc = datetime.utcnow()
|
||||
db.commit()
|
||||
return "updated"
|
||||
emp = Employee(
|
||||
tenant_id=int(data["tenant_id"]), branch_id=int(data["branch_id"]), user_id=user_id,
|
||||
employee_code=data["employee_code"], full_name=data["full_name"], created_by_user_id=actor.id, updated_by_user_id=actor.id,
|
||||
**{k: v for k, v in payload.items() if hasattr(Employee, k)}
|
||||
)
|
||||
db.add(emp)
|
||||
db.commit()
|
||||
return "created"
|
||||
|
||||
|
||||
def _commit_leave_type(db: Session, actor: User, scope: EmployeeScope, data: dict[str, Any]) -> str:
|
||||
row = _leave_type_by_code(db, scope.tenant_id, int(data["branch_id"]), data["code"])
|
||||
fields = ["name", "description", "annual_quota_days", "carry_forward_allowed", "allow_negative_balance", "requires_approval", "is_paid", "is_active"]
|
||||
if row:
|
||||
for field in fields:
|
||||
setattr(row, field, data.get(field))
|
||||
row.updated_by_user_id = actor.id
|
||||
row.updated_at_utc = datetime.utcnow()
|
||||
db.commit()
|
||||
return "updated"
|
||||
row = EmployeeLeaveType(tenant_id=scope.tenant_id, branch_id=int(data["branch_id"]), code=data["code"], created_by_user_id=actor.id, updated_by_user_id=actor.id, **{k: data.get(k) for k in fields})
|
||||
db.add(row)
|
||||
db.commit()
|
||||
return "created"
|
||||
|
||||
|
||||
def _commit_leave_balance(db: Session, actor: User, scope: EmployeeScope, data: dict[str, Any]) -> str:
|
||||
row = db.execute(select(EmployeeLeaveBalance).where(EmployeeLeaveBalance.tenant_id == scope.tenant_id, EmployeeLeaveBalance.employee_id == data["employee_id"], EmployeeLeaveBalance.leave_type_id == data["leave_type_id"])).scalar_one_or_none()
|
||||
fields = ["opening_days", "credited_days", "availed_days", "adjusted_days", "balance_days"]
|
||||
if row:
|
||||
for field in fields:
|
||||
setattr(row, field, int(data.get(field) or 0))
|
||||
row.updated_by_user_id = actor.id
|
||||
row.updated_at_utc = datetime.utcnow()
|
||||
db.commit()
|
||||
return "updated"
|
||||
row = EmployeeLeaveBalance(tenant_id=scope.tenant_id, branch_id=int(data["branch_id"]), employee_id=int(data["employee_id"]), leave_type_id=int(data["leave_type_id"]), updated_by_user_id=actor.id, **{k: int(data.get(k) or 0) for k in fields})
|
||||
db.add(row)
|
||||
db.commit()
|
||||
return "created"
|
||||
|
||||
|
||||
def _commit_salary_structure(db: Session, actor: User, scope: EmployeeScope, data: dict[str, Any]) -> str:
|
||||
effective_from = _date(data.get("effective_from"))
|
||||
row = db.execute(select(EmployeeSalaryStructure).where(EmployeeSalaryStructure.tenant_id == scope.tenant_id, EmployeeSalaryStructure.employee_id == data["employee_id"], EmployeeSalaryStructure.effective_from == effective_from)).scalar_one_or_none()
|
||||
payload = dict(data)
|
||||
payload["effective_from"] = effective_from
|
||||
payload["effective_to"] = _date(payload.get("effective_to"))
|
||||
for remove in ("employee_code", "tenant_id"):
|
||||
payload.pop(remove, None)
|
||||
fields = ["effective_from", "effective_to", "pay_cycle", "monthly_ctc_amount", "basic_amount", "hra_amount", "allowance_amount", "employee_pf_amount", "employee_esi_amount", "professional_tax_amount", "tds_amount", "other_deduction_amount", "is_active", "remarks"]
|
||||
if row:
|
||||
for field in fields:
|
||||
setattr(row, field, payload.get(field))
|
||||
row.updated_by_user_id = actor.id
|
||||
row.updated_at_utc = datetime.utcnow()
|
||||
db.commit()
|
||||
return "updated"
|
||||
row = EmployeeSalaryStructure(tenant_id=scope.tenant_id, branch_id=int(data["branch_id"]), employee_id=int(data["employee_id"]), created_by_user_id=actor.id, updated_by_user_id=actor.id, **{k: payload.get(k) for k in fields})
|
||||
db.add(row)
|
||||
db.commit()
|
||||
return "created"
|
||||
@@ -0,0 +1,660 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time, timezone
|
||||
|
||||
from sqlalchemy import Boolean, Date, DateTime, Float, ForeignKey, Integer, String, Text, Time, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
|
||||
class Employee(CommonBase):
|
||||
"""Tenant and branch aware employee master.
|
||||
|
||||
This is the v2 Employee Core foundation migrated from the older HRMS module.
|
||||
It intentionally keeps attendance, leave, payroll, documents and ESS out of
|
||||
this table so those features can be added safely in later phases.
|
||||
"""
|
||||
|
||||
__tablename__ = "employees"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "employee_code", name="uq_employees_tenant_code"),
|
||||
UniqueConstraint("tenant_id", "user_id", name="uq_employees_tenant_user"),
|
||||
)
|
||||
|
||||
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] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True)
|
||||
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
employee_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
full_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
mobile: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
alternate_mobile: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
|
||||
date_of_joining: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
date_of_leaving: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
employment_type: Mapped[str] = mapped_column(String(50), nullable=False, default="full_time", 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)
|
||||
|
||||
department: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
|
||||
designation: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
|
||||
reporting_manager_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
pan: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
uan: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
esi_no: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
pf_no: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
aadhaar_last4: Mapped[str | None] = mapped_column(String(4), nullable=True)
|
||||
|
||||
bank_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
bank_account_no: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
bank_ifsc: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
|
||||
address: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
emergency_contact_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
emergency_contact_mobile: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), 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])
|
||||
reporting_manager = relationship("User", foreign_keys=[reporting_manager_user_id])
|
||||
|
||||
|
||||
|
||||
class EmployeeRegistrationRequest(CommonBase):
|
||||
"""Employee self-registration / linkage request for ESS onboarding.
|
||||
|
||||
This table is deliberately separate from employees so a logged-in user can
|
||||
request an employee profile without immediately creating an employee master.
|
||||
Firm Admin / Partner / Branch Manager can review and approve it.
|
||||
"""
|
||||
|
||||
__tablename__ = "employee_registration_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] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
requested_employee_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
|
||||
full_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
mobile: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
department: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
|
||||
designation: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
|
||||
date_of_joining: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending", index=True)
|
||||
review_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
reviewed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
reviewed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_employee_id: Mapped[int | None] = mapped_column(ForeignKey("employees.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,
|
||||
)
|
||||
|
||||
user = relationship("User", foreign_keys=[user_id])
|
||||
reviewed_by = relationship("User", foreign_keys=[reviewed_by_user_id])
|
||||
created_employee = relationship("Employee", foreign_keys=[created_employee_id])
|
||||
|
||||
|
||||
|
||||
class EmployeeAttendance(CommonBase):
|
||||
"""Daily attendance records for employee self-service and admin review.
|
||||
|
||||
One row is maintained per employee per attendance date. Phase 6C keeps the
|
||||
model deliberately simple and tenant/branch-safe. Geo/IP validation can be
|
||||
added later without changing the employee master.
|
||||
"""
|
||||
|
||||
__tablename__ = "employee_attendance"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "employee_id", "attendance_date", name="uq_employee_attendance_employee_date"),
|
||||
)
|
||||
|
||||
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] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True)
|
||||
employee_id: Mapped[int] = mapped_column(ForeignKey("employees.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
attendance_date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
|
||||
punch_in_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
punch_out_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
punch_in_local_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=False), nullable=True)
|
||||
punch_out_local_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=False), nullable=True)
|
||||
branch_timezone: Mapped[str] = mapped_column(String(64), nullable=False, default="Asia/Kolkata")
|
||||
scheduled_start_local: Mapped[time | None] = mapped_column(Time, nullable=True)
|
||||
scheduled_end_local: Mapped[time | None] = mapped_column(Time, nullable=True)
|
||||
late_by_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
attendance_rule_status: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
|
||||
is_weekly_off: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
work_duration_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, default="present", index=True)
|
||||
approval_status: Mapped[str] = mapped_column(String(30), nullable=False, default="approved", index=True)
|
||||
source: Mapped[str] = mapped_column(String(30), nullable=False, default="self_punch", index=True)
|
||||
remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
punch_in_latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
punch_in_longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
punch_in_accuracy_meters: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
punch_in_distance_meters: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
punch_in_ip: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
punch_in_geo_status: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
|
||||
punch_in_ip_status: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
|
||||
|
||||
punch_out_latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
punch_out_longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
punch_out_accuracy_meters: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
punch_out_distance_meters: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
punch_out_ip: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
punch_out_geo_status: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
|
||||
punch_out_ip_status: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
|
||||
|
||||
reviewed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
reviewed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
review_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), 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,
|
||||
)
|
||||
|
||||
employee = relationship("Employee", foreign_keys=[employee_id])
|
||||
user = relationship("User", foreign_keys=[user_id])
|
||||
reviewed_by = relationship("User", foreign_keys=[reviewed_by_user_id])
|
||||
|
||||
|
||||
|
||||
class EmployeeLeaveType(CommonBase):
|
||||
"""Tenant/branch aware leave type master for Phase 6D."""
|
||||
|
||||
__tablename__ = "employee_leave_types"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "branch_id", "code", name="uq_employee_leave_types_tenant_branch_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] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True)
|
||||
|
||||
code: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
annual_quota_days: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
carry_forward_allowed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
allow_negative_balance: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
requires_approval: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
is_paid: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), 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 EmployeeLeaveBalance(CommonBase):
|
||||
"""Leave balance per employee and leave type."""
|
||||
|
||||
__tablename__ = "employee_leave_balances"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "employee_id", "leave_type_id", name="uq_employee_leave_balances_employee_type"),
|
||||
)
|
||||
|
||||
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] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True)
|
||||
employee_id: Mapped[int] = mapped_column(ForeignKey("employees.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
leave_type_id: Mapped[int] = mapped_column(ForeignKey("employee_leave_types.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
opening_days: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
credited_days: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
availed_days: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
adjusted_days: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
balance_days: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
employee = relationship("Employee", foreign_keys=[employee_id])
|
||||
leave_type = relationship("EmployeeLeaveType", foreign_keys=[leave_type_id])
|
||||
|
||||
|
||||
class EmployeeLeaveRequest(CommonBase):
|
||||
"""Employee leave request, review and approval workflow."""
|
||||
|
||||
__tablename__ = "employee_leave_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] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True)
|
||||
employee_id: Mapped[int] = mapped_column(ForeignKey("employees.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
leave_type_id: Mapped[int] = mapped_column(ForeignKey("employee_leave_types.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||
|
||||
from_date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
|
||||
to_date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
|
||||
days: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending", index=True)
|
||||
request_source: Mapped[str] = mapped_column(String(30), nullable=False, default="employee_portal", index=True)
|
||||
|
||||
reviewed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
reviewed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
review_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), 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,
|
||||
)
|
||||
|
||||
employee = relationship("Employee", foreign_keys=[employee_id])
|
||||
user = relationship("User", foreign_keys=[user_id])
|
||||
leave_type = relationship("EmployeeLeaveType", foreign_keys=[leave_type_id])
|
||||
reviewed_by = relationship("User", foreign_keys=[reviewed_by_user_id])
|
||||
|
||||
|
||||
|
||||
class EmployeeDocumentType(CommonBase):
|
||||
"""Tenant/branch aware employee document type master for Phase 6E."""
|
||||
|
||||
__tablename__ = "employee_document_types"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "branch_id", "code", name="uq_employee_document_types_tenant_branch_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] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True)
|
||||
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(150), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
is_mandatory: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
allow_employee_upload: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
requires_verification: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), 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 EmployeeDocument(CommonBase):
|
||||
"""Employee document metadata.
|
||||
|
||||
Files are stored on disk under data/uploads/employee_documents. The DB keeps
|
||||
only controlled metadata and the relative file path so a later storage
|
||||
backend such as Nextcloud/local office storage can be introduced safely.
|
||||
"""
|
||||
|
||||
__tablename__ = "employee_documents"
|
||||
|
||||
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] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True)
|
||||
employee_id: Mapped[int] = mapped_column(ForeignKey("employees.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
document_type_id: Mapped[int | None] = mapped_column(ForeignKey("employee_document_types.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
|
||||
document_no: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
|
||||
issue_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
expiry_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
|
||||
|
||||
original_filename: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
stored_filename: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
storage_path: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
content_type: Mapped[str | None] = mapped_column(String(150), nullable=True)
|
||||
file_size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, default="uploaded", index=True)
|
||||
visibility: Mapped[str] = mapped_column(String(30), nullable=False, default="employee_and_hr", index=True)
|
||||
remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
verified_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
verified_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
verification_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
uploaded_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), 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,
|
||||
)
|
||||
|
||||
employee = relationship("Employee", foreign_keys=[employee_id])
|
||||
document_type = relationship("EmployeeDocumentType", foreign_keys=[document_type_id])
|
||||
verified_by = relationship("User", foreign_keys=[verified_by_user_id])
|
||||
uploaded_by = relationship("User", foreign_keys=[uploaded_by_user_id])
|
||||
|
||||
|
||||
|
||||
class EmployeeOnboardingChecklistItem(CommonBase):
|
||||
"""Reusable tenant/branch onboarding checklist master."""
|
||||
|
||||
__tablename__ = "employee_onboarding_checklist_items"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "branch_id", "code", name="uq_employee_onboarding_items_tenant_branch_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] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True)
|
||||
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
stage: Mapped[str] = mapped_column(String(50), nullable=False, default="joining", index=True)
|
||||
default_due_days: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
is_mandatory: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), 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 EmployeeOnboardingTask(CommonBase):
|
||||
"""Employee-specific onboarding checklist task."""
|
||||
|
||||
__tablename__ = "employee_onboarding_tasks"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "employee_id", "checklist_item_id", name="uq_employee_onboarding_task_employee_item"),
|
||||
)
|
||||
|
||||
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] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True)
|
||||
employee_id: Mapped[int] = mapped_column(ForeignKey("employees.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
checklist_item_id: Mapped[int | None] = mapped_column(ForeignKey("employee_onboarding_checklist_items.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
stage: Mapped[str] = mapped_column(String(50), nullable=False, default="joining", index=True)
|
||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending", index=True)
|
||||
assigned_to_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
completed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
completed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
review_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), 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,
|
||||
)
|
||||
|
||||
employee = relationship("Employee", foreign_keys=[employee_id])
|
||||
checklist_item = relationship("EmployeeOnboardingChecklistItem", foreign_keys=[checklist_item_id])
|
||||
assigned_to = relationship("User", foreign_keys=[assigned_to_user_id])
|
||||
completed_by = relationship("User", foreign_keys=[completed_by_user_id])
|
||||
|
||||
|
||||
class EmployeeOffboardingRequest(CommonBase):
|
||||
"""Employee resignation/relieving/offboarding workflow request."""
|
||||
|
||||
__tablename__ = "employee_offboarding_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] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True)
|
||||
employee_id: Mapped[int] = mapped_column(ForeignKey("employees.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
request_type: Mapped[str] = mapped_column(String(50), nullable=False, default="resignation", index=True)
|
||||
requested_relieving_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
|
||||
approved_relieving_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
|
||||
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
handover_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending", index=True)
|
||||
|
||||
requested_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
reviewed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
reviewed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
review_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
completed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
completed_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,
|
||||
)
|
||||
|
||||
employee = relationship("Employee", foreign_keys=[employee_id])
|
||||
requested_by = relationship("User", foreign_keys=[requested_by_user_id])
|
||||
reviewed_by = relationship("User", foreign_keys=[reviewed_by_user_id])
|
||||
completed_by = relationship("User", foreign_keys=[completed_by_user_id])
|
||||
|
||||
|
||||
class EmployeeOffboardingTask(CommonBase):
|
||||
"""Offboarding checklist task linked to an offboarding request."""
|
||||
|
||||
__tablename__ = "employee_offboarding_tasks"
|
||||
|
||||
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] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True)
|
||||
request_id: Mapped[int] = mapped_column(ForeignKey("employee_offboarding_requests.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
employee_id: Mapped[int] = mapped_column(ForeignKey("employees.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending", index=True)
|
||||
assigned_to_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
completed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
completed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
review_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), 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,
|
||||
)
|
||||
|
||||
request = relationship("EmployeeOffboardingRequest", foreign_keys=[request_id])
|
||||
employee = relationship("Employee", foreign_keys=[employee_id])
|
||||
assigned_to = relationship("User", foreign_keys=[assigned_to_user_id])
|
||||
completed_by = relationship("User", foreign_keys=[completed_by_user_id])
|
||||
|
||||
|
||||
|
||||
class EmployeeSalaryStructure(CommonBase):
|
||||
"""Employee salary structure header for Phase 6G payroll foundation."""
|
||||
|
||||
__tablename__ = "employee_salary_structures"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "employee_id", "effective_from", name="uq_employee_salary_structure_effective"),
|
||||
)
|
||||
|
||||
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] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True)
|
||||
employee_id: Mapped[int] = mapped_column(ForeignKey("employees.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
effective_from: Mapped[date] = mapped_column(Date, nullable=False, index=True)
|
||||
effective_to: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
|
||||
pay_cycle: Mapped[str] = mapped_column(String(30), nullable=False, default="monthly", index=True)
|
||||
monthly_ctc_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
basic_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
hra_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
allowance_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
employee_pf_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
employee_esi_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
professional_tax_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
tds_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
other_deduction_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
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", ondelete="SET NULL"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), 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)
|
||||
|
||||
employee = relationship("Employee", foreign_keys=[employee_id])
|
||||
|
||||
|
||||
class EmployeePayrollRun(CommonBase):
|
||||
"""Monthly payroll run header."""
|
||||
|
||||
__tablename__ = "employee_payroll_runs"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "branch_id", "pay_year", "pay_month", name="uq_employee_payroll_run_period"),
|
||||
)
|
||||
|
||||
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] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True)
|
||||
pay_year: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||
pay_month: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||
run_name: Mapped[str] = mapped_column(String(150), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, default="draft", index=True)
|
||||
total_employees: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
gross_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
deduction_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
net_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
processed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
approved_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
approved_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
paid_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
paid_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)
|
||||
|
||||
processed_by = relationship("User", foreign_keys=[processed_by_user_id])
|
||||
approved_by = relationship("User", foreign_keys=[approved_by_user_id])
|
||||
paid_by = relationship("User", foreign_keys=[paid_by_user_id])
|
||||
|
||||
|
||||
class EmployeePayslip(CommonBase):
|
||||
"""Employee payslip generated from an approved salary structure."""
|
||||
|
||||
__tablename__ = "employee_payslips"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "payroll_run_id", "employee_id", name="uq_employee_payslip_run_employee"),
|
||||
)
|
||||
|
||||
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] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True)
|
||||
payroll_run_id: Mapped[int] = mapped_column(ForeignKey("employee_payroll_runs.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
employee_id: Mapped[int] = mapped_column(ForeignKey("employees.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
salary_structure_id: Mapped[int | None] = mapped_column(ForeignKey("employee_salary_structures.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
pay_year: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||
pay_month: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||
basic_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
hra_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
allowance_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
gross_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
employee_pf_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
employee_esi_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
professional_tax_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
tds_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
other_deduction_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
deduction_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
net_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, default="generated", index=True)
|
||||
remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
generated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), 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)
|
||||
|
||||
payroll_run = relationship("EmployeePayrollRun", foreign_keys=[payroll_run_id])
|
||||
employee = relationship("Employee", foreign_keys=[employee_id])
|
||||
salary_structure = relationship("EmployeeSalaryStructure", foreign_keys=[salary_structure_id])
|
||||
generated_by = relationship("User", foreign_keys=[generated_by_user_id])
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
{% set current_path = request.url.path %}
|
||||
{% set _tab_base = "inline-flex shrink-0 items-center rounded-xl px-3 py-2 text-sm font-semibold whitespace-nowrap transition" %}
|
||||
{% set _tab_active = "bg-brand-600 text-white shadow-soft" %}
|
||||
{% set _tab_idle = "border border-slate-300 bg-white text-slate-700 hover:bg-slate-50" %}
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-3 shadow-soft">
|
||||
<div class="mb-2 px-1 text-xs font-semibold uppercase tracking-wide text-slate-500">My Workspace</div>
|
||||
<div class="overflow-x-auto pb-1">
|
||||
<div class="flex min-w-max flex-nowrap gap-2">
|
||||
{% if can_view_employee_portal(current_user, current_user_permissions, current_user_roles) %}
|
||||
<a href="/employee/dashboard" class="{{ _tab_base }} {{ _tab_active if current_path == '/employee/dashboard' else _tab_idle }}">Overview</a>
|
||||
{% endif %}
|
||||
{% if can_view_own_employee_work(current_user, current_user_permissions, current_user_roles) %}
|
||||
<a href="/employee/work" class="{{ _tab_base }} {{ _tab_active if current_path.startswith('/employee/work') else _tab_idle }}">My Work Board</a>
|
||||
{% endif %}
|
||||
{% if can_view_own_employee_attendance(current_user, current_user_permissions, current_user_roles) %}
|
||||
<a href="/employee/attendance" class="{{ _tab_base }} {{ _tab_active if current_path.startswith('/employee/attendance') else _tab_idle }}">My Attendance</a>
|
||||
{% endif %}
|
||||
{% if can_view_employee_portal(current_user, current_user_permissions, current_user_roles) %}
|
||||
<a href="/employee/profile" class="{{ _tab_base }} {{ _tab_active if current_path.startswith('/employee/profile') else _tab_idle }}">My Profile</a>
|
||||
{% endif %}
|
||||
{% if can_view_own_employee_leave(current_user, current_user_permissions, current_user_roles) %}
|
||||
<a href="/employee/leave" class="{{ _tab_base }} {{ _tab_active if current_path.startswith('/employee/leave') else _tab_idle }}">My Leave</a>
|
||||
{% endif %}
|
||||
{% if can_view_own_employee_documents(current_user, current_user_permissions, current_user_roles) %}
|
||||
<a href="/employee/documents" class="{{ _tab_base }} {{ _tab_active if current_path.startswith('/employee/documents') else _tab_idle }}">My Documents</a>
|
||||
{% endif %}
|
||||
{% if can_view_own_employee_payslips(current_user, current_user_permissions, current_user_roles) %}
|
||||
<a href="/employee/payslips" class="{{ _tab_base }} {{ _tab_active if current_path.startswith('/employee/payslips') else _tab_idle }}">My Payslips</a>
|
||||
{% endif %}
|
||||
{% if can_request_own_employee_offboarding(current_user, current_user_permissions, current_user_roles) %}
|
||||
<a href="/employee/offboarding" class="{{ _tab_base }} {{ _tab_active if current_path.startswith('/employee/offboarding') else _tab_idle }}">My Offboarding</a>
|
||||
{% endif %}
|
||||
<a href="/alerts" class="{{ _tab_base }} {{ _tab_active if current_path.startswith('/alerts') else _tab_idle }}">
|
||||
My Alert{% if unread_alert_count is defined and unread_alert_count > 0 %}<span class="ml-2 rounded-full bg-white/20 px-2 py-0.5 text-[10px]">{{ unread_alert_count }}</span>{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,114 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/managers/templates/managers/_manager_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">Employee Attendance</h2>
|
||||
<p class="text-sm text-slate-500">Review employee attendance, filter records, and manually mark attendance where required.</p>
|
||||
</div>
|
||||
<a href="/employees" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Employees</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-6">
|
||||
<select name="employee_id" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="">All employees</option>
|
||||
{% for emp in employees %}
|
||||
<option value="{{ emp.id }}" {% if selected_employee_id == emp.id %}selected{% endif %}>{{ emp.full_name }} - {{ emp.employee_code }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input type="date" name="from_date" value="{{ from_date }}" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<input type="date" name="to_date" value="{{ to_date }}" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<select name="status" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="">Any status</option>
|
||||
{% for st in attendance_statuses %}<option value="{{ st }}" {% if selected_status == st %}selected{% endif %}>{{ st.replace('_',' ').title() }}</option>{% endfor %}
|
||||
</select>
|
||||
<select name="approval_status" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="">Any approval</option>
|
||||
{% for st in attendance_approval_statuses %}<option value="{{ st }}" {% if selected_approval_status == st %}selected{% endif %}>{{ st.title() }}</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>
|
||||
|
||||
{% if can_approve_employee_attendance(current_user, current_user_permissions, current_user_roles) %}
|
||||
<form method="post" action="/employees/attendance/manual" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<h3 class="font-semibold text-slate-900">Manual Attendance Marking</h3>
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-5">
|
||||
<select name="employee_id" required class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="">Select employee</option>
|
||||
{% for emp in employees %}<option value="{{ emp.id }}">{{ emp.full_name }} - {{ emp.employee_code }}</option>{% endfor %}
|
||||
</select>
|
||||
<input type="date" name="attendance_date" required class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<select name="status" required class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
{% for st in attendance_statuses %}<option value="{{ st }}">{{ st.replace('_',' ').title() }}</option>{% endfor %}
|
||||
</select>
|
||||
<input name="remarks" placeholder="Remarks" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
{% 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">Date</th>
|
||||
<th class="px-4 py-3">Employee</th>
|
||||
<th class="px-4 py-3">Punch</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Approval</th>
|
||||
<th class="px-4 py-3">Timing</th>
|
||||
<th class="px-4 py-3">Geo/IP Evidence</th>
|
||||
<th class="px-4 py-3">Remarks</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 font-medium text-slate-900">{{ row.attendance_date }}</td>
|
||||
<td class="px-4 py-3 text-slate-600"><div class="font-medium text-slate-900">{{ row.employee.full_name if row.employee else ('Employee #' ~ row.employee_id) }}</div><div class="text-xs">{{ row.employee.employee_code if row.employee else '' }}</div></td>
|
||||
<td class="px-4 py-3 text-slate-600"><div>In: {{ row.punch_in_local_at.strftime('%H:%M') if row.punch_in_local_at else (row.punch_in_utc.strftime('%H:%M UTC') if row.punch_in_utc else '-') }}</div><div>Out: {{ row.punch_out_local_at.strftime('%H:%M') if row.punch_out_local_at else (row.punch_out_utc.strftime('%H:%M UTC') if row.punch_out_utc else '-') }}</div></td>
|
||||
<td class="px-4 py-3">{{ row.status.replace('_',' ').title() }}</td>
|
||||
<td class="px-4 py-3">{{ row.approval_status.title() }}</td>
|
||||
<td class="px-4 py-3 text-xs text-slate-600">
|
||||
<div>TZ: {{ row.branch_timezone or 'Asia/Kolkata' }}</div>
|
||||
<div>Rule: {{ (row.attendance_rule_status or '-').replace('_',' ').title() }}</div>
|
||||
<div>Late: {{ (row.late_by_minutes ~ ' min') if row.late_by_minutes else '-' }}</div>
|
||||
<div>Weekly Off: {{ 'Yes' if row.is_weekly_off else 'No' }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-slate-600">
|
||||
<div>Source: {{ (row.source or '-').replace('_',' ').title() }}</div>
|
||||
<div>Geo: {{ (row.punch_in_geo_status or '-').replace('_',' ').title() }}</div>
|
||||
<div>Distance: {{ (row.punch_in_distance_meters ~ ' m') if row.punch_in_distance_meters is not none else '-' }}</div>
|
||||
<div>IP: {{ (row.punch_in_ip_status or '-').replace('_',' ').title() }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ row.remarks or row.review_notes or '-' }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
{% if can_approve_employee_attendance(current_user, current_user_permissions, current_user_roles) %}
|
||||
<form method="post" action="/employees/attendance/{{ row.id }}/review" class="inline-flex gap-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="approval_status" value="approved">
|
||||
<input type="hidden" name="review_notes" value="Approved from attendance review">
|
||||
<button class="rounded-lg border border-emerald-300 px-3 py-1.5 text-xs font-semibold text-emerald-700 hover:bg-emerald-50">Approve</button>
|
||||
</form>
|
||||
<form method="post" action="/employees/attendance/{{ row.id }}/review" class="inline-flex gap-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="approval_status" value="rejected">
|
||||
<input type="hidden" name="review_notes" value="Rejected from attendance review">
|
||||
<button class="rounded-lg border border-red-300 px-3 py-1.5 text-xs font-semibold text-red-700 hover:bg-red-50">Reject</button>
|
||||
</form>
|
||||
{% else %}-{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="9" class="px-4 py-8 text-center text-slate-500">No attendance records found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,108 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="mb-4 flex flex-wrap gap-3">
|
||||
<form method="post" action="/employees/{{ employee.id }}/onboarding/generate" class="inline-block">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="rounded-xl bg-indigo-700 px-4 py-2 text-sm font-semibold text-white">Generate Onboarding</button>
|
||||
</form>
|
||||
<form method="post" action="/employees/{{ employee.id }}/offboarding/initiate" class="inline-grid gap-2 rounded-2xl border bg-white p-3 align-top shadow-sm md:grid-cols-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<select name="request_type" class="rounded-lg border px-2 py-1 text-sm"><option value="resignation">Resignation</option><option value="termination">Termination</option><option value="contract_end">Contract End</option></select>
|
||||
<input type="date" name="requested_relieving_date" class="rounded-lg border px-2 py-1 text-sm">
|
||||
<input name="reason" placeholder="Reason" class="rounded-lg border px-2 py-1 text-sm">
|
||||
<button class="rounded-lg bg-rose-700 px-3 py-1 text-sm font-semibold text-white">Initiate Offboarding</button>
|
||||
</form>
|
||||
</div>
|
||||
<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">{{ employee.full_name }}</h2>
|
||||
<p class="text-sm text-slate-500">Employee Code: {{ employee.employee_code }} • Audit Firm {{ employee.tenant_id }} • Branch {{ employee.branch_id }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="/employees" 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_employees(current_user, current_user_permissions, current_user_roles) %}<a href="/employees/{{ employee.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>
|
||||
|
||||
{% if link_error %}
|
||||
<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">Unable to update login linkage. Please select an active user from the same audit firm and branch who is not already linked to another employee.</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="rounded-2xl border {% if employee.user_id %}border-emerald-200 bg-emerald-50{% else %}border-amber-200 bg-amber-50{% endif %} p-4 shadow-soft">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="font-semibold {% if employee.user_id %}text-emerald-900{% else %}text-amber-900{% endif %}">Login user linkage</h3>
|
||||
{% if employee.user_id %}
|
||||
<p class="mt-1 text-sm text-emerald-800">This employee is linked to {% if employee.user %}{{ employee.user.full_name or employee.user.email }}{% else %}User #{{ employee.user_id }}{% endif %}. Employee self-service pages will work for this login.</p>
|
||||
{% else %}
|
||||
<p class="mt-1 text-sm text-amber-900">This employee is not linked to a login user yet. Attendance, My Workspace, profile, leave, documents and payslip self-service need this link.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if can_manage_employees(current_user, current_user_permissions, current_user_roles) %}
|
||||
<form method="post" action="/employees/{{ employee.id }}/link-user" class="flex flex-wrap items-center gap-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<select name="user_id" class="min-w-64 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm">
|
||||
<option value="">Unlink / no login user</option>
|
||||
{% for u in linkable_users %}
|
||||
<option value="{{ u.id }}" {% if employee.user_id == u.id %}selected{% endif %}>{{ u.full_name or u.email }} — {{ u.email }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Update Link</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="space-y-6 lg:col-span-2">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-soft">
|
||||
<h3 class="font-semibold text-slate-900">Basic Details</h3>
|
||||
<dl class="mt-4 grid gap-4 md:grid-cols-2 text-sm">
|
||||
<div><dt class="text-slate-500">Email</dt><dd class="font-medium text-slate-900">{{ employee.email or '-' }}</dd></div>
|
||||
<div><dt class="text-slate-500">Mobile</dt><dd class="font-medium text-slate-900">{{ employee.mobile or '-' }}</dd></div>
|
||||
<div><dt class="text-slate-500">Department</dt><dd class="font-medium text-slate-900">{{ employee.department or '-' }}</dd></div>
|
||||
<div><dt class="text-slate-500">Designation</dt><dd class="font-medium text-slate-900">{{ employee.designation or '-' }}</dd></div>
|
||||
<div><dt class="text-slate-500">Employment Type</dt><dd class="font-medium text-slate-900">{{ employee.employment_type.replace('_',' ').title() }}</dd></div>
|
||||
<div><dt class="text-slate-500">Date of Joining</dt><dd class="font-medium text-slate-900">{{ employee.date_of_joining or '-' }}</dd></div>
|
||||
<div><dt class="text-slate-500">Date of Leaving</dt><dd class="font-medium text-slate-900">{{ employee.date_of_leaving or '-' }}</dd></div>
|
||||
<div><dt class="text-slate-500">Linked User</dt><dd class="font-medium text-slate-900">{% if employee.user %}{{ employee.user.full_name or employee.user.email }} <span class="text-xs text-slate-500">({{ employee.user.email }})</span>{% else %}<span class="text-amber-700">Not linked</span>{% endif %}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-soft">
|
||||
<h3 class="font-semibold text-slate-900">Statutory & Bank Details</h3>
|
||||
<dl class="mt-4 grid gap-4 md:grid-cols-2 text-sm">
|
||||
<div><dt class="text-slate-500">PAN</dt><dd class="font-medium text-slate-900">{{ employee.pan or '-' }}</dd></div>
|
||||
<div><dt class="text-slate-500">UAN</dt><dd class="font-medium text-slate-900">{{ employee.uan or '-' }}</dd></div>
|
||||
<div><dt class="text-slate-500">ESI No</dt><dd class="font-medium text-slate-900">{{ employee.esi_no or '-' }}</dd></div>
|
||||
<div><dt class="text-slate-500">PF No</dt><dd class="font-medium text-slate-900">{{ employee.pf_no or '-' }}</dd></div>
|
||||
<div><dt class="text-slate-500">Bank Name</dt><dd class="font-medium text-slate-900">{{ employee.bank_name or '-' }}</dd></div>
|
||||
<div><dt class="text-slate-500">Bank Account</dt><dd class="font-medium text-slate-900">{{ employee.bank_account_no or '-' }}</dd></div>
|
||||
<div><dt class="text-slate-500">IFSC</dt><dd class="font-medium text-slate-900">{{ employee.bank_ifsc or '-' }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-soft">
|
||||
<h3 class="font-semibold text-slate-900">Status</h3>
|
||||
<div class="mt-3"><span class="rounded-full px-3 py-1 text-sm font-semibold {% if employee.status == 'active' %}bg-emerald-50 text-emerald-700{% elif employee.status == 'relieved' %}bg-amber-50 text-amber-700{% else %}bg-slate-100 text-slate-600{% endif %}">{{ employee.status.replace('_',' ').title() }}</span></div>
|
||||
{% if can_change_employee_status(current_user, current_user_permissions, current_user_roles) %}
|
||||
<form method="post" action="/employees/{{ employee.id }}/status" class="mt-5 space-y-3">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div><label class="mb-1 block text-sm font-medium text-slate-700">Change Status</label><select name="status" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{% for st in employee_statuses %}<option value="{{ st }}" {% if employee.status == st %}selected{% endif %}>{{ st.title() }}</option>{% endfor %}</select></div>
|
||||
<div><label class="mb-1 block text-sm font-medium text-slate-700">Date of Leaving</label><input type="date" name="date_of_leaving" value="{{ employee.date_of_leaving or '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"></div>
|
||||
<button class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Update Status</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-soft">
|
||||
<h3 class="font-semibold text-slate-900">Emergency / Notes</h3>
|
||||
<dl class="mt-4 space-y-3 text-sm"><div><dt class="text-slate-500">Emergency Contact</dt><dd class="font-medium text-slate-900">{{ employee.emergency_contact_name or '-' }} {{ employee.emergency_contact_mobile or '' }}</dd></div><div><dt class="text-slate-500">Address</dt><dd class="whitespace-pre-line font-medium text-slate-900">{{ employee.address or '-' }}</dd></div><div><dt class="text-slate-500">Notes</dt><dd class="whitespace-pre-line font-medium text-slate-900">{{ employee.notes or '-' }}</dd></div></dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,61 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
{% if errors %}<div class="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">{{ errors|join(', ') }}</div>{% endif %}
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Employee Document Types</h2>
|
||||
<p class="text-sm text-slate-500">Maintain branch-wise document categories like PAN, Aadhaar, certificates and bank proof.</p>
|
||||
</div>
|
||||
<form method="post" action="/employees/document-types/defaults">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">Create Defaults</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<h3 class="mb-4 font-semibold text-slate-900">Add Document Type</h3>
|
||||
<form method="post" action="/employees/document-types" class="grid gap-4 md:grid-cols-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div><label class="text-xs font-semibold text-slate-500">Code</label><input name="code" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" required></div>
|
||||
<div><label class="text-xs font-semibold text-slate-500">Name</label><input name="name" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" required></div>
|
||||
<div><label class="text-xs font-semibold text-slate-500">Description</label><input name="description" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></div>
|
||||
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||
<label class="flex items-center gap-2"><input type="checkbox" name="is_mandatory" value="1"> Mandatory</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" name="allow_employee_upload" value="1" checked> Employee Upload</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" name="requires_verification" value="1" checked> Verify</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" name="is_active" value="1" checked> Active</label>
|
||||
</div>
|
||||
<div class="md:col-span-4"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Save Type</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500"><tr><th class="px-4 py-3">Code</th><th class="px-4 py-3">Name</th><th class="px-4 py-3">Rules</th><th class="px-4 py-3">Edit</th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
<td class="px-4 py-3 font-medium">{{ row.code }}</td><td class="px-4 py-3">{{ row.name }}<div class="text-xs text-slate-500">{{ row.description or '' }}</div></td>
|
||||
<td class="px-4 py-3 text-xs text-slate-600">{% if row.is_mandatory %}Mandatory{% else %}Optional{% endif %} • {% if row.allow_employee_upload %}Employee upload{% else %}HR upload only{% endif %} • {% if row.requires_verification %}Verification{% else %}No verification{% endif %} • {% if row.is_active %}Active{% else %}Inactive{% endif %}</td>
|
||||
<td class="px-4 py-3">
|
||||
<details><summary class="cursor-pointer text-brand-700">Edit</summary>
|
||||
<form method="post" action="/employees/document-types/{{ row.id }}/edit" class="mt-3 grid gap-2 rounded-xl border border-slate-200 p-3">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input name="name" value="{{ row.name }}" class="rounded-lg border border-slate-300 px-3 py-2">
|
||||
<input name="description" value="{{ row.description or '' }}" class="rounded-lg border border-slate-300 px-3 py-2">
|
||||
<label><input type="checkbox" name="is_mandatory" value="1" {% if row.is_mandatory %}checked{% endif %}> Mandatory</label>
|
||||
<label><input type="checkbox" name="allow_employee_upload" value="1" {% if row.allow_employee_upload %}checked{% endif %}> Employee upload allowed</label>
|
||||
<label><input type="checkbox" name="requires_verification" value="1" {% if row.requires_verification %}checked{% endif %}> Requires verification</label>
|
||||
<label><input type="checkbox" name="is_active" value="1" {% if row.is_active %}checked{% endif %}> Active</label>
|
||||
<button class="rounded-lg bg-slate-900 px-3 py-2 text-white">Update</button>
|
||||
</form>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}<tr><td colspan="4" class="px-4 py-6 text-center text-slate-500">No document types found.</td></tr>{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,44 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
{% if errors %}<div class="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">{{ errors|join(', ') }}</div>{% endif %}
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div><h2 class="text-xl font-semibold text-slate-900">Employee Documents</h2><p class="text-sm text-slate-500">Upload, review and archive employee documents with audit firm/branch scope.</p></div>
|
||||
<a href="/employees/document-types" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Document Types</a>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<h3 class="mb-4 font-semibold text-slate-900">Upload Document</h3>
|
||||
<form method="post" enctype="multipart/form-data" action="/employees/{{ selected_employee_id or (employees[0].id if employees else 0) }}/documents/upload" onsubmit="if(!document.getElementById('upload_employee_id').value){return false;} this.action='/employees/'+document.getElementById('upload_employee_id').value+'/documents/upload';" class="grid gap-4 md:grid-cols-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div><label class="text-xs font-semibold text-slate-500">Employee</label><select id="upload_employee_id" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" required>{% for emp in employees %}<option value="{{ emp.id }}" {% if selected_employee_id==emp.id %}selected{% endif %}>{{ emp.employee_code }} - {{ emp.full_name }}</option>{% endfor %}</select></div>
|
||||
<div><label class="text-xs font-semibold text-slate-500">Type</label><select name="document_type_id" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"><option value="">General</option>{% for dt in document_types %}<option value="{{ dt.id }}">{{ dt.code }} - {{ dt.name }}</option>{% endfor %}</select></div>
|
||||
<div><label class="text-xs font-semibold text-slate-500">Title</label><input name="title" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></div>
|
||||
<div><label class="text-xs font-semibold text-slate-500">File</label><input type="file" name="document_file" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" required></div>
|
||||
<div><label class="text-xs font-semibold text-slate-500">Document No</label><input name="document_no" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></div>
|
||||
<div><label class="text-xs font-semibold text-slate-500">Issue Date</label><input type="date" name="issue_date" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></div>
|
||||
<div><label class="text-xs font-semibold text-slate-500">Expiry Date</label><input type="date" name="expiry_date" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></div>
|
||||
<div><label class="text-xs font-semibold text-slate-500">Visibility</label><select name="visibility" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"><option value="employee_and_hr">Employee and HR</option><option value="hr_only">HR Only</option></select></div>
|
||||
<div class="md:col-span-4"><label class="text-xs font-semibold text-slate-500">Remarks</label><input name="remarks" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></div>
|
||||
<div class="md:col-span-4"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Upload Document</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm"><thead class="bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500"><tr><th class="px-4 py-3">Employee</th><th class="px-4 py-3">Document</th><th class="px-4 py-3">Status</th><th class="px-4 py-3">Dates</th><th class="px-4 py-3">Review</th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-100">{% for row in rows %}<tr>
|
||||
<td class="px-4 py-3">{{ row.employee.employee_code if row.employee else '' }}<div class="font-medium">{{ row.employee.full_name if row.employee else '' }}</div></td>
|
||||
<td class="px-4 py-3"><div class="font-medium">{{ row.title }}</div><div class="text-xs text-slate-500">{{ row.document_type.code if row.document_type else 'GENERAL' }} • {{ row.original_filename }} • {{ row.file_size_bytes or 0 }} bytes</div><div class="text-xs text-slate-400">{{ row.storage_path }}</div></td>
|
||||
<td class="px-4 py-3"><span class="rounded-full bg-slate-100 px-2 py-1 text-xs">{{ row.status }}</span><div class="text-xs text-slate-500">{{ row.visibility }}</div></td>
|
||||
<td class="px-4 py-3 text-xs">Issue: {{ row.issue_date or '-' }}<br>Expiry: {{ row.expiry_date or '-' }}</td>
|
||||
<td class="px-4 py-3">
|
||||
{% if row.status != 'archived' %}
|
||||
<form method="post" action="/employees/documents/{{ row.id }}/review" class="mb-2 flex gap-2"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input name="verification_notes" placeholder="Notes" class="w-32 rounded-lg border border-slate-300 px-2 py-1"><button name="status" value="verified" class="rounded-lg bg-emerald-600 px-2 py-1 text-xs text-white">Verify</button><button name="status" value="rejected" class="rounded-lg bg-red-600 px-2 py-1 text-xs text-white">Reject</button></form>
|
||||
<form method="post" action="/employees/documents/{{ row.id }}/archive"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="text-xs text-red-700">Archive</button></form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>{% else %}<tr><td colspan="5" class="px-4 py-6 text-center text-slate-500">No documents found.</td></tr>{% endfor %}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,107 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% set is_dict = employee is mapping %}
|
||||
{% set is_edit = mode == 'edit' %}
|
||||
{% macro val(name, default='') -%}
|
||||
{%- if employee -%}
|
||||
{%- if is_dict -%}{{ employee.get(name, default) or '' }}{%- else -%}{{ employee|attr(name) or '' }}{%- endif -%}
|
||||
{%- else -%}{{ default }}{%- endif -%}
|
||||
{%- endmacro %}
|
||||
<div class="mx-auto max-w-5xl 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 the employee master. Attendance, leave, payroll and ESS will be added in later phases.</p>
|
||||
</div>
|
||||
<a href="/employees" 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 }}">
|
||||
|
||||
{% if not is_edit %}
|
||||
<div class="rounded-2xl border border-brand-100 bg-brand-50 p-4">
|
||||
<h3 class="font-semibold text-slate-900">Employee Login Link</h3>
|
||||
<p class="mt-1 text-sm text-slate-600">Either link an existing unlinked login user or create a login user automatically for this employee.</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 user</label>
|
||||
<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 users %}<option value="{{ u.id }}">{{ u.full_name or u.email }} — {{ u.email }}</option>{% endfor %}
|
||||
</select>
|
||||
</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"> Create login user</label>
|
||||
<p class="text-xs text-slate-500">When selected, Login Email and Temporary Password below are required.</p>
|
||||
</div>
|
||||
<div><label class="mb-1 block text-sm font-medium text-slate-700">Login Email</label><input type="email" name="login_email" value="{{ val('login_email') }}" 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" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"><p class="mt-1 text-xs text-slate-500">Minimum 8 characters. User must change password after login.</p></div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Employee User Role</label>
|
||||
<select name="employee_role" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{% for r in employee_role_names %}<option value="{{ r }}" {% if r == 'Staff' %}selected{% endif %}>{{ r }}</option>{% endfor %}</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600">Login user linkage can be changed below. The dropdown shows unlinked users from the same audit firm/branch, plus the currently linked user if any. New user creation is available only while creating an employee.</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Linked user</label>
|
||||
{% set uid = employee.user_id if employee and not is_dict else employee.get('user_id') if employee else None %}
|
||||
<select name="user_id" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="">No user linked</option>
|
||||
{% for u in users %}<option value="{{ u.id }}" {% if uid == u.id %}selected{% endif %}>{{ u.full_name or u.email }} — {{ u.email }}</option>{% endfor %}
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-slate-500">Selecting “No user linked” will keep this employee as HR master only. Employee self-service will not work until linked.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not is_edit and scope.allow_cross_tenant %}
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div><label class="mb-1 block text-sm font-medium text-slate-700">Audit Firm</label><select name="tenant_id" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{% for t in tenants %}<option value="{{ t.id }}" {% if t.id == scope.tenant_id %}selected{% endif %}>{{ t.name }} ({{ t.code }})</option>{% endfor %}</select></div>
|
||||
<div><label class="mb-1 block text-sm font-medium text-slate-700">Branch</label><select name="branch_id" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{% for b in branches %}<option value="{{ b.id }}" {% if b.id == scope.branch_id %}selected{% endif %}>{{ b.name }} ({{ b.code }})</option>{% endfor %}</select></div>
|
||||
</div>
|
||||
{% else %}
|
||||
<input type="hidden" name="tenant_id" value="{{ scope.tenant_id }}">
|
||||
<input type="hidden" name="branch_id" value="{{ scope.branch_id or current_user.branch_id }}">
|
||||
{% endif %}
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div><label class="mb-1 block text-sm font-medium text-slate-700">Employee Code *</label><input name="employee_code" value="{{ val('employee_code') }}" required 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">Full Name *</label><input name="full_name" value="{{ val('full_name') }}" required 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="{{ val('email') }}" 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="{{ val('mobile') }}" 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">Alternate Mobile</label><input name="alternate_mobile" value="{{ val('alternate_mobile') }}" 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">Date of Joining</label><input type="date" name="date_of_joining" value="{{ val('date_of_joining') }}" 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">Employment Type</label><select name="employment_type" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{% set et = val('employment_type','full_time') %}{% for code in employment_types %}<option value="{{ code }}" {% if et == code %}selected{% endif %}>{{ code.replace('_',' ').title() }}</option>{% endfor %}</select></div>
|
||||
<div><label class="mb-1 block text-sm font-medium text-slate-700">Status</label><select name="status" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{% set st = val('status','active') %}{% for code in employee_statuses %}<option value="{{ code }}" {% if st == code %}selected{% endif %}>{{ code.title() }}</option>{% endfor %}</select></div>
|
||||
<div><label class="mb-1 block text-sm font-medium text-slate-700">Department</label><input name="department" value="{{ val('department') }}" 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">Designation</label><input name="designation" value="{{ val('designation') }}" 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">Reporting Manager</label><select name="reporting_manager_user_id" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{% set mid = employee.reporting_manager_user_id if employee and not is_dict else employee.get('reporting_manager_user_id') if employee else None %}<option value="">No manager selected</option>{% for m in managers %}<option value="{{ m.id }}" {% if mid == m.id %}selected{% endif %}>{{ m.full_name or m.email }}</option>{% endfor %}</select></div>
|
||||
<div><label class="mb-1 block text-sm font-medium text-slate-700">PAN</label><input name="pan" value="{{ val('pan') }}" 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">UAN</label><input name="uan" value="{{ val('uan') }}" 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">ESI No</label><input name="esi_no" value="{{ val('esi_no') }}" 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">PF No</label><input name="pf_no" value="{{ val('pf_no') }}" 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">Aadhaar Last 4</label><input name="aadhaar_last4" maxlength="4" value="{{ val('aadhaar_last4') }}" 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">Bank Name</label><input name="bank_name" value="{{ val('bank_name') }}" 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">Bank Account No</label><input name="bank_account_no" value="{{ val('bank_account_no') }}" 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">Bank IFSC</label><input name="bank_ifsc" value="{{ val('bank_ifsc') }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div><label class="mb-1 block text-sm font-medium text-slate-700">Emergency Contact Name</label><input name="emergency_contact_name" value="{{ val('emergency_contact_name') }}" 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">Emergency Contact Mobile</label><input name="emergency_contact_mobile" value="{{ val('emergency_contact_mobile') }}" 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">{{ val('address') }}</textarea></div>
|
||||
<div class="md:col-span-2"><label class="mb-1 block text-sm font-medium text-slate-700">Notes</label><textarea name="notes" rows="3" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ val('notes') }}</textarea></div>
|
||||
</div>
|
||||
|
||||
<label class="inline-flex items-center gap-2 text-sm text-slate-700"><input type="checkbox" name="is_active" {% if not employee or val('status','active') == 'active' %}checked{% endif %}> Active employee</label>
|
||||
|
||||
<div class="flex justify-end gap-3"><a href="/employees" 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 Employee</button></div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,97 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-500">Employees / HRMS</p>
|
||||
<h1 class="text-2xl font-bold text-slate-900">HR Dashboard & Reports</h1>
|
||||
<p class="mt-1 text-sm text-slate-600">Live summary for the active audit firm{% if scope.branch_id %} and branch{% else %} across accessible branches{% endif %}.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/employees" class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-700">Employee Master</a>
|
||||
<a href="/employees/attendance" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Attendance</a>
|
||||
<a href="/employees/leave" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Leave</a>
|
||||
<a href="/employees/payroll/runs" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Payroll</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||
<div class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200"><p class="text-sm text-slate-500">Total Employees</p><p class="mt-2 text-3xl font-bold text-slate-900">{{ stats.employees.total }}</p><p class="mt-1 text-xs text-slate-500">Active: {{ stats.employees.active }}</p></div>
|
||||
<div class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200"><p class="text-sm text-slate-500">Today Attendance</p><p class="mt-2 text-3xl font-bold text-slate-900">{{ stats.attendance.today_present }}</p><p class="mt-1 text-xs text-slate-500">Records: {{ stats.attendance.today_total }}, Pending: {{ stats.attendance.today_pending }}</p></div>
|
||||
<div class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200"><p class="text-sm text-slate-500">Pending Leave</p><p class="mt-2 text-3xl font-bold text-slate-900">{{ stats.leave.pending }}</p><p class="mt-1 text-xs text-slate-500">Approved: {{ stats.leave.approved }}</p></div>
|
||||
<div class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200"><p class="text-sm text-slate-500">Docs to Verify</p><p class="mt-2 text-3xl font-bold text-slate-900">{{ stats.documents.uploaded }}</p><p class="mt-1 text-xs text-slate-500">Verified: {{ stats.documents.verified }}</p></div>
|
||||
<div class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200"><p class="text-sm text-slate-500">Payroll Pending</p><p class="mt-2 text-3xl font-bold text-slate-900">{{ stats.payroll.runs_draft + stats.payroll.runs_generated + stats.payroll.runs_approved }}</p><p class="mt-1 text-xs text-slate-500">Paid runs: {{ stats.payroll.runs_paid }}</p></div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<div class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
|
||||
<h2 class="text-lg font-semibold text-slate-900">Employee Status</h2>
|
||||
<div class="mt-4 space-y-3 text-sm">
|
||||
<div class="flex justify-between"><span>Active</span><strong>{{ stats.employees.active }}</strong></div>
|
||||
<div class="flex justify-between"><span>Inactive</span><strong>{{ stats.employees.inactive }}</strong></div>
|
||||
<div class="flex justify-between"><span>Relieved</span><strong>{{ stats.employees.relieved }}</strong></div>
|
||||
<div class="flex justify-between"><span>Pending registrations</span><strong>{{ stats.employees.pending_registrations }}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
|
||||
<h2 class="text-lg font-semibold text-slate-900">Workflow Pending</h2>
|
||||
<div class="mt-4 space-y-3 text-sm">
|
||||
<div class="flex justify-between"><span>Attendance approvals</span><strong>{{ stats.attendance.today_pending }}</strong></div>
|
||||
<div class="flex justify-between"><span>Leave approvals</span><strong>{{ stats.leave.pending }}</strong></div>
|
||||
<div class="flex justify-between"><span>Onboarding tasks</span><strong>{{ stats.onboarding.pending }}</strong></div>
|
||||
<div class="flex justify-between"><span>Offboarding requests</span><strong>{{ stats.offboarding.pending }}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
|
||||
<h2 class="text-lg font-semibold text-slate-900">Payroll Snapshot</h2>
|
||||
<div class="mt-4 space-y-3 text-sm">
|
||||
<div class="flex justify-between"><span>Active salary structures</span><strong>{{ stats.payroll.salary_structures_active }}</strong></div>
|
||||
<div class="flex justify-between"><span>Draft runs</span><strong>{{ stats.payroll.runs_draft }}</strong></div>
|
||||
<div class="flex justify-between"><span>Generated payslips</span><strong>{{ stats.payroll.payslips_generated }}</strong></div>
|
||||
<div class="flex justify-between"><span>Paid payslips</span><strong>{{ stats.payroll.payslips_paid }}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 xl:grid-cols-3">
|
||||
<div class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
|
||||
<div class="mb-4 flex items-center justify-between"><h2 class="text-lg font-semibold text-slate-900">Recent Employees</h2><a class="text-sm font-medium text-slate-600 hover:text-slate-900" href="/employees">View all</a></div>
|
||||
<div class="overflow-hidden rounded-xl border border-slate-200">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for emp in stats.recent_employees %}
|
||||
<tr><td class="px-3 py-2"><a class="font-medium text-slate-900 hover:underline" href="/employees/{{ emp.id }}">{{ emp.full_name }}</a><div class="text-xs text-slate-500">{{ emp.employee_code }} · {{ emp.designation or '-' }}</div></td><td class="px-3 py-2 text-right"><span class="rounded-full bg-slate-100 px-2 py-1 text-xs">{{ emp.status }}</span></td></tr>
|
||||
{% else %}
|
||||
<tr><td class="px-3 py-4 text-slate-500">No employees found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
|
||||
<div class="mb-4 flex items-center justify-between"><h2 class="text-lg font-semibold text-slate-900">Recent Leave</h2><a class="text-sm font-medium text-slate-600 hover:text-slate-900" href="/employees/leave">View all</a></div>
|
||||
<div class="space-y-3 text-sm">
|
||||
{% for item in stats.recent_leave_requests %}
|
||||
<div class="rounded-xl border border-slate-200 p-3"><div class="flex justify-between gap-3"><strong>{{ item.employee.full_name if item.employee else 'Employee' }}</strong><span class="text-xs text-slate-500">{{ item.status }}</span></div><div class="mt-1 text-xs text-slate-500">{{ item.leave_type.code if item.leave_type else '-' }} · {{ item.from_date }} to {{ item.to_date }}</div></div>
|
||||
{% else %}
|
||||
<p class="text-slate-500">No leave requests found.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
|
||||
<div class="mb-4 flex items-center justify-between"><h2 class="text-lg font-semibold text-slate-900">Recent Offboarding</h2><a class="text-sm font-medium text-slate-600 hover:text-slate-900" href="/employees/offboarding">View all</a></div>
|
||||
<div class="space-y-3 text-sm">
|
||||
{% for item in stats.recent_offboarding_requests %}
|
||||
<div class="rounded-xl border border-slate-200 p-3"><div class="flex justify-between gap-3"><strong>{{ item.employee.full_name if item.employee else 'Employee' }}</strong><span class="text-xs text-slate-500">{{ item.status }}</span></div><div class="mt-1 text-xs text-slate-500">Requested relieving date: {{ item.requested_relieving_date or '-' }}</div></div>
|
||||
{% else %}
|
||||
<p class="text-slate-500">No offboarding requests found.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,48 @@
|
||||
{% 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">Import Preview - {{ preview.import_label }}</h2>
|
||||
<p class="text-sm text-slate-500">Review validation results. Rows with error will be skipped during commit.</p>
|
||||
</div>
|
||||
<a href="/employees/imports" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back to Imports</a>
|
||||
</div>
|
||||
|
||||
{% if errors %}<div class="rounded-xl border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">{{ errors|join(', ') }}</div>{% endif %}
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="text-xs uppercase text-slate-500">Total Rows</div><div class="text-2xl font-semibold">{{ preview.summary.total }}</div></div>
|
||||
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4"><div class="text-xs uppercase text-emerald-700">Valid</div><div class="text-2xl font-semibold text-emerald-800">{{ preview.summary.valid }}</div></div>
|
||||
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-4"><div class="text-xs uppercase text-amber-700">Warnings</div><div class="text-2xl font-semibold text-amber-800">{{ preview.summary.warning }}</div></div>
|
||||
<div class="rounded-2xl border border-rose-200 bg-rose-50 p-4"><div class="text-xs uppercase text-rose-700">Errors</div><div class="text-2xl font-semibold text-rose-800">{{ preview.summary.error }}</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 tracking-wide text-slate-500">
|
||||
<tr><th class="px-4 py-3">Excel Row</th><th class="px-4 py-3">Status</th><th class="px-4 py-3">Action</th><th class="px-4 py-3">Key Data</th><th class="px-4 py-3">Messages</th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for row in preview.rows %}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3 font-semibold">{{ row.row_no }}</td>
|
||||
<td class="px-4 py-3"><span class="rounded-full px-2 py-1 text-xs font-semibold {% if row.status == 'valid' %}bg-emerald-50 text-emerald-700{% elif row.status == 'warning' %}bg-amber-50 text-amber-700{% else %}bg-rose-50 text-rose-700{% endif %}">{{ row.status|title }}</span></td>
|
||||
<td class="px-4 py-3">{{ row.action|title }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">
|
||||
{% if preview.import_type == 'employees' %}{{ row.data.employee_code }} - {{ row.data.full_name }}{% elif preview.import_type == 'leave_types' %}{{ row.data.code }} - {{ row.data.name }}{% elif preview.import_type == 'leave_balances' %}{{ row.data.employee_code }} / {{ row.data.leave_code }}{% else %}{{ row.data.employee_code }} from {{ row.data.effective_from }}{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ row.messages|join('; ') if row.messages else '-' }}</td>
|
||||
</tr>
|
||||
{% else %}<tr><td colspan="5" class="px-4 py-6 text-center text-slate-500">No rows found.</td></tr>{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/employees/imports/commit" class="flex items-center justify-end gap-3">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<a href="/employees/imports" 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" {% if preview.summary.total == 0 or (preview.summary.valid == 0 and preview.summary.warning == 0) %}disabled{% endif %}>Confirm Import</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,40 @@
|
||||
{% 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">HR Excel Imports</h2>
|
||||
<p class="text-sm text-slate-500">Import employees, leave types, opening leave balances and salary structures with validation preview before commit.</p>
|
||||
</div>
|
||||
<a href="/employees" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Employees</a>
|
||||
</div>
|
||||
|
||||
{% if errors %}<div class="rounded-xl border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">{{ errors|join(', ') }}</div>{% endif %}
|
||||
{% if message %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-700">{{ message }}</div>{% endif %}
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
{% for key, label in import_types.items() %}
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-900">{{ label }}</h3>
|
||||
<p class="mt-1 text-xs text-slate-500">Download template, fill data, upload and preview before import.</p>
|
||||
</div>
|
||||
<a href="/employees/imports/template/{{ key }}" class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Template</a>
|
||||
</div>
|
||||
<form method="post" action="/employees/imports/preview" enctype="multipart/form-data" class="mt-4 space-y-3">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="import_type" value="{{ key }}">
|
||||
<input type="file" name="import_file" accept=".xlsx,.xlsm" required class="block w-full rounded-xl border border-slate-300 px-3 py-2 text-sm file:mr-3 file:rounded-lg file:border-0 file:bg-slate-100 file:px-3 file:py-1.5 file:text-sm file:font-semibold file:text-slate-700">
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Upload & Preview</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
|
||||
<div class="font-semibold">Important</div>
|
||||
<p class="mt-1">For System Admin/Firm Admin using all-branch context, provide <code>branch_id</code> in Excel or switch to a specific active branch before importing. Existing rows are updated based on employee code, leave code, or employee/effective date as applicable.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,7 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/managers/templates/managers/_manager_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">Leave Balances</h2><p class="text-sm text-slate-500">View and adjust employee leave balances branch-wise.</p></div><a href="/employees/leave" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Leave Requests</a></div><form method="post" action="/employees/leave-balances/adjust" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><h3 class="font-semibold text-slate-900">Adjust Balance</h3><div class="mt-4 grid gap-3 md:grid-cols-5"><select name="employee_id" required class="rounded-xl border border-slate-300 px-3 py-2 text-sm"><option value="">Select employee</option>{% for emp in employees %}<option value="{{ emp.id }}">{{ emp.full_name }} - {{ emp.employee_code }}</option>{% endfor %}</select><select name="leave_type_id" required class="rounded-xl border border-slate-300 px-3 py-2 text-sm"><option value="">Leave type</option>{% for lt in leave_types %}<option value="{{ lt.id }}">{{ lt.code }} - {{ lt.name }}</option>{% endfor %}</select><input type="number" name="adjusted_days" value="0" class="rounded-xl border border-slate-300 px-3 py-2 text-sm"><input name="notes" placeholder="Notes" class="rounded-xl border border-slate-300 px-3 py-2 text-sm"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Save</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">Employee</th><th class="px-4 py-3">Leave Type</th><th class="px-4 py-3">Opening</th><th class="px-4 py-3">Credit</th><th class="px-4 py-3">Availed</th><th class="px-4 py-3">Adjust</th><th class="px-4 py-3">Balance</th></tr></thead><tbody class="divide-y divide-slate-100">{% for row in rows %}<tr><td class="px-4 py-3">{{ row.employee.full_name if row.employee else row.employee_id }}</td><td class="px-4 py-3">{{ row.leave_type.code if row.leave_type else row.leave_type_id }}</td><td class="px-4 py-3">{{ row.opening_days }}</td><td class="px-4 py-3">{{ row.credited_days }}</td><td class="px-4 py-3">{{ row.availed_days }}</td><td class="px-4 py-3">{{ row.adjusted_days }}</td><td class="px-4 py-3 font-semibold">{{ row.balance_days }}</td></tr>{% else %}<tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">No leave balances found. Balances are created when leave is approved or adjusted.</td></tr>{% endfor %}</tbody></table></div></div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,7 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/managers/templates/managers/_manager_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">Employee Leave Requests</h2><p class="text-sm text-slate-500">Review and approve/reject employee leave applications.</p></div><a href="/employees/leave-balances" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Leave Balances</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-4"><select name="employee_id" class="rounded-xl border border-slate-300 px-3 py-2 text-sm"><option value="">All employees</option>{% for emp in employees %}<option value="{{ emp.id }}" {% if selected_employee_id == emp.id %}selected{% endif %}>{{ emp.full_name }} - {{ emp.employee_code }}</option>{% endfor %}</select><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 statuses</option>{% for st in statuses %}<option value="{{ st }}" {% if status == st %}selected{% endif %}>{{ st.title() }}</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">Employee</th><th class="px-4 py-3">Leave</th><th class="px-4 py-3">Period</th><th class="px-4 py-3">Days</th><th class="px-4 py-3">Status</th><th class="px-4 py-3">Reason / Review</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><td class="px-4 py-3"><div class="font-medium text-slate-900">{{ row.employee.full_name if row.employee else ('Employee #' ~ row.employee_id) }}</div><div class="text-xs text-slate-500">{{ row.employee.employee_code if row.employee else '' }}</div></td><td class="px-4 py-3">{{ row.leave_type.name if row.leave_type else row.leave_type_id }}</td><td class="px-4 py-3">{{ row.from_date }} to {{ row.to_date }}</td><td class="px-4 py-3">{{ row.days }}</td><td class="px-4 py-3">{{ row.status.title() }}</td><td class="px-4 py-3 text-slate-600"><div>{{ row.reason or '-' }}</div>{% if row.review_notes %}<div class="text-xs text-slate-500">Review: {{ row.review_notes }}</div>{% endif %}</td><td class="px-4 py-3 text-right">{% if row.status == 'pending' and can_approve_employee_leave(current_user, current_user_permissions, current_user_roles) %}<form method="post" action="/employees/leave/{{ row.id }}/review" class="inline-flex gap-2"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="status" value="approved"><button class="rounded-lg border border-emerald-300 px-3 py-1.5 text-xs font-semibold text-emerald-700 hover:bg-emerald-50">Approve</button></form><form method="post" action="/employees/leave/{{ row.id }}/review" class="inline-flex gap-2"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="status" value="rejected"><button class="rounded-lg border border-red-300 px-3 py-1.5 text-xs font-semibold text-red-700 hover:bg-red-50">Reject</button></form>{% else %}-{% endif %}</td></tr>{% else %}<tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">No leave requests found.</td></tr>{% endfor %}</tbody></table></div></div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,10 @@
|
||||
{% 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">Leave Types</h2><p class="text-sm text-slate-500">Configure branch-wise leave masters such as CL, SL, EL and LOP.</p></div><form method="post" action="/employees/leave-types/defaults"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Create Defaults</button></form></div>
|
||||
<form method="post" action="/employees/leave-types/new" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><h3 class="font-semibold text-slate-900">Add Leave Type</h3><div class="mt-4 grid gap-3 md:grid-cols-4"><input name="code" required placeholder="Code e.g. CL" class="rounded-xl border border-slate-300 px-3 py-2 text-sm"><input name="name" required placeholder="Name" class="rounded-xl border border-slate-300 px-3 py-2 text-sm"><input type="number" name="annual_quota_days" value="0" min="0" class="rounded-xl border border-slate-300 px-3 py-2 text-sm"><input name="description" placeholder="Description" class="rounded-xl border border-slate-300 px-3 py-2 text-sm"></div><div class="mt-3 flex flex-wrap gap-4 text-sm text-slate-700"><label><input type="checkbox" name="requires_approval" checked> Requires approval</label><label><input type="checkbox" name="is_paid" checked> Paid leave</label><label><input type="checkbox" name="carry_forward_allowed"> Carry forward</label><label><input type="checkbox" name="allow_negative_balance"> Allow negative balance</label><label><input type="checkbox" name="is_active" checked> Active</label></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 Leave Type</button></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">Code</th><th class="px-4 py-3">Name</th><th class="px-4 py-3">Quota</th><th class="px-4 py-3">Rules</th><th class="px-4 py-3">Status</th><th class="px-4 py-3">Edit</th></tr></thead><tbody class="divide-y divide-slate-100">{% for row in rows %}<tr><td class="px-4 py-3 font-semibold">{{ row.code }}</td><td class="px-4 py-3">{{ row.name }}</td><td class="px-4 py-3">{{ row.annual_quota_days }}</td><td class="px-4 py-3 text-slate-600">{{ 'Approval' if row.requires_approval else 'Auto approve' }} · {{ 'Paid' if row.is_paid else 'Unpaid' }}{% if row.allow_negative_balance %} · Negative allowed{% endif %}</td><td class="px-4 py-3">{{ 'Active' if row.is_active else 'Inactive' }}</td><td class="px-4 py-3"><details><summary class="cursor-pointer text-brand-700">Edit</summary><form method="post" action="/employees/leave-types/{{ row.id }}/edit" class="mt-3 grid gap-2"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input name="name" value="{{ row.name }}" class="rounded-lg border px-2 py-1"><input type="number" name="annual_quota_days" value="{{ row.annual_quota_days }}" class="rounded-lg border px-2 py-1"><input name="description" value="{{ row.description or '' }}" class="rounded-lg border px-2 py-1"><label><input type="checkbox" name="requires_approval" {% if row.requires_approval %}checked{% endif %}> Requires approval</label><label><input type="checkbox" name="is_paid" {% if row.is_paid %}checked{% endif %}> Paid</label><label><input type="checkbox" name="carry_forward_allowed" {% if row.carry_forward_allowed %}checked{% endif %}> Carry forward</label><label><input type="checkbox" name="allow_negative_balance" {% if row.allow_negative_balance %}checked{% endif %}> Negative allowed</label><label><input type="checkbox" name="is_active" {% if row.is_active %}checked{% endif %}> Active</label><button class="rounded-lg bg-slate-900 px-3 py-1.5 text-xs text-white">Update</button></form></details></td></tr>{% else %}<tr><td colspan="6" class="px-4 py-8 text-center text-slate-500">No leave types found.</td></tr>{% endfor %}</tbody></table></div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,85 @@
|
||||
{% 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">Employees</h2>
|
||||
<p class="text-sm text-slate-500">Employee master, user linkage, department, designation, reporting manager and status.</p>
|
||||
</div>
|
||||
{% if can_import_employee_hr(current_user, current_user_permissions, current_user_roles) %}
|
||||
<a href="/employees/imports" class="inline-flex rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">HR Imports</a>
|
||||
{% endif %}
|
||||
{% if can_manage_employees(current_user, current_user_permissions, current_user_roles) %}
|
||||
<a href="/employees/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 Employee</a>
|
||||
{% endif %}
|
||||
</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_auto]">
|
||||
<input name="q" value="{{ q or '' }}" placeholder="Search by code, name, email, mobile, PAN, department or designation" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<select name="link_status" class="rounded-xl border border-slate-300 px-3 py-2 text-sm text-slate-700">
|
||||
<option value="all" {% if link_status == 'all' %}selected{% endif %}>All login link status</option>
|
||||
<option value="linked" {% if link_status == 'linked' %}selected{% endif %}>Linked to login user</option>
|
||||
<option value="unlinked" {% if link_status == 'unlinked' %}selected{% endif %}>Not linked to login user</option>
|
||||
</select>
|
||||
<label class="inline-flex items-center gap-2 rounded-xl border border-slate-300 px-3 py-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="include_inactive" value="1" {% if include_inactive %}checked{% endif %}> Include inactive
|
||||
</label>
|
||||
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-5">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Visible Employees</div><div class="mt-1 text-2xl font-semibold">{{ rows|length }}</div></div>
|
||||
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-emerald-700">Linked Users</div><div class="mt-1 text-2xl font-semibold text-emerald-800">{{ link_summary.linked if link_summary else 0 }}</div></div>
|
||||
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-amber-700">Unlinked Employees</div><div class="mt-1 text-2xl font-semibold text-amber-800">{{ link_summary.unlinked if link_summary else 0 }}</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">Active Scope Branch</div><div class="mt-1 text-lg font-semibold">{{ scope.branch_id if scope.branch_id else 'All' }}</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">Access</div><div class="mt-1 text-sm font-semibold">{{ 'Cross Audit Firm' if scope.allow_cross_tenant else 'Audit Firm Scoped' }} / {{ 'Cross Branch' if scope.allow_cross_branch else 'Branch Scoped' }}</div></div>
|
||||
</div>
|
||||
|
||||
{% if link_summary and link_summary.unlinked %}
|
||||
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||
<div class="font-semibold">{{ link_summary.unlinked }} employee(s) are not linked to login users.</div>
|
||||
<p class="mt-1">Employee self-service pages such as My Workspace, attendance, leave, documents and payslips work fully only after the employee master is linked to an IAM user.</p>
|
||||
<a href="/employees?link_status=unlinked{% if include_inactive %}&include_inactive=1{% endif %}" class="mt-2 inline-flex rounded-lg border border-amber-300 px-3 py-1.5 text-xs font-semibold text-amber-900 hover:bg-amber-100">Show unlinked employees</a>
|
||||
</div>
|
||||
{% 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">Employee</th>
|
||||
<th class="px-4 py-3">Department</th>
|
||||
<th class="px-4 py-3">Contact</th>
|
||||
<th class="px-4 py-3">Joining</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.full_name }}</div>
|
||||
<div class="text-xs text-slate-500">{{ row.employee_code }}{% if row.user_id %} • User #{{ row.user_id }}{% endif %}</div>
|
||||
{% if row.user_id %}
|
||||
<span class="mt-1 inline-flex rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-semibold text-emerald-700">Login linked</span>
|
||||
{% else %}
|
||||
<span class="mt-1 inline-flex rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-semibold text-amber-700">Login not linked</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-600"><div>{{ row.department or '-' }}</div><div class="text-xs">{{ row.designation or '-' }}</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.date_of_joining or '-' }}</td>
|
||||
<td class="px-4 py-3"><span class="rounded-full px-2 py-1 text-xs font-semibold {% if row.status == 'active' %}bg-emerald-50 text-emerald-700{% elif row.status == 'relieved' %}bg-amber-50 text-amber-700{% else %}bg-slate-100 text-slate-600{% endif %}">{{ row.status.replace('_',' ').title() }}</span></td>
|
||||
<td class="px-4 py-3 text-right"><a href="/employees/{{ 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 employees found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,10 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div><h1 class="text-2xl font-semibold text-slate-900">Employee Offboarding</h1><p class="text-sm text-slate-500">Manage resignation, handover and relieving checklist.</p></div>
|
||||
<div class="rounded-2xl border bg-white p-4 shadow-sm"><h2 class="mb-3 font-semibold">Initiate offboarding</h2><form method="post" id="offboardForm" class="grid gap-3 md:grid-cols-6"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><select name="employee_id" onchange="document.getElementById('offboardForm').action='/employees/'+this.value+'/offboarding/initiate'" required class="rounded-xl border px-3 py-2 text-sm md:col-span-2"><option value="">Select employee</option>{% for e in employees %}<option value="{{ e.id }}">{{ e.employee_code }} - {{ e.full_name }}</option>{% endfor %}</select><select name="request_type" class="rounded-xl border px-3 py-2 text-sm"><option value="resignation">Resignation</option><option value="termination">Termination</option><option value="contract_end">Contract End</option></select><input type="date" name="requested_relieving_date" class="rounded-xl border px-3 py-2 text-sm"><input name="reason" placeholder="Reason" class="rounded-xl border px-3 py-2 text-sm md:col-span-2"><textarea name="handover_notes" placeholder="Handover notes" class="rounded-xl border px-3 py-2 text-sm md:col-span-5"></textarea><button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white">Create Request</button></form></div>
|
||||
<div class="overflow-hidden rounded-2xl border bg-white shadow-sm"><table class="min-w-full divide-y text-sm"><thead class="bg-slate-50"><tr><th class="p-3 text-left">Employee</th><th class="p-3">Requested Date</th><th class="p-3">Status</th><th class="p-3 text-left">Review / Completion</th></tr></thead><tbody class="divide-y">
|
||||
{% for r in rows %}<tr><td class="p-3"><div class="font-medium">{{ r.employee.full_name if r.employee else r.employee_id }}</div><div class="text-xs text-slate-500">{{ r.request_type }}{% if r.reason %} · {{ r.reason }}{% endif %}</div></td><td class="p-3 text-center">{{ r.requested_relieving_date or '-' }}</td><td class="p-3 text-center"><span class="rounded-full bg-slate-100 px-2 py-1 text-xs">{{ r.status }}</span></td><td class="p-3 space-y-2">{% if r.status in ['pending','approved'] %}<form method="post" action="/employees/offboarding/{{ r.id }}/review" class="flex flex-wrap gap-2"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><select name="status" class="rounded-lg border px-2 py-1"><option value="approved">Approve</option><option value="rejected">Reject</option></select><input type="date" name="approved_relieving_date" value="{{ r.approved_relieving_date or r.requested_relieving_date or '' }}" class="rounded-lg border px-2 py-1"><input name="review_notes" placeholder="Review notes" class="rounded-lg border px-2 py-1"><button class="rounded-lg bg-blue-700 px-3 py-1 text-white">Submit</button></form>{% endif %}{% if r.status == 'approved' %}<form method="post" action="/employees/offboarding/{{ r.id }}/complete"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="rounded-lg bg-emerald-700 px-3 py-1 text-white">Complete Final Offboarding</button></form>{% endif %}</td></tr>{% else %}<tr><td colspan="4" class="p-6 text-center text-slate-500">No offboarding requests.</td></tr>{% endfor %}
|
||||
</tbody></table></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,26 @@
|
||||
{% 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">Onboarding Checklist</h1><p class="text-sm text-slate-500">Branch-wise reusable joining checklist items.</p></div>
|
||||
<form method="post" action="/employees/onboarding-checklist/defaults"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white">Create Defaults</button></form>
|
||||
</div>
|
||||
<div class="rounded-2xl border bg-white p-4 shadow-sm"><h2 class="mb-3 font-semibold">Add checklist item</h2>
|
||||
<form method="post" action="/employees/onboarding-checklist" class="grid gap-3 md:grid-cols-6">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input name="code" required placeholder="Code" class="rounded-xl border px-3 py-2 text-sm">
|
||||
<input name="title" required placeholder="Title" class="rounded-xl border px-3 py-2 text-sm md:col-span-2">
|
||||
<input name="stage" value="joining" class="rounded-xl border px-3 py-2 text-sm">
|
||||
<input name="default_due_days" type="number" value="0" class="rounded-xl border px-3 py-2 text-sm">
|
||||
<input name="sort_order" type="number" value="0" class="rounded-xl border px-3 py-2 text-sm">
|
||||
<textarea name="description" placeholder="Description" class="rounded-xl border px-3 py-2 text-sm md:col-span-4"></textarea>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="is_mandatory" checked> Mandatory</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="is_active" checked> Active</label>
|
||||
<button class="rounded-xl bg-blue-700 px-4 py-2 text-sm font-semibold text-white md:col-span-6">Add Item</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-2xl border bg-white shadow-sm"><table class="min-w-full divide-y text-sm"><thead class="bg-slate-50"><tr><th class="p-3 text-left">Code</th><th class="p-3 text-left">Title</th><th class="p-3">Stage</th><th class="p-3">Due Days</th><th class="p-3">Status</th><th class="p-3 text-left">Update</th></tr></thead><tbody class="divide-y">
|
||||
{% for r in rows %}<tr><form method="post" action="/employees/onboarding-checklist/{{ r.id }}/edit"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><td class="p-3 font-medium">{{ r.code }}</td><td class="p-3"><input name="title" value="{{ r.title }}" class="w-full rounded-lg border px-2 py-1"><input type="hidden" name="code" value="{{ r.code }}"><textarea name="description" class="mt-2 w-full rounded-lg border px-2 py-1">{{ r.description or '' }}</textarea></td><td class="p-3"><input name="stage" value="{{ r.stage }}" class="w-28 rounded-lg border px-2 py-1"></td><td class="p-3"><input name="default_due_days" type="number" value="{{ r.default_due_days }}" class="w-20 rounded-lg border px-2 py-1"><input name="sort_order" type="hidden" value="{{ r.sort_order }}"></td><td class="p-3"><label><input type="checkbox" name="is_mandatory" {% if r.is_mandatory %}checked{% endif %}> Mandatory</label><br><label><input type="checkbox" name="is_active" {% if r.is_active %}checked{% endif %}> Active</label></td><td class="p-3"><button class="rounded-lg bg-slate-900 px-3 py-1 text-white">Save</button></td></form></tr>{% else %}<tr><td colspan="6" class="p-6 text-center text-slate-500">No checklist items yet.</td></tr>{% endfor %}
|
||||
</tbody></table></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,10 @@
|
||||
{% 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">Employee Onboarding</h1><p class="text-sm text-slate-500">Generate and track joining checklist tasks.</p></div><a href="/employees/onboarding-checklist" class="rounded-xl border px-4 py-2 text-sm font-semibold">Checklist Master</a></div>
|
||||
<div class="rounded-2xl border bg-white p-4 shadow-sm"><form method="get" action="/employees/onboarding" class="flex flex-wrap gap-3"><select name="employee_id" class="rounded-xl border px-3 py-2 text-sm"><option value="">All employees</option>{% for e in employees %}<option value="{{ e.id }}" {% if selected_employee_id==e.id %}selected{% endif %}>{{ e.employee_code }} - {{ e.full_name }}</option>{% endfor %}</select><button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white">Filter</button></form></div>
|
||||
<div class="overflow-hidden rounded-2xl border bg-white shadow-sm"><table class="min-w-full divide-y text-sm"><thead class="bg-slate-50"><tr><th class="p-3 text-left">Employee</th><th class="p-3 text-left">Task</th><th class="p-3">Due</th><th class="p-3">Status</th><th class="p-3 text-left">Action</th></tr></thead><tbody class="divide-y">
|
||||
{% for r in rows %}<tr><td class="p-3">{{ r.employee.full_name if r.employee else r.employee_id }}</td><td class="p-3"><div class="font-medium">{{ r.title }}</div><div class="text-xs text-slate-500">{{ r.stage }}{% if r.description %} · {{ r.description }}{% endif %}</div></td><td class="p-3 text-center">{{ r.due_date or '-' }}</td><td class="p-3 text-center"><span class="rounded-full bg-slate-100 px-2 py-1 text-xs">{{ r.status }}</span></td><td class="p-3"><form method="post" action="/employees/onboarding/{{ r.id }}/status" class="flex flex-wrap gap-2"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><select name="status" class="rounded-lg border px-2 py-1">{% for st in onboarding_task_statuses %}<option value="{{ st }}" {% if r.status==st %}selected{% endif %}>{{ st }}</option>{% endfor %}</select><input name="review_notes" placeholder="Notes" class="rounded-lg border px-2 py-1"><button class="rounded-lg bg-blue-700 px-3 py-1 text-white">Update</button></form></td></tr>{% else %}<tr><td colspan="5" class="p-6 text-center text-slate-500">No onboarding tasks. Open an employee detail page and click Generate Onboarding.</td></tr>{% endfor %}
|
||||
</tbody></table></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,13 @@
|
||||
{% 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">Payroll Runs</h2><p class="text-sm text-slate-500">Create monthly payroll run, generate payslips, approve and mark paid.</p></div><a href="/employees/payroll/payslips" class="rounded-xl border px-4 py-2 text-sm font-semibold">Payslips</a></div>
|
||||
{% if scope.branch_id is none %}<div class="rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">Please select a branch context before creating payroll run.</div>{% endif %}
|
||||
<form method="post" action="/employees/payroll/runs" class="grid gap-3 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm md:grid-cols-5">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input name="pay_year" type="number" min="2020" max="2100" value="{{ now().year if now is defined else 2026 }}" required class="rounded-xl border px-3 py-2 text-sm"><input name="pay_month" type="number" min="1" max="12" required placeholder="Month" class="rounded-xl border px-3 py-2 text-sm"><input name="notes" placeholder="Notes" class="rounded-xl border px-3 py-2 text-sm md:col-span-2"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Create Run</button>
|
||||
</form>
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm"><table class="min-w-full divide-y divide-slate-200 text-sm"><thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left">Period</th><th class="px-4 py-3 text-left">Status</th><th class="px-4 py-3 text-right">Employees</th><th class="px-4 py-3 text-right">Gross</th><th class="px-4 py-3 text-right">Deduction</th><th class="px-4 py-3 text-right">Net</th><th class="px-4 py-3 text-right">Actions</th></tr></thead><tbody class="divide-y divide-slate-100">
|
||||
{% for row in rows %}<tr><td class="px-4 py-3 font-medium">{{ '%02d' % row.pay_month }}/{{ row.pay_year }}</td><td class="px-4 py-3">{{ row.status.title() }}</td><td class="px-4 py-3 text-right">{{ row.total_employees }}</td><td class="px-4 py-3 text-right">{{ row.gross_amount }}</td><td class="px-4 py-3 text-right">{{ row.deduction_amount }}</td><td class="px-4 py-3 text-right font-semibold">{{ row.net_amount }}</td><td class="px-4 py-3"><div class="flex justify-end gap-2"><form method="post" action="/employees/payroll/runs/{{ row.id }}/generate"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="rounded-lg border px-2 py-1 text-xs">Generate</button></form>{% if row.status == 'generated' %}<form method="post" action="/employees/payroll/runs/{{ row.id }}/approve"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="rounded-lg bg-emerald-600 px-2 py-1 text-xs text-white">Approve</button></form>{% endif %}{% if row.status == 'approved' %}<form method="post" action="/employees/payroll/runs/{{ row.id }}/paid"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="rounded-lg bg-indigo-600 px-2 py-1 text-xs text-white">Paid</button></form>{% endif %}<a href="/employees/payroll/payslips?payroll_run_id={{ row.id }}" class="rounded-lg border px-2 py-1 text-xs">View</a></div></td></tr>{% else %}<tr><td colspan="7" class="px-4 py-6 text-center text-slate-500">No payroll runs yet.</td></tr>{% endfor %}
|
||||
</tbody></table></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,36 @@
|
||||
{% 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">Salary Structures</h2><p class="text-sm text-slate-500">Maintain branch-wise employee salary structures for payroll generation.</p></div>
|
||||
<a href="/employees" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Employees</a>
|
||||
</div>
|
||||
{% if errors %}<div class="rounded-xl border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">{{ errors|join(', ') }}</div>{% endif %}
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<h3 class="font-semibold text-slate-900">Add Salary Structure</h3>
|
||||
<form method="post" action="/employees/payroll/structures" class="mt-4 grid gap-3 md:grid-cols-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<select name="employee_id" required class="rounded-xl border border-slate-300 px-3 py-2 text-sm md:col-span-2"><option value="">Select Employee</option>{% for emp in employees %}<option value="{{ emp.id }}">{{ emp.employee_code }} - {{ emp.full_name }}</option>{% endfor %}</select>
|
||||
<input type="date" name="effective_from" required class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<input type="date" name="effective_to" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<input name="monthly_ctc_amount" type="number" min="0" step="1" placeholder="Monthly CTC" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<input name="basic_amount" type="number" min="0" step="1" placeholder="Basic" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<input name="hra_amount" type="number" min="0" step="1" placeholder="HRA" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<input name="allowance_amount" type="number" min="0" step="1" placeholder="Other Allowance" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<input name="employee_pf_amount" type="number" min="0" step="1" placeholder="PF" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<input name="employee_esi_amount" type="number" min="0" step="1" placeholder="ESI" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<input name="professional_tax_amount" type="number" min="0" step="1" placeholder="Professional Tax" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<input name="tds_amount" type="number" min="0" step="1" placeholder="TDS" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<input name="other_deduction_amount" type="number" min="0" step="1" placeholder="Other Deduction" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<input name="remarks" placeholder="Remarks" class="rounded-xl border border-slate-300 px-3 py-2 text-sm md:col-span-2">
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="is_active" checked> Active</label>
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Save Structure</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm"><thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left">Employee</th><th class="px-4 py-3 text-left">Effective</th><th class="px-4 py-3 text-right">Gross</th><th class="px-4 py-3 text-right">Deductions</th><th class="px-4 py-3 text-right">Net</th><th class="px-4 py-3 text-left">Status</th></tr></thead><tbody class="divide-y divide-slate-100">
|
||||
{% for row in rows %}{% set gross = row.basic_amount + row.hra_amount + row.allowance_amount %}{% set ded = row.employee_pf_amount + row.employee_esi_amount + row.professional_tax_amount + row.tds_amount + row.other_deduction_amount %}<tr><td class="px-4 py-3 font-medium">{{ row.employee.employee_code if row.employee else row.employee_id }} - {{ row.employee.full_name if row.employee else '' }}</td><td class="px-4 py-3">{{ row.effective_from }}{% if row.effective_to %} to {{ row.effective_to }}{% endif %}</td><td class="px-4 py-3 text-right">{{ gross }}</td><td class="px-4 py-3 text-right">{{ ded }}</td><td class="px-4 py-3 text-right font-semibold">{{ gross - ded }}</td><td class="px-4 py-3">{{ 'Active' if row.is_active else 'Inactive' }}</td></tr>{% else %}<tr><td colspan="6" class="px-4 py-6 text-center text-slate-500">No salary structures available.</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">Employee Payslips</h2><p class="text-sm text-slate-500">Review generated payroll slips for employees.</p></div><a href="/employees/payroll/runs" class="rounded-xl border px-4 py-2 text-sm font-semibold">Payroll Runs</a></div>
|
||||
<form method="get" class="grid gap-3 rounded-2xl border border-slate-200 bg-white p-4 md:grid-cols-4"><select name="payroll_run_id" class="rounded-xl border px-3 py-2 text-sm"><option value="">All Runs</option>{% for run in runs %}<option value="{{ run.id }}" {% if selected_run_id == run.id %}selected{% endif %}>{{ '%02d' % run.pay_month }}/{{ run.pay_year }} - {{ run.status }}</option>{% endfor %}</select><select name="employee_id" class="rounded-xl border px-3 py-2 text-sm"><option value="">All Employees</option>{% for emp in employees %}<option value="{{ emp.id }}" {% if selected_employee_id == emp.id %}selected{% endif %}>{{ emp.employee_code }} - {{ emp.full_name }}</option>{% endfor %}</select><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Filter</button></form>
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm"><table class="min-w-full divide-y divide-slate-200 text-sm"><thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left">Period</th><th class="px-4 py-3 text-left">Employee</th><th class="px-4 py-3 text-right">Gross</th><th class="px-4 py-3 text-right">Deduction</th><th class="px-4 py-3 text-right">Net</th><th class="px-4 py-3 text-left">Status</th></tr></thead><tbody class="divide-y divide-slate-100">{% for row in rows %}<tr><td class="px-4 py-3">{{ '%02d' % row.pay_month }}/{{ row.pay_year }}</td><td class="px-4 py-3 font-medium">{{ row.employee.employee_code if row.employee else row.employee_id }} - {{ row.employee.full_name if row.employee else '' }}</td><td class="px-4 py-3 text-right">{{ row.gross_amount }}</td><td class="px-4 py-3 text-right">{{ row.deduction_amount }}</td><td class="px-4 py-3 text-right font-semibold">{{ row.net_amount }}</td><td class="px-4 py-3">{{ row.status.title() }}</td></tr>{% else %}<tr><td colspan="6" class="px-4 py-6 text-center text-slate-500">No payslips available.</td></tr>{% endfor %}</tbody></table></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,148 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
{% include "modules/employees/templates/employees/_my_workspace_tabs.html" %}
|
||||
|
||||
<section class="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-soft">
|
||||
<div class="bg-gradient-to-r from-brand-700 via-brand-600 to-slate-900 px-6 py-6 text-white">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-brand-100">My Workspace</p>
|
||||
<h2 class="mt-2 text-2xl font-semibold">Today’s work, attendance and personal profile</h2>
|
||||
<p class="mt-2 max-w-3xl text-sm text-brand-100">Use this dashboard for your own assignments, attendance, leave, documents, payslips and alerts.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% if employee %}
|
||||
<a href="/employee/work" class="rounded-xl bg-white px-4 py-2 text-sm font-semibold text-brand-700 hover:bg-brand-50">Open My Work Board</a>
|
||||
<a href="/employee/attendance" class="rounded-xl border border-white/40 px-4 py-2 text-sm font-semibold text-white hover:bg-white/10">Punch / Attendance</a>
|
||||
{% else %}
|
||||
<a href="/employee/register" class="rounded-xl bg-white px-4 py-2 text-sm font-semibold text-brand-700 hover:bg-brand-50">Request Employee Linkage</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if employee %}
|
||||
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
|
||||
<a href="/employee/work" class="af-metric-card hover:border-brand-200">
|
||||
<div class="text-xs font-semibold uppercase text-slate-500">Open Work</div>
|
||||
<div class="mt-2 text-3xl font-semibold text-slate-900">{{ work_payload.summary.open if work_payload else 0 }}</div>
|
||||
<div class="mt-1 text-xs text-slate-500">Assigned to me</div>
|
||||
</a>
|
||||
<a href="/employee/work?status=in_progress" class="af-metric-card hover:border-brand-200">
|
||||
<div class="text-xs font-semibold uppercase text-slate-500">In Progress</div>
|
||||
<div class="mt-2 text-3xl font-semibold text-brand-700">{{ work_payload.summary.in_progress if work_payload else 0 }}</div>
|
||||
<div class="mt-1 text-xs text-slate-500">Currently active</div>
|
||||
</a>
|
||||
<a href="/employee/work?status=blocked" class="af-metric-card border-amber-200 bg-amber-50 hover:border-amber-300">
|
||||
<div class="text-xs font-semibold uppercase text-amber-700">Blocked</div>
|
||||
<div class="mt-2 text-3xl font-semibold text-amber-700">{{ work_payload.summary.blocked if work_payload else 0 }}</div>
|
||||
<div class="mt-1 text-xs text-amber-700">Need clarification</div>
|
||||
</a>
|
||||
<a href="/employee/work?date_bucket=overdue" class="af-metric-card border-red-200 bg-red-50 hover:border-red-300">
|
||||
<div class="text-xs font-semibold uppercase text-red-700">Overdue</div>
|
||||
<div class="mt-2 text-3xl font-semibold text-red-700">{{ work_payload.summary.overdue if work_payload else 0 }}</div>
|
||||
<div class="mt-1 text-xs text-red-700">Immediate action</div>
|
||||
</a>
|
||||
<a href="/employee/attendance" class="af-metric-card hover:border-brand-200">
|
||||
<div class="text-xs font-semibold uppercase text-slate-500">Attendance</div>
|
||||
<div class="mt-2 text-lg font-semibold text-slate-900">{% if today_attendance %}Marked{% else %}Not Marked{% endif %}</div>
|
||||
<div class="mt-1 text-xs text-slate-500">Today</div>
|
||||
</a>
|
||||
<a href="/employee/payslips" class="af-metric-card hover:border-brand-200">
|
||||
<div class="text-xs font-semibold uppercase text-slate-500">Payslips</div>
|
||||
<div class="mt-2 text-3xl font-semibold text-slate-900">{{ payslips|length }}</div>
|
||||
<div class="mt-1 text-xs text-slate-500">Available slips</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 flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-slate-900">My Assignment Board Snapshot</h3>
|
||||
<p class="mt-1 text-sm text-slate-600">Your work is grouped by practical status so you can start from urgent items first.</p>
|
||||
</div>
|
||||
<a href="/employee/work" class="af-btn af-btn-primary">Open My Work Board</a>
|
||||
</div>
|
||||
<div class="mt-5 grid gap-3 md:grid-cols-4">
|
||||
{% if work_payload %}
|
||||
{% for column in work_payload.columns %}
|
||||
<a href="/employee/work?status={{ column.code }}" class="rounded-2xl border border-slate-200 bg-slate-50 p-4 transition hover:border-brand-300 hover:bg-brand-50/50">
|
||||
<div class="text-sm font-semibold text-slate-900">{{ column.label }}</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ column.cards|length }}</div>
|
||||
<div class="mt-1 text-xs text-slate-500">assignment card(s)</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500 md:col-span-4">No work board data available yet.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="af-card">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-slate-900">Leave & Availability</h3>
|
||||
<p class="mt-1 text-sm text-slate-600">Balances and recent leave requests for your profile.</p>
|
||||
</div>
|
||||
<a href="/employee/leave" class="af-btn af-btn-secondary">Open Leave</a>
|
||||
</div>
|
||||
<div class="mt-5 grid gap-3 md:grid-cols-3">
|
||||
{% for bal in leave_balances %}
|
||||
<div class="rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div class="text-xs font-semibold uppercase text-slate-500">{{ bal.leave_type.name if bal.leave_type else bal.leave_type_id }}</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ bal.balance_days }}</div>
|
||||
<div class="text-xs text-slate-500">days balance</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500 md:col-span-3">No leave balance available yet.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="space-y-6">
|
||||
<div class="af-card">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-900">Today’s Attendance</h3>
|
||||
<p class="mt-1 text-sm text-slate-600">{% if today_attendance %}Punch In: {{ today_attendance.punch_in_utc.strftime('%H:%M') if today_attendance.punch_in_utc else '-' }} · Punch Out: {{ today_attendance.punch_out_utc.strftime('%H:%M') if today_attendance.punch_out_utc else '-' }}{% else %}Attendance not marked for today.{% endif %}</p>
|
||||
</div>
|
||||
<a href="/employee/attendance" class="af-btn af-btn-secondary">Open</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="af-card">
|
||||
<h3 class="font-semibold text-slate-900">Profile Summary</h3>
|
||||
<dl class="mt-4 grid gap-4 text-sm">
|
||||
<div><dt class="text-slate-500">Name</dt><dd class="font-medium text-slate-900">{{ employee.full_name }}</dd></div>
|
||||
<div><dt class="text-slate-500">Code / Status</dt><dd class="font-medium text-slate-900">{{ employee.employee_code }} · {{ employee.status.replace('_',' ').title() }}</dd></div>
|
||||
<div><dt class="text-slate-500">Designation</dt><dd class="font-medium text-slate-900">{{ employee.designation or '-' }}</dd></div>
|
||||
<div><dt class="text-slate-500">Email</dt><dd class="font-medium text-slate-900">{{ employee.email or current_user.email }}</dd></div>
|
||||
<div><dt class="text-slate-500">Mobile</dt><dd class="font-medium text-slate-900">{{ employee.mobile or current_user.mobile or '-' }}</dd></div>
|
||||
</dl>
|
||||
<a href="/employee/profile" class="mt-5 inline-flex af-btn af-btn-secondary">Update My Profile</a>
|
||||
</div>
|
||||
|
||||
<div class="af-card">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h3 class="font-semibold text-slate-900">Quick Links</h3>
|
||||
</div>
|
||||
<div class="mt-4 grid gap-2 text-sm">
|
||||
<a href="/employee/documents" class="rounded-xl border border-slate-200 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-50">My Documents</a>
|
||||
<a href="/employee/payslips" class="rounded-xl border border-slate-200 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-50">My Payslips</a>
|
||||
<a href="/alerts" class="rounded-xl border border-slate-200 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-50">My Alert</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
{% elif pending_request %}
|
||||
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-6 text-sm text-amber-900 shadow-soft"><h3 class="font-semibold">Employee profile request is pending</h3><p class="mt-1">Your request #{{ pending_request.id }} is waiting for approval.</p></div>
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-6 shadow-soft"><h3 class="font-semibold text-amber-900">Your login is not linked to an employee master</h3><p class="mt-1 text-sm text-amber-900">Submit an employee link request or ask Firm Admin/Partner/Branch Manager to link your user account from Employee Master.</p><a href="/employee/register" class="mt-4 inline-flex rounded-xl bg-amber-700 px-4 py-2 text-sm font-semibold text-white hover:bg-amber-800">Request Employee Link</a></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,105 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/managers/templates/managers/_manager_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-slate-900">Engagement Progress</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Client-wise and engagement-wise progress based on existing service task instances.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-4 xl:grid-cols-8">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Clients</div><div class="mt-1 text-2xl font-semibold">{{ progress_payload.summary.clients }}</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">Engagements</div><div class="mt-1 text-2xl font-semibold">{{ progress_payload.summary.engagements }}</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">Tasks</div><div class="mt-1 text-2xl font-semibold">{{ progress_payload.summary.tasks }}</div></div>
|
||||
<div class="rounded-2xl border border-blue-200 bg-blue-50 p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-blue-600">Avg Progress</div><div class="mt-1 text-2xl font-semibold text-blue-700">{{ progress_payload.summary.average_progress }}%</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">Open</div><div class="mt-1 text-2xl font-semibold">{{ progress_payload.summary.open }}</div></div>
|
||||
<div class="rounded-2xl border border-red-200 bg-red-50 p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-red-600">Overdue</div><div class="mt-1 text-2xl font-semibold text-red-700">{{ progress_payload.summary.overdue }}</div></div>
|
||||
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-amber-600">Due Today</div><div class="mt-1 text-2xl font-semibold text-amber-700">{{ progress_payload.summary.due_today }}</div></div>
|
||||
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-emerald-600">Completed</div><div class="mt-1 text-2xl font-semibold text-emerald-700">{{ progress_payload.summary.completed }}</div></div>
|
||||
</div>
|
||||
|
||||
<form method="get" action="/employees/progress" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="grid gap-3 md:grid-cols-[1fr_180px_240px_auto]">
|
||||
<input type="search" name="q" value="{{ q or '' }}" placeholder="Search client, engagement or task" class="rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100">
|
||||
<select name="status" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="open" {% if status == 'open' %}selected{% endif %}>Open engagements</option>
|
||||
<option value="all" {% if status == 'all' %}selected{% endif %}>All tasks</option>
|
||||
<option value="overdue" {% if status == 'overdue' %}selected{% endif %}>Overdue only</option>
|
||||
<option value="due_today" {% if status == 'due_today' %}selected{% endif %}>Due today</option>
|
||||
<option value="closed" {% if status == 'closed' %}selected{% endif %}>Closed tasks</option>
|
||||
<option value="pending" {% if status == 'pending' %}selected{% endif %}>Pending</option>
|
||||
<option value="in_progress" {% if status == 'in_progress' %}selected{% endif %}>In Progress</option>
|
||||
<option value="blocked" {% if status == 'blocked' %}selected{% endif %}>Blocked</option>
|
||||
<option value="completed" {% if status == 'completed' %}selected{% endif %}>Completed</option>
|
||||
</select>
|
||||
<select name="assigned_to_user_id" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="0">All assignees</option>
|
||||
{% for user in assignable_users %}
|
||||
<option value="{{ user.id }}" {% if selected_assigned_to_user_id == user.id %}selected{% endif %}>{{ user.full_name or user.email }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="space-y-4">
|
||||
{% for client_group in progress_payload.clients %}
|
||||
<details class="group rounded-2xl border border-slate-200 bg-white shadow-soft" {% if loop.first %}open{% endif %}>
|
||||
<summary class="flex cursor-pointer list-none flex-wrap items-center justify-between gap-4 rounded-2xl px-5 py-4 hover:bg-slate-50">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-slate-900">{{ client_group.client_name }}</div>
|
||||
<div class="text-xs text-slate-500">{{ client_group.client_code or 'No client code' }} · {{ client_group.engagements|length }} engagement{{ '' if client_group.engagements|length == 1 else 's' }} · {{ client_group.task_count }} task{{ '' if client_group.task_count == 1 else 's' }}</div>
|
||||
</div>
|
||||
<div class="min-w-[240px]">
|
||||
<div class="flex justify-between text-xs font-semibold text-slate-500"><span>Client progress</span><span>{{ client_group.progress_percent }}%</span></div>
|
||||
<div class="mt-1 h-2 rounded-full bg-slate-100"><div class="h-2 rounded-full bg-brand-600" style="width: {{ client_group.progress_percent }}%"></div></div>
|
||||
<div class="mt-1 text-xs text-slate-500">Open {{ client_group.open_count }} · Completed {{ client_group.completed_count }} · Overdue {{ client_group.overdue_count }}</div>
|
||||
</div>
|
||||
<span class="text-lg text-slate-400 transition group-open:rotate-90">›</span>
|
||||
</summary>
|
||||
|
||||
<div class="space-y-3 border-t border-slate-100 p-4">
|
||||
{% for engagement_group in client_group.engagements %}
|
||||
<details class="group/eng rounded-xl border border-slate-200 bg-slate-50" {% if engagement_group.overdue_count > 0 or loop.first %}open{% endif %}>
|
||||
<summary class="flex cursor-pointer list-none flex-wrap items-center justify-between gap-4 px-4 py-3 hover:bg-slate-100">
|
||||
<div>
|
||||
<div class="font-semibold text-slate-900">{{ engagement_group.label }}</div>
|
||||
<div class="text-xs text-slate-500">Status: {{ engagement_group.status.replace('_',' ').title() }} · Due: {{ engagement_group.due_date or '-' }}</div>
|
||||
</div>
|
||||
<div class="min-w-[260px]">
|
||||
<div class="flex justify-between text-xs font-semibold text-slate-500"><span>{{ engagement_group.progress_status.replace('_',' ').title() }}</span><span>{{ engagement_group.progress_percent }}%</span></div>
|
||||
<div class="mt-1 h-2 rounded-full bg-slate-200"><div class="h-2 rounded-full {% if engagement_group.progress_status == 'completed' %}bg-emerald-600{% elif engagement_group.progress_status == 'overdue' %}bg-red-600{% else %}bg-brand-600{% endif %}" style="width: {{ engagement_group.progress_percent }}%"></div></div>
|
||||
<div class="mt-1 text-xs text-slate-500">Open {{ engagement_group.open_count }} · Completed {{ engagement_group.completed_count }} · Unassigned {{ engagement_group.unassigned_count }}</div>
|
||||
</div>
|
||||
<span class="text-slate-400 transition group-open/eng:rotate-90">›</span>
|
||||
</summary>
|
||||
<div class="overflow-x-auto border-t border-slate-200">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-100 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
<tr><th class="px-4 py-3">Task</th><th class="px-4 py-3">Assignee</th><th class="px-4 py-3">Status</th><th class="px-4 py-3">Priority</th><th class="px-4 py-3">Target</th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 bg-white">
|
||||
{% for task in engagement_group.tasks %}
|
||||
<tr class="{% if task.is_overdue %}bg-red-50{% elif task.is_due_today %}bg-amber-50{% endif %}">
|
||||
<td class="px-4 py-3"><div class="font-medium text-slate-900">{{ task.task_name }}</div>{% if task.description %}<div class="mt-1 text-xs text-slate-500">{{ task.description }}</div>{% endif %}</td>
|
||||
<td class="px-4 py-3">{{ task.assigned_to.full_name if task.assigned_to else 'Unassigned' }}</td>
|
||||
<td class="px-4 py-3"><span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-700">{{ task.status_label }}</span></td>
|
||||
<td class="px-4 py-3">{{ task.priority_label }}</td>
|
||||
<td class="px-4 py-3">{{ task.internal_target_date or '-' }}<div class="text-xs text-slate-500">{{ task.date_bucket }}</div></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-soft"><h3 class="font-semibold text-slate-900">No progress data found</h3><p class="mt-1 text-sm text-slate-500">No engagement task matches the selected filter.</p></div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,35 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-3xl space-y-6">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Request Employee Profile</h2>
|
||||
<p class="text-sm text-slate-500">Use this when your login user is not yet linked to an employee master.</p>
|
||||
</div>
|
||||
<a href="/employee/dashboard" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back</a>
|
||||
</div>
|
||||
|
||||
{% if employee %}
|
||||
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">Your user is already linked to employee {{ employee.employee_code }}.</div>
|
||||
{% elif pending_request %}
|
||||
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">Your request #{{ pending_request.id }} is already pending approval.</div>
|
||||
{% else %}
|
||||
{% 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 %}
|
||||
{% set f = form if form is defined else {} %}
|
||||
<form method="post" class="space-y-5 rounded-2xl border border-slate-200 bg-white p-6 shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div><label class="mb-1 block text-sm font-medium text-slate-700">Requested Employee Code</label><input name="requested_employee_code" value="{{ f.get('requested_employee_code','') }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"><p class="mt-1 text-xs text-slate-500">Optional. Admin may approve with another code.</p></div>
|
||||
<div><label class="mb-1 block text-sm font-medium text-slate-700">Full Name *</label><input name="full_name" value="{{ f.get('full_name') or current_user.full_name or '' }}" required 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="{{ f.get('email') or current_user.email or '' }}" 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="{{ f.get('mobile','') }}" 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">Department</label><input name="department" value="{{ f.get('department','') }}" 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">Designation</label><input name="designation" value="{{ f.get('designation','') }}" 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">Date of Joining</label><input type="date" name="date_of_joining" value="{{ f.get('date_of_joining','') }}" 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">Remarks</label><textarea name="remarks" rows="3" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ f.get('remarks','') }}</textarea></div>
|
||||
</div>
|
||||
<div class="flex justify-end"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Submit Request</button></div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,46 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<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">Employee Registration Request #{{ item.id }}</h2>
|
||||
<p class="text-sm text-slate-500">Review and approve/reject employee self-service linkage request.</p>
|
||||
</div>
|
||||
<a href="/employees/registration-requests" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back</a>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-soft">
|
||||
<h3 class="font-semibold text-slate-900">Request Details</h3>
|
||||
<dl class="mt-4 grid gap-4 md:grid-cols-2 text-sm">
|
||||
<div><dt class="text-slate-500">Full Name</dt><dd class="font-medium text-slate-900">{{ item.full_name }}</dd></div>
|
||||
<div><dt class="text-slate-500">Requested Code</dt><dd class="font-medium text-slate-900">{{ item.requested_employee_code or 'Auto generate' }}</dd></div>
|
||||
<div><dt class="text-slate-500">Email</dt><dd class="font-medium text-slate-900">{{ item.email or '-' }}</dd></div>
|
||||
<div><dt class="text-slate-500">Mobile</dt><dd class="font-medium text-slate-900">{{ item.mobile or '-' }}</dd></div>
|
||||
<div><dt class="text-slate-500">Department</dt><dd class="font-medium text-slate-900">{{ item.department or '-' }}</dd></div>
|
||||
<div><dt class="text-slate-500">Designation</dt><dd class="font-medium text-slate-900">{{ item.designation or '-' }}</dd></div>
|
||||
<div><dt class="text-slate-500">Status</dt><dd class="font-medium text-slate-900">{{ item.status.title() }}</dd></div>
|
||||
<div><dt class="text-slate-500">User ID</dt><dd class="font-medium text-slate-900">{{ item.user_id }}</dd></div>
|
||||
<div class="md:col-span-2"><dt class="text-slate-500">Remarks</dt><dd class="whitespace-pre-line font-medium text-slate-900">{{ item.remarks or '-' }}</dd></div>
|
||||
{% if item.review_notes %}<div class="md:col-span-2"><dt class="text-slate-500">Review Notes</dt><dd class="whitespace-pre-line font-medium text-slate-900">{{ item.review_notes }}</dd></div>{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{% if item.status == 'pending' %}
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<form method="post" action="/employees/registration-requests/{{ item.id }}/approve" class="space-y-4 rounded-2xl border border-emerald-200 bg-emerald-50 p-6 shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<h3 class="font-semibold text-emerald-900">Approve Request</h3>
|
||||
<div><label class="mb-1 block text-sm font-medium text-emerald-900">Employee Code</label><input name="employee_code" value="{{ item.requested_employee_code or '' }}" placeholder="Leave blank to auto-generate" class="w-full rounded-xl border border-emerald-200 px-3 py-2 text-sm"></div>
|
||||
<div><label class="mb-1 block text-sm font-medium text-emerald-900">Review Notes</label><textarea name="review_notes" rows="3" class="w-full rounded-xl border border-emerald-200 px-3 py-2 text-sm"></textarea></div>
|
||||
<button class="rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700">Approve & Create Employee</button>
|
||||
</form>
|
||||
<form method="post" action="/employees/registration-requests/{{ item.id }}/reject" class="space-y-4 rounded-2xl border border-red-200 bg-red-50 p-6 shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<h3 class="font-semibold text-red-900">Reject Request</h3>
|
||||
<div><label class="mb-1 block text-sm font-medium text-red-900">Reason</label><textarea name="review_notes" rows="5" class="w-full rounded-xl border border-red-200 px-3 py-2 text-sm"></textarea></div>
|
||||
<button class="rounded-xl bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700">Reject Request</button>
|
||||
</form>
|
||||
</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-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Employee Registration Requests</h2>
|
||||
<p class="text-sm text-slate-500">Approve self-service employee profile requests and create linked employee masters.</p>
|
||||
</div>
|
||||
<a href="/employees" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Employees</a>
|
||||
</div>
|
||||
|
||||
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div><label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Status</label><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</option>{% for st in statuses %}<option value="{{ st }}" {% if status == st %}selected{% endif %}>{{ st.title() }}</option>{% endfor %}</select></div>
|
||||
<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">User</th><th class="px-4 py-3">Role Details</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.id }} {{ row.full_name }}</div><div class="text-xs text-slate-500">Requested Code: {{ row.requested_employee_code or 'Auto' }} • {{ row.created_at_utc }}</div></td>
|
||||
<td class="px-4 py-3 text-slate-600"><div>{{ row.email or '-' }}</div><div class="text-xs">User #{{ row.user_id }}</div></td>
|
||||
<td class="px-4 py-3 text-slate-600"><div>{{ row.department or '-' }}</div><div class="text-xs">{{ row.designation or '-' }}</div></td>
|
||||
<td class="px-4 py-3"><span class="rounded-full px-2 py-1 text-xs font-semibold {% if row.status == 'pending' %}bg-amber-50 text-amber-700{% elif row.status == 'approved' %}bg-emerald-50 text-emerald-700{% else %}bg-red-50 text-red-700{% endif %}">{{ row.status.title() }}</span></td>
|
||||
<td class="px-4 py-3 text-right"><a href="/employees/registration-requests/{{ row.id }}" class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Open</a></td>
|
||||
</tr>
|
||||
{% else %}<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">No registration requests found.</td></tr>{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,248 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
{% include "modules/employees/templates/employees/_my_workspace_tabs.html" %}
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">My Attendance</h2>
|
||||
<p class="text-sm text-slate-500">Punch in/out and view your recent attendance records. Times are documented in your branch timezone.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{% for error in errors or [] %}
|
||||
<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">{{ error }}</div>
|
||||
{% endfor %}
|
||||
|
||||
{% if not employee %}
|
||||
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-6 text-sm text-amber-900 shadow-soft">
|
||||
Your login is not linked to an employee profile yet. Please request employee linkage first.
|
||||
<div class="mt-3"><a href="/employee/register" class="font-semibold underline">Request employee profile</a></div>
|
||||
</div>
|
||||
{% else %}
|
||||
<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">Today</div>
|
||||
<div class="mt-1 text-lg font-semibold text-slate-900">{{ today_attendance.attendance_date if today_attendance else 'Not marked' }}</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">Punch In</div>
|
||||
<div class="mt-1 text-lg font-semibold text-slate-900">{{ today_attendance.punch_in_local_at.strftime('%H:%M') if today_attendance and today_attendance.punch_in_local_at else (today_attendance.punch_in_utc.strftime('%H:%M UTC') if today_attendance and today_attendance.punch_in_utc else '-') }}</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">Punch Out</div>
|
||||
<div class="mt-1 text-lg font-semibold text-slate-900">{{ today_attendance.punch_out_local_at.strftime('%H:%M') if today_attendance and today_attendance.punch_out_local_at else (today_attendance.punch_out_utc.strftime('%H:%M UTC') if today_attendance and today_attendance.punch_out_utc else '-') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<h3 class="font-semibold text-slate-900">Today's Action</h3>
|
||||
<p class="mt-1 text-xs text-slate-500">Location will be captured when your browser allows it. If you are outside the branch geofence, the attendance is saved as pending approval for OD/client visit review.</p>
|
||||
<div id="geo-status" class="mt-3 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-600">Location not captured yet.</div>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<form method="post" action="/employee/attendance/punch-in" class="attendance-geo-form space-y-3 rounded-xl border border-slate-200 p-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="latitude">
|
||||
<input type="hidden" name="longitude">
|
||||
<input type="hidden" name="accuracy_meters">
|
||||
<label class="block text-sm font-medium text-slate-700">Remarks / OD Reason</label>
|
||||
<input name="remarks" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Optional; required in practice for client visit/OD">
|
||||
<button class="rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50" {% if today_attendance and today_attendance.punch_in_utc %}disabled{% endif %}>Punch In</button>
|
||||
</form>
|
||||
<form method="post" action="/employee/attendance/punch-out" class="attendance-geo-form space-y-3 rounded-xl border border-slate-200 p-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="latitude">
|
||||
<input type="hidden" name="longitude">
|
||||
<input type="hidden" name="accuracy_meters">
|
||||
<label class="block text-sm font-medium text-slate-700">Remarks / OD Reason</label>
|
||||
<input name="remarks" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Optional remarks">
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700 disabled:opacity-50" {% if not today_attendance or not today_attendance.punch_in_utc or today_attendance.punch_out_utc %}disabled{% endif %}>Punch Out</button>
|
||||
</form>
|
||||
</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 tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Date</th>
|
||||
<th class="px-4 py-3">Punch In</th>
|
||||
<th class="px-4 py-3">Punch Out</th>
|
||||
<th class="px-4 py-3">Duration</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Approval</th>
|
||||
<th class="px-4 py-3">Timing Rule</th>
|
||||
<th class="px-4 py-3">Geo/IP</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.attendance_date }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ row.punch_in_local_at.strftime('%H:%M') if row.punch_in_local_at else (row.punch_in_utc.strftime('%H:%M UTC') if row.punch_in_utc else '-') }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ row.punch_out_local_at.strftime('%H:%M') if row.punch_out_local_at else (row.punch_out_utc.strftime('%H:%M UTC') if row.punch_out_utc else '-') }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ (row.work_duration_minutes ~ ' min') if row.work_duration_minutes else '-' }}</td>
|
||||
<td class="px-4 py-3">{{ row.status.replace('_',' ').title() }}</td>
|
||||
<td class="px-4 py-3">{{ row.approval_status.replace('_',' ').title() }}</td>
|
||||
<td class="px-4 py-3 text-xs text-slate-600">
|
||||
<div>TZ: {{ row.branch_timezone or 'Asia/Kolkata' }}</div>
|
||||
<div>Rule: {{ (row.attendance_rule_status or '-').replace('_',' ').title() }}</div>
|
||||
<div>Late: {{ (row.late_by_minutes ~ ' min') if row.late_by_minutes else '-' }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-slate-600">
|
||||
<div>Geo: {{ (row.punch_in_geo_status or '-').replace('_',' ').title() }}</div>
|
||||
<div>Distance: {{ (row.punch_in_distance_meters ~ ' m') if row.punch_in_distance_meters is not none else '-' }}</div>
|
||||
<div>IP: {{ (row.punch_in_ip_status or '-').replace('_',' ').title() }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8" class="px-4 py-8 text-center text-slate-500">No attendance records found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div id="location-consent-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-slate-900/60 p-4" aria-hidden="true">
|
||||
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">📍</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-slate-900">Enable location for attendance</h3>
|
||||
<p class="mt-2 text-sm text-slate-600">
|
||||
To mark attendance, this app needs your current location to verify whether you are within your branch geofence.
|
||||
Click Continue, then choose <span class="font-semibold text-slate-900">Allow</span> in the browser location popup.
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-slate-500">
|
||||
If you are outside the branch or location is denied/unavailable, your attendance can still be saved as pending approval for OD/client visit review.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5 flex justify-end gap-3">
|
||||
<button type="button" id="location-modal-cancel" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Cancel</button>
|
||||
<button type="button" id="location-modal-continue" class="rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700">Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const statusBox = document.getElementById('geo-status');
|
||||
const forms = document.querySelectorAll('.attendance-geo-form');
|
||||
const modal = document.getElementById('location-consent-modal');
|
||||
const modalContinue = document.getElementById('location-modal-continue');
|
||||
const modalCancel = document.getElementById('location-modal-cancel');
|
||||
let lastPosition = null;
|
||||
let geoAttempted = false;
|
||||
let activeForm = null;
|
||||
|
||||
function updateStatus(message, tone) {
|
||||
if (!statusBox) return;
|
||||
statusBox.textContent = message;
|
||||
statusBox.className = 'mt-3 rounded-xl border px-3 py-2 text-xs ' + (
|
||||
tone === 'ok'
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
||||
: tone === 'warn'
|
||||
? 'border-amber-200 bg-amber-50 text-amber-700'
|
||||
: 'border-slate-200 bg-slate-50 text-slate-600'
|
||||
);
|
||||
}
|
||||
|
||||
function setHidden(lat, lon, acc) {
|
||||
forms.forEach(function (form) {
|
||||
const latInput = form.querySelector('input[name="latitude"]');
|
||||
const lonInput = form.querySelector('input[name="longitude"]');
|
||||
const accInput = form.querySelector('input[name="accuracy_meters"]');
|
||||
if (latInput) latInput.value = lat || '';
|
||||
if (lonInput) lonInput.value = lon || '';
|
||||
if (accInput) accInput.value = acc || '';
|
||||
});
|
||||
}
|
||||
|
||||
function openLocationModal(form) {
|
||||
return new Promise(function (resolve) {
|
||||
activeForm = form;
|
||||
if (!modal || !modalContinue || !modalCancel) {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
|
||||
function cleanup(result) {
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('flex');
|
||||
modal.setAttribute('aria-hidden', 'true');
|
||||
modalContinue.removeEventListener('click', onContinue);
|
||||
modalCancel.removeEventListener('click', onCancel);
|
||||
resolve(result);
|
||||
}
|
||||
|
||||
function onContinue() {
|
||||
cleanup(true);
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
updateStatus('Attendance punch cancelled. Location permission was not requested.', 'warn');
|
||||
cleanup(false);
|
||||
}
|
||||
|
||||
modalContinue.addEventListener('click', onContinue);
|
||||
modalCancel.addEventListener('click', onCancel);
|
||||
modal.classList.remove('hidden');
|
||||
modal.classList.add('flex');
|
||||
modal.setAttribute('aria-hidden', 'false');
|
||||
modalContinue.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function captureLocation() {
|
||||
return new Promise(function (resolve) {
|
||||
geoAttempted = true;
|
||||
if (!navigator.geolocation) {
|
||||
setHidden('', '', '');
|
||||
updateStatus('Browser geolocation is not available. Punch will be saved as pending approval if branch geofence is enabled.', 'warn');
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
updateStatus('Browser location popup opened. Please choose Allow to capture location...', 'info');
|
||||
navigator.geolocation.getCurrentPosition(function (pos) {
|
||||
const c = pos.coords || {};
|
||||
lastPosition = c;
|
||||
setHidden(c.latitude, c.longitude, c.accuracy);
|
||||
updateStatus('Location captured. Accuracy: ' + Math.round(c.accuracy || 0) + ' meters.', 'ok');
|
||||
resolve(true);
|
||||
}, function (err) {
|
||||
lastPosition = null;
|
||||
setHidden('', '', '');
|
||||
let reason = 'Location permission denied or unavailable.';
|
||||
if (err && err.code === 1) reason = 'Location permission denied. Please allow location for this site in browser settings.';
|
||||
if (err && err.code === 2) reason = 'Location unavailable. Please enable device GPS/location service and try again.';
|
||||
if (err && err.code === 3) reason = 'Location capture timed out. Please try again near a window or with GPS enabled.';
|
||||
updateStatus(reason + ' Punch will be saved as pending approval if branch geofence is enabled.', 'warn');
|
||||
resolve(false);
|
||||
}, { enableHighAccuracy: true, timeout: 20000, maximumAge: 0 });
|
||||
});
|
||||
}
|
||||
|
||||
updateStatus('Click Punch In/Punch Out. The app will ask you to enable location before submitting attendance.', 'info');
|
||||
|
||||
forms.forEach(function (form) {
|
||||
form.addEventListener('submit', async function (event) {
|
||||
const latInput = form.querySelector('input[name="latitude"]');
|
||||
const lonInput = form.querySelector('input[name="longitude"]');
|
||||
if (!latInput || !lonInput) return;
|
||||
|
||||
if (!latInput.value || !lonInput.value) {
|
||||
event.preventDefault();
|
||||
const proceed = await openLocationModal(form);
|
||||
if (!proceed) return;
|
||||
|
||||
await captureLocation();
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,32 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
{% include "modules/employees/templates/employees/_my_workspace_tabs.html" %}
|
||||
{% if errors %}<div class="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">{{ errors|join(', ') }}</div>{% endif %}
|
||||
<div><h2 class="text-xl font-semibold text-slate-900">My Documents</h2><p class="text-sm text-slate-500">Upload and track your own employee documents.</p></div>
|
||||
{% if not employee %}
|
||||
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-5 text-sm text-amber-800">Your user is not linked to an employee profile. <a class="font-semibold underline" href="/employee/register">Request employee profile linkage</a>.</div>
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<h3 class="mb-4 font-semibold text-slate-900">Upload Document</h3>
|
||||
<form method="post" enctype="multipart/form-data" action="/employee/documents/upload" class="grid gap-4 md:grid-cols-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div><label class="text-xs font-semibold text-slate-500">Type</label><select name="document_type_id" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"><option value="">General</option>{% for dt in document_types %}<option value="{{ dt.id }}">{{ dt.code }} - {{ dt.name }}</option>{% endfor %}</select></div>
|
||||
<div><label class="text-xs font-semibold text-slate-500">Title</label><input name="title" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></div>
|
||||
<div><label class="text-xs font-semibold text-slate-500">Document No</label><input name="document_no" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></div>
|
||||
<div><label class="text-xs font-semibold text-slate-500">File</label><input type="file" name="document_file" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" required></div>
|
||||
<div><label class="text-xs font-semibold text-slate-500">Issue Date</label><input type="date" name="issue_date" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></div>
|
||||
<div><label class="text-xs font-semibold text-slate-500">Expiry Date</label><input type="date" name="expiry_date" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></div>
|
||||
<div class="md:col-span-2"><label class="text-xs font-semibold text-slate-500">Remarks</label><input name="remarks" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></div>
|
||||
<div class="md:col-span-4"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Upload</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm"><thead class="bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500"><tr><th class="px-4 py-3">Document</th><th class="px-4 py-3">Status</th><th class="px-4 py-3">Dates</th><th class="px-4 py-3">Remarks</th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-100">{% for row in rows %}<tr><td class="px-4 py-3"><div class="font-medium">{{ row.title }}</div><div class="text-xs text-slate-500">{{ row.document_type.code if row.document_type else 'GENERAL' }} • {{ row.original_filename }}</div></td><td class="px-4 py-3"><span class="rounded-full bg-slate-100 px-2 py-1 text-xs">{{ row.status }}</span></td><td class="px-4 py-3 text-xs">Issue: {{ row.issue_date or '-' }}<br>Expiry: {{ row.expiry_date or '-' }}</td><td class="px-4 py-3 text-xs">{{ row.remarks or '' }}<br>{{ row.verification_notes or '' }}</td></tr>{% else %}<tr><td colspan="4" class="px-4 py-6 text-center text-slate-500">No documents uploaded.</td></tr>{% endfor %}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,7 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
|
||||
<div class="space-y-6">
|
||||
{% include "modules/employees/templates/employees/_my_workspace_tabs.html" %}<div class="flex flex-wrap items-center justify-between gap-3"><div><h2 class="text-xl font-semibold text-slate-900">My Leave</h2><p class="text-sm text-slate-500">Apply for leave and track approval status.</p></div></div>{% if not employee %}<div class="rounded-2xl border border-amber-200 bg-amber-50 p-6 text-sm text-amber-900">Your login user is not linked to an employee profile. Please request employee linkage first.</div>{% else %}<div class="grid gap-4 md:grid-cols-3">{% for bal in balances %}<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">{{ bal.leave_type.name if bal.leave_type else bal.leave_type_id }}</div><div class="mt-1 text-2xl font-semibold text-slate-900">{{ bal.balance_days }}</div><div class="text-xs text-slate-500">Availed {{ bal.availed_days }} days</div></div>{% else %}<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft text-sm text-slate-600">No leave balance available yet.</div>{% endfor %}</div><form method="post" action="/employee/leave/apply" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><h3 class="font-semibold text-slate-900">Apply Leave</h3><div class="mt-4 grid gap-3 md:grid-cols-4"><select name="leave_type_id" required class="rounded-xl border border-slate-300 px-3 py-2 text-sm"><option value="">Leave type</option>{% for lt in leave_types %}<option value="{{ lt.id }}">{{ lt.code }} - {{ lt.name }}</option>{% endfor %}</select><input type="date" name="from_date" required class="rounded-xl border border-slate-300 px-3 py-2 text-sm"><input type="date" name="to_date" required class="rounded-xl border border-slate-300 px-3 py-2 text-sm"><input name="reason" placeholder="Reason" class="rounded-xl border border-slate-300 px-3 py-2 text-sm"></div><button class="mt-4 rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Submit Leave Request</button></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">Leave</th><th class="px-4 py-3">Period</th><th class="px-4 py-3">Days</th><th class="px-4 py-3">Status</th><th class="px-4 py-3">Reason</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><td class="px-4 py-3">{{ row.leave_type.name if row.leave_type else row.leave_type_id }}</td><td class="px-4 py-3">{{ row.from_date }} to {{ row.to_date }}</td><td class="px-4 py-3">{{ row.days }}</td><td class="px-4 py-3">{{ row.status.title() }}</td><td class="px-4 py-3 text-slate-600">{{ row.reason or row.review_notes or '-' }}</td><td class="px-4 py-3 text-right">{% if row.status == 'pending' %}<form method="post" action="/employee/leave/{{ row.id }}/cancel"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Cancel</button></form>{% else %}-{% endif %}</td></tr>{% else %}<tr><td colspan="6" class="px-4 py-8 text-center text-slate-500">No leave requests found.</td></tr>{% endfor %}</tbody></table></div>{% endif %}</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,11 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
{% include "modules/employees/templates/employees/_my_workspace_tabs.html" %}
|
||||
<div><h1 class="text-2xl font-semibold text-slate-900">My Offboarding</h1><p class="text-sm text-slate-500">Submit resignation/offboarding request and track status.</p></div>
|
||||
{% if not employee %}<div class="rounded-2xl border bg-amber-50 p-4 text-sm text-amber-800">Employee profile is not linked. Please request employee profile first.</div>{% else %}
|
||||
<div class="rounded-2xl border bg-white p-4 shadow-sm"><form method="post" action="/employee/offboarding/request" class="grid gap-3 md:grid-cols-4"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><select name="request_type" class="rounded-xl border px-3 py-2 text-sm"><option value="resignation">Resignation</option><option value="contract_end">Contract End</option></select><input type="date" name="requested_relieving_date" class="rounded-xl border px-3 py-2 text-sm"><input name="reason" placeholder="Reason" class="rounded-xl border px-3 py-2 text-sm md:col-span-2"><textarea name="handover_notes" placeholder="Handover notes" class="rounded-xl border px-3 py-2 text-sm md:col-span-3"></textarea><button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white">Submit Request</button></form></div>
|
||||
{% endif %}
|
||||
<div class="overflow-hidden rounded-2xl border bg-white shadow-sm"><table class="min-w-full divide-y text-sm"><thead class="bg-slate-50"><tr><th class="p-3 text-left">Type</th><th class="p-3">Requested Date</th><th class="p-3">Approved Date</th><th class="p-3">Status</th><th class="p-3 text-left">Notes</th></tr></thead><tbody class="divide-y">{% for r in rows %}<tr><td class="p-3">{{ r.request_type }}</td><td class="p-3 text-center">{{ r.requested_relieving_date or '-' }}</td><td class="p-3 text-center">{{ r.approved_relieving_date or '-' }}</td><td class="p-3 text-center"><span class="rounded-full bg-slate-100 px-2 py-1 text-xs">{{ r.status }}</span></td><td class="p-3 text-xs text-slate-500">{{ r.review_notes or r.handover_notes or '-' }}</td></tr>{% else %}<tr><td colspan="5" class="p-6 text-center text-slate-500">No requests yet.</td></tr>{% endfor %}</tbody></table></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,10 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
{% include "modules/employees/templates/employees/_my_workspace_tabs.html" %}
|
||||
<div class="flex flex-wrap items-center justify-between gap-3"><div><h2 class="text-xl font-semibold text-slate-900">My Payslips</h2><p class="text-sm text-slate-500">View your generated monthly payslips.</p></div></div>
|
||||
{% if not employee %}<div class="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">Your user is not linked to an employee profile.</div>{% else %}
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm"><table class="min-w-full divide-y divide-slate-200 text-sm"><thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left">Period</th><th class="px-4 py-3 text-right">Gross</th><th class="px-4 py-3 text-right">Deductions</th><th class="px-4 py-3 text-right">Net Pay</th><th class="px-4 py-3 text-left">Status</th></tr></thead><tbody class="divide-y divide-slate-100">{% for row in rows %}<tr><td class="px-4 py-3 font-medium">{{ '%02d' % row.pay_month }}/{{ row.pay_year }}</td><td class="px-4 py-3 text-right">{{ row.gross_amount }}</td><td class="px-4 py-3 text-right">{{ row.deduction_amount }}</td><td class="px-4 py-3 text-right font-semibold">{{ row.net_amount }}</td><td class="px-4 py-3">{{ row.status.title() }}</td></tr>{% else %}<tr><td colspan="5" class="px-4 py-6 text-center text-slate-500">No payslips generated yet.</td></tr>{% endfor %}</tbody></table></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,93 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% set profile_photo_url = get_user_profile_photo_url(current_user) %}
|
||||
{% set profile_initials = get_user_initials(current_user) %}
|
||||
<div class="mx-auto max-w-7xl space-y-6">
|
||||
{% include "modules/employees/templates/employees/_my_workspace_tabs.html" %}
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">My Profile</h2>
|
||||
<p class="text-sm text-slate-500">Maintain your profile photo and public contact details. Official HR fields remain controlled by admin.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if saved %}<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">Profile updated successfully.</div>{% endif %}
|
||||
{% 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 %}
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-soft">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
{% if profile_photo_url %}
|
||||
<img src="{{ profile_photo_url }}" alt="Profile photo" class="h-28 w-28 rounded-3xl border border-slate-200 object-cover shadow-soft">
|
||||
{% else %}
|
||||
<div class="flex h-28 w-28 items-center justify-center rounded-3xl bg-brand-600 text-3xl font-bold text-white shadow-soft">{{ profile_initials }}</div>
|
||||
{% endif %}
|
||||
<h3 class="mt-4 text-lg font-semibold text-slate-900">{{ current_user.full_name or employee.full_name }}</h3>
|
||||
<p class="text-sm text-slate-500">{{ current_user.qualification or '' }}{% if current_user.qualification and (current_user.designation or employee.designation) %} • {% endif %}{{ current_user.designation or employee.designation or '' }}</p>
|
||||
<p class="mt-1 text-xs text-slate-400">{{ current_user.email }}</p>
|
||||
</div>
|
||||
{% if current_user.bio %}
|
||||
<div class="mt-5 rounded-2xl bg-slate-50 p-4 text-sm text-slate-600">{{ current_user.bio }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-soft">
|
||||
<h3 class="font-semibold text-slate-900">Official Details</h3>
|
||||
<dl class="mt-4 grid gap-4 md:grid-cols-2 text-sm">
|
||||
<div><dt class="text-slate-500">Employee Code</dt><dd class="font-medium text-slate-900">{{ employee.employee_code }}</dd></div>
|
||||
<div><dt class="text-slate-500">Name</dt><dd class="font-medium text-slate-900">{{ employee.full_name }}</dd></div>
|
||||
<div><dt class="text-slate-500">Department</dt><dd class="font-medium text-slate-900">{{ employee.department or '-' }}</dd></div>
|
||||
<div><dt class="text-slate-500">Official Designation</dt><dd class="font-medium text-slate-900">{{ employee.designation or '-' }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<form method="post" enctype="multipart/form-data" 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>
|
||||
<h3 class="font-semibold text-slate-900">Public Profile</h3>
|
||||
<p class="text-sm text-slate-500">These details will be reused in dashboards and future client-facing auditor cards.</p>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Profile Photo</label>
|
||||
<input type="file" name="profile_photo" accept="image/png,image/jpeg,image/gif,image/webp" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<p class="mt-1 text-xs text-slate-400">JPG, PNG, GIF or WebP. Maximum 2 MB.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Qualification</label>
|
||||
<input name="qualification" value="{{ current_user.qualification or '' }}" placeholder="CA, FCA, DISA" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Display Designation</label>
|
||||
<input name="public_designation" value="{{ current_user.designation or employee.designation or '' }}" placeholder="Partner / Manager / Staff" 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="{{ employee.mobile or current_user.mobile or '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Short Bio</label>
|
||||
<textarea name="bio" rows="3" placeholder="Short client-facing profile or area of responsibility" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ current_user.bio or '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-900">Personal / HR Self-Service Details</h3>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div><label class="mb-1 block text-sm font-medium text-slate-700">Alternate Mobile</label><input name="alternate_mobile" value="{{ employee.alternate_mobile or '' }}" 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">Emergency Contact Name</label><input name="emergency_contact_name" value="{{ employee.emergency_contact_name or '' }}" 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">Emergency Contact Mobile</label><input name="emergency_contact_mobile" value="{{ employee.emergency_contact_mobile or '' }}" 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">Bank Name</label><input name="bank_name" value="{{ employee.bank_name or '' }}" 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">Bank Account No</label><input name="bank_account_no" value="{{ employee.bank_account_no or '' }}" 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">Bank IFSC</label><input name="bank_ifsc" value="{{ employee.bank_ifsc or '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"></div>
|
||||
<div class="md:col-span-2"><label class="mb-1 block text-sm font-medium text-slate-700">Address</label><textarea name="address" rows="3" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ employee.address or '' }}</textarea></div>
|
||||
</div>
|
||||
<div class="flex justify-end"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Save My Profile</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,87 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
{% include "modules/employees/templates/employees/_my_workspace_tabs.html" %}
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">My Work</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Board view of your assigned engagements. Open a card to work on tasks and refer to engagement documents.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not employee %}
|
||||
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-6 text-sm text-amber-900 shadow-soft">
|
||||
<h3 class="font-semibold">Your login is not linked to an employee master</h3>
|
||||
<p class="mt-1">You may still see tasks assigned directly to your user ID, but attendance, profile, leave, documents and payslip self-service require an employee master link.</p>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<a href="/employee/register" class="rounded-xl bg-amber-700 px-4 py-2 text-sm font-semibold text-white hover:bg-amber-800">Request Employee Link</a>
|
||||
<a href="/employee/dashboard" class="rounded-xl border border-amber-300 px-4 py-2 text-sm font-semibold text-amber-900 hover:bg-amber-100">Back to My Workspace</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-6">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Total</div><div class="mt-1 text-2xl font-semibold">{{ work_payload.summary.total }}</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">Open</div><div class="mt-1 text-2xl font-semibold">{{ work_payload.summary.open }}</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">In Progress</div><div class="mt-1 text-2xl font-semibold">{{ work_payload.summary.in_progress }}</div></div>
|
||||
<div class="rounded-2xl border border-rose-200 bg-rose-50 p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-rose-600">Blocked</div><div class="mt-1 text-2xl font-semibold text-rose-700">{{ work_payload.summary.blocked }}</div></div>
|
||||
<div class="rounded-2xl border border-red-200 bg-red-50 p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-red-600">Overdue</div><div class="mt-1 text-2xl font-semibold text-red-700">{{ work_payload.summary.overdue }}</div></div>
|
||||
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-emerald-600">Completed</div><div class="mt-1 text-2xl font-semibold text-emerald-700">{{ work_payload.summary.completed }}</div></div>
|
||||
</div>
|
||||
|
||||
<form method="get" action="/employee/work" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="grid gap-3 md:grid-cols-[1fr_220px_auto]">
|
||||
<input type="search" name="q" value="{{ q or '' }}" placeholder="Search engagement, task, client or service" class="rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100">
|
||||
<select name="status" class="rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100">
|
||||
<option value="open" {% if status == 'open' %}selected{% endif %}>Open work</option>
|
||||
<option value="pending" {% if status == 'pending' %}selected{% endif %}>Pending</option>
|
||||
<option value="in_progress" {% if status == 'in_progress' %}selected{% endif %}>In Progress</option>
|
||||
<option value="blocked" {% if status == 'blocked' %}selected{% endif %}>Blocked</option>
|
||||
<option value="completed" {% if status == 'completed' %}selected{% endif %}>Completed</option>
|
||||
<option value="closed" {% if status == 'closed' %}selected{% endif %}>Closed work</option>
|
||||
</select>
|
||||
<button type="submit" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Apply</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="grid gap-4 xl:grid-cols-4">
|
||||
{% for column in work_payload.columns %}
|
||||
<section class="min-h-[420px] rounded-2xl border border-slate-200 bg-slate-50 p-3 shadow-soft">
|
||||
<div class="mb-3 flex items-center justify-between px-1">
|
||||
<h3 class="text-sm font-semibold text-slate-900">{{ column.label }}</h3>
|
||||
<span class="rounded-full bg-white px-2.5 py-1 text-xs font-semibold text-slate-600">{{ column.cards|length }}</span>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
{% for card in column.cards %}
|
||||
<article class="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm hover:border-brand-200 hover:shadow-soft">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">{{ card.client_code or 'Client' }}</div>
|
||||
<h4 class="mt-1 text-sm font-semibold text-slate-900">{{ card.client_name }}</h4>
|
||||
</div>
|
||||
{% if card.overdue_count %}<span class="rounded-full bg-red-100 px-2 py-1 text-[11px] font-semibold text-red-700">Overdue {{ card.overdue_count }}</span>{% elif card.due_today_count %}<span class="rounded-full bg-amber-100 px-2 py-1 text-[11px] font-semibold text-amber-700">Due today</span>{% endif %}
|
||||
</div>
|
||||
<div class="mt-3 rounded-xl bg-slate-50 p-3">
|
||||
<div class="text-sm font-semibold text-slate-900">{{ card.service_name }}</div>
|
||||
<div class="mt-1 text-xs text-slate-500">FY {{ card.financial_year }} · Due {{ card.due_date or '-' }}</div>
|
||||
</div>
|
||||
<div class="mt-3 grid grid-cols-3 gap-2 text-center text-xs">
|
||||
<div class="rounded-xl border border-slate-200 p-2"><div class="font-semibold text-slate-900">{{ card.open_count }}</div><div class="text-slate-500">Open</div></div>
|
||||
<div class="rounded-xl border border-slate-200 p-2"><div class="font-semibold text-slate-900">{{ card.blocked_count }}</div><div class="text-slate-500">Blocked</div></div>
|
||||
<div class="rounded-xl border border-slate-200 p-2"><div class="font-semibold text-slate-900">{{ card.completed_count }}</div><div class="text-slate-500">Done</div></div>
|
||||
</div>
|
||||
{% if card.latest_comment %}<p class="mt-3 truncate text-xs text-slate-500">Latest: {{ card.latest_comment.message }}</p>{% endif %}
|
||||
<a href="/work/engagements/{{ card.engagement_id }}" class="mt-4 inline-flex w-full justify-center rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Open Work Details</a>
|
||||
</article>
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-dashed border-slate-300 bg-white p-6 text-center text-sm text-slate-500">No cards in this column.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,105 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/managers/templates/managers/_manager_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Employee Work Allocation</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Assign and review engagement/service tasks grouped by priority, client and engagement.</p>
|
||||
</div>
|
||||
<a href="/employees/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 HR Dashboard</a>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-5">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Total</div><div class="mt-1 text-2xl font-semibold">{{ work_payload.summary.total }}</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">Open</div><div class="mt-1 text-2xl font-semibold">{{ work_payload.summary.open }}</div></div>
|
||||
<div class="rounded-2xl border border-orange-200 bg-orange-50 p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-orange-600">Unassigned</div><div class="mt-1 text-2xl font-semibold text-orange-700">{{ work_payload.summary.unassigned }}</div></div>
|
||||
<div class="rounded-2xl border border-red-200 bg-red-50 p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-red-600">Overdue</div><div class="mt-1 text-2xl font-semibold text-red-700">{{ work_payload.summary.overdue }}</div></div>
|
||||
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-emerald-600">Completed</div><div class="mt-1 text-2xl font-semibold text-emerald-700">{{ work_payload.summary.completed }}</div></div>
|
||||
</div>
|
||||
|
||||
<form method="get" action="/employees/work" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="grid gap-3 md:grid-cols-[1fr_180px_240px_auto]">
|
||||
<input type="search" name="q" value="{{ q or '' }}" placeholder="Search task, client or engagement" class="rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100">
|
||||
<select name="status" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="open" {% if status == 'open' %}selected{% endif %}>Open tasks</option>
|
||||
<option value="unassigned" {% if status == 'unassigned' %}selected{% endif %}>Unassigned</option>
|
||||
<option value="closed" {% if status == 'closed' %}selected{% endif %}>Closed tasks</option>
|
||||
<option value="pending" {% if status == 'pending' %}selected{% endif %}>Pending</option>
|
||||
<option value="in_progress" {% if status == 'in_progress' %}selected{% endif %}>In Progress</option>
|
||||
<option value="blocked" {% if status == 'blocked' %}selected{% endif %}>Blocked</option>
|
||||
<option value="completed" {% if status == 'completed' %}selected{% endif %}>Completed</option>
|
||||
</select>
|
||||
<select name="assigned_to_user_id" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="0">All assignees</option>
|
||||
{% for user in assignable_users %}
|
||||
<option value="{{ user.id }}" {% if selected_assigned_to_user_id == user.id %}selected{% endif %}>{{ user.full_name or user.email }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="space-y-4">
|
||||
{% for priority_group in work_payload.groups %}
|
||||
<details class="group rounded-2xl border border-slate-200 bg-white shadow-soft" {% if loop.first %}open{% endif %}>
|
||||
<summary class="flex cursor-pointer list-none items-center justify-between gap-3 rounded-2xl px-5 py-4 hover:bg-slate-50">
|
||||
<div><div class="text-sm font-semibold uppercase tracking-wide text-slate-500">Priority</div><div class="text-lg font-semibold text-slate-900">{{ priority_group.label }}</div></div>
|
||||
<div class="flex items-center gap-3 text-sm text-slate-500"><span>{{ priority_group.task_count }} task{{ '' if priority_group.task_count == 1 else 's' }}</span><span class="text-lg transition group-open:rotate-90">›</span></div>
|
||||
</summary>
|
||||
<div class="space-y-3 border-t border-slate-100 p-4">
|
||||
{% for client_group in priority_group.clients %}
|
||||
<details class="group/client rounded-xl border border-slate-200 bg-slate-50" {% if loop.first %}open{% endif %}>
|
||||
<summary class="flex cursor-pointer list-none items-center justify-between gap-3 px-4 py-3 hover:bg-slate-100">
|
||||
<div><div class="font-semibold text-slate-900">{{ client_group.client_name }}</div><div class="text-xs text-slate-500">{{ client_group.client_code or 'No client code' }}</div></div>
|
||||
<div class="flex items-center gap-3 text-sm text-slate-500"><span>{{ client_group.task_count }} task{{ '' if client_group.task_count == 1 else 's' }}</span><span class="transition group-open/client:rotate-90">›</span></div>
|
||||
</summary>
|
||||
<div class="space-y-3 border-t border-slate-200 p-3">
|
||||
{% for engagement_group in client_group.engagements %}
|
||||
<details class="group/eng rounded-xl border border-slate-200 bg-white" {% if loop.first %}open{% endif %}>
|
||||
<summary class="flex cursor-pointer list-none items-center justify-between gap-3 px-4 py-3 hover:bg-slate-50">
|
||||
<div><div class="font-semibold text-slate-900">{{ engagement_group.label }}</div><div class="text-xs text-slate-500">Open: {{ engagement_group.open_count }} · Completed: {{ engagement_group.completed_count }} · Status: {{ engagement_group.status.replace('_',' ').title() }}</div></div>
|
||||
<div class="flex items-center gap-3 text-sm text-slate-500"><span>{{ engagement_group.task_count }} task{{ '' if engagement_group.task_count == 1 else 's' }}</span><span class="transition group-open/eng:rotate-90">›</span></div>
|
||||
</summary>
|
||||
<div class="overflow-x-auto border-t border-slate-100">
|
||||
<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">Task</th><th class="px-4 py-3">Current</th><th class="px-4 py-3">Assign / Review</th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 bg-white">
|
||||
{% for task in engagement_group.tasks %}
|
||||
<tr class="align-top {% if task.is_overdue %}bg-red-50{% elif task.is_due_today %}bg-amber-50{% endif %}">
|
||||
<td class="px-4 py-3 min-w-[260px]"><div class="font-medium text-slate-900">{{ task.task_name }}</div>{% if task.description %}<div class="mt-1 max-w-xl text-xs text-slate-500">{{ task.description }}</div>{% endif %}<div class="mt-2 text-xs text-slate-500">Target: {{ task.internal_target_date or '-' }} · {{ task.date_bucket }}</div></td>
|
||||
<td class="px-4 py-3 min-w-[180px]"><div><span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-700">{{ task.status_label }}</span></div><div class="mt-2 text-xs text-slate-600">Assigned: {{ task.assigned_to.full_name if task.assigned_to else 'Unassigned' }}</div><div class="mt-1 text-xs text-slate-600">Priority: {{ task.priority_label }}</div>
|
||||
<div class="mt-2"><a href="/employees/work/tasks/{{ task.id }}/communication" class="inline-flex rounded-lg border border-slate-300 px-2.5 py-1 text-xs font-semibold text-slate-700 hover:bg-slate-50">Timeline{% if task.comment_count %} · {{ task.comment_count }}{% endif %}</a></div>
|
||||
{% if task.latest_comment %}<div class="mt-1 max-w-xs truncate text-xs text-slate-500">{{ task.latest_comment.message }}</div>{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 min-w-[520px]">
|
||||
<form method="post" action="/employees/work/tasks/{{ task.id }}/assign" class="grid gap-2 md:grid-cols-5">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<select name="assigned_to_user_id" class="rounded-lg border border-slate-300 px-2 py-1 text-xs"><option value="">Unassigned</option>{% for user in assignable_users %}<option value="{{ user.id }}" {% if task.assigned_to_user_id == user.id %}selected{% endif %}>{{ user.full_name or user.email }}</option>{% endfor %}</select>
|
||||
<select name="status" class="rounded-lg border border-slate-300 px-2 py-1 text-xs"><option value="pending" {% if task.status == 'pending' %}selected{% endif %}>Pending</option><option value="in_progress" {% if task.status == 'in_progress' %}selected{% endif %}>In Progress</option><option value="blocked" {% if task.status == 'blocked' %}selected{% endif %}>Blocked</option><option value="completed" {% if task.status == 'completed' %}selected{% endif %}>Completed</option><option value="not_applicable" {% if task.status == 'not_applicable' %}selected{% endif %}>Not Applicable</option><option value="cancelled" {% if task.status == 'cancelled' %}selected{% endif %}>Cancelled</option></select>
|
||||
<select name="priority" class="rounded-lg border border-slate-300 px-2 py-1 text-xs"><option value="low" {% if task.priority == 'low' %}selected{% endif %}>Low</option><option value="normal" {% if task.priority == 'normal' %}selected{% endif %}>Normal</option><option value="high" {% if task.priority == 'high' %}selected{% endif %}>High</option><option value="urgent" {% if task.priority == 'urgent' %}selected{% endif %}>Urgent</option></select>
|
||||
<input type="date" name="internal_target_date" value="{{ task.internal_target_date or '' }}" class="rounded-lg border border-slate-300 px-2 py-1 text-xs">
|
||||
<button type="submit" class="rounded-lg bg-brand-600 px-3 py-1 text-xs font-semibold text-white hover:bg-brand-700">Save</button>
|
||||
<input type="text" name="remarks" value="{{ task.remarks or '' }}" placeholder="Remarks" class="md:col-span-5 rounded-lg border border-slate-300 px-2 py-1 text-xs">
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-soft"><h3 class="font-semibold text-slate-900">No work items found</h3><p class="mt-1 text-sm text-slate-500">No engagement task matches the selected filter.</p></div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,117 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
{% include "modules/employees/templates/employees/_my_workspace_tabs.html" %}
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">{{ board.label }}</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
{{ board.client.client_name if board.client else 'Unlinked Client' }}
|
||||
{% if board.client and board.client.client_code %} · {{ board.client.client_code }}{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/employee/work" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">My Work Board</a>
|
||||
<a href="/documents/engagements/{{ board.engagement_id }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Full Documents</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-6">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Total Tasks</div><div class="mt-1 text-2xl font-semibold">{{ board.summary.total }}</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">Open</div><div class="mt-1 text-2xl font-semibold">{{ board.summary.open }}</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">In Progress</div><div class="mt-1 text-2xl font-semibold">{{ board.summary.in_progress }}</div></div>
|
||||
<div class="rounded-2xl border border-rose-200 bg-rose-50 p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-rose-600">Blocked</div><div class="mt-1 text-2xl font-semibold text-rose-700">{{ board.summary.blocked }}</div></div>
|
||||
<div class="rounded-2xl border border-red-200 bg-red-50 p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-red-600">Overdue</div><div class="mt-1 text-2xl font-semibold text-red-700">{{ board.summary.overdue }}</div></div>
|
||||
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-emerald-600">Completed</div><div class="mt-1 text-2xl font-semibold text-emerald-700">{{ board.summary.completed }}</div></div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div class="grid gap-4 lg:grid-cols-2 2xl:grid-cols-4">
|
||||
{% for column in board.columns %}
|
||||
<section class="rounded-2xl border border-slate-200 bg-slate-50 p-3 shadow-soft">
|
||||
<div class="mb-3 flex items-center justify-between px-1">
|
||||
<h3 class="text-sm font-semibold text-slate-900">{{ column.label }}</h3>
|
||||
<span class="rounded-full bg-white px-2.5 py-1 text-xs font-semibold text-slate-600">{{ column.tasks|length }}</span>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
{% for task in column.tasks %}
|
||||
<article class="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm {% if task.is_overdue %}border-red-200 bg-red-50{% elif task.is_due_today %}border-amber-200 bg-amber-50{% endif %}">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-slate-900">{{ task.task_name }}</h4>
|
||||
{% if task.description %}<p class="mt-1 line-clamp-3 text-xs text-slate-500">{{ task.description }}</p>{% endif %}
|
||||
</div>
|
||||
<span class="rounded-full bg-white px-2 py-1 text-[11px] font-semibold text-slate-600">#{{ task.sequence_no }}</span>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap gap-2 text-[11px] font-semibold">
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-700">{{ task.priority_label }}</span>
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-700">{{ task.date_bucket }}</span>
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-700">Target {{ task.internal_target_date or '-' }}</span>
|
||||
</div>
|
||||
{% if task.latest_comment %}<p class="mt-3 truncate text-xs text-slate-500">Latest: {{ task.latest_comment.message }}</p>{% endif %}
|
||||
<form method="post" action="/employee/work/tasks/{{ task.id }}/status" class="mt-4 space-y-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="return_url" value="/employee/work/engagements/{{ board.engagement_id }}">
|
||||
<select name="status" class="w-full rounded-lg border border-slate-300 px-2 py-1.5 text-xs">
|
||||
<option value="pending" {% if task.status == 'pending' %}selected{% endif %}>Pending</option>
|
||||
<option value="in_progress" {% if task.status == 'in_progress' %}selected{% endif %}>In Progress</option>
|
||||
<option value="blocked" {% if task.status == 'blocked' %}selected{% endif %}>Blocked</option>
|
||||
<option value="completed" {% if task.status == 'completed' %}selected{% endif %}>Completed</option>
|
||||
</select>
|
||||
<input type="text" name="remarks" value="" placeholder="Remark / reason" class="w-full rounded-lg border border-slate-300 px-2 py-1.5 text-xs">
|
||||
<div class="flex gap-2">
|
||||
<button type="submit" class="flex-1 rounded-lg bg-brand-600 px-2.5 py-1.5 text-xs font-semibold text-white hover:bg-brand-700">Save</button>
|
||||
<a href="/employee/work/tasks/{{ task.id }}/communication" class="rounded-lg border border-slate-300 px-2.5 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Timeline{% if task.comment_count %} · {{ task.comment_count }}{% endif %}</a>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-dashed border-slate-300 bg-white p-6 text-center text-sm text-slate-500">No tasks.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<aside class="space-y-4">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-900">Engagement Documents</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Quick access while performing tasks.</p>
|
||||
</div>
|
||||
<a href="/documents/engagements/{{ board.engagement_id }}" class="text-sm font-semibold text-brand-700 hover:text-brand-800">Open</a>
|
||||
</div>
|
||||
<div class="mt-4 divide-y divide-slate-100">
|
||||
{% for doc in board.documents %}
|
||||
<div class="py-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-slate-900">{{ doc.title }}</div>
|
||||
<div class="mt-1 text-xs text-slate-500">{{ doc.document_type }} · v{{ doc.current_version_no }} · {{ doc.status }}</div>
|
||||
</div>
|
||||
{% if doc.current_version_no and doc.versions %}
|
||||
<a href="/documents/{{ doc.id }}/download" class="rounded-lg border border-slate-300 px-2.5 py-1 text-xs font-semibold text-slate-700 hover:bg-slate-50">Download</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if doc.description %}<p class="mt-2 text-xs text-slate-500">{{ doc.description }}</p>{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="rounded-xl border border-dashed border-slate-300 p-5 text-center text-sm text-slate-500">No engagement document uploaded yet.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<h3 class="font-semibold text-slate-900">Engagement Summary</h3>
|
||||
<dl class="mt-4 space-y-3 text-sm">
|
||||
<div class="flex justify-between gap-4"><dt class="text-slate-500">Status</dt><dd class="font-medium text-slate-900">{{ board.subscription.status if board.subscription else '-' }}</dd></div>
|
||||
<div class="flex justify-between gap-4"><dt class="text-slate-500">FY</dt><dd class="font-medium text-slate-900">{{ board.subscription.financial_year if board.subscription else '-' }}</dd></div>
|
||||
<div class="flex justify-between gap-4"><dt class="text-slate-500">Due Date</dt><dd class="font-medium text-slate-900">{{ board.subscription.current_due_date if board.subscription else '-' }}</dd></div>
|
||||
<div class="flex justify-between gap-4"><dt class="text-slate-500">Review Partner</dt><dd class="font-medium text-slate-900">{{ board.subscription.review_partner.full_name if board.subscription and board.subscription.review_partner else '-' }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,96 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% if is_employee_self %}
|
||||
{% include "modules/employees/templates/employees/_my_workspace_tabs.html" %}
|
||||
{% else %}
|
||||
{% include "modules/managers/templates/managers/_manager_tabs.html" %}
|
||||
{% endif %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Task Communication</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Communication timeline for engagement task notes, clarifications and review remarks.</p>
|
||||
</div>
|
||||
<a href="{{ back_url }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back</a>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<div class="md:col-span-2">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Task</div>
|
||||
<div class="mt-1 text-lg font-semibold text-slate-900">{{ task.task_name }}</div>
|
||||
{% if task.description %}<p class="mt-1 text-sm text-slate-600">{{ task.description }}</p>{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Client</div>
|
||||
<div class="mt-1 text-sm font-semibold text-slate-900">{{ task.client.client_name if task.client else 'Unlinked Client' }}</div>
|
||||
<div class="text-xs text-slate-500">{{ task.client.client_code if task.client else '' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Engagement / Service</div>
|
||||
<div class="mt-1 text-sm font-semibold text-slate-900">{{ task.engagement_label }}</div>
|
||||
<div class="text-xs text-slate-500">Target: {{ task.internal_target_date or '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-wrap gap-2 text-xs">
|
||||
<span class="rounded-full bg-slate-100 px-2.5 py-1 font-semibold text-slate-700">{{ task.status_label }}</span>
|
||||
<span class="rounded-full bg-slate-100 px-2.5 py-1 font-semibold text-slate-700">{{ task.priority_label }}</span>
|
||||
<span class="rounded-full bg-slate-100 px-2.5 py-1 font-semibold text-slate-700">{{ task.date_bucket }}</span>
|
||||
<span class="rounded-full bg-slate-100 px-2.5 py-1 font-semibold text-slate-700">Assigned: {{ task.assigned_to.full_name if task.assigned_to else 'Unassigned' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<h3 class="font-semibold text-slate-900">Add communication</h3>
|
||||
<form method="post" action="{{ post_url }}" class="mt-4 space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<label class="block text-sm font-medium text-slate-700">Type
|
||||
<select name="comment_type" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100">
|
||||
{% for code, label in communication_types %}
|
||||
<option value="{{ code }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="block text-sm font-medium text-slate-700">Visibility
|
||||
<select name="visibility" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100">
|
||||
{% for code, label in communication_visibilities %}
|
||||
<option value="{{ code }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label class="block text-sm font-medium text-slate-700">Message
|
||||
<textarea name="message" rows="4" required placeholder="Write note, client clarification, consultant communication, or review remark" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100"></textarea>
|
||||
</label>
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Add to Timeline</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<div class="border-b border-slate-100 px-5 py-4">
|
||||
<h3 class="font-semibold text-slate-900">Communication Timeline</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Newest communication appears first.</p>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-100">
|
||||
{% for item in task.communication_items %}
|
||||
<div class="p-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-700">{{ item.comment_type.replace('_',' ').title() }}</span>
|
||||
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-700">{{ item.visibility.replace('_',' ').title() }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-slate-500">{{ item.created_at_utc }}</div>
|
||||
</div>
|
||||
<p class="mt-3 whitespace-pre-wrap text-sm text-slate-800">{{ item.message }}</p>
|
||||
<div class="mt-3 text-xs text-slate-500">By {{ item.created_by.full_name if item.created_by else 'System/User' }}</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-8 text-center text-sm text-slate-500">No communication has been added for this task yet.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user