Add Asset Management app
Assets from purchase invoice lines, straight-line depreciation/amortization with salvage value, disposals, and [Asset Mgmt] sources in Batch GL Entries. Green third workspace with an app access guard.
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_app_access('asset');
|
||||
require_once '../../../assets/utils/classes_as/AssetManager.php';
|
||||
|
||||
$answer['output'] = (new AssetManager($pdo2, $company_id))->getList((int)($data['invoice_id'] ?? 0));
|
||||
$answer['success'] = 1;
|
||||
exit(json_encode($answer));
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_app_access('asset');
|
||||
require_once '../../../assets/utils/classes_as/AssetManager.php';
|
||||
require_once '../../../assets/utils/classes_as/AssetRunManager.php';
|
||||
|
||||
try {
|
||||
$answer['output'] = (new AssetManager($pdo2, $company_id))->getStats();
|
||||
$answer['due'] = (new AssetRunManager($pdo2, $company_id))->dueSummary();
|
||||
$answer['success'] = 1;
|
||||
} catch (PDOException $e) {
|
||||
error_log('[asset stats] ' . $e->getMessage());
|
||||
$answer['message'] = 'Database error, please try again.';
|
||||
http_response_code(500);
|
||||
} catch (Exception $e) {
|
||||
$answer['message'] = $e->getMessage();
|
||||
http_response_code(400);
|
||||
}
|
||||
|
||||
exit(json_encode($answer));
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_app_access('asset');
|
||||
require_once '../../../assets/utils/classes_as/AssetCategoryManager.php';
|
||||
|
||||
$categories = new AssetCategoryManager($pdo2, $company_id);
|
||||
$answer['output'] = $categories->getAll();
|
||||
$answer['stats'] = $categories->getStats();
|
||||
$answer['success'] = 1;
|
||||
exit(json_encode($answer));
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_app_access('asset');
|
||||
require_once '../../../assets/utils/classes/UsageGuard.php';
|
||||
require_once '../../../assets/utils/classes_ac/PostingWindowGuard.php';
|
||||
require_once '../../../assets/utils/classes_ac/GlManager.php';
|
||||
require_once '../../../assets/utils/classes_as/AssetRunManager.php';
|
||||
|
||||
$action = (string)($data['action'] ?? 'list');
|
||||
$class = trim((string)($data['asset_class'] ?? ''));
|
||||
$through = trim((string)($data['through_period'] ?? ''));
|
||||
$run_id = (int)($data['run_id'] ?? 0);
|
||||
|
||||
if (in_array($action, ['run', 'void'], true)) {
|
||||
require_role($user_role, ['owner', 'admin']);
|
||||
}
|
||||
|
||||
try {
|
||||
$runs = new AssetRunManager($pdo2, $company_id);
|
||||
|
||||
switch ($action) {
|
||||
case 'list':
|
||||
$answer['output'] = $runs->getList($class);
|
||||
break;
|
||||
|
||||
case 'preview':
|
||||
$answer['output'] = $runs->preview($class, $through);
|
||||
break;
|
||||
|
||||
case 'detail':
|
||||
$answer['output'] = $runs->getDetail($run_id);
|
||||
break;
|
||||
|
||||
case 'run':
|
||||
$created = dbTransaction($pdo2, fn($pdo) => (new AssetRunManager($pdo, $company_id))->run($class, $through, $user_id));
|
||||
(new UsageGuard($pdo1, $company_id, $packages))->increment();
|
||||
$answer['output'] = $created;
|
||||
$answer['message'] = count($created) . ' run(s) created. Accounting posts them from Batch GL Entries.';
|
||||
break;
|
||||
|
||||
case 'void':
|
||||
dbTransaction($pdo2, function ($pdo) use ($company_id, $run_id, $pdo1) {
|
||||
$gl = new GlManager($pdo, $company_id, new PostingWindowGuard($pdo1, $company_id));
|
||||
(new AssetRunManager($pdo, $company_id))->void($run_id, $gl);
|
||||
});
|
||||
$answer['message'] = 'Run voided.';
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Exception('Invalid action.');
|
||||
}
|
||||
|
||||
$answer['success'] = 1;
|
||||
} catch (PDOException $e) {
|
||||
$answer['message'] = 'Database error, please try again.';
|
||||
http_response_code(500);
|
||||
} catch (Exception $e) {
|
||||
$answer['message'] = $e->getMessage();
|
||||
http_response_code(400);
|
||||
}
|
||||
|
||||
exit(json_encode($answer));
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_app_access('asset');
|
||||
require_once '../../../assets/utils/classes_as/AssetManager.php';
|
||||
|
||||
try {
|
||||
$assets = new AssetManager($pdo2, $company_id);
|
||||
|
||||
if (($data['action'] ?? '') === 'line') {
|
||||
$answer['output'] = $assets->getInvoiceLine((int)($data['invoice_id'] ?? 0), (int)($data['item_id'] ?? 0));
|
||||
} else {
|
||||
$answer['output'] = $assets->getCapitalizableLines(
|
||||
(string)($data['date_from'] ?? ''),
|
||||
(string)($data['date_to'] ?? '')
|
||||
);
|
||||
}
|
||||
$answer['success'] = 1;
|
||||
} catch (PDOException $e) {
|
||||
error_log('[asset invoice lines] ' . $e->getMessage());
|
||||
$answer['message'] = 'Database error, please try again.';
|
||||
http_response_code(500);
|
||||
} catch (Exception $e) {
|
||||
$answer['message'] = $e->getMessage();
|
||||
http_response_code(400);
|
||||
}
|
||||
|
||||
exit(json_encode($answer));
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_app_access('asset');
|
||||
require_once '../../../assets/utils/classes/UsageGuard.php';
|
||||
require_once '../../../assets/utils/classes_ac/PostingWindowGuard.php';
|
||||
require_once '../../../assets/utils/classes_ac/GlManager.php';
|
||||
require_once '../../../assets/utils/classes_as/AssetManager.php';
|
||||
|
||||
$action = (string)($data['action'] ?? '');
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
|
||||
$messages = [
|
||||
'create' => 'Asset saved as a draft.',
|
||||
'update' => 'Asset saved.',
|
||||
'activate' => 'Asset activated. Accounting posts its capitalization from Batch GL Entries.',
|
||||
'delete' => 'Draft asset deleted.',
|
||||
'void' => 'Asset voided.',
|
||||
'dispose' => 'Disposal recorded. Accounting posts it from Batch GL Entries.',
|
||||
'undo_disposal' => 'Disposal undone.',
|
||||
];
|
||||
|
||||
if (!isset($messages[$action])) {
|
||||
$answer['message'] = 'Invalid action.';
|
||||
http_response_code(400);
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
// Staff may prepare assets; everything that changes the books is owner/admin.
|
||||
require_role($user_role, in_array($action, ['create', 'update'], true) ? ['owner', 'admin', 'staff'] : ['owner', 'admin']);
|
||||
|
||||
if ($action !== 'create' && $id <= 0) {
|
||||
$answer['message'] = 'Missing asset id.';
|
||||
http_response_code(400);
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
try {
|
||||
$result_id = dbTransaction($pdo2, function ($pdo) use ($action, $id, $data, $company_id, $user_id, $pdo1) {
|
||||
$assets = new AssetManager($pdo, $company_id);
|
||||
$gl = new GlManager($pdo, $company_id, new PostingWindowGuard($pdo1, $company_id));
|
||||
|
||||
switch ($action) {
|
||||
case 'create':
|
||||
case 'update': return $assets->save($data, $user_id);
|
||||
case 'activate': $assets->activate($id); break;
|
||||
case 'delete': $assets->delete($id); break;
|
||||
case 'void': $assets->void($id, $gl); break;
|
||||
case 'dispose': $assets->dispose($id, $data); break;
|
||||
case 'undo_disposal': $assets->undoDisposal($id, $gl); break;
|
||||
}
|
||||
return $id;
|
||||
});
|
||||
|
||||
if ($action === 'create') {
|
||||
(new UsageGuard($pdo1, $company_id, $packages))->increment();
|
||||
}
|
||||
|
||||
$answer['success'] = 1;
|
||||
$answer['output'] = ['id' => $result_id];
|
||||
$answer['message'] = $messages[$action];
|
||||
} catch (PDOException $e) {
|
||||
$answer['message'] = 'Database error, please try again.';
|
||||
http_response_code(500);
|
||||
} catch (Exception $e) {
|
||||
$answer['message'] = $e->getMessage();
|
||||
http_response_code(400);
|
||||
}
|
||||
|
||||
exit(json_encode($answer));
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_app_access('asset');
|
||||
require_role($user_role, ['owner', 'admin']);
|
||||
require_once '../../../assets/utils/classes_as/AssetCategoryManager.php';
|
||||
|
||||
try {
|
||||
$id = (new AssetCategoryManager($pdo2, $company_id))->save($data);
|
||||
$answer['success'] = 1;
|
||||
$answer['output'] = ['id' => $id];
|
||||
$answer['message'] = !empty($data['id']) ? 'Category updated.' : 'Category created.';
|
||||
} catch (PDOException $e) {
|
||||
error_log('[asset category] ' . $e->getMessage());
|
||||
$answer['message'] = 'Database error, please try again.';
|
||||
http_response_code(500);
|
||||
} catch (Exception $e) {
|
||||
$answer['message'] = $e->getMessage();
|
||||
http_response_code(400);
|
||||
}
|
||||
|
||||
exit(json_encode($answer));
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_app_access('asset');
|
||||
require_role($user_role, ['owner', 'admin']);
|
||||
require_once '../../../assets/utils/classes_as/AssetCategoryManager.php';
|
||||
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
if (!$id) { $answer['message'] = 'Missing id'; exit(json_encode($answer)); }
|
||||
|
||||
(new AssetCategoryManager($pdo2, $company_id))->deactivate($id);
|
||||
|
||||
$answer['success'] = 1;
|
||||
$answer['message'] = 'Category deactivated.';
|
||||
exit(json_encode($answer));
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_app_access('asset');
|
||||
require_once '../../../assets/utils/classes_as/AssetManager.php';
|
||||
|
||||
try {
|
||||
$answer['output'] = (new AssetManager($pdo2, $company_id))->getDetail((int)($data['id'] ?? 0));
|
||||
$answer['success'] = 1;
|
||||
} catch (PDOException $e) {
|
||||
error_log('[asset retrieve] ' . $e->getMessage());
|
||||
$answer['message'] = 'Database error, please try again.';
|
||||
http_response_code(500);
|
||||
} catch (Exception $e) {
|
||||
$answer['message'] = $e->getMessage();
|
||||
http_response_code(400);
|
||||
}
|
||||
|
||||
exit(json_encode($answer));
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_app_access('asset');
|
||||
require_once '../../../assets/utils/classes_as/AssetCategoryManager.php';
|
||||
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
if (!$id) { $answer['message'] = 'Missing id'; exit(json_encode($answer)); }
|
||||
|
||||
$row = (new AssetCategoryManager($pdo2, $company_id))->getById($id);
|
||||
if (!$row) { $answer['message'] = 'Category not found'; exit(json_encode($answer)); }
|
||||
|
||||
$answer['output'] = $row;
|
||||
$answer['success'] = 1;
|
||||
exit(json_encode($answer));
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../config.php';
|
||||
require '../include_header.php';
|
||||
?>
|
||||
|
||||
<body>
|
||||
<?php require '../include_topbar.php'; ?>
|
||||
<?php require '../include_sidebar_asset.php'; ?>
|
||||
|
||||
<main id="content" class="content py-15">
|
||||
<div class="container-fluid">
|
||||
|
||||
<!-- Page header -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-5 gap-3">
|
||||
<div>
|
||||
<h1 class="fs-3 mb-1">Asset Categories</h1>
|
||||
<p class="mb-0">Useful life and GL accounts used by each kind of asset</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="<?php echo $server_url?>asset/manage_category.php" class="btn btn-primary" data-min-role="admin">
|
||||
<i class="ti ti-plus me-1"></i>Add Category
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card overflow-hidden">
|
||||
<div class="card-body p-6">
|
||||
|
||||
<div class="mb-5 d-flex justify-content-between align-items-center gap-3">
|
||||
<span class="text-muted small" id="category_summary"></span>
|
||||
<div class="position-relative" style="min-width:240px;">
|
||||
<input type="search" id="search_category" class="form-control ps-9" placeholder="Search code or name…">
|
||||
<span class="position-absolute top-25 ms-4">
|
||||
<i class="ti ti-search text-muted"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-0 text-nowrap table-hover table-centered" id="category_table">
|
||||
<thead class="table-primary border-light">
|
||||
<tr>
|
||||
<th>Code</th>
|
||||
<th>Name</th>
|
||||
<th>Class</th>
|
||||
<th class="text-end">Life (months)</th>
|
||||
<th>Cost / Accumulated / Expense</th>
|
||||
<th class="text-end">Assets</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
<tfoot></tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php require '../include_ending.php'; ?>
|
||||
<script src="<?php echo $server_url?>assets/js/asset.js"></script>
|
||||
|
||||
<script>
|
||||
var search_term = '';
|
||||
|
||||
function retrieve_categories() {
|
||||
return ajax_request({
|
||||
url: server_url + 'asset/api/engine/category.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'read',
|
||||
onSuccess: function(res) {
|
||||
alasql('CREATE TABLE IF NOT EXISTS md_asset_category');
|
||||
alasql.tables.md_asset_category.data = res.output || [];
|
||||
var s = res.stats || {};
|
||||
$('#category_summary').text((s.total || 0) + ' categories · ' + (s.active || 0) + ' active');
|
||||
change_page(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function change_page(page_num) {
|
||||
var offset = (page_num - 1) * prop_limit;
|
||||
var kw = search_term.toLowerCase();
|
||||
var rows = alasql('SELECT * FROM md_asset_category ORDER BY category_code').filter(function(r) {
|
||||
return !kw || String(r.category_code).toLowerCase().indexOf(kw) !== -1 || String(r.category_name).toLowerCase().indexOf(kw) !== -1;
|
||||
});
|
||||
|
||||
$('table#category_table tfoot').html(generate_pagination('category_table', rows.length));
|
||||
|
||||
if (!rows.length) {
|
||||
$('table#category_table tbody').html('<tr><td colspan="8" class="text-center py-5 text-muted">No categories yet. Add one before creating assets.</td></tr>');
|
||||
return;
|
||||
}
|
||||
|
||||
var body = '';
|
||||
rows.slice(offset, offset + parseInt(prop_limit, 10)).forEach(function(item) {
|
||||
body += `<tr>
|
||||
<td class="py-3 fw-semibold">${escape_html(item.category_code)}</td>
|
||||
<td class="py-3">${escape_html(item.category_name)}</td>
|
||||
<td class="py-3">${asset_class_badge(item.asset_class)}</td>
|
||||
<td class="py-3 text-end">${item.useful_life_months}</td>
|
||||
<td class="py-3 small text-muted">${escape_html(item.cost_account_code)} / ${escape_html(item.accum_account_code)} / ${escape_html(item.expense_account_code)}</td>
|
||||
<td class="py-3 text-end">${item.asset_count}</td>
|
||||
<td class="py-3">${String(item.status) === '1' ? '<span class="badge bg-success">Active</span>' : '<span class="badge bg-secondary">Inactive</span>'}</td>
|
||||
<td class="py-3">
|
||||
<a href="${server_url}asset/manage_category.php?id=${item.id}"><i class="ti ti-eye fs-5"></i></a>
|
||||
${String(item.status) === '1' ? `<a href="javascript:;" class="link-danger ms-2" data-min-role="admin" onclick="remove_category(${item.id})"><i class="ti ti-ban fs-5"></i></a>` : ''}
|
||||
</td>
|
||||
</tr>`;
|
||||
});
|
||||
$('table#category_table tbody').html(body);
|
||||
}
|
||||
|
||||
function remove_category(id) {
|
||||
bootbox.confirm({
|
||||
message: 'Deactivate this category? Existing assets keep using its accounts.',
|
||||
buttons: { confirm: { label: 'Deactivate', className: 'btn-danger' }, cancel: { label: 'Back', className: 'btn-secondary' } },
|
||||
callback: function(ok) {
|
||||
if (!ok) return;
|
||||
ajax_request({
|
||||
url: server_url + 'asset/api/engine/remove_category.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'delete',
|
||||
data: { id: id },
|
||||
onSuccess: function() { retrieve_categories(); }
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$('#search_category').on('input', debounce(function() {
|
||||
search_term = $(this).val().trim();
|
||||
change_page(1);
|
||||
}, 200));
|
||||
|
||||
$(async function() {
|
||||
try { await retrieve_categories(); } catch (e) { console.log(e); }
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,317 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../config.php';
|
||||
require '../include_header.php';
|
||||
?>
|
||||
|
||||
<body>
|
||||
<?php require '../include_topbar.php'; ?>
|
||||
<?php require '../include_sidebar_asset.php'; ?>
|
||||
|
||||
<main id="content" class="content py-15">
|
||||
<div class="container-fluid">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="mb-5">
|
||||
<h1 class="fs-3 mb-1">Depreciation & Amortization Runs</h1>
|
||||
<p class="mb-0">Charge each month's straight-line depreciation (tangible) or amortization (intangible)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New run -->
|
||||
<div class="card mb-5" data-min-role="admin">
|
||||
<div class="card-body p-5">
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label mb-1">Type</label>
|
||||
<select id="asset_class" class="form-select">
|
||||
<option value="tangible">Depreciation — tangible assets</option>
|
||||
<option value="intangible">Amortization — intangible assets</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label mb-1">Run Through Month</label>
|
||||
<input type="text" id="through_period" class="form-control" placeholder="YYYY-MM">
|
||||
</div>
|
||||
<div class="col-md-5 d-flex gap-2">
|
||||
<button class="btn btn-secondary" onclick="preview_run()"><i class="ti ti-eye me-1"></i>Preview</button>
|
||||
<button class="btn btn-primary" id="btn_run" onclick="create_run()" disabled><i class="ti ti-player-play me-1"></i>Create Runs</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted small mt-3 mb-0">
|
||||
One run is created per month still due, oldest first. The in-service month counts as a full month and the
|
||||
disposal month is not charged. Each run becomes one GL entry that Accounting posts from
|
||||
<strong>Batch GL Entries</strong>, labelled <strong>[Asset Mgmt]</strong>.
|
||||
</p>
|
||||
<div id="preview_area" class="mt-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Runs -->
|
||||
<div class="card">
|
||||
<div class="card-body p-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4 gap-3">
|
||||
<h2 class="fs-5 mb-0">Runs</h2>
|
||||
<select id="filter_class" class="form-select form-select-sm" style="width:220px;">
|
||||
<option value="">All types</option>
|
||||
<option value="tangible">Depreciation</option>
|
||||
<option value="intangible">Amortization</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 table-centered text-nowrap" id="run_table">
|
||||
<thead class="table-primary border-light">
|
||||
<tr>
|
||||
<th>Run No.</th>
|
||||
<th>Type</th>
|
||||
<th>Month</th>
|
||||
<th>Run Date</th>
|
||||
<th class="text-end">Assets</th>
|
||||
<th class="text-end">Amount</th>
|
||||
<th>GL</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td colspan="9" class="text-center text-muted py-5">Loading...</td></tr>
|
||||
</tbody>
|
||||
<tfoot></tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Run detail -->
|
||||
<div class="modal fade" id="run_detail_modal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="run_detail_title">Run</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="run_detail_body"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php require '../include_ending.php'; ?>
|
||||
<script src="<?php echo $server_url?>assets/js/asset.js"></script>
|
||||
|
||||
<script>
|
||||
var can_admin = ['owner', 'admin'].includes(user_role);
|
||||
var all_runs = [];
|
||||
var last_preview = null;
|
||||
|
||||
function run_type_label(asset_class) {
|
||||
return asset_class === 'intangible' ? 'Amortization' : 'Depreciation';
|
||||
}
|
||||
|
||||
function preview_run() {
|
||||
last_preview = null;
|
||||
set_btn_state('#btn_run', false);
|
||||
return ajax_request({
|
||||
url: server_url + 'asset/api/engine/depreciation_run.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'preview',
|
||||
data: { asset_class: $('#asset_class').val(), through_period: $('#through_period').val() },
|
||||
onSuccess: function(res) {
|
||||
var runs = res.output || [];
|
||||
last_preview = { asset_class: $('#asset_class').val(), through_period: $('#through_period').val(), runs: runs };
|
||||
render_preview(runs);
|
||||
set_btn_state('#btn_run', runs.length > 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function render_preview(runs) {
|
||||
if (!runs.length) {
|
||||
$('#preview_area').html('<div class="alert alert-success small mb-0"><i class="ti ti-circle-check me-1"></i>Nothing is due through this month.</div>');
|
||||
return;
|
||||
}
|
||||
var total = 0;
|
||||
var html = '<div class="table-responsive"><table class="table table-sm mb-0 table-centered">'
|
||||
+ '<thead class="table-light"><tr><th>Month</th><th>Run Date</th><th class="text-end">Assets</th><th class="text-end">Amount</th><th></th></tr></thead><tbody>';
|
||||
runs.forEach(function(r, idx) {
|
||||
total += parseFloat(r.total) || 0;
|
||||
html += '<tr>'
|
||||
+ '<td class="fw-semibold">' + escape_html(r.period) + '</td>'
|
||||
+ '<td>' + format_date(r.run_date) + '</td>'
|
||||
+ '<td class="text-end">' + r.asset_count + '</td>'
|
||||
+ '<td class="text-end">' + format_number(r.total, 2) + '</td>'
|
||||
+ '<td class="text-end"><a href="javascript:;" class="small" onclick="$(\'#preview_items_' + idx + '\').toggleClass(\'d-none\')">Assets</a></td>'
|
||||
+ '</tr>'
|
||||
+ '<tr id="preview_items_' + idx + '" class="d-none"><td colspan="5" class="bg-light">'
|
||||
+ items_table(r.items, false)
|
||||
+ '</td></tr>';
|
||||
});
|
||||
html += '</tbody><tfoot><tr class="fw-semibold"><td colspan="3">Total</td><td class="text-end">' + format_number(total, 2) + '</td><td></td></tr></tfoot></table></div>';
|
||||
$('#preview_area').html(html);
|
||||
}
|
||||
|
||||
function items_table(items, with_dept) {
|
||||
var html = '<table class="table table-sm mb-0"><thead><tr><th>Asset</th><th>Category</th>'
|
||||
+ (with_dept ? '<th>Dept</th>' : '')
|
||||
+ '<th class="text-end">Amount</th><th class="text-end">Accumulated</th><th class="text-end">Book Value</th></tr></thead><tbody>';
|
||||
(items || []).forEach(function(i) {
|
||||
html += '<tr>'
|
||||
+ '<td>' + escape_html(i.asset_number) + ' <span class="text-muted">' + escape_html(i.asset_name) + '</span></td>'
|
||||
+ '<td>' + escape_html((i.category_code ? i.category_code + ' ' : '') + (i.category_name || '')) + '</td>'
|
||||
+ (with_dept ? '<td>' + escape_html(i.dept_code || '—') + '</td>' : '')
|
||||
+ '<td class="text-end">' + format_number(i.amount, 2) + '</td>'
|
||||
+ '<td class="text-end">' + format_number(i.accumulated_after, 2) + '</td>'
|
||||
+ '<td class="text-end">' + format_number(i.book_value_after, 2) + '</td>'
|
||||
+ '</tr>';
|
||||
});
|
||||
return html + '</tbody></table>';
|
||||
}
|
||||
|
||||
function create_run() {
|
||||
if (!last_preview || !last_preview.runs.length) return;
|
||||
var total = last_preview.runs.reduce(function(s, r) { return s + (parseFloat(r.total) || 0); }, 0);
|
||||
bootbox.confirm({
|
||||
message: 'Create <strong>' + last_preview.runs.length + '</strong> ' + run_type_label(last_preview.asset_class).toLowerCase()
|
||||
+ ' run(s) through ' + escape_html(last_preview.through_period) + ', totalling <strong>' + format_number(total, 2) + '</strong>?',
|
||||
buttons: { confirm: { label: 'Create Runs', className: 'btn-primary' }, cancel: { label: 'Back', className: 'btn-secondary' } },
|
||||
callback: function(ok) {
|
||||
if (!ok) return;
|
||||
ajax_request({
|
||||
url: server_url + 'asset/api/engine/depreciation_run.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'run',
|
||||
data: { asset_class: last_preview.asset_class, through_period: last_preview.through_period },
|
||||
onSuccess: function(res) {
|
||||
toastr.success(res.message || 'Runs created.');
|
||||
$('#preview_area').empty();
|
||||
set_btn_state('#btn_run', false);
|
||||
last_preview = null;
|
||||
load_runs();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function load_runs() {
|
||||
return ajax_request({
|
||||
url: server_url + 'asset/api/engine/depreciation_run.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'list',
|
||||
data: { asset_class: '' },
|
||||
onSuccess: function(res) {
|
||||
all_runs = res.output || [];
|
||||
change_page(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function change_page(page_num) {
|
||||
var filter = $('#filter_class').val();
|
||||
var rows = all_runs.filter(function(r) { return !filter || r.asset_class === filter; });
|
||||
var limit = parseInt(prop_limit, 10);
|
||||
var offset = (page_num - 1) * limit;
|
||||
|
||||
$('#run_table tfoot').html(generate_pagination('run_table', rows.length));
|
||||
if (!rows.length) {
|
||||
$('#run_table tbody').html('<tr><td colspan="9" class="text-center text-muted py-5">No runs yet.</td></tr>');
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '';
|
||||
rows.slice(offset, offset + limit).forEach(function(r) {
|
||||
var active = String(r.status) === '1';
|
||||
html += '<tr class="' + (active ? '' : 'text-muted') + '">'
|
||||
+ '<td class="fw-semibold">' + escape_html(r.run_number) + '</td>'
|
||||
+ '<td>' + run_type_label(r.asset_class) + '</td>'
|
||||
+ '<td>' + escape_html(r.period) + '</td>'
|
||||
+ '<td>' + format_date(r.run_date) + '</td>'
|
||||
+ '<td class="text-end">' + r.asset_count + '</td>'
|
||||
+ '<td class="text-end">' + format_number(r.total_amount, 2) + '</td>'
|
||||
+ '<td>' + (active ? asset_gl_badge(r.gl_status) : '—') + '</td>'
|
||||
+ '<td>' + (active ? '<span class="badge bg-success">Active</span>' : '<span class="badge bg-light text-dark">Void</span>') + '</td>'
|
||||
+ '<td class="text-end">'
|
||||
+ (active ? '<a href="javascript:;" onclick="show_run(' + r.id + ')"><i class="ti ti-eye fs-5"></i></a>' : '')
|
||||
+ (active && can_admin ? '<a href="javascript:;" class="link-danger ms-2" title="Void run" onclick="void_run(' + r.id + ', \'' + escape_html(r.run_number) + '\', ' + r.gl_status + ')"><i class="ti ti-ban fs-5"></i></a>' : '')
|
||||
+ '</td></tr>';
|
||||
});
|
||||
$('#run_table tbody').html(html);
|
||||
}
|
||||
|
||||
function show_run(run_id) {
|
||||
$('#run_detail_body').html('<div class="text-center text-muted py-5">Loading...</div>');
|
||||
$('#run_detail_modal').modal('show');
|
||||
ajax_request({
|
||||
url: server_url + 'asset/api/engine/depreciation_run.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'detail',
|
||||
data: { run_id: run_id },
|
||||
onSuccess: function(res) {
|
||||
var run = res.output.run;
|
||||
$('#run_detail_title').text(run.run_number + ' · ' + run_type_label(run.asset_class) + ' ' + run.period);
|
||||
$('#run_detail_body').html(
|
||||
'<div class="small text-muted mb-3">Run date ' + format_date(run.run_date) + ' · '
|
||||
+ run.asset_count + ' assets · total ' + format_number(run.total_amount, 2) + '</div>'
|
||||
+ items_table(res.output.items, true)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function void_run(run_id, run_number, gl_status) {
|
||||
var note = String(gl_status) === '1'
|
||||
? '<br><span class="text-danger">Its GL entry is posted, so a reversal entry will be created.</span>'
|
||||
: '';
|
||||
bootbox.confirm({
|
||||
message: '<strong>Void run ' + run_number + '?</strong><br>Its charges are removed from the assets.' + note,
|
||||
buttons: { confirm: { label: 'Void', className: 'btn-danger' }, cancel: { label: 'Back', className: 'btn-secondary' } },
|
||||
callback: function(ok) {
|
||||
if (!ok) return;
|
||||
ajax_request({
|
||||
url: server_url + 'asset/api/engine/depreciation_run.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'void',
|
||||
data: { run_id: run_id },
|
||||
onSuccess: function(res) {
|
||||
toastr.success(res.message || 'Run voided.');
|
||||
load_runs();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$('#filter_class').on('change', function() { change_page(1); });
|
||||
$('#asset_class, #through_period').on('change', function() {
|
||||
last_preview = null;
|
||||
set_btn_state('#btn_run', false);
|
||||
$('#preview_area').empty();
|
||||
});
|
||||
|
||||
$(async function() {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
if (params.get('class') === 'intangible' || params.get('class') === 'tangible') {
|
||||
$('#asset_class').val(params.get('class'));
|
||||
}
|
||||
flatpickr('#through_period', {
|
||||
plugins: [new monthSelectPlugin({ shorthand: true, dateFormat: 'Y-m', altFormat: 'M Y' })],
|
||||
defaultDate: current_period() + '-01',
|
||||
maxDate: new Date()
|
||||
});
|
||||
$('#through_period').val(current_period());
|
||||
|
||||
try { await load_runs(); } catch (e) { console.log(e); }
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../config.php';
|
||||
require '../include_header.php';
|
||||
?>
|
||||
|
||||
<body>
|
||||
<?php require '../include_topbar.php'; ?>
|
||||
<?php require '../include_sidebar_asset.php'; ?>
|
||||
|
||||
<main id="content" class="content py-15">
|
||||
<div class="container-fluid">
|
||||
|
||||
<!-- Page header -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-5 gap-3">
|
||||
<div>
|
||||
<h1 class="fs-3 mb-1">Asset Register</h1>
|
||||
<p class="mb-0">Every asset with its cost, accumulated depreciation and book value</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="<?php echo $server_url?>asset/purchase_invoices.php" class="btn btn-outline-primary">
|
||||
<i class="ti ti-file-invoice me-1"></i>From Purchase Invoice
|
||||
</a>
|
||||
<a href="<?php echo $server_url?>asset/manage_asset.php" class="btn btn-primary">
|
||||
<i class="ti ti-plus me-1"></i>New Asset
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card overflow-hidden">
|
||||
<div class="card-body p-6">
|
||||
|
||||
<div class="mb-5 d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3">
|
||||
<ul class="nav nav-pills small" id="status_filter">
|
||||
<li class="nav-item"><a class="nav-link active" href="javascript:;" data-status="">All <span class="count"></span></a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="javascript:;" data-status="0">Draft <span class="count"></span></a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="javascript:;" data-status="1">Active <span class="count"></span></a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="javascript:;" data-status="2">Fully depreciated <span class="count"></span></a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="javascript:;" data-status="3">Disposed <span class="count"></span></a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="javascript:;" data-status="4">Void <span class="count"></span></a></li>
|
||||
</ul>
|
||||
<div class="position-relative" style="min-width:240px;">
|
||||
<input type="search" id="search_asset" class="form-control ps-9" placeholder="Search number, name, category…">
|
||||
<span class="position-absolute top-25 ms-4">
|
||||
<i class="ti ti-search text-muted"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-0 text-nowrap table-hover table-centered" id="asset_table">
|
||||
<thead class="table-primary border-light">
|
||||
<tr>
|
||||
<th>Asset No.</th>
|
||||
<th>Name</th>
|
||||
<th>Category</th>
|
||||
<th>In Service</th>
|
||||
<th class="text-end">Cost</th>
|
||||
<th class="text-end">Accumulated</th>
|
||||
<th class="text-end">Book Value</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
<tfoot></tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php require '../include_ending.php'; ?>
|
||||
<script src="<?php echo $server_url?>assets/js/asset.js"></script>
|
||||
|
||||
<script>
|
||||
var all_assets = [];
|
||||
var search_term = '';
|
||||
var status_filter = new URLSearchParams(window.location.search).get('status') || '';
|
||||
|
||||
function retrieve_assets() {
|
||||
return ajax_request({
|
||||
url: server_url + 'asset/api/engine/asset.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'read',
|
||||
onSuccess: function(res) {
|
||||
all_assets = res.output || [];
|
||||
update_counts();
|
||||
change_page(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function update_counts() {
|
||||
$('#status_filter a').each(function() {
|
||||
var s = String($(this).data('status'));
|
||||
var n = s === '' ? all_assets.length : all_assets.filter(function(a) { return String(a.status) === s; }).length;
|
||||
$(this).find('.count').text('(' + n + ')');
|
||||
$(this).toggleClass('active', s === status_filter);
|
||||
});
|
||||
}
|
||||
|
||||
function filtered_assets() {
|
||||
var kw = search_term.toLowerCase();
|
||||
return all_assets.filter(function(a) {
|
||||
if (status_filter !== '' && String(a.status) !== status_filter) return false;
|
||||
if (!kw) return true;
|
||||
return [a.asset_number, a.asset_name, a.category_code, a.category_name, a.invoice_number]
|
||||
.some(function(v) { return String(v || '').toLowerCase().indexOf(kw) !== -1; });
|
||||
});
|
||||
}
|
||||
|
||||
function change_page(page_num) {
|
||||
var rows = filtered_assets();
|
||||
var limit = parseInt(prop_limit, 10);
|
||||
var offset = (page_num - 1) * limit;
|
||||
|
||||
$('table#asset_table tfoot').html(generate_pagination('asset_table', rows.length));
|
||||
|
||||
if (!rows.length) {
|
||||
$('table#asset_table tbody').html('<tr><td colspan="9" class="text-center py-5 text-muted">No assets found.</td></tr>');
|
||||
return;
|
||||
}
|
||||
|
||||
var body = '';
|
||||
rows.slice(offset, offset + limit).forEach(function(a) {
|
||||
var source = a.invoice_number
|
||||
? '<div class="small text-muted"><i class="ti ti-file-invoice me-1"></i>' + escape_html(a.invoice_number) + '</div>'
|
||||
: '';
|
||||
body += `<tr>
|
||||
<td class="py-3 fw-semibold">${escape_html(a.asset_number)}</td>
|
||||
<td class="py-3">${escape_html(a.asset_name)}${source}</td>
|
||||
<td class="py-3">${escape_html(a.category_code)} <span class="text-muted">${escape_html(a.category_name)}</span></td>
|
||||
<td class="py-3">${format_date(a.in_service_date)}</td>
|
||||
<td class="py-3 text-end">${format_number(a.cost, 2)}</td>
|
||||
<td class="py-3 text-end">${format_number(a.accumulated, 2)}</td>
|
||||
<td class="py-3 text-end">${format_number(a.book_value, 2)}</td>
|
||||
<td class="py-3">${asset_status_badge(a.status, a.asset_class)}</td>
|
||||
<td class="py-3"><a href="${server_url}asset/manage_asset.php?id=${a.id}"><i class="ti ti-eye fs-5"></i></a></td>
|
||||
</tr>`;
|
||||
});
|
||||
$('table#asset_table tbody').html(body);
|
||||
}
|
||||
|
||||
$('#status_filter').on('click', 'a', function() {
|
||||
status_filter = String($(this).data('status'));
|
||||
update_counts();
|
||||
change_page(1);
|
||||
});
|
||||
|
||||
$('#search_asset').on('input', debounce(function() {
|
||||
search_term = $(this).val().trim();
|
||||
change_page(1);
|
||||
}, 200));
|
||||
|
||||
$(async function() {
|
||||
try { await retrieve_assets(); } catch (e) { console.log(e); }
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,595 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../config.php';
|
||||
require '../include_header.php';
|
||||
|
||||
$asset_id = (int)($_GET['id'] ?? 0);
|
||||
$invoice_id = (int)($_GET['invoice_id'] ?? 0);
|
||||
$item_id = (int)($_GET['item_id'] ?? 0);
|
||||
$pi_link_ok = in_array($_SESSION['login_app_access'] ?? 'wms', ['all', 'accounting'], true);
|
||||
?>
|
||||
|
||||
<body>
|
||||
<?php require '../include_topbar.php'; ?>
|
||||
<?php require '../include_sidebar_asset.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 gap-3">
|
||||
<div>
|
||||
<h1 class="fs-3 mb-1" id="page_title">New Asset</h1>
|
||||
<p class="mb-0 text-muted" id="page_subtitle">Straight-line depreciation with your own salvage value</p>
|
||||
</div>
|
||||
<a href="<?php echo $server_url?>asset/index.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: form + schedule -->
|
||||
<div class="col-lg-8">
|
||||
|
||||
<div class="alert alert-info small d-none" id="source_alert"></div>
|
||||
|
||||
<div class="card mb-5" id="assetForm">
|
||||
<div class="card-body p-5">
|
||||
<h2 class="fs-5 mb-4">Asset Details</h2>
|
||||
<input type="hidden" id="id" value="<?php echo $asset_id ?: ''; ?>">
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label">Asset Name <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="asset_name" required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Department</label>
|
||||
<select class="form-select" id="department_id"></select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Category <span class="text-danger">*</span></label>
|
||||
<select class="form-select" id="category_id" required></select>
|
||||
<div class="form-text" id="class_hint"></div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Clearing Account</label>
|
||||
<select class="form-select" id="clearing_account_code"></select>
|
||||
<div class="form-text">Credited when the asset is capitalized — the account the purchase was posted to. Leave empty if the asset is already on the books.</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Acquisition Date <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="acquisition_date" placeholder="DD/MM/YYYY" required>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">In-Service Date <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="in_service_date" placeholder="DD/MM/YYYY" required>
|
||||
</div>
|
||||
<div class="col-md-6"></div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Cost <span class="text-danger">*</span></label>
|
||||
<input type="number" class="form-control text-end" id="cost" min="0" step="0.01" required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Salvage Value</label>
|
||||
<input type="number" class="form-control text-end" id="salvage_value" min="0" step="0.01" value="0">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Useful Life (months) <span class="text-danger">*</span></label>
|
||||
<input type="number" class="form-control text-end" id="useful_life_months" min="1" max="1200" step="1" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="bg-light rounded-2 px-4 py-3 d-flex flex-wrap gap-4 small">
|
||||
<div><span class="text-muted">Amount to <span class="label-charge">depreciate</span>:</span> <strong id="calc_base">—</strong></div>
|
||||
<div><span class="text-muted">Per month:</span> <strong id="calc_monthly">—</strong></div>
|
||||
<div><span class="text-muted">Last month:</span> <strong id="calc_last">—</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-control" id="description" rows="2" placeholder="Serial number, location, notes"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text mt-3 d-none" id="locked_hint">
|
||||
<i class="ti ti-lock me-1"></i>Cost, salvage value, life, dates and category are locked because months have already been charged.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card d-none" id="schedule_card">
|
||||
<div class="card-body p-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="fs-5 mb-0"><span class="label-schedule">Depreciation</span> Schedule</h2>
|
||||
<span class="small text-muted" id="schedule_summary"></span>
|
||||
</div>
|
||||
<div class="table-responsive" style="max-height:480px;">
|
||||
<table class="table table-sm mb-0 table-centered">
|
||||
<thead class="table-light" style="position:sticky; top:0;">
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Month</th>
|
||||
<th class="text-end">Amount</th>
|
||||
<th class="text-end">Accumulated</th>
|
||||
<th class="text-end">Book Value</th>
|
||||
<th>State</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="schedule_tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: status + actions -->
|
||||
<div class="col-lg-4">
|
||||
|
||||
<div class="card mb-5 d-none" id="status_card">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="fs-5 mb-3">Status</h2>
|
||||
<div class="mb-3 d-flex gap-2 flex-wrap" id="status_badges"></div>
|
||||
<div class="small mb-2"><span class="text-muted">Accumulated:</span> <span class="fw-semibold ms-1" id="display_accumulated">—</span></div>
|
||||
<div class="small mb-2"><span class="text-muted">Book value:</span> <span class="fw-semibold ms-1" id="display_book_value">—</span></div>
|
||||
<div class="small mb-2"><span class="text-muted">Last month charged:</span> <span class="ms-1" id="display_last_period">—</span></div>
|
||||
<div class="small mb-2 d-none" id="source_row"><span class="text-muted">Source:</span> <span class="ms-1" id="display_source"></span></div>
|
||||
|
||||
<hr>
|
||||
<h3 class="fs-6 mb-2">GL Entries <span class="badge bg-success text-white ms-1">[Asset Mgmt]</span></h3>
|
||||
<div class="small mb-2"><div class="text-muted">Capitalization</div><div id="gl_capitalization"></div></div>
|
||||
<div class="small mb-2 d-none" id="gl_disposal_row"><div class="text-muted">Disposal</div><div id="gl_disposal"></div></div>
|
||||
|
||||
<div class="d-none" id="disposal_info">
|
||||
<hr>
|
||||
<h3 class="fs-6 mb-2">Disposal</h3>
|
||||
<div class="small mb-1"><span class="text-muted">Date:</span> <span class="ms-1" id="display_disposal_date"></span></div>
|
||||
<div class="small mb-1"><span class="text-muted">Sale price:</span> <span class="ms-1" id="display_sale_price"></span></div>
|
||||
<div class="small mb-1"><span class="text-muted">Gain / (loss):</span> <span class="ms-1 fw-semibold" id="display_gain_loss"></span></div>
|
||||
<div class="small mb-1 text-muted" id="display_disposal_notes"></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_asset()">
|
||||
<i class="ti ti-device-floppy me-1"></i><span>Save as Draft</span>
|
||||
</button>
|
||||
<button class="btn btn-success w-100 d-none" id="btn_activate" onclick="asset_action('activate')">
|
||||
<i class="ti ti-circle-check me-1"></i>Activate
|
||||
</button>
|
||||
<button class="btn btn-outline-dark w-100 d-none" id="btn_dispose" onclick="open_disposal()">
|
||||
<i class="ti ti-receipt-refund me-1"></i>Sell / Dispose
|
||||
</button>
|
||||
<button class="btn btn-outline-warning w-100 d-none" id="btn_undo_disposal" onclick="asset_action('undo_disposal')">
|
||||
<i class="ti ti-arrow-back-up me-1"></i>Undo Disposal
|
||||
</button>
|
||||
<button class="btn btn-outline-danger w-100 d-none" id="btn_void" onclick="asset_action('void')">
|
||||
<i class="ti ti-ban me-1"></i>Void
|
||||
</button>
|
||||
<button class="btn btn-outline-danger w-100 d-none" id="btn_delete" onclick="asset_action('delete')">
|
||||
<i class="ti ti-trash me-1"></i>Delete Draft
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Disposal modal -->
|
||||
<div class="modal fade" id="disposal_modal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Sell / Dispose of Asset</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Disposal Date</label>
|
||||
<input type="text" class="form-control" id="disposal_date" placeholder="DD/MM/YYYY">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Sale Price</label>
|
||||
<input type="number" class="form-control text-end" id="sale_price" min="0" step="0.01" value="0">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Proceeds Account</label>
|
||||
<select class="form-select" id="proceeds_account_code"></select>
|
||||
<div class="form-text">Cash, bank or receivable that received the money. Not needed for a write-off (price 0).</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Notes</label>
|
||||
<textarea class="form-control" id="disposal_notes" rows="2" placeholder="Buyer, reason"></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="bg-light rounded-2 px-3 py-2 small">
|
||||
<div class="d-flex justify-content-between"><span class="text-muted">Cost</span><span id="dp_cost"></span></div>
|
||||
<div class="d-flex justify-content-between"><span class="text-muted">Accumulated (months charged so far)</span><span id="dp_accumulated"></span></div>
|
||||
<div class="d-flex justify-content-between"><span class="text-muted">Book value</span><span id="dp_book"></span></div>
|
||||
<div class="d-flex justify-content-between fw-semibold"><span id="dp_result_label">Gain / (loss)</span><span id="dp_result"></span></div>
|
||||
</div>
|
||||
<div class="form-text">Every month before the disposal month must already be charged. The disposal month itself is not charged.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" data-bs-dismiss="modal">Back</button>
|
||||
<button class="btn btn-dark" onclick="submit_disposal()"><i class="ti ti-receipt-refund me-1"></i>Record Disposal</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php require '../include_ending.php'; ?>
|
||||
<script src="<?php echo $server_url?>assets/js/asset.js"></script>
|
||||
|
||||
<script>
|
||||
var asset_id = <?php echo $asset_id; ?>;
|
||||
var invoice_id = <?php echo $invoice_id; ?>;
|
||||
var item_id = <?php echo $item_id; ?>;
|
||||
var pi_link_ok = <?php echo $pi_link_ok ? 'true' : 'false'; ?>;
|
||||
var can_admin = ['owner', 'admin'].includes(user_role);
|
||||
var can_edit = ['owner', 'admin', 'staff'].includes(user_role);
|
||||
|
||||
var categories = [];
|
||||
var accounts = [];
|
||||
var detail = null;
|
||||
var invoice_line = null;
|
||||
var life_touched = false;
|
||||
|
||||
var FINANCIAL_FIELDS = '#category_id, #acquisition_date, #in_service_date, #cost, #salvage_value, #useful_life_months, #clearing_account_code';
|
||||
|
||||
// ── lookups ────────────────────────────────────────────────────────────────
|
||||
function load_categories() {
|
||||
return ajax_request({
|
||||
url: server_url + 'asset/api/engine/category.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'read',
|
||||
queueLock: false,
|
||||
noLoading: true,
|
||||
onSuccess: function(res) { categories = res.output || []; }
|
||||
});
|
||||
}
|
||||
|
||||
function category_by_id(id) {
|
||||
return categories.find(function(c) { return String(c.id) === String(id); }) || null;
|
||||
}
|
||||
|
||||
function fill_category_select(selected) {
|
||||
var html = '<option value="">— Select category —</option>';
|
||||
categories.forEach(function(c) {
|
||||
if (String(c.status) !== '1' && String(c.id) !== String(selected)) return;
|
||||
html += '<option value="' + c.id + '">' + escape_html(c.category_code + ' — ' + c.category_name)
|
||||
+ (c.asset_class === 'intangible' ? ' (intangible)' : '') + '</option>';
|
||||
});
|
||||
$('#category_id').html(html).val(selected ? String(selected) : '');
|
||||
}
|
||||
|
||||
function update_class_hint() {
|
||||
var c = category_by_id($('#category_id').val());
|
||||
var intangible = c && c.asset_class === 'intangible';
|
||||
$('#class_hint').html(c ? asset_class_badge(c.asset_class) : '');
|
||||
$('.label-charge').text(intangible ? 'amortize' : 'depreciate');
|
||||
$('.label-schedule').text(intangible ? 'Amortization' : 'Depreciation');
|
||||
}
|
||||
|
||||
// ── live calculation ───────────────────────────────────────────────────────
|
||||
function update_calc() {
|
||||
var cost = parseFloat($('#cost').val()) || 0;
|
||||
var salvage = parseFloat($('#salvage_value').val()) || 0;
|
||||
var life = parseInt($('#useful_life_months').val(), 10) || 0;
|
||||
var base = round_dp(cost - salvage, 2);
|
||||
if (base <= 0 || life <= 0) {
|
||||
$('#calc_base, #calc_monthly, #calc_last').text('—');
|
||||
return;
|
||||
}
|
||||
var monthly = round_dp(base / life, 2);
|
||||
var last = round_dp(base - monthly * (life - 1), 2);
|
||||
$('#calc_base').text(format_number(base, 2));
|
||||
$('#calc_monthly').text(format_number(monthly, 2));
|
||||
$('#calc_last').text(life > 1 ? format_number(Math.max(last, 0), 2) : format_number(base, 2));
|
||||
}
|
||||
|
||||
// ── load ───────────────────────────────────────────────────────────────────
|
||||
function retrieve_asset() {
|
||||
return ajax_request({
|
||||
url: server_url + 'asset/api/engine/retrieve_asset.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'read',
|
||||
data: { id: asset_id },
|
||||
onSuccess: function(res) {
|
||||
detail = res.output;
|
||||
render_detail();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function load_invoice_line() {
|
||||
return ajax_request({
|
||||
url: server_url + 'asset/api/engine/invoice_lines.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'line',
|
||||
data: { invoice_id: invoice_id, item_id: item_id },
|
||||
onSuccess: function(res) {
|
||||
invoice_line = res.output;
|
||||
var l = invoice_line;
|
||||
$('#asset_name').val(l.product_name || l.product_sku);
|
||||
$('#acquisition_date, #in_service_date').val(format_date(l.issued_date));
|
||||
$('#cost').val(Math.max(parseFloat(l.remaining) || 0, 0).toFixed(2));
|
||||
$('#clearing_account_code').html(account_options(accounts, [], l.clearing_account_code, '— None —'));
|
||||
load_departments('department_id', l.department_id);
|
||||
$('#source_alert').removeClass('d-none').html(
|
||||
'<i class="ti ti-file-invoice me-1"></i>From purchase invoice <strong>' + escape_html(l.invoice_number) + '</strong>'
|
||||
+ ' (' + escape_html(l.contact_name || '—') + '), line <strong>' + escape_html(l.product_sku) + '</strong>: '
|
||||
+ 'amount ' + format_number(l.line_amount, 2) + ', already capitalized ' + format_number(l.capitalized, 2)
|
||||
+ ', remaining <strong>' + format_number(l.remaining, 2) + '</strong>.'
|
||||
+ (l.clearing_account_code ? '' : ' <span class="text-danger">Select the account the invoice line was posted to as the clearing account.</span>')
|
||||
);
|
||||
$('#page_subtitle').text('Capitalizing a purchase invoice line');
|
||||
update_calc();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function render_detail() {
|
||||
var a = detail.asset;
|
||||
var status = parseInt(a.status, 10);
|
||||
var recorded = detail.schedule.some(function(r) { return r.state === 'recorded'; });
|
||||
var closed = status === 3 || status === 4;
|
||||
|
||||
$('#page_title').text(a.asset_number + ' · ' + a.asset_name);
|
||||
$('#page_subtitle').text(a.category_code + ' ' + a.category_name);
|
||||
|
||||
$('#id').val(a.id);
|
||||
$('#asset_name').val(a.asset_name);
|
||||
fill_category_select(a.category_id);
|
||||
update_class_hint();
|
||||
$('#clearing_account_code').html(account_options(accounts, [], a.clearing_account_code, '— None —'));
|
||||
$('#acquisition_date').val(format_date(a.acquisition_date));
|
||||
$('#in_service_date').val(format_date(a.in_service_date));
|
||||
$('#cost').val(parseFloat(a.cost).toFixed(2));
|
||||
$('#salvage_value').val(parseFloat(a.salvage_value).toFixed(2));
|
||||
$('#useful_life_months').val(a.useful_life_months);
|
||||
$('#description').val(a.description || '');
|
||||
load_departments('department_id', a.department_id);
|
||||
life_touched = true;
|
||||
update_calc();
|
||||
|
||||
// Status card
|
||||
$('#status_card').removeClass('d-none');
|
||||
$('#status_badges').html(asset_status_badge(a.status, a.asset_class) + asset_class_badge(a.asset_class));
|
||||
$('#display_accumulated').text(format_number(a.accumulated, 2));
|
||||
$('#display_book_value').text(format_number(a.book_value, 2));
|
||||
$('#display_last_period').text(a.last_period || '—');
|
||||
if (parseInt(a.invoice_id, 10) > 0) {
|
||||
var ref = escape_html(a.invoice_number || ('PI #' + a.invoice_id));
|
||||
$('#display_source').html(pi_link_ok
|
||||
? '<a href="' + server_url + 'expense/manage_purchase_invoice.php?id=' + a.invoice_id + '">' + ref + '</a>'
|
||||
: ref);
|
||||
$('#source_row').removeClass('d-none');
|
||||
}
|
||||
|
||||
// GL entries
|
||||
var cap_html;
|
||||
if (!a.clearing_account_code) {
|
||||
cap_html = '<span class="text-muted">No entry — no clearing account.</span>';
|
||||
} else if (status === 0) {
|
||||
cap_html = '<span class="text-muted">Activate the asset, then Accounting posts it.</span>';
|
||||
} else {
|
||||
cap_html = gl_entry_html(detail.gl.capitalization);
|
||||
}
|
||||
$('#gl_capitalization').html(cap_html);
|
||||
$('#gl_disposal_row').toggleClass('d-none', status !== 3);
|
||||
$('#gl_disposal').html(gl_entry_html(detail.gl.disposal));
|
||||
|
||||
// Disposal
|
||||
if (status === 3) {
|
||||
var gain = round_dp(parseFloat(a.sale_price) - (parseFloat(a.cost) - parseFloat(a.accumulated)), 2);
|
||||
$('#display_disposal_date').text(format_date(a.disposal_date));
|
||||
$('#display_sale_price').text(format_number(a.sale_price, 2));
|
||||
$('#display_gain_loss').text(gain < 0 ? '(' + format_number(-gain, 2) + ')' : format_number(gain, 2))
|
||||
.toggleClass('text-danger', gain < 0).toggleClass('text-success', gain > 0);
|
||||
$('#display_disposal_notes').text(a.disposal_notes || '');
|
||||
$('#disposal_info').removeClass('d-none');
|
||||
} else {
|
||||
$('#disposal_info').addClass('d-none');
|
||||
}
|
||||
|
||||
render_schedule();
|
||||
|
||||
// Editing rules
|
||||
$(FINANCIAL_FIELDS).prop('disabled', closed || recorded || !can_edit);
|
||||
$('#asset_name, #description, #department_id').prop('disabled', closed || !can_edit);
|
||||
$('#locked_hint').toggleClass('d-none', !(recorded && !closed));
|
||||
|
||||
$('#btn_save').toggleClass('d-none', closed || !can_edit).find('span').text(status === 0 ? 'Save Draft' : 'Save');
|
||||
$('#btn_activate').toggleClass('d-none', !(status === 0 && can_admin));
|
||||
$('#btn_dispose').toggleClass('d-none', !((status === 1 || status === 2) && can_admin));
|
||||
$('#btn_undo_disposal').toggleClass('d-none', !(status === 3 && can_admin));
|
||||
$('#btn_void').toggleClass('d-none', !((status === 1 || status === 2) && can_admin && !recorded));
|
||||
$('#btn_delete').toggleClass('d-none', !(status === 0 && can_admin));
|
||||
}
|
||||
|
||||
function gl_entry_html(entry) {
|
||||
if (entry) {
|
||||
return '<span class="badge bg-success-subtle text-success">Posted</span>'
|
||||
+ ' <span class="text-muted ms-1">' + escape_html(entry.reference) + ' · ' + format_date(entry.journal_date) + '</span>';
|
||||
}
|
||||
return '<span class="badge bg-danger-subtle text-danger">Not posted</span>'
|
||||
+ ' <span class="text-muted ms-1">Accounting → Batch GL Entries</span>';
|
||||
}
|
||||
|
||||
function render_schedule() {
|
||||
var rows = detail.schedule || [];
|
||||
if (!rows.length) { $('#schedule_card').addClass('d-none'); return; }
|
||||
|
||||
var state_badge = {
|
||||
recorded: function(r) { return '<span class="badge bg-success-subtle text-success">Charged</span> <span class="small text-muted">' + escape_html(r.run_number || '') + '</span>'; },
|
||||
projected: function() { return '<span class="badge bg-light text-dark">Projected</span>'; },
|
||||
stopped: function() { return '<span class="badge bg-secondary-subtle text-secondary">Not charged</span>'; }
|
||||
};
|
||||
|
||||
var html = '';
|
||||
var charged = 0;
|
||||
rows.forEach(function(r, i) {
|
||||
if (r.state === 'recorded') charged++;
|
||||
html += '<tr class="' + (r.state === 'stopped' ? 'text-muted' : '') + '">'
|
||||
+ '<td>' + (i + 1) + '</td>'
|
||||
+ '<td>' + escape_html(r.period) + '</td>'
|
||||
+ '<td class="text-end">' + format_number(r.amount, 2) + '</td>'
|
||||
+ '<td class="text-end">' + format_number(r.accumulated, 2) + '</td>'
|
||||
+ '<td class="text-end">' + format_number(r.book_value, 2) + '</td>'
|
||||
+ '<td>' + state_badge[r.state](r) + '</td>'
|
||||
+ '</tr>';
|
||||
});
|
||||
$('#schedule_tbody').html(html);
|
||||
$('#schedule_summary').text(charged + ' of ' + rows.length + ' months charged · ' + format_number(detail.monthly_amount, 2) + ' per month');
|
||||
$('#schedule_card').removeClass('d-none');
|
||||
}
|
||||
|
||||
// ── actions ────────────────────────────────────────────────────────────────
|
||||
function save_asset() {
|
||||
return ajax_request({
|
||||
url: server_url + 'asset/api/engine/manage_asset.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 1,
|
||||
action: 'manage',
|
||||
data: { id: asset_id || '', invoice_id: invoice_id, invoice_item_id: item_id },
|
||||
onSuccess: function(res) {
|
||||
var saved_id = res.output && res.output.id;
|
||||
if (!asset_id && saved_id) {
|
||||
window.location.href = server_url + 'asset/manage_asset.php?id=' + saved_id;
|
||||
return;
|
||||
}
|
||||
toastr.success(res.message || 'Saved.');
|
||||
retrieve_asset();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var ACTION_CONFIRM = {
|
||||
activate: ['Activate this asset? It will start appearing in depreciation runs from its in-service month.', 'Activate', 'btn-success'],
|
||||
undo_disposal: ['Undo the disposal? If its GL entry was posted, a reversal entry is created.', 'Undo Disposal', 'btn-warning'],
|
||||
void: ['<strong>Void this asset?</strong><br>If its capitalization was posted, a reversal entry is created. This cannot be undone.', 'Void', 'btn-danger'],
|
||||
delete: ['Delete this draft asset?', 'Delete', 'btn-danger']
|
||||
};
|
||||
|
||||
function asset_action(action) {
|
||||
var c = ACTION_CONFIRM[action];
|
||||
bootbox.confirm({
|
||||
message: c[0],
|
||||
buttons: { confirm: { label: c[1], className: c[2] }, cancel: { label: 'Back', className: 'btn-secondary' } },
|
||||
callback: function(ok) {
|
||||
if (!ok) return;
|
||||
ajax_request({
|
||||
url: server_url + 'asset/api/engine/manage_asset.php',
|
||||
autoPrepare: false,
|
||||
action: action,
|
||||
data: { json: JSON.stringify({
|
||||
otp: document.getElementById('session-context').dataset.otp,
|
||||
company_id: company_id,
|
||||
action: action,
|
||||
id: asset_id
|
||||
}) },
|
||||
onSuccess: function(res) {
|
||||
if (action === 'delete') { window.location.href = server_url + 'asset/index.php'; return; }
|
||||
toastr.success(res.message || 'Done.');
|
||||
retrieve_asset();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function open_disposal() {
|
||||
var a = detail.asset;
|
||||
$('#proceeds_account_code').html(account_options(accounts, ['asset'], '', '— None (write-off) —'));
|
||||
$('#sale_price').val('0');
|
||||
$('#disposal_notes').val('');
|
||||
$('#dp_cost').text(format_number(a.cost, 2));
|
||||
$('#dp_accumulated').text(format_number(a.accumulated, 2));
|
||||
$('#dp_book').text(format_number(a.book_value, 2));
|
||||
update_disposal_result();
|
||||
$('#disposal_modal').modal('show');
|
||||
}
|
||||
|
||||
function update_disposal_result() {
|
||||
if (!detail) return;
|
||||
var gain = round_dp((parseFloat($('#sale_price').val()) || 0) - parseFloat(detail.asset.book_value), 2);
|
||||
$('#dp_result_label').text(gain < 0 ? 'Loss' : 'Gain');
|
||||
$('#dp_result').text(format_number(Math.abs(gain), 2)).toggleClass('text-danger', gain < 0).toggleClass('text-success', gain > 0);
|
||||
}
|
||||
|
||||
function submit_disposal() {
|
||||
ajax_request({
|
||||
url: server_url + 'asset/api/engine/manage_asset.php',
|
||||
autoPrepare: false,
|
||||
action: 'dispose',
|
||||
data: { json: JSON.stringify({
|
||||
otp: document.getElementById('session-context').dataset.otp,
|
||||
company_id: company_id,
|
||||
action: 'dispose',
|
||||
id: asset_id,
|
||||
disposal_date: $('#disposal_date').val(),
|
||||
sale_price: $('#sale_price').val(),
|
||||
proceeds_account_code: $('#proceeds_account_code').val(),
|
||||
disposal_notes: $('#disposal_notes').val()
|
||||
}) },
|
||||
onSuccess: function(res) {
|
||||
$('#disposal_modal').modal('hide');
|
||||
toastr.success(res.message || 'Disposal recorded.');
|
||||
retrieve_asset();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── events ─────────────────────────────────────────────────────────────────
|
||||
$('#cost, #salvage_value, #useful_life_months').on('input', update_calc);
|
||||
$('#useful_life_months').on('input', function() { life_touched = true; });
|
||||
$('#category_id').on('change', function() {
|
||||
var c = category_by_id($(this).val());
|
||||
if (c && !life_touched) {
|
||||
$('#useful_life_months').val(c.useful_life_months);
|
||||
update_calc();
|
||||
}
|
||||
update_class_hint();
|
||||
});
|
||||
$('#sale_price').on('input', update_disposal_result);
|
||||
|
||||
$(async function() {
|
||||
flatpickr('#acquisition_date, #in_service_date, #disposal_date', { dateFormat: 'd/m/Y', allowInput: true });
|
||||
|
||||
try {
|
||||
await load_categories();
|
||||
await load_posting_accounts(function(rows) { accounts = rows; });
|
||||
fill_category_select('');
|
||||
$('#clearing_account_code').html(account_options(accounts, [], '', '— None —'));
|
||||
|
||||
if (asset_id) {
|
||||
await retrieve_asset();
|
||||
} else {
|
||||
load_departments('department_id', 0);
|
||||
if (invoice_id && item_id) await load_invoice_line();
|
||||
if (!can_edit) $('#btn_save').addClass('d-none');
|
||||
}
|
||||
} catch (e) { console.log(e); }
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../config.php';
|
||||
require '../include_header.php';
|
||||
?>
|
||||
|
||||
<body>
|
||||
<?php require '../include_topbar.php'; ?>
|
||||
<?php require '../include_sidebar_asset.php'; ?>
|
||||
|
||||
<main id="content" class="content py-15">
|
||||
<div class="container-fluid">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex align-items-center mb-5 gap-3">
|
||||
<a href="<?php echo $server_url?>asset/categories.php" class="btn btn-light btn-sm">
|
||||
<i class="ti ti-arrow-left me-1"></i>Back
|
||||
</a>
|
||||
<h1 class="fs-3 mb-0">Manage Asset Category</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" id="categoryForm">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-body p-6">
|
||||
|
||||
<input type="hidden" id="id">
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Code <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="category_code" placeholder="e.g. IT" required>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">Name <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="category_name" placeholder="e.g. Computer equipment" required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Status</label>
|
||||
<select class="form-select" id="status">
|
||||
<option value="1">Active</option>
|
||||
<option value="0">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Asset Class <span class="text-danger">*</span></label>
|
||||
<select class="form-select" id="asset_class" required>
|
||||
<option value="tangible">Tangible — straight-line depreciation</option>
|
||||
<option value="intangible">Intangible — straight-line amortization</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Default Useful Life (months) <span class="text-danger">*</span></label>
|
||||
<input type="number" class="form-control" id="useful_life_months" min="1" max="1200" step="1" value="60" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<h2 class="fs-6 mb-0 mt-2">GL Accounts</h2>
|
||||
<p class="text-muted small mb-0">Used when Accounting posts this category's entries from Batch GL Entries.</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Asset Cost Account <span class="text-danger">*</span></label>
|
||||
<select class="form-select" id="cost_account_code" required></select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label"><span class="label-accum">Accumulated Depreciation</span> Account <span class="text-danger">*</span></label>
|
||||
<select class="form-select" id="accum_account_code" required></select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label"><span class="label-expense">Depreciation</span> Expense Account <span class="text-danger">*</span></label>
|
||||
<select class="form-select" id="expense_account_code" required></select>
|
||||
</div>
|
||||
<div class="col-md-6"></div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Gain on Disposal Account <span class="text-danger">*</span></label>
|
||||
<select class="form-select" id="gain_account_code" required></select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Loss on Disposal Account <span class="text-danger">*</span></label>
|
||||
<select class="form-select" id="loss_account_code" required></select>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-control" id="description" rows="2" placeholder="Optional"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 mt-5">
|
||||
<button class="btn btn-primary" onclick="manage_category();" id="btn_submit" data-min-role="admin">Create Category</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php require '../include_ending.php'; ?>
|
||||
<script src="<?php echo $server_url?>assets/js/asset.js"></script>
|
||||
|
||||
<script>
|
||||
var category_id = <?php echo (int)($_GET['id'] ?? 0); ?>;
|
||||
var accounts = [];
|
||||
|
||||
var ACCOUNT_SELECTS = {
|
||||
cost_account_code: ['asset'],
|
||||
accum_account_code: ['asset'],
|
||||
expense_account_code: ['expense'],
|
||||
gain_account_code: ['revenue'],
|
||||
loss_account_code: ['expense']
|
||||
};
|
||||
|
||||
function fill_account_selects(values) {
|
||||
Object.keys(ACCOUNT_SELECTS).forEach(function(field) {
|
||||
$('#' + field).html(account_options(accounts, ACCOUNT_SELECTS[field], (values || {})[field] || ''));
|
||||
});
|
||||
}
|
||||
|
||||
function update_class_labels() {
|
||||
var intangible = $('#asset_class').val() === 'intangible';
|
||||
$('.label-accum').text(intangible ? 'Accumulated Amortization' : 'Accumulated Depreciation');
|
||||
$('.label-expense').text(intangible ? 'Amortization' : 'Depreciation');
|
||||
}
|
||||
|
||||
function manage_category() {
|
||||
return ajax_request({
|
||||
url: server_url + 'asset/api/engine/manage_category.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 1,
|
||||
action: 'manage',
|
||||
onSuccess: function() {
|
||||
window.location.href = server_url + 'asset/categories.php';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function retrieve_for_edit() {
|
||||
if (!category_id) return Promise.resolve();
|
||||
return ajax_request({
|
||||
url: server_url + 'asset/api/engine/retrieve_category.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'read',
|
||||
data: { id: category_id },
|
||||
onSuccess: function(res) {
|
||||
var c = res.output;
|
||||
$('#id').val(c.id);
|
||||
$('#category_code').val(c.category_code);
|
||||
$('#category_name').val(c.category_name);
|
||||
$('#asset_class').val(c.asset_class);
|
||||
$('#useful_life_months').val(c.useful_life_months);
|
||||
$('#description').val(c.description || '');
|
||||
$('#status').val(c.status);
|
||||
fill_account_selects(c);
|
||||
update_class_labels();
|
||||
$('#btn_submit').text('Update Category');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$('#asset_class').on('change', update_class_labels);
|
||||
|
||||
$(async function() {
|
||||
try {
|
||||
await load_posting_accounts(function(rows) { accounts = rows; fill_account_selects({}); });
|
||||
await retrieve_for_edit();
|
||||
} catch (e) { console.log(e); }
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../config.php';
|
||||
require '../include_header.php';
|
||||
$pi_link_ok = in_array($_SESSION['login_app_access'] ?? 'wms', ['all', 'accounting'], true);
|
||||
?>
|
||||
|
||||
<body>
|
||||
<?php require '../include_topbar.php'; ?>
|
||||
<?php require '../include_sidebar_asset.php'; ?>
|
||||
|
||||
<main id="content" class="content py-15">
|
||||
<div class="container-fluid">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="mb-5">
|
||||
<h1 class="fs-3 mb-1">Assets from Purchase Invoices</h1>
|
||||
<p class="mb-0">Issued purchase invoice lines (AP) that can be capitalized as assets</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-5">
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-auto">
|
||||
<label class="form-label mb-1 small">Invoice Date From</label>
|
||||
<input type="text" id="date_from" class="form-control form-control-sm" placeholder="d/m/Y" style="width:140px;">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label mb-1 small">Invoice Date To</label>
|
||||
<input type="text" id="date_to" class="form-control form-control-sm" placeholder="d/m/Y" style="width:140px;">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-secondary btn-sm" onclick="load_lines()">
|
||||
<i class="ti ti-search me-1"></i>Load
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-auto ms-3">
|
||||
<div class="form-check mb-1">
|
||||
<input class="form-check-input" type="checkbox" id="show_capitalized">
|
||||
<label class="form-check-label small" for="show_capitalized">Show fully capitalized lines</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col ms-auto d-flex justify-content-end">
|
||||
<div class="position-relative" style="min-width:220px;">
|
||||
<input type="search" id="search_line" class="form-control form-control-sm ps-8" placeholder="Search invoice, supplier, item…">
|
||||
<span class="position-absolute top-25 ms-3"><i class="ti ti-search text-muted small"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 table-centered text-nowrap" id="line_table">
|
||||
<thead class="table-primary border-light">
|
||||
<tr>
|
||||
<th>Invoice</th>
|
||||
<th>Date</th>
|
||||
<th>Supplier</th>
|
||||
<th>Item</th>
|
||||
<th class="text-end">Qty</th>
|
||||
<th class="text-end">Line Amount</th>
|
||||
<th class="text-end">Capitalized</th>
|
||||
<th class="text-end">Remaining</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td colspan="9" class="text-center text-muted py-5">Use the filters above and click Load.</td></tr>
|
||||
</tbody>
|
||||
<tfoot></tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php require '../include_ending.php'; ?>
|
||||
<script src="<?php echo $server_url?>assets/js/asset.js"></script>
|
||||
|
||||
<script>
|
||||
var pi_link_ok = <?php echo $pi_link_ok ? 'true' : 'false'; ?>;
|
||||
var all_lines = [];
|
||||
var search_term = '';
|
||||
|
||||
function load_lines() {
|
||||
return ajax_request({
|
||||
url: server_url + 'asset/api/engine/invoice_lines.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'read',
|
||||
data: { date_from: $('#date_from').val(), date_to: $('#date_to').val() },
|
||||
onSuccess: function(res) {
|
||||
all_lines = res.output || [];
|
||||
change_page(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function visible_lines() {
|
||||
var show_all = $('#show_capitalized').is(':checked');
|
||||
var kw = search_term.toLowerCase();
|
||||
return all_lines.filter(function(l) {
|
||||
if (!show_all && parseFloat(l.remaining) <= 0) return false;
|
||||
if (!kw) return true;
|
||||
return [l.invoice_number, l.contact_name, l.product_sku, l.product_name]
|
||||
.some(function(v) { return String(v || '').toLowerCase().indexOf(kw) !== -1; });
|
||||
});
|
||||
}
|
||||
|
||||
function change_page(page_num) {
|
||||
var rows = visible_lines();
|
||||
var limit = parseInt(prop_limit, 10);
|
||||
var offset = (page_num - 1) * limit;
|
||||
|
||||
$('#line_table tfoot').html(generate_pagination('line_table', rows.length));
|
||||
|
||||
if (!rows.length) {
|
||||
$('#line_table tbody').html('<tr><td colspan="9" class="text-center text-muted py-5">No invoice lines to capitalize in this date range.</td></tr>');
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '';
|
||||
rows.slice(offset, offset + limit).forEach(function(l) {
|
||||
var invoice = pi_link_ok
|
||||
? '<a href="' + server_url + 'expense/manage_purchase_invoice.php?id=' + l.invoice_id + '">' + escape_html(l.invoice_number) + '</a>'
|
||||
: escape_html(l.invoice_number);
|
||||
var action = parseFloat(l.remaining) > 0
|
||||
? '<a class="btn btn-sm btn-primary" href="' + server_url + 'asset/manage_asset.php?invoice_id=' + l.invoice_id + '&item_id=' + l.item_id + '">'
|
||||
+ '<i class="ti ti-plus me-1"></i>Create Asset</a>'
|
||||
: '<span class="badge bg-success-subtle text-success">Capitalized</span>';
|
||||
var assets = parseInt(l.asset_count, 10) > 0
|
||||
? ' <span class="small text-muted">(' + l.asset_count + ' asset' + (l.asset_count == 1 ? '' : 's') + ')</span>'
|
||||
: '';
|
||||
html += '<tr>'
|
||||
+ '<td>' + invoice + '</td>'
|
||||
+ '<td>' + format_date(l.issued_date) + '</td>'
|
||||
+ '<td>' + escape_html(l.contact_name || '—') + '</td>'
|
||||
+ '<td>' + escape_html(l.product_name || l.product_sku) + '<div class="small text-muted">' + escape_html(l.product_sku) + '</div></td>'
|
||||
+ '<td class="text-end">' + format_number(l.quantity, 2) + '</td>'
|
||||
+ '<td class="text-end">' + format_number(l.line_amount, 2) + '</td>'
|
||||
+ '<td class="text-end">' + format_number(l.capitalized, 2) + assets + '</td>'
|
||||
+ '<td class="text-end fw-semibold">' + format_number(l.remaining, 2) + '</td>'
|
||||
+ '<td class="text-end">' + action + '</td>'
|
||||
+ '</tr>';
|
||||
});
|
||||
$('#line_table tbody').html(html);
|
||||
}
|
||||
|
||||
$('#show_capitalized').on('change', function() { change_page(1); });
|
||||
$('#search_line').on('input', debounce(function() {
|
||||
search_term = $(this).val().trim();
|
||||
change_page(1);
|
||||
}, 200));
|
||||
|
||||
$(function() {
|
||||
var today = new Date();
|
||||
var start = new Date(today.getFullYear(), 0, 1);
|
||||
flatpickr('#date_from', { dateFormat: 'd/m/Y', allowInput: true, defaultDate: start });
|
||||
flatpickr('#date_to', { dateFormat: 'd/m/Y', allowInput: true, defaultDate: today });
|
||||
load_lines().catch(function(e) { console.log(e); });
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user