bmc_hub/app/prepaid/frontend/index.html
Christian eacbd36e83 feat: Implement Transcription Service for audio files using Whisper API
- Added `transcription_service.py` to handle audio transcription via Whisper API.
- Integrated logging for transcription processes and error handling.
- Supported audio format checks based on configuration settings.

docs: Create Ordre System Implementation Plan

- Drafted comprehensive implementation plan for e-conomic order integration.
- Outlined business requirements, database changes, backend and frontend implementation details.
- Included testing plan and deployment steps for the new order system.

feat: Add AI prompts and regex action capabilities

- Created `ai_prompts` table for storing custom AI prompts.
- Added regex extraction and linking action to email workflow actions.

feat: Introduce conversations module for transcribed audio

- Created `conversations` table to store transcribed conversations with relevant metadata.
- Added indexing for customer, ticket, and user linkage.
- Implemented full-text search capabilities for Danish language.

fix: Add category column to conversations for classification

- Added `category` column to `conversations` table for better conversation classification.
2026-01-11 19:23:21 +01:00

657 lines
25 KiB
HTML

{% extends "base.html" %}
{% block title %}Prepaid Cards - BMC Hub{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Header -->
<div class="row mb-4">
<div class="col">
<h1 class="h3 mb-0">💳 Prepaid Cards</h1>
<p class="text-muted">Oversigt og kontrol af kunders timekort</p>
</div>
<div class="col-auto">
<button class="btn btn-primary" onclick="openCreateModal()">
<i class="bi bi-plus-circle"></i> Opret Nyt Kort
</button>
</div>
</div>
<!-- Stats Row -->
<div class="row g-3 mb-4" id="statsCards">
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="rounded-circle bg-success bg-opacity-10 p-3">
<i class="bi bi-credit-card-2-front text-success fs-4"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<p class="text-muted small mb-1">Aktive Kort</p>
<h3 class="mb-0" id="activeCount">-</h3>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="rounded-circle bg-primary bg-opacity-10 p-3">
<i class="bi bi-clock-history text-primary fs-4"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<p class="text-muted small mb-1">Tilbageværende Timer</p>
<h3 class="mb-0" id="remainingHours">-</h3>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="rounded-circle bg-info bg-opacity-10 p-3">
<i class="bi bi-hourglass-split text-info fs-4"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<p class="text-muted small mb-1">Brugte Timer</p>
<h3 class="mb-0" id="usedHours">-</h3>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="rounded-circle bg-warning bg-opacity-10 p-3">
<i class="bi bi-currency-dollar text-warning fs-4"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<p class="text-muted small mb-1">Total Omsætning</p>
<h3 class="mb-0" id="totalRevenue">-</h3>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Filter Bar -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label small text-muted">Status</label>
<select class="form-select" id="statusFilter" onchange="loadCards()">
<option value="">Alle</option>
<option value="active">Aktive</option>
<option value="depleted">Opbrugt</option>
<option value="expired">Udløbet</option>
<option value="cancelled">Annulleret</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label small text-muted">Søg Kunde</label>
<input type="text" class="form-control" id="customerSearch"
placeholder="Søg efter kundenavn eller email...">
</div>
<div class="col-md-3 d-flex align-items-end">
<button class="btn btn-outline-secondary w-100" onclick="resetFilters()">
<i class="bi bi-x-circle"></i> Nulstil
</button>
</div>
</div>
</div>
</div>
<!-- Cards Table -->
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle" id="cardsTable">
<thead class="table-light">
<tr>
<th>Kortnummer</th>
<th>Kunde</th>
<th class="text-end">Købte Timer</th>
<th class="text-end">Brugte Timer</th>
<th class="text-end">Tilbage</th>
<th>Forbrug</th>
<th class="text-end">Pris/Time</th>
<th class="text-end">Total</th>
<th>Status</th>
<th>Udløber</th>
<th class="text-end">Handlinger</th>
</tr>
</thead>
<tbody id="cardsTableBody">
<tr>
<td colspan="11" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Create Card Modal -->
<div class="modal fade" id="createCardModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">💳 Opret Nyt Prepaid Kort</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-4">
<form id="createCardForm" class="needs-validation" novalidate>
<!-- Customer Dropdown -->
<div class="mb-4">
<label class="form-label fw-bold">Kunde <span class="text-danger">*</span></label>
<div class="dropdown" id="customerDropdown">
<button class="form-select text-start d-flex justify-content-between align-items-center" type="button"
data-bs-toggle="dropdown" aria-expanded="false" id="customerDropdownBtn">
<span class="text-muted">Vælg kunde...</span>
<i class="bi bi-chevron-down small"></i>
</button>
<div class="dropdown-menu w-100 p-2 shadow-sm" aria-labelledby="customerDropdownBtn">
<div class="px-2 pb-2">
<input type="text" class="form-control form-control-sm" id="customerSearchInput"
placeholder="🔍 Søg kunde..." autocomplete="off">
</div>
<div id="customerList" style="max-height: 250px; overflow-y: auto;">
<!-- Options will be injected here -->
</div>
</div>
<input type="hidden" id="customerId" required>
<div class="invalid-feedback">
Vælg venligst en kunde
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-7">
<label class="form-label fw-bold">Antal Timer <span class="text-danger">*</span></label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-clock"></i></span>
<input type="number" class="form-control" id="purchasedHours"
step="0.5" min="1" required placeholder="0.0">
</div>
<div class="mt-2 d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary flex-fill" onclick="setPurchasedHours(10)">10t</button>
<button type="button" class="btn btn-sm btn-outline-secondary flex-fill" onclick="setPurchasedHours(25)">25t</button>
<button type="button" class="btn btn-sm btn-outline-secondary flex-fill" onclick="setPurchasedHours(50)">50t</button>
</div>
</div>
<div class="col-md-5">
<label class="form-label fw-bold">Pris / Time <span class="text-danger">*</span></label>
<div class="input-group">
<input type="number" class="form-control text-end" id="pricePerHour"
step="0.01" min="0" required placeholder="0.00">
<span class="input-group-text">kr</span>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label fw-bold">Udløbsdato <small class="text-muted fw-normal">(valgfri)</small></label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-calendar"></i></span>
<input type="date" class="form-control" id="expiresAt">
</div>
</div>
<div class="mb-3">
<label class="form-label fw-bold">Bemærkninger</label>
<textarea class="form-control" id="notes" rows="3" placeholder="Interne noter..."></textarea>
</div>
<div class="alert alert-light border small text-muted d-flex align-items-center gap-2">
<i class="bi bi-info-circle-fill text-primary"></i>
Kortnummeret genereres automatisk ved oprettelse
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="createCard()">
<i class="bi bi-plus-circle"></i> Opret Kort
</button>
</div>
</div>
</div>
</div>
<script>
let createCardModal;
// Initialize
document.addEventListener('DOMContentLoaded', () => {
createCardModal = new bootstrap.Modal(document.getElementById('createCardModal'));
loadStats();
loadCards();
loadCustomers();
});
// Load Statistics
async function loadStats() {
try {
const response = await fetch('/api/v1/prepaid-cards/stats/summary');
const stats = await response.json();
document.getElementById('activeCount').textContent = stats.active_count || 0;
document.getElementById('remainingHours').textContent =
parseFloat(stats.total_remaining_hours || 0).toFixed(1) + ' t';
document.getElementById('usedHours').textContent =
parseFloat(stats.total_used_hours || 0).toFixed(1) + ' t';
document.getElementById('totalRevenue').textContent =
new Intl.NumberFormat('da-DK', {
style: 'currency',
currency: 'DKK'
}).format(parseFloat(stats.total_revenue || 0));
} catch (error) {
console.error('Error loading stats:', error);
}
}
// Load Cards
async function loadCards() {
const status = document.getElementById('statusFilter').value;
const search = document.getElementById('customerSearch').value.toLowerCase();
try {
let url = '/api/v1/prepaid-cards';
if (status) url += `?status=${status}`;
const response = await fetch(url);
const cards = await response.json();
// Filter by search
const filtered = cards.filter(card => {
if (!search) return true;
const name = (card.customer_name || '').toLowerCase();
const email = (card.customer_email || '').toLowerCase();
return name.includes(search) || email.includes(search);
});
renderCards(filtered);
} catch (error) {
console.error('Error loading cards:', error);
document.getElementById('cardsTableBody').innerHTML = `
<tr><td colspan="10" class="text-center text-danger">
❌ Fejl ved indlæsning: ${error.message}
</td></tr>
`;
}
}
// Render Cards Table
function renderCards(cards) {
const tbody = document.getElementById('cardsTableBody');
if (!cards || cards.length === 0) {
tbody.innerHTML = `
<tr><td colspan="11" class="text-center text-muted py-5">
Ingen kort fundet
</td></tr>
`;
return;
}
tbody.innerHTML = cards.map(card => {
const statusBadge = getStatusBadge(card.status);
const expiresAt = card.expires_at ?
new Date(card.expires_at).toLocaleDateString('da-DK') : '-';
// Parse decimal strings to numbers
const purchasedHours = parseFloat(card.purchased_hours);
const usedHours = parseFloat(card.used_hours);
const remainingHours = parseFloat(card.remaining_hours);
const pricePerHour = parseFloat(card.price_per_hour);
const totalAmount = parseFloat(card.total_amount);
// Calculate usage percentage
const usedPercent = purchasedHours > 0 ? Math.min(100, Math.max(0, (usedHours / purchasedHours) * 100)) : 0;
// Progress bar color based on usage
let progressClass = 'bg-success';
if (usedPercent >= 90) progressClass = 'bg-danger';
else if (usedPercent >= 75) progressClass = 'bg-warning';
return `
<tr>
<td>
<a href="/prepaid-cards/${card.id}" class="text-decoration-none">
<strong>${card.card_number}</strong>
</a>
</td>
<td>
<div>${card.customer_name || '-'}</div>
<small class="text-muted">${card.customer_email || ''}</small>
</td>
<td class="text-end">${purchasedHours.toFixed(1)} t</td>
<td class="text-end">${usedHours.toFixed(1)} t</td>
<td class="text-end">
<strong class="${remainingHours < 5 ? 'text-danger' : 'text-success'}">
${remainingHours.toFixed(1)} t
</strong>
</td>
<td>
<div class="progress" style="height: 20px; min-width: 100px;">
<div class="progress-bar ${progressClass}"
role="progressbar"
style="width: ${usedPercent.toFixed(0)}%"
aria-valuenow="${usedPercent.toFixed(0)}"
aria-valuemin="0"
aria-valuemax="100">
${usedPercent.toFixed(0)}%
</div>
</div>
<small class="text-muted">Forbrug</small>
</td>
<td class="text-end">${pricePerHour.toFixed(2)} kr</td>
<td class="text-end"><strong>${totalAmount.toFixed(2)} kr</strong></td>
<td>${statusBadge}</td>
<td>${expiresAt}</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<a href="/prepaid-cards/${card.id}" class="btn btn-outline-primary"
title="Se detaljer">
<i class="bi bi-eye"></i>
</a>
${card.status === 'active' ? `
<button class="btn btn-outline-warning"
onclick="cancelCard(${card.id})" title="Annuller">
<i class="bi bi-x-circle"></i>
</button>
` : ''}
</div>
</td>
</tr>
`;
}).join('');
}
// Get Status Badge
function getStatusBadge(status) {
const badges = {
'active': '<span class="badge bg-success">Aktiv</span>',
'depleted': '<span class="badge bg-secondary">Opbrugt</span>',
'expired': '<span class="badge bg-danger">Udløbet</span>',
'cancelled': '<span class="badge bg-warning">Annulleret</span>'
};
return badges[status] || status;
}
// Set purchased hours from quick template buttons
function setPurchasedHours(hours) {
document.getElementById('purchasedHours').value = hours;
// Optionally focus next field (pricePerHour) for quick workflow
document.getElementById('pricePerHour').focus();
}
// Load Customers for Dropdown
let allCustomers = [];
async function loadCustomers() {
try {
// Fetch max customers for client-side filtering (up to 1000)
const response = await fetch('/api/v1/customers?limit=1000');
const data = await response.json();
// Handle API response format (might be array or paginated object)
allCustomers = Array.isArray(data) ? data : (data.customers || []);
renderCustomerDropdown(allCustomers);
// Setup search listener
const searchInput = document.getElementById('customerSearchInput');
if (searchInput) {
searchInput.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase();
const filtered = allCustomers.filter(c =>
(c.name || '').toLowerCase().includes(query) ||
(c.email || '').toLowerCase().includes(query)
);
renderCustomerDropdown(filtered);
});
// Prevent dropdown from closing when clicking input
searchInput.addEventListener('click', (e) => e.stopPropagation());
}
} catch (error) {
console.error('Error loading customers:', error);
allCustomers = [];
renderCustomerDropdown([]);
}
}
function renderCustomerDropdown(customers) {
const list = document.getElementById('customerList');
if (!list) return;
if (customers.length === 0) {
list.innerHTML = '<div class="text-muted p-2 text-center small">Ingen kunder fundet</div>';
return;
}
list.innerHTML = customers.map(c => `
<a href="#" class="dropdown-item py-2 border-bottom" onclick="selectCustomer(${c.id}, '${c.name.replace(/'/g, "\\'")}')">
<div class="fw-bold">${c.name}</div>
${c.email ? `<small class="text-muted">${c.email}</small>` : ''}
</a>
`).join('');
}
function selectCustomer(id, name) {
document.getElementById('customerId').value = id;
const btn = document.getElementById('customerDropdownBtn');
btn.innerHTML = `
<span class="fw-bold text-dark">${name}</span>
<i class="bi bi-chevron-down small"></i>
`;
btn.classList.add('border-primary'); // Highlight selection
// Reset search
document.getElementById('customerSearchInput').value = '';
renderCustomerDropdown(allCustomers);
}
// Open Create Modal
function openCreateModal() {
const form = document.getElementById('createCardForm');
form.reset();
form.classList.remove('was-validated');
// Reset dropdown
document.getElementById('customerId').value = '';
document.getElementById('customerDropdownBtn').innerHTML = `
<span class="text-muted">Vælg kunde...</span>
<i class="bi bi-chevron-down small"></i>
`;
document.getElementById('customerDropdownBtn').classList.remove('border-primary', 'is-invalid');
createCardModal.show();
}
// Create Card
async function createCard() {
const form = document.getElementById('createCardForm');
// Custom validation for dropdown
const customerId = document.getElementById('customerId').value;
const dropdownBtn = document.getElementById('customerDropdownBtn');
if (!customerId) {
dropdownBtn.classList.add('is-invalid');
} else {
dropdownBtn.classList.remove('is-invalid');
}
if (!form.checkValidity() || !customerId) {
form.classList.add('was-validated');
return;
}
const data = {
customer_id: parseInt(customerId),
purchased_hours: parseFloat(document.getElementById('purchasedHours').value),
price_per_hour: parseFloat(document.getElementById('pricePerHour').value),
expires_at: document.getElementById('expiresAt').value || null,
notes: document.getElementById('notes').value || null
};
try {
const response = await fetch('/api/v1/prepaid-cards', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Fejl ved oprettelse');
}
createCardModal.hide();
loadStats();
loadCards();
// Show success toast or alert
// alert('✅ Prepaid kort oprettet!'); // Using toast instead if available, keeping alert for now
// But let's use a nicer non-blocking notification if possible, but sticking to existing pattern
alert('✅ Prepaid kort oprettet!');
} catch (error) {
console.error('Error creating card:', error);
alert('❌ Fejl: ' + error.message);
}
}
// Cancel Card
async function cancelCard(cardId) {
if (!confirm('Er du sikker på at du vil annullere dette kort?')) {
return;
}
try {
const response = await fetch(`/api/v1/prepaid-cards/${cardId}/status`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'cancelled' })
});
if (!response.ok) throw new Error('Fejl ved annullering');
loadStats();
loadCards();
alert('✅ Kort annulleret');
} catch (error) {
console.error('Error cancelling card:', error);
alert('❌ Fejl: ' + error.message);
}
}
// Reset Filters
function resetFilters() {
document.getElementById('statusFilter').value = '';
document.getElementById('customerSearch').value = '';
loadCards();
}
// Live search on customer input
document.getElementById('customerSearch').addEventListener('input', loadCards);
</script>
<style>
.table th {
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--bs-secondary);
}
.card {
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
}
.btn-group-sm .btn {
padding: 0.25rem 0.5rem;
}
/* Custom Dropdown Styles */
#customerDropdownBtn {
background-color: #fff;
border: 1px solid #ced4da;
}
#customerDropdownBtn:focus {
border-color: #86b7fe;
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
}
#customerDropdownBtn.is-invalid {
border-color: #dc3545;
}
#customerDropdownBtn.is-invalid ~ .invalid-feedback {
display: block;
}
#customerList .dropdown-item:active,
#customerList .dropdown-item.active {
background-color: var(--bs-primary);
color: white;
}
#customerList .dropdown-item:active small,
#customerList .dropdown-item.active small {
color: rgba(255,255,255,0.8) !important;
}
/* Modal Styling Improvements */
#createCardModal .modal-content {
border: none;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
}
#createCardModal .modal-header {
background-color: #f8f9fa;
border-bottom: 1px solid #eee;
}
#createCardModal .input-group-text {
background-color: #f8f9fa;
color: #6c757d;
}
</style>
{% endblock %}