Merge branch 'dev' of bitbucket.org:jubilian/nhance into dev
This commit is contained in:
commit
699973ddec
@ -138,3 +138,11 @@ LEAD_CLIENT_FROM_MAIL_ID =
|
||||
# BDS Daily Report Emails Configuration
|
||||
bds.dailyReportEmails =
|
||||
|
||||
|
||||
#--------------------------------------------------------------------
|
||||
# MEDI ASSIST WELLNESS SSO Configuration
|
||||
#--------------------------------------------------------------------
|
||||
|
||||
MEDIASSIST_WELLNESS_KEY =
|
||||
MEDIASSIST_WELLNESS_IV =
|
||||
MEDIASSIST_WELLNESS_LOGIN_URL =
|
||||
|
||||
@ -451,6 +451,7 @@ $routes->group("/util", ["filter" => "authMVC"], function ($routes) {
|
||||
$routes->get('croneDailyActivityReport', 'DashboardController::croneDailyActivityReport');
|
||||
$routes->get('insertSampleTpaApiData/(:any)', 'TestingController::insertSampleTpaApiData/$1');
|
||||
$routes->get('listEmployeeCountByClientPolicy', 'TestingController::listEmployeeCountByClientPolicy');
|
||||
$routes->get('testMediAssistWellness','TestingController::testMediAssistWellness');
|
||||
});
|
||||
|
||||
$routes->post("policy_tranction/sendInstallmentRemainderMail","PolicyTransactionController::sendInstallmentRemainderMail");
|
||||
|
||||
@ -425,13 +425,17 @@ class ApiServiceController extends BaseController
|
||||
$userParams = [];
|
||||
foreach ($data as $row) {
|
||||
|
||||
if($row['wellness_vendor_id'] != null)
|
||||
if($row['wellness_vendor_id'] == $this->vidal_primary_key)
|
||||
{
|
||||
$vidalApiController = new VidalApiController();
|
||||
return $vidalApiController->getWellnessSSORedirectUrl($row['email']);
|
||||
|
||||
}else if ($row['wellness_vendor_id'] == $this->medi_assist_primary_key)
|
||||
{
|
||||
$mediAssistController = new MediAssistApiController();
|
||||
return $mediAssistController->getWellnessSSORedirectUrl($row['planId'], $row['memberId']);
|
||||
}
|
||||
else if ($row['planId'] != null) // VISIT
|
||||
else if ($row['planId'] != null && empty($row['wellness_vendor_id'])) // VISIT
|
||||
{
|
||||
$userParams['name'] = $row['name'];
|
||||
$userParams['email'] = $row['email'];
|
||||
|
||||
@ -34,8 +34,7 @@ class ExpenseController extends AdminController
|
||||
try {
|
||||
$data['tab_name'] = 'Expense';
|
||||
$data['page_name'] = 'Expense';
|
||||
$descriptionPattern = '/^[a-zA-Z0-9\s\.\,\-\(\)\/\&\+]+$/u';
|
||||
|
||||
$descriptionPattern = '/^[a-zA-Z0-9\s\.\,\-\(\)\/\&\+\'"]+$/u';
|
||||
// Raw GET filters
|
||||
$rawFilters = $this->request->getGet() ?? [];
|
||||
$rawFilters = is_array($rawFilters) ? $rawFilters : [];
|
||||
@ -97,6 +96,7 @@ class ExpenseController extends AdminController
|
||||
// Clients for dropdown
|
||||
$data['clients'] = $this->clientModel
|
||||
->select('id, client_name, short_name')
|
||||
->where('client_type', 1)
|
||||
->where('is_active', 1)
|
||||
->orderBy('client_name', 'ASC')
|
||||
->findAll();
|
||||
@ -228,11 +228,12 @@ class ExpenseController extends AdminController
|
||||
],
|
||||
],
|
||||
'amount' => [
|
||||
'rules' => 'required|numeric|greater_than_equal_to[0]',
|
||||
'rules' => 'required|numeric|greater_than_equal_to[0]|less_than_equal_to[100000000]',
|
||||
'errors' => [
|
||||
'required' => 'Amount is required',
|
||||
'numeric' => 'Amount must be numeric',
|
||||
'greater_than_equal_to' => 'Amount cannot be negative',
|
||||
'less_than_equal_to' => 'Amount cannot be greater than 100 Cr.',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@ -1349,7 +1349,72 @@ class MediAssistApiController extends BaseController
|
||||
}
|
||||
|
||||
|
||||
public function getWellnessSSORedirectUrl($planId, $emp_code)
|
||||
{
|
||||
log_message('error', 'MEDI_ASSIST - Wellness SSO URL generation | Plan ID: ' . $planId . ' | Employee code: ' . $emp_code);
|
||||
if (empty($planId) || empty($emp_code)) {
|
||||
log_message('error', 'MEDI_ASSIST - Wellness SSO URL generation | Plan ID and employee code are required.');
|
||||
return [
|
||||
'status' => 'failed',
|
||||
'message' => 'Coming soon........!',
|
||||
];
|
||||
}
|
||||
|
||||
// Configuration – prefer environment variables, fall back to demo values
|
||||
$keyString = env('MEDIASSIST_WELLNESS_KEY');
|
||||
$ivString = env('MEDIASSIST_WELLNESS_IV');
|
||||
$loginUrlTemplate = env('MEDIASSIST_WELLNESS_LOGIN_URL');
|
||||
$cipher_algorithm = 'AES-256-CBC';
|
||||
|
||||
if (empty($keyString) || empty($ivString) || empty($loginUrlTemplate)) {
|
||||
log_message('error', 'MEDI_ASSIST - Wellness SSO URL generation | Key string, IV string and login URL are required.');
|
||||
return [
|
||||
'status' => 'failed',
|
||||
'message' => 'Key string, IV string and login URL are required.',
|
||||
];
|
||||
}
|
||||
|
||||
// Plain SSO JSON payload, as per Medi Assist sample
|
||||
$payload = [
|
||||
'Id' => $emp_code,
|
||||
'expiryTime' => time() + (10 * 60), // 10 minutes
|
||||
'CPartnerId' => $planId,
|
||||
];
|
||||
|
||||
$plainJson = json_encode($payload, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
// Derive a 32‑byte key (AES‑256) and 16‑byte IV from the provided strings
|
||||
// $key = substr(hash('sha256', $keyString, true), 0, 32);
|
||||
// $iv = substr(hash('md5', $ivString, true), 0, 16);
|
||||
|
||||
// Encrypt with AES‑256‑CBC + PKCS7 padding (OpenSSL default)
|
||||
$cipherTextRaw = openssl_encrypt($plainJson, $cipher_algorithm, $keyString, OPENSSL_RAW_DATA, $ivString);
|
||||
|
||||
|
||||
if ($cipherTextRaw === false) {
|
||||
log_message('error', 'MEDI_ASSIST - Wellness SSO URL generation | Encryption failed while generating Medi Assist wellness token.');
|
||||
return [
|
||||
'status' => 'failed',
|
||||
'message' => 'Encryption failed while generating Medi Assist wellness token.',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
// Base64 encode and URL‑encode for use as EncryptedSSO
|
||||
$encryptedSSO = urlencode(base64_encode($cipherTextRaw));
|
||||
// $encryptedSSO = rtrim(strtr(base64_encode($cipherTextRaw), '+/', '-_'), '=');
|
||||
|
||||
// Build the final login URL
|
||||
$loginUrl = str_replace(['{0}', '{1}'], [$planId, $encryptedSSO], $loginUrlTemplate);
|
||||
|
||||
log_message('error', 'MEDI_ASSIST - Wellness SSO URL generation | Medi Assist wellness SSO URL generated successfully.');
|
||||
|
||||
return [
|
||||
'status' => 'success',
|
||||
'message' => 'Medi Assist wellness SSO URL generated successfully.',
|
||||
'data' => $loginUrl
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@ -1304,4 +1304,74 @@ class TestingController extends BaseController
|
||||
'data' => $list,
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Wellness SSO token generation for Medi Assist (MediBuddy).
|
||||
*
|
||||
* This uses the token-based authentication details shared by Medi Assist:
|
||||
* - Cipher: AES-256-CBC
|
||||
* - Padding: PKCS7 (OpenSSL default)
|
||||
* - Login URL: https://login.mediassist.in/SSOLogon.aspx?PartnerCorpId={0}&EncryptedSSO={1}
|
||||
*
|
||||
* Environment variables (recommended):
|
||||
* - MEDIASSIST_WELLNESS_KEY
|
||||
* - MEDIASSIST_WELLNESS_IV
|
||||
* - MEDIASSIST_WELLNESS_PARTNER_CORP_ID
|
||||
* - MEDIASSIST_WELLNESS_LOGIN_URL
|
||||
*
|
||||
* If env values are not present, sensible dummy defaults are used so that
|
||||
* the function can still be exercised.
|
||||
*/
|
||||
public function testMediAssistWellness()
|
||||
{
|
||||
|
||||
// Configuration – prefer environment variables, fall back to demo values
|
||||
$keyString = env('MEDIASSIST_WELLNESS_KEY');
|
||||
$ivString = env('MEDIASSIST_WELLNESS_IV');
|
||||
$loginUrlTemplate = env('MEDIASSIST_WELLNESS_LOGIN_URL' );
|
||||
$partnerCorpId = '15963';
|
||||
$cipher_algorithm = 'AES-256-CBC';
|
||||
|
||||
// Plain SSO JSON payload, as per Medi Assist sample
|
||||
$payload = [
|
||||
'Id' => '15022',
|
||||
'expiryTime' => time() + (10 * 60), // 10 minutes
|
||||
'CPartnerId' => $partnerCorpId,
|
||||
];
|
||||
|
||||
$plainJson = json_encode($payload, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
// Derive a 32‑byte key (AES‑256) and 16‑byte IV from the provided strings
|
||||
// $key = substr(hash('sha256', $keyString, true), 0, 32);
|
||||
// $iv = substr(hash('md5', $ivString, true), 0, 16);
|
||||
|
||||
// Encrypt with AES‑256‑CBC + PKCS7 padding (OpenSSL default)
|
||||
$cipherTextRaw = openssl_encrypt( $plainJson, $cipher_algorithm, $keyString, OPENSSL_RAW_DATA, $ivString);
|
||||
|
||||
|
||||
if ($cipherTextRaw === false) {
|
||||
return $this->respond([
|
||||
'status' => false,
|
||||
'message' => 'Encryption failed while generating Medi Assist wellness token.',
|
||||
], 500);
|
||||
}
|
||||
|
||||
|
||||
// Base64 encode and URL‑encode for use as EncryptedSSO
|
||||
$encryptedSSO = urlencode(base64_encode($cipherTextRaw));
|
||||
// $encryptedSSO = rtrim(strtr(base64_encode($cipherTextRaw), '+/', '-_'), '=');
|
||||
|
||||
// Build the final login URL
|
||||
$loginUrl = str_replace(['{0}', '{1}'], [$partnerCorpId, $encryptedSSO], $loginUrlTemplate);
|
||||
|
||||
return $this->respond([
|
||||
'status' => true,
|
||||
'message' => 'Medi Assist wellness test URL generated successfully.',
|
||||
'data' => [
|
||||
'loginUrl' => $loginUrl,
|
||||
'encryptedSSO' => $encryptedSSO,
|
||||
'plainPayload' => $payload,
|
||||
],
|
||||
], 200);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1178,9 +1178,9 @@ class TicketController extends BaseController
|
||||
],
|
||||
'tpa_no' => [
|
||||
'label' => 'TPA ID',
|
||||
'rules' => 'permit_empty|regex_match[/^[a-zA-Z0-9\/]+$/]',
|
||||
'rules' => 'permit_empty|regex_match[/^[a-zA-Z0-9\/\-_]+$/]',
|
||||
'errors' => [
|
||||
'regex_match' => 'TPA ID can only contain letters, numbers, and /.'
|
||||
'regex_match' => 'TPA ID can only contain letters, numbers, /, -, and _.'
|
||||
]
|
||||
],
|
||||
'emp_mobile' => ['label' => 'Employee Mobile No','rules' => 'required|numeric|exact_length[10]',
|
||||
@ -1233,9 +1233,9 @@ class TicketController extends BaseController
|
||||
],
|
||||
'hospital_address' => [
|
||||
'label' => 'Hospital Address',
|
||||
'rules' => 'permit_empty|regex_match[/^[a-zA-Z0-9\s\-_.,#\/]+$/]',
|
||||
'rules' => 'permit_empty|regex_match[/^[a-zA-Z0-9\s.,#\/_-]+$/]',
|
||||
'errors' => [
|
||||
'regex_match' => 'The {field} contains invalid characters (Allowed: letters, numbers, spaces, dashes, commas, dots, # and /).'
|
||||
'regex_match' => 'The {field} contains invalid characters (Allowed: letters, numbers, spaces, -, _, ., ,, # and /).'
|
||||
],
|
||||
],
|
||||
'hospital_state' => [
|
||||
@ -1531,9 +1531,9 @@ class TicketController extends BaseController
|
||||
],
|
||||
'tpa_no' => [
|
||||
'label' => 'TPA ID',
|
||||
'rules' => 'permit_empty|regex_match[/^[a-zA-Z0-9\/]+$/]',
|
||||
'rules' => 'permit_empty|regex_match[/^[a-zA-Z0-9\/\-_]+$/]',
|
||||
'errors' => [
|
||||
'regex_match' => 'TPA ID can only contain letters, numbers, and /.'
|
||||
'regex_match' => 'TPA ID can only contain letters, numbers, /, -, and _.'
|
||||
]
|
||||
],
|
||||
'emp_mobile' => ['label' => 'Employee Mobile No','rules' => 'required|numeric|exact_length[10]',
|
||||
@ -1586,9 +1586,9 @@ class TicketController extends BaseController
|
||||
],
|
||||
'hospital_address' => [
|
||||
'label' => 'Hospital Address',
|
||||
'rules' => 'permit_empty|regex_match[/^[a-zA-Z0-9\s\-_.,#\/]+$/]',
|
||||
'rules' => 'permit_empty|regex_match[/^[a-zA-Z0-9\s.,#\/_-]+$/]',
|
||||
'errors' => [
|
||||
'regex_match' => 'The {field} contains invalid characters (Allowed: letters, numbers, spaces, dashes, commas, dots, # and /).'
|
||||
'regex_match' => 'The {field} contains invalid characters (Allowed: letters, numbers, spaces, -, _, ., ,, # and /).'
|
||||
],
|
||||
],
|
||||
'hospital_state' => [
|
||||
@ -3549,9 +3549,13 @@ class TicketController extends BaseController
|
||||
claim_dump_files.file_name,
|
||||
claim_dump_files.status,
|
||||
claim_dump_files.created_at,
|
||||
up.first_name as user_name
|
||||
up.first_name as user_name,
|
||||
c.client_name,
|
||||
cp.policy_no
|
||||
')
|
||||
->join('user_profiles as up', 'claim_dump_files.created_by = up.id', 'left')
|
||||
->join('client_policy as cp', 'claim_dump_files.client_policy_id = cp.id', 'left')
|
||||
->join('clients as c', 'cp.client_id = c.id', 'left')
|
||||
->where('claim_dump_files.is_active', 1)
|
||||
->orderBy('claim_dump_files.id', 'desc')
|
||||
->findAll();
|
||||
|
||||
@ -135,6 +135,8 @@
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th class="font-weight-medium">S.No </th>
|
||||
<th class="font-weight-medium">Client</th>
|
||||
<th class="font-weight-medium">Policy</th>
|
||||
<th class="font-weight-medium">File name</th>
|
||||
<th class="font-weight-medium">User/Time</th>
|
||||
<th class="font-weight-medium">Status</th>
|
||||
@ -150,6 +152,8 @@
|
||||
|
||||
<tr>
|
||||
<td class="text-center"><b><?php echo ($key + 1) ?></b></td>
|
||||
<td><?php echo $file['client_name'] ?></td>
|
||||
<td><?php echo $file['policy_no'] ?></td>
|
||||
<td style="overflow: hidden;" class="reload truncate" data-toggle="tooltip" data-placement="top" title="<?php echo $file['file_name'] ?>">
|
||||
<?php echo $file['file_name'] ?>
|
||||
</td>
|
||||
@ -740,13 +744,18 @@
|
||||
}));
|
||||
|
||||
$.each(data, function(index, item) {
|
||||
var option = $('<option>', {
|
||||
value: item.id,
|
||||
text: item.policy_type + '-' + item.policy_no,
|
||||
'data-tpaid': item.tpa_id,
|
||||
});
|
||||
|
||||
$('#client_policy_id').append(option);
|
||||
if(item.policy_type_id == 2 || item.policy_type_id == 3 || item.policy_type_id == 4 || item.policy_type_id == 5) {
|
||||
|
||||
var option = $('<option>', {
|
||||
value: item.id,
|
||||
text: item.policy_type + '-' + item.policy_no,
|
||||
'data-tpaid': item.tpa_id,
|
||||
});
|
||||
|
||||
$('#client_policy_id').append(option);
|
||||
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -1,3 +1,35 @@
|
||||
<style>
|
||||
|
||||
.truncate {
|
||||
max-width: 80px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.desc-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
#expense-desc-popup {
|
||||
position: absolute;
|
||||
z-index: 9999;
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
max-width: 320px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
display: none;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<div class="row" id="expense_module">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
@ -80,7 +112,18 @@
|
||||
|
||||
<div class="form-group col-md-4">
|
||||
<label for="amount">Amount <span class="text-danger">*</span></label>
|
||||
<input type="number" step="0.01" min="0" class="form-control" id="amount" name="amount" placeholder="Enter amount" value="<?= isset($filters['amount']) ? esc($filters['amount']) : ''; ?>" required>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100000000"
|
||||
class="form-control"
|
||||
id="amount"
|
||||
name="amount"
|
||||
placeholder="Enter amount"
|
||||
value="<?= isset($filters['amount']) ? esc($filters['amount']) : ''; ?>"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group col-md-4">
|
||||
@ -126,7 +169,16 @@
|
||||
<?= ! empty($row['short_name']) ? ' (' . esc($row['short_name']) . ')' : ''; ?>
|
||||
</td>
|
||||
<td><?= esc($row['policy_no'] ?? ''); ?></td>
|
||||
<td><?= esc($row['description'] ?? ''); ?></td>
|
||||
<td class="desc-cell">
|
||||
<i
|
||||
class="mdi mdi-information-outline text-muted expense-desc-icon"
|
||||
style="cursor: pointer;"
|
||||
data-description="<?= esc($row['description'] ?? ''); ?>"
|
||||
></i>
|
||||
<span class="truncate">
|
||||
<?= esc($row['description'] ?? ''); ?>
|
||||
</span>
|
||||
</td>
|
||||
<td><?= esc($row['approved_by_name'] ?? ''); ?></td>
|
||||
<td><?= number_format((float) ($row['amount'] ?? 0), 2); ?></td>
|
||||
<td>
|
||||
@ -213,8 +265,13 @@
|
||||
|
||||
if (!amount) {
|
||||
errors.push('Amount is required.');
|
||||
} else if (isNaN(amount) || Number(amount) < 0) {
|
||||
errors.push('Amount must be a non-negative number.');
|
||||
} else {
|
||||
const amountNum = Number(amount);
|
||||
if (isNaN(amountNum) || amountNum < 0) {
|
||||
errors.push('Amount must be a non-negative number.');
|
||||
} else if (amountNum > 100000000) {
|
||||
errors.push('Amount cannot be greater than 100 Cr.');
|
||||
}
|
||||
}
|
||||
|
||||
if (!expenseDate) {
|
||||
@ -261,8 +318,11 @@
|
||||
}
|
||||
|
||||
if (amount) {
|
||||
if (isNaN(amount) || Number(amount) < 0) {
|
||||
const amountNum = Number(amount);
|
||||
if (isNaN(amountNum) || amountNum < 0) {
|
||||
errors.push('Amount filter must be a non-negative number.');
|
||||
} else if (amountNum > 100000000) {
|
||||
errors.push('Amount filter cannot be greater than 100 Cr.');
|
||||
}
|
||||
}
|
||||
|
||||
@ -430,10 +490,12 @@
|
||||
|
||||
window.expenseDatePicker = flatpickr('#expense_date', {
|
||||
dateFormat: 'd-m-Y',
|
||||
allowInput: true
|
||||
allowInput: false,
|
||||
maxDate: new Date(),
|
||||
});
|
||||
|
||||
$('#expense-table').DataTable({
|
||||
scrollX: true,
|
||||
dom: "<'row'<'col-12 d-flex justify-content-between align-items-center'<'datatable-search dataTables_filter'f><'datatable-buttons dt-buttons'B>>>" +
|
||||
"<'row'<'col-sm-12'tr>>" +
|
||||
"<'row align-items-center'<'col-5 text-start'i><'col-7 d-flex justify-content-end align-items-center'<'me-2'l>p>>",
|
||||
@ -450,7 +512,18 @@
|
||||
title: 'Expense List',
|
||||
className: 'app-btn-primary ',
|
||||
exportOptions: {
|
||||
columns: ':not(:last-child)'
|
||||
columns: ':not(:last-child)',
|
||||
format: {
|
||||
body: function (data, row, column, node) {
|
||||
// Column index 2 is "Policy No" (S.No=0, Client=1, Policy No=2)
|
||||
if (column === 2) {
|
||||
// Prefix with apostrophe to force Excel treat as text and preserve long numbers
|
||||
var text = $(node).text ? $(node).text() : data;
|
||||
return "'" + text;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -461,7 +534,16 @@
|
||||
className: 'app-btn-primary ',
|
||||
exportOptions: {
|
||||
orthogonal: 'sort',
|
||||
columns: ':not(:last-child)'
|
||||
columns: ':not(:last-child)',
|
||||
format: {
|
||||
body: function (data, row, column, node) {
|
||||
if (column === 2) {
|
||||
var text = $(node).text ? $(node).text() : data;
|
||||
return "'" + text;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -581,6 +663,34 @@
|
||||
$('#btnResetExpense, #btnCancelExpense').on('click', function () {
|
||||
resetExpenseForm();
|
||||
});
|
||||
|
||||
// Show small popup near the info icon on hover (no tooltip)
|
||||
let $popup = $('#expense-desc-popup');
|
||||
if (!$popup.length) {
|
||||
$popup = $('<div id="expense-desc-popup"></div>').appendTo('body');
|
||||
}
|
||||
|
||||
$(document).on('mouseenter', '.expense-desc-icon', function () {
|
||||
const desc = $(this).data('description') || '';
|
||||
if (!desc) {
|
||||
return;
|
||||
}
|
||||
|
||||
$popup.text(desc);
|
||||
|
||||
const offset = $(this).offset();
|
||||
const iconHeight = $(this).outerHeight() || 16;
|
||||
|
||||
$popup.css({
|
||||
top: offset.top + iconHeight + 4,
|
||||
left: offset.left,
|
||||
display: 'block'
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on('mouseleave', '.expense-desc-icon', function () {
|
||||
$popup.hide();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user