Files
wms-app/app/po/manage_purchase_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

446 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.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">Purchase Invoice</h1>
<p class="mb-0 text-muted" id="page_subtitle"></p>
</div>
<a href="<?php echo $server_url?>po/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">
<!-- Left: Items -->
<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"></tbody>
<tfoot id="item_tfoot"></tfoot>
</table>
</div>
</div>
</div>
<!-- Linked supplier credit notes (for purchase invoices) -->
<div class="card d-none" id="dn_card">
<div class="card-body p-5">
<h2 class="fs-5 mb-4">Supplier Credit Notes</h2>
<div class="table-responsive">
<table class="table mb-0 table-hover">
<thead class="table-light">
<tr>
<th>DN #</th>
<th>Date</th>
<th class="text-end">Amount</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody id="dn_tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Right: Info + actions -->
<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-2 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">Source:</span>
<a href="#" id="link_source" class="ms-1 fw-semibold"></a>
</div>
<div class="mb-2 small">
<span class="text-muted">Supplier:</span>
<span class="ms-1 fw-semibold" id="display_contact"></span>
</div>
<div class="mb-2 small">
<span class="text-muted">Department:</span>
<span class="ms-1" id="display_department">—</span>
</div>
<div class="mb-2 small">
<span class="text-muted">Date:</span>
<span class="ms-1" id="display_issued"></span>
</div>
<div class="mb-2 small" id="due_date_row">
<span class="text-muted">Due:</span>
<input type="text" id="due_date" class="form-control form-control-sm ms-1 d-inline-block"
style="width:140px;" placeholder="DD/MM/YYYY">
</div>
<div class="mt-3 small">
<span class="text-muted">Notes:</span>
<textarea id="notes" class="form-control form-control-sm mt-1" rows="2"></textarea>
</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>
</div>
<div class="card">
<div class="card-body p-4 d-flex flex-column gap-2">
<button class="btn btn-primary w-100" id="btn_save" onclick="save_invoice()">
<i class="ti ti-device-floppy me-1"></i>Save
</button>
<button class="btn btn-success w-100" id="btn_issue" onclick="issue_invoice()">
<i class="ti ti-send me-1"></i>Issue
</button>
<button class="btn btn-outline-danger w-100" id="btn_void" onclick="void_invoice()">
<i class="ti ti-ban me-1"></i>Void
</button>
<button class="btn btn-outline-danger w-100 d-none" id="btn_delete" onclick="delete_invoice()">
<i class="ti ti-trash me-1"></i>Delete
</button>
</div>
</div>
</div>
</div>
</div>
</main>
<?php require '../include_ending.php'; ?>
<script>
const can_delete = ['admin', 'owner'].includes(user_role);
var invoice_id = <?php echo $invoice_id; ?>;
var invoice_data = null;
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 = {
'purchase_invoice': '<span class="badge bg-primary">Purchase Invoice</span>',
'supplier_credit_note': '<span class="badge bg-info text-white">Supplier Credit Note</span>',
};
return map[doc_type] ?? '—';
}
function invoice_status_badge(status, due_date) {
if (String(status) === '1' && due_date) {
var today = new Date().toISOString().slice(0, 10);
if (due_date < today) 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 set_mode(inv) {
var status = parseInt(inv.status);
var doc_type = inv.doc_type;
var is_pi = doc_type === 'purchase_invoice';
var is_dn = doc_type === 'supplier_credit_note';
var is_draft = status === 0;
var is_editable = is_draft && (is_pi || is_dn);
$('#due_date_row').toggleClass('d-none', is_dn);
$('#due_date, #notes').prop('disabled', !is_editable);
$('#btn_issue').html(is_dn
? '<i class="ti ti-send me-1"></i>Issue Supplier Credit Note'
: '<i class="ti ti-send me-1"></i>Issue Purchase Invoice');
var not_editable_hint = (!is_pi && !is_dn) ? 'Unsupported document type' : '';
set_btn_state('#btn_save', is_editable, not_editable_hint || 'Only draft documents can be edited');
set_btn_state('#btn_issue', is_editable, not_editable_hint || (status > 0 ? 'Already issued' : 'Only drafts can be issued'));
set_btn_state('#btn_void', is_pi && status >= 1 && status !== 4,
!is_pi ? 'Only purchase invoices can be voided'
: status === 0 ? 'Issue the document before voiding'
: status === 4 ? 'Already void' : '');
$('#btn_delete').toggleClass('d-none', !can_delete || !invoice_id);
}
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) {
invoice_data = res.output;
var inv = invoice_data;
$('#page_title').text(inv.invoice_number);
$('#badge_doc_type').html(doc_type_badge(inv.doc_type));
$('#badge_status').html(invoice_status_badge(inv.status, inv.due_date));
$('#display_contact').text(inv.contact_name || '—');
$('#display_department').text(get_dept_label(inv.department_id));
$('#display_issued').text(format_date(inv.issued_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);
$('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : '');
$('#notes').val(inv.notes || '');
// Source link
if (inv.source === 'po' && inv.source_id) {
$('#link_source')
.attr('href', '<?php echo $server_url?>po/manage_po.php?id=' + inv.source_id)
.text('PO #' + inv.source_id);
} else if (inv.source === 'supplier_return' && inv.source_id) {
$('#link_source')
.attr('href', '<?php echo $server_url?>po/manage_supplier_return.php?id=' + inv.source_id)
.text('Return #' + inv.source_id);
}
// Items
var tbody = '';
$.each(inv.items || [], function(i, item) {
tbody += `<tr>
<td>${escape_html(item.product_name || item.product_sku)}</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(tbody);
var is_draft = parseInt(inv.status) === 0;
var is_editable_doc = inv.doc_type === 'purchase_invoice' || inv.doc_type === 'supplier_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);
// Linked debit notes (for purchase invoices)
if (res.supplier_credit_notes && res.supplier_credit_notes.length) {
var dn_rows = '';
$.each(res.supplier_credit_notes, function(i, dn) {
dn_rows += `<tr>
<td>${escape_html(dn.invoice_number)}</td>
<td>${format_date(dn.issued_date)}</td>
<td class="text-end">${format_number(dn.grand_total, 2)}</td>
<td>${invoice_status_badge(dn.status, dn.due_date)}</td>
<td><a href="<?php echo $server_url?>po/manage_purchase_invoice.php?id=${dn.id}"><i class="ti ti-eye fs-5"></i></a></td>
</tr>`;
});
$('#dn_tbody').html(dn_rows);
$('#dn_card').removeClass('d-none');
}
var gl_type = inv.doc_type === 'supplier_credit_note' ? 'supplier_credit_note' : 'purchase_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);
}
});
$('#btn_save_formula').prop('disabled', parseInt(inv.status) === 4);
set_mode(inv);
}
});
}
function save_invoice() {
var due_val = $('#due_date').val().trim();
if (due_val && issued_date && to_iso_date(due_val) < 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').val().split('/').reverse().join('-'),
notes: $('#notes').val(),
tax_adjustment: parseFloat($('#tax_adjustment').val()) || 0,
},
onSuccess: function() { retrieve_invoice(); }
});
}
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 issue_invoice() {
var is_dn = invoice_data && invoice_data.doc_type === 'supplier_credit_note';
var due_date = $('#due_date').val().trim();
if (!is_dn && !due_date) {
bootbox.alert('Please enter a due date before issuing this purchase invoice.');
return;
}
if (!is_dn && due_date && issued_date && to_iso_date(due_date) < issued_date) {
bootbox.alert('The due date cannot be earlier than the issue date.');
return;
}
var label = is_dn ? 'Issue Supplier Credit Note' : 'Issue Purchase Invoice';
bootbox.confirm({
message: is_dn ? 'Issue this supplier credit note?' : 'Issue this purchase invoice?',
buttons: {
confirm: { label: label, 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: {
invoice_id: invoice_id,
due_date: is_dn ? '' : 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('Document issued, but GL posting failed: ' + (res.gl_message || 'Unknown error'), 'GL Skipped', { timeOut: 8000 });
}
}
});
}
});
}
function delete_invoice() {
bootbox.confirm({
message: 'Delete this document? Any GL entry will be removed.',
buttons: { confirm: { label: 'Delete', 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/delete_invoice.php',
autoPrepare: false,
checkRequired: 0,
action: 'delete',
data: { id: invoice_id },
onSuccess: function() { window.location.href = '<?php echo $server_url?>po/invoice.php'; }
});
}
});
}
function void_invoice() {
bootbox.confirm({
message: '<strong>Void this purchase invoice?</strong><br>This action cannot be undone.',
buttons: {
confirm: { label: 'Void', 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() {
load_dept_cache();
due_picker = flatpickr('#due_date', { dateFormat: 'd/m/Y', allowInput: true });
if (invoice_id) retrieve_invoice();
});
</script>
</body>
</html>