361 lines
17 KiB
HTML
Executable File
361 lines
17 KiB
HTML
Executable File
{% extends "base.html" %}
|
|
{% block title %}Mobile Phones - Queensway Europe Telco Portal{% endblock %}
|
|
{% block content %}
|
|
<div class="d-flex flex-wrap justify-content-between align-items-center mb-4 gap-2">
|
|
<h2 class="mb-0 d-flex align-items-center gap-2">
|
|
{% include "icons/phone.svg" %} Mobile Phones
|
|
<span class="carrier-badge" title="Carrier">Orange</span>
|
|
</h2>
|
|
{% if session.role == 'admin' %}
|
|
<button type="button" class="btn btn-success d-inline-flex align-items-center gap-2" onclick="openPhoneModal(null)">
|
|
{% include "icons/plus-circle.svg" %} Add Phone
|
|
</button>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<div class="row g-3 mb-4">
|
|
<div class="col-6 col-lg-3">
|
|
<div class="stat-card">
|
|
<div class="stat-icon stat-icon-total">{% include "icons/collection.svg" %}</div>
|
|
<div>
|
|
<div class="stat-value">{{ stats.total }}</div>
|
|
<div class="stat-label">Total Phones</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6 col-lg-3">
|
|
<div class="stat-card">
|
|
<div class="stat-icon stat-icon-warning">{% include "icons/exclamation-triangle.svg" %}</div>
|
|
<div>
|
|
<div class="stat-value">{{ stats.expiring_soon }}</div>
|
|
<div class="stat-label">Expiring Soon</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6 col-lg-3">
|
|
<div class="stat-card">
|
|
<div class="stat-icon stat-icon-danger">{% include "icons/calendar-x.svg" %}</div>
|
|
<div>
|
|
<div class="stat-value">{{ stats.expired }}</div>
|
|
<div class="stat-label">Expired</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6 col-lg-3">
|
|
<div class="stat-card">
|
|
<div class="stat-icon stat-icon-success">{% include "icons/calendar-check.svg" %}</div>
|
|
<div>
|
|
<div class="stat-value">{{ stats.eligible_now }}</div>
|
|
<div class="stat-label">Eligible Now</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6 col-lg-3">
|
|
<div class="stat-card">
|
|
<div class="stat-icon stat-icon-headoffice">{% include "icons/building.svg" %}</div>
|
|
<div>
|
|
<div class="stat-value">{{ stats.headoffice }}</div>
|
|
<div class="stat-label">QWE Headoffice · {{ stats.headoffice_pct }}%</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6 col-lg-3">
|
|
<div class="stat-card">
|
|
<div class="stat-icon stat-icon-restaurant">{% include "icons/shop.svg" %}</div>
|
|
<div>
|
|
<div class="stat-value">{{ stats.restaurant }}</div>
|
|
<div class="stat-label">KFC Restaurant · {{ stats.restaurant_pct }}%</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6 col-lg-3">
|
|
<div class="stat-card">
|
|
<div class="stat-icon stat-icon-verified">{% include "icons/patch-check.svg" %}</div>
|
|
<div>
|
|
<div class="stat-value">{{ stats.verified }}</div>
|
|
<div class="stat-label">Verified</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6 col-lg-3">
|
|
<div class="stat-card">
|
|
<div class="stat-icon stat-icon-unverified">{% include "icons/patch-exclamation.svg" %}</div>
|
|
<div>
|
|
<div class="stat-value">{{ stats.not_verified }}</div>
|
|
<div class="stat-label">Not Verified</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modern-card mb-0">
|
|
<div class="table-toolbar d-flex flex-wrap align-items-center justify-content-between gap-2">
|
|
<div class="search-box">
|
|
{% include "icons/search.svg" %}
|
|
<input type="text" id="phoneSearch" class="form-control" placeholder="Search phones, owners, venues, comments...">
|
|
</div>
|
|
<div class="text-muted small" id="phoneRowCount"></div>
|
|
</div>
|
|
|
|
<div class="table-responsive">
|
|
<table class="table table-hover align-middle mb-0 modern-table" id="phonesTable">
|
|
<thead>
|
|
<tr>
|
|
{% macro sort_link(field, label) -%}
|
|
{% set next_dir = 'desc' if sort == field and direction == 'asc' else 'asc' %}
|
|
<th>
|
|
<a href="{{ url_for('phones_page', sort=field, dir=next_dir) }}" class="sort-link d-inline-flex align-items-center gap-1">
|
|
{{ label }}
|
|
{% if sort == field %}
|
|
<span class="sort-active">{{ '▲' if direction == 'asc' else '▼' }}</span>
|
|
{% else %}
|
|
<span class="sort-inactive">↕</span>
|
|
{% endif %}
|
|
</a>
|
|
</th>
|
|
{%- endmacro %}
|
|
{{ sort_link('phone_number', 'Phone Number') }}
|
|
{{ sort_link('current_owner', 'Current Owner') }}
|
|
{{ sort_link('plan', 'Plan') }}
|
|
{{ sort_link('contract_expiration', 'Contract Expiration') }}
|
|
{{ sort_link('eligible_for_replacement', 'Eligibility for Replacement') }}
|
|
{{ sort_link('venue', 'Venue') }}
|
|
{{ sort_link('verified', 'Verified') }}
|
|
{{ sort_link('comment', 'Comment') }}
|
|
{% if session.role == 'admin' %}<th class="text-end">Actions</th>{% endif %}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for p in phones %}
|
|
<tr>
|
|
<td class="fw-semibold">{{ p.phone_number }}</td>
|
|
<td>{{ p.current_owner or "" }}</td>
|
|
<td>{{ p.plan or "" }}</td>
|
|
<td>
|
|
{% if p.contract_expiration %}
|
|
{% if p.contract_expiration < today %}
|
|
<span class="badge rounded-pill bg-danger-subtle text-danger-emphasis">Expired</span>
|
|
{% elif (p.contract_expiration - today).days <= 60 %}
|
|
<span class="badge rounded-pill bg-warning-subtle text-warning-emphasis">Expiring soon</span>
|
|
{% endif %}
|
|
<span class="d-block text-muted small mt-1">{{ p.contract_expiration.strftime("%d-%m-%Y") }}</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>
|
|
{% if not p.eligible_for_replacement or p.eligible_for_replacement <= today %}
|
|
<span class="badge rounded-pill bg-success-subtle text-success-emphasis">Eligible now</span>
|
|
{% else %}
|
|
<span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis">From {{ p.eligible_for_replacement.strftime("%d-%m-%Y") }}</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>{% if p.venue %}<span class="badge rounded-pill text-bg-light border">{{ p.venue }}</span>{% endif %}</td>
|
|
<td>
|
|
{% if p.verified == "Yes" %}
|
|
<span class="badge rounded-pill bg-success-subtle text-success-emphasis">Yes</span>
|
|
{% elif p.verified == "No" %}
|
|
<span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis">No</span>
|
|
{% endif %}
|
|
</td>
|
|
<td class="text-truncate" style="max-width: 220px;" title="{{ p.comment or '' }}">{{ p.comment or "" }}</td>
|
|
{% if session.role == 'admin' %}
|
|
<td class="text-end">
|
|
<div class="d-inline-flex gap-1">
|
|
<button type="button" class="btn btn-icon btn-icon-primary" title="Edit"
|
|
onclick='openPhoneModal({{ p.to_dict() | tojson }})'>{% include "icons/pencil-square.svg" %}</button>
|
|
<button type="button" class="btn btn-icon btn-icon-danger" title="Delete"
|
|
onclick="deletePhone({{ p.id }}, '{{ p.phone_number }}')">{% include "icons/trash.svg" %}</button>
|
|
</div>
|
|
</td>
|
|
{% endif %}
|
|
</tr>
|
|
{% else %}
|
|
<tr><td colspan="{{ 9 if session.role == 'admin' else 8 }}" class="text-center text-muted py-5">No phones yet.</td></tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div id="phoneNoResults" class="text-center text-muted py-5" hidden>No phones match your search.</div>
|
|
</div>
|
|
|
|
<!-- Edit / Add modal -->
|
|
<div class="modal fade" id="phoneModal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<form id="phoneForm">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="phoneModalTitle">Edit Phone</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<input type="hidden" id="phoneId">
|
|
<div class="mb-3">
|
|
<label class="form-label">Phone Number</label>
|
|
<input type="text" class="form-control" id="phoneNumber" required>
|
|
<div class="form-text" id="phoneNumberHelp" hidden>Phone number can't be changed after the phone is added.</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Current Owner</label>
|
|
<input type="text" class="form-control" id="phoneOwner">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Plan</label>
|
|
<select class="form-select" id="phonePlan">
|
|
<option value="">-- Select Plan --</option>
|
|
{% for option in plan_options %}
|
|
<option value="{{ option }}">{{ option }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
<div class="form-text" id="phonePlanHelp" hidden>Plan can't be changed after the phone is added.</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Contract Expiration</label>
|
|
<input type="date" class="form-control" id="phoneContractExpiration">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Eligibility for Replacement</label>
|
|
<input type="date" class="form-control" id="phoneEligible">
|
|
<div class="form-text">Leave blank if there is no restriction (eligible now).</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Venue</label>
|
|
<select class="form-select" id="phoneVenue">
|
|
<option value="">-- Select Venue --</option>
|
|
{% for option in venue_options %}
|
|
<option value="{{ option }}">{{ option }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Verified</label>
|
|
<select class="form-select" id="phoneVerified">
|
|
<option value="">-- Select --</option>
|
|
{% for option in verified_options %}
|
|
<option value="{{ option }}">{{ option }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Comment</label>
|
|
<textarea class="form-control" id="phoneComment" rows="3"></textarea>
|
|
</div>
|
|
<div id="phoneError" class="text-danger small"></div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="submit" class="btn btn-primary">Save</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
const phoneModalEl = document.getElementById('phoneModal');
|
|
const phoneModal = new bootstrap.Modal(phoneModalEl);
|
|
|
|
function openPhoneModal(phone) {
|
|
document.getElementById('phoneForm').reset();
|
|
document.getElementById('phoneError').textContent = '';
|
|
const isEdit = !!phone;
|
|
|
|
const numberInput = document.getElementById('phoneNumber');
|
|
const planSelect = document.getElementById('phonePlan');
|
|
numberInput.readOnly = isEdit;
|
|
planSelect.disabled = isEdit;
|
|
document.getElementById('phoneNumberHelp').hidden = !isEdit;
|
|
document.getElementById('phonePlanHelp').hidden = !isEdit;
|
|
|
|
if (isEdit) {
|
|
document.getElementById('phoneModalTitle').textContent = 'Edit Phone';
|
|
document.getElementById('phoneId').value = phone.id;
|
|
numberInput.value = phone.phone_number || '';
|
|
planSelect.value = phone.plan || '';
|
|
document.getElementById('phoneContractExpiration').value = phone.contract_expiration || '';
|
|
document.getElementById('phoneEligible').value = phone.eligible_for_replacement || '';
|
|
document.getElementById('phoneOwner').value = phone.current_owner || '';
|
|
document.getElementById('phoneVenue').value = phone.venue || '';
|
|
document.getElementById('phoneVerified').value = phone.verified || '';
|
|
document.getElementById('phoneComment').value = phone.comment || '';
|
|
} else {
|
|
document.getElementById('phoneModalTitle').textContent = 'Add Phone';
|
|
document.getElementById('phoneId').value = '';
|
|
}
|
|
phoneModal.show();
|
|
}
|
|
|
|
document.getElementById('phoneForm').addEventListener('submit', async function (e) {
|
|
e.preventDefault();
|
|
const id = document.getElementById('phoneId').value;
|
|
const payload = {
|
|
phone_number: document.getElementById('phoneNumber').value,
|
|
plan: document.getElementById('phonePlan').value,
|
|
contract_expiration: document.getElementById('phoneContractExpiration').value,
|
|
eligible_for_replacement: document.getElementById('phoneEligible').value,
|
|
current_owner: document.getElementById('phoneOwner').value,
|
|
venue: document.getElementById('phoneVenue').value,
|
|
verified: document.getElementById('phoneVerified').value,
|
|
comment: document.getElementById('phoneComment').value,
|
|
};
|
|
const url = id ? `/api/phones/${id}` : '/api/phones';
|
|
const method = id ? 'PUT' : 'POST';
|
|
const resp = await fetch(url, {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (resp.ok) {
|
|
window.location.reload();
|
|
} else {
|
|
const data = await resp.json().catch(() => ({}));
|
|
document.getElementById('phoneError').textContent = data.error || 'Failed to save. Please check the fields.';
|
|
}
|
|
});
|
|
|
|
async function deletePhone(id, phoneNumber) {
|
|
if (!confirm(`Delete phone ${phoneNumber}? This cannot be undone.`)) return;
|
|
const resp = await fetch(`/api/phones/${id}`, { method: 'DELETE' });
|
|
if (resp.ok) {
|
|
window.location.reload();
|
|
} else {
|
|
alert('Failed to delete.');
|
|
}
|
|
}
|
|
|
|
// Live client-side search across every visible column of the phones table.
|
|
(function () {
|
|
const searchInput = document.getElementById('phoneSearch');
|
|
const table = document.getElementById('phonesTable');
|
|
const noResults = document.getElementById('phoneNoResults');
|
|
const rowCount = document.getElementById('phoneRowCount');
|
|
if (!searchInput || !table) return;
|
|
const rows = Array.from(table.querySelectorAll('tbody tr')).filter(tr => tr.children.length > 1);
|
|
const totalRows = rows.length;
|
|
|
|
// Strip accents/diacritics so a search for "n" also matches "ň", "z" matches "ž", etc.
|
|
function foldDiacritics(str) {
|
|
return str.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
|
}
|
|
|
|
const rowText = rows.map(row => foldDiacritics(row.textContent.toLowerCase()));
|
|
|
|
function applyFilter() {
|
|
const q = foldDiacritics(searchInput.value.trim().toLowerCase());
|
|
let visible = 0;
|
|
rows.forEach((row, i) => {
|
|
const match = !q || rowText[i].includes(q);
|
|
row.hidden = !match;
|
|
if (match) visible++;
|
|
});
|
|
noResults.hidden = !(q && visible === 0);
|
|
table.closest('.table-responsive').hidden = q && visible === 0;
|
|
rowCount.textContent = q ? `${visible} of ${totalRows} phones` : `${totalRows} phones`;
|
|
}
|
|
|
|
searchInput.addEventListener('input', applyFilter);
|
|
applyFilter();
|
|
})();
|
|
</script>
|
|
{% endblock %}
|