659 lines
30 KiB
HTML
659 lines
30 KiB
HTML
{% extends "layout.html" %}
|
|
{% block content %}
|
|
<div class="page-wide">
|
|
<div class="panel" style="width:100%;">
|
|
<div class="panel-title">Review & Edit</div>
|
|
{% if flash %}<div class="alert success">{{ flash }}</div>{% endif %}
|
|
<div style="display:flex;gap:8px;align-items:center;justify-content:space-between;margin-bottom:10px;flex-wrap:wrap;">
|
|
<div class="muted"><strong>{{ employee.name }}</strong> – {{ period_name }}</div>
|
|
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
|
<a class="btn" href="/review?timesheet_id={{ timesheet_id }}">Return to Review Timesheets</a>
|
|
<button class="btn" type="button" onclick="openPrint('/review/print?timesheet_id={{ timesheet_id }}&employee_id={{ employee.id }}')">Print</button>
|
|
</div>
|
|
</div>
|
|
|
|
<form id="carry-over-form-edit" method="post" action="/viewer/update-employee-period"
|
|
style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:12px;"
|
|
onsubmit="return saveCarryOverEdit(event)">
|
|
<input type="hidden" name="employee_id" value="{{ employee.id }}">
|
|
<input type="hidden" name="timesheet_id" value="{{ timesheet_id }}">
|
|
<input type="hidden" name="redirect_to" value="/review/edit?timesheet_id={{ timesheet_id }}&employee_id={{ employee.id }}">
|
|
<label class="label" for="carry_over_hours">Carry over hours</label>
|
|
<input class="input" id="carry_over_hours" name="carry_over_hours" type="number" step="0.01" value="{{ carry_over_hours|fmt2 }}" {% if not can_edit %}disabled{% endif %}>
|
|
<button class="btn" type="submit" {% if not can_edit %}disabled{% endif %}>Save</button>
|
|
<span class="muted">Applied to Week 1 regular cap (40 minus carry over).</span>
|
|
</form>
|
|
|
|
<!-- NEW: Payroll extras panel (included in Excel export) -->
|
|
<div class="panel" style="margin-bottom:12px;">
|
|
<div class="panel-title">Payroll Extras</div>
|
|
<form method="post" action="/review/update-payroll-note" class="inline" style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;">
|
|
<input type="hidden" name="timesheet_id" value="{{ timesheet_id }}">
|
|
<input type="hidden" name="employee_id" value="{{ employee.id }}">
|
|
|
|
<div style="display:grid;gap:6px;min-width:220px;">
|
|
<label class="label">Reimbursement (optional)</label>
|
|
<input class="input" name="reimbursement_amount" type="text" placeholder="$0.00"
|
|
value="{% if payroll_note and payroll_note.reimbursement_amount is not none %}{{ '%.2f'|format(payroll_note.reimbursement_amount) }}{% endif %}"
|
|
{% if not can_edit %}disabled{% endif %}>
|
|
</div>
|
|
|
|
<div style="display:grid;gap:6px;min-width:220px;">
|
|
<label class="label">Additional Payroll Changes (optional)</label>
|
|
<input class="input" name="additional_payroll_amount" type="text" placeholder="$0.00"
|
|
value="{% if payroll_note and payroll_note.additional_payroll_amount is not none %}{{ '%.2f'|format(payroll_note.additional_payroll_amount) }}{% endif %}"
|
|
{% if not can_edit %}disabled{% endif %}>
|
|
</div>
|
|
|
|
<div style="display:grid;gap:6px;min-width:320px;flex:1;">
|
|
<label class="label">Notes</label>
|
|
<textarea class="input" name="notes" rows="2" placeholder="Notes for payroll team..." {% if not can_edit %}disabled{% endif %}>{{ payroll_note.notes if payroll_note and payroll_note.notes else '' }}</textarea>
|
|
</div>
|
|
|
|
<div>
|
|
<button type="submit" class="btn primary" {% if not can_edit %}disabled{% endif %}>Save Extras</button>
|
|
</div>
|
|
</form>
|
|
<div class="muted" style="margin-top:8px;">Saved values appear in the Overview Excel export.</div>
|
|
</div>
|
|
<!-- /Payroll extras -->
|
|
|
|
{% if holiday_needs is defined and holiday_needs|length > 0 %}
|
|
<div class="alert warn" style="margin-bottom:12px;">
|
|
<div class="mb-8"><strong>Holiday review required{% if employee %} for {{ employee.name }}{% endif %}:</strong> The dates below include Holiday hours.</div>
|
|
<ul class="dup-list">
|
|
{% for h in holiday_needs %}
|
|
<li>{{ h.work_date.strftime("%A, %B %d, %Y") }} – Holiday {{ h.holiday_hours|fmt2 }} hr(s)</li>
|
|
{% endfor %}
|
|
</ul>
|
|
{% if timesheet_id and employee and can_edit %}
|
|
<div class="mt-8 inline" style="gap:8px;">
|
|
<form method="post" action="/review/review-holiday-needs">
|
|
<input type="hidden" name="employee_id" value="{{ employee.id }}">
|
|
<input type="hidden" name="timesheet_id" value="{{ timesheet_id }}">
|
|
<button class="btn" type="submit">Reviewed</button>
|
|
</form>
|
|
</div>
|
|
{% endif %}
|
|
<div class="mt-8">Holiday rows are highlighted in the grid until you click Reviewed. Saving rows does not clear highlights.</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div class="table-wrap">
|
|
<table class="table" style="width:100%;">
|
|
<thead>
|
|
<tr>
|
|
<th>DATE</th>
|
|
<th>CLOCK IN</th>
|
|
<th>CLOCK OUT</th>
|
|
<th>BREAK</th>
|
|
<th>TOTAL</th>
|
|
<th>PTO</th>
|
|
<th>PTO TYPE</th>
|
|
<th>HOLIDAY</th>
|
|
<th>OTHER</th>
|
|
<th>PAID</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% set has_holiday_flags = flagged_holiday_dates is defined and reviewed_holiday_dates is defined %}
|
|
{% for r in grouped.rows %}
|
|
<form id="row-{{ r.entry_id }}" action="/timesheet/update-entry" method="post"></form>
|
|
{% set highlight_holiday = (has_holiday_flags and (r.work_date in flagged_holiday_dates and r.work_date not in reviewed_holiday_dates)) or (not has_holiday_flags and (r.holiday_hours and r.holiday_hours > 0)) %}
|
|
<tr id="row-tr-{{ r.entry_id }}" class="{% if highlight_holiday %}row-holiday{% endif %}" data-entry-id="{{ r.entry_id }}" tabindex="0">
|
|
<td class="mono">
|
|
{{ r.work_date.strftime("%b %d, %Y") }}
|
|
{% if highlight_holiday %}
|
|
<span class="badge holiday" title="This entry has Holiday hours and needs review.">Holiday – Needs review</span>
|
|
{% endif %}
|
|
</td>
|
|
|
|
<td id="ci-{{ r.entry_id }}" class="mono clock-cell"
|
|
title="{{ r.clock_in|fmt_dt }}"
|
|
data-ci="{{ r.clock_in|fmt_excel_dt }}"
|
|
data-date="{{ r.work_date.strftime('%Y-%m-%d') }}">
|
|
<div class="clock-row">
|
|
<span class="clock-text">
|
|
{% if r.holiday_hours and r.holiday_hours > 0 %}
|
|
Holiday
|
|
{% elif r.pto_type %}
|
|
{{ r.pto_type }}
|
|
{% else %}
|
|
{{ r.clock_in|fmt_excel_dt if r.clock_in else '-' }}
|
|
{% endif %}
|
|
</span>
|
|
<button type="button" class="icon-btn calendar" title="Pick date & time"
|
|
onclick="if(window.can_edit){openClockPicker(this, {{ r.entry_id }}, 'clock_in')}"
|
|
{% if not can_edit %}disabled{% endif %}>
|
|
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
|
<rect x="3" y="4" width="18" height="18" rx="3" ry="3" fill="none" stroke="currentColor" stroke-width="2"/>
|
|
<line x1="3" y1="10" x2="21" y2="10" stroke="currentColor" stroke-width="2"/>
|
|
<line x1="8" y1="2" x2="8" y2="6" stroke="currentColor" stroke-width="2"/>
|
|
<line x1="16" y1="2" x2="16" y2="6" stroke="currentColor" stroke-width="2"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<input class="clock-input" id="ci-input-{{ r.entry_id }}" type="datetime-local"
|
|
value="{{ r.clock_in.strftime('%Y-%m-%dT%H:%M') if r.clock_in else '' }}"
|
|
onchange="onClockChanged({{ r.entry_id }}, 'clock_in', this.value)" />
|
|
</td>
|
|
|
|
<td id="co-{{ r.entry_id }}" class="mono clock-cell"
|
|
title="{{ r.clock_out|fmt_dt }}"
|
|
data-co="{{ r.clock_out|fmt_excel_dt }}"
|
|
data-date="{{ r.work_date.strftime('%Y-%m-%d') }}">
|
|
<div class="clock-row">
|
|
<span class="clock-text">
|
|
{% if r.holiday_hours and r.holiday_hours > 0 %}
|
|
Holiday
|
|
{% elif r.pto_type %}
|
|
{{ r.pto_type }}
|
|
{% else %}
|
|
{{ r.clock_out|fmt_excel_dt if r.clock_out else '-' }}
|
|
{% endif %}
|
|
</span>
|
|
<button type="button" class="icon-btn calendar" title="Pick date & time"
|
|
onclick="if(window.can_edit){openClockPicker(this, {{ r.entry_id }}, 'clock_out')}"
|
|
{% if not can_edit %}disabled{% endif %}>
|
|
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
|
<rect x="3" y="4" width="18" height="18" rx="3" ry="3" fill="none" stroke="currentColor" stroke-width="2"/>
|
|
<line x1="3" y1="10" x2="21" y2="10" stroke="currentColor" stroke-width="2"/>
|
|
<line x1="8" y1="2" x2="8" y2="6" stroke="currentColor" stroke-width="2"/>
|
|
<line x1="16" y1="2" x2="16" y2="6" stroke="currentColor" stroke-width="2"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<input class="clock-input" id="co-input-{{ r.entry_id }}" type="datetime-local"
|
|
value="{{ r.clock_out.strftime('%Y-%m-%dT%H:%M') if r.clock_out else '' }}"
|
|
onchange="onClockChanged({{ r.entry_id }}, 'clock_out', this.value)" />
|
|
</td>
|
|
|
|
<td class="num">
|
|
<input form="row-{{ r.entry_id }}" type="hidden" name="entry_id" value="{{ r.entry_id }}">
|
|
<input form="row-{{ r.entry_id }}" type="hidden" name="timesheet_id" value="{{ timesheet_id }}">
|
|
<input form="row-{{ r.entry_id }}" type="hidden" name="employee_id" value="{{ employee.id }}">
|
|
<input class="input" form="row-{{ r.entry_id }}" type="number" step="0.01" name="break_hours" value="{{ r.break_hours|fmt2 }}" {% if not can_edit %}readonly disabled{% endif %}>
|
|
</td>
|
|
|
|
<td class="num">
|
|
<input class="input total-hours" form="row-{{ r.entry_id }}" type="number" step="0.01" name="total_hours" value="{{ r.total_hours|fmt2 }}" {% if not can_edit %}readonly disabled{% endif %}>
|
|
</td>
|
|
|
|
<td class="num">
|
|
<input class="input" form="row-{{ r.entry_id }}" type="number" step="0.01" name="pto_hours" value="{{ r.pto_hours|fmt2 }}" {% if not can_edit %}readonly disabled{% endif %}>
|
|
</td>
|
|
|
|
<td>
|
|
<select class="select" form="row-{{ r.entry_id }}" name="pto_type"
|
|
onchange="onPtoTypeChange(this, {{ r.entry_id }})" {% if not can_edit %}disabled{% endif %}>
|
|
<option value="" {% if not r.pto_type %}selected{% endif %}></option>
|
|
<option value="PTO" {% if r.pto_type == 'PTO' %}selected{% endif %}>PTO</option>
|
|
<option value="Sick" {% if r.pto_type == 'Sick' %}selected{% endif %}>Sick</option>
|
|
<option value="Off" {% if r.pto_type == 'Off' %}selected{% endif %}>Off</option>
|
|
</select>
|
|
</td>
|
|
|
|
<td class="num">
|
|
<input class="input" form="row-{{ r.entry_id }}" type="number" step="0.01" name="holiday_hours" value="{{ r.holiday_hours|fmt2 }}" {% if not can_edit %}readonly disabled{% endif %}>
|
|
</td>
|
|
<td class="num">
|
|
<input class="input" form="row-{{ r.entry_id }}" type="number" step="0.01" name="bereavement_hours" value="{{ r.bereavement_hours|fmt2 }}" {% if not can_edit %}readonly disabled{% endif %}>
|
|
</td>
|
|
|
|
<td class="num mono paid-cell">{{ r.hours_paid|fmt2 }}</td>
|
|
|
|
<td>
|
|
<input form="row-{{ r.entry_id }}" type="hidden" name="redirect_to" value="/review/edit?timesheet_id={{ timesheet_id }}&employee_id={{ employee.id }}">
|
|
{% if can_edit %}
|
|
<button type="button" class="btn primary sm" onclick="saveEntry(event, {{ r.entry_id }})">Save</button>
|
|
{% else %}
|
|
<button type="button" class="btn primary sm" disabled>Save</button>
|
|
{% endif %}
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="panel totals" id="summary-tiles" style="width:100%;">
|
|
<div class="total"><div class="t-label">Regular</div><div class="t-val">{{ grouped.totals.regular|fmt2 }}</div></div>
|
|
<div class="total"><div class="t-label">Overtime</div><div class="t-val">{{ grouped.totals.overtime|fmt2 }}</div></div>
|
|
<div class="total"><div class="t-label">PTO</div><div class="t-val">{{ grouped.totals.pto|fmt2 }}</div></div>
|
|
<div class="total"><div class="t-label">Holiday</div><div class="t-val">{{ grouped.totals.holiday|fmt2 }}</div></div>
|
|
<div class="total"><div class="t-label">Other</div><div class="t-val">{{ grouped.totals.bereavement|fmt2 }}</div></div>
|
|
<div class="total"><div class="t-label">Paid Total</div><div class="t-val">{{ grouped.totals.paid_total|fmt2 }}</div></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<iframe id="print-iframe" style="position:fixed; inset:0; width:0; height:0; border:0; visibility:hidden;"></iframe>
|
|
|
|
<div id="clock-picker" class="picker-popover" hidden>
|
|
<div class="picker-header">
|
|
<div class="picker-title">Set date & time</div>
|
|
<div class="picker-actions">
|
|
<button class="btn sm" type="button" onclick="closeClockPicker()">Cancel</button>
|
|
<button id="picker-apply" class="btn primary sm" type="button" onclick="applyClockPicker()">Apply</button>
|
|
</div>
|
|
</div>
|
|
<div class="picker-body">
|
|
<div class="picker-row">
|
|
<label>Date</label>
|
|
<input id="picker-date" type="date">
|
|
</div>
|
|
<div class="picker-row">
|
|
<label>Time</label>
|
|
<input id="picker-time" type="time" step="60">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="row-menu" class="ctx-menu" role="menu" hidden>
|
|
{% if can_edit %}
|
|
<button type="button" id="ctx-delete" class="ctx-item danger" role="menuitem">Delete row</button>
|
|
{% else %}
|
|
<button type="button" class="ctx-item danger" role="menuitem" disabled>Delete row</button>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<div id="toast" class="toast" hidden>Time entry saved</div>
|
|
|
|
<style>
|
|
.table thead th { text-align: center; }
|
|
.table thead th.num { text-align: center; }
|
|
.table tbody td.num, .table tfoot td.num { text-align: right; }
|
|
.row-holiday { background: #fff6e6; }
|
|
.badge.holiday { display:inline-block; padding:2px 6px; font-size:11px; border-radius:10px; border:1px solid #ffc470; color:#8a5200; background:#fff4e0; margin-left: 6px; }
|
|
.clock-cell { white-space: nowrap; overflow: visible; }
|
|
.clock-row { display: grid; grid-template-columns: 1fr 28px; align-items: center; column-gap: 6px; }
|
|
.clock-text { display: block; width: 100%; min-width: 0; text-align: center; overflow: hidden; text-overflow: ellipsis; }
|
|
.icon-btn.calendar { border:0; background:#f8fafc; color:#64748b; cursor:pointer; width:28px; height:28px; border-radius:8px; display:inline-flex; align-items:center; justify-content:center; border:1px solid var(--line); }
|
|
.icon-btn.calendar:hover { color:#0f172a; filter:brightness(1.02); }
|
|
.icon-btn.calendar[disabled] { opacity: 0.5; cursor: not-allowed; }
|
|
.clock-input { display:none; }
|
|
.picker-popover { position:fixed; z-index:1000; width:280px; background:#fff; border:1px solid var(--line); border-radius:8px; box-shadow:0 8px 24px rgba(2,6,23,0.15); overflow:hidden; }
|
|
.picker-header { display:flex; align-items:center; justify-content:space-between; gap:8px; position:sticky; top:0; background:#fff; border-bottom:1px solid var(--line); padding:8px; }
|
|
.picker-title { font-size:12px; color:#475569; font-weight:600; }
|
|
.picker-actions { display:flex; gap:8px; }
|
|
.picker-body { padding:10px; }
|
|
.picker-row input { flex:1; min-height:32px; }
|
|
.toast { position:fixed; right:16px; bottom:16px; background:#10b981; color:#fff; padding:10px 14px; border-radius:8px; box-shadow:0 8px 24px rgba(2,6,23,0.25); opacity:0; transform:translateY(6px); transition:opacity .2s ease, transform .2s ease; z-index:1500; }
|
|
.toast.show { opacity:1; transform:translateY(0); }
|
|
.table-wrap { overflow-x:auto; }
|
|
</style>
|
|
|
|
<script>
|
|
function openPrint(url) {
|
|
const iframe = document.getElementById('print-iframe');
|
|
iframe.src = url;
|
|
}
|
|
window.addEventListener('message', function (e) {
|
|
if (e && e.data && e.data.type === 'close-print') {
|
|
const iframe = document.getElementById('print-iframe');
|
|
if (iframe) iframe.removeAttribute('src');
|
|
}
|
|
});
|
|
|
|
function onPtoTypeChange(selectEl, entryId) {
|
|
const ciCell = document.getElementById('ci-' + entryId);
|
|
const coCell = document.getElementById('co-' + entryId);
|
|
if (!ciCell || !coCell) return;
|
|
|
|
const type = (selectEl.value || '').trim();
|
|
const ciTextEl = ciCell.querySelector('.clock-text');
|
|
const coTextEl = coCell.querySelector('.clock-text');
|
|
|
|
const row = document.getElementById('row-tr-' + entryId);
|
|
const holInput = row ? row.querySelector('input[name="holiday_hours"]') : null;
|
|
const holVal = holInput ? parseFloat(holInput.value || '0') : 0;
|
|
if (holVal > 0) {
|
|
if (ciTextEl) ciTextEl.textContent = 'Holiday';
|
|
if (coTextEl) coTextEl.textContent = 'Holiday';
|
|
return;
|
|
}
|
|
|
|
if (type) {
|
|
if (ciTextEl) ciTextEl.textContent = type;
|
|
if (coTextEl) coTextEl.textContent = type;
|
|
} else {
|
|
const ci = ciCell.getAttribute('data-ci') || '-';
|
|
const co = coCell.getAttribute('data-co') || '-';
|
|
if (ciTextEl) ciTextEl.textContent = ci;
|
|
if (coTextEl) coTextEl.textContent = co;
|
|
}
|
|
}
|
|
|
|
function num(v){const n=parseFloat(v);return isFinite(n)?n:0;}
|
|
function fmt2(v){return (Math.round(num(v)*100)/100).toFixed(2);}
|
|
// FIX: read PTO type from the table row (not the empty <form>)
|
|
function getPtoType(entryId){
|
|
const row = document.getElementById('row-tr-' + entryId);
|
|
const sel = row ? row.querySelector('select[name="pto_type"]') : null;
|
|
return (sel && sel.value ? sel.value.trim() : '');
|
|
}
|
|
|
|
let toastTimer = null;
|
|
function showToast(msg) {
|
|
const t = document.getElementById('toast');
|
|
if (!t) return;
|
|
t.textContent = msg || 'Saved';
|
|
t.hidden = false;
|
|
t.classList.add('show');
|
|
clearTimeout(toastTimer);
|
|
toastTimer = setTimeout(() => {
|
|
t.classList.remove('show');
|
|
setTimeout(() => { t.hidden = true; }, 200);
|
|
}, 1800);
|
|
}
|
|
|
|
async function refreshSummary(timesheetId, employeeId) {
|
|
try {
|
|
const url = `/review/edit?timesheet_id=${encodeURIComponent(timesheetId)}&employee_id=${encodeURIComponent(employeeId)}`;
|
|
const res = await fetch(url, { credentials: 'same-origin' });
|
|
if (!res.ok) return;
|
|
const html = await res.text();
|
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
const newTiles = doc.querySelector('#summary-tiles');
|
|
const curTiles = document.querySelector('#summary-tiles');
|
|
if (newTiles && curTiles) curTiles.innerHTML = newTiles.innerHTML;
|
|
} catch (e) {
|
|
console.warn('refreshSummary failed', e);
|
|
}
|
|
}
|
|
|
|
let pickerState = { anchor: null, entryId: null, field: null };
|
|
let pickerOutsideHandler = null;
|
|
|
|
function placePopover(anchorBtn) {
|
|
const pop = document.getElementById('clock-picker');
|
|
const rect = anchorBtn.getBoundingClientRect();
|
|
const vpW = window.innerWidth || document.documentElement.clientWidth;
|
|
const vpH = window.innerHeight || document.documentElement.clientHeight;
|
|
|
|
pop.style.visibility = 'hidden';
|
|
pop.hidden = false;
|
|
const measure = pop.getBoundingClientRect();
|
|
const popW = measure.width || 280;
|
|
const popH = measure.height || 220;
|
|
|
|
const belowTop = rect.bottom + 8;
|
|
const aboveTop = rect.top - popH - 8;
|
|
const top = (belowTop + popH + 16 <= vpH) ? belowTop : Math.max(8, aboveTop);
|
|
const left = Math.min(vpW - popW - 8, rect.right + 8);
|
|
|
|
pop.style.left = left + 'px';
|
|
pop.style.top = top + 'px';
|
|
pop.style.visibility = 'visible';
|
|
}
|
|
|
|
function openClockPicker(anchorBtn, entryId, field) {
|
|
const pop = document.getElementById('clock-picker');
|
|
const dateInput = document.getElementById('picker-date');
|
|
const timeInput = document.getElementById('picker-time');
|
|
|
|
pickerState.anchor = anchorBtn;
|
|
pickerState.entryId = entryId;
|
|
pickerState.field = field;
|
|
|
|
const cellId = (field === 'clock_in' ? 'ci-' : 'co-') + entryId;
|
|
const theHiddenId = (field === 'clock_in' ? 'ci-input-' : 'co-input-') + entryId;
|
|
const cell = document.getElementById(cellId);
|
|
const hidden = document.getElementById(theHiddenId);
|
|
const baseDate = cell ? (cell.getAttribute('data-date') || '') : '';
|
|
|
|
let dval = '', tval = '';
|
|
if (hidden && hidden.value) {
|
|
const parts = hidden.value.split('T');
|
|
dval = parts[0] || baseDate || '';
|
|
tval = parts[1] || '';
|
|
} else {
|
|
dval = baseDate || '';
|
|
tval = '08:00';
|
|
}
|
|
dateInput.value = dval;
|
|
timeInput.value = tval;
|
|
|
|
placePopover(anchorBtn);
|
|
|
|
setTimeout(() => {
|
|
pickerOutsideHandler = (e) => {
|
|
const popNow = document.getElementById('clock-picker');
|
|
if (!popNow.hidden && !popNow.contains(e.target) && e.target !== anchorBtn) {
|
|
closeClockPicker();
|
|
document.removeEventListener('mousedown', pickerOutsideHandler);
|
|
pickerOutsideHandler = null;
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', pickerOutsideHandler);
|
|
}, 0);
|
|
}
|
|
|
|
function closeClockPicker() {
|
|
const pop = document.getElementById('clock-picker');
|
|
pop.hidden = true;
|
|
pickerState = { anchor: null, entryId: null, field: null };
|
|
if (pickerOutsideHandler) {
|
|
document.removeEventListener('mousedown', pickerOutsideHandler);
|
|
pickerOutsideHandler = null;
|
|
}
|
|
}
|
|
|
|
function applyClockPicker() {
|
|
const dateInput = document.getElementById('picker-date');
|
|
const timeInput = document.getElementById('picker-time');
|
|
const dval = (dateInput.value || '').trim();
|
|
const tval = (timeInput.value || '').trim();
|
|
if (!dval || !tval || !pickerState.entryId || !pickerState.field) {
|
|
closeClockPicker();
|
|
return;
|
|
}
|
|
const newIso = dval + 'T' + tval;
|
|
|
|
const otherHiddenId = (pickerState.field === 'clock_in' ? 'co-input-' : 'ci-input-') + pickerState.entryId;
|
|
const otherHidden = document.getElementById(otherHiddenId);
|
|
const otherVal = otherHidden ? (otherHidden.value || '').trim() : '';
|
|
if (otherVal) {
|
|
const newDt = new Date(newIso);
|
|
const otherDt = new Date(otherVal);
|
|
if (pickerState.field === 'clock_in' && newDt >= otherDt) { alert('Clock In must be before Clock Out for the same entry.'); return; }
|
|
if (pickerState.field === 'clock_out' && newDt <= otherDt) { alert('Clock Out must be after Clock In for the same entry.'); return; }
|
|
}
|
|
|
|
const hiddenId = (pickerState.field === 'clock_in' ? 'ci-input-' : 'co-input-') + pickerState.entryId;
|
|
const hidden = document.getElementById(hiddenId);
|
|
if (hidden) hidden.value = newIso;
|
|
|
|
onClockChanged(pickerState.entryId, pickerState.field, newIso);
|
|
closeClockPicker();
|
|
}
|
|
|
|
async function onClockChanged(entryId, field, value) {
|
|
if (!window.can_edit) { showToast('Read-only mode'); return; }
|
|
|
|
const row = document.getElementById('row-tr-' + entryId);
|
|
const breakVal = row ? ((row.querySelector('input[name="break_hours"]') || {}).value || '') : '';
|
|
const ptoVal = row ? ((row.querySelector('input[name="pto_hours"]') || {}).value || '') : '';
|
|
const holVal = row ? ((row.querySelector('input[name="holiday_hours"]') || {}).value || '') : '';
|
|
const othVal = row ? ((row.querySelector('input[name="bereavement_hours"]') || {}).value || '') : '';
|
|
|
|
const fd = new FormData();
|
|
fd.append('entry_id', entryId);
|
|
if (field === 'clock_in') fd.append('clock_in', value);
|
|
if (field === 'clock_out') fd.append('clock_out', value);
|
|
fd.append('break_hours', breakVal);
|
|
fd.append('pto_hours', ptoVal);
|
|
fd.append('holiday_hours', holVal);
|
|
fd.append('bereavement_hours', othVal);
|
|
|
|
try {
|
|
const res = await fetch('/timesheet/update-clocks', {
|
|
method: 'POST',
|
|
headers: { 'Accept': 'application/json' },
|
|
credentials: 'same-origin',
|
|
body: fd
|
|
});
|
|
if (!res.ok) {
|
|
let err = '';
|
|
try { const e = await res.json(); err = e && e.error ? e.error : JSON.stringify(e); } catch {}
|
|
alert('Unable to update clock time (' + res.status + '). ' + (err || 'Please try again.'));
|
|
return;
|
|
}
|
|
|
|
const data = await res.json();
|
|
|
|
const ciCell = document.getElementById('ci-' + entryId);
|
|
const coCell = document.getElementById('co-' + entryId);
|
|
|
|
if (parseFloat(holVal || '0') > 0) {
|
|
const ciText = ciCell ? ciCell.querySelector('.clock-text') : null;
|
|
const coText = coCell ? coCell.querySelector('.clock-text') : null;
|
|
if (ciText) ciText.textContent = 'Holiday';
|
|
if (coText) coText.textContent = 'Holiday';
|
|
row.classList.add('row-holiday');
|
|
const dateCell = row.querySelector('td:first-child');
|
|
if (dateCell && !dateCell.querySelector('.badge.holiday')) {
|
|
const b = document.createElement('span');
|
|
b.className = 'badge holiday';
|
|
b.title = 'This entry has Holiday hours and needs review.';
|
|
b.textContent = 'Holiday - Needs review';
|
|
dateCell.appendChild(document.createTextNode(' '));
|
|
dateCell.appendChild(b);
|
|
}
|
|
} else {
|
|
const ptoType = getPtoType(entryId);
|
|
if (ptoType) {
|
|
const ciText = ciCell ? ciCell.querySelector('.clock-text') : null;
|
|
const coText = coCell ? coCell.querySelector('.clock-text') : null;
|
|
if (ciText) ciText.textContent = ptoType;
|
|
if (coText) coText.textContent = ptoType;
|
|
} else {
|
|
if (data.clock_in_fmt && ciCell) {
|
|
const t = ciCell.querySelector('.clock-text'); if (t) t.textContent = data.clock_in_fmt;
|
|
ciCell.setAttribute('data-ci', data.clock_in_fmt);
|
|
}
|
|
if (data.clock_out_fmt && coCell) {
|
|
const t = coCell.querySelector('.clock-text'); if (t) t.textContent = data.clock_out_fmt;
|
|
coCell.setAttribute('data-co', data.clock_out_fmt);
|
|
}
|
|
}
|
|
row.classList.remove('row-holiday');
|
|
const dateCell = row.querySelector('td:first-child');
|
|
if (dateCell) {
|
|
const badge = dateCell.querySelector('.badge.holiday');
|
|
if (badge) badge.remove();
|
|
}
|
|
}
|
|
|
|
if (row) {
|
|
const totalInput = row.querySelector('input.total-hours');
|
|
if (totalInput && typeof data.total_hours_fmt === 'string') totalInput.value = data.total_hours_fmt;
|
|
const paidCell = row.querySelector('.paid-cell');
|
|
if (paidCell && typeof data.hours_paid_fmt === 'string') paidCell.textContent = data.hours_paid_fmt;
|
|
}
|
|
|
|
await refreshSummary('{{ timesheet_id }}', '{{ employee.id }}');
|
|
showToast('Time updated');
|
|
} catch (e) {
|
|
console.error('Clock update failed', e);
|
|
alert('Unable to update clock time. Please try again.');
|
|
}
|
|
}
|
|
|
|
async function saveEntry(ev, entryId) {
|
|
if (ev) ev.preventDefault();
|
|
if (!window.can_edit) { showToast('Read-only mode'); return; }
|
|
|
|
const form = document.getElementById('row-' + entryId);
|
|
if (!form) return;
|
|
|
|
const fd = new FormData(form);
|
|
try {
|
|
const res = await fetch('/timesheet/update-entry', {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
body: fd
|
|
});
|
|
if (!res.ok) { showToast('Save failed (' + res.status + ')'); return; }
|
|
|
|
const row = document.getElementById('row-tr-' + entryId);
|
|
if (row) {
|
|
['break_hours','total_hours','pto_hours','holiday_hours','bereavement_hours'].forEach(name=>{
|
|
const inp = row.querySelector('input[name="'+name+'"]');
|
|
if (inp) inp.value = fmt2(inp.value);
|
|
});
|
|
const total = num((row.querySelector('input[name="total_hours"]')||{}).value);
|
|
const brk = num((row.querySelector('input[name="break_hours"]')||{}).value);
|
|
const pto = num((row.querySelector('input[name="pto_hours"]')||{}).value);
|
|
const hol = num((row.querySelector('input[name="holiday_hours"]')||{}).value);
|
|
const oth = num((row.querySelector('input[name="bereavement_hours"]')||{}).value);
|
|
const paid = Math.max(0, total - brk) + pto + hol + oth;
|
|
const paidCell = row.querySelector('.paid-cell');
|
|
if (paidCell) paidCell.textContent = fmt2(paid);
|
|
|
|
const ciCell = document.getElementById('ci-' + entryId);
|
|
const coCell = document.getElementById('co-' + entryId);
|
|
const ciText = ciCell ? ciCell.querySelector('.clock-text') : null;
|
|
const coText = coCell ? coCell.querySelector('.clock-text') : null;
|
|
|
|
const ptoType = getPtoType(entryId);
|
|
if (hol > 0) {
|
|
if (ciText) ciText.textContent = 'Holiday';
|
|
if (coText) coText.textContent = 'Holiday';
|
|
row.classList.add('row-holiday');
|
|
const dateCell = row.querySelector('td:first-child');
|
|
if (dateCell && !dateCell.querySelector('.badge.holiday')) {
|
|
const b = document.createElement('span');
|
|
b.className = 'badge holiday';
|
|
b.title = 'This entry has Holiday hours and needs review.';
|
|
b.textContent = 'Holiday - Needs review';
|
|
dateCell.appendChild(document.createTextNode(' '));
|
|
dateCell.appendChild(b);
|
|
}
|
|
} else if (ptoType) {
|
|
if (ciText) ciText.textContent = ptoType;
|
|
if (coText) coText.textContent = ptoType;
|
|
row.classList.remove('row-holiday');
|
|
const dateCell = row.querySelector('td:first-child');
|
|
if (dateCell) { const badge = dateCell.querySelector('.badge.holiday'); if (badge) badge.remove(); }
|
|
} else {
|
|
if (ciText) ciText.textContent = (ciCell ? (ciCell.getAttribute('data-ci') || '-') : '-');
|
|
if (coText) coText.textContent = (coCell ? (coCell.getAttribute('data-co') || '-') : '-');
|
|
row.classList.remove('row-holiday');
|
|
const dateCell = row.querySelector('td:first-child');
|
|
if (dateCell) { const badge = dateCell.querySelector('.badge.holiday'); if (badge) badge.remove(); }
|
|
}
|
|
}
|
|
|
|
await refreshSummary('{{ timesheet_id }}', '{{ employee.id }}');
|
|
showToast('Time entry saved');
|
|
} catch (e) {
|
|
console.error('saveEntry error', e);
|
|
showToast('Save failed');
|
|
}
|
|
}
|
|
|
|
function saveCarryOverEdit(ev) {
|
|
if (ev) ev.preventDefault();
|
|
if (!window.can_edit) { showToast('Read-only mode'); return false; }
|
|
const form = document.getElementById('carry-over-form-edit');
|
|
if (!form) return false;
|
|
|
|
const fd = new FormData(form);
|
|
fetch('/viewer/update-employee-period', {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
body: fd
|
|
}).then(async (res) => {
|
|
if (!res.ok) { showToast('Save failed ('+res.status+')'); return; }
|
|
await refreshSummary(fd.get('timesheet_id'), fd.get('employee_id'));
|
|
showToast('Carry over saved');
|
|
}).catch((e) => {
|
|
console.error('carry-over save error', e);
|
|
showToast('Save failed');
|
|
});
|
|
return false;
|
|
}
|
|
</script>
|
|
{% endblock %} |