Files
wms-app/app/revenue/manage_invoice.php
T
Thanakorn f70f226bd1 Fix timestamps, delete requests, invoice dates and GR quantities
Apply the configured timezone to PHP and both DB connections, wrap
unwrapped ajax payloads so delete buttons reach their engines, normalise
and validate invoice due dates, reject stock quantities below the stored
4dp scale, and list stock movements across all warehouses.
2026-09-17 09:00:15 +07:00

424 lines
18 KiB
PHP

<?php
session_start();
require '../config.php';
require '../include_header.php';
$invoice_id = (int)($_GET['id'] ?? 0);
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_sidebar_ac.php'; ?>
<main id="content" class="content py-15">
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="mb-6 d-flex justify-content-between align-items-center">
<div>
<h1 class="fs-3 mb-1" id="page_title">Invoice Detail</h1>
<p class="mb-0 text-muted" id="page_subtitle">Revenue invoice management</p>
</div>
<a href="<?php echo $server_url?>revenue/invoice.php" class="btn btn-light">
<i class="ti ti-arrow-left me-1"></i>Back
</a>
</div>
</div>
</div>
<div class="row g-5">
<div class="col-lg-8">
<div class="card mb-5">
<div class="card-body p-5">
<h2 class="fs-5 mb-4">Items</h2>
<div class="table-responsive">
<table class="table mb-0">
<thead class="table-light">
<tr>
<th>Product</th>
<th class="text-end">Qty</th>
<th class="text-end">Unit Price</th>
<th class="text-end" style="width:60px;">Tax%</th>
<th class="text-end">Tax Amt</th>
<th class="text-end">Total</th>
</tr>
</thead>
<tbody id="item_tbody">
<tr><td colspan="4" class="text-center py-5 text-muted">Loading invoice...</td></tr>
</tbody>
<tfoot id="item_tfoot"></tfoot>
</table>
</div>
</div>
</div>
<div class="card d-none" id="cn_card">
<div class="card-body p-5">
<h2 class="fs-5 mb-4">Credit Notes</h2>
<div class="table-responsive">
<table class="table mb-0 table-hover">
<thead class="table-light">
<tr>
<th>CN #</th>
<th>Date</th>
<th class="text-end">Amount</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody id="cn_tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card mb-5">
<div class="card-body p-4">
<h2 class="fs-5 mb-3">Document Info</h2>
<div class="mb-3 d-flex gap-2">
<span id="badge_doc_type"></span>
<span id="badge_status"></span>
</div>
<div class="mb-2 small">
<span class="text-muted">Order:</span>
<span class="ms-1 fw-semibold" id="display_order">—</span>
</div>
<div class="mb-2 small">
<span class="text-muted">Contact:</span>
<span class="ms-1 fw-semibold" id="display_contact">—</span>
</div>
<div class="mb-2 small">
<span class="text-muted">Issued:</span>
<span class="ms-1" id="display_issued">—</span>
</div>
<div class="mb-2 small">
<span class="text-muted">Due:</span>
<span class="ms-1" id="display_due">—</span>
<input type="text" id="due_date" class="form-control form-control-sm mt-1 d-none"
placeholder="DD/MM/YYYY" autocomplete="off">
</div>
<div class="mt-3 small">
<span class="text-muted">GL Formula:</span>
<div class="d-flex gap-1 mt-1">
<select id="formula_id" class="form-select form-select-sm">
<option value="">— None —</option>
</select>
<button class="btn btn-outline-secondary btn-sm px-2" id="btn_save_formula"
onclick="save_formula()" title="Save formula">
<i class="ti ti-device-floppy"></i>
</button>
</div>
</div>
<div class="mt-3 small">
<span class="text-muted">Notes:</span>
<div class="mt-1" id="display_notes">—</div>
</div>
</div>
</div>
<div class="card">
<div class="card-body p-4 d-flex flex-column gap-2">
<button class="btn btn-outline-secondary w-100" onclick="print_invoice()">
<i class="ti ti-printer me-1"></i>Print / Save PDF
</button>
<button class="btn btn-primary w-100 d-none" id="btn_save" onclick="save_invoice()">
<i class="ti ti-device-floppy me-1"></i>Save
</button>
<button class="btn btn-success w-100 d-none" id="btn_issue" onclick="issue_invoice()">
<i class="ti ti-send me-1"></i>Issue Invoice
</button>
<a href="#" class="btn btn-outline-warning w-100 d-none" id="btn_create_cn">
<i class="ti ti-file-minus me-1"></i>Create Credit Note
</a>
<button class="btn btn-outline-danger w-100 d-none" id="btn_void" onclick="void_invoice()">
<i class="ti ti-ban me-1"></i>Void Invoice
</button>
</div>
</div>
</div>
</div>
</div>
</main>
<?php require '../include_ending.php'; ?>
<script>
var invoice_id = <?php echo $invoice_id; ?>;
var issued_date = ''; // ISO issue date — lower bound for the due date
var due_picker = null; // flatpickr instance on #due_date
function doc_type_badge(doc_type) {
const map = {
'invoice': '<span class="badge bg-primary">Invoice</span>',
'credit_note': '<span class="badge bg-warning text-dark">Credit Note</span>',
'supplier_credit_note': '<span class="badge bg-info text-white">Supplier Credit Note</span>',
};
return map[doc_type] || '—';
}
function is_invoice_overdue(status, due_date) {
if (String(status) !== '1' || !due_date) return false;
var d = new Date();
var today = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
return due_date < today;
}
function invoice_status_badge(status, due_date, payment_state, doc_type) {
if (payment_state === 'partial') {
return '<span class="badge bg-warning text-dark">Partial</span>';
}
if (payment_state === 'paid') {
return doc_type === 'credit_note'
? '<span class="badge bg-success">Settled</span>'
: '<span class="badge bg-success">Paid</span>';
}
if (is_invoice_overdue(status, due_date)) {
return '<span class="badge bg-danger">Overdue</span>';
}
const map = {
'0': '<span class="badge bg-secondary">Draft</span>',
'1': '<span class="badge bg-info text-white">Issued</span>',
'2': '<span class="badge bg-success">Paid</span>',
'4': '<span class="badge bg-light text-dark">Void</span>',
};
return map[String(status)] || '—';
}
function display_text(value) {
return value ? escape_html(value) : '<span class="text-muted">—</span>';
}
function retrieve_invoice() {
return ajax_request({
url: '<?php echo $server_url?>order/api/engine/retrieve_invoice.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
data: { invoice_id: invoice_id },
onSuccess: function(res) {
var inv = res.output || {};
$('#page_title').text(inv.invoice_number || 'Invoice Detail');
$('#page_subtitle').text(inv.doc_type === 'credit_note' ? 'Revenue credit note management' : 'Revenue invoice management');
$('#badge_doc_type').html(doc_type_badge(inv.doc_type));
$('#badge_status').html(invoice_status_badge(inv.status, inv.due_date, inv.payment_state, inv.doc_type));
$('#display_order').html(inv.order_number ? escape_html(inv.order_number) : '<span class="text-muted">—</span>');
$('#display_contact').html(display_text(inv.contact_name));
$('#display_issued').html(inv.issued_date ? format_date(inv.issued_date) : '<span class="text-muted">—</span>');
$('#display_due').html(inv.due_date ? format_date(inv.due_date) : '<span class="text-muted">—</span>');
$('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : '');
// A due date before the issue date is not a valid payment term, so stop
// the picker from offering one.
issued_date = inv.issued_date ? String(inv.issued_date).split(' ')[0] : '';
if (due_picker && issued_date) due_picker.set('minDate', issued_date);
$('#display_notes').html(inv.notes ? escape_html(inv.notes).replace(/\n/g, '<br>') : '<span class="text-muted">—</span>');
var rows = '';
$.each(inv.items || [], function(i, item) {
var name = item.product_name || item.product_sku || '';
rows += `<tr>
<td>${escape_html(name)}</td>
<td class="text-end">${format_number(item.quantity, 2)}</td>
<td class="text-end">${format_number(item.unit_price, 2)}</td>
<td class="text-end">${format_number(item.tax_rate, 2)}</td>
<td class="text-end">${format_number(item.tax_amount, 4)}</td>
<td class="text-end">${format_number(item.total_price, 2)}</td>
</tr>`;
});
$('#item_tbody').html(rows || '<tr><td colspan="6" class="text-center py-5 text-muted">No items found.</td></tr>');
var is_draft = parseInt(inv.status) === 0;
var is_editable_doc = inv.doc_type === 'invoice' || inv.doc_type === 'credit_note';
var tax_adj_val = parseFloat(inv.tax_adjustment) || 0;
var tax_adj_cell = (is_draft && is_editable_doc)
? `<input type="number" id="tax_adjustment" class="form-control form-control-sm text-end d-inline-block" style="width:90px;" min="-0.3" max="0.3" step="0.01" value="${tax_adj_val.toFixed(2)}">`
: `${format_number(tax_adj_val, 2)}`;
var tfoot = `
<tr><td colspan="5" class="text-muted border-bottom-0">Subtotal</td><td class="text-end border-bottom-0">${format_number(inv.subtotal, 2)}</td></tr>
<tr><td colspan="5" class="text-muted border-bottom-0">Discount</td><td class="text-end border-bottom-0">${format_number(inv.discount, 2)}</td></tr>
<tr><td colspan="5" class="text-muted border-bottom-0">Tax</td><td class="text-end border-bottom-0">${format_number(inv.tax, 2)}</td></tr>
<tr><td colspan="5" class="text-muted border-bottom-0">Tax Adj.</td><td class="text-end border-bottom-0">${tax_adj_cell}</td></tr>
<tr><td colspan="5" class="text-muted">Shipping</td><td class="text-end">${format_number(inv.shipping_fee, 2)}</td></tr>
<tr><td colspan="5" class="fw-bold">Grand Total</td><td class="text-end fw-bold">${format_number(inv.grand_total, 2)}</td></tr>`;
$('#item_tfoot').html(tfoot);
if (res.credit_notes && res.credit_notes.length) {
var cn_rows = '';
$.each(res.credit_notes, function(i, cn) {
cn_rows += `<tr>
<td>${escape_html(cn.invoice_number || '')}</td>
<td>${cn.issued_date ? format_date(cn.issued_date) : '—'}</td>
<td class="text-end text-danger">${format_number(cn.grand_total, 2)}</td>
<td>${invoice_status_badge(cn.status, cn.due_date, cn.payment_state, cn.doc_type)}</td>
<td><a href="<?php echo $server_url?>revenue/manage_invoice.php?id=${cn.id}"><i class="ti ti-eye fs-5"></i></a></td>
</tr>`;
});
$('#cn_tbody').html(cn_rows);
$('#cn_card').removeClass('d-none');
}
var gl_type = inv.doc_type === 'credit_note' ? 'credit_note' : 'invoice';
var selected_formula = inv.formula_id || '';
ajax_request({
url: '<?php echo $server_url?>accounting/api/engine/account_formula.php',
autoPrepare: true,
checkRequired: 0,
action: 'get_by_type',
data: { document_type: gl_type },
onSuccess: function(fres) {
var opts = '<option value="">— None —</option>';
$.each(fres.output || [], function(i, f) {
var sel = String(f.id) === String(selected_formula) ? ' selected' : '';
opts += '<option value="' + f.id + '"' + sel + '>' + escape_html(f.formula_name) + '</option>';
});
$('#formula_id').html(opts);
}
});
set_action_mode(inv);
}
});
}
function save_invoice() {
// autoPrepare sweeps every .form-control into the payload, so #due_date
// arrives as the picker's DD/MM/YYYY text. Sent unconverted it reaches a
// MySQL DATE column verbatim and the insert fails, which the engine
// reports as the opaque "Database error, please try again." Convert it
// here, the way the purchase-invoice page already does.
var due_date = $('#due_date').val().trim();
if (due_date && issued_date && to_iso_date(due_date) < issued_date) {
bootbox.alert('The due date cannot be earlier than the issue date.');
return;
}
ajax_request({
url: '<?php echo $server_url?>order/api/engine/manage_invoice.php',
autoPrepare: true,
checkRequired: 0,
action: 'update',
data: {
id: invoice_id,
due_date: due_date ? to_iso_date(due_date) : '',
tax_adjustment: parseFloat($('#tax_adjustment').val()) || 0,
},
onSuccess: function() { retrieve_invoice(); }
});
}
function set_action_mode(inv) {
var is_invoice = inv.doc_type === 'invoice';
var is_credit_note = inv.doc_type === 'credit_note';
var status = parseInt(inv.status);
$('#btn_save_formula').prop('disabled', status === 4);
$('#btn_save').toggleClass('d-none', !((is_invoice || is_credit_note) && status === 0));
$('#btn_issue')
.toggleClass('d-none', !((is_invoice || is_credit_note) && status === 0))
.html(is_credit_note
? '<i class="ti ti-send me-1"></i>Issue Credit Note'
: '<i class="ti ti-send me-1"></i>Issue Invoice');
$('#btn_create_cn')
.toggleClass('d-none', !(is_invoice && status >= 1 && status !== 4))
.attr('href', '<?php echo $server_url?>revenue/manage_credit_note.php?ref_invoice_id=' + invoice_id);
$('#btn_void').toggleClass('d-none', !((is_invoice || is_credit_note) && status >= 1 && status !== 4));
$('#due_date').toggleClass('d-none', !(is_invoice && status === 0));
$('#display_due').toggleClass('d-none', is_invoice && status === 0);
}
function issue_invoice() {
var due_date = $('#due_date').val().trim();
var is_credit_note = $('#page_subtitle').text().toLowerCase().includes('credit note');
if (!is_credit_note && !due_date) {
bootbox.alert('Please enter a due date before issuing this invoice.');
return;
}
if (due_date && issued_date && to_iso_date(due_date) < issued_date) {
bootbox.alert('The due date cannot be earlier than the issue date.');
return;
}
bootbox.confirm({
message: 'Issue this invoice?',
buttons: {
confirm: { label: 'Issue', className: 'btn-success' },
cancel: { label: 'Back', className: 'btn-secondary' }
},
callback: function(result) {
if (!result) return;
ajax_request({
url: '<?php echo $server_url?>order/api/engine/issue_invoice.php',
autoPrepare: true,
checkRequired: 0,
action: 'update',
data: {
id: invoice_id,
due_date: due_date ? due_date.split('/').reverse().join('-') : ''
},
onSuccess: function(res) {
retrieve_invoice();
if (res.gl_posted === true) {
toastr.success('GL entry posted automatically.', 'GL Posted');
} else if (res.gl_posted === false) {
toastr.warning('Invoice issued, but GL posting failed: ' + (res.gl_message || 'Unknown error'), 'GL Skipped', { timeOut: 8000 });
}
}
});
}
});
}
function void_invoice() {
bootbox.confirm({
message: '<strong>Void this invoice?</strong><br>This action cannot be undone.',
buttons: {
confirm: { label: 'Void Invoice', className: 'btn-danger' },
cancel: { label: 'Back', className: 'btn-secondary' }
},
callback: function(result) {
if (!result) return;
ajax_request({
url: '<?php echo $server_url?>order/api/engine/update_invoice_status.php',
autoPrepare: true,
checkRequired: 0,
action: 'update',
data: { invoice_id: invoice_id, status: 4 },
onSuccess: function() { retrieve_invoice(); }
});
}
});
}
function print_invoice() {
if (!invoice_id) return;
window.open('<?php echo $server_url?>order/print_invoice.php?id=' + invoice_id, '_blank');
}
function save_formula() {
ajax_request({
url: '<?php echo $server_url?>order/api/engine/manage_invoice.php',
autoPrepare: true,
checkRequired: 0,
action: 'save_formula',
data: { id: invoice_id, formula_id: $('#formula_id').val() || null },
onSuccess: function() {
bootbox.alert({ message: 'GL formula saved.', size: 'small' });
}
});
}
$(function() {
due_picker = flatpickr('#due_date', { dateFormat: 'd/m/Y', allowInput: true });
if (invoice_id) retrieve_invoice();
});
</script>
</body>
</html>