502 lines
21 KiB
HTML
502 lines
21 KiB
HTML
{% 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.</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">Send Selected To Invoices</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, '<').replace(/>/g, '>');
|
|
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('Send selected entries to invoices now?');
|
|
if (!ok) return;
|
|
|
|
try {
|
|
const result = await api('/api/v1/economy/time-queue/send-to-invoices', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ ids }),
|
|
});
|
|
const exports = (result.exports || []).map((x) => {
|
|
return `customer ${x.customer_id}, order ${x.order_id}, success=${x.success}, dry_run=${x.dry_run}`;
|
|
}).join('\n');
|
|
alert(exports || 'No export result');
|
|
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 %}
|