Ajaxify employee portal dashboard
This commit is contained in:
@@ -1,20 +1,18 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
{% include "modules/employees/templates/employees/_my_workspace_tabs.html" %}
|
||||
|
||||
<div class="space-y-6" id="employee-portal-root">
|
||||
<section class="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-soft">
|
||||
<div class="bg-gradient-to-r from-brand-700 via-brand-600 to-slate-900 px-6 py-6 text-white">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-brand-100">My Workspace</p>
|
||||
<h2 class="mt-2 text-2xl font-semibold">Today’s work, attendance and personal profile</h2>
|
||||
<p class="mt-2 max-w-3xl text-sm text-brand-100">Use this dashboard for your own assignments, attendance, leave, documents, payslips and alerts.</p>
|
||||
<h2 class="mt-2 text-2xl font-semibold">Employee Portal</h2>
|
||||
<p class="mt-2 max-w-3xl text-sm text-brand-100">Use this dashboard for your assigned work, attendance, leave, documents, payslips, profile 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>
|
||||
<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">Full 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 %}
|
||||
@@ -23,309 +21,87 @@
|
||||
</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>
|
||||
{% set tabs = [
|
||||
('overview','Overview'),
|
||||
('work','My Work Board'),
|
||||
('attendance','My Attendance'),
|
||||
('profile','My Profile'),
|
||||
('leave','My Leave'),
|
||||
('documents','My Documents'),
|
||||
('payslips','My Payslips'),
|
||||
('offboarding','My Offboarding'),
|
||||
('alerts','My Alert')
|
||||
] %}
|
||||
|
||||
<section 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" id="employee-portal-tabs">
|
||||
{% for code, label in tabs %}
|
||||
<button type="button" data-tab="{{ code }}" class="employee-portal-tab inline-flex shrink-0 items-center rounded-xl px-3 py-2 text-sm font-semibold whitespace-nowrap transition {% if active_tab == code %}bg-brand-600 text-white shadow-soft{% else %}border border-slate-300 bg-white text-slate-700 hover:bg-slate-50{% endif %}">
|
||||
{{ label }}{% if code == 'alerts' and 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 %}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_380px]">
|
||||
<div class="space-y-6">
|
||||
<div class="af-card">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-slate-900">My Assignment Board Snapshot</h3>
|
||||
<p class="mt-1 text-sm text-slate-600">Your work is grouped by practical status so you can start from urgent items first.</p>
|
||||
</div>
|
||||
<a href="/employee/work" class="af-btn af-btn-primary">Open My Work Board</a>
|
||||
</div>
|
||||
<div class="mt-5 grid gap-3 md:grid-cols-4">
|
||||
{% if work_payload %}
|
||||
{% for column in work_payload.columns %}
|
||||
<a href="/employee/work?status={{ column.code }}" class="rounded-2xl border border-slate-200 bg-slate-50 p-4 transition hover:border-brand-300 hover:bg-brand-50/50">
|
||||
<div class="text-sm font-semibold text-slate-900">{{ column.label }}</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ column.cards|length }}</div>
|
||||
<div class="mt-1 text-xs text-slate-500">assignment card(s)</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500 md:col-span-4">No work board data available yet.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="af-card">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-slate-900">Leave & Availability</h3>
|
||||
<p class="mt-1 text-sm text-slate-600">Balances and recent leave requests for your profile.</p>
|
||||
</div>
|
||||
<a href="/employee/leave" class="af-btn af-btn-secondary">Open Leave</a>
|
||||
</div>
|
||||
<div class="mt-5 grid gap-3 md:grid-cols-3">
|
||||
{% for bal in leave_balances %}
|
||||
<div class="rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div class="text-xs font-semibold uppercase text-slate-500">{{ bal.leave_type.name if bal.leave_type else bal.leave_type_id }}</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ bal.balance_days }}</div>
|
||||
<div class="text-xs text-slate-500">days balance</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500 md:col-span-3">No leave balance available yet.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="space-y-6">
|
||||
<div class="af-card">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-900">Today’s Attendance</h3>
|
||||
<p class="mt-1 text-sm text-slate-600">
|
||||
{% if today_attendance %}
|
||||
Punch In: {{ today_attendance.punch_in_local_at.strftime('%H:%M') if today_attendance.punch_in_local_at else (today_attendance.punch_in_utc.strftime('%H:%M UTC') if today_attendance.punch_in_utc else '-') }} ·
|
||||
Punch Out: {{ today_attendance.punch_out_local_at.strftime('%H:%M') if today_attendance.punch_out_local_at else (today_attendance.punch_out_utc.strftime('%H:%M UTC') 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">Details</a>
|
||||
</div>
|
||||
|
||||
<div id="employee-dashboard-geo-status" class="mt-4 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-600">
|
||||
Click Punch In/Punch Out. The app will ask you to allow location before submitting attendance.
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3">
|
||||
<form method="post" action="/employee/attendance/punch-in" class="employee-dashboard-attendance-geo-form rounded-2xl border border-emerald-200 bg-emerald-50 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-xs font-semibold uppercase text-emerald-700">Punch In Remarks</label>
|
||||
<input name="remarks" class="mt-2 w-full rounded-xl border border-emerald-200 bg-white px-3 py-2 text-sm" placeholder="Optional; client visit / OD reason">
|
||||
<button class="mt-3 w-full rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:cursor-not-allowed 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="employee-dashboard-attendance-geo-form rounded-2xl border border-brand-200 bg-brand-50 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-xs font-semibold uppercase text-brand-700">Punch Out Remarks</label>
|
||||
<input name="remarks" class="mt-2 w-full rounded-xl border border-brand-200 bg-white px-3 py-2 text-sm" placeholder="Optional remarks">
|
||||
<button class="mt-3 w-full rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700 disabled:cursor-not-allowed 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 class="mt-5 border-t border-slate-100 pt-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h4 class="text-sm font-semibold text-slate-900">Recent Attendance</h4>
|
||||
<a href="/employee/attendance" class="text-xs font-semibold text-brand-700 hover:underline">View all</a>
|
||||
</div>
|
||||
<div class="mt-3 space-y-2">
|
||||
{% for row in attendance_rows or [] %}
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-600">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-semibold text-slate-900">{{ row.attendance_date }}</span>
|
||||
<span>{{ (row.status or '-').replace('_',' ').title() }}</span>
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
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 '-') }} ·
|
||||
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 '-') }} ·
|
||||
Duration: {{ (row.work_duration_minutes ~ ' min') if row.work_duration_minutes else '-' }}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="rounded-xl border border-dashed border-slate-300 px-3 py-3 text-xs text-slate-500">No recent attendance records found.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</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 id="employee-portal-panel" class="space-y-6">
|
||||
{% include "modules/employees/templates/employees/portal_partials/overview.html" %}
|
||||
</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>
|
||||
|
||||
|
||||
<div id="employee-dashboard-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 location is denied/unavailable or you are outside branch range, the punch can still be saved for approval as per existing attendance rules.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5 flex justify-end gap-3">
|
||||
<button type="button" id="employee-dashboard-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="employee-dashboard-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('employee-dashboard-geo-status');
|
||||
const forms = document.querySelectorAll('.employee-dashboard-attendance-geo-form');
|
||||
const modal = document.getElementById('employee-dashboard-location-consent-modal');
|
||||
const modalContinue = document.getElementById('employee-dashboard-location-modal-continue');
|
||||
const modalCancel = document.getElementById('employee-dashboard-location-modal-cancel');
|
||||
const panel = document.getElementById('employee-portal-panel');
|
||||
const tabs = Array.from(document.querySelectorAll('.employee-portal-tab'));
|
||||
if (!panel || !tabs.length) return;
|
||||
|
||||
function updateStatus(message, tone) {
|
||||
if (!statusBox) return;
|
||||
statusBox.textContent = message;
|
||||
statusBox.className = 'mt-4 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(form, lat, lon, acc) {
|
||||
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() {
|
||||
return new Promise(function (resolve) {
|
||||
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 setActive(tabName) {
|
||||
tabs.forEach(function (button) {
|
||||
const active = button.dataset.tab === tabName;
|
||||
button.classList.toggle('bg-brand-600', active);
|
||||
button.classList.toggle('text-white', active);
|
||||
button.classList.toggle('shadow-soft', active);
|
||||
button.classList.toggle('border', !active);
|
||||
button.classList.toggle('border-slate-300', !active);
|
||||
button.classList.toggle('bg-white', !active);
|
||||
button.classList.toggle('text-slate-700', !active);
|
||||
button.classList.toggle('hover:bg-slate-50', !active);
|
||||
});
|
||||
}
|
||||
|
||||
function captureLocation(form) {
|
||||
return new Promise(function (resolve) {
|
||||
if (!navigator.geolocation) {
|
||||
setHidden(form, '', '', '');
|
||||
updateStatus('Browser geolocation is not available. Punch will be saved as pending approval if branch geofence is enabled.', 'warn');
|
||||
resolve(false);
|
||||
return;
|
||||
async function loadTab(tabName, pushState) {
|
||||
setActive(tabName);
|
||||
panel.innerHTML = '<div class="rounded-3xl border border-slate-200 bg-white p-8 text-center text-sm text-slate-500 shadow-soft">Loading...</div>';
|
||||
try {
|
||||
const response = await fetch('/employee/dashboard/tab/' + encodeURIComponent(tabName), {
|
||||
headers: {'X-Requested-With': 'fetch'}
|
||||
});
|
||||
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||
panel.innerHTML = await response.text();
|
||||
if (pushState) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('tab', tabName);
|
||||
window.history.pushState({tab: tabName}, '', url.toString());
|
||||
}
|
||||
if (window.EmployeePortalGeo && typeof window.EmployeePortalGeo.bind === 'function') {
|
||||
window.EmployeePortalGeo.bind();
|
||||
}
|
||||
} catch (error) {
|
||||
panel.innerHTML = '<div class="rounded-3xl border border-red-200 bg-red-50 p-8 text-center text-sm text-red-700 shadow-soft">Unable to load tab. Please refresh the page.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
updateStatus('Browser location popup opened. Please choose Allow to capture location...', 'info');
|
||||
navigator.geolocation.getCurrentPosition(function (pos) {
|
||||
const c = pos.coords || {};
|
||||
setHidden(form, c.latitude, c.longitude, c.accuracy);
|
||||
updateStatus('Location captured. Accuracy: ' + Math.round(c.accuracy || 0) + ' meters.', 'ok');
|
||||
resolve(true);
|
||||
}, function (err) {
|
||||
setHidden(form, '', '', '');
|
||||
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 });
|
||||
tabs.forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
loadTab(button.dataset.tab || 'overview', true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
if (!proceed) return;
|
||||
await captureLocation(form);
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
window.addEventListener('popstate', function (event) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
loadTab((event.state && event.state.tab) || params.get('tab') || 'overview', false);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div><h3 class="text-lg font-semibold text-slate-900">My Alert</h3><p class="mt-1 text-sm text-slate-600">Open your alert centre to view notifications and follow-ups.</p></div>
|
||||
<a href="/alerts" class="af-btn af-btn-primary">Open Alerts</a>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-soft">
|
||||
<div class="text-sm text-slate-600">{% if unread_alert_count is defined and unread_alert_count > 0 %}You have <strong class="text-slate-900">{{ unread_alert_count }}</strong> unread alert(s).{% else %}No unread alert count is pending in this workspace context.{% endif %}</div>
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-3">
|
||||
<a href="/alerts" class="rounded-2xl border border-slate-200 bg-slate-50 p-4 font-semibold text-slate-700 hover:bg-brand-50 hover:border-brand-200">View Alerts</a>
|
||||
<a href="/employee/work" class="rounded-2xl border border-slate-200 bg-slate-50 p-4 font-semibold text-slate-700 hover:bg-brand-50 hover:border-brand-200">Open Work Board</a>
|
||||
<a href="/employee/attendance" class="rounded-2xl border border-slate-200 bg-slate-50 p-4 font-semibold text-slate-700 hover:bg-brand-50 hover:border-brand-200">Open Attendance</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,42 @@
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-slate-900">My Attendance</h3>
|
||||
<p class="mt-1 text-sm text-slate-600">Punch in/out and view your recent attendance records.</p>
|
||||
</div>
|
||||
<a href="/employee/attendance" class="af-btn af-btn-secondary">Open Full Attendance Page</a>
|
||||
</div>
|
||||
|
||||
{% 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>
|
||||
{% else %}
|
||||
<section 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>
|
||||
</section>
|
||||
|
||||
{% include "modules/employees/templates/employees/portal_partials/attendance_punch_card.html" %}
|
||||
|
||||
<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">Geo/IP</th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for row in attendance_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>Geo: {{ (row.punch_in_geo_status or '-').replace('_',' ').title() }}</div><div>IP: {{ (row.punch_in_ip_status or '-').replace('_',' ').title() }}</div></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">No attendance records available yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,94 @@
|
||||
<div class="af-card">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-900">Today’s Attendance</h3>
|
||||
<p class="mt-1 text-sm text-slate-600">
|
||||
{% if today_attendance %}
|
||||
Punch In: {{ today_attendance.punch_in_local_at.strftime('%H:%M') if today_attendance.punch_in_local_at else (today_attendance.punch_in_utc.strftime('%H:%M UTC') if today_attendance.punch_in_utc else '-') }} ·
|
||||
Punch Out: {{ today_attendance.punch_out_local_at.strftime('%H:%M') if today_attendance.punch_out_local_at else (today_attendance.punch_out_utc.strftime('%H:%M UTC') 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">Full View</a>
|
||||
</div>
|
||||
|
||||
{% if employee %}
|
||||
<div id="employee-geo-status" class="mt-4 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-3 md:grid-cols-2 xl:grid-cols-1">
|
||||
<form method="post" action="/employee/attendance/punch-in" class="employee-attendance-geo-form rounded-2xl 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-xs font-semibold uppercase text-slate-500">Remarks / OD Reason</label>
|
||||
<input name="remarks" class="mt-2 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Optional remarks">
|
||||
<button class="mt-3 w-full 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="employee-attendance-geo-form rounded-2xl 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-xs font-semibold uppercase text-slate-500">Remarks / OD Reason</label>
|
||||
<input name="remarks" class="mt-2 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Optional remarks">
|
||||
<button class="mt-3 w-full 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>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.EmployeePortalGeo = window.EmployeePortalGeo || (function () {
|
||||
function updateStatus(message, tone) {
|
||||
var status = document.getElementById('employee-geo-status');
|
||||
if (!status) return;
|
||||
status.textContent = message;
|
||||
status.className = 'mt-4 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(form, lat, lon, acc) {
|
||||
var latInput = form.querySelector('input[name="latitude"]');
|
||||
var lonInput = form.querySelector('input[name="longitude"]');
|
||||
var accInput = form.querySelector('input[name="accuracy_meters"]');
|
||||
if (latInput) latInput.value = lat || '';
|
||||
if (lonInput) lonInput.value = lon || '';
|
||||
if (accInput) accInput.value = acc || '';
|
||||
}
|
||||
function bind() {
|
||||
document.querySelectorAll('.employee-attendance-geo-form').forEach(function (form) {
|
||||
if (form.dataset.geoBound === '1') return;
|
||||
form.dataset.geoBound = '1';
|
||||
form.addEventListener('submit', function (event) {
|
||||
if (form.dataset.geoSubmitted === '1') return;
|
||||
event.preventDefault();
|
||||
if (!navigator.geolocation) {
|
||||
updateStatus('Browser geolocation is not available. Punch will continue without location.', 'warn');
|
||||
form.dataset.geoSubmitted = '1';
|
||||
form.submit();
|
||||
return;
|
||||
}
|
||||
updateStatus('Requesting browser location. Please click Allow...', 'info');
|
||||
navigator.geolocation.getCurrentPosition(function (pos) {
|
||||
var c = pos.coords || {};
|
||||
setHidden(form, c.latitude, c.longitude, c.accuracy);
|
||||
updateStatus('Location captured. Accuracy: ' + Math.round(c.accuracy || 0) + ' meters.', 'ok');
|
||||
form.dataset.geoSubmitted = '1';
|
||||
form.submit();
|
||||
}, function () {
|
||||
setHidden(form, '', '', '');
|
||||
updateStatus('Location denied/unavailable. Punch will continue and may need approval if branch geofence is enabled.', 'warn');
|
||||
form.dataset.geoSubmitted = '1';
|
||||
form.submit();
|
||||
}, {enableHighAccuracy: true, timeout: 12000, maximumAge: 0});
|
||||
});
|
||||
});
|
||||
}
|
||||
return {bind: bind};
|
||||
})();
|
||||
window.EmployeePortalGeo.bind();
|
||||
</script>
|
||||
@@ -0,0 +1,9 @@
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div><h3 class="text-lg font-semibold text-slate-900">My Documents</h3><p class="mt-1 text-sm text-slate-600">Recently uploaded employee documents and verification status.</p></div>
|
||||
<a href="/employee/documents" class="af-btn af-btn-primary">Open Documents Page</a>
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm"><thead class="bg-slate-50 text-left text-xs font-semibold uppercase text-slate-500"><tr><th class="px-4 py-3">Document</th><th class="px-4 py-3">Type</th><th class="px-4 py-3">Status</th><th class="px-4 py-3">Uploaded</th><th class="px-4 py-3">Action</th></tr></thead><tbody class="divide-y divide-slate-100">
|
||||
{% for doc in own_documents %}<tr><td class="px-4 py-3 font-medium text-slate-900">{{ doc.title or doc.file_name or ('Document #' ~ doc.id) }}</td><td class="px-4 py-3">{{ doc.document_type.name if doc.document_type else '-' }}</td><td class="px-4 py-3">{{ doc.status.replace('_',' ').title() if doc.status else '-' }}</td><td class="px-4 py-3">{{ doc.created_at.strftime('%d-%m-%Y') if doc.created_at else '-' }}</td><td class="px-4 py-3"><a href="/employee/documents" class="font-semibold text-brand-700 hover:underline">View</a></td></tr>{% else %}<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">No employee documents available yet.</td></tr>{% endfor %}
|
||||
</tbody></table>
|
||||
</div>
|
||||
@@ -0,0 +1,21 @@
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div><h3 class="text-lg font-semibold text-slate-900">My Leave</h3><p class="mt-1 text-sm text-slate-600">Leave balances and recent applications.</p></div>
|
||||
<a href="/employee/leave" class="af-btn af-btn-primary">Open Leave Page</a>
|
||||
</div>
|
||||
<div class="grid gap-3 md:grid-cols-3">
|
||||
{% for bal in leave_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-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 bg-white p-6 text-sm text-slate-500 md:col-span-3">No leave balance available yet.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<div class="border-b border-slate-200 px-5 py-4"><h3 class="font-semibold text-slate-900">Recent Leave Requests</h3></div>
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm"><thead class="bg-slate-50 text-left text-xs font-semibold uppercase text-slate-500"><tr><th class="px-4 py-3">Type</th><th class="px-4 py-3">From</th><th class="px-4 py-3">To</th><th class="px-4 py-3">Days</th><th class="px-4 py-3">Status</th></tr></thead><tbody class="divide-y divide-slate-100">
|
||||
{% for row in leave_requests %}<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 }}</td><td class="px-4 py-3">{{ row.to_date }}</td><td class="px-4 py-3">{{ row.days }}</td><td class="px-4 py-3">{{ row.status.replace('_',' ').title() }}</td></tr>{% else %}<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">No leave requests available.</td></tr>{% endfor %}
|
||||
</tbody></table>
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div><h3 class="text-lg font-semibold text-slate-900">My Offboarding</h3><p class="mt-1 text-sm text-slate-600">Offboarding requests and status.</p></div>
|
||||
<a href="/employee/offboarding" class="af-btn af-btn-primary">Open Offboarding Page</a>
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm"><thead class="bg-slate-50 text-left text-xs font-semibold uppercase text-slate-500"><tr><th class="px-4 py-3">Request</th><th class="px-4 py-3">Reason</th><th class="px-4 py-3">Status</th><th class="px-4 py-3">Created</th></tr></thead><tbody class="divide-y divide-slate-100">
|
||||
{% for row in offboarding_requests %}<tr><td class="px-4 py-3 font-medium text-slate-900">#{{ row.id }}</td><td class="px-4 py-3">{{ row.reason or '-' }}</td><td class="px-4 py-3">{{ row.status.replace('_',' ').title() if row.status else '-' }}</td><td class="px-4 py-3">{{ row.created_at.strftime('%d-%m-%Y') if row.created_at else '-' }}</td></tr>{% else %}<tr><td colspan="4" class="px-4 py-8 text-center text-slate-500">No offboarding requests available.</td></tr>{% endfor %}
|
||||
</tbody></table>
|
||||
</div>
|
||||
@@ -0,0 +1,111 @@
|
||||
{% 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 %}{% if today_attendance.punch_out_utc %}Completed{% elif today_attendance.punch_in_utc %}Punched In{% else %}Marked{% endif %}{% 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)_400px]">
|
||||
<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. Open the full board for detailed updates.</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">
|
||||
{% include "modules/employees/templates/employees/portal_partials/attendance_punch_card.html" %}
|
||||
|
||||
<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">
|
||||
<h3 class="font-semibold text-slate-900">Quick Links</h3>
|
||||
<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 %}
|
||||
@@ -0,0 +1,16 @@
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div><h3 class="text-lg font-semibold text-slate-900">My Payslips</h3><p class="mt-1 text-sm text-slate-600">Recently available payslips.</p></div>
|
||||
<a href="/employee/payslips" class="af-btn af-btn-primary">Open Payslips Page</a>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{% for slip in payslips %}
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="text-xs font-semibold uppercase text-slate-500">Payslip</div>
|
||||
<h3 class="mt-2 text-lg font-semibold text-slate-900">{{ slip.period_label if slip.period_label is defined else ('Payslip #' ~ slip.id) }}</h3>
|
||||
<p class="mt-1 text-sm text-slate-600">Status: {{ slip.status.replace('_',' ').title() if slip.status else '-' }}</p>
|
||||
<a href="/employee/payslips" class="mt-4 inline-flex af-btn af-btn-secondary">View</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-dashed border-slate-300 bg-white p-6 text-sm text-slate-500 md:col-span-2 xl:col-span-3">No payslips available yet.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -0,0 +1,31 @@
|
||||
{% 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. <a href="/employee/register" class="font-semibold underline">Request employee profile</a>.</div>
|
||||
{% else %}
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div><h3 class="text-lg font-semibold text-slate-900">My Profile</h3><p class="mt-1 text-sm text-slate-600">Your employee master and public profile details.</p></div>
|
||||
<a href="/employee/profile" class="af-btn af-btn-primary">Open / Update Profile</a>
|
||||
</div>
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<div class="af-card">
|
||||
<h3 class="font-semibold text-slate-900">Employment Details</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">Employee Code</dt><dd class="font-medium text-slate-900">{{ employee.employee_code }}</dd></div>
|
||||
<div><dt class="text-slate-500">Status</dt><dd class="font-medium text-slate-900">{{ 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">Department</dt><dd class="font-medium text-slate-900">{{ employee.department or '-' }}</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>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="af-card">
|
||||
<h3 class="font-semibold text-slate-900">Contact Details</h3>
|
||||
<dl class="mt-4 grid gap-4 text-sm">
|
||||
<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>
|
||||
<div><dt class="text-slate-500">Alternate Mobile</dt><dd class="font-medium text-slate-900">{{ employee.alternate_mobile or '-' }}</dd></div>
|
||||
<div><dt class="text-slate-500">Address</dt><dd class="font-medium text-slate-900">{{ employee.address or '-' }}</dd></div>
|
||||
<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>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,55 @@
|
||||
{% 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 full self-service requires employee master linkage.</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 class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-slate-900">My Work Board</h3>
|
||||
<p class="mt-1 text-sm text-slate-600">Snapshot of your assigned engagements. Use the full work board to update task status and communication.</p>
|
||||
</div>
|
||||
<a href="/employee/work" class="af-btn af-btn-primary">Open Full Work Board</a>
|
||||
</div>
|
||||
|
||||
<section 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 if work_payload 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">Open</div><div class="mt-1 text-2xl font-semibold">{{ work_payload.summary.open if work_payload 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">In Progress</div><div class="mt-1 text-2xl font-semibold">{{ work_payload.summary.in_progress if work_payload else 0 }}</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 if work_payload else 0 }}</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 if work_payload else 0 }}</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 if work_payload else 0 }}</div></div>
|
||||
</section>
|
||||
|
||||
<div class="grid gap-4 xl:grid-cols-4">
|
||||
{% if work_payload %}
|
||||
{% for column in work_payload.columns %}
|
||||
<section class="min-h-[320px] 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[:5] %}
|
||||
<article class="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm hover:border-brand-200 hover:shadow-soft">
|
||||
<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 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>
|
||||
<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 %}
|
||||
{% if column.cards|length > 5 %}<a href="/employee/work?status={{ column.code }}" class="block rounded-xl border border-slate-200 bg-white px-4 py-2 text-center text-sm font-semibold text-slate-700 hover:bg-slate-50">View all {{ column.cards|length }}</a>{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-dashed border-slate-300 bg-white p-6 text-sm text-slate-500 xl:col-span-4">No work board data available yet.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
+73
-17
@@ -2472,8 +2472,56 @@ def employee_status_submit(
|
||||
db.close()
|
||||
|
||||
|
||||
|
||||
EMPLOYEE_PORTAL_TABS = {
|
||||
"overview": "modules/employees/templates/employees/portal_partials/overview.html",
|
||||
"work": "modules/employees/templates/employees/portal_partials/work.html",
|
||||
"attendance": "modules/employees/templates/employees/portal_partials/attendance.html",
|
||||
"profile": "modules/employees/templates/employees/portal_partials/profile.html",
|
||||
"leave": "modules/employees/templates/employees/portal_partials/leave.html",
|
||||
"documents": "modules/employees/templates/employees/portal_partials/documents.html",
|
||||
"payslips": "modules/employees/templates/employees/portal_partials/payslips.html",
|
||||
"offboarding": "modules/employees/templates/employees/portal_partials/offboarding.html",
|
||||
"alerts": "modules/employees/templates/employees/portal_partials/alerts.html",
|
||||
}
|
||||
|
||||
|
||||
def _employee_portal_context(request: Request, db, current_user, *, active_tab: str = "overview"):
|
||||
employee = get_employee_for_user(db, current_user)
|
||||
pending_request = None if employee else get_pending_registration_for_user(db, current_user)
|
||||
work_payload = None
|
||||
if employee:
|
||||
scope = build_employee_scope(
|
||||
db,
|
||||
current_user,
|
||||
tenant_id=request.session.get("active_tenant_id") or current_user.tenant_id,
|
||||
branch_id=request.session.get("active_branch_id"),
|
||||
)
|
||||
work_payload = list_employee_work_kanban(
|
||||
db,
|
||||
scope,
|
||||
q="",
|
||||
status="open",
|
||||
financial_year=_active_financial_year(request),
|
||||
)
|
||||
return {
|
||||
"title": "Employee Portal",
|
||||
"active_tab": active_tab if active_tab in EMPLOYEE_PORTAL_TABS else "overview",
|
||||
"employee": employee,
|
||||
"pending_request": pending_request,
|
||||
"today_attendance": get_today_attendance_for_user(db, current_user),
|
||||
"attendance_rows": list_own_attendance(db, current_user, limit=10),
|
||||
"leave_balances": list_own_leave_balances(db, current_user),
|
||||
"leave_requests": list_own_leave_requests(db, current_user)[:5],
|
||||
"offboarding_requests": list_own_offboarding_requests(db, current_user)[:5],
|
||||
"payslips": list_own_payslips(db, current_user)[:5],
|
||||
"own_documents": list_own_employee_documents(db, current_user)[:8],
|
||||
"work_payload": work_payload,
|
||||
}
|
||||
|
||||
|
||||
@portal_router.get("/dashboard")
|
||||
def employee_portal_dashboard(request: Request):
|
||||
def employee_portal_dashboard(request: Request, tab: str = "overview"):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
current_user = get_current_user(request, db=db)
|
||||
@@ -2483,32 +2531,40 @@ def employee_portal_dashboard(request: Request):
|
||||
require_permission(db, current_user, "employees.ess.view")
|
||||
except Exception:
|
||||
return _redirect_denied()
|
||||
employee = get_employee_for_user(db, current_user)
|
||||
pending_request = None if employee else get_pending_registration_for_user(db, current_user)
|
||||
work_payload = None
|
||||
if employee:
|
||||
scope = build_employee_scope(db, current_user, tenant_id=request.session.get("active_tenant_id") or current_user.tenant_id, branch_id=request.session.get("active_branch_id"))
|
||||
work_payload = list_employee_work_kanban(db, scope, q="", status="open", financial_year=_active_financial_year(request))
|
||||
active_tab = tab if tab in EMPLOYEE_PORTAL_TABS else "overview"
|
||||
return _render(
|
||||
request,
|
||||
"modules/employees/templates/employees/portal_dashboard.html",
|
||||
db,
|
||||
current_user,
|
||||
title="Employee Portal",
|
||||
employee=employee,
|
||||
pending_request=pending_request,
|
||||
today_attendance=get_today_attendance_for_user(db, current_user),
|
||||
attendance_rows=list_own_attendance(db, current_user)[:7],
|
||||
leave_balances=list_own_leave_balances(db, current_user),
|
||||
leave_requests=list_own_leave_requests(db, current_user)[:5],
|
||||
offboarding_requests=list_own_offboarding_requests(db, current_user)[:5],
|
||||
payslips=list_own_payslips(db, current_user)[:5],
|
||||
work_payload=work_payload,
|
||||
**_employee_portal_context(request, db, current_user, active_tab=active_tab),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@portal_router.get("/dashboard/tab/{tab_name}")
|
||||
def employee_portal_dashboard_tab(request: Request, tab_name: str):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
current_user = get_current_user(request, db=db)
|
||||
if not current_user:
|
||||
return _redirect_login()
|
||||
try:
|
||||
require_permission(db, current_user, "employees.ess.view")
|
||||
except Exception:
|
||||
return _redirect_denied()
|
||||
active_tab = tab_name if tab_name in EMPLOYEE_PORTAL_TABS else "overview"
|
||||
return _render(
|
||||
request,
|
||||
EMPLOYEE_PORTAL_TABS[active_tab],
|
||||
db,
|
||||
current_user,
|
||||
**_employee_portal_context(request, db, current_user, active_tab=active_tab),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@portal_router.get("/attendance")
|
||||
def employee_self_attendance(request: Request):
|
||||
db = CommonSessionLocal()
|
||||
|
||||
Reference in New Issue
Block a user