1146 lines
52 KiB
HTML
1146 lines
52 KiB
HTML
{% extends "layout.html" %}
|
|
{% block content %}
|
|
{% if all_done %}
|
|
<div class="page-wide" style="display:flex;justify-content:center;align-items:flex-start;padding-top:40px;">
|
|
<div class="panel" style="width:720px; max-width:90%; padding:20px 24px;">
|
|
<div class="panel-title">Time Period Complete</div>
|
|
<div class="alert success" style="margin-bottom:16px; font-size:16px;">{{ flash }}</div>
|
|
|
|
<div style="display:flex;gap:12px;flex-wrap:wrap;">
|
|
<a class="btn" href="/review?timesheet_id={{ active_ts }}">Review Timesheets</a>
|
|
{% if can_edit %}
|
|
<a class="btn" href="/upload">Import New Time Period</a>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% else %}
|
|
<div class="page-wide">
|
|
<div class="editor-grid">
|
|
<!-- Sidebar sized to names -->
|
|
<aside class="panel sidebar">
|
|
<div class="panel-title">Employees</div>
|
|
<ul class="emp-list">
|
|
{% for e in employees %}
|
|
<!-- Add data-employee-id to enable right-click removal -->
|
|
<li data-employee-id="{{ e.id }}" class="{% if selected_employee and e.id == selected_employee.id %}active{% endif %}">
|
|
<a href="/viewer?timesheet_id={{ active_ts }}&employee_id={{ e.id }}" title="{{ e.name }}">{{ e.name }}</a>
|
|
</li>
|
|
{% endfor %}
|
|
{% if not employees or employees|length == 0 %}
|
|
<li class="empty">No employees yet for this timesheet.</li>
|
|
{% endif %}
|
|
</ul>
|
|
|
|
<div class="divider"></div>
|
|
|
|
<div class="panel-title">Actions</div>
|
|
<div class="actions">
|
|
{% if active_ts and can_edit %}
|
|
<a class="btn w-full btn-md btn-text-left" id="reassign-weeks-btn"
|
|
href="/assign-weeks?timesheet_id={{ active_ts }}">
|
|
Reassign weeks
|
|
</a>
|
|
|
|
<!-- Import another department -->
|
|
<a class="btn w-full" id="import-dept-btn" href="/import/department?timesheet_id={{ active_ts }}">
|
|
Import Department
|
|
</a>
|
|
|
|
<!-- Undo last department import -->
|
|
<form method="post" action="/import/department/undo-last" class="inline w-full"
|
|
onsubmit="return confirm('Undo last department import for this time period?');">
|
|
<input type="hidden" name="timesheet_id" value="{{ active_ts }}">
|
|
<button class="btn w-full btn-md btn-text-left" id="undo-dept-btn" type="submit">
|
|
Undo Department Import
|
|
</button>
|
|
</form>
|
|
{% endif %}
|
|
</div>
|
|
</aside>
|
|
|
|
<section class="panel main">
|
|
{% if flash %}<div class="alert success">{{ flash }}</div>{% endif %}
|
|
|
|
<!-- Toolbar -->
|
|
<div class="panel toolbar" style="gap:16px;flex-wrap:wrap;align-items:center;">
|
|
<form method="get" action="/viewer" class="inline" style="display:flex;align-items:center;gap:8px;">
|
|
{% if selected_employee %}
|
|
<input type="hidden" name="employee_id" value="{{ selected_employee.id }}">
|
|
{% endif %}
|
|
<label class="label mr-8">Time Period</label>
|
|
<select name="timesheet_id" class="select">
|
|
{% for p in period_options %}
|
|
<option value="{{ p.timesheet_id }}" {% if p.timesheet_id == active_ts %}selected{% endif %}>
|
|
{{ p.display }}
|
|
</option>
|
|
{% endfor %}
|
|
</select>
|
|
<button type="submit" class="btn primary btn-md btn-text-center">Load</button>
|
|
</form>
|
|
|
|
{% if active_ts and can_edit %}
|
|
<form method="post" action="/viewer/delete-period"
|
|
onsubmit="return confirm('Delete this entire time period instance (including department imports)? This cannot be undone.');"
|
|
class="inline" style="display:flex;align-items:center;">
|
|
<input type="hidden" name="timesheet_id" value="{{ active_ts }}">
|
|
<button class="btn danger btn-md btn-text-center" type="submit">Delete Time Period</button>
|
|
</form>
|
|
{% endif %}
|
|
|
|
{% if active_ts and selected_employee and can_edit %}
|
|
<form method="post" action="/viewer/submit" class="inline">
|
|
<input type="hidden" name="employee_id" value="{{ selected_employee.id }}">
|
|
<input type="hidden" name="timesheet_id" value="{{ active_ts }}">
|
|
<button type="submit" class="btn primary btn-md btn-text-center">Submit Timesheets</button>
|
|
</form>
|
|
{% endif %}
|
|
|
|
{% if can_edit %}
|
|
<a class="btn btn-md btn-text-center" href="/upload">Import New Time Period</a>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{% if active_ts and selected_employee %}
|
|
<!-- Employee settings: Carry over hours -->
|
|
<div class="panel" style="margin-top:12px;">
|
|
<div class="panel-title">Employee Settings</div>
|
|
<form id="carry-over-form-viewer" method="post" action="/viewer/update-employee-period"
|
|
class="inline" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;"
|
|
onsubmit="return saveCarryOverViewer(event)">
|
|
<input type="hidden" name="employee_id" value="{{ selected_employee.id }}">
|
|
<input type="hidden" name="timesheet_id" value="{{ active_ts }}">
|
|
<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="{{ employee_setting.carry_over_hours|fmt2 }}" {% if not can_edit %}disabled{% endif %}>
|
|
<button class="btn btn-md btn-text-center" 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>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if employees and selected_employee and grouped %}
|
|
{% if duplicates and duplicates|length > 0 %}
|
|
<div class="alert warn">
|
|
<div class="mb-8"><strong>Duplicate dates detected{% if selected_employee %} for {{ selected_employee.name }}{% endif %}:</strong></div>
|
|
<ul class="dup-list">
|
|
{% for d in duplicates %}
|
|
<li>{{ d.date.strftime("%A, %B %d, %Y") }} - {{ d.count }} rows</li>
|
|
{% endfor %}
|
|
</ul>
|
|
|
|
{% if selected_employee and active_ts and can_edit %}
|
|
<div class="mt-8 inline" style="gap:8px;">
|
|
<form method="post" action="/viewer/keep-duplicates">
|
|
<input type="hidden" name="employee_id" value="{{ selected_employee.id }}">
|
|
<input type="hidden" name="timesheet_id" value="{{ active_ts }}">
|
|
<button type="submit" class="btn btn-md btn-text-center">Reviewed</button>
|
|
</form>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div class="mt-8">Duplicate rows are highlighted in the grid until you click Reviewed.</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if pto_needs and pto_needs|length > 0 %}
|
|
<div class="alert warn" style="margin-top:12px;">
|
|
<div class="mb-8"><strong>PTO review required{% if selected_employee %} for {{ selected_employee.name }}{% endif %}:</strong> Missing PTO type on the dates below.</div>
|
|
<ul class="dup-list">
|
|
{% for r in pto_needs %}
|
|
<li>{{ r.work_date.strftime("%A, %B %d, %Y") }} – PTO {{ r.pto_hours|fmt2 }} hr(s)</li>
|
|
{% endfor %}
|
|
</ul>
|
|
{% if selected_employee and active_ts and can_edit %}
|
|
<div class="mt-8 inline" style="gap:8px;">
|
|
<form method="post" action="/viewer/review-pto-needs">
|
|
<input type="hidden" name="employee_id" value="{{ selected_employee.id }}">
|
|
<input type="hidden" name="timesheet_id" value="{{ active_ts }}">
|
|
<button class="btn btn-md btn-text-center" type="submit">Reviewed</button>
|
|
</form>
|
|
</div>
|
|
{% endif %}
|
|
<div class="mt-8">Rows needing PTO type stay highlighted until you click Reviewed. Saving rows does not clear highlights.</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if holiday_needs is defined and holiday_needs|length > 0 %}
|
|
<div class="alert warn" style="margin-top:12px;">
|
|
<div class="mb-8"><strong>Holiday review required{% if selected_employee %} for {{ selected_employee.name }}{% endif %}:</strong> Dates below include Holiday hours.</div>
|
|
<ul class="dup-list">
|
|
{% for r in holiday_needs %}
|
|
<li>{{ r.work_date.strftime("%A, %B %d, %Y") }} – Holiday {{ r.holiday_hours|fmt2 }} hr(s)</li>
|
|
{% endfor %}
|
|
</ul>
|
|
{% if selected_employee and active_ts and can_edit %}
|
|
<div class="mt-8 inline" style="gap:8px;">
|
|
<form method="post" action="/viewer/review-holiday-needs">
|
|
<input type="hidden" name="employee_id" value="{{ selected_employee.id }}">
|
|
<input type="hidden" name="timesheet_id" value="{{ active_ts }}">
|
|
<button class="btn btn-md btn-text-center" type="submit">Reviewed</button>
|
|
</form>
|
|
</div>
|
|
{% endif %}
|
|
<div class="mt-8">Holiday rows are highlighted until you click Reviewed. Saving rows does not clear highlights.</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if long_shift_needs and long_shift_needs|length > 0 %}
|
|
<div class="alert warn" style="margin-top:12px;">
|
|
<div class="mb-8"><strong>Long shifts detected{% if selected_employee %} for {{ selected_employee.name }}{% endif %} (over 10 hours):</strong></div>
|
|
<ul class="dup-list">
|
|
{% for r in long_shift_needs %}
|
|
<li>{{ r.work_date.strftime("%A, %B %d, %Y") }} – {{ r.total_hours|fmt2 }} hours</li>
|
|
{% endfor %}
|
|
</ul>
|
|
{% if selected_employee and active_ts and can_edit %}
|
|
<div class="mt-8 inline" style="gap:8px;">
|
|
<form method="post" action="/viewer/review-long-shifts">
|
|
<input type="hidden" name="employee_id" value="{{ selected_employee.id }}">
|
|
<input type="hidden" name="timesheet_id" value="{{ active_ts }}">
|
|
<button class="btn btn-md btn-text-center" type="submit">Reviewed</button>
|
|
</form>
|
|
</div>
|
|
{% endif %}
|
|
<div class="mt-8">Long-shift rows are highlighted in the grid until you click Reviewed.</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Summary tiles -->
|
|
<div class="panel totals" id="summary-tiles">
|
|
<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">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">Overtime</div><div class="t-val">{{ grouped.totals.overtime|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>
|
|
|
|
<!-- Grid -->
|
|
<div class="panel">
|
|
<div class="table-wrap">
|
|
<table class="table" style="width:100%;">
|
|
<!-- Column widths -->
|
|
<colgroup>
|
|
<col style="width:160px;">
|
|
<col style="width:240px;">
|
|
<col style="width:240px;">
|
|
<col style="width:90px;">
|
|
<col style="width:90px;">
|
|
<col style="width:120px;">
|
|
<col style="width:90px;">
|
|
<col style="width:90px;">
|
|
<col style="width:90px;">
|
|
<col style="width:92px;">
|
|
<col style="width:120px;">
|
|
</colgroup>
|
|
<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_long = (r.work_date in flagged_long_dates and r.work_date not in reviewed_long_dates) %}
|
|
{% set highlight_pto = (r.work_date in flagged_pto_dates and r.work_date not in reviewed_pto_dates) %}
|
|
{% 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 r.work_date in dup_dates %}dup-row{% endif %} {% if highlight_long %}row-long{% endif %} {% if highlight_pto %}needs-pto{% endif %} {% if highlight_holiday %}row-holiday{% endif %}" data-entry-id="{{ r.entry_id }}" tabindex="0">
|
|
<td>
|
|
{{ r.work_date.strftime("%A, %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>
|
|
|
|
<!-- CLOCK IN with calendar -->
|
|
<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>
|
|
|
|
<!-- CLOCK OUT with calendar -->
|
|
<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>
|
|
|
|
<!-- BREAK -->
|
|
<td class="num col-break">
|
|
<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="{{ active_ts }}">
|
|
<input form="row-{{ r.entry_id }}" type="hidden" name="employee_id" value="{{ selected_employee.id if selected_employee else '' }}">
|
|
<input class="input compact" 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>
|
|
|
|
<!-- TOTAL -->
|
|
<td class="num col-total">
|
|
<input class="input compact 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>
|
|
|
|
<!-- PTO (numeric) -->
|
|
<td class="num col-pto">
|
|
<input class="input compact pto-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>
|
|
|
|
<!-- PTO TYPE (select) -->
|
|
<td class="col-pto-type">
|
|
<select class="select compact" 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>
|
|
|
|
<!-- HOLIDAY -->
|
|
<td class="num col-holiday">
|
|
<input class="input compact" 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>
|
|
<!-- OTHER -->
|
|
<td class="num col-other">
|
|
<input class="input compact" 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>
|
|
<div class="row-actions">
|
|
<input form="row-{{ r.entry_id }}" type="hidden" name="redirect_to" value="/viewer?timesheet_id={{ active_ts }}&employee_id={{ selected_employee.id if selected_employee else '' }}">
|
|
{% if can_edit %}
|
|
<button type="button" class="btn primary sm" onclick="saveEntry(event, {{ r.entry_id }})">Save</button>
|
|
<!-- Delete button removed: use right-click context menu -->
|
|
{% else %}
|
|
<button type="button" class="btn primary sm" disabled>Save</button>
|
|
{% endif %}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Weekly summary (separate box under the table) -->
|
|
<div class="panel" id="weekly-summary">
|
|
<div class="table-wrap">
|
|
<table class="table compact" style="width:100%;">
|
|
<thead>
|
|
<tr>
|
|
<th></th>
|
|
<th class="num">Reg</th>
|
|
<th class="num">OT</th>
|
|
<th class="num">Week Total</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for w in grouped.weekly_summary %}
|
|
<tr>
|
|
<td>{{ w.label }}</td>
|
|
<td class="num mono">{{ w.reg|fmt2 }}</td>
|
|
<td class="num mono">{{ w.ot|fmt2 }}</td>
|
|
<td class="num mono">{{ w.all|fmt2 }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
<tr class="total">
|
|
<td>Total</td>
|
|
<td class="num mono">{{ (grouped.weekly_summary|sum(attribute='reg'))|fmt2 }}</td>
|
|
<td class="num mono">{{ (grouped.weekly_summary|sum(attribute='ot'))|fmt2 }}</td>
|
|
<td class="num mono">{{ (grouped.weekly_summary|sum(attribute='all'))|fmt2 }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
{% else %}
|
|
<div class="panel">
|
|
<div class="empty">No timesheet instances found.</div>
|
|
{% if can_edit %}
|
|
<div class="mt-8">
|
|
<a class="btn primary" href="/upload">Import New Time Period</a>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
</section>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Date+Time picker popover (shared) -->
|
|
<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>
|
|
|
|
<!-- Row context menu (right-click to delete entries) -->
|
|
<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(s)</button>
|
|
{% else %}
|
|
<button type="button" class="ctx-item danger" role="menuitem" disabled>Delete row</button>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- NEW: Employee sidebar context menu (right-click to remove employee from period) -->
|
|
<div id="emp-menu" class="ctx-menu" role="menu" hidden>
|
|
{% if can_edit %}
|
|
<button type="button" id="ctx-emp-remove" class="ctx-item danger" role="menuitem">Remove employee from this time period</button>
|
|
{% else %}
|
|
<button type="button" class="ctx-item danger" role="menuitem" disabled>Remove employee</button>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Toast -->
|
|
<div id="toast" class="toast" hidden>Time entry saved</div>
|
|
|
|
<style>
|
|
.table thead th { text-align: center; }
|
|
.table thead th.num { text-align: center; } /* default for main grid headers */
|
|
.table tbody td.num, .table tfoot td.num { text-align: right; }
|
|
.table { table-layout: fixed; }
|
|
.editor-grid { display: grid; grid-template-columns: minmax(220px, 340px) 1fr; gap: 12px; align-items: start; }
|
|
.panel.sidebar { padding: 12px; }
|
|
.emp-list { list-style: none; padding: 0; margin: 0; }
|
|
.emp-list li { margin: 0; }
|
|
.emp-list li a { display: block; padding: 6px 8px; font-size: 14px; line-height: 1.25; white-space: nowrap; }
|
|
.emp-list li.active a { font-weight: 600; }
|
|
.emp-list li.empty { padding: 6px 8px; }
|
|
.actions { display: flex; flex-direction: column; gap: 10px; }
|
|
.actions .btn.w-full { width: 100%; white-space: nowrap; }
|
|
.actions form { margin: 0; }
|
|
.panel.toolbar { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px; align-items: center; }
|
|
.panel.toolbar .inline { display: flex; gap: 8px; align-items: center; }
|
|
.panel.toolbar .inline .label { white-space: nowrap; }
|
|
.panel.toolbar .inline .select { flex: 1; min-width: 160px; }
|
|
.panel.toolbar .btn, .panel.toolbar a.btn { width: 100%; }
|
|
.btn { min-height: 36px; line-height: 1; padding: 8px 12px; }
|
|
.btn.sm { min-height: 28px; padding: 6px 10px; }
|
|
.btn-md { min-height: 36px; padding: 8px 12px; font-size: 14px; }
|
|
.btn-text-left { display:inline-flex; justify-content:flex-start; text-align:left; }
|
|
.btn-text-center{ display:inline-flex; justify-content:center; text-align:center; }
|
|
.btn-text-right { display:inline-flex; justify-content:flex-end; text-align:right; }
|
|
#import-dept-btn { background:#2563eb; border:1px solid #2563eb; color:#fff; }
|
|
#import-dept-btn:hover { filter:brightness(0.98); }
|
|
#undo-dept-btn { font-weight:600; border-radius:8px; letter-spacing:.2px; }
|
|
#reassign-weeks-btn { font-weight:600; border-radius:8px; letter-spacing:.2px; }
|
|
.badge { display:inline-block; padding:2px 6px; font-size:11px; border-radius:10px; border:1px solid #d08; color:#a0063a; background:#ffe5ef; }
|
|
.badge.warn { border-color:#d08; color:#a0063a; background:#ffe5ef; }
|
|
.row-holiday { background: #fff6e6; }
|
|
.badge.holiday { border: 1px solid #ffc470; color: #8a5200; background: #fff4e0; }
|
|
.needs-pto { background: #fff6d5; }
|
|
.row-long { background: #fff6d5; }
|
|
.dup-row { background: #fff6d5; }
|
|
.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; }
|
|
.input.compact, .select.compact { width: 100%; min-width: 0; padding: 2px 4px; }
|
|
.input.compact { text-align: right; }
|
|
.col-break .input.compact,
|
|
.col-total .input.compact,
|
|
.col-holiday .input.compact,
|
|
.col-other .input.compact { width: 10ch; }
|
|
.pto-input { width: 100%; }
|
|
.col-pto-type .select.compact { width: 100%; min-width: 0; }
|
|
.table th, .table td { overflow: hidden; }
|
|
.table td.clock-cell { overflow: visible; }
|
|
.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; }
|
|
.total-hours { background:#fff; }
|
|
.row-actions { display: grid; grid-template-columns: 1fr; gap: 6px; }
|
|
.ctx-menu { position: fixed; z-index: 2000; min-width: 160px; background:#fff; border:1px solid var(--line); border-radius:8px; box-shadow: 0 10px 24px rgba(2,6,23,0.18); padding:6px; }
|
|
.ctx-item { display:block; width:100%; text-align:left; padding:8px 10px; border:0; background:transparent; border-radius:6px; font-size:13px; cursor:pointer; }
|
|
.ctx-item:hover { background:#f3f4f6; }
|
|
.ctx-item.danger { color:#b91c1c; }
|
|
.ctx-item[disabled] { opacity:.5; cursor:not-allowed; }
|
|
.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 { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:8px; }
|
|
.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; }
|
|
|
|
/* Align weekly summary headers with right-aligned numbers below */
|
|
#weekly-summary thead th.num { text-align: right; }
|
|
|
|
/* Multi-select row highlight */
|
|
.table tbody tr.is-selected {
|
|
outline: 2px solid #fdba74;
|
|
outline-offset: -2px;
|
|
background: #fff7ed;
|
|
}
|
|
|
|
@media (max-width: 1260px) { .editor-grid { grid-template-columns: minmax(220px, 300px) 1fr; } }
|
|
@media (max-width: 960px) { .editor-grid { grid-template-columns: minmax(180px, 260px) 1fr; } .clock-row { grid-template-columns: 1fr 28px; } .clock-text { width:auto; } .panel.toolbar { grid-template-columns: 1fr; } }
|
|
</style>
|
|
|
|
<script>
|
|
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');
|
|
|
|
// If Holiday hours are present, keep showing "Holiday" regardless of PTO type
|
|
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);}
|
|
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 = `/viewer?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;
|
|
|
|
const newWeekly = doc.querySelector('#weekly-summary');
|
|
const curWeekly = document.querySelector('#weekly-summary');
|
|
if (newWeekly && curWeekly) curWeekly.innerHTML = newWeekly.innerHTML;
|
|
} catch (e) {
|
|
console.warn('refreshSummary failed', e);
|
|
}
|
|
}
|
|
|
|
async function saveCarryOverViewer(ev) {
|
|
if (ev) ev.preventDefault();
|
|
if (!window.can_edit) { showToast('Read-only mode'); return false; }
|
|
const form = document.getElementById('carry-over-form-viewer');
|
|
if (!form) return false;
|
|
|
|
const fd = new FormData(form);
|
|
try {
|
|
const res = await fetch('/viewer/update-employee-period', {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
body: fd
|
|
});
|
|
if (!res.ok) { showToast('Save failed (' + res.status + ')'); return false; }
|
|
|
|
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;
|
|
}
|
|
|
|
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 hiddenId = (field === 'clock_in' ? 'ci-input-' : 'co-input-') + entryId;
|
|
const cell = document.getElementById(cellId);
|
|
const hidden = document.getElementById(hiddenId);
|
|
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) => {
|
|
if (!pop.hidden && !pop.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';
|
|
} 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
const tsInput = document.querySelector('#row-'+entryId+' input[name="timesheet_id"]');
|
|
const empInput = document.querySelector('#row-'+entryId+' input[name="employee_id"]');
|
|
await refreshSummary(tsInput ? tsInput.value : '{{ active_ts }}',
|
|
empInput ? empInput.value : '{{ selected_employee.id if selected_employee else "" }}');
|
|
|
|
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(function(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';
|
|
} else if (ptoType) {
|
|
if (ciText) ciText.textContent = ptoType;
|
|
if (coText) coText.textContent = ptoType;
|
|
} else {
|
|
if (ciText) ciText.textContent = (ciCell ? (ciCell.getAttribute('data-ci') || '-') : '-');
|
|
if (coText) coText.textContent = (coCell ? (coCell.getAttribute('data-co') || '-') : '-');
|
|
}
|
|
}
|
|
|
|
const tsInput = document.querySelector('#row-'+entryId+' input[name="timesheet_id"]');
|
|
const empInput = document.querySelector('#row-'+entryId+' input[name="employee_id"]');
|
|
await refreshSummary(tsInput ? tsInput.value : '{{ active_ts }}',
|
|
empInput ? empInput.value : '{{ selected_employee.id if selected_employee else "" }}');
|
|
|
|
showToast('Time entry saved');
|
|
} catch (e) {
|
|
console.error('saveEntry error', e);
|
|
showToast('Save failed');
|
|
}
|
|
}
|
|
|
|
async function deleteEntry(ev, entryId) {
|
|
if (ev) ev.preventDefault();
|
|
if (!window.can_edit) return;
|
|
if (!confirm('Delete this time entry? This cannot be undone.')) return;
|
|
|
|
const row = document.getElementById('row-tr-' + entryId);
|
|
const tsInput = document.querySelector('#row-'+entryId+' input[name="timesheet_id"]');
|
|
const empInput = document.querySelector('#row-'+entryId+' input[name="employee_id"]');
|
|
|
|
const fd = new FormData();
|
|
fd.append('entry_id', entryId);
|
|
fd.append('timesheet_id', tsInput ? tsInput.value : '{{ active_ts }}');
|
|
|
|
try {
|
|
const res = await fetch('/timesheet/delete-entry', {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
body: fd
|
|
});
|
|
if (!res.ok) {
|
|
alert('Delete failed (' + res.status + ').');
|
|
return;
|
|
}
|
|
const data = await res.json();
|
|
if (!data || !data.ok) {
|
|
alert('Delete failed.');
|
|
return;
|
|
}
|
|
|
|
if (row) row.remove();
|
|
await refreshSummary(tsInput ? tsInput.value : '{{ active_ts }}',
|
|
empInput ? empInput.value : '{{ selected_employee.id if selected_employee else "" }}');
|
|
showToast('Entry deleted');
|
|
} catch (e) {
|
|
console.error('delete entry error', e);
|
|
alert('Unable to delete entry. Please try again.');
|
|
}
|
|
}
|
|
|
|
/* Multi-select + bulk delete via right-click */
|
|
(function multiSelectAndBulkDelete() {
|
|
var gridBody = document.querySelector('.table tbody');
|
|
if (!gridBody) return;
|
|
|
|
function getRow(el) { return el && el.closest ? el.closest('tr[data-entry-id]') : null; }
|
|
function getId(row) { var id = row ? row.getAttribute('data-entry-id') : null; return id ? parseInt(id, 10) : null; }
|
|
function selectedRows() { return Array.from(gridBody.querySelectorAll('tr[data-entry-id].is-selected')); }
|
|
function clearSelection() { selectedRows().forEach(function(r){ r.classList.remove('is-selected'); }); }
|
|
|
|
// Click to toggle selection; Ctrl/Cmd supports additive multi-select
|
|
gridBody.addEventListener('click', function(ev) {
|
|
if (ev.target.closest('button, input, select, a, label')) return;
|
|
var row = getRow(ev.target);
|
|
if (!row) return;
|
|
|
|
var additive = ev.ctrlKey || ev.metaKey;
|
|
if (!additive) {
|
|
if (!row.classList.contains('is-selected')) clearSelection();
|
|
}
|
|
row.classList.toggle('is-selected');
|
|
});
|
|
|
|
// Clear when clicking outside the table
|
|
document.addEventListener('click', function(ev) {
|
|
if (!gridBody.contains(ev.target)) clearSelection();
|
|
});
|
|
})();
|
|
|
|
/* Context menu with bulk delete support */
|
|
(function setupRowContextMenu() {
|
|
var menu = document.getElementById('row-menu');
|
|
var deleteBtn = document.getElementById('ctx-delete');
|
|
var gridBody = document.querySelector('.table tbody');
|
|
var targetEntryId = null;
|
|
var touchTimer = null;
|
|
var touchStartXY = null;
|
|
|
|
if (!menu || !gridBody) return;
|
|
|
|
function selectedRows() { return Array.from(gridBody.querySelectorAll('tr[data-entry-id].is-selected')); }
|
|
function selectedIds() { return selectedRows().map(function(r){ return parseInt(r.getAttribute('data-entry-id'), 10); }).filter(Boolean); }
|
|
|
|
function closeMenu() { if (!menu.hidden) { menu.hidden = true; targetEntryId = null; } }
|
|
function placeMenu(x, y) {
|
|
var vw = window.innerWidth, vh = window.innerHeight;
|
|
var rect = menu.getBoundingClientRect(); var left = x, top = y;
|
|
if (left + rect.width > vw - 8) left = Math.max(8, vw - rect.width - 8);
|
|
if (top + rect.height > vh - 8) top = Math.max(8, vh - rect.height - 8);
|
|
menu.style.left = left + 'px'; menu.style.top = top + 'px';
|
|
}
|
|
function openMenuForRow(entryId, clientX, clientY) {
|
|
if (!window.can_edit) return;
|
|
targetEntryId = entryId; menu.hidden = false; placeMenu(clientX, clientY);
|
|
if (deleteBtn && !deleteBtn.disabled) deleteBtn.focus();
|
|
}
|
|
|
|
gridBody.addEventListener('contextmenu', function(e) {
|
|
var tr = e.target.closest('tr[data-entry-id]'); if (!tr) return;
|
|
e.preventDefault(); openMenuForRow(tr.getAttribute('data-entry-id'), e.clientX, e.clientY);
|
|
});
|
|
gridBody.addEventListener('keydown', function(e) {
|
|
if (!((e.shiftKey && e.key === 'F10') || e.key === 'ContextMenu')) return;
|
|
var tr = e.target.closest('tr[data-entry-id]'); if (!tr) return;
|
|
e.preventDefault(); var r = tr.getBoundingClientRect(); openMenuForRow(tr.getAttribute('data-entry-id'), r.right - 8, r.top + 16);
|
|
});
|
|
|
|
gridBody.addEventListener('touchstart', function(e) {
|
|
var t = e.target.closest('tr[data-entry-id]'); if (!t) return;
|
|
touchStartXY = (e.touches && e.touches[0]) ? { x: e.touches[0].clientX, y: e.touches[0].clientY } : null;
|
|
clearTimeout(touchTimer);
|
|
touchTimer = setTimeout(function(){
|
|
if (!touchStartXY) return;
|
|
openMenuForRow(t.getAttribute('data-entry-id'), touchStartXY.x, touchStartXY.y);
|
|
}, 600);
|
|
}, { passive: true });
|
|
gridBody.addEventListener('touchmove', function(e) {
|
|
if (!touchStartXY) return;
|
|
var pt = e.touches && e.touches[0]; if (!pt) return;
|
|
var dx = Math.abs(pt.clientX - touchStartXY.x), dy = Math.abs(pt.clientY - touchStartXY.y);
|
|
if (dx > 10 || dy > 10) { clearTimeout(touchTimer); touchTimer = null; }
|
|
}, { passive: true });
|
|
['touchend','touchcancel'].forEach(function(evt){
|
|
gridBody.addEventListener(evt, function(){ clearTimeout(touchTimer); touchTimer = null; touchStartXY = null; }, { passive: true });
|
|
});
|
|
|
|
async function bulkDelete(ids) {
|
|
if (!ids || !ids.length) return;
|
|
var fd = new FormData();
|
|
fd.append('timesheet_id', '{{ active_ts }}');
|
|
fd.append('entry_ids', ids.join(','));
|
|
try {
|
|
var res = await fetch('/timesheet/delete-entries', { method: 'POST', credentials: 'same-origin', body: fd });
|
|
var json = await res.json();
|
|
if (!json || !json.ok) { alert('Delete failed. Please try again.'); return; }
|
|
ids.forEach(function(id){
|
|
var r = document.querySelector('tr[data-entry-id="' + id + '"]');
|
|
if (r && r.parentNode) r.parentNode.removeChild(r);
|
|
});
|
|
await refreshSummary('{{ active_ts }}', '{{ selected_employee.id if selected_employee else "" }}');
|
|
showToast(ids.length === 1 ? 'Entry deleted' : ('Deleted ' + ids.length + ' rows'));
|
|
} catch (e) {
|
|
alert('Delete failed: ' + (e && e.message ? e.message : e));
|
|
}
|
|
}
|
|
|
|
if (deleteBtn) {
|
|
deleteBtn.addEventListener('click', async function() {
|
|
var ids = selectedIds();
|
|
if (ids.length > 0) {
|
|
if (!confirm('Delete ' + ids.length + ' selected row(s)? This cannot be undone.')) { closeMenu(); return; }
|
|
await bulkDelete(ids);
|
|
} else if (targetEntryId) {
|
|
if (!confirm('Delete this row? This cannot be undone.')) { closeMenu(); return; }
|
|
await bulkDelete([parseInt(targetEntryId, 10)]);
|
|
}
|
|
closeMenu();
|
|
});
|
|
}
|
|
|
|
window.addEventListener('mousedown', function(e){ if (!menu.hidden && !menu.contains(e.target)) closeMenu(); });
|
|
window.addEventListener('scroll', closeMenu, true);
|
|
window.addEventListener('resize', closeMenu);
|
|
window.addEventListener('keydown', function(e){ if (e.key === 'Escape') closeMenu(); });
|
|
})();
|
|
|
|
/* NEW: Employee right-click removal in sidebar */
|
|
(function setupEmployeeContextMenu() {
|
|
const list = document.querySelector('.emp-list');
|
|
const menu = document.getElementById('emp-menu');
|
|
const removeBtn = document.getElementById('ctx-emp-remove');
|
|
let targetEmpId = null;
|
|
|
|
if (!list || !menu) return;
|
|
|
|
function placeMenu(x, y) {
|
|
const vw = window.innerWidth, vh = window.innerHeight;
|
|
const rect = menu.getBoundingClientRect();
|
|
let left = x, top = y;
|
|
if (left + rect.width > vw - 8) left = Math.max(8, vw - rect.width - 8);
|
|
if (top + rect.height > vh - 8) top = Math.max(8, vh - rect.height - 8);
|
|
menu.style.left = left + 'px';
|
|
menu.style.top = top + 'px';
|
|
}
|
|
function closeMenu() { menu.hidden = true; targetEmpId = null; }
|
|
|
|
list.addEventListener('contextmenu', function(e) {
|
|
const li = e.target.closest('li[data-employee-id]');
|
|
if (!li) return;
|
|
e.preventDefault();
|
|
targetEmpId = li.getAttribute('data-employee-id');
|
|
if (!targetEmpId) return;
|
|
menu.hidden = false;
|
|
placeMenu(e.clientX, e.clientY);
|
|
if (removeBtn && !removeBtn.disabled) removeBtn.focus();
|
|
});
|
|
|
|
window.addEventListener('mousedown', function(e) {
|
|
if (!menu.hidden && !menu.contains(e.target)) closeMenu();
|
|
});
|
|
window.addEventListener('scroll', closeMenu, true);
|
|
window.addEventListener('resize', closeMenu);
|
|
window.addEventListener('keydown', function(e){ if (e.key === 'Escape') closeMenu(); });
|
|
|
|
if (removeBtn) {
|
|
removeBtn.addEventListener('click', async function() {
|
|
if (!targetEmpId) { closeMenu(); return; }
|
|
if (!window.can_edit) { closeMenu(); return; }
|
|
if (!confirm('Remove this employee and all of their time entries from this time period? This cannot be undone.')) {
|
|
closeMenu();
|
|
return;
|
|
}
|
|
const fd = new FormData();
|
|
fd.append('timesheet_id', '{{ active_ts }}');
|
|
fd.append('employee_id', targetEmpId);
|
|
|
|
try {
|
|
const res = await fetch('/viewer/remove-employee', { method: 'POST', credentials: 'same-origin', body: fd });
|
|
if (!res.ok) { alert('Remove failed (' + res.status + ')'); closeMenu(); return; }
|
|
const json = await res.json();
|
|
if (!json || !json.ok) { alert('Remove failed'); closeMenu(); return; }
|
|
|
|
// Remove the employee from the sidebar quickly
|
|
const li = document.querySelector('.emp-list li[data-employee-id="' + targetEmpId + '"]');
|
|
if (li && li.parentNode) li.parentNode.removeChild(li);
|
|
|
|
// If we just removed the selected employee, navigate to the viewer without employee_id
|
|
const currentSel = '{{ selected_employee.id if selected_employee else "" }}';
|
|
if (String(currentSel) === String(targetEmpId)) {
|
|
window.location.href = '/viewer?timesheet_id={{ active_ts }}';
|
|
} else {
|
|
window.location.reload();
|
|
}
|
|
} catch (e) {
|
|
alert('Unable to remove employee. Please try again.');
|
|
} finally {
|
|
closeMenu();
|
|
}
|
|
});
|
|
}
|
|
})();
|
|
</script>
|
|
{% endif %}
|
|
{% endblock %} |