bmc_hub/app/economy/frontend/time_queue.html

511 lines
21 KiB
HTML
Raw Normal View History

{% extends "shared/frontend/base.html" %}
{% block title %}Economy Time Queue{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex flex-wrap align-items-center justify-content-between mb-3">
<div>
<h2 class="mb-1">Economy Time Queue</h2>
<p class="text-muted mb-0">Hub-created, non-billed time entries. Opretter kun lokale ordrer.</p>
</div>
<div class="d-flex gap-2 mt-2 mt-md-0 align-items-center">
<span class="badge text-bg-secondary" id="selectedCountBadge">0 selected</span>
<button class="btn btn-outline-secondary" id="reloadBtn">Reload</button>
<button class="btn btn-outline-dark" id="clearFiltersBtn">Clear Filters</button>
<button class="btn btn-success" id="sendInvoicesBtn">Opret lokale ordrer</button>
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<div class="d-flex flex-wrap gap-2 mb-3">
<button class="btn btn-sm btn-outline-primary quick-filter-btn" data-filter="pending">Kun pending</button>
<button class="btn btn-sm btn-outline-primary quick-filter-btn" data-filter="billable">Kun billable</button>
<button class="btn btn-sm btn-outline-primary quick-filter-btn" data-filter="ready">Klar til faktura</button>
</div>
<div class="row g-2 align-items-end">
<div class="col-12 col-md-3">
<label for="filterCustomer" class="form-label">Firma</label>
<select id="filterCustomer" class="form-select">
<option value="">Alle firmaer med ubehandlede registreringer</option>
</select>
</div>
<div class="col-12 col-md-3">
<label for="filterStatus" class="form-label">Status</label>
<select id="filterStatus" class="form-select">
<option value="">All</option>
<option value="pending">pending</option>
<option value="approved">approved</option>
<option value="rejected">rejected</option>
</select>
</div>
<div class="col-12 col-md-3">
<label for="filterBillable" class="form-label">Billable</label>
<select id="filterBillable" class="form-select">
<option value="">All</option>
<option value="true">true</option>
<option value="false">false</option>
</select>
</div>
<div class="col-12 col-md-3">
<label for="filterQuery" class="form-label">Search</label>
<input id="filterQuery" class="form-control" type="text" placeholder="Customer, case, description">
</div>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-12 col-md-3">
<label for="bulkDescription" class="form-label">Description</label>
<input id="bulkDescription" class="form-control" type="text" placeholder="Optional update">
</div>
<div class="col-12 col-md-2">
<label for="bulkHours" class="form-label">Hours</label>
<input id="bulkHours" class="form-control" type="number" step="0.25" min="0.25" placeholder="Optional">
</div>
<div class="col-12 col-md-2">
<label for="bulkBillingMethod" class="form-label">Billing method</label>
<select id="bulkBillingMethod" class="form-select">
<option value="">No change</option>
<option value="invoice">invoice</option>
<option value="internal">internal</option>
<option value="prepaid">prepaid</option>
<option value="fixed_price">fixed_price</option>
</select>
</div>
<div class="col-12 col-md-2">
<label for="bulkPrepaidCard" class="form-label">Prepaid card</label>
<select id="bulkPrepaidCard" class="form-select">
<option value="">Select card</option>
</select>
</div>
<div class="col-12 col-md-3 d-flex gap-2 flex-wrap">
<button class="btn btn-primary" id="bulkUpdateBtn">Update Selected</button>
<button class="btn btn-outline-primary" id="bulkApproveBtn">Approve Selected</button>
<button class="btn btn-outline-warning" id="bulkPrepaidBtn">Apply Prepaid</button>
<button class="btn btn-outline-danger" id="bulkDeleteBtn">Soft Delete</button>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-striped table-hover align-middle mb-0">
<thead>
<tr>
<th style="width: 42px;"><input type="checkbox" id="selectAll"></th>
<th>ID</th>
<th>Customer</th>
<th>Date</th>
<th>Case</th>
<th>Hours</th>
<th>Status</th>
<th>Billable</th>
<th>Method</th>
<th>Hours edit</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="queueBody">
<tr>
<td colspan="12" class="text-center py-4 text-muted">Loading...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
(() => {
const state = {
items: [],
selected: new Set(),
loading: false,
};
const queueBody = document.getElementById('queueBody');
const selectAll = document.getElementById('selectAll');
const selectedCountBadge = document.getElementById('selectedCountBadge');
const filterCustomer = document.getElementById('filterCustomer');
const filterStatus = document.getElementById('filterStatus');
const filterBillable = document.getElementById('filterBillable');
const filterQuery = document.getElementById('filterQuery');
const bulkDescription = document.getElementById('bulkDescription');
const bulkHours = document.getElementById('bulkHours');
const bulkBillingMethod = document.getElementById('bulkBillingMethod');
const bulkPrepaidCard = document.getElementById('bulkPrepaidCard');
const quickFilterBtns = document.querySelectorAll('.quick-filter-btn');
function selectedIds() {
return Array.from(state.selected);
}
function renderRows() {
if (!state.items.length) {
queueBody.innerHTML = '<tr><td colspan="12" class="text-center py-4 text-muted">No entries found</td></tr>';
return;
}
queueBody.innerHTML = state.items.map((item) => {
const id = Number(item.id);
const checked = state.selected.has(id) ? 'checked' : '';
const date = item.worked_date || '-';
const hours = item.approved_hours || item.original_hours || 0;
const customer = `${item.customer_id || '-'} / ${item.customer_name || ''}`;
const title = item.case_title || '-';
const desc = (item.description || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const method = item.billing_method || 'invoice';
return `
<tr>
<td><input type="checkbox" class="row-check" data-id="${id}" ${checked}></td>
<td>${id}</td>
<td>${customer}</td>
<td>${date}</td>
<td>${title}</td>
<td>${hours}</td>
<td>${item.status || '-'}</td>
<td>${item.billable === false ? 'false' : 'true'}</td>
<td>
<select class="form-select form-select-sm inline-method" data-id="${id}">
<option value="invoice" ${method === 'invoice' ? 'selected' : ''}>invoice</option>
<option value="internal" ${method === 'internal' ? 'selected' : ''}>internal</option>
<option value="prepaid" ${method === 'prepaid' ? 'selected' : ''}>prepaid</option>
<option value="fixed_price" ${method === 'fixed_price' ? 'selected' : ''}>fixed_price</option>
</select>
</td>
<td>
<input type="number" step="0.25" min="0.25" class="form-control form-control-sm inline-hours" data-id="${id}" value="${hours}">
</td>
<td>
<input type="text" class="form-control form-control-sm inline-desc" data-id="${id}" value="${desc}">
</td>
<td>
<button class="btn btn-sm btn-outline-success inline-save" data-id="${id}">Gem</button>
</td>
</tr>
`;
}).join('');
document.querySelectorAll('.row-check').forEach((cb) => {
cb.addEventListener('change', (e) => {
const id = Number(e.target.dataset.id);
if (e.target.checked) state.selected.add(id);
else state.selected.delete(id);
syncSelectAll();
});
});
document.querySelectorAll('.inline-save').forEach((btn) => {
btn.addEventListener('click', async (e) => {
const id = Number(e.target.dataset.id);
await saveInlineRow(id, e.target);
});
});
syncSelectAll();
}
function syncSelectAll() {
const ids = state.items.map((x) => Number(x.id));
const allSelected = ids.length && ids.every((id) => state.selected.has(id));
selectAll.checked = Boolean(allSelected);
selectedCountBadge.textContent = `${state.selected.size} selected`;
}
async function api(url, options = {}) {
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json' },
...options,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.detail || 'Request failed');
}
return data;
}
function buildListUrl() {
const params = new URLSearchParams();
if (filterCustomer.value) params.set('customer_id', filterCustomer.value);
if (filterStatus.value) params.set('status', filterStatus.value);
if (filterBillable.value) params.set('billable', filterBillable.value);
if (filterQuery.value.trim()) params.set('q', filterQuery.value.trim());
params.set('limit', '500');
return `/api/v1/economy/time-queue?${params.toString()}`;
}
async function loadEntries() {
if (state.loading) return;
state.loading = true;
queueBody.innerHTML = '<tr><td colspan="10" class="text-center py-4 text-muted">Loading...</td></tr>';
try {
const data = await api(buildListUrl());
state.items = data.items || [];
state.selected = new Set(Array.from(state.selected).filter((id) => state.items.some((x) => Number(x.id) === id)));
renderRows();
} catch (err) {
queueBody.innerHTML = `<tr><td colspan="10" class="text-center py-4 text-danger">${err.message}</td></tr>`;
} finally {
state.loading = false;
}
}
async function loadPrepaidCards() {
try {
const data = await api('/api/v1/economy/time-queue/prepaid-cards');
const opts = ['<option value="">Select card</option>'];
(data.items || []).forEach((card) => {
const label = `${card.id} | ${card.card_number || '-'} | rem: ${card.remaining_hours || 0}`;
opts.push(`<option value="${card.id}">${label}</option>`);
});
bulkPrepaidCard.innerHTML = opts.join('');
} catch (_) {
bulkPrepaidCard.innerHTML = '<option value="">No cards</option>';
}
}
async function loadCustomers() {
try {
const data = await api('/api/v1/economy/time-queue/customers');
const current = filterCustomer.value;
const opts = ['<option value="">Alle firmaer med ubehandlede registreringer</option>'];
(data.items || []).forEach((row) => {
const label = `${row.customer_name || 'Ukendt'} (${row.open_count || 0})`;
opts.push(`<option value="${row.customer_id}">${label}</option>`);
});
filterCustomer.innerHTML = opts.join('');
if (current) {
filterCustomer.value = current;
}
} catch (_) {
filterCustomer.innerHTML = '<option value="">Kunne ikke hente firmaer</option>';
}
}
async function clearFilters() {
filterCustomer.value = '';
filterStatus.value = '';
filterBillable.value = '';
filterQuery.value = '';
setActiveQuickFilter(null);
await loadEntries();
}
function setActiveQuickFilter(active) {
quickFilterBtns.forEach((btn) => {
const isActive = btn.dataset.filter === active;
btn.classList.toggle('btn-primary', isActive);
btn.classList.toggle('btn-outline-primary', !isActive);
});
}
async function applyQuickFilter(type) {
if (type === 'pending') {
filterStatus.value = 'pending';
filterBillable.value = '';
} else if (type === 'billable') {
filterStatus.value = '';
filterBillable.value = 'true';
} else if (type === 'ready') {
filterStatus.value = 'approved';
filterBillable.value = 'true';
}
setActiveQuickFilter(type);
await loadEntries();
}
async function saveInlineRow(id, buttonEl) {
const hoursInput = document.querySelector(`.inline-hours[data-id="${id}"]`);
const descInput = document.querySelector(`.inline-desc[data-id="${id}"]`);
const methodSelect = document.querySelector(`.inline-method[data-id="${id}"]`);
if (!hoursInput || !descInput || !methodSelect) return;
const originalHours = Number(hoursInput.value);
const description = (descInput.value || '').trim();
const billingMethod = methodSelect.value;
if (!originalHours || originalHours <= 0) {
alert('Hours must be greater than 0');
return;
}
const prevText = buttonEl.textContent;
buttonEl.disabled = true;
buttonEl.textContent = 'Gemmer...';
try {
await api('/api/v1/economy/time-queue/bulk-update', {
method: 'PATCH',
body: JSON.stringify({
ids: [id],
original_hours: originalHours,
description,
billing_method: billingMethod,
}),
});
await loadEntries();
await loadCustomers();
} catch (err) {
alert(err.message);
} finally {
buttonEl.disabled = false;
buttonEl.textContent = prevText;
}
}
async function doBulkUpdate() {
const ids = selectedIds();
if (!ids.length) return alert('Select at least one entry');
const payload = { ids };
if (bulkDescription.value.trim()) payload.description = bulkDescription.value.trim();
if (bulkHours.value) payload.original_hours = Number(bulkHours.value);
if (bulkBillingMethod.value) payload.billing_method = bulkBillingMethod.value;
if (!payload.description && !payload.original_hours && !payload.billing_method) {
return alert('Set at least one update field');
}
try {
await api('/api/v1/economy/time-queue/bulk-update', {
method: 'PATCH',
body: JSON.stringify(payload),
});
await loadCustomers();
await loadEntries();
} catch (err) {
alert(err.message);
}
}
async function doBulkApprove() {
const ids = selectedIds();
if (!ids.length) return alert('Select at least one entry');
const payload = { ids };
if (bulkBillingMethod.value) payload.billing_method = bulkBillingMethod.value;
try {
await api('/api/v1/economy/time-queue/bulk-approve', {
method: 'POST',
body: JSON.stringify(payload),
});
await loadCustomers();
await loadEntries();
} catch (err) {
alert(err.message);
}
}
async function doBulkDelete() {
const ids = selectedIds();
if (!ids.length) return alert('Select at least one entry');
const reason = prompt('Reason for soft delete:', 'Soft deleted from economy queue');
if (reason === null) return;
try {
await api('/api/v1/economy/time-queue/bulk-soft-delete', {
method: 'POST',
body: JSON.stringify({ ids, reason }),
});
await loadCustomers();
await loadEntries();
} catch (err) {
alert(err.message);
}
}
async function doApplyPrepaid() {
const ids = selectedIds();
if (!ids.length) return alert('Select at least one entry');
if (!bulkPrepaidCard.value) return alert('Select a prepaid card first');
try {
await api('/api/v1/economy/time-queue/bulk-apply-prepaid', {
method: 'POST',
body: JSON.stringify({ ids, prepaid_card_id: Number(bulkPrepaidCard.value) }),
});
await loadCustomers();
await loadEntries();
} catch (err) {
alert(err.message);
}
}
async function doSendInvoices() {
const ids = selectedIds();
if (!ids.length) return alert('Select at least one entry');
const ok = confirm('Opret lokale ordrer for de valgte linjer? (Ingen direkte overfoersel til e-conomic)');
if (!ok) return;
try {
const result = await api('/api/v1/economy/time-queue/send-to-invoices', {
method: 'POST',
body: JSON.stringify({ ids }),
});
const drafts = (result.created_drafts || result.created_orders || []).map((x) => {
const draftId = x.draft_id || x.order_id;
return `customer ${x.customer_id}, draft ${draftId}`;
}).join('\n');
const skipped = (result.skipped_missing_customer || []);
const failedCustomers = (result.failed_customers || []);
const orderMessage = drafts || 'Ingen ordrekladder oprettet';
const nextStep = result.orders_url ? `\n\nAabn ordre: ${result.orders_url}` : '';
const skippedMsg = skipped.length ? `\n\nSprunget over (mangler kunde-link): ${skipped.join(', ')}` : '';
const failedMsg = failedCustomers.length
? `\n\nFejl ved kunde-grupper:\n${failedCustomers.map((f) => `customer ${f.customer_id}: ${f.error}`).join('\n')}`
: '';
alert(`Ordrekladder oprettet i /ordre:\n${orderMessage}${skippedMsg}${failedMsg}${nextStep}`);
await loadCustomers();
await loadEntries();
} catch (err) {
alert(err.message);
}
}
selectAll.addEventListener('change', () => {
if (selectAll.checked) {
state.items.forEach((item) => state.selected.add(Number(item.id)));
} else {
state.items.forEach((item) => state.selected.delete(Number(item.id)));
}
renderRows();
});
document.getElementById('reloadBtn').addEventListener('click', loadEntries);
document.getElementById('clearFiltersBtn').addEventListener('click', clearFilters);
document.getElementById('bulkUpdateBtn').addEventListener('click', doBulkUpdate);
document.getElementById('bulkApproveBtn').addEventListener('click', doBulkApprove);
document.getElementById('bulkPrepaidBtn').addEventListener('click', doApplyPrepaid);
document.getElementById('bulkDeleteBtn').addEventListener('click', doBulkDelete);
document.getElementById('sendInvoicesBtn').addEventListener('click', doSendInvoices);
quickFilterBtns.forEach((btn) => {
btn.addEventListener('click', () => applyQuickFilter(btn.dataset.filter));
});
[filterCustomer, filterStatus, filterBillable].forEach((el) => {
el.addEventListener('change', loadEntries);
});
filterQuery.addEventListener('keydown', (e) => {
if (e.key === 'Enter') loadEntries();
});
loadCustomers();
loadPrepaidCards();
loadEntries();
})();
</script>
{% endblock %}