MERGE_TEST_LIVE_REPORTED_ISSUES&CRS
This commit is contained in:
commit
2c0ebe8c30
@ -238,7 +238,7 @@ class EmployeeMultiEventServiceController extends BaseController
|
||||
'format' => null,
|
||||
'allowed_values' => null,
|
||||
'custom' => 'check_dup_email',
|
||||
'params' => ['row', 'existing_mobilenos']
|
||||
'params' => ['row', 'existing_mobilenos', 'excel_data', 'row_key']
|
||||
],
|
||||
'pre_existing_ailments' => [
|
||||
'col_idx' => 14,
|
||||
|
||||
@ -241,7 +241,7 @@ class EmployeeServiceController extends AdminController
|
||||
'format' => null,
|
||||
'allowed_values' => null,
|
||||
'custom' => 'check_dup_email',
|
||||
'params' => ['row', 'existing_mobilenos']
|
||||
'params' => ['row', 'existing_mobilenos', 'excel_data', 'row_key']
|
||||
],
|
||||
'pre_existing_ailments' => [
|
||||
'col_idx' => 14,
|
||||
|
||||
@ -40,6 +40,7 @@ class SalesController extends BaseController
|
||||
|
||||
public function index() {
|
||||
$data = $this->getSalesStaffData();
|
||||
$data = array_merge($data, $this->getSalesFilterYears());
|
||||
|
||||
$data['tab_name'] = 'Leads';
|
||||
$data['page_name'] = 'Leads';
|
||||
@ -48,6 +49,7 @@ class SalesController extends BaseController
|
||||
|
||||
public function loadactivities(){
|
||||
$data = $this->getSalesStaffData();
|
||||
$data = array_merge($data, $this->getSalesFilterYears());
|
||||
|
||||
$data['tab_name'] = 'Activities';
|
||||
$data['page_name'] = 'Activities';
|
||||
@ -350,7 +352,9 @@ class SalesController extends BaseController
|
||||
'status' => $this->request->getGet('status'),
|
||||
'assigned_to' => $this->request->getGet('assigned_to'),
|
||||
'search' => $this->request->getGet('search'),
|
||||
'financial_year' => $this->request->getGet('financial_year'),
|
||||
];
|
||||
$filters = $this->applyFinancialYearDateRange($filters);
|
||||
|
||||
$result = $this->leadModel->getLeadsWithFilters($filters, $limit, $offset);
|
||||
|
||||
@ -732,8 +736,10 @@ class SalesController extends BaseController
|
||||
'assigned_to' => $this->request->getGet('assigned_to'),
|
||||
'date_from' => $this->request->getGet('date_from'),
|
||||
'date_to' => $this->request->getGet('date_to'),
|
||||
'financial_year' => $this->request->getGet('financial_year'),
|
||||
'search' => $this->request->getGet('search'), // ← ADD THIS
|
||||
];
|
||||
$filters = $this->applyFinancialYearDateRange($filters);
|
||||
|
||||
$result = $this->activityModel->getActivitiesWithFilters($filters, $limit, $offset);
|
||||
|
||||
@ -1205,6 +1211,46 @@ class SalesController extends BaseController
|
||||
return 1;
|
||||
}
|
||||
|
||||
private function getSalesFilterYears(): array
|
||||
{
|
||||
$currentFinYear = getCurrentFinancialYear();
|
||||
$db = \Config\Database::connect();
|
||||
|
||||
$finYearsRaw = $db->table('sales_target')
|
||||
->select('fy_year', false)
|
||||
->distinct()
|
||||
->orderBy('fy_year', 'DESC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$finYears = array_column($finYearsRaw, 'fy_year');
|
||||
if (empty($finYears)) {
|
||||
$finYears[] = $currentFinYear;
|
||||
}
|
||||
|
||||
if (!in_array($currentFinYear, $finYears, true)) {
|
||||
array_unshift($finYears, $currentFinYear);
|
||||
}
|
||||
|
||||
return [
|
||||
'fin_years' => $finYears,
|
||||
'current_fin_year' => $currentFinYear,
|
||||
];
|
||||
}
|
||||
|
||||
private function applyFinancialYearDateRange(array $filters): array
|
||||
{
|
||||
if (empty($filters['financial_year'])) {
|
||||
return $filters;
|
||||
}
|
||||
|
||||
$fyRange = $this->getFYDateRange((string) $filters['financial_year']);
|
||||
$filters['date_from'] = $fyRange['start'];
|
||||
$filters['date_to'] = $fyRange['end'];
|
||||
|
||||
return $filters;
|
||||
}
|
||||
|
||||
|
||||
// ==================== Dashboard ====================
|
||||
|
||||
@ -1392,6 +1438,7 @@ public function branchLevelDashboard($branchId, $branchwise_all_sales_team_ids,
|
||||
// Activities & opportunities also scoped to FY via CASE WHEN
|
||||
$leadsOverview = $db->table('sales_actual_leads sal')
|
||||
->select("sal.lead_id, sal.company_name, sal.status,
|
||||
sal.assigned_to AS assigned_to_id,
|
||||
up.first_name AS assigned_to,
|
||||
COUNT(DISTINCT CASE WHEN sa.scheduled_date >= '{$fyStart}'
|
||||
AND sa.scheduled_date <= '{$fyEnd}' THEN sa.activity_id END) AS activities,
|
||||
@ -1403,7 +1450,7 @@ public function branchLevelDashboard($branchId, $branchwise_all_sales_team_ids,
|
||||
->whereIn('sal.assigned_to', $branchwise_all_sales_team_ids)
|
||||
->where('sal.created_at >=', $fyStart)
|
||||
->where('sal.created_at <=', $fyEnd)
|
||||
->groupBy('sal.lead_id, sal.company_name, sal.status, up.first_name')
|
||||
->groupBy('sal.lead_id, sal.company_name, sal.status, sal.assigned_to, up.first_name')
|
||||
->orderBy('sal.created_at', 'DESC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
@ -1497,6 +1544,7 @@ private function buildTeamAchievement($db, array $sales_manager_ids, $branchId,
|
||||
->get()
|
||||
->getRowArray();
|
||||
$targetAmt = (float)($targetRow['target_amount'] ?? 0);
|
||||
$targetId = $targetRow['id'] ?? null;
|
||||
|
||||
// Achieved (won leads in FY)
|
||||
$achievedAmt = (float)$this->getUserAchievedAmount($fy, $uid);
|
||||
@ -1589,6 +1637,7 @@ private function buildTeamAchievement($db, array $sales_manager_ids, $branchId,
|
||||
'role' => $m['role'],
|
||||
'total_acts' => $totalActs,
|
||||
'done_acts' => $doneActs,
|
||||
'target_id' => $targetId,
|
||||
'target_amt' => $targetAmt,
|
||||
'achieved_amt' => $achievedAmt,
|
||||
'grad' => $palette['grad'],
|
||||
|
||||
@ -2173,8 +2173,8 @@ if (!function_exists('check_dup_mobileno')) {
|
||||
// }
|
||||
// }
|
||||
|
||||
if (!function_exists('check_dup_email')) {
|
||||
function check_dup_email(array $row, array $existing_mobilenos)
|
||||
if (!function_exists('check_dup_email_old')) {
|
||||
function check_dup_email_old(array $row, array $existing_mobilenos)
|
||||
{
|
||||
$errorMessages = [];
|
||||
|
||||
@ -2203,6 +2203,54 @@ if (!function_exists('check_dup_email')) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('check_dup_email')) {
|
||||
function check_dup_email(array $row, array $existing_mobilenos, $excel_data = [], $row_key = null)
|
||||
{
|
||||
$errorMessages = [];
|
||||
$current_relation = strtolower(trim($row['5'] ?? ''));
|
||||
$current_email = trim((string) ($row['13'] ?? ''));
|
||||
|
||||
// Validate email
|
||||
$emailCheck = is_valid_or_empty_email($current_email);
|
||||
if (!$emailCheck) {
|
||||
$errorMessages[] = 'Invalid email format';
|
||||
}
|
||||
|
||||
// Check duplicate only if email is not empty
|
||||
if (!empty($current_email)) {
|
||||
foreach ($existing_mobilenos as $value) {
|
||||
if ($current_relation === 'self' && strcasecmp($current_email, trim((string) ($value['email_corporate'] ?? ''))) === 0) {
|
||||
$errorMessages[] = 'Duplicate Email';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($excel_data as $ed_row => $value) {
|
||||
if ($row_key !== null && (string) $ed_row === (string) $row_key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$excel_relation = strtolower(trim($value['5'] ?? ''));
|
||||
$excel_email = trim((string) ($value['13'] ?? ''));
|
||||
|
||||
if ($current_relation === 'self' && $excel_relation === 'self' && $excel_email !== '' && strcasecmp($current_email, $excel_email) === 0) {
|
||||
$duplicate_row_no = is_numeric($ed_row) ? $ed_row + 1 : $ed_row;
|
||||
$current_row_no = is_numeric($row_key) ? $row_key + 1 : $row_key;
|
||||
$errorMessages[] = "This email has already been used for another self in Excel at row no. " . ($duplicate_row_no) . " and " . ($current_row_no);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare final return
|
||||
if (!empty($errorMessages)) {
|
||||
return ['status' => false, 'error' => implode(' & ', $errorMessages)];
|
||||
}
|
||||
|
||||
return ['status' => true, 'error' => ''];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!function_exists('generate_family_relationship_array')) {
|
||||
function generate_family_relationship_array($family_structure_from_policy_terms)
|
||||
|
||||
@ -13,14 +13,14 @@ if (! defined('MERGED_CLAIM_FILE_TYPE')) {
|
||||
|
||||
if (! function_exists('merge_ticket_pdfs')) {
|
||||
/**
|
||||
* Merge all active PDF rows in claim_files for a given ticket_master id
|
||||
* Merge all active PDF/image rows in claim_files for a given ticket_master id
|
||||
* into one combined PDF and register that PDF as a new claim_files row
|
||||
* with file_type = MERGED_CLAIM_FILE_TYPE.
|
||||
*
|
||||
* Source rows are picked from claim_files where:
|
||||
* - ticket_id = $ticket_master_id
|
||||
* - is_active = 1
|
||||
* - mime_type = 'application/pdf'
|
||||
* - mime_type IN $opts['include_mime_types'] (default PDF/JPG/PNG)
|
||||
* - file_type IN $opts['include_file_types'] (default [2, 3])
|
||||
*
|
||||
* @param int $ticket_master_id
|
||||
@ -29,6 +29,7 @@ if (! function_exists('merge_ticket_pdfs')) {
|
||||
* @var int $created_by Override created_by user id on the inserted row.
|
||||
* @var int $ticket_type Default 1. Stored on the inserted claim_files row.
|
||||
* @var array $include_file_types Default [2, 3].
|
||||
* @var array $include_mime_types Default ['application/pdf', 'image/jpeg', 'image/png'].
|
||||
* }
|
||||
* @return array {status, merged_file_id, file_name, pages, source_count, message}
|
||||
*/
|
||||
@ -39,6 +40,7 @@ if (! function_exists('merge_ticket_pdfs')) {
|
||||
'created_by' => null,
|
||||
'ticket_type' => 1,
|
||||
'include_file_types' => [2, 3],
|
||||
'include_mime_types' => ['application/pdf', 'image/jpeg', 'image/png'],
|
||||
];
|
||||
|
||||
$result = [
|
||||
@ -60,14 +62,14 @@ if (! function_exists('merge_ticket_pdfs')) {
|
||||
$rows = $claimFiles
|
||||
->where('ticket_id', $ticket_master_id)
|
||||
->where('is_active', 1)
|
||||
->where('mime_type', 'application/pdf')
|
||||
->whereIn('mime_type', $opts['include_mime_types'])
|
||||
->whereIn('file_type', $opts['include_file_types'])
|
||||
->orderBy('id', 'ASC')
|
||||
->findAll();
|
||||
|
||||
if (empty($rows)) {
|
||||
$result['status'] = true;
|
||||
$result['message'] = 'No PDF files to merge';
|
||||
$result['message'] = 'No PDF/image files to merge';
|
||||
return $result;
|
||||
}
|
||||
|
||||
@ -75,7 +77,7 @@ if (! function_exists('merge_ticket_pdfs')) {
|
||||
. 'uploads' . DIRECTORY_SEPARATOR
|
||||
. 'claim_files' . DIRECTORY_SEPARATOR;
|
||||
|
||||
$sourcePaths = [];
|
||||
$sourceFiles = [];
|
||||
foreach ($rows as $row) {
|
||||
$name = ! empty($row['url']) ? $row['url'] : ($row['file_name'] ?? '');
|
||||
if (empty($name)) {
|
||||
@ -84,18 +86,22 @@ if (! function_exists('merge_ticket_pdfs')) {
|
||||
// url column may sometimes hold a full URL; we only care about the file basename on disk.
|
||||
$full = $uploadDir . basename($name);
|
||||
if (is_file($full) && is_readable($full)) {
|
||||
$sourcePaths[] = $full;
|
||||
$sourceFiles[] = [
|
||||
'path' => $full,
|
||||
'mime' => strtolower((string) ($row['mime_type'] ?? '')),
|
||||
'id' => $row['id'] ?? null,
|
||||
];
|
||||
} else {
|
||||
log_message('error', "merge_ticket_pdfs | missing PDF on disk | claim_file_id={$row['id']} | path={$full}");
|
||||
log_message('error', "merge_ticket_pdfs | missing file on disk | claim_file_id={$row['id']} | path={$full}");
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($sourcePaths)) {
|
||||
$result['message'] = 'No readable PDF files on disk';
|
||||
if (empty($sourceFiles)) {
|
||||
$result['message'] = 'No readable PDF/image files on disk';
|
||||
return $result;
|
||||
}
|
||||
|
||||
$result['source_count'] = count($sourcePaths);
|
||||
$result['source_count'] = count($sourceFiles);
|
||||
|
||||
$tempDir = rtrim(WRITEPATH, '/\\') . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR . 'mpdf';
|
||||
if (! is_dir($tempDir)) {
|
||||
@ -112,18 +118,24 @@ if (! function_exists('merge_ticket_pdfs')) {
|
||||
]);
|
||||
|
||||
$totalPages = 0;
|
||||
foreach ($sourcePaths as $src) {
|
||||
foreach ($sourceFiles as $sourceFile) {
|
||||
$src = $sourceFile['path'];
|
||||
try {
|
||||
$pageCount = $mpdf->setSourceFile($src);
|
||||
for ($p = 1; $p <= $pageCount; $p++) {
|
||||
$tplId = $mpdf->importPage($p);
|
||||
$size = $mpdf->getTemplateSize($tplId);
|
||||
$mpdf->AddPageByArray([
|
||||
'orientation' => ($size['width'] > $size['height']) ? 'L' : 'P',
|
||||
'sheet-size' => [$size['width'], $size['height']],
|
||||
]);
|
||||
$mpdf->useTemplate($tplId);
|
||||
$totalPages++;
|
||||
if ($sourceFile['mime'] === 'application/pdf') {
|
||||
$pageCount = $mpdf->setSourceFile($src);
|
||||
for ($p = 1; $p <= $pageCount; $p++) {
|
||||
$tplId = $mpdf->importPage($p);
|
||||
$mpdf->AddPage();
|
||||
// adjustPageSize=true makes the output page match the imported page.
|
||||
$mpdf->useTemplate($tplId, 0, 0, null, null, true);
|
||||
$totalPages++;
|
||||
}
|
||||
} elseif (in_array($sourceFile['mime'], ['image/jpeg', 'image/png'], true)) {
|
||||
if (merge_ticket_pdf_add_image_page($mpdf, $src)) {
|
||||
$totalPages++;
|
||||
}
|
||||
} else {
|
||||
log_message('error', "merge_ticket_pdfs | unsupported source mime {$sourceFile['mime']} | {$src}");
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', "merge_ticket_pdfs | failed to import {$src} | " . $e->getMessage());
|
||||
@ -131,7 +143,7 @@ if (! function_exists('merge_ticket_pdfs')) {
|
||||
}
|
||||
|
||||
if ($totalPages === 0) {
|
||||
$result['message'] = 'All source PDFs failed to import';
|
||||
$result['message'] = 'All source PDF/image files failed to import';
|
||||
return $result;
|
||||
}
|
||||
|
||||
@ -192,3 +204,48 @@ if (! function_exists('merge_ticket_pdfs')) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('merge_ticket_pdf_add_image_page')) {
|
||||
/**
|
||||
* Add an uploaded image as a single PDF page, preserving portrait/landscape
|
||||
* orientation and fitting the image proportionally inside the page.
|
||||
*/
|
||||
function merge_ticket_pdf_add_image_page(Mpdf $mpdf, string $imagePath): bool
|
||||
{
|
||||
$imageInfo = @getimagesize($imagePath);
|
||||
if (empty($imageInfo[0]) || empty($imageInfo[1])) {
|
||||
log_message('error', "merge_ticket_pdfs | invalid image | {$imagePath}");
|
||||
return false;
|
||||
}
|
||||
|
||||
$imageWidthPx = (int) $imageInfo[0];
|
||||
$imageHeightPx = (int) $imageInfo[1];
|
||||
$isLandscape = $imageWidthPx > $imageHeightPx;
|
||||
|
||||
$pageWidth = $isLandscape ? 297 : 210;
|
||||
$pageHeight = $isLandscape ? 210 : 297;
|
||||
$margin = 0;
|
||||
|
||||
$availableWidth = $pageWidth - ($margin * 2);
|
||||
$availableHeight = $pageHeight - ($margin * 2);
|
||||
$scale = min($availableWidth / $imageWidthPx, $availableHeight / $imageHeightPx);
|
||||
$drawWidth = $imageWidthPx * $scale;
|
||||
$drawHeight = $imageHeightPx * $scale;
|
||||
$x = ($pageWidth - $drawWidth) / 2;
|
||||
$y = ($pageHeight - $drawHeight) / 2;
|
||||
|
||||
$mpdf->AddPageByArray([
|
||||
'orientation' => $isLandscape ? 'L' : 'P',
|
||||
'sheet-size' => 'A4',
|
||||
'margin-left' => 0,
|
||||
'margin-right' => 0,
|
||||
'margin-top' => 0,
|
||||
'margin-bottom' => 0,
|
||||
'margin-header' => 0,
|
||||
'margin-footer' => 0,
|
||||
]);
|
||||
|
||||
$mpdf->Image($imagePath, $x, $y, $drawWidth, $drawHeight);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@ -197,6 +197,14 @@ class SalesActivityModel extends Model
|
||||
$builder->whereIn('sales_activities.assigned_to', $assignedToIds);
|
||||
}
|
||||
|
||||
if (!empty($filters['date_from'])) {
|
||||
$builder->where('sales_activities.created_at >=', $filters['date_from']);
|
||||
}
|
||||
|
||||
if (!empty($filters['date_to'])) {
|
||||
$builder->where('sales_activities.created_at <=', $filters['date_to']);
|
||||
}
|
||||
|
||||
if (!empty($filters['search'])) {
|
||||
// $builder->groupStart()
|
||||
$this->groupStart()
|
||||
@ -243,6 +251,12 @@ class SalesActivityModel extends Model
|
||||
$ids = is_array($filters['assigned_to']) ? $filters['assigned_to'] : explode(',', $filters['assigned_to']);
|
||||
$query->whereIn('assigned_to', $ids);
|
||||
}
|
||||
if (!empty($filters['date_from'])) {
|
||||
$query->where('created_at >=', $filters['date_from']);
|
||||
}
|
||||
if (!empty($filters['date_to'])) {
|
||||
$query->where('created_at <=', $filters['date_to']);
|
||||
}
|
||||
$counts[$status] = $query->countAllResults();
|
||||
}
|
||||
$counts['all'] = $counts['pending'] + $counts['completed'];
|
||||
|
||||
@ -112,6 +112,14 @@ class SalesActualLeadModel extends Model
|
||||
$this->whereIn('sales_actual_leads.assigned_to', $assignedToIds);
|
||||
}
|
||||
|
||||
if (!empty($filters['date_from'])) {
|
||||
$this->where('sales_actual_leads.created_at >=', $filters['date_from']);
|
||||
}
|
||||
|
||||
if (!empty($filters['date_to'])) {
|
||||
$this->where('sales_actual_leads.created_at <=', $filters['date_to']);
|
||||
}
|
||||
|
||||
$this->orderBy('sales_actual_leads.created_at', 'DESC');
|
||||
|
||||
if (!empty($filters['search'])) {
|
||||
@ -153,6 +161,14 @@ class SalesActualLeadModel extends Model
|
||||
$countQuery->whereIn('assigned_to', $assignedToIds);
|
||||
}
|
||||
|
||||
if (!empty($filters['date_from'])) {
|
||||
$countQuery->where('created_at >=', $filters['date_from']);
|
||||
}
|
||||
|
||||
if (!empty($filters['date_to'])) {
|
||||
$countQuery->where('created_at <=', $filters['date_to']);
|
||||
}
|
||||
|
||||
$counts[$status] = $countQuery->where('status', $status)->countAllResults();
|
||||
}
|
||||
|
||||
|
||||
@ -15,12 +15,32 @@
|
||||
|
||||
/* Filter Tabs */
|
||||
.filter-tabs { display: flex; gap: 10px; padding: 20px 30px; }
|
||||
.tab { padding: 10px 20px; border-radius: 20px; cursor: pointer; font-size: 14px; background: white; color: #666; border: 1px solid #e0e0e0; transition: all 0.2s; }
|
||||
.tab { padding: 10px 14px; border-radius: 20px; cursor: pointer; font-size: 12px; background: white; color: #666; border: 1px solid #e0e0e0; transition: all 0.2s; white-space: nowrap; flex: 0 0 auto; }
|
||||
.tab.active { background: #02a8b5; color: white; border-color: #02a8b5; }
|
||||
|
||||
/* Leads Grid */
|
||||
.lead-header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; /* responsive */ gap: 15px; }
|
||||
.lead-actions { display: flex; align-items: center; gap: 10px; }
|
||||
.sales-toolbar { flex-wrap: nowrap; padding: 16px 30px; gap: 12px; overflow-x: auto; }
|
||||
.sales-toolbar .filter-tabs { padding: 0; flex: 1 1 auto; min-width: 0; overflow-x: auto; }
|
||||
.sales-toolbar .lead-actions { flex: 0 0 auto; flex-wrap: nowrap; }
|
||||
.sales-toolbar .search-input { width: 230px !important; }
|
||||
.sales-toolbar .toolbar-select { width: 175px !important; min-width: 175px; padding: 9px 12px; }
|
||||
.sales-toolbar .toolbar-select + .select2-container { width: 175px !important; min-width: 175px; }
|
||||
.sales-toolbar .financial-year-select { width: 108px !important; min-width: 108px; }
|
||||
.sales-toolbar .select2-container .select2-selection--single { height: 38px; border: 1px solid #ddd; border-radius: 6px; display: flex; align-items: center; }
|
||||
.sales-toolbar .select2-container--default .select2-selection--single .select2-selection__rendered { line-height: 36px; }
|
||||
.sales-toolbar .select2-container--default .select2-selection--single .select2-selection__arrow { height: 36px; }
|
||||
.sales-toolbar #memberFilter + .select2-container { width: 175px !important; min-width: 175px; flex: 0 0 175px; }
|
||||
.sales-toolbar #memberFilter + .select2-container .select2-selection--single { height: 38px !important; background: #fff !important; border: 1px solid #ddd !important; border-radius: 6px !important; display: flex; align-items: center; }
|
||||
.sales-toolbar #memberFilter + .select2-container .select2-selection__rendered { flex: 1; line-height: 36px !important; padding-left: 12px; padding-right: 28px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.sales-toolbar #memberFilter + .select2-container .select2-selection__arrow { height: 25px !important; width: 20px; top: 1px; right: 6px; }
|
||||
.toolbar-icon-btn { width: 38px; height: 38px; padding: 0; display: inline-flex; align-items: center; justify-content: center; border: none; border-radius: 8px; color: #fff; cursor: pointer; transition: all 0.2s; }
|
||||
.toolbar-icon-btn:hover { transform: translateY(-1px); filter: brightness(0.96); }
|
||||
.toolbar-icon-btn:focus { outline: none; box-shadow: 0 0 0 2px rgba(2, 168, 181, 0.25); }
|
||||
.toolbar-icon-btn i { font-size: 20px; line-height: 1; }
|
||||
.toolbar-icon-btn.export-btn { background: #1f9d55; }
|
||||
.toolbar-icon-btn.add-btn { background: #02a8b5; }
|
||||
/* This forces 2 equal columns on desktop */
|
||||
.activities-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; padding: 30px; }
|
||||
|
||||
@ -172,21 +192,35 @@
|
||||
|
||||
<div class="main-content">
|
||||
<hr style="margin-bottom: 0 !important;">
|
||||
<div class="lead-header">
|
||||
<div class="lead-header sales-toolbar">
|
||||
|
||||
<!-- LEFT SIDE -->
|
||||
<div class="filter-tabs">
|
||||
<div class="tab active" data-filter="all" onclick="setFilter('all', this)">All</div>
|
||||
<div class="tab" data-filter="Pending" onclick="setFilter('Pending', this)">Pending</div>
|
||||
<div class="tab" data-filter="Completed" onclick="setFilter('Completed', this)">Completed</div>
|
||||
<div class="tab" data-filter="Pending" onclick="setFilter('pending', this)">Pending</div>
|
||||
<div class="tab" data-filter="Completed" onclick="setFilter('completed', this)">Completed</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT SIDE -->
|
||||
<div class="lead-actions">
|
||||
<input type="text" class="search-input" id="mainSearch"
|
||||
placeholder="Search activities..." onkeyup="fetchActivities(false)" style="width: 300px !important;">
|
||||
<button class="btn-primary" onclick="openMainActivityModal()">
|
||||
+ Add Activity
|
||||
placeholder="Search activities..." onkeyup="fetchActivities(false)">
|
||||
<select id="memberFilter" class="toolbar-select searchable" onchange="fetchActivities(false)">
|
||||
<option value="" selected>All Member</option>
|
||||
<?php foreach(($sales_manager_with_head ?? []) as $sm): ?>
|
||||
<option value="<?= $sm['id'] ?>"><?= esc($sm['first_name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<select id="financialYearFilter" class="toolbar-select financial-year-select" onchange="fetchActivities(false)">
|
||||
<?php foreach(($fin_years ?? []) as $year): ?>
|
||||
<option value="<?= esc($year) ?>" <?= (($current_fin_year ?? '') === $year) ? 'selected' : '' ?>><?= esc($year) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<button class="toolbar-icon-btn export-btn" onclick="exportActivitiesCsv()" title="Export" aria-label="Export activities">
|
||||
<i class="mdi mdi-file-excel"></i>
|
||||
</button>
|
||||
<button class="toolbar-icon-btn add-btn" onclick="openMainActivityModal()" title="Add Activity" aria-label="Add activity">
|
||||
<i class="mdi mdi-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -448,12 +482,15 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
// Single selects
|
||||
document.querySelectorAll('.searchable:not(.multi-searchable)').forEach(function(el) {
|
||||
let parentModal = el.closest('.modal');
|
||||
$(el).select2({
|
||||
placeholder: "Select..",
|
||||
const select2Options = {
|
||||
allowClear: false,
|
||||
minimumResultsForSearch: 0,
|
||||
dropdownParent: parentModal ? $(parentModal) : $(document.body)
|
||||
});
|
||||
};
|
||||
if (el.id !== 'memberFilter') {
|
||||
select2Options.placeholder = "Select..";
|
||||
}
|
||||
$(el).select2(select2Options);
|
||||
});
|
||||
|
||||
// Multi selects
|
||||
@ -509,6 +546,7 @@ const activityIcons = {
|
||||
const salesManagerWithHeadIds = <?= json_encode($sales_manager_with_head_ids ?? []) ?>;
|
||||
|
||||
const API = '<?= base_url('sales') ?>';
|
||||
const defaultFinancialYear = '<?= esc($current_fin_year ?? '') ?>';
|
||||
let filter = 'all';
|
||||
let lead_id = null;
|
||||
let global_lead_assigned_to = null;
|
||||
@ -519,6 +557,58 @@ let limit = 10;
|
||||
let currentOffset = 0;
|
||||
const department = '<?= $sales_role ?>';
|
||||
|
||||
function getCurrentFY() {
|
||||
const today = new Date();
|
||||
const year = today.getFullYear();
|
||||
const month = today.getMonth();
|
||||
const startYear = month >= 3 ? year : year - 1;
|
||||
return startYear + '-' + String(startYear + 1);
|
||||
}
|
||||
|
||||
function initSalesToolbarFilters() {
|
||||
const fySelect = document.getElementById('financialYearFilter');
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
if (fySelect) {
|
||||
let fy = params.get('fy') || defaultFinancialYear || getCurrentFY();
|
||||
const matched = Array.from(fySelect.options).some(opt => opt.value === fy);
|
||||
if (!matched && fySelect.options.length > 0) {
|
||||
fy = fySelect.options[0].value;
|
||||
}
|
||||
fySelect.value = fy;
|
||||
}
|
||||
|
||||
const statusParam = (params.get('status') || 'all').toLowerCase();
|
||||
const statusTab = Array.from(document.querySelectorAll('.filter-tabs .tab'))
|
||||
.find(tab => (tab.dataset.filter || '').toLowerCase() === statusParam);
|
||||
if (statusTab) {
|
||||
document.querySelectorAll('.filter-tabs .tab').forEach(tab => tab.classList.remove('active'));
|
||||
statusTab.classList.add('active');
|
||||
filter = statusParam;
|
||||
}
|
||||
|
||||
const memberSelect = document.getElementById('memberFilter');
|
||||
const memberId = params.get('member');
|
||||
if (memberSelect && memberId && Array.from(memberSelect.options).some(opt => opt.value === memberId)) {
|
||||
memberSelect.value = memberId;
|
||||
if (window.jQuery) {
|
||||
$(memberSelect).trigger('change.select2');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getSelectedMemberIds(defaultIds) {
|
||||
const memberFilter = document.getElementById('memberFilter');
|
||||
if (memberFilter && memberFilter.value) {
|
||||
return [memberFilter.value];
|
||||
}
|
||||
return Array.isArray(defaultIds) ? defaultIds : [];
|
||||
}
|
||||
|
||||
function getSelectedFinancialYear() {
|
||||
return document.getElementById('financialYearFilter')?.value || defaultFinancialYear || getCurrentFY();
|
||||
}
|
||||
|
||||
|
||||
function openModal(id) { document.getElementById(id).classList.add('active'); resetFlatpicker(); }
|
||||
|
||||
@ -583,7 +673,11 @@ function setFilter(val, el) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
filter = val;
|
||||
fetchActivities();
|
||||
const searchInput = document.getElementById('mainSearch');
|
||||
if (searchInput) {
|
||||
searchInput.value = '';
|
||||
}
|
||||
fetchActivities(false);
|
||||
}
|
||||
|
||||
async function fetchActivities(isLoadMore = false) {
|
||||
@ -592,6 +686,7 @@ async function fetchActivities(isLoadMore = false) {
|
||||
const btnLoadMore = document.getElementById('btn-load-more');
|
||||
const spinner = document.getElementById('load-more-spinner');
|
||||
const text = document.getElementById('load-more-text');
|
||||
const selectedMemberIds = getSelectedMemberIds(salesManagerWithHeadIds);
|
||||
|
||||
|
||||
// 1. Define the empty state HTML early so we can use it immediately if needed
|
||||
@ -606,7 +701,7 @@ async function fetchActivities(isLoadMore = false) {
|
||||
console.log(salesManagerWithHeadIds);
|
||||
// return;
|
||||
// 2. 🛑 SHORT-CIRCUIT: If no sales manager IDs exist, show empty state and stop!
|
||||
if (typeof salesManagerWithHeadIds === 'undefined' || salesManagerWithHeadIds.length === 0) {
|
||||
if (selectedMemberIds.length === 0) {
|
||||
console.log("No Sales Manager IDs found. Skipping API call.");
|
||||
grid.innerHTML = emptyStateHTML;
|
||||
btnLoadMore.style.display = 'none'; // Hide the load more button
|
||||
@ -631,11 +726,15 @@ async function fetchActivities(isLoadMore = false) {
|
||||
}
|
||||
|
||||
// 5. Build URL with dynamic offset
|
||||
let url = `${API}/activities?status=${filter === 'all' ? '' : filter}&search=${q}&limit=${limit}&offset=${currentOffset}`;
|
||||
// url += `&assigned_to=${salesManagerIds.join(',')}`; // We already proved it exists above!
|
||||
if (typeof salesManagerWithHeadIds !== 'undefined' && salesManagerWithHeadIds.length > 0) {
|
||||
url += `&assigned_to=${salesManagerWithHeadIds.join(',')}`;
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
status: filter === 'all' ? '' : filter,
|
||||
search: q,
|
||||
limit,
|
||||
offset: currentOffset,
|
||||
assigned_to: selectedMemberIds.join(','),
|
||||
financial_year: getSelectedFinancialYear()
|
||||
});
|
||||
let url = `${API}/activities?${params.toString()}`;
|
||||
|
||||
console.log("Fetching API:", url);
|
||||
|
||||
@ -1289,6 +1388,68 @@ function convertDBFormatted(input) {
|
||||
);
|
||||
}
|
||||
|
||||
function csvEscape(value) {
|
||||
const text = String(value ?? '').replace(/"/g, '""');
|
||||
return `"${text}"`;
|
||||
}
|
||||
|
||||
function formatIndianDate(value) {
|
||||
if (!value) return '';
|
||||
const date = new Date(String(value).replace(' ', 'T'));
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
|
||||
return date.toLocaleString('en-IN', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
}).replace(',', '').toUpperCase();
|
||||
}
|
||||
|
||||
function downloadCsv(filename, rows) {
|
||||
const csv = rows.map(row => row.map(csvEscape).join(',')).join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
}
|
||||
|
||||
async function exportActivitiesCsv() {
|
||||
const selectedMemberIds = getSelectedMemberIds(salesManagerWithHeadIds);
|
||||
if (selectedMemberIds.length === 0) return toastr.warning('No members available to export');
|
||||
|
||||
const params = new URLSearchParams({
|
||||
status: filter === 'all' ? '' : filter,
|
||||
search: document.getElementById('mainSearch')?.value || '',
|
||||
limit: 10000,
|
||||
offset: 0,
|
||||
assigned_to: selectedMemberIds.join(','),
|
||||
financial_year: getSelectedFinancialYear()
|
||||
});
|
||||
|
||||
const res = await fetch(`${API}/activities?${params.toString()}`);
|
||||
const json = await res.json();
|
||||
const rows = [
|
||||
['Company', 'Activity Type', 'Status', 'Assigned To', 'Additional Members', 'Scheduled Date', 'Created At', 'Notes'],
|
||||
...(json.data || []).map(a => [
|
||||
a.company_name || '',
|
||||
a.activity_type || '',
|
||||
capitalize(a.status),
|
||||
a.assigned_to_name || '',
|
||||
a.additional_assigned_names || '',
|
||||
formatIndianDate(a.scheduled_date),
|
||||
formatIndianDate(a.created_at),
|
||||
a.notes || a.completion_notes || ''
|
||||
])
|
||||
];
|
||||
downloadCsv(`sales-activities-${getSelectedFinancialYear()}.csv`, rows);
|
||||
}
|
||||
|
||||
initSalesToolbarFilters();
|
||||
fetchActivities();
|
||||
|
||||
</script>
|
||||
@ -67,6 +67,7 @@
|
||||
.card-hero:nth-child(3)::before { background: linear-gradient(90deg, #fda085, #f6d365); }
|
||||
.card-hero:nth-child(4)::before { background: linear-gradient(90deg, #43e97b, #38f9d7); }
|
||||
.card-hero:hover { transform: translateY(-4px); box-shadow: 0 12px 24px rgba(0,0,0,0.09); }
|
||||
.card-hero.stat-link { cursor: pointer; }
|
||||
.stat-icon { width: 42px; height: 42px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 18px; margin-bottom: 14px; }
|
||||
.stat-val { font-size: 30px; font-weight: 800; color: #1a202c; line-height: 1; }
|
||||
.stat-label { color: #8896aa; font-size: 13px; margin-top: 6px; font-weight: 600; }
|
||||
@ -114,6 +115,8 @@
|
||||
.status-not-a-prospects { background: #ffebee; color: #d32f2f; }
|
||||
.leads-table-wrap { max-height: 480px; overflow-y: auto; }
|
||||
.leads-table-wrap thead th { position: sticky; top: 0; background: white; z-index: 1; }
|
||||
.activity-count-link { display: inline-block; color: #2563eb; cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
|
||||
.activity-count-muted { display: inline-block; color: #8896aa; cursor: help; }
|
||||
.empty-state { text-align: center; padding: 50px 20px; color: #aaa; }
|
||||
.empty-icon { width: 70px; height: 70px; margin: 0 auto 16px; background: #f5f5f5; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 32px; }
|
||||
|
||||
@ -178,6 +181,14 @@
|
||||
.ms-num { font-size: 19px; font-weight: 800; }
|
||||
.ms-lbl { font-size: 10px; color: #7c8db0; font-weight: 700; letter-spacing: .05em; margin-top: 2px; }
|
||||
.modal-body { padding: 22px 24px; overflow-y: auto; flex: 1; }
|
||||
.target-edit-panel { display: grid; grid-template-columns: 1fr 170px 120px; gap: 12px; align-items: end; padding: 16px; margin-bottom: 18px; border: 1px solid #bfdbfe; border-radius: 14px; background: #eff6ff; }
|
||||
.target-edit-title { font-size: 13px; font-weight: 800; color: #1e3a8a; margin-bottom: 4px; }
|
||||
.target-edit-help { font-size: 11px; color: #64748b; font-weight: 600; }
|
||||
.target-edit-input { width: 100%; height: 38px; border: 1px solid #93c5fd; border-radius: 9px; padding: 8px 12px; font-size: 14px; font-weight: 700; color: #0f172a; background: #fff; }
|
||||
.target-edit-input:focus { outline: none; border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.14); }
|
||||
.target-edit-btn { height: 38px; border: none; border-radius: 9px; background: #2563eb; color: #fff; font-size: 13px; font-weight: 800; cursor: pointer; transition: background .2s, transform .2s; }
|
||||
.target-edit-btn:hover { background: #1d4ed8; transform: translateY(-1px); }
|
||||
.target-edit-btn:disabled { background: #94a3b8; cursor: not-allowed; transform: none; }
|
||||
|
||||
/* ── Modal Tabs ── */
|
||||
.modal-tabs { display: flex; gap: 4px; background: #f1f5f9; border-radius: 10px; padding: 4px; margin-bottom: 20px; }
|
||||
@ -219,7 +230,7 @@
|
||||
.opp-card-lbl { font-size: 11px; color: #7c8db0; font-weight: 700; margin-top: 4px; letter-spacing: .04em; }
|
||||
|
||||
@media(max-width:900px) { .list-col-head,.achieve-row { grid-template-columns: 220px 1fr 110px 110px 110px; } .col-lbl:nth-child(6),.col-lbl:nth-child(7),.achieve-row>*:nth-child(6),.achieve-row>*:nth-child(7) { display: none; } }
|
||||
@media(max-width:640px) { .list-col-head { display: none; } .achieve-row { grid-template-columns: 1fr auto; gap: 12px; padding: 14px 16px; } .achieve-row>*:not(:nth-child(1)):not(:nth-child(7)) { display: none; } .section-head { flex-direction: column; align-items: flex-start; gap: 12px; } .modal-summary { grid-template-columns: repeat(2,1fr); } .opp-summary-cards { grid-template-columns: 1fr 1fr; } }
|
||||
@media(max-width:640px) { .list-col-head { display: none; } .achieve-row { grid-template-columns: 1fr auto; gap: 12px; padding: 14px 16px; } .achieve-row>*:not(:nth-child(1)):not(:nth-child(7)) { display: none; } .section-head { flex-direction: column; align-items: flex-start; gap: 12px; } .modal-summary { grid-template-columns: repeat(2,1fr); } .target-edit-panel { grid-template-columns: 1fr; } .opp-summary-cards { grid-template-columns: 1fr 1fr; } }
|
||||
</style>
|
||||
|
||||
<div class="dash-container">
|
||||
@ -243,22 +254,22 @@
|
||||
|
||||
<!-- ── Stat Cards ── -->
|
||||
<div class="stat-cards">
|
||||
<div class="card-hero">
|
||||
<div class="card-hero stat-link" onclick="goSalesPage('leads', 'all')" title="View all leads">
|
||||
<div class="stat-icon" style="background:#ede9fe;">👥</div>
|
||||
<div class="stat-val"><?php echo $total_leads; ?></div>
|
||||
<div class="stat-label">Total Leads</div>
|
||||
</div>
|
||||
<div class="card-hero">
|
||||
<div class="card-hero stat-link" onclick="goSalesPage('activities', 'all')" title="View all activities">
|
||||
<div class="stat-icon" style="background:#fce7f3;">⚡</div>
|
||||
<div class="stat-val"><?php echo $total_acts ?></div>
|
||||
<div class="stat-label">Total Activities</div>
|
||||
</div>
|
||||
<div class="card-hero">
|
||||
<div class="card-hero stat-link" onclick="goSalesPage('activities', 'pending')" title="View pending activities">
|
||||
<div class="stat-icon" style="background:#fef3c7;">⏳</div>
|
||||
<div class="stat-val"><?php echo $total_pending_acts ?></div>
|
||||
<div class="stat-label">Pending Activities</div>
|
||||
</div>
|
||||
<div class="card-hero">
|
||||
<div class="card-hero stat-link" onclick="goSalesPage('activities', 'completed')" title="View completed activities">
|
||||
<div class="stat-icon" style="background:#d1fae5;">✅</div>
|
||||
<div class="stat-val"><?php echo $total_completed_acts ?></div>
|
||||
<div class="stat-label">Completed Activities</div>
|
||||
@ -349,9 +360,14 @@
|
||||
</div>
|
||||
<div class="team-members-scroll">
|
||||
<?php
|
||||
if (!empty($team)):
|
||||
$visibleTeam = array_values(array_filter($team ?? [], function ($member) {
|
||||
return !(stripos($member['role'] ?? '', 'admin') !== false
|
||||
&& (int) ($member['total_acts'] ?? 0) === 0
|
||||
&& (int) ($member['done_acts'] ?? 0) === 0);
|
||||
}));
|
||||
if (!empty($visibleTeam)):
|
||||
$colors = ['#ff6b35','#667eea','#48bb78','#ed8936','#9f7aea'];
|
||||
foreach ($team as $member):
|
||||
foreach ($visibleTeam as $member):
|
||||
$firstLetter = strtoupper(substr($member['first_name'], 0, 1));
|
||||
$fullName = $member['first_name'];
|
||||
$color = $colors[abs(crc32($member['first_name'])) % count($colors)];
|
||||
@ -425,10 +441,29 @@
|
||||
<?php foreach ($leads_overview as $lead): ?>
|
||||
<?php $statusClass = strtolower(str_replace(' ', '-', $lead['status'])); ?>
|
||||
<tr>
|
||||
<td><strong><?= esc($lead['company_name']) ?></strong></td>
|
||||
<td>
|
||||
<strong>
|
||||
<a href="javascript:void(0);"
|
||||
onclick="goLeadDetail('<?= esc($lead['lead_id'] ?? '', 'attr') ?>')"
|
||||
title="View lead details"
|
||||
style="color:#0f172a; text-decoration:underline; text-underline-offset:2px;">
|
||||
<?= esc($lead['company_name']) ?>
|
||||
</a>
|
||||
</strong>
|
||||
</td>
|
||||
<td><span class="status-pill status-<?= $statusClass ?>"><?= esc($lead['status']) ?></span></td>
|
||||
<td><?= esc($lead['assigned_to'] ?? 'Unassigned') ?></td>
|
||||
<td><div style="text-align:center; font-weight:700;"><?= $lead['activities'] ?></div></td>
|
||||
<td>
|
||||
<div style="text-align:center; font-weight:700;">
|
||||
<?php if ((int) $lead['activities'] > 0): ?>
|
||||
<span class="activity-count-link"
|
||||
title="View activities"
|
||||
onclick="goLeadActivities('<?= esc($lead['assigned_to_id'] ?? '', 'attr') ?>')"><?= $lead['activities'] ?></span>
|
||||
<?php else: ?>
|
||||
<span class="activity-count-muted" title="No activities"><?= $lead['activities'] ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</td>
|
||||
<td><div style="text-align:center; font-weight:700;"><?= $lead['opportunities'] ?></div></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
@ -470,11 +505,155 @@ const OPP_DATA = <?php echo json_encode($opp_achievement ?? []); ?>;
|
||||
/* ── JS Helpers ── */
|
||||
const ACT_ICONS = {Call:'📞',Email:'✉️',Meeting:'📅',Visit:'🚗',Demo:'🖥️','Share Docs':'📄','To Do':'✓'};
|
||||
const ACT_COLORS = {Call:'#4f46e5',Email:'#10b981',Meeting:'#06b6d4',Visit:'#f59e0b',Demo:'#ec4899','Share Docs':'#f97316','To Do':'#64748b'};
|
||||
const fmt = v => '₹' + (v / 100000).toFixed(1) + 'L';
|
||||
const fmtIndianFull = value => '₹' + (Number(value) || 0).toLocaleString('en-IN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
});
|
||||
const fmt = value => {
|
||||
const amount = Number(value) || 0;
|
||||
let display = '₹0';
|
||||
if (Math.abs(amount) >= 100000) {
|
||||
display = '₹' + (amount / 100000).toFixed(1) + 'L';
|
||||
} else if (Math.abs(amount) >= 1000) {
|
||||
display = '₹' + (amount / 1000).toFixed(1) + 'K';
|
||||
} else if (amount !== 0) {
|
||||
display = '₹' + amount.toLocaleString('en-IN', { maximumFractionDigits: 0 });
|
||||
}
|
||||
return '<span title="' + fmtIndianFull(amount) + '">' + display + '</span>';
|
||||
};
|
||||
const fmtFull = v => '₹' + Number(v).toLocaleString('en-IN');
|
||||
const pct = (a, t) => t > 0 ? Math.min(100, Math.round(a / t * 100)) : 0;
|
||||
const badge = p => p >= 100 ? ['b-achieved','🏆 Achieved'] : p >= 75 ? ['b-ontrack','🎯 On Track'] : p >= 50 ? ['b-behind','⚡ Behind'] : ['b-atrisk','⚠️ At Risk'];
|
||||
const progColor= p => p >= 100 ? 'linear-gradient(90deg,#10b981,#34d399)' : p >= 75 ? 'linear-gradient(90deg,#06b6d4,#67e8f9)' : p >= 50 ? 'linear-gradient(90deg,#f97316,#fbbf24)' : 'linear-gradient(90deg,#ef4444,#fca5a5)';
|
||||
const SALES_LEADS_URL = '<?= base_url('sales') ?>';
|
||||
const SALES_ACTIVITIES_URL = '<?= base_url('sales/loadactivities') ?>';
|
||||
const SALES_TARGETS_URL = '<?= base_url('sales/targets') ?>';
|
||||
|
||||
function getSelectedDashboardFY() {
|
||||
return document.getElementById('financial_year')?.value || getCurrentFY();
|
||||
}
|
||||
|
||||
function goSalesPage(page, status, memberId) {
|
||||
const url = new URL(page === 'activities' ? SALES_ACTIVITIES_URL : SALES_LEADS_URL, window.location.origin);
|
||||
url.searchParams.set('fy', getSelectedDashboardFY());
|
||||
url.searchParams.set('status', status || 'all');
|
||||
if (memberId) {
|
||||
url.searchParams.set('member', memberId);
|
||||
}
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
|
||||
function goLeadActivities(memberId) {
|
||||
if (!memberId) return;
|
||||
goSalesPage('activities', 'all', memberId);
|
||||
}
|
||||
|
||||
function goLeadDetail(leadId) {
|
||||
if (!leadId) return;
|
||||
const url = new URL(SALES_LEADS_URL, window.location.origin);
|
||||
url.searchParams.set('fy', getSelectedDashboardFY());
|
||||
url.searchParams.set('status', 'all');
|
||||
url.searchParams.set('lead_id', leadId);
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
|
||||
function showToast(type, message) {
|
||||
if (window.toastr && typeof toastr[type] === 'function') {
|
||||
toastr[type](message);
|
||||
return;
|
||||
}
|
||||
alert(message);
|
||||
}
|
||||
|
||||
function refreshTargetInDashboard(userId, targetId, targetAmount) {
|
||||
const member = TEAM.find(item => item.id == userId);
|
||||
if (!member) return;
|
||||
|
||||
member.target_id = targetId || member.target_id;
|
||||
member.target_amt = targetAmount;
|
||||
if (Array.isArray(member.splits)) {
|
||||
member.splits.forEach(split => {
|
||||
split.target = Number((targetAmount / 4).toFixed(2));
|
||||
});
|
||||
}
|
||||
|
||||
if (OPP_DATA[userId]) {
|
||||
OPP_DATA[userId].target_amt = targetAmount;
|
||||
}
|
||||
|
||||
renderBranchSummary();
|
||||
renderList();
|
||||
}
|
||||
|
||||
async function getSavedTargetRecord(userId, fyYear, fallbackTarget, forceFetch = false) {
|
||||
if (!forceFetch && fallbackTarget?.id && fallbackTarget?.target_amount) {
|
||||
return fallbackTarget;
|
||||
}
|
||||
|
||||
const res = await fetch(`${SALES_TARGETS_URL}/user/${userId}`);
|
||||
if (!res.ok) {
|
||||
return fallbackTarget || {};
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return (json.data || []).find(record => record.fy_year === fyYear) || fallbackTarget || {};
|
||||
}
|
||||
|
||||
async function saveModalTarget(targetId, userId, fyYear) {
|
||||
const input = document.getElementById('modalTargetAmount');
|
||||
const button = document.getElementById('btnSaveModalTarget');
|
||||
const amount = parseInt(input?.value || '0', 10);
|
||||
|
||||
if (!amount || amount <= 0) {
|
||||
input?.focus();
|
||||
showToast('warning', 'Please enter a valid target amount.');
|
||||
return;
|
||||
}
|
||||
|
||||
const originalText = button ? button.textContent : '';
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.textContent = 'Saving...';
|
||||
}
|
||||
|
||||
try {
|
||||
const isUpdate = !!targetId;
|
||||
const res = await fetch(isUpdate ? `${SALES_TARGETS_URL}/${targetId}` : SALES_TARGETS_URL, {
|
||||
method: isUpdate ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
user_id: userId,
|
||||
fy_year: fyYear,
|
||||
target_amount: amount
|
||||
})
|
||||
});
|
||||
const result = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(result?.message || 'Unable to update target amount.');
|
||||
}
|
||||
|
||||
const activeTabIndex = Array.from(document.querySelectorAll('.modal-tab'))
|
||||
.findIndex(tab => tab.classList.contains('active'));
|
||||
const updatedTarget = await getSavedTargetRecord(userId, fyYear, result?.data || {}, !isUpdate);
|
||||
const updatedAmount = Number(updatedTarget.target_amount ?? amount);
|
||||
|
||||
refreshTargetInDashboard(userId, updatedTarget.id || targetId, updatedAmount);
|
||||
openModal(userId);
|
||||
if (activeTabIndex > 0) {
|
||||
const tabButton = document.querySelectorAll('.modal-tab')[activeTabIndex];
|
||||
if (tabButton) switchTab(activeTabIndex, tabButton);
|
||||
}
|
||||
|
||||
showToast('success', result?.message || (isUpdate ? 'Target amount updated successfully.' : 'Target amount created successfully.'));
|
||||
} catch (err) {
|
||||
showToast('error', err.message || 'Unable to save target amount.');
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.textContent = originalText || 'Save Target';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Date formatters ── */
|
||||
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
@ -608,6 +787,19 @@ function openModal(id) {
|
||||
+ '<div class="ms-box"><div class="ms-num" style="color:#f97316;">' + fmt(rem) + '</div><div class="ms-lbl">REMAINING</div></div>'
|
||||
+ '<div class="ms-box"><div class="ms-num" style="color:' + (p>=75?'#10b981':p>=50?'#f97316':'#ef4444') + ';">' + p + '%</div><div class="ms-lbl">ACHIEVED %</div></div>';
|
||||
|
||||
const hasTargetRecord = !!m.target_id;
|
||||
const targetEditorHtml =
|
||||
'<div class="target-edit-panel">'
|
||||
+ '<div>'
|
||||
+ '<div class="target-edit-title">' + (hasTargetRecord ? 'Edit Target Amount' : 'Create Target Amount') + '</div>'
|
||||
+ '<div class="target-edit-help">' + (hasTargetRecord ? 'Update' : 'Create') + ' target for ' + m.first_name + ' - ' + currentFY + '</div>'
|
||||
+ '</div>'
|
||||
+ '<div>'
|
||||
+ '<input type="text" id="modalTargetAmount" class="target-edit-input" value="' + Math.round(m.target_amt || 0) + '" placeholder="Target amount" oninput="this.value=this.value.replace(/[^0-9]/g, \'\')">'
|
||||
+ '</div>'
|
||||
+ '<button type="button" class="target-edit-btn" id="btnSaveModalTarget" onclick="saveModalTarget(' + (m.target_id || 0) + ', ' + m.id + ', \'' + currentFY + '\')" title="' + (hasTargetRecord ? 'Update target amount' : 'Create target amount') + '">' + (hasTargetRecord ? 'Update Target' : 'Create Target') + '</button>'
|
||||
+ '</div>';
|
||||
|
||||
/* ── Overall progress block (shared) ── */
|
||||
const overallHtml =
|
||||
'<div class="overall-prog">'
|
||||
@ -767,7 +959,8 @@ function openModal(id) {
|
||||
|
||||
/* ── Inject tabs ── */
|
||||
document.getElementById('modalBody').innerHTML =
|
||||
'<div class="modal-tabs">'
|
||||
targetEditorHtml
|
||||
+ '<div class="modal-tabs">'
|
||||
+ '<button class="modal-tab active" onclick="switchTab(0,this)">📊 ' + fyLabel + ' Split-wise Achievement</button>'
|
||||
+ '<button class="modal-tab" onclick="switchTab(1,this)">🏆 Opportunities Achievement</button>'
|
||||
+ '</div>'
|
||||
|
||||
@ -12,12 +12,32 @@
|
||||
|
||||
/* Filter Tabs */
|
||||
.filter-tabs { display: flex; gap: 10px; padding: 20px 30px; }
|
||||
.tab { padding: 10px 20px; border-radius: 20px; cursor: pointer; font-size: 14px; background: white; color: #666; border: 1px solid #e0e0e0; transition: all 0.2s; }
|
||||
.tab { padding: 10px 14px; border-radius: 20px; cursor: pointer; font-size: 12px; background: white; color: #666; border: 1px solid #e0e0e0; transition: all 0.2s; white-space: nowrap; flex: 0 0 auto; }
|
||||
.tab.active { background: #02a8b5; color: white; border-color: #02a8b5; }
|
||||
|
||||
/* Leads Grid */
|
||||
.lead-header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; /* responsive */ gap: 15px; }
|
||||
.lead-actions { display: flex; align-items: center; gap: 10px; }
|
||||
.lead-actions { display: flex; align-items: center; gap: 4px; }
|
||||
.sales-toolbar { flex-wrap: nowrap; padding: 16px 30px; gap: 12px; overflow-x: auto; }
|
||||
.sales-toolbar .filter-tabs { padding: 0; flex: 1 1 auto; min-width: 0; overflow-x: auto; }
|
||||
.sales-toolbar .lead-actions { flex: 0 0 auto; flex-wrap: nowrap; }
|
||||
.sales-toolbar .search-input { width: 230px !important; }
|
||||
.sales-toolbar .toolbar-select { width: 175px !important; min-width: 175px; padding: 9px 12px; }
|
||||
.sales-toolbar .toolbar-select + .select2-container { width: 175px !important; min-width: 175px; }
|
||||
.sales-toolbar .financial-year-select { width: 108px !important; min-width: 108px; }
|
||||
.sales-toolbar .select2-container .select2-selection--single { height: 38px; border: 1px solid #ddd; border-radius: 6px; display: flex; align-items: center; }
|
||||
.sales-toolbar .select2-container--default .select2-selection--single .select2-selection__rendered { line-height: 36px; }
|
||||
.sales-toolbar .select2-container--default .select2-selection--single .select2-selection__arrow { height: 36px; }
|
||||
.sales-toolbar #memberFilter + .select2-container { width: 175px !important; min-width: 175px; flex: 0 0 175px; }
|
||||
.sales-toolbar #memberFilter + .select2-container .select2-selection--single { height: 38px !important; background: #fff !important; border: 1px solid #ddd !important; border-radius: 6px !important; display: flex; align-items: center; }
|
||||
.sales-toolbar #memberFilter + .select2-container .select2-selection__rendered { flex: 1; line-height: 36px !important; padding-left: 12px; padding-right: 28px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.sales-toolbar #memberFilter + .select2-container .select2-selection__arrow { height: 25px !important; width: 20px; top: 1px; right: 6px; }
|
||||
.toolbar-icon-btn { width: 38px; height: 38px; padding: 0; display: inline-flex; align-items: center; justify-content: center; border: none; border-radius: 8px; color: #fff; cursor: pointer; transition: all 0.2s; }
|
||||
.toolbar-icon-btn:hover { transform: translateY(-1px); filter: brightness(0.96); }
|
||||
.toolbar-icon-btn:focus { outline: none; box-shadow: 0 0 0 2px rgba(2, 168, 181, 0.25); }
|
||||
.toolbar-icon-btn i { font-size: 20px; line-height: 1; }
|
||||
.toolbar-icon-btn.export-btn { background: #1f9d55; }
|
||||
.toolbar-icon-btn.add-btn { background: #02a8b5; }
|
||||
.leads-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 20px; padding: 0 30px 30px; overflow-y: auto; }
|
||||
.lead-card { background: white; border-radius: 12px; padding: 20px; border: 1px solid #e0e0e0; cursor: pointer; transition: all 0.2s; }
|
||||
.lead-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.1); transform: translateY(-2px); }
|
||||
@ -146,7 +166,7 @@
|
||||
<div class="tab" data-filter="Not a Prospects" onclick="setFilter('Not a Prospects', this)">Not a Prospects</div>
|
||||
</div> -->
|
||||
<hr style="margin-bottom: 0 !important;">
|
||||
<div class="lead-header">
|
||||
<div class="lead-header sales-toolbar">
|
||||
|
||||
<!-- LEFT SIDE -->
|
||||
<div class="filter-tabs">
|
||||
@ -160,9 +180,23 @@
|
||||
<!-- RIGHT SIDE -->
|
||||
<div class="lead-actions">
|
||||
<input type="text" class="search-input" id="mainSearch"
|
||||
placeholder="Search leads..." onkeyup="fetchLeads(false)" style="width: 300px !important;">
|
||||
<button class="btn-primary" onclick="openModal('addLeadModal')">
|
||||
+ Add Lead
|
||||
placeholder="Search leads..." onkeyup="fetchLeads(false)">
|
||||
<select id="memberFilter" class="toolbar-select searchable" onchange="fetchLeads(false)">
|
||||
<option value="" selected>All Member</option>
|
||||
<?php foreach(($sales_manager_with_head ?? []) as $sm): ?>
|
||||
<option value="<?= $sm['id'] ?>"><?= esc($sm['first_name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<select id="financialYearFilter" class="toolbar-select financial-year-select" onchange="fetchLeads(false)">
|
||||
<?php foreach(($fin_years ?? []) as $year): ?>
|
||||
<option value="<?= esc($year) ?>" <?= (($current_fin_year ?? '') === $year) ? 'selected' : '' ?>><?= esc($year) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<button class="toolbar-icon-btn export-btn" onclick="exportLeadsCsv()" title="Export" aria-label="Export leads">
|
||||
<i class="mdi mdi-file-excel"></i>
|
||||
</button>
|
||||
<button class="toolbar-icon-btn add-btn" onclick="openModal('addLeadModal')" title="Add Lead" aria-label="Add lead">
|
||||
<i class="mdi mdi-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -729,12 +763,15 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
// Single selects
|
||||
document.querySelectorAll('.searchable:not(.multi-searchable)').forEach(function(el) {
|
||||
let parentModal = el.closest('.modal');
|
||||
$(el).select2({
|
||||
placeholder: "Select..",
|
||||
const select2Options = {
|
||||
allowClear: false,
|
||||
minimumResultsForSearch: 0,
|
||||
dropdownParent: parentModal ? $(parentModal) : $(document.body)
|
||||
});
|
||||
};
|
||||
if (el.id !== 'memberFilter') {
|
||||
select2Options.placeholder = "Select..";
|
||||
}
|
||||
$(el).select2(select2Options);
|
||||
});
|
||||
|
||||
// Multi selects
|
||||
@ -771,6 +808,7 @@ const API = '<?= base_url('sales') ?>';
|
||||
const SALES_CHECK_DUPLICATE_URL = '<?= base_url('sales/checkDuplicate') ?>';
|
||||
/** Normalized company name when edit modal opened — same name retyped after clear must not count as duplicate */
|
||||
let trackerEditLeadOriginalCompanyNorm = '';
|
||||
const defaultFinancialYear = '<?= esc($current_fin_year ?? '') ?>';
|
||||
let filter = 'all';
|
||||
let lead_id = null;
|
||||
let global_lead_assigned_to = null;
|
||||
@ -780,6 +818,58 @@ let currentPage = 1;
|
||||
let limit = 9;
|
||||
let currentOffset = 0;
|
||||
|
||||
function getCurrentFY() {
|
||||
const today = new Date();
|
||||
const year = today.getFullYear();
|
||||
const month = today.getMonth();
|
||||
const startYear = month >= 3 ? year : year - 1;
|
||||
return startYear + '-' + String(startYear + 1);
|
||||
}
|
||||
|
||||
function initSalesToolbarFilters() {
|
||||
const fySelect = document.getElementById('financialYearFilter');
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
if (fySelect) {
|
||||
let fy = params.get('fy') || defaultFinancialYear || getCurrentFY();
|
||||
const matched = Array.from(fySelect.options).some(opt => opt.value === fy);
|
||||
if (!matched && fySelect.options.length > 0) {
|
||||
fy = fySelect.options[0].value;
|
||||
}
|
||||
fySelect.value = fy;
|
||||
}
|
||||
|
||||
const statusParam = (params.get('status') || 'all').toLowerCase();
|
||||
const statusTab = Array.from(document.querySelectorAll('.filter-tabs .tab'))
|
||||
.find(tab => (tab.dataset.filter || '').toLowerCase() === statusParam);
|
||||
if (statusTab) {
|
||||
document.querySelectorAll('.filter-tabs .tab').forEach(tab => tab.classList.remove('active'));
|
||||
statusTab.classList.add('active');
|
||||
filter = statusTab.dataset.filter || 'all';
|
||||
}
|
||||
|
||||
const memberSelect = document.getElementById('memberFilter');
|
||||
const memberId = params.get('member');
|
||||
if (memberSelect && memberId && Array.from(memberSelect.options).some(opt => opt.value === memberId)) {
|
||||
memberSelect.value = memberId;
|
||||
if (window.jQuery) {
|
||||
$(memberSelect).trigger('change.select2');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getSelectedMemberIds(defaultIds) {
|
||||
const memberFilter = document.getElementById('memberFilter');
|
||||
if (memberFilter && memberFilter.value) {
|
||||
return [memberFilter.value];
|
||||
}
|
||||
return Array.isArray(defaultIds) ? defaultIds : [];
|
||||
}
|
||||
|
||||
function getSelectedFinancialYear() {
|
||||
return document.getElementById('financialYearFilter')?.value || defaultFinancialYear || getCurrentFY();
|
||||
}
|
||||
|
||||
function openModal(id) { document.getElementById(id).classList.add('active'); resetFlatpicker(); }
|
||||
// function closeModal(id) { document.getElementById(id).classList.remove('active'); }
|
||||
|
||||
@ -940,7 +1030,11 @@ function setFilter(val, el) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
filter = val;
|
||||
fetchLeads();
|
||||
const searchInput = document.getElementById('mainSearch');
|
||||
if (searchInput) {
|
||||
searchInput.value = '';
|
||||
}
|
||||
fetchLeads(false);
|
||||
}
|
||||
|
||||
// 1. Leads Grid Logic
|
||||
@ -970,6 +1064,7 @@ async function fetchLeads(isLoadMore = false) {
|
||||
const btnLoadMore = document.getElementById('btn-load-more');
|
||||
const spinner = document.getElementById('load-more-spinner');
|
||||
const text = document.getElementById('load-more-text');
|
||||
const selectedMemberIds = getSelectedMemberIds(salesManagerHeadIds);
|
||||
|
||||
// 1. Define the empty state HTML early so we can use it immediately if needed
|
||||
const emptyStateHTML = `<div style="display: contents;">
|
||||
@ -980,7 +1075,7 @@ async function fetchLeads(isLoadMore = false) {
|
||||
</div>`;
|
||||
|
||||
// 2. 🛑 SHORT-CIRCUIT: If no sales manager IDs exist, show empty state and stop!
|
||||
if (typeof salesManagerHeadIds === 'undefined' || salesManagerHeadIds.length === 0) {
|
||||
if (selectedMemberIds.length === 0) {
|
||||
console.log("No Sales Manager IDs found. Skipping API call.");
|
||||
grid.innerHTML = emptyStateHTML;
|
||||
btnLoadMore.style.display = 'none'; // Hide the load more button
|
||||
@ -1005,11 +1100,15 @@ async function fetchLeads(isLoadMore = false) {
|
||||
}
|
||||
|
||||
// 5. Build URL with dynamic offset
|
||||
let url = `${API}/leads?status=${filter === 'all' ? '' : filter}&search=${q}&limit=${limit}&offset=${currentOffset}`;
|
||||
// url += `&assigned_to=${salesManagerHeadIds.join(',')}`; // We already proved it exists above!
|
||||
if (typeof salesManagerHeadIds !== 'undefined' && salesManagerHeadIds.length > 0) {
|
||||
url += `&assigned_to=${salesManagerHeadIds.join(',')}`;
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
status: filter === 'all' ? '' : filter,
|
||||
search: q,
|
||||
limit,
|
||||
offset: currentOffset,
|
||||
assigned_to: selectedMemberIds.join(','),
|
||||
financial_year: getSelectedFinancialYear()
|
||||
});
|
||||
let url = `${API}/leads?${params.toString()}`;
|
||||
|
||||
console.log("Fetching API:", url);
|
||||
|
||||
@ -2394,7 +2493,78 @@ function convertDBFormatted(input) {
|
||||
seconds.padStart(2, '0')
|
||||
);
|
||||
}
|
||||
fetchLeads();
|
||||
|
||||
function csvEscape(value) {
|
||||
const text = String(value ?? '').replace(/"/g, '""');
|
||||
return `"${text}"`;
|
||||
}
|
||||
|
||||
function capitalizeStatus(value) {
|
||||
const text = String(value ?? '').trim();
|
||||
return text ? text.charAt(0).toUpperCase() + text.slice(1).toLowerCase() : '';
|
||||
}
|
||||
|
||||
function formatIndianDate(value) {
|
||||
if (!value) return '';
|
||||
const date = new Date(String(value).replace(' ', 'T'));
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
|
||||
return date.toLocaleString('en-IN', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
}).replace(',', '').toUpperCase();
|
||||
}
|
||||
|
||||
function downloadCsv(filename, rows) {
|
||||
const csv = rows.map(row => row.map(csvEscape).join(',')).join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
}
|
||||
|
||||
async function exportLeadsCsv() {
|
||||
const selectedMemberIds = getSelectedMemberIds(salesManagerHeadIds);
|
||||
if (selectedMemberIds.length === 0) return toastr.warning('No members available to export');
|
||||
|
||||
const params = new URLSearchParams({
|
||||
status: filter === 'all' ? '' : filter,
|
||||
search: document.getElementById('mainSearch')?.value || '',
|
||||
limit: 10000,
|
||||
offset: 0,
|
||||
assigned_to: selectedMemberIds.join(','),
|
||||
financial_year: getSelectedFinancialYear()
|
||||
});
|
||||
|
||||
const res = await fetch(`${API}/leads?${params.toString()}`);
|
||||
const json = await res.json();
|
||||
const rows = [
|
||||
['Company', 'Email', 'Phone', 'Status', 'Assigned To', 'Created At'],
|
||||
...(json.data || []).map(l => [
|
||||
l.company_name || '',
|
||||
l.email || '',
|
||||
l.phone || '',
|
||||
capitalizeStatus(l.status),
|
||||
l.assigned_to_name || '',
|
||||
formatIndianDate(l.created_at)
|
||||
])
|
||||
];
|
||||
downloadCsv(`sales-leads-${getSelectedFinancialYear()}.csv`, rows);
|
||||
}
|
||||
|
||||
initSalesToolbarFilters();
|
||||
fetchLeads().then(() => {
|
||||
const leadId = new URLSearchParams(window.location.search).get('lead_id');
|
||||
if (leadId) {
|
||||
viewDetail(leadId);
|
||||
}
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// COMPANY SEARCH — Odoo-style autocomplete
|
||||
|
||||
Loading…
Reference in New Issue
Block a user