`); w.document.close(); w.print(); }; } // Three-state statutory selector: inherit / on / off. // // A checkbox cannot express "whatever the company does", and that is the right // answer for almost every employee — so it has to be the default and it has to // be visibly distinct from "off". const statSel = (name, label, value) => ` `; async function openEmployeeMaster(pin) { const [emp, shifts, locations] = await Promise.all([ api(`/employees`).then((list) => list.find((e) => e.pin === pin)), api("/shifts"), api("/locations"), ]); if (!emp) return toast("Employee not found"); const [companyLocations, departments] = await Promise.all([ emp.location_id ? api(`/company-locations?company_id=${encodeURIComponent(emp.location_id)}`) : [], emp.company_location_id ? api(`/departments?company_location_id=${encodeURIComponent(emp.company_location_id)}`) : (emp.location_id ? api(`/departments?location_id=${encodeURIComponent(emp.location_id)}`) : []), ]); const backdrop = document.getElementById("modal-backdrop"); const modal = document.getElementById("modal"); const departmentLabel = (department) => { const scope = [department.company_name, department.location_name].filter(Boolean).join(" · "); return scope ? `${scope} · ${department.name}` : department.name; }; const sel = (name, label, options, current) => ` `; const inp = (name, label, value = "", type = "text") => ``; modal.innerHTML = `

Employee master details

Personal, employment, payroll and biometric information.

👤
${inp("name","Full name",emp.name)}
${inp("email","Email address",emp.email,"email")}${inp("phone","Mobile / WhatsApp",emp.phone)}
${inp("address","Residential address",emp.address)}
Organisation assignment

Company → Location → Department → Designation

${sel("location_id","1. Company",locations,emp.location_id)}
${sel("department_id","3. Department",departments,emp.department_id)} ${inp("designation","4. Designation",emp.designation)}
Statutory deductions

Leave on Company setting unless this person is a genuine exception — for example someone above the PF wage ceiling who opted out, or an employee with their own TDS rate. ESI still stops automatically above the wage cap even when switched on, because that is a legal ceiling rather than a preference.

${statSel("pf_enabled","Provident Fund (PF)",emp.pf_enabled)} ${statSel("esi_enabled","ESI",emp.esi_enabled)}
${statSel("pt_enabled","Professional Tax",emp.pt_enabled)} ${statSel("tds_enabled","TDS",emp.tds_enabled)}
${sel("shift_id","Shift",shifts,emp.shift_id)}${inp("employment_type","Employment type",emp.employment_type)}
${inp("join_date","Joining date",emp.join_date,"date")}${inp("salary","Gross monthly salary",emp.salary,"number")}

This is the full gross (CTC-in-hand before deductions). Payroll splits it into Basic, HRA and allowances using the percentages under Payroll › Salary structure.

${inp("last_working_day","Last working day (leave blank if still employed)",emp.last_working_day,"date")}

Set this when an employee resigns or is terminated. Salary for their last month is automatically pro-rated: e.g. leaves on 17th July → paid for 17 of 31 days only.

${inp("bank_account","Bank account number",emp.bank_account)}${inp("ifsc","IFSC code",emp.ifsc)}
${inp("pan","PAN number",emp.pan)}${inp("uan","UAN / PF number",emp.uan)}
${inp("emergency_name","Emergency contact name",emp.emergency_name)}${inp("emergency_phone","Emergency contact mobile",emp.emergency_phone)}

Inactive employees are hidden from attendance and payroll, but their past records stay viewable.

`; backdrop.classList.add("open"); const companySelect = modal.querySelector('[name="location_id"]'); const branchSelect = modal.querySelector('[name="company_location_id"]'); const departmentSelect = modal.querySelector('[name="department_id"]'); companySelect.addEventListener("change", async () => { branchSelect.innerHTML = ''; departmentSelect.innerHTML = ''; const rows = companySelect.value ? await api(`/company-locations?company_id=${encodeURIComponent(companySelect.value)}`) : []; branchSelect.innerHTML = '' + rows.map((r) => ``).join(""); }); branchSelect.addEventListener("change", async () => { departmentSelect.innerHTML = ''; const rows = branchSelect.value ? await api(`/departments?company_location_id=${encodeURIComponent(branchSelect.value)}`) : []; departmentSelect.innerHTML = '' + rows.map((r) => ``).join(""); }); let newPhotoDataUrl = null; document.getElementById("edit-photo-input").addEventListener("change", async (e) => { const file = e.target.files[0]; if (!file) return; newPhotoDataUrl = await resizeImageFile(file); const preview = document.getElementById("edit-photo-preview"); preview.src = newPhotoDataUrl; preview.style.display = ""; document.getElementById("edit-photo-placeholder").style.display = "none"; }); modal.querySelector(".modal-cancel").onclick = () => backdrop.classList.remove("open"); modal.querySelector(".modal-clear").onclick = () => { // Blank out every field so the user can re-enter details from scratch, // without closing the window. Photo selection is reset too. Status is // left as-is (clearing it to a default could accidentally reactivate // someone who left). Nothing is saved until "Save changes" is pressed. modal.querySelectorAll("input, select").forEach((el) => { if (el.name === "active") return; if (el.type === "file") { el.value = ""; return; } if (el.tagName === "SELECT") el.value = ""; else el.value = ""; }); newPhotoDataUrl = null; const preview = document.getElementById("edit-photo-preview"); preview.src = ""; preview.style.display = "none"; document.getElementById("edit-photo-placeholder").style.display = ""; toast("Fields cleared — re-enter details, then Save changes"); }; modal.querySelector(".modal-save").onclick = async () => { const data = { photo: newPhotoDataUrl }; // null unless a new photo was picked — server preserves existing photo in that case modal.querySelectorAll("input, select").forEach((el) => { if (el.name) data[el.name] = el.value; }); data.active = data.active === "1" ? 1 : 0; await api(`/employees/${encodeURIComponent(pin)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }); backdrop.classList.remove("open"); loadEmployees(); toast("Employee details saved"); }; } // --------------------------------------------------------------------------- // Departments & shifts // --------------------------------------------------------------------------- async function loadDepartments() { const [departments, shifts, employees, locations, branches, users] = await Promise.all([ api("/departments"), api("/shifts"), api("/employees"), api("/locations"), api("/company-locations"), api("/auth/users").catch(() => []), ]); const managers=users.filter(u=>u.role==='manager'&&u.employee_pin); const groupedDepartments=[...departments.reduce((map,d)=>{const key=String(d.name||'').trim().toLowerCase();if(!map.has(key))map.set(key,{name:d.name,rows:[]});map.get(key).rows.push(d);return map},new Map()).values()]; const renderDepartmentGroups=()=>{ const query=document.getElementById('department-search').value.trim().toLowerCase(); const visible=groupedDepartments.filter(g=>!query||g.name.toLowerCase().includes(query)||g.rows.some(d=>`${d.company_name||''} ${d.location_name||''}`.toLowerCase().includes(query))); document.getElementById('department-groups').innerHTML=visible.map((g,index)=>{const assigned=g.rows.filter(d=>d.manager_pin).length;return `
${notificationEsc(g.name)}${g.rows.length} location${g.rows.length===1?'':'s'} · ${assigned}/${g.rows.length} managers assigned
${g.rows.map(d=>`
${notificationEsc(d.company_name||locations.find(l=>l.id===d.location_id)?.name||'-')}${notificationEsc(d.location_name||'Location not assigned')}
`).join('')}
`}).join('')||`
No departments match this search.
`; bindDepartmentActions(); }; const bindDepartmentActions=()=>{ document.querySelectorAll('.department-manager').forEach(sel=>sel.addEventListener('change',async()=>{await api(`/departments/${sel.dataset.id}/manager`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({manager_pin:sel.value||null})});toast(sel.value?'Department manager assigned to all employees':'Department manager removed');loadDepartments()})); document.querySelectorAll('.remove-department').forEach(btn=>btn.addEventListener('click',async()=>{if(!confirm(`Remove ${btn.dataset.name} from ${btn.dataset.location}? Existing attendance and payroll history will be retained.`))return;try{await api(`/departments/${btn.dataset.id}`,{method:'DELETE'});toast('Department mapping removed');loadDepartments()}catch(error){toast(error.message||'Move assigned employees before removing this department')}})); }; renderDepartmentGroups(); document.getElementById('department-search').oninput=renderDepartmentGroups; document.querySelector("#shifts-table tbody").innerHTML = shifts.map((s) => ` ${s.name}${s.start_time}${s.end_time}${s.grace_minutes} `).join("") || `

No shifts defined

Add a shift if some staff work different hours from the company default.

`; const assignmentLocation=document.getElementById('assignment-location'); assignmentLocation.innerHTML=''+branches.map(b=>``).join(''); let assignmentPage=1;const assignmentPageSize=30; const renderAssignments=()=>{ const query=document.getElementById('assignment-search').value.trim().toLowerCase(),branch=assignmentLocation.value; const filtered=employees.filter(e=>(!branch||String(e.company_location_id||'')===branch)&&(!query||`${e.name||''} ${e.pin||''}`.toLowerCase().includes(query))); const pages=Math.max(1,Math.ceil(filtered.length/assignmentPageSize));assignmentPage=Math.min(assignmentPage,pages); const start=(assignmentPage-1)*assignmentPageSize,rows=filtered.slice(start,start+assignmentPageSize); document.querySelector('#assign-table tbody').innerHTML=rows.map(e=>`${notificationEsc(e.name)}PIN ${notificationEsc(e.pin)}`).join('')||`
No employees match this filter.
`; document.getElementById('assignment-count').textContent=filtered.length?`Showing ${start+1}-${Math.min(start+assignmentPageSize,filtered.length)} of ${filtered.length} employees`:'No employees found'; document.getElementById('assignment-prev').disabled=assignmentPage<=1;document.getElementById('assignment-next').disabled=assignmentPage>=pages; document.querySelectorAll('.assign-dept, .assign-shift').forEach(sel=>sel.addEventListener('change',async()=>{const row=sel.closest('tr');await api(`/employees/${encodeURIComponent(sel.dataset.pin)}/assign`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({department_id:row.querySelector('.assign-dept').value||null,shift_id:row.querySelector('.assign-shift').value||null})});toast('Assignment updated')})); }; document.getElementById('assignment-search').oninput=()=>{assignmentPage=1;renderAssignments()};assignmentLocation.onchange=()=>{assignmentPage=1;renderAssignments()};document.getElementById('assignment-prev').onclick=()=>{assignmentPage=Math.max(1,assignmentPage-1);renderAssignments()};document.getElementById('assignment-next').onclick=()=>{assignmentPage+=1;renderAssignments()};renderAssignments(); document.querySelectorAll(".edit-shift").forEach((b) => b.addEventListener("click", () => { openModal("Edit shift", [ { name: "name", label: "Shift name", value: b.dataset.name }, { name: "start_time", label: "Start time", type: "time", value: b.dataset.start }, { name: "end_time", label: "End time", type: "time", value: b.dataset.end }, { name: "grace_minutes", label: "Grace period (minutes)", type: "number", value: b.dataset.grace }, ], async (data) => { await api(`/shifts/${b.dataset.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }); loadDepartments(); toast("Shift updated"); }); })); document.querySelectorAll(".delete-shift").forEach((b) => b.addEventListener("click", async () => { if (!confirm("Delete this shift? Employees on it will be unassigned.")) return; await api(`/shifts/${b.dataset.id}`, { method: "DELETE" }); loadDepartments(); toast("Shift deleted"); })); } document.getElementById("add-department-btn").addEventListener("click", async () => { const [companies,branches,users,employees]=await Promise.all([api('/locations'),api('/company-locations'),api('/auth/users').catch(()=>[]),api('/employees')]); const managers=users.filter(u=>u.role==='manager'&&u.employee_pin); const backdrop=document.getElementById('modal-backdrop'),modal=document.getElementById('modal'); modal.innerHTML=`

Map department to locations

Create it once across selected branches. Choose a separate eligible manager for each location.

${companies.map(c=>{const rows=branches.filter(b=>Number(b.company_id)===Number(c.id));return rows.length?`
${notificationEsc(c.name)}${rows.map(b=>`
`).join('')}
`:''}).join('')}
`; backdrop.classList.add('open');modal.querySelector('.modal-cancel').onclick=()=>backdrop.classList.remove('open'); modal.querySelectorAll('.dept-branch').forEach(cb=>cb.onchange=()=>{modal.querySelector(`.dept-branch-manager[data-branch="${cb.value}"]`).disabled=!cb.checked}); modal.querySelector('.modal-save').onclick=async()=>{const name=document.getElementById('dept-name').value.trim(),selected=[...modal.querySelectorAll('.dept-branch:checked')];if(!name||!selected.length)return toast('Enter a department name and select at least one location');const mappings=selected.map(cb=>({company_id:Number(cb.dataset.company),company_location_id:Number(cb.value),manager_pin:modal.querySelector(`.dept-branch-manager[data-branch="${cb.value}"]`).value||null}));await api('/departments/bulk',post({name,mappings}));backdrop.classList.remove('open');loadDepartments();toast(`Department mapped to ${mappings.length} location${mappings.length===1?'':'s'}`)}; }); document.getElementById("add-shift-btn").addEventListener("click", () => { openModal("Add shift", [ { name: "name", label: "Shift name" }, { name: "start_time", label: "Start time", type: "time" }, { name: "end_time", label: "End time", type: "time" }, { name: "grace_minutes", label: "Grace period (minutes)", type: "number" }, ], async (data) => { await api("/shifts", post(data)); loadDepartments(); toast("Shift added"); }); }); // --------------------------------------------------------------------------- // Assets // --------------------------------------------------------------------------- let _assetsCache = []; async function loadAssets() { const [assets, summary] = await Promise.all([api("/assets"), api("/assets/summary")]); _assetsCache = assets; document.getElementById("asset-total").textContent = summary.total; document.getElementById("asset-assigned").textContent = summary.assigned; document.getElementById("asset-available").textContent = summary.available; document.getElementById("asset-retired").textContent = summary.retired; document.getElementById("asset-categories-note").textContent = summary.by_category.length ? summary.by_category.slice(0, 3).map((c) => `${c.name} ${c.count}`).join(" \u00b7 ") : "No categories yet"; // Assets still booked out to a PIN with no employee record - typically // someone who left without returning the kit. This is precisely how // equipment quietly disappears, so it gets a banner rather than a column. const warn = document.getElementById("asset-orphan-warning"); if (summary.orphaned > 0) { warn.textContent = `${summary.orphaned} asset${summary.orphaned === 1 ? " is" : "s are"} assigned to an employee record that no longer exists - likely not returned when they left.`; warn.classList.remove("hidden"); } else { warn.classList.add("hidden"); } ["asset-search", "asset-filter"].forEach((id) => { const el = document.getElementById(id); if (el && !el.dataset.bound) { el.addEventListener(el.tagName === "SELECT" ? "change" : "input", renderAssets); el.dataset.bound = "1"; } }); const exportBtn = document.getElementById("export-assets-btn"); if (exportBtn && !exportBtn.dataset.bound) { exportBtn.addEventListener("click", () => { window.location = tenantUrl("/api/assets/export"); }); exportBtn.dataset.bound = "1"; } renderAssets(); } function renderAssets() { const q = (document.getElementById("asset-search").value || "").trim().toLowerCase(); const filter = document.getElementById("asset-filter").value; let assets = _assetsCache; if (filter !== "all") assets = assets.filter((a) => a.status === filter); if (q) assets = assets.filter((a) => [a.name, a.serial_number, a.assigned_employee_name, a.category] .some((v) => String(v || "").toLowerCase().includes(q))); document.querySelector("#assets-table tbody").innerHTML = assets.map((a) => ` ${a.name}${a.category}${a.serial_number || "-"} ${a.status} ${a.assigned_employee_name || "-"} ${a.status !== "assigned" ? `` : ` `} `).join("") || `
${_assetsCache.length ? "No matching assets" : "Build your asset register"}

${_assetsCache.length ? "Try another status or search term." : "Add laptops, phones, access cards and other equipment, then assign them to employees with a complete custody trail."}

${_assetsCache.length ? "" : ''}`; const emptyAdd = document.getElementById("asset-empty-add"); if (emptyAdd) emptyAdd.onclick = openAddAssetModal; // Delete is permanent and loses the assignment history with it, so it warns // explicitly when the asset is still out with someone - "Retire" is almost // always what was actually meant in that case. document.querySelectorAll(".delete-asset").forEach((b) => b.addEventListener("click", async () => { const stillOut = b.dataset.status === "assigned"; const msg = stillOut ? `"${b.dataset.name}" is still assigned to someone.\n\nDeleting removes it permanently, including who had it. If it was lost or written off, use Retire instead - that keeps the record.\n\nDelete anyway?` : `Delete "${b.dataset.name}" permanently? This cannot be undone.`; if (!confirm(msg)) return; await api(`/assets/${b.dataset.id}`, { method: "DELETE" }); loadAssets(); toast("Asset deleted"); })); document.querySelectorAll(".assign-asset").forEach((b) => b.addEventListener("click", async () => { const employees = await api("/employees"); openModal("Assign asset", [ { name: "assigned_employee_pin", label: "Employee", type: "select", options: employees.map((e) => ({ value: e.pin, label: e.name })) }, ], async (data) => { await api(`/assets/${b.dataset.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: "assigned", assigned_employee_pin: data.assigned_employee_pin }) }); loadAssets(); toast("Asset assigned"); }); })); document.querySelectorAll(".transfer-asset").forEach((b) => b.addEventListener("click", async () => { const employees = await api("/employees"); openModal("Transfer asset", [ { name: "assigned_employee_pin", label: "New employee", type: "select", options: employees.map((e) => ({ value: e.pin, label: e.name })) }, ], async (data) => { await api(`/assets/${b.dataset.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: "assigned", assigned_employee_pin: data.assigned_employee_pin }) }); loadAssets(); toast("Asset transferred"); }); })); document.querySelectorAll(".unassign-asset").forEach((b) => b.addEventListener("click", async () => { await api(`/assets/${b.dataset.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: "available", assigned_employee_pin: null }) }); loadAssets(); toast("Asset returned"); })); document.querySelectorAll(".retire-asset").forEach((b) => b.addEventListener("click", async () => { await api(`/assets/${b.dataset.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: "retired", assigned_employee_pin: null }) }); loadAssets(); toast("Asset retired"); })); } document.getElementById("add-asset-btn").addEventListener("click", () => openAddAssetModal()); document.getElementById("asset-hero-add").addEventListener("click", () => openAddAssetModal()); async function openAddAssetModal() { const [categories, employees] = await Promise.all([api("/asset-categories"), api("/employees")]); const backdrop = document.getElementById("modal-backdrop"); const modal = document.getElementById("modal"); modal.innerHTML = `

Add asset

`; backdrop.classList.add("open"); modal.querySelector(".modal-cancel").onclick = () => backdrop.classList.remove("open"); modal.querySelector("#add-category-inline").onclick = async () => { const name = await showModal("New category name:", "Enter category name...", 1); if (!name) return; await api("/asset-categories", post({ name })); backdrop.classList.remove("open"); openAddAssetModal(); // reopen fresh so the new category shows in the list }; modal.querySelector(".modal-save").onclick = async () => { const name = document.getElementById("asset-name").value; if (!name) { toast("Asset name is required"); return; } await api("/assets", post({ name, category: document.getElementById("asset-category").value, serial_number: document.getElementById("asset-serial").value, purchase_date: document.getElementById("asset-purchase-date").value, assigned_employee_pin: document.getElementById("asset-assignee").value, })); backdrop.classList.remove("open"); loadAssets(); toast("Asset added"); }; } // --------------------------------------------------------------------------- // Holidays // --------------------------------------------------------------------------- async function loadHolidays() { const holidays = await api("/holidays"); document.querySelector("#holidays-table tbody").innerHTML = holidays.map((h) => ` ${h.date}${h.name}${h.location_name || "All companies"} ${h.recurring ? "Yearly" : "One-time"} `).join("") || `
🏖

No holidays set

Add your company holidays so they are not counted as absences in payroll.

`; document.querySelectorAll(".delete-holiday").forEach((b) => b.addEventListener("click", async () => { await api(`/holidays/${b.dataset.id}`, { method: "DELETE" }); loadHolidays(); toast("Holiday removed"); })); } document.getElementById("add-holiday-btn").addEventListener("click", async () => { const locations = await api("/locations"); openModal("Add holiday", [ { name: "date", label: "Date", type: "date" }, { name: "name", label: "Holiday name" }, { name: "location_id", label: "Company (blank = all)", type: "select", options: locations.map((l) => ({ value: l.id, label: l.name })) }, { name: "recurring", label: "Repeats every year? (yes/no)" }, ], async (data) => { await api("/holidays", post({ ...data, recurring: data.recurring?.toLowerCase().startsWith("y") })); loadHolidays(); toast("Holiday added"); }); }); // --------------------------------------------------------------------------- // Attendance + rules // --------------------------------------------------------------------------- async function loadAttendance() { await loadFieldPunchApprovals(); const todayRows = await api("/today-status"); if (Array.isArray(todayRows)) { const count = status => todayRows.filter(r => r.status === status).length; document.getElementById("att-kpi-present").textContent = count("present") + count("late"); document.getElementById("att-kpi-absent").textContent = count("absent"); document.getElementById("att-kpi-leave").textContent = count("leave"); document.getElementById("att-kpi-late").textContent = count("late"); } const fieldCountText = document.getElementById("field-approval-count")?.textContent || "0"; document.getElementById("att-kpi-field").textContent = (fieldCountText.match(/\d+/) || ["0"])[0]; // Populate employee select const empSel = document.getElementById("att-emp-select"); if (empSel.options.length <= 1) { const emps = await api("/employees"); empSel.innerHTML = `` + emps.map((e) => ``).join(""); } // Default month picker to current month const attMonth = document.getElementById("att-month"); if (!attMonth.value) attMonth.value = localDateStr(new Date()).slice(0, 7); const select = document.getElementById("filter-location"); if (select.options.length <= 1) { const locations = await api("/locations"); select.innerHTML += locations.map((l) => ``).join(""); const wdSelect = document.getElementById("wd-location"); wdSelect.innerHTML += locations.map((l) => ``).join(""); } const rules = await api("/attendance-rules"); const freeDays = rules.allowed_late_days ?? 0; const pct = rules.late_deduction_percent ?? 0; const perDay = rules.late_deduction_mode !== "flat"; const slabText = pct > 0 ? `Late: ${freeDays} free day${freeDays === 1 ? "" : "s"}/month, then ${pct}% of one day's pay${perDay ? " per late day" : " once"}` + (rules.late_deduction_max_percent ? ` (max ${rules.late_deduction_max_percent}% of salary)` : "") : "No late deduction"; document.getElementById("active-rule-summary").textContent = `Active rule: Shift ${rules.shift_start}–${rules.shift_end} · Grace ${rules.grace_minutes} min · Full day ${rules.full_day_hours}h · Half day ${rules.half_day_hours}h · ${slabText}`; const rangeInfo = currentUser?.role==='hr' ? {total:0} : await api("/attendance/date-range"); const hint = document.getElementById("wd-data-range-hint"); if (!rangeInfo.total) { hint.textContent = "No attendance data recorded yet."; } else { hint.textContent = `Data on file: ${rangeInfo.earliest} to ${rangeInfo.latest} (${rangeInfo.total} punches total).`; } const date = document.getElementById("filter-date").value; const locationId = select.value; const qs = new URLSearchParams(); if (date) qs.set("date", date); if (locationId) qs.set("location_id", locationId); const logs = await api(`/attendance?${qs.toString()}`); document.getElementById("raw-punch-count").textContent = `(${logs.length} record${logs.length === 1 ? "" : "s"})`; document.querySelector("#attendance-table tbody").innerHTML = logs.map((l) => ` ${l.employee_name ?? l.employee_pin}${l.location_name ?? "-"}${l.punch_time}${l.device_sn}` ).join("") || `No punches recorded for this filter.`; } async function loadFieldPunchApprovals() { const status = document.getElementById("field-punch-status").value; const rows = await api(`/field-attendance?status=${encodeURIComponent(status)}`); document.getElementById("field-approval-count").textContent = `(${rows.length} ${status === "all" ? "records" : status})`; document.querySelector("#field-approvals-table tbody").innerHTML = rows.map((r) => { const map = `https://www.google.com/maps?q=${encodeURIComponent(r.latitude + "," + r.longitude)}`; const selfie = r.has_selfie ? `View image` : `Not provided`; const review = r.approval_status === "pending" ? `
` : `${notificationEsc(r.approval_status)}
${r.reviewed_by ? `${notificationEsc(r.reviewed_by_role || "Reviewer")} ${notificationEsc(r.reviewed_by)}
${notificationEsc(r.reviewed_at || "")}` : ""}${r.rejection_reason ? `
${notificationEsc(r.rejection_reason)}` : ""}
`; return ` ${notificationEsc(r.employee_name || r.employee_pin)}
${notificationEsc([r.company_name,r.company_location_name,r.designation].filter(Boolean).join(" · "))} ${r.action === "in" ? "PUNCH IN" : "PUNCH OUT"}
${notificationEsc(r.punch_time)} ${notificationEsc(r.work_location || "Field")}
${notificationEsc(r.note || "No note")} Open map
Accuracy: ${Math.round(Number(r.accuracy || 0))} m ${selfie} ${review} `; }).join("") || `No field punch records for this filter.`; } async function reviewFieldPunch(id, status) { let reason = ""; if (status === "rejected") reason = await showModal("Reason for rejecting this field punch:", "Optional reason...", 0) || ""; await api(`/field-attendance/${id}`, {method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({status,reason})}); toast(`Field punch ${status}. ${status === "approved" ? "It is now included in attendance." : "It will not count in attendance."}`); await loadFieldPunchApprovals(); loadNotifications(); } document.getElementById("refresh-field-approvals").addEventListener("click", loadFieldPunchApprovals); document.getElementById("field-punch-status").addEventListener("change", loadFieldPunchApprovals); // Per-employee attendance lookup const STATUS_COLOR = { P:"#DDF3E4", A:"#FBD9D9", WO:"#E9ECF1", HO:"#DCE7FB", L:"#EADBF7", HALF:"#FFF3CF", WOP:"#CFF0EC", LA:"#F4E4D6", LC:"#FFE0B2", FUT:"#F6F7F9", }; function statusColor(s) { if (!s || s === "-") return STATUS_COLOR.FUT; if (s.startsWith("WOP")) return STATUS_COLOR.WOP; if (s.startsWith("WO")) return STATUS_COLOR.WO; if (s.startsWith("HO")) return STATUS_COLOR.HO; if (s.startsWith("L½") || s.includes("A½")) return STATUS_COLOR.LA; if (s.startsWith("L")) return STATUS_COLOR.L; if (s.startsWith("A")) return STATUS_COLOR.A; if (s.includes("½")) return STATUS_COLOR.HALF; if (s.includes("LC")) return STATUS_COLOR.LC; return STATUS_COLOR.P; } function statusLabel(s) { if (!s || s === "-") return ""; if (s.startsWith("WOP")) return "W·P"; if (s.startsWith("WO")) return "WO"; if (s.startsWith("HO")) return "HO"; if (s.startsWith("L½") || s.includes("A½")) return "L½"; if (s.startsWith("L")) return "L"; if (s.startsWith("A")) return "A"; if (s.includes("P½") || s.includes("½")) return "½"; if (s.includes("LC")) return "P·LC"; return "P"; } async function lookupEmployeeAttendance() { const pin = document.getElementById("att-emp-select").value; const month = document.getElementById("att-month").value || localDateStr(new Date()).slice(0, 7); const res = document.getElementById("att-emp-result"); if (!pin) { res.innerHTML = `

Select an employee first.

`; return; } res.innerHTML = `

Loading…

`; const data = await api(`/reports/work-duration?month=${month}&pin=${encodeURIComponent(pin)}&detail=1`); // Fallback: use detailed grid report for a single employee const qs = new URLSearchParams({ month }); const grid = await api(`/payroll?month=${month}`); const payRow = (grid.rows || []).find((r) => r.pin === pin); // Build a per-day status grid from the detailed endpoint const [y, m] = month.split("-").map(Number); const daysInMonth = new Date(y, m, 0).getDate(); const DAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]; // Fetch per-employee classification via the attendance reports endpoint const det = await api(`/reports/employee-month?pin=${encodeURIComponent(pin)}&month=${month}`); const days = (det && det.days) ? det.days : []; // Build the grid HTML let cells = ""; for (let d = 1; d <= daysInMonth; d++) { const dayRec = days.find((x) => x.day === d) || {}; const s = dayRec.status || (new Date(y, m - 1, d).toISOString().slice(0, 10) > localDateStr(new Date()) ? "-" : ""); const dow = new Date(y, m - 1, d).getDay(); const bg = statusColor(s); const label = statusLabel(s); const note = dayRec.note ? ` title="${dayRec.note.replace(/"/g,'"')}"` : ""; cells += `
${d}
${DAYS[dow]}
${label||"–"}
`; } const absent = days.filter((d) => d.status === "A"); const absentDates = absent.map((d) => { const dt = new Date(y, m - 1, d.day); return `${d.day}-${DAYS[dt.getDay()]}`; }).join(", "); const sumKeys = ["present","halfDays","absent","weeklyOff","holidays","leavesTaken","lateByDays"]; const summary = det && det.summary ? det.summary : {}; res.innerHTML = `
${[["Present", summary.present ?? "–", "#DDF3E4"], ["Half", summary.halfDays ?? "–", "#FFF3CF"], ["Absent", summary.absent ?? "–", "#FBD9D9"], ["WO", summary.weeklyOff ?? "–", "#E9ECF1"], ["Leave", summary.leavesTaken ?? "–", "#EADBF7"], ["Late", summary.lateByDays ?? "–", "#FFE0B2"]].map(([lbl, val, bg]) => `
${val}
${lbl}
` ).join("")}
${absent.length ? `
Absent dates: ${absentDates}
` : ""}
${cells}

Hover a cell with a reason (like LC or ½) to see why.

`; } document.getElementById("att-lookup-btn").addEventListener("click", lookupEmployeeAttendance); document.getElementById("att-month").addEventListener("change", () => { if (document.getElementById("att-emp-select").value) lookupEmployeeAttendance(); }); document.getElementById("filter-date").addEventListener("change", loadAttendance); document.getElementById("filter-location").addEventListener("change", loadAttendance); document.getElementById("raw-punch-toggle").addEventListener("click", () => { const body = document.getElementById("raw-punch-body"); const arrow = document.getElementById("raw-punch-arrow"); const open = body.style.display !== "none"; body.style.display = open ? "none" : ""; arrow.textContent = open ? "▸" : "▾"; }); document.getElementById("attendance-rules-btn").addEventListener("click", async () => { const r = await api("/attendance-rules"); const backdrop = document.getElementById("modal-backdrop"); const modal = document.getElementById("modal"); const num = (name, label, val) => ``; const days = ["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"]; modal.innerHTML = `

Attendance rules

The essentials are below — most businesses never need to touch anything past this.

The normal working hours for your team.

Arriving within this many minutes of shift start doesn't count as late at all — e.g. 15 means 9:00–9:15 is treated the same as arriving exactly on time.

If someone works fewer hours than this in a day, it's marked half-day instead of full present — affects both reports and the half-day salary deduction.

${(() => { // Multiple weekly-off days: Saturday + Sunday is the common case, so this // is a set of tick boxes rather than a single dropdown. Pre-tick from the // stored comma-separated list, falling back to the legacy single day. const current = (r.weekly_off_days != null && String(r.weekly_off_days).trim() !== "") ? String(r.weekly_off_days).split(",").map((n) => parseInt(n, 10)) : [r.weekly_off_day ?? 0]; const cur = new Set(current.filter((n) => n >= 0 && n <= 6)); return `
${days.map((d, i) => ``).join("")}
`; })()}

Tick every day your team is off — e.g. Saturday and Sunday. These days are never counted as absent, and working on one is handled by the rule further down.

Late arrival policy

The default every company inherits. A company can override it under Companies › Settings.

${num("allowed_late_days","Free late days per month",r.allowed_late_days)}${num("late_deduction_percent","Late deduction %",r.late_deduction_percent)}
The % is always of one day's pay, never of the month's salary.
${num("late_deduction_max_percent","Never deduct more than (% of monthly salary)",r.late_deduction_max_percent)}

Example: on ₹50,000 over 26 working days, one day is ₹1,923, so 25% per late day is about ₹481. Ten chargeable late days would be ₹4,808.
Late deductions count as fines under the Payment of Wages Act, which caps them at 3% of wages — ₹1,500 on that salary. Keep the maximum low and check it with your CA.

`; backdrop.classList.add("open"); modal.querySelector("#ar-advanced-toggle").addEventListener("click", (e) => { const section = modal.querySelector("#ar-advanced-section"); const isHidden = section.style.display === "none"; section.style.display = isHidden ? "" : "none"; e.target.textContent = isHidden ? "⚙ Hide advanced settings ▴" : "⚙ Show advanced settings ▾"; }); modal.querySelectorAll(".early-preset").forEach((b) => b.addEventListener("click", () => { document.getElementById("ar-early-window").value = b.dataset.min; })); modal.querySelector(".modal-cancel").onclick = () => backdrop.classList.remove("open"); modal.querySelector(".modal-save").onclick = async () => { // Holidays and weekly offs are always treated as paid by the engine, so // these are sent as constants rather than exposed as toggles that do nothing. // Holidays and weekly offs are always treated as paid by the engine, so // these are sent as constants rather than exposed as toggles that do nothing. const data = { count_holiday: true, count_weekly_off: true }; // The two ${active.map((e) => ``).join("")}
`; backdrop.classList.add("open"); modal.querySelector(".modal-cancel").onclick = () => backdrop.classList.remove("open"); document.getElementById("gp-save").onclick = async () => { const pin = document.getElementById("gp-pin").value; if (!pin) { toast("Select an employee"); return; } const body = { employee_pin: pin, pass_date: document.getElementById("gp-date").value, type: document.getElementById("gp-type").value, out_time: document.getElementById("gp-out").value, expected_in: document.getElementById("gp-in").value, reason: document.getElementById("gp-reason").value, status: document.getElementById("gp-approve-now").value, }; const approver = currentUser?.name || "Admin"; const res = await api("/gatepasses", post(body)); // If created as approved, stamp the approver name in a follow-up PATCH. if (body.status === "approved" && res.id) { await api(`/gatepasses/${res.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: "approved", approved_by: approver }) }); } backdrop.classList.remove("open"); loadGatepasses(); toast("Gate pass created"); }; } // --------------------------------------------------------------------------- // Payroll // --------------------------------------------------------------------------- async function loadPayroll() { const month = localDateStr(new Date()).slice(0, 7); const data = await api(`/payroll?month=${month}`); // A failed request comes back as an error object with no totals/rows. Left // unchecked, reading data.totals.gross throws and the payslip table is left // showing its loading skeleton forever with no explanation. Surface the real // problem instead so it can be acted on. if (!data || !data.totals || !Array.isArray(data.rows)) { const tbody = document.querySelector("#payroll-table tbody"); if (tbody) { tbody.innerHTML = `
⚠️
` + `

Could not load payroll

` + `

${(data && data.error) ? String(data.error) : "Please refresh and try again."}

`; } if (data && data.error) toast(data.error); return; } document.getElementById("payroll-gross").textContent = "₹" + data.totals.gross.toLocaleString("en-IN"); document.getElementById("payroll-deduction").textContent = "₹" + data.totals.deduction.toLocaleString("en-IN"); document.getElementById("payroll-overtime").textContent = "₹" + data.totals.overtime.toLocaleString("en-IN"); document.getElementById("payroll-pf-employee").textContent = "₹" + data.totals.pf_employee.toLocaleString("en-IN"); document.getElementById("payroll-statutory").textContent = "₹" + (data.totals.esi_employee + data.totals.professional_tax + data.totals.tds).toLocaleString("en-IN"); document.getElementById("payroll-net").textContent = "₹" + data.totals.net.toLocaleString("en-IN"); document.getElementById("payroll-count").textContent = `${data.rows.length} employees`; // "Not recovered" card. Hidden entirely when nothing is outstanding, so it // reads as an exception worth acting on rather than another zero to skim // past. Counts the employees affected, because one person owing a large // amount and twenty owing small ones need different responses. const shortfall = data.totals.shortfall || 0; const card = document.getElementById("payroll-shortfall-card"); if (shortfall > 0) { const affected = data.rows.filter((r) => (r.shortfall || 0) > 0).length; document.getElementById("payroll-shortfall").textContent = "\u20B9" + shortfall.toLocaleString("en-IN"); document.getElementById("payroll-shortfall-note").textContent = `${affected} employee${affected === 1 ? "" : "s"} - deductions exceeded pay`; card.style.display = ""; } else { card.style.display = "none"; } document.getElementById("payroll-month").textContent = `Payroll for ${month}`; document.getElementById("payroll-policy-summary").innerHTML = `PF: ${data.payrollSettings.pf_enabled ? data.payrollSettings.pf_employee_percent + "% / " + data.payrollSettings.pf_employer_percent + "%" : "disabled"} · ` + `ESI: ${data.payrollSettings.esi_enabled ? "on" : "disabled"} · ` + `PT: ${data.payrollSettings.professional_tax_enabled ? "₹" + data.payrollSettings.professional_tax_monthly + "/mo" : "disabled"} · ` + `TDS: ${data.payrollSettings.tds_enabled ? data.payrollSettings.tds_percent + "%" : "disabled"} · ` + `Overtime: ${data.payrollSettings.overtime_enabled ? data.payrollSettings.overtime_multiplier + "x beyond " + data.payrollSettings.standard_daily_hours + "h/day" : "disabled"}`; document.querySelector("#payroll-table tbody").innerHTML = data.rows.map((r) => ` ${r.name}₹${r.gross.toLocaleString("en-IN")} ₹${r.late_deduction.toLocaleString("en-IN")} ₹${r.half_day_deduction.toLocaleString("en-IN")} ₹${r.absent_deduction.toLocaleString("en-IN")} ₹${r.unpaid_leave_deduction.toLocaleString("en-IN")} ₹${r.overtime_pay.toLocaleString("en-IN")} ₹${r.pf_employee.toLocaleString("en-IN")} ₹${r.esi_employee.toLocaleString("en-IN")} ₹${r.professional_tax.toLocaleString("en-IN")} ₹${r.tds.toLocaleString("en-IN")} ${r.components_total >= 0 ? "+" : ""}₹${r.components_total.toLocaleString("en-IN")} ₹${r.advance_deduction.toLocaleString("en-IN")} ${(r.shortfall || 0) > 0 ? ` 0 ? `Statutory dues funded by employer: \u20B9${r.statutory_shortfall.toLocaleString("en-IN")}` : "", ].filter(Boolean).join(" | ")}">₹${r.shortfall.toLocaleString("en-IN")}` : ``} ₹${r.net.toLocaleString("en-IN")} `).join("") || `

Nothing to pay yet

Payroll needs employees who are active, assigned to a company, and have a salary set.

`; document.querySelectorAll("#payroll-table .employee-actions-btn, #payroll-table .employee-actions-link").forEach((el) => { el.addEventListener("click", (e) => { e.preventDefault(); openEmployeeActionsModal(el.dataset.pin, el.dataset.name); }); }); } // Reloads the payroll table, but only when the payroll view is the one on // screen. The employee actions modal opens from several places, and calling // loadPayroll() from a view that isn't payroll would fire a needless request // and stamp figures into a table the user isn't looking at. function refreshPayrollIfOpen() { const view = document.getElementById("view-payroll"); if (view && !view.classList.contains("hidden")) loadPayroll(); } async function openEmployeeActionsModal(pin, name) { const advances = await api(`/advances?pin=${encodeURIComponent(pin)}`); const backdrop = document.getElementById("modal-backdrop"); const modal = document.getElementById("modal"); // Current month plus the previous 11 — covers a full year back, which is // plenty for reprinting an old payslip (e.g. an employee asking for last // March's, or one that got lost/deleted from their downloads). const months = []; const now = new Date(); for (let i = 0; i < 12; i++) { const dt = new Date(now.getFullYear(), now.getMonth() - i, 1); const val = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, "0")}`; const label = dt.toLocaleString("en-IN", { month: "long", year: "numeric" }); months.push({ value: val, label }); } modal.innerHTML = `

${name}

Payslip

Advances & debits

Interest-free, repaid evenly over one or more months with no charges. A debit works the same way as an advance — use it to recover the cost of damage or loss.

${advances.length ? ` ${advances.map((a) => ` `).join("")}
TYPEAMOUNTEMISTARTSREPAIDLEFTSTATUS
${a.type === "debit" ? "Debit" : "Advance"} ₹${Number(a.amount).toLocaleString("en-IN")} ${a.emi_months} mo ${a.start_month} ₹${a.repaid_so_far.toLocaleString("en-IN")} ₹${a.remaining.toLocaleString("en-IN")} ${String(a.status).replaceAll('_',' ')}${a.disbursed_by?`
Paid by ${a.disbursed_by}`:''}
${a.can_approve ? `` : ""} ${a.status === "active" ? `` : ""}
` : `
No advances or debits yet.
`}
`; backdrop.classList.add("open"); modal.querySelector(".modal-cancel").onclick = () => backdrop.classList.remove("open"); document.getElementById("payslip-download-btn").onclick = () => { const month = document.getElementById("payslip-month-select").value; window.open(tenantUrl(`/api/payslip.pdf?pin=${encodeURIComponent(pin)}&month=${month}`), "_blank"); }; modal.querySelectorAll(".cancel-advance").forEach((b) => b.addEventListener("click", async () => { await api(`/advances/${b.dataset.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: "cancelled" }), }); // loadPayroll() here for the same reason it is called when ADDING an // advance: cancelling one changes this month's deduction and net pay, so // leaving the payroll table showing pre-change figures makes it look like // the action did nothing. backdrop.classList.remove("open"); openEmployeeActionsModal(pin, name); refreshPayrollIfOpen(); })); modal.querySelectorAll(".approve-advance,.reject-advance").forEach((b) => b.addEventListener("click", async () => { const action=b.classList.contains('approve-advance')?'approved':'rejected'; await api(`/advances/${b.dataset.id}`, {method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({action})}); backdrop.classList.remove("open"); openEmployeeActionsModal(pin, name); refreshPayrollIfOpen(); })); modal.querySelectorAll(".advance-ledger").forEach((b) => b.addEventListener("click", async () => { const entries=await api(`/advances/${b.dataset.id}/transactions`); alert(entries.length?entries.map(x=>`${x.created_at} · ${x.transaction_type.replaceAll('_',' ')} · ₹${Number(x.amount).toLocaleString('en-IN')} · ${x.recorded_by}${x.payment_reference?' · '+x.payment_reference:''}`).join('\n'):'No transactions yet'); })); document.getElementById("add-advance-btn").onclick = async () => { const type = document.getElementById("adv-type").value; const amount = Number(document.getElementById("adv-amount").value); const emi_months = Number(document.getElementById("adv-emi").value) || 1; const start_month = document.getElementById("adv-start").value; const notes = document.getElementById("adv-notes").value; if (!amount) { toast("Amount is required"); return; } await api("/advances", post({ employee_pin: pin, type, amount, emi_months, start_month, notes })); backdrop.classList.remove("open"); openEmployeeActionsModal(pin, name); refreshPayrollIfOpen(); toast(type === "debit" ? "Debit added" : "Advance added"); }; } document.getElementById("settings-statutory-btn-2").addEventListener("click", () => document.getElementById("settings-statutory-btn").click()); document.getElementById("export-all-payslips-btn").addEventListener("click", () => { const month = localDateStr(new Date()).slice(0, 7); window.open(tenantUrl(`/api/payslips-bulk.pdf?month=${month}`), "_blank"); }); document.getElementById("salary-structure-btn").addEventListener("click", async () => { const s = await api("/payroll-settings"); openModal("Salary structure", [ { name: "basic_percent_of_gross", label: "Basic (% of Gross salary)", type: "number", value: s.basic_percent_of_gross ?? 50 }, { name: "hra_percent_of_basic", label: "HRA (% of Basic — 40% non-metro / 50% metro is typical)", type: "number", value: s.hra_percent_of_basic ?? 40 }, { name: "conveyance_allowance", label: "Conveyance Allowance (₹/month, flat)", type: "number", value: s.conveyance_allowance ?? 1600 }, { name: "medical_allowance", label: "Medical Allowance (₹/month, flat)", type: "number", value: s.medical_allowance ?? 1250 }, ], async (data) => { await api("/payroll-settings", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...s, ...data }), }); loadPayroll(); toast("Salary structure saved — payslips and PF now use these figures"); }); }); document.getElementById("payroll-settings-btn").addEventListener("click", async () => { const s = await api("/payroll-settings"); openModal("Overtime & absence settings", [ { name: "salary_method", label: "Salary calculation method", type: "select", value: s.salary_method || "monthly", options: [ { value: "monthly", label: "Monthly salary — full amount minus absent/half deductions (standard)" }, { value: "lop", label: "Loss of Pay (LOP) — pay only for days present + weekly off + leave" }, ] }, { type: "note", label: "Monthly: Rs 20,000 employee absent 3 days → Rs 20,000 − Rs 1,935 = Rs 17,742 net.
LOP: Present 16 + WO 3 + Half 0.5 = 19.5 days × Rs 645 = Rs 12,580 net. Best for contractual staff or mid-month payroll." }, { name: "overtime_enabled", label: "Overtime pay", type: "select", value: s.overtime_enabled ? "yes" : "no", options: [ { value: "yes", label: "Yes \u2014 pay overtime beyond standard hours" }, { value: "no", label: "No \u2014 do not pay any overtime" }, ] }, { name: "overtime_multiplier", label: "Overtime rate (1 = normal hourly, 1.5, 2 = double)", type: "number", value: s.overtime_multiplier }, { name: "standard_daily_hours", label: "Standard hours/day (overtime is paid only beyond this)", type: "number", value: s.standard_daily_hours }, { type: "note", label: "When overtime is on, any time worked beyond the standard hours on a normal working day is paid at the rate above (e.g. 8 hrs standard, worked 10 → 2 hrs OT). When off, extra hours are never paid \u2014 OT always shows as \u20B90. This is separate from working on a weekly off / Sunday or a holiday, which is controlled by \u201cWorking on a weekly off or holiday\u201d under Attendance \u203a Attendance rules." }, { name: "working_days_per_month", label: "Working days / month (0 or blank = automatic, uses actual days of each month)", type: "number", value: s.working_days_per_month }, { name: "half_day_deduction_percent", label: "Half-day deduction (% of one day's pay)", type: "number", value: s.half_day_deduction_percent ?? 50 }, { name: "absent_deduction_percent", label: "Absent-day deduction (% of one day's pay)", type: "number", value: s.absent_deduction_percent ?? 100 }, { name: "paid_leaves_per_year", label: "Paid leaves per year (when granted all at once)", type: "number", value: s.paid_leaves_per_year ?? 12 }, { name: "leave_accrual_mode", label: "How paid leave is granted", type: "select", value: s.leave_accrual_mode || "annual", options: [ { value: "annual", label: "All at once in January" }, { value: "monthly", label: "Accrues each month" }, ] }, { name: "paid_leaves_per_month", label: "Days earned per month (accrual mode)", type: "number", value: s.paid_leaves_per_month ?? 1.5 }, { name: "leave_accrual_start", label: "New joiner leave entitlement starts from", type: "select", value: s.leave_accrual_start || "joining_month", options: [ { value: "joining_month", label: "Joining month — prorate entitlement (recommended)" }, { value: "calendar_year", label: "January — grant the same entitlement regardless of joining date" }, ] }, { name: "leave_on_rest_day", label: "Leave overlapping a holiday / weekly off", type: "select", value: s.leave_on_rest_day || "exclude", options: [ { value: "exclude", label: "Do not consume leave balance (recommended)" }, { value: "consume", label: "Consume leave balance" }, ] }, { name: "partial_leave_balance", label: "When only part of paid leave remains", type: "select", value: s.partial_leave_balance || "split", options: [ { value: "split", label: "Split into paid and unpaid fractions" }, { value: "full_unpaid", label: "Treat the whole requested day as unpaid" }, ] }, { name: "statutory_wage_basis", label: "PF / ESI wage basis", type: "select", value: s.statutory_wage_basis || "earned", options: [ { value: "earned", label: "Wages earned after LOP/absence (recommended)" }, { value: "contractual", label: "Contractual monthly wages before attendance deductions" }, ] }, ], async (data) => { await api("/payroll-settings", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...s, ...data, overtime_enabled: data.overtime_enabled?.toLowerCase().startsWith("y"), }), }); loadPayroll(); toast("Overtime settings saved"); }); }); document.getElementById("jump-late-arrivals-btn").addEventListener("click", () => switchView("latearrivals")); document.getElementById("jump-missing-punches-btn").addEventListener("click", () => switchView("missingpunches")); document.getElementById("jump-reports-btn").addEventListener("click", () => switchView("reports")); // --------------------------------------------------------------------------- // Work duration report (In/Out/Total per employee per day) // --------------------------------------------------------------------------- function wdRange() { const from = document.getElementById("wd-from").value; const to = document.getElementById("wd-to").value; const location_id = document.getElementById("wd-location").value; const qs = new URLSearchParams(); if (from) qs.set("from", from); if (to) qs.set("to", to); if (location_id) qs.set("location_id", location_id); return qs; } async function loadWorkDurationTable() { /* Flat list preview was removed — the range picker now only feeds report-card downloads. */ } document.getElementById("wd-grid-btn").addEventListener("click", () => { window.location.href = tenantUrl(`/api/reports/monthly-grid.xlsx?${gridReportParams().toString()}`); }); document.getElementById("wd-grid-pdf-btn").addEventListener("click", () => { window.location.href = tenantUrl(`/api/reports/monthly-grid.pdf?${gridReportParams().toString()}`); }); document.getElementById("wd-detailed-grid-btn").addEventListener("click", () => { window.location.href = tenantUrl(`/api/reports/detailed-grid.xlsx?${gridReportParams().toString()}`); }); document.getElementById("wd-attsummary-btn").addEventListener("click", () => { window.location.href = tenantUrl(`/api/reports/attendance-summary.xlsx?${gridReportParams().toString()}`); }); document.getElementById("wd-attsummary-pdf-btn").addEventListener("click", () => { window.location.href = tenantUrl(`/api/reports/attendance-summary.pdf?${gridReportParams().toString()}`); }); document.getElementById("wd-absence-btn").addEventListener("click", () => { window.location.href = tenantUrl(`/api/reports/absence.xlsx?${gridReportParams().toString()}`); }); document.getElementById("wd-absence-pdf-btn").addEventListener("click", () => { window.location.href = tenantUrl(`/api/reports/absence.pdf?${gridReportParams().toString()}`); }); document.getElementById("wd-late-btn").addEventListener("click", () => { window.location.href = tenantUrl(`/api/reports/late-arrivals.xlsx?${gridReportParams().toString()}`); }); document.getElementById("wd-late-pdf-btn").addEventListener("click", () => { window.location.href = tenantUrl(`/api/reports/late-arrivals.pdf?${gridReportParams().toString()}`); }); document.getElementById("wd-late-excused-btn").addEventListener("click", () => { window.location.href = tenantUrl(`/api/reports/late-arrivals.xlsx?${gridReportParams().toString()}&status=approved`); }); document.getElementById("wd-detailed-grid-pdf-btn").addEventListener("click", () => { window.location.href = tenantUrl(`/api/reports/detailed-grid.pdf?${gridReportParams().toString()}`); }); function gridReportParams() { // The Reports Hub month picker is the source of truth; wd-from is the // hidden fallback kept for backward compatibility. const hubMonth = document.getElementById("hub-month"); const month = (hubMonth && hubMonth.value) || (document.getElementById("wd-from").value || localDateStr(new Date())).slice(0, 7); const location_id = (document.getElementById("hub-location") || {}).value || document.getElementById("wd-location").value; const qs = new URLSearchParams({ month }); if (location_id) qs.set("location_id", location_id); return qs; } // Default to today on load (() => { const today = localDateStr(new Date()); document.getElementById("wd-from").value = today; document.getElementById("wd-to").value = today; })(); document.getElementById("back-to-attendance-btn").addEventListener("click", () => switchView("attendance")); document.getElementById("back-to-attendance-btn-2").addEventListener("click", () => switchView("attendance")); // --------------------------------------------------------------------------- // Device users // --------------------------------------------------------------------------- async function loadDeviceUsers() { const [statuses, devices, employees] = await Promise.all([api("/device-users"), api("/devices"), api("/employees")]); document.getElementById("du-device-select").innerHTML = devices.map((d) => ``).join(""); document.getElementById("du-employee-select").innerHTML = employees.map((e) => ``).join(""); document.querySelector("#deviceusers-table tbody").innerHTML = statuses.map((s) => ` ${s.device_name ?? s.device_sn}${s.employee_name ?? s.employee_pin} ${s.enrolled ? "Yes" : "No"}${s.blocked ? "Yes" : "No"}${s.updated_at}` ).join("") || `No device-user actions queued yet.`; loadDeviceCommands(); } async function loadDeviceCommands() { const cmds = await api("/device-commands"); document.querySelector("#device-commands-table tbody").innerHTML = cmds.map((c) => { // A command is "fetched" once the device polled and took it (sent=1). A // non-empty result_code means the device reported back after running it. const fetched = c.sent ? `Yes` : `Waiting`; const reply = c.completed_at ? `${c.result_code === "0" || c.result_code === "" ? "OK" : "code " + c.result_code}` : ``; // Show a friendly label instead of the raw ADMS string. const label = c.command_text.startsWith("DATA UPDATE USERINFO") ? "Create/update user" : c.command_text.startsWith("DATA DELETE USERINFO") ? "Delete user" : c.command_text; return `${c.created_at}${c.device_sn}${label}${fetched}${reply}`; }).join("") || `No commands sent to any device yet.`; } document.getElementById("du-refresh-commands").addEventListener("click", loadDeviceCommands); async function queueDeviceUserCommand(action) { const device_sn = document.getElementById("du-device-select").value; const employee_pin = document.getElementById("du-employee-select").value; if (!device_sn || !employee_pin) return; await api("/device-users/command", post({ device_sn, employee_pin, action })); loadDeviceUsers(); toast(`${action[0].toUpperCase()}${action.slice(1)} queued — device will pick it up on next check-in`); } document.getElementById("du-enroll-btn").addEventListener("click", () => queueDeviceUserCommand("enroll")); document.getElementById("du-delete-btn").addEventListener("click", () => queueDeviceUserCommand("delete")); document.getElementById("du-block-btn").addEventListener("click", () => queueDeviceUserCommand("block")); document.getElementById("du-unblock-btn").addEventListener("click", () => queueDeviceUserCommand("unblock")); // --------------------------------------------------------------------------- // Live feed // --------------------------------------------------------------------------- async function loadLive() { const logs = await api("/live"); document.querySelector("#live-table tbody").innerHTML = logs.map((l) => ` ${l.employee_name ?? l.employee_pin}${l.location_name ?? "-"}${l.punch_time}` ).join("") || `No punches in the last 15 minutes.`; } setInterval(() => { if (!document.getElementById("view-live").classList.contains("hidden")) loadLive(); }, 10000); // --------------------------------------------------------------------------- // Reports // --------------------------------------------------------------------------- async function loadReports() { // Populate the two location dropdowns (both used by report cards on this hub) // and default the month picker to the current month on first open. const hubMonth = document.getElementById("hub-month"); if (hubMonth && !hubMonth.value) hubMonth.value = localDateStr(new Date()).slice(0, 7); const locSelects = [document.getElementById("hub-location"), document.getElementById("report-location")].filter(Boolean); for (const sel of locSelects) { if (sel.options.length <= 1) { const locations = await api("/locations"); sel.innerHTML += locations.map((l) => ``).join(""); } } // Keep the hidden wd-from / wd-location in sync so any older code path that // still reads them (e.g. the punch-log CSV filters) gets the hub's values. const syncHub = () => { const m = document.getElementById("hub-month").value || localDateStr(new Date()).slice(0, 7); document.getElementById("wd-from").value = `${m}-01`; document.getElementById("wd-to").value = `${m}-28`; document.getElementById("wd-location").value = document.getElementById("hub-location").value; // The Payroll cards still read from `report-payroll-month`; keep it in sync. const pm = document.getElementById("report-payroll-month"); if (pm) pm.value = m; }; syncHub(); document.getElementById("hub-month").addEventListener("change", syncHub); document.getElementById("hub-location").addEventListener("change", syncHub); } // Month shortcut buttons. document.getElementById("hub-this-month-btn").addEventListener("click", () => { document.getElementById("hub-month").value = localDateStr(new Date()).slice(0, 7); document.getElementById("hub-month").dispatchEvent(new Event("change")); }); document.getElementById("hub-last-month-btn").addEventListener("click", () => { const d = new Date(); d.setDate(1); d.setMonth(d.getMonth() - 1); document.getElementById("hub-month").value = localDateStr(d).slice(0, 7); document.getElementById("hub-month").dispatchEvent(new Event("change")); }); // A hidden mirror of the hub month so old `report-payroll-month` handlers keep // working. Placed in the DOM once, at load time. (() => { if (!document.getElementById("report-payroll-month")) { const hidden = document.createElement("input"); hidden.type = "hidden"; hidden.id = "report-payroll-month"; document.body.appendChild(hidden); } })(); document.getElementById("report-download-btn").addEventListener("click", () => { const date = document.getElementById("report-date").value; const locationId = document.getElementById("report-location").value; const qs = new URLSearchParams(); if (date) qs.set("date", date); if (locationId) qs.set("location_id", locationId); window.location.href = tenantUrl(`/api/reports/attendance.csv?${qs.toString()}`); }); document.getElementById("report-employees-btn").addEventListener("click", () => { window.location.href = tenantUrl("/api/reports/employees.csv"); }); document.getElementById("report-payroll-btn").addEventListener("click", () => { const month = document.getElementById("report-payroll-month").value || localDateStr(new Date()).slice(0, 7); window.location.href = tenantUrl(`/api/reports/payroll.csv?month=${month}`); }); document.getElementById("report-payroll-xlsx-btn").addEventListener("click", () => { const month = document.getElementById("report-payroll-month").value || localDateStr(new Date()).slice(0, 7); window.location.href = tenantUrl(`/api/reports/payroll.xlsx?month=${month}`); }); document.getElementById("report-payroll-pdf-btn").addEventListener("click", () => { const month = document.getElementById("report-payroll-month").value || localDateStr(new Date()).slice(0, 7); window.location.href = tenantUrl(`/api/reports/payroll.pdf?month=${month}`); }); document.getElementById("report-payroll-detailed-xlsx-btn").addEventListener("click", () => { const month = document.getElementById("report-payroll-month").value || localDateStr(new Date()).slice(0, 7); window.location.href = tenantUrl(`/api/reports/payroll-detailed.xlsx?month=${month}`); }); document.getElementById("report-payroll-detailed-pdf-btn").addEventListener("click", () => { const month = document.getElementById("report-payroll-month").value || localDateStr(new Date()).slice(0, 7); window.location.href = tenantUrl(`/api/reports/payroll-detailed.pdf?month=${month}`); }); // --------------------------------------------------------------------------- // Settings (card hub) // --------------------------------------------------------------------------- async function loadSettings() { await loadRecipients(); } document.getElementById("settings-company-btn").addEventListener("click", async () => { const s = await api("/settings"); openModal("Company profile", [ { name: "company_name", label: "Company name", value: s.company_name }, { name: "company_address", label: "Company address (shown on payslips)", value: s.company_address }, { name: "support_email", label: "Support email", value: s.support_email }, ], async (data) => { await api("/settings", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...s, ...data }) }); toast("Company profile saved"); }); }); document.getElementById("settings-shifts-btn").addEventListener("click", () => { const backdrop = document.getElementById("modal-backdrop"); const modal = document.getElementById("modal"); modal.innerHTML = `

Shifts and holidays

Jump to the relevant page to manage each.

`; backdrop.classList.add("open"); modal.querySelector(".modal-cancel").onclick = () => backdrop.classList.remove("open"); modal.querySelector("#jump-departments").onclick = () => { backdrop.classList.remove("open"); switchView("departments"); }; modal.querySelector("#jump-holidays").onclick = () => { backdrop.classList.remove("open"); switchView("holidays"); }; modal.querySelector("#jump-attendance-rules").onclick = () => { backdrop.classList.remove("open"); switchView("attendance"); setTimeout(() => document.getElementById("attendance-rules-btn").click(), 150); }; }); document.getElementById("settings-workforce-btn").addEventListener("click", async () => { const p=await api("/workforce-policies"),backdrop=document.getElementById("modal-backdrop"),modal=document.getElementById("modal"); modal.innerHTML=`

Workforce policies

These tenant-wide rules are enforced by the server for every employee. Company attendance and payroll overrides remain under Companies.

Leave requests

Gate pass and missed return

Field attendance

`; backdrop.classList.add('open');modal.querySelector('.modal-cancel').onclick=()=>backdrop.classList.remove('open'); modal.querySelector('.modal-save').onclick=async()=>{ const inputs=[...modal.querySelectorAll('input[type="number"]')]; if(inputs.some(input=>!input.checkValidity())){inputs.find(input=>!input.checkValidity())?.reportValidity();return;} const notice=Number(document.getElementById('wp-notice').value),future=Number(document.getElementById('wp-future').value); if(future { const s = await api("/payroll-settings"); const backdrop = document.getElementById("modal-backdrop"); const modal = document.getElementById("modal"); modal.innerHTML = `

Statutory payroll rules

ESI, professional tax, and TDS vary by state and income — the defaults here are placeholders, not guaranteed-correct figures. Confirm exact rates with a CA or tax consultant before relying on this for real payroll.

Provident Fund (PF)

ESI

Professional tax

TDS

`; backdrop.classList.add("open"); const toggleModeUI = () => { const isSlab = document.getElementById("st-pt-mode").value === "maharashtra_slab"; document.getElementById("st-pt-flat-row").style.display = isSlab ? "none" : ""; document.getElementById("st-pt-slab-note").style.display = isSlab ? "" : "none"; }; toggleModeUI(); document.getElementById("st-pt-mode").addEventListener("change", toggleModeUI); modal.querySelector(".modal-cancel").onclick = () => backdrop.classList.remove("open"); modal.querySelector(".modal-save").onclick = async () => { const invalid=[...modal.querySelectorAll('input[type="number"]')].find(input=>!input.checkValidity()); if(invalid){invalid.reportValidity();return;} await api("/payroll-settings", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...s, pf_enabled: document.getElementById("st-pf-enabled").checked, pf_employee_percent: document.getElementById("st-pf-emp").value, pf_employer_percent: document.getElementById("st-pf-empr").value, pf_wage_cap: document.getElementById("st-pf-cap").value, esi_enabled: document.getElementById("st-esi-enabled").checked, esi_employee_percent: document.getElementById("st-esi-emp").value, esi_employer_percent: document.getElementById("st-esi-empr").value, esi_wage_cap: document.getElementById("st-esi-cap").value, professional_tax_enabled: document.getElementById("st-pt-enabled").checked, professional_tax_mode: document.getElementById("st-pt-mode").value, professional_tax_monthly: document.getElementById("st-pt-amount").value, tds_enabled: document.getElementById("st-tds-enabled").checked, tds_percent: document.getElementById("st-tds-percent").value, }), }); backdrop.classList.remove("open"); toast("Statutory payroll rules saved"); }; }); document.getElementById("settings-components-btn").addEventListener("click", async () => { await openPayrollComponentsModal(); }); async function openPayrollComponentsModal() { const components = await api("/payroll-components"); const backdrop = document.getElementById("modal-backdrop"); const modal = document.getElementById("modal"); modal.innerHTML = `

Payroll components

Custom recurring earnings or deductions applied to every active employee (e.g. transport allowance, uniform deduction).

${components.map((c) => ` `).join("") || ``}
NAMETYPEAMOUNT
${notificationEsc(c.name)}${notificationEsc(c.type)} ${c.amount_type === "percent_of_basic" ? c.value + "% of basic" : "₹" + c.value}
None added yet.
`; backdrop.classList.add("open"); modal.querySelector(".modal-cancel").onclick = () => backdrop.classList.remove("open"); modal.querySelectorAll(".remove-component").forEach((b) => b.addEventListener("click", async () => { if(!confirm('Remove this payroll component? It will stop applying to future payroll calculations.'))return; await api(`/payroll-components/${b.dataset.id}`, { method: "DELETE" }); backdrop.classList.remove("open"); openPayrollComponentsModal(); })); document.getElementById("add-component-inline").onclick = async () => { const name = document.getElementById("comp-name").value; const value = document.getElementById("comp-value").value; if (!name.trim()){toast('Enter a component name');document.getElementById('comp-name').focus();return;} if (!value || Number(value)<=0){toast('Enter a value greater than zero');document.getElementById('comp-value').focus();return;} await api("/payroll-components", post({ name, type: document.getElementById("comp-type").value, amount_type: document.getElementById("comp-amount-type").value, value: Number(value), })); backdrop.classList.remove("open"); openPayrollComponentsModal(); }; } async function openRolesModal() { const members = await api("/team-members"); const backdrop = document.getElementById("modal-backdrop"); const modal = document.getElementById("modal"); modal.innerHTML = `

Roles and access

This is a directory only — it does not enforce login or permissions. The sign-in screen doesn't check credentials against anything real (see README). For actual access control, put this Worker behind Cloudflare Access.

${members.map((m) => ` `).join("") || ``}
NAMEEMAILROLE
${m.name}${m.email}${m.role}
No team members listed yet.
`; backdrop.classList.add("open"); modal.querySelector(".modal-cancel").onclick = () => backdrop.classList.remove("open"); modal.querySelectorAll(".remove-member").forEach((b) => b.addEventListener("click", async () => { await api(`/team-members/${b.dataset.id}`, { method: "DELETE" }); backdrop.classList.remove("open"); openRolesModal(); })); document.getElementById("add-member-inline").onclick = async () => { const name = document.getElementById("member-name").value; const email = document.getElementById("member-email").value; if (!name || !email) return; await api("/team-members", post({ name, email, role: document.getElementById("member-role").value })); backdrop.classList.remove("open"); openRolesModal(); }; } document.getElementById("settings-templates-btn").addEventListener("click", async () => { const s = await api("/settings"); const backdrop = document.getElementById("modal-backdrop"); const modal = document.getElementById("modal"); modal.innerHTML = `

Notification templates

`; backdrop.classList.add("open"); modal.querySelector(".modal-cancel").onclick = () => backdrop.classList.remove("open"); modal.querySelector(".modal-save").onclick = async () => { await api("/settings", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...s, punch_notifications_enabled: document.getElementById("nt-punch-enabled").checked, punch_message_template: document.getElementById("nt-punch-template").value, whatsapp_alerts_enabled: document.getElementById("nt-report-enabled").checked, daily_report_time: document.getElementById("nt-report-time").value, daily_report_template: document.getElementById("nt-report-template").value, }), }); backdrop.classList.remove("open"); toast("Notification templates saved"); }; }); document.getElementById("settings-users-btn").addEventListener("click", () => openUsersModal()); async function openUsersModal() { let users, employeeList, locationList, branchList; try { [users, employeeList, locationList, branchList] = await Promise.all([api("/auth/users"), api("/employees"), api("/locations"), api("/company-locations")]); } catch { toast("Only admins can manage user accounts"); return; } const backdrop = document.getElementById("modal-backdrop"); const modal = document.getElementById("modal"); const activeEmployees=employeeList.filter(e=>e.active); const newScopeCompanies=locationList.filter(company=>company.active!==0&&branchList.some(branch=>String(branch.company_id)===String(company.id))); const employeeByPin=new Map(activeEmployees.map(e=>[String(e.pin),e])); const branchById=new Map(branchList.map(l=>[String(l.id),l])); const roleLabel={admin:"Admin",hr:"HR",manager:"Manager",employee:"Employee",finance:"Finance",security:"Security",readonly:"Read-only"}; const roleOptions=currentUser.role==='hr'?'':''; const accessDetails=(u)=>{ if(u.role==='admin')return {label:'All companies & locations',detail:'Tenant-wide access',kind:'all'}; if(u.role==='employee')return {label:'Own employee portal',detail:'Only their own records',kind:'linked'}; const branches=(u.company_location_ids||[]).map(id=>branchById.get(String(id))).filter(Boolean); if(!branches.length)return {label:'No location access',detail:'Dashboard data is restricted',kind:'none'}; const first=branches[0],more=branches.length-1; return {label:`${first.company_name} · ${first.name}${more?` +${more} more`:''}`,detail:`${branches.length} assigned location${branches.length===1?'':'s'}`,kind:'scoped'}; }; modal.innerHTML = `

User accounts

Employee accounts can only open the Employee Portal and see their own data. Admin, HR and read-only accounts use this dashboard.

${users.length} portal account${users.length===1?'':'s'}${activeEmployees.length} active employees · ${users.filter(u=>u.employee_pin).length} linked to login accounts
${users.map((u) => {const linked=employeeByPin.get(String(u.employee_pin||'')),access=accessDetails(u),search=[u.name,u.email,u.role,u.employee_pin,linked?.name,access.label,access.detail].filter(Boolean).join(' ').toLowerCase();return ` `}).join("")}
USERLOGIN EMAILROLELINKED EMPLOYEEACCESSACTIONS

Employee: /employee.html${tenantQuery()} · Finance: /finance.html${tenantQuery()} · Gate security: /security.html${tenantQuery()}

`; backdrop.classList.add("open"); modal.style.width="min(1080px,100%)"; modal.querySelector(".modal-cancel").onclick = () => { backdrop.classList.remove("open"); modal.style.removeProperty("width"); }; const filterUserAccounts=()=>{ const term=document.getElementById('user-account-search').value.trim().toLowerCase(),role=document.getElementById('user-account-role').value,access=document.getElementById('user-account-access').value; let visible=0; document.querySelectorAll('.user-account-row').forEach(row=>{const show=(!term||row.dataset.search.includes(term))&&(!role||row.dataset.role===role)&&(!access||row.dataset.access===access);row.style.display=show?'':'none';if(show)visible++;}); document.getElementById('user-account-result').textContent=`${visible} shown`; document.getElementById('user-account-empty').style.display=visible?'none':'block'; }; document.getElementById('user-account-search').addEventListener('input',filterUserAccounts); document.getElementById('user-account-role').addEventListener('change',filterUserAccounts); document.getElementById('user-account-access').addEventListener('change',filterUserAccounts); filterUserAccounts(); const populateEmployeeLinkOptions=()=>{ const term=document.getElementById('new-user-employee-search').value.trim().toLowerCase(); const matches=activeEmployees.filter(e=>!term||`${e.name} ${e.pin}`.toLowerCase().includes(term)).slice(0,100); const select=document.getElementById('new-user-employee'),previous=select.value; select.innerHTML=`${matches.map(e=>``).join('')}`; if(matches.some(e=>String(e.pin)===String(previous)))select.value=previous; document.getElementById('new-user-employee-help').textContent=matches.length?`${matches.length} matching employee${matches.length===1?'':'s'} shown`:'No active employee matches this search'; }; document.getElementById('new-user-employee-search').addEventListener('input',populateEmployeeLinkOptions); modal.querySelectorAll(".remove-user").forEach((b) => b.addEventListener("click", async () => { const target=users.find(u=>String(u.id)===String(b.dataset.id)); if(!confirm(`Remove ${target?.name||'this user'}? They will immediately lose access and be signed out.`))return; try{ await api(`/auth/users/${b.dataset.id}`, { method: "DELETE" }); toast("User account removed"); backdrop.classList.remove("open"); await openUsersModal(); }catch(error){toast(error.message||"Could not remove this user");} })); modal.querySelectorAll(".reset-user").forEach((b) => b.addEventListener("click", async () => { const password = await showModal("Set a temporary password:", "Minimum 8 characters", 8); if (!password || password.length < 8) return; await api(`/auth/users/${b.dataset.id}/reset`, { method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({password}) }); toast("Temporary password set; existing sessions were revoked"); })); modal.querySelectorAll(".access-user").forEach((b) => b.addEventListener("click", () => { const u=users.find(x=>String(x.id)===String(b.dataset.id)); const scopedCompanies=locationList.filter(company=>company.active!==0&&branchList.some(branch=>String(branch.company_id)===String(company.id))); const otherHrUsers=users.filter(account=>account.role==='hr'&&String(account.id)!==String(u.id)); const locationAccess=u.role==='admin' ? '
✓ Admin has tenant-wide access to every company and location.
' : `
Company & location access
Choose the locations this user can manage.
${u.role==='hr'&&otherHrUsers.length?`
`:''}
${scopedCompanies.map(company=>{const companyBranches=branchList.filter(branch=>String(branch.company_id)===String(company.id)),search=[company.name,...companyBranches.map(branch=>branch.name)].join(' ').toLowerCase();return `
${companyBranches.map(branch=>``).join('')}
`}).join('')||'

Create company locations first, then assign them here.

'}

Employees follow their assigned location automatically. No employee-by-employee access assignment is required.

`; const permissionCard=(id,title,detail,checked)=>``; modal.innerHTML=`

Access for ${notificationEsc(u.name)}

${notificationEsc(u.role)}

Choose locations and responsibilities. Saving signs this user out so the new access applies at next login.

${locationAccess}
Permissions
${permissionCard('acc-approve','Employee request approvals','Approve leave, half-day and gate-pass requests within assigned locations.',u.can_approve_requests)}${permissionCard('acc-recruit','Recruitment access','View candidates and résumé documents.',u.can_view_recruitment)}${permissionCard('acc-expense','Expense review','View receipts and review employee expense claims.',u.can_review_expenses)}${currentUser.role==='admin'&&u.role==='hr'?permissionCard('acc-workforce','Manage workforce access','Create departments, assign department managers, and manage Manager/Security accounts within assigned locations.',u.can_manage_workforce):''}
${u.role==='manager'?`
Employees reporting to this manager
Only employees from selected locations are shown.
${employeeList.filter(e=>e.active&&String(e.pin)!==String(u.employee_pin)).map(e=>``).join('')}
`:''}`; if(u.role==='manager'){ const oldAssignment=document.getElementById('acc-employees')?.closest('div[style*="margin-top:20px"]'); if(oldAssignment)oldAssignment.innerHTML='
Team assignment is automatic.
Choose this manager under Departments & shifts. Every employee in that department will report to this manager automatically.
'; } if(u.role==='hr'){ modal.querySelector('p').insertAdjacentHTML('afterend',``); document.getElementById('acc-own-employee').addEventListener('change',async(e)=>{if(!e.target.value)return;await api(`/auth/users/${u.id}/employee-link`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({employee_pin:e.target.value})});u.employee_pin=e.target.value;toast('HR employee linked. This user must sign in again.');}); } const refreshScopeAccess=()=>{ const selectedBranches=new Set([...document.querySelectorAll('.acc-branch:checked')].map(box=>String(box.value))); const selectedCompanies=new Set(); document.querySelectorAll('.acc-company').forEach(companyBox=>{const branches=[...document.querySelectorAll(`.acc-branch[data-company-id="${companyBox.value}"]`)],checked=branches.filter(box=>box.checked).length;companyBox.checked=branches.length>0&&checked===branches.length;companyBox.indeterminate=checked>0&&checkedselectedBranches.has(String(employee.company_location_id||''))).length; const summary=document.getElementById('acc-scope-summary'); if(summary)summary.textContent=`${selectedCompanies.size} compan${selectedCompanies.size===1?'y':'ies'} · ${selectedBranches.size} location${selectedBranches.size===1?'':'s'} · ${visibleEmployees} employee${visibleEmployees===1?'':'s'} visible`; }; document.querySelectorAll('.acc-branch').forEach(box=>box.addEventListener('change',refreshScopeAccess)); document.querySelectorAll('.acc-company').forEach(companyBox=>companyBox.addEventListener('change',()=>{document.querySelectorAll(`.acc-branch[data-company-id="${companyBox.value}"]`).forEach(branch=>branch.checked=companyBox.checked);refreshScopeAccess();})); document.getElementById('acc-select-all-locations')?.addEventListener('click',()=>{document.querySelectorAll('.acc-branch').forEach(box=>box.checked=true);refreshScopeAccess();}); document.getElementById('acc-clear-locations')?.addEventListener('click',()=>{document.querySelectorAll('.acc-branch').forEach(box=>box.checked=false);refreshScopeAccess();}); document.getElementById('acc-location-search')?.addEventListener('input',event=>{const term=event.target.value.trim().toLowerCase();document.querySelectorAll('.acc-company-card').forEach(card=>card.style.display=!term||card.dataset.search.includes(term)?'':'none');}); document.getElementById('acc-copy-access')?.addEventListener('click',()=>{const source=users.find(account=>String(account.id)===String(document.getElementById('acc-copy-user').value));if(!source){toast('Select an HR account to copy');return;}const copied=new Set((source.company_location_ids||[]).map(String));document.querySelectorAll('.acc-branch').forEach(box=>box.checked=copied.has(String(box.value)));document.getElementById('acc-approve').checked=!!source.can_approve_requests;document.getElementById('acc-recruit').checked=!!source.can_view_recruitment;document.getElementById('acc-expense').checked=!!source.can_review_expenses;refreshScopeAccess();toast(`Access copied from ${source.name}. Click Save access to apply.`);}); document.getElementById('acc-select-visible')?.addEventListener('click',()=>document.querySelectorAll('.acc-employee-card').forEach(card=>{if(card.style.display!=='none')card.querySelector('.acc-employee').checked=true;})); document.getElementById('acc-clear-all')?.addEventListener('click',()=>document.querySelectorAll('.acc-employee').forEach(box=>box.checked=false)); refreshScopeAccess(); modal.querySelector('.modal-cancel').onclick=()=>{backdrop.classList.remove('open');openUsersModal()}; document.getElementById('save-access').onclick=async()=>{const button=document.getElementById('save-access');button.disabled=true;button.textContent='Saving…';try{const branches=[...document.querySelectorAll('.acc-branch:checked')].map(o=>Number(o.value));await api(`/auth/users/${u.id}/access`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({can_approve_requests:document.getElementById('acc-approve').checked,can_view_recruitment:document.getElementById('acc-recruit').checked,can_review_expenses:document.getElementById('acc-expense').checked,can_manage_workforce:(u.role==='admin'&&document.getElementById('acc-workforce')?.checked)||false,company_location_ids:branches})});toast(branches.length||u.role==='admin'?'Access updated. User must sign in again.':'All location access removed. User must sign in again.');backdrop.classList.remove('open');await openUsersModal()}catch(error){toast(error.message||'Could not update access');button.disabled=false;button.textContent='Save access'}}; })); const roleSelect = document.getElementById("new-user-role"); const scopedRoles = ["manager","hr","security","readonly","finance"]; const refreshNewScope = () => { const selectedBranches=new Set([...document.querySelectorAll('.new-scope-branch:checked')].map(box=>String(box.value))); const selectedCompanies=new Set(); document.querySelectorAll('.new-scope-company').forEach(companyBox=>{const branches=[...document.querySelectorAll(`.new-scope-branch[data-company-id="${companyBox.value}"]`)],checked=branches.filter(box=>box.checked).length;companyBox.checked=branches.length>0&&checked===branches.length;companyBox.indeterminate=checked>0&&checkedselectedBranches.has(String(employee.company_location_id||''))).length; document.getElementById('new-scope-summary').textContent=`${selectedCompanies.size} compan${selectedCompanies.size===1?'y':'ies'} · ${selectedBranches.size} location${selectedBranches.size===1?'':'s'} · ${visibleEmployees} employee${visibleEmployees===1?'':'s'} visible`; }; document.querySelectorAll('.new-scope-branch').forEach(box=>box.addEventListener('change',refreshNewScope)); document.querySelectorAll('.new-scope-company').forEach(companyBox=>companyBox.addEventListener('change',()=>{document.querySelectorAll(`.new-scope-branch[data-company-id="${companyBox.value}"]`).forEach(branch=>branch.checked=companyBox.checked);refreshNewScope();})); document.getElementById('new-scope-select-all').addEventListener('click',()=>{document.querySelectorAll('.new-scope-branch').forEach(box=>box.checked=true);refreshNewScope();}); document.getElementById('new-scope-clear').addEventListener('click',()=>{document.querySelectorAll('.new-scope-branch').forEach(box=>box.checked=false);refreshNewScope();}); document.getElementById('new-scope-search').addEventListener('input',event=>{const term=event.target.value.trim().toLowerCase();document.querySelectorAll('.new-scope-company-card').forEach(card=>card.style.display=!term||card.dataset.search.includes(term)?'':'none');}); const toggleEmployee = () => { document.getElementById("employee-link-label").style.display = ["employee","manager","hr"].includes(roleSelect.value) ? "" : "none"; document.getElementById("new-user-scope").style.display = scopedRoles.includes(roleSelect.value) ? "" : "none"; }; roleSelect.onchange = toggleEmployee; toggleEmployee(); refreshNewScope(); document.getElementById("add-user-inline").onclick = async () => { const body = { name: document.getElementById("new-user-name").value, email: document.getElementById("new-user-email").value, password: document.getElementById("new-user-password").value, role: document.getElementById("new-user-role").value, employee_pin: document.getElementById("new-user-employee").value, company_location_ids: scopedRoles.includes(document.getElementById("new-user-role").value) ? [...document.querySelectorAll('.new-scope-branch:checked')].map(box=>Number(box.value)) : [], }; if (!body.name || !body.email || (body.password || "").length < 8) { toast("Fill all fields; password needs 8+ characters"); return; } if (["employee","manager","hr"].includes(body.role) && !body.employee_pin) { toast("Select the linked employee"); return; } if (scopedRoles.includes(body.role) && !body.company_location_ids.length) { toast("Select at least one company location for this account"); return; } try { await api("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); } catch (e) { toast(e.message || "Could not add user"); return; } backdrop.classList.remove("open"); openUsersModal(); }; } async function loadRecipients() { const recipients = await api("/whatsapp/recipients"); document.querySelector("#recipients-table tbody").innerHTML = recipients.map((r) => ` ${r.name}${r.phone_number} ` ).join("") || `
💬

No recipients yet

Add the WhatsApp numbers that should receive the daily attendance summary.

`; document.querySelectorAll(".remove-recipient").forEach((b) => b.addEventListener("click", async () => { await api(`/whatsapp/recipients/${b.dataset.id}`, { method: "DELETE" }); loadRecipients(); toast("Recipient removed"); })); } document.getElementById("add-recipient-btn").addEventListener("click", () => { openModal("Add HR recipient", [ { name: "name", label: "Name" }, { name: "phone_number", label: "WhatsApp number (with country code, e.g. 919876543210)" }, ], async (data) => { await api("/whatsapp/recipients", post(data)); loadRecipients(); toast("Recipient added"); }); }); document.getElementById("send-test-report-btn").addEventListener("click", async () => { const res = await api("/whatsapp/test-report", post({})); if (res.recipientCount === 0) toast("No HR recipients added yet"); else toast(`Sent to ${res.recipientCount} recipient(s)`); }); // --------------------------------------------------------------------------- // Late arrivals — approve (excuse) or reject // --------------------------------------------------------------------------- async function loadLateArrivals() { const month = localDateStr(new Date()).slice(0, 7); const rows = await api(`/late-arrivals?month=${month}`); document.querySelector("#latearrivals-table tbody").innerHTML = rows.map((r) => ` ${r.punch_date}${r.employee_name ?? r.employee_pin}${r.first_punch} ${r.status} ${(r.comment||'—').replace(/&/g,'&').replace(//g,'>')}${(r.reviewed_by||'—').replace(/&/g,'&').replace(//g,'>')} `).join("") || `No late arrivals this month.`; document.querySelectorAll(".approve-late").forEach((b) => b.addEventListener("click", () => reviewLateArrival(b.dataset.pin, b.dataset.date, "approved", b.dataset.name))); document.querySelectorAll(".reject-late").forEach((b) => b.addEventListener("click", () => reviewLateArrival(b.dataset.pin, b.dataset.date, "rejected", b.dataset.name))); } async function reviewLateArrival(employee_pin, punch_date, status, employeeName) { const comment = askLateReason(status, employeeName || `PIN ${employee_pin}`, punch_date); if (comment === null) return; await api("/late-arrivals/review", post({ employee_pin, punch_date, status, comment, reviewed_by: currentUser?.name || currentUser?.email || null })); loadLateArrivals(); toast(status === "approved" ? "Late arrival excused — won't count against this employee" : "Marked as late"); } // --------------------------------------------------------------------------- // Missing punches — someone punched in but never punched out (or vice // versa). Admin manually adds the actual/assumed time; it's inserted as a // normal attendance record, so duration/status/payroll all recalculate // correctly on their own with no further changes needed. // --------------------------------------------------------------------------- async function loadMissingPunches() { const month = localDateStr(new Date()).slice(0, 7); const rows = await api(`/missing-punches?month=${month}`); document.querySelector("#missingpunches-table tbody").innerHTML = rows.map((r) => ` ${r.punch_date}${r.employee_name ?? r.employee_pin} ${r.only_punch} likely missing: ${r.likely_missing.toUpperCase()} punch `).join("") || `No missing punches this month.`; document.querySelectorAll(".add-missing-punch").forEach((b) => b.addEventListener("click", () => { openModal(`Add missing ${b.dataset.missing.toUpperCase()} punch — ${b.dataset.name}`, [ { name: "corrected_time", label: `Actual/assumed ${b.dataset.missing.toUpperCase()} time (24-hour, e.g. ${b.dataset.missing === "in" ? "09:00" : "18:00"})`, type: "time" }, ], async (data) => { const res = await api("/missing-punches/correct", post({ employee_pin: b.dataset.pin, punch_date: b.dataset.date, corrected_time: data.corrected_time })); if (res.error) { toast(res.error); return; } loadMissingPunches(); toast("Punch added — duration and payroll will now reflect it"); }); })); } // --------------------------------------------------------------------------- // Shared modal helper // --------------------------------------------------------------------------- function post(data) { return { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }; } function openModal(title, fields, onSave) { const backdrop = document.getElementById("modal-backdrop"); const modal = document.getElementById("modal"); modal.innerHTML = `

${title}

${fields.map((f) => { if (f.type === "note") { return `

${f.label}

`; } if (f.type === "checkbox") { return `
`; } return ` ${f.type === "select" ? `` : ``}`; }).join("")} `; backdrop.classList.add("open"); modal.querySelector(".modal-cancel").onclick = () => backdrop.classList.remove("open"); modal.querySelector(".modal-save").onclick = async () => { const data = {}; fields.forEach((f) => { if (f.type === "note") return; const el = modal.querySelector(`[name="${f.name}"]`); data[f.name] = f.type === "checkbox" ? el.checked : el.value; }); await onSave(data); backdrop.classList.remove("open"); }; } // Pressing Esc closes whichever modal is open — every modal in the app uses // the same #modal-backdrop, so one handler covers all of them. document.addEventListener("keydown", (e) => { if (e.key !== "Escape") return; const backdrop = document.getElementById("modal-backdrop"); if (backdrop && backdrop.classList.contains("open")) backdrop.classList.remove("open"); }); // Clicking the dimmed area outside the modal card also closes it, matching // the Esc behaviour — but clicks inside the card must NOT close it. document.getElementById("modal-backdrop").addEventListener("click", (e) => { if (e.target.id === "modal-backdrop") e.currentTarget.classList.remove("open"); }); // ---- Approval History ---- (function() { let ahData = []; function typeLabel(k) { return k === 'leave' ? '🌿 Leave' : k === 'gatepass' ? '🚪 Gate pass' : '⏰ Late arrival'; } function typeBadge(k) { const c = k === 'leave' ? 'approved' : k === 'gatepass' ? 'warn' : 'red'; return `${typeLabel(k)}`; } function when(iso) { if (!iso) return '—'; try { return new Date(iso.replace(' ','T')+'Z').toLocaleString('en-IN',{dateStyle:'medium',timeStyle:'short'}); } catch { return iso; } } function renderTable(rows) { const status = document.getElementById('ah-status').value; const filtered = status ? rows.filter(r => r.status === status) : rows; const tbody = document.getElementById('ah-tbody'); const empty = document.getElementById('ah-empty'); document.getElementById('ah-summary').textContent = `${filtered.length} record${filtered.length !== 1 ? 's' : ''} · ${filtered.filter(r=>r.status==='approved').length} approved · ${filtered.filter(r=>r.status==='rejected').length} rejected`; if (!filtered.length) { tbody.innerHTML = ''; empty.style.display = 'block'; return; } empty.style.display = 'none'; tbody.innerHTML = filtered.map(r => { const detail = r.leave_type || ''; const endDate = r.end_date && r.end_date !== r.event_date ? ` → ${r.end_date}` : ''; const esc = s => (s||'').replace(/&/g,'&').replace(//g,'>'); return ` ${r.event_date||'—'}${endDate} ${typeBadge(r.kind)} ${esc(r.employee_name||r.employee_pin)} ${esc(detail)} ${r.status} ${esc(r.request_reason||'—')} ${esc(r.reviewed_by||'—')} ${when(r.reviewed_at)} `; }).join('') || 'No records'; } async function loadAH() { const from = document.getElementById('ah-from').value; const to = document.getElementById('ah-to').value; const type = document.getElementById('ah-type').value; let url = '/api/approval-history?type=' + (type||'all'); if (from) url += '&from=' + from; if (to) url += '&to=' + to; document.getElementById('ah-summary').textContent = 'Loading…'; document.getElementById('ah-tbody').innerHTML = ''; try { const d = await api(url); ahData = d.rows || []; renderTable(ahData); } catch(e) { document.getElementById('ah-summary').textContent = 'Error: ' + e.message; } } function exportCSV() { if (!ahData.length) return alert('Load data first.'); const status = document.getElementById('ah-status').value; const rows = status ? ahData.filter(r => r.status === status) : ahData; const headers = ['Date','End Date','Type','Employee','Detail','Status','Reason','Reviewed By','Reviewed At']; const csv = [headers, ...rows.map(r => [ r.event_date||'', r.end_date||'', r.kind||'', r.employee_name||r.employee_pin||'', r.leave_type||'', r.status||'', (r.request_reason||'').replace(/,/g,' '), r.reviewed_by||'', r.reviewed_at||'' ])].map(row => row.map(v => `"${v}"`).join(',')).join('\n'); const a = document.createElement('a'); a.href = 'data:text/csv;charset=utf-8,' + encodeURIComponent(csv); a.download = 'approval-history-' + (new Date().toISOString().slice(0,10)) + '.csv'; a.click(); } // Set default date range to current month const now = new Date(); const y = now.getFullYear(), m = String(now.getMonth()+1).padStart(2,'0'); document.getElementById('ah-from').value = `${y}-${m}-01`; document.getElementById('ah-to').value = `${y}-${m}-${String(new Date(y,now.getMonth()+1,0).getDate()).padStart(2,'0')}`; document.getElementById('ah-load-btn').onclick = loadAH; document.getElementById('ah-export-btn').onclick = exportCSV; document.getElementById('ah-status').onchange = () => renderTable(ahData); // Auto-load when view is shown document.querySelectorAll('.nav[data-view="approval-history"]').forEach(btn => { btn.addEventListener('click', () => { if (!ahData.length) loadAH(); }); }); })(); // Late history date filter (function() { // Set default to current month when late-history-btn is clicked const histBtn = document.getElementById('late-history-btn'); if (histBtn) { const orig = histBtn.onclick; histBtn.onclick = function() { // Set default dates to current month if not already set const now = new Date(); const y = now.getFullYear(); const m = String(now.getMonth()+1).padStart(2,'0'); const lastDay = new Date(y, now.getMonth()+1, 0).getDate(); const fromEl = document.getElementById('lh-from'); const toEl = document.getElementById('lh-to'); if (fromEl && !fromEl.value) fromEl.value = `${y}-${m}-01`; if (toEl && !toEl.value) toEl.value = `${y}-${m}-${String(lastDay).padStart(2,'0')}`; if (orig) orig.call(this); }; } // Search button triggers reload document.addEventListener('click', function(e) { if (e.target && e.target.id === 'lh-search-btn') { // Re-trigger the current active filter const active = document.querySelector('.cs-tab.active[data-f]'); const filter = active ? active.dataset.f : ''; // Dispatch click on All tab to reload document.querySelector('.cs-tab[data-f=""]')?.click(); } }); })();