Prepare ERP source for Gitea deployment

This commit is contained in:
A R R R Associates
2026-06-20 15:01:44 +05:30
commit 5c75eb6bd9
450 changed files with 67698 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Employee core module for Audit Firm v2."""
+543
View File
@@ -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"
+660
View File
@@ -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">Todays 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">Todays 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