Initial commit: Queensway Europe Telco Portal (Flask + SQLite, Docker deploy)
This commit is contained in:
Executable
+360
@@ -0,0 +1,360 @@
|
||||
{% 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 %}
|
||||
Reference in New Issue
Block a user