FEAT_NON_EB_RFQ/QCR

This commit is contained in:
velz 2026-03-23 18:33:11 +05:30
parent 65394d7aea
commit e8e79c3cea
12 changed files with 1672 additions and 76 deletions

View File

@ -63,7 +63,8 @@ NHANCE_LOGO =
helpdeskURL =
TOKENTIMEOUT =
POST_ENROLLMENT_APP_LINK =
GDRIVE_ROOT_FOLDER_ID =
GDRIVE_ROOT_FOLDER_ID =
RFQ_PARENT_FOLDER_ID =
email.fromEmail =

View File

@ -7,9 +7,17 @@ class RfqConfig extends BaseConfig
{
/**
* Google Drive parent folder IDs for RFQ and QCR sheets.
* Values are loaded from .env (RFQ_PARENT_FOLDER_ID).
*/
public string $rfqParentFolderId = '1MnYh5PTPDlc9mYMGsjmTv02y8EZ-BQf1';
public string $qcrParentFolderId = '1MnYh5PTPDlc9mYMGsjmTv02y8EZ-BQf1';
public string $rfqParentFolderId = '';
public string $qcrParentFolderId = '';
public function __construct()
{
parent::__construct();
$this->rfqParentFolderId = env('RFQ_PARENT_FOLDER_ID', '1MnYh5PTPDlc9mYMGsjmTv02y8EZ-BQf1');
$this->qcrParentFolderId = env('RFQ_PARENT_FOLDER_ID', '1MnYh5PTPDlc9mYMGsjmTv02y8EZ-BQf1');
}
/**
* Default permissions for created sheets.
@ -48,4 +56,27 @@ class RfqConfig extends BaseConfig
'groups' => [],
],
];
/**
* Placeholder token for past 3 years claims table (written as multi-row values at that cell).
*/
public string $rfqClaimsPlaceholder = '{{CLAIMS_DETAILS}}';
/**
* Maps Google Sheet placeholder => internal key from buildRfqPlaceholderData().
* Add these exact strings in your RFQ templates (any sheet/tab).
*
* @var array<string, string>
*/
public array $rfqPlaceholders = [
'{{INSURED_NAME}}' => 'insured_name',
'{{COMMUNICATION_ADDRESS}}' => 'communication_address',
'{{GST}}' => 'gst',
'{{PAN}}' => 'pan',
'{{POLICY_PERIOD}}' => 'policy_period',
'{{OPPORTUNITY_TYPE}}' => 'opportunity_type',
'{{RISK_LOCATION}}' => 'risk_location',
'{{OCCUPANCY}}' => 'occupancy',
'{{CLAIMS_DETAILS}}' => 'claims_details',
];
}

View File

@ -511,6 +511,8 @@ $routes->group("leads", ["filter" => "authMVC"], function ($routes) {
$routes->post("create", "LeadsController::createLead");
$routes->get("list/(:any)", "LeadsController::getLeadDataForEdit/$1");
$routes->get("createRfqSheet", "LeadsController::createRfqSheet");
$routes->get("createQcrSheet", "LeadsController::createQcrSheet");
$routes->post("uploadLeadAttachment", "LeadsController::uploadLeadAttachment");
$routes->get("mailTemplate", "LeadsController::getLeadMailTemplate");
$routes->get("sendMail", "LeadsController::sendMailWithAttachement");
$routes->post("sendMail", "LeadsController::sendMailWithAttachement");
@ -550,6 +552,8 @@ $routes->cli('cli/sendZeptoMail', 'MasterController::testZeptoSMTP');
$routes->cli('cli/check_bounce_mail_cli', 'MasterController::testCheckBounceMails');
$routes->cli('cli/app_check_list', 'MasterController::appCheckList');
$routes->cli('cli/new_gdrive_token', 'GoogleDriveController::generateNewGoogleDriveAccessToken');
$routes->cli('cli/list-sheet-folder-files', 'GoogleSheetController::listFolderSheetFilesCli');
$routes->cli('cli/list-sheet-folder-files/(:any)', 'GoogleSheetController::listFolderSheetFilesCli/$1');
$routes->cli('cli/check_env', 'MasterController::checkEnv');
//crone job

View File

@ -1,6 +1,7 @@
<?php namespace App\Controllers;
use App\Libraries\GoogleSheetLib;
use CodeIgniter\CLI\CLI;
use CodeIgniter\Controller;
class GoogleSheetController extends Controller
@ -133,4 +134,23 @@ class GoogleSheetController extends Controller
]);
}
}
/**
* CLI: php public/index.php cli/list-sheet-folder-files [folderId]
*/
public function listFolderSheetFilesCli(string $folderId = '')
{
if (! is_cli()) {
return $this->response
->setStatusCode(405)
->setJSON(['status' => 'error', 'message' => 'CLI only route']);
}
$folderId = CLI::getOption('folderId') ?: $folderId;
$json = $this->sheetLib->listFolderSheetIdsAsJson($folderId);
file_put_contents('sheetid.json', $json);
CLI::write($json);
return;
}
}

View File

@ -627,7 +627,6 @@ class LeadsController extends BaseController
$request_data = $this->request->getPost();
$data = sanitizeInputArrayAdvanced($request_data);
$data['client_type'] = 1;
$data['pan'] = "";
if ($data['lead_type'] != 1) {
$client_data = $this->clientModel->where('id', $data['client_id'])->where('is_active', 1)->first();
@ -843,6 +842,7 @@ class LeadsController extends BaseController
'source_policy_id' => $data['source_policy_id'] ?? 0,
'policy_type_id' => $value,
'salse_person_id' => $data['salse_person_id'] ?? 0,
'rfq_qcr_viewers' => $data['rfq_qcr_viewers'] ?? null,
'insurer_id' => $insurer_id ?? 0,
'insurer_branch_id' => $insurer_branch_id ?? 0,
'tpa_id' => $tpa_id ?? 0,
@ -1120,6 +1120,59 @@ class LeadsController extends BaseController
}
}
public function uploadLeadAttachment()
{
try {
$leadId = (int) $this->request->getPost('lead_id');
$docsName = trim($this->request->getPost('docs_name') ?? '');
if ($leadId <= 0) {
return $this->respond(['status' => 'error', 'code' => 400, 'message' => 'Invalid lead id'], 400);
}
if (empty($docsName)) {
return $this->respond(['status' => 'error', 'code' => 400, 'message' => 'Document label is required'], 400);
}
$file = $this->request->getFile('attachment_file');
if (! $file || ! $file->isValid() || $file->hasMoved()) {
return $this->respond(['status' => 'error', 'code' => 400, 'message' => 'Invalid or missing file'], 400);
}
$uploadFilePath = WRITEPATH . 'uploads/lead_files/';
$fileName = file_Upload_for_lead($file, $uploadFilePath, UPLOAD_EXT_LEAD_FILES);
if (empty($fileName)) {
return $this->respond(['status' => 'error', 'code' => 400, 'message' => 'File upload failed. Check file type or size.'], 400);
}
$this->leadFilesModel->insert([
'lead_id' => $leadId,
'docs_name' => $docsName,
'file_name' => $fileName,
]);
// Return refreshed attachment HTML
$attachments = $this->leadFilesModel
->where('lead_id', $leadId)
->where('type !=', 2)
->where('is_active', 1)
->findAll();
$attachmentHtml = view('rfq/attachment_files', ['multi_file_data' => $attachments]);
return $this->respond([
'status' => 'success',
'code' => 200,
'message' => 'File uploaded successfully',
'attachment_html' => $attachmentHtml,
], 200);
} catch (\Throwable $e) {
$this->myLogger->logme('error', 'uploadLeadAttachment: ' . $e->getMessage());
return $this->respond(['status' => 'error', 'code' => 500, 'message' => 'Upload failed'], 500);
}
}
//--------RFQ-----------------------------------------------------------------------------------------------
// public function viewRFQ($id, $type = 1)
@ -3348,6 +3401,15 @@ class LeadsController extends BaseController
// Handle case where param_cc_mail is not valid
// return $this->respond(['status' => 'failed','code' => 400,'data' => '','message' => 'CC mail not found!'], 200);
}
// Append external CC emails for client mail (sent as email strings, not user IDs)
if ($recipient_type === 'client' && ! empty($params['external_cc'])) {
$externalCCs = json_decode($params['external_cc'], true);
if (is_array($externalCCs)) {
$cc_mails = array_merge($cc_mails, array_values(array_filter(array_map('trim', $externalCCs))));
}
}
} else if ($recipient_type == 'placement') {
if(isset($params['cc']) && !empty($params['cc'])) {
@ -3399,9 +3461,12 @@ class LeadsController extends BaseController
//get file path to attach
if ($lead_data["lead_form_type"] == 2) {
// For Non-EB, download RFQ/QCR file from Google Sheet (if configured)
$file_info = $this->downloadFileFromGoogleSheet($lead_data);
if ($lead_data["lead_form_type"] == 2 && $recipient_type === 'placement') {
// For Non-EB placement: copy QCR sheet as placement file, then download it
$file_info = $this->createAndDownloadPlacementSheet($lead_data);
} else if ($lead_data["lead_form_type"] == 2) {
// For Non-EB, download RFQ/QCR file from Google Sheet based on recipient type
$file_info = $this->downloadFileFromGoogleSheet($lead_data, $recipient_type);
} else {
$file_info = $this->constructExcelToSaveTemp($lead_id, ($file_type == 'rfq' ? 1 : 2), $propsal_and_insurer);
}
@ -3442,8 +3507,17 @@ class LeadsController extends BaseController
// print_r($attachments); die;
//get recipient address
if ($recipient_type == 'insurer' || $recipient_type == 'placement') {
if ($recipient_type == 'placement') {
// Placement TO is client contact email sent directly
if(!is_array($recipient_mail)) {
$recipient_mail = [$recipient_mail];
}
$recipient_data = [];
foreach ($recipient_mail as $mail) {
$recipient_data[] = ['name' => $lead_data['contact_person_name'] ?? '', 'email' => trim($mail)];
}
} else if ($recipient_type == 'insurer') {
if(!is_array($recipient_mail)) {
$recipient_mail = [$recipient_mail];
}
@ -4535,7 +4609,17 @@ class LeadsController extends BaseController
}
}
// Client contact from lead data for placement TO
$clientContact = null;
if (! empty($lead['contact_person_email'])) {
$clientContact = [
'name' => $lead['contact_person_name'] ?? '',
'email' => $lead['contact_person_email'],
];
}
$data['insurer_contacts'] = $insurerContacts;
$data['client_contact'] = $clientContact;
return $this->respond(
[
@ -4761,15 +4845,21 @@ class LeadsController extends BaseController
$this->myLogger->logme('error', "RFQ Sheet: starting for lead_id={$leadId}");
// Fetch lead + policy type (including misc JSON from both)
// Fetch lead + policy type + client address (LEFT JOIN for communication address on EB leads)
$lead = $this->leadsModel
->select('
leads.*,
policy_type.policy_type,
policy_type.long_name,
policy_type.misc as policy_misc
policy_type.misc as policy_misc,
clients.address1 as client_address1,
clients.address2 as client_address2,
clients.city as client_city,
clients.state as client_state,
clients.pincode as client_pincode
')
->join('policy_type', 'leads.policy_type_id = policy_type.id', 'left')
->join('clients', 'leads.client_id = clients.id', 'left')
->where('leads.id', $leadId)
->where('leads.is_active', 1)
->first();
@ -4892,22 +4982,76 @@ class LeadsController extends BaseController
$newSheetId = $sheetLib->copyTemplate($templateId, $sheetName, $parentFolderId);
$this->myLogger->logme('error', "RFQ Sheet: template copied | lead_id={$leadId}, template_id={$templateId}, new_sheet_id={$newSheetId}");
// Fill placeholders across all sheets (see Config\RfqConfig::$rfqPlaceholders)
try {
/** @var \Config\RfqConfig $rfqCfg */
$rfqCfg = config('RfqConfig');
$placeholderData = $this->buildRfqPlaceholderData($lead);
$claimsPh = $rfqCfg->rfqClaimsPlaceholder ?? '{{CLAIMS_DETAILS}}';
$map = $rfqCfg->rfqPlaceholders ?? [];
$textReplacements = [];
foreach ($map as $placeholder => $key) {
if ($placeholder === $claimsPh) {
continue;
}
$textReplacements[$placeholder] = (string) ($placeholderData['values'][$key] ?? '');
}
$sheetLib->findAndReplaceAllSheets($newSheetId, $textReplacements);
if ($placeholderData['claims_table'] !== null) {
$written = $sheetLib->writeTableAtPlaceholder(
$newSheetId,
$claimsPh,
$placeholderData['claims_table']
);
if (! $written) {
$sheetLib->findAndReplaceAllSheets($newSheetId, [
$claimsPh => $placeholderData['claims_placeholder_fallback'],
]);
}
} else {
$sheetLib->findAndReplaceAllSheets($newSheetId, [
$claimsPh => $placeholderData['claims_placeholder_fallback'],
]);
}
$this->myLogger->logme('error', "RFQ Sheet: placeholders applied | lead_id={$leadId}, sheet_id={$newSheetId}");
} catch (\Throwable $e) {
$this->myLogger->logme(
'error',
"RFQ Sheet: placeholder fill failed | lead_id={$leadId}, error=" . $e->getMessage()
);
}
// Apply permissions: get sales users from DB (team_id 5) and add as editors
$salesTeam = $this->userModel
->select('user_profiles.email')
->join('user_teams', 'user_profiles.id = user_teams.user_id')
->where('user_teams.team_id', 5)
->where('user_teams.is_active', 1)
->where('user_profiles.is_active', 1)
->findAll();
$editorEmails = array_values(array_filter(array_unique(array_column($salesTeam, 'email'))));
if (! empty($editorEmails)) {
// $salesTeam = $this->userModel
// ->select('user_profiles.email')
// ->join('user_teams', 'user_profiles.id = user_teams.user_id')
// ->where('user_teams.team_id', 5)
// ->where('user_teams.is_active', 1)
// ->where('user_profiles.is_active', 1)
// ->findAll();
// $editorEmails = array_values(array_filter(array_unique(array_column($salesTeam, 'email'))));
// Decode rfq_qcr_viewers from lead and add as viewers
$editorEmails = [];
if (! empty($lead['rfq_qcr_viewers'])) {
$decoded = json_decode($lead['rfq_qcr_viewers'], true);
if (is_array($decoded)) {
$editorEmails = array_values(array_filter(array_unique($decoded)));
}
}
if ( ! empty($editorEmails)) {
try {
$sheetLib->applyPermissions($newSheetId, [
'editors' => $editorEmails,
'viewers' => [],
// 'viewers' => $viewerEmails,
]);
$this->myLogger->logme('error', "RFQ Sheet: permissions applied (sales team) | sheet_id={$newSheetId}, count=" . count($editorEmails));
$this->myLogger->logme('error', "RFQ Sheet: permissions applied | sheet_id={$newSheetId}, editors=" . count($editorEmails) );
} catch (\Throwable $e) {
$this->myLogger->logme(
'error',
@ -4976,17 +5120,344 @@ class LeadsController extends BaseController
}
}
/**
* Build values for RFQ Google Sheet placeholders (see RfqConfig::$rfqPlaceholders).
*
* @return array{values: array<string, string>, claims_table: ?list<list<string|int|float>>, claims_placeholder_fallback: string}
*/
private function buildRfqPlaceholderData(array $lead): array
{
helper('excel_util_helper');
$custom = [];
if (! empty($lead['custom_fields'])) {
$decoded = json_decode($lead['custom_fields'], true);
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
$custom = $decoded;
}
}
$clientParts = array_filter([
trim((string) ($lead['client_address1'] ?? '')),
trim((string) ($lead['client_address2'] ?? '')),
trim((string) ($lead['client_city'] ?? '')),
trim((string) ($lead['client_state'] ?? '')),
trim((string) ($lead['client_pincode'] ?? '')),
], static function ($s) {
return $s !== '';
});
$clientAddr = implode(', ', $clientParts);
$cfParts = array_filter([
trim((string) ($custom['address'] ?? '')),
trim((string) ($custom['pincode'] ?? '')),
], static function ($s) {
return $s !== '';
});
$cfAddr = implode(', ', $cfParts);
$communicationAddress = $clientAddr !== '' ? $clientAddr : $cfAddr;
$policyPeriod = '';
if (! empty($lead['policy_start_date']) && ! empty($lead['policy_end_date'])) {
$policyPeriod = date('d-m-Y', strtotime((string) $lead['policy_start_date']))
. ' to '
. date('d-m-Y', strtotime((string) $lead['policy_end_date']));
}
$lt = (int) ($lead['lead_type'] ?? 0);
$opportunityType = $this->leadType[$lt] ?? '';
$riskRaw = (string) ($custom['risk_location'] ?? '');
$riskLocation = $riskRaw !== ''
? ucwords(str_replace('_', ' ', strtolower($riskRaw)))
: '';
$occupancyRaw = (string) ($custom['business_description'] ?? $custom['occupancy_risk'] ?? '');
$occupancy = $occupancyRaw !== ''
? ucwords(str_replace('_', ' ', strtolower($occupancyRaw)))
: '';
$claimsTable = null;
$claimsFallback = 'No claims data available';
if (! empty($lead['fin_years_claims'])) {
$claimJson = json_decode($lead['fin_years_claims'], true);
if (json_last_error() === JSON_ERROR_NONE && is_array($claimJson)) {
$finyear = $claimJson['finyear'] ?? null;
if (is_array($finyear) && $finyear !== []) {
$claimsTable = $this->buildRfqClaimsTableValues($finyear);
}
}
}
$values = [
'insured_name' => trim((string) ($lead['client_name'] ?? $lead['client_short_name'] ?? '')),
'communication_address' => $communicationAddress,
'gst' => trim((string) ($lead['gst'] ?? '')),
'pan' => trim((string) ($lead['pan'] ?? '')),
'policy_period' => $policyPeriod,
'opportunity_type' => $opportunityType,
'risk_location' => $riskLocation,
'occupancy' => $occupancy,
'claims_details' => '',
];
return [
'values' => $values,
'claims_table' => $claimsTable,
'claims_placeholder_fallback' => $claimsFallback,
];
}
/**
* @param list<array<string, mixed>> $finyear
*
* @return list<list<string|int|float>>
*/
private function buildRfqClaimsTableValues(array $finyear): array
{
helper('excel_util_helper');
$first = $finyear[0] ?? null;
if (! is_array($first) || $first === []) {
return [['No data available']];
}
$headers = [];
foreach (array_keys($first) as $key) {
$headers[] = ucwords(str_replace('_', ' ', (string) $key));
}
$rows = [];
$rows[] = $headers;
foreach ($finyear as $record) {
if (! is_array($record)) {
continue;
}
$line = [];
foreach ($record as $arrayKey => $value) {
if (in_array($arrayKey, ['sum_insured', 'claim_amount', 'settled'], true)) {
$line[] = function_exists('formatIndianCurrency')
? formatIndianCurrency((int) $value)
: (string) $value;
} else {
$line[] = $value === null || $value === '' ? '' : (string) $value;
}
}
$rows[] = $line;
}
return $rows;
}
public function createQcrSheet()
{
try {
$leadId = (int) $this->request->getGet('lead_id');
if ($leadId <= 0) {
return $this->respond(
['status' => 'error', 'code' => 400, 'message' => 'Invalid lead id'],
400
);
}
$this->myLogger->logme('error', "QCR Sheet: starting for lead_id={$leadId}");
$lead = $this->leadsModel
->select('
leads.*,
policy_type.policy_type,
policy_type.long_name,
policy_type.misc as policy_misc
')
->join('policy_type', 'leads.policy_type_id = policy_type.id', 'left')
->where('leads.id', $leadId)
->where('leads.is_active', 1)
->first();
if (! $lead) {
$this->myLogger->logme('error', "QCR Sheet: lead not found | lead_id={$leadId}");
return $this->respond(
['status' => 'error', 'code' => 404, 'message' => 'Lead not found'],
404
);
}
$leadMisc = [];
if (! empty($lead['misc'])) {
$decoded = json_decode($lead['misc'], true);
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
$leadMisc = $decoded;
}
}
// If QCR sheet already exists, just return it
if (! empty($leadMisc['qcr_sheet_id'])) {
$sheetId = (string) $leadMisc['qcr_sheet_id'];
$sheetLib = new GoogleSheetLib();
$sheetUrl = $sheetLib->sheetUrl($sheetId);
$this->myLogger->logme('error', "QCR Sheet: existing sheet found | lead_id={$leadId}, sheet_id={$sheetId}");
return $this->respond(
[
'status' => 'success',
'code' => 200,
'sheet_id' => $sheetId,
'sheet_url' => $sheetUrl,
'message' => 'QCR sheet already exists',
],
200
);
}
// Source is the existing RFQ sheet for this lead
$rfqSheetId = $leadMisc['rfq_sheet_id'] ?? null;
if (empty($rfqSheetId)) {
$this->myLogger->logme('error', "QCR Sheet: rfq_sheet_id missing in leads.misc | lead_id={$leadId}");
return $this->respond(
['status' => 'error', 'code' => 400, 'message' => 'RFQ sheet not found. Please create the RFQ sheet first.'],
400
);
}
// Load config for folder id and permissions
$parentFolderId = null;
$permissions = [];
try {
/** @var \Config\RfqConfig $rfqConfig */
$rfqConfig = config('RfqConfig');
if ($rfqConfig) {
$parentFolderId = $rfqConfig->rfqParentFolderId ?? null;
$permissions = $rfqConfig->permissions ?? [];
}
} catch (\Throwable $e) {
$this->myLogger->logme('error', 'QCR Sheet: error loading RfqConfig | ' . $e->getMessage());
}
if (! $parentFolderId) {
return $this->respond(
['status' => 'error', 'code' => 400, 'message' => 'Google config not correct, contact administrator'],
400
);
}
// Build filename: same as RFQ naming but with QCR
$string = 'QCR';
$current_year = date('Y');
$next_year = $current_year + 1;
$policy_year = "{$current_year}-{$next_year}";
$clientName = trim($lead['client_name'] ?? $lead['client_short_name'] ?? '');
$policyType = trim($lead['policy_type'] ?? '');
if (! empty($lead['policy_end_date'])) {
$policy_expiry = strtotime($lead['policy_end_date']);
$formatted_policy = date('d-m-Y', $policy_expiry);
$sheetName = "{$clientName}_{$policyType}_{$string}_{$policy_year}(Due On {$formatted_policy}).xlsx";
} else {
$sheetName = "{$clientName}_{$policyType}_{$string}_{$policy_year}_.xlsx";
}
$sheetLib = new GoogleSheetLib();
$newSheetId = $sheetLib->copyTemplate($rfqSheetId, $sheetName, $parentFolderId);
$this->myLogger->logme('error', "QCR Sheet: copied from RFQ sheet | lead_id={$leadId}, rfq_sheet_id={$rfqSheetId}, new_sheet_id={$newSheetId}");
// Apply permissions: sales team as editors
$salesTeam = $this->userModel
->select('user_profiles.email')
->join('user_teams', 'user_profiles.id = user_teams.user_id')
->where('user_teams.team_id', 5)
->where('user_teams.is_active', 1)
->where('user_profiles.is_active', 1)
->findAll();
$editorEmails = array_values(array_filter(array_unique(array_column($salesTeam, 'email'))));
// Decode rfq_qcr_viewers from lead and add as viewers
$viewerEmails = [];
if (! empty($lead['rfq_qcr_viewers'])) {
$decoded = json_decode($lead['rfq_qcr_viewers'], true);
if (is_array($decoded)) {
$viewerEmails = array_values(array_filter(array_unique($decoded)));
}
}
if (! empty($editorEmails) || ! empty($viewerEmails)) {
try {
$sheetLib->applyPermissions($newSheetId, [
'editors' => $editorEmails,
'viewers' => $viewerEmails,
]);
$this->myLogger->logme('error', "QCR Sheet: permissions applied | sheet_id={$newSheetId}, editors=" . count($editorEmails) . ", viewers=" . count($viewerEmails));
} catch (\Throwable $e) {
$this->myLogger->logme('error', "QCR Sheet: failed to apply permissions | sheet_id={$newSheetId}, error=" . $e->getMessage());
}
}
// No protections applied for QCR as of now
// Save qcr_sheet_id to leads.misc and update status
$leadMisc['qcr_sheet_id'] = $newSheetId;
$this->leadsModel->update($leadId, [
'misc' => json_encode($leadMisc),
'status' => 'qcr_created',
]);
$sheetUrl = $sheetLib->sheetUrl($newSheetId);
$this->myLogger->logme('error', "QCR Sheet: created and stored | lead_id={$leadId}, sheet_id={$newSheetId}");
return $this->respond(
[
'status' => 'success',
'code' => 200,
'sheet_id' => $newSheetId,
'sheet_url' => $sheetUrl,
'message' => 'QCR sheet created successfully',
],
200
);
} catch (\Throwable $e) {
$errorDetails = [
'message' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
];
$this->myLogger->logme('error', 'QCR Sheet: exception occurred | ' . json_encode($errorDetails));
return $this->respond(
['status' => 'error', 'code' => 500, 'message' => 'Failed to create QCR sheet'],
500
);
}
}
/**
* Download RFQ/QCR Excel file from Google Sheet for Non-EB leads.
*
* Expects $lead_data to contain misc JSON with rfq_sheet_id or qcr_sheet_id.
* Returns an array compatible with constructNonEbExcelToSaveTemp:
* [
* 'filePath' => string,
* 'fileName' => string,
* ]
* Sheet selection based on recipient_type:
* - 'insurer' rfq_sheet_id
* - 'client' qcr_sheet_id
* - 'internal' based on lead status: qcr_created/qcr_sent qcr_sheet_id, else rfq_sheet_id
*
* @param string $recipientType insurer|client|internal
*/
protected function downloadFileFromGoogleSheet(array $lead_data): array
protected function downloadFileFromGoogleSheet(array $lead_data, string $recipientType = 'insurer'): array
{
try {
$leadId = (int) ($lead_data['id'] ?? 0);
@ -5002,17 +5473,31 @@ class LeadsController extends BaseController
}
}
// Prefer RFQ sheet id, fallback to QCR if needed
$sheetId = $misc['rfq_sheet_id'] ?? ($misc['qcr_sheet_id'] ?? null);
// Pick sheet id based on recipient type
$sheetId = null;
$sheetType = '';
if ($recipientType === 'client') {
$sheetId = $misc['qcr_sheet_id'] ?? null;
$sheetType = 'qcr_sheet_id';
} elseif ($recipientType === 'internal') {
$leadStatus = $lead_data['status'] ?? '';
if (in_array($leadStatus, ['qcr_created', 'qcr_sent'])) {
$sheetId = $misc['qcr_sheet_id'] ?? null;
$sheetType = 'qcr_sheet_id';
} else {
$sheetId = $misc['rfq_sheet_id'] ?? null;
$sheetType = 'rfq_sheet_id';
}
} else {
// insurer or default
$sheetId = $misc['rfq_sheet_id'] ?? null;
$sheetType = 'rfq_sheet_id';
}
if (empty($sheetId)) {
$this->myLogger->logme('error', "GSHEET DOWNLOAD: sheet id missing in misc | lead_id={$leadId}");
// Fallback: behave like old flow and construct Non-EB Excel locally
// $file_info = $this->constructNonEbExcelToSaveTemp($leadId, 1, null);
// return [
// 'filePath' => $file_info['filePath'],
// 'fileName' => $file_info['fileName'],
// ];
$this->myLogger->logme('error', "GSHEET DOWNLOAD: {$sheetType} missing in leads.misc | lead_id={$leadId}, recipient_type={$recipientType}");
throw new \RuntimeException("{$sheetType} not found in leads. Please create the sheet first.");
}
$this->myLogger->logme('error', "GSHEET DOWNLOAD: starting download | lead_id={$leadId}, sheet_id={$sheetId}");
@ -5071,6 +5556,143 @@ class LeadsController extends BaseController
}
}
/**
* Create a placement Google Sheet by copying the QCR sheet (Non-EB only).
*
* - Reads qcr_sheet_id from leads.misc
* - If placement_sheet_id already exists, reuses it
* - Copies the QCR sheet, replacing "QCR" with "Placement" in the filename
* - Applies same editor/viewer permissions as QCR
* - Saves placement_sheet_id to leads.misc
* - Downloads the placement sheet as Excel and returns file info
*/
protected function createAndDownloadPlacementSheet(array $lead_data): array
{
$leadId = (int) ($lead_data['id'] ?? 0);
$sheetLib = new GoogleSheetLib();
// Decode leads.misc
$leadMisc = [];
if (! empty($lead_data['misc'])) {
$decoded = json_decode($lead_data['misc'], true);
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
$leadMisc = $decoded;
}
}
// If placement sheet already exists, download and return it
if (! empty($leadMisc['placement_sheet_id'])) {
$placementSheetId = (string) $leadMisc['placement_sheet_id'];
$this->myLogger->logme('error', "Placement Sheet: existing sheet found | lead_id={$leadId}, sheet_id={$placementSheetId}");
return $this->downloadPlacementExcel($sheetLib, $placementSheetId, $leadId);
}
// QCR sheet must exist to create placement
$qcrSheetId = $leadMisc['qcr_sheet_id'] ?? null;
if (empty($qcrSheetId)) {
$this->myLogger->logme('error', "Placement Sheet: qcr_sheet_id missing in leads.misc | lead_id={$leadId}");
throw new \RuntimeException('QCR sheet not found. Please create the QCR sheet first.');
}
// Get QCR filename and replace "QCR" with "Placement"
$qcrFileName = $sheetLib->getFileName($qcrSheetId);
$placementFileName = str_ireplace('QCR', 'Placement', $qcrFileName);
$this->myLogger->logme('error', "Placement Sheet: renaming | qcr_name={$qcrFileName}, placement_name={$placementFileName}");
// Load folder ID from config
$parentFolderId = null;
try {
$rfqConfig = config('RfqConfig');
if ($rfqConfig) {
$parentFolderId = $rfqConfig->rfqParentFolderId ?? null;
}
} catch (\Throwable $e) {
$this->myLogger->logme('error', 'Placement Sheet: error loading RfqConfig | ' . $e->getMessage());
}
if (! $parentFolderId) {
throw new \RuntimeException('Google config not correct, contact administrator');
}
// Copy QCR sheet as placement
$placementSheetId = $sheetLib->copyTemplate($qcrSheetId, $placementFileName, $parentFolderId);
$this->myLogger->logme('error', "Placement Sheet: copied from QCR | lead_id={$leadId}, qcr_sheet_id={$qcrSheetId}, placement_sheet_id={$placementSheetId}");
// Apply same permissions as QCR (editors: sales team, viewers: rfq_qcr_viewers)
$salesTeam = $this->userModel
->select('user_profiles.email')
->join('user_teams', 'user_profiles.id = user_teams.user_id')
->where('user_teams.team_id', 5)
->where('user_teams.is_active', 1)
->where('user_profiles.is_active', 1)
->findAll();
$editorEmails = array_values(array_filter(array_unique(array_column($salesTeam, 'email'))));
$viewerEmails = [];
if (! empty($lead_data['rfq_qcr_viewers'])) {
$decoded = json_decode($lead_data['rfq_qcr_viewers'], true);
if (is_array($decoded)) {
$viewerEmails = array_values(array_filter(array_unique($decoded)));
}
}
if (! empty($editorEmails) || ! empty($viewerEmails)) {
try {
$sheetLib->applyPermissions($placementSheetId, [
'editors' => $editorEmails,
'viewers' => $viewerEmails,
]);
$this->myLogger->logme('error', "Placement Sheet: permissions applied | sheet_id={$placementSheetId}, editors=" . count($editorEmails) . ", viewers=" . count($viewerEmails));
} catch (\Throwable $e) {
$this->myLogger->logme('error', "Placement Sheet: failed to apply permissions | sheet_id={$placementSheetId}, error=" . $e->getMessage());
}
}
// Save placement_sheet_id to leads.misc
$leadMisc['placement_sheet_id'] = $placementSheetId;
$this->leadsModel->update($leadId, [
'misc' => json_encode($leadMisc),
]);
$this->myLogger->logme('error', "Placement Sheet: saved to leads.misc | lead_id={$leadId}, placement_sheet_id={$placementSheetId}");
return $this->downloadPlacementExcel($sheetLib, $placementSheetId, $leadId);
}
/**
* Download a placement Google Sheet as Excel to tmp directory.
*/
private function downloadPlacementExcel(GoogleSheetLib $sheetLib, string $sheetId, int $leadId): array
{
$binary = $sheetLib->downloadExcel($sheetId);
if (! $binary) {
$this->myLogger->logme('error', "Placement Sheet: empty content from Google | lead_id={$leadId}, sheet_id={$sheetId}");
throw new \RuntimeException('Empty content from Google Sheet for placement');
}
$filename = "lead_{$leadId}_placement_" . date('Ymd_His') . '.xlsx';
$uploadDir = WRITEPATH . 'tmp/';
$uploadFilePath = $uploadDir . $filename;
if (! is_dir($uploadDir)) {
if (! mkdir($uploadDir, 0777, true) && ! is_dir($uploadDir)) {
throw new \RuntimeException('Failed to create temporary directory');
}
}
$bytes = file_put_contents($uploadFilePath, $binary);
if ($bytes === false) {
throw new \RuntimeException('Failed to write placement Excel file');
}
$this->myLogger->logme('error', "Placement Sheet: downloaded | lead_id={$leadId}, path={$uploadFilePath}");
return [
'filePath' => $uploadFilePath,
'fileName' => $filename,
];
}
public function generateViewPageHtml($policy_type_id, $data = [])
{
$data['insurer'] = $this->insurerBranchModel->getInsurerBranchesWithInsurerNames();

View File

@ -81,6 +81,53 @@ class GoogleSheetLib
return $response->getBody()->getContents();
}
/* ================= LIST FOLDER SHEETS ===================== */
/**
* Lists files inside a Google Drive folder and returns JSON with
* filename and sheet id (Drive file id / spreadsheet id).
*/
public function listFolderSheetIdsAsJson(string $folderId = ''): string
{
if ($folderId === '') {
$folderId = '19uySI-PSFQfFpZvMSnBtirXbwfCTXFBL';
}
$results = [];
$pageToken = null;
$folderIdEsc = str_replace("'", "\\'", $folderId);
$query = "'{$folderIdEsc}' in parents and trashed = false";
$listParams = [
'q' => $query,
'fields' => 'nextPageToken, files(id,name,mimeType)',
'pageSize' => 1000,
'supportsAllDrives' => true,
'includeItemsFromAllDrives' => true,
];
do {
if ($pageToken) {
$listParams['pageToken'] = $pageToken;
} else {
unset($listParams['pageToken']);
}
$response = $this->drive->files->listFiles($listParams);
foreach (($response->getFiles() ?? []) as $file) {
// "sheetId" here is the Drive file id (spreadsheetId for Google Sheets)
$results[] = [
'name' => (string) $file->getName(),
'sheetId' => (string) $file->getId(),
];
}
$pageToken = $response->getNextPageToken();
} while (! empty($pageToken));
return json_encode($results, JSON_UNESCAPED_SLASHES);
}
/* ================= COPY TEMPLATE ================= */
public function copyTemplate(string $templateId, string $name, string $folderId): string
@ -100,6 +147,184 @@ class GoogleSheetLib
return $file->id;
}
/**
* Find/replace text across all sheets in the spreadsheet (batchUpdate).
*
* @param array<string, string> $replacements placeholder => replacement (empty string allowed)
*/
public function findAndReplaceAllSheets(string $spreadsheetId, array $replacements): void
{
if ($replacements === []) {
return;
}
$requests = [];
foreach ($replacements as $find => $replacement) {
if ($find === '') {
continue;
}
$requests[] = [
'findReplace' => [
'find' => (string) $find,
'replacement' => (string) $replacement,
'allSheets' => true,
'matchCase' => false,
'matchEntireCell' => false,
'includeFormulas' => true,
],
];
}
if ($requests === []) {
return;
}
$this->sheets->spreadsheets->batchUpdate(
$spreadsheetId,
new Google_Service_Sheets_BatchUpdateSpreadsheetRequest([
'requests' => $requests,
])
);
}
/**
* Quote a sheet title for A1 notation ranges.
*/
private function quoteSheetTitleForRange(string $title): string
{
return "'" . str_replace("'", "''", $title) . "'";
}
/**
* 1-based column index to Excel column letters (A, B, , Z, AA, ).
*/
private function columnNumberToLetters(int $n): string
{
$s = '';
while ($n > 0) {
$m = ($n - 1) % 26;
$s = chr(65 + $m) . $s;
$n = intdiv($n - 1, 26);
}
return $s;
}
/**
* Sheet title from an A1 range like 'RFQ Page'!A1:ZZ3000 or Sheet1!A1.
*/
private function parseSheetTitleFromBatchRange(string $rangeStr): string
{
$parts = explode('!', $rangeStr, 2);
$sheet = $parts[0] ?? '';
if ($sheet === '') {
return '';
}
if (str_starts_with($sheet, "'")) {
return str_replace("''", "'", trim($sheet, "'"));
}
return $sheet;
}
/**
* Locate a placeholder in any sheet and write a 2D table starting at that cell.
* First row of $tableValues is treated as headers.
*
* @param list<list<string|int|float>> $tableValues
*/
public function writeTableAtPlaceholder(string $spreadsheetId, string $placeholder, array $tableValues): bool
{
if ($placeholder === '' || $tableValues === []) {
return false;
}
$meta = $this->sheets->spreadsheets->get(
$spreadsheetId,
['fields' => 'sheets(properties(title))']
);
$titles = [];
foreach ($meta->getSheets() ?? [] as $sheet) {
$props = $sheet->getProperties();
if ($props !== null) {
$titles[] = (string) $props->getTitle();
}
}
if ($titles === []) {
return false;
}
$ranges = [];
// Use row-based range so API returns row 1 == index 0 (leading empty rows are not dropped).
foreach ($titles as $title) {
$ranges[] = $this->quoteSheetTitleForRange($title) . '!1:3000';
}
$batch = $this->sheets->spreadsheets_values->batchGet($spreadsheetId, [
'ranges' => $ranges,
'majorDimension' => 'ROWS',
]);
$valueRanges = $batch->getValueRanges() ?? [];
foreach ($valueRanges as $vr) {
$title = $this->parseSheetTitleFromBatchRange((string) ($vr->getRange() ?? ''));
if ($title === '') {
continue;
}
$values = $vr->getValues() ?? [];
foreach ($values as $r => $row) {
if (! is_array($row)) {
continue;
}
foreach ($row as $c => $cell) {
if (! is_string($cell) && ! is_numeric($cell)) {
continue;
}
$text = (string) $cell;
if (! str_contains($text, $placeholder)) {
continue;
}
$startCol = $this->columnNumberToLetters((int) $c + 1);
$startRow = (int) $r + 1;
$q = $this->quoteSheetTitleForRange($title);
$range = "{$q}!{$startCol}{$startRow}";
$body = new Google_Service_Sheets_ValueRange([
'values' => $tableValues,
]);
$this->sheets->spreadsheets_values->update(
$spreadsheetId,
$range,
$body,
['valueInputOption' => 'USER_ENTERED']
);
return true;
}
}
}
return false;
}
/* ================= PERMISSIONS ================= */
public function applyPermissions(string $fileId, array $permissions)
@ -233,6 +458,18 @@ class GoogleSheetLib
return true;
}
/* ================= FILE NAME ================= */
public function getFileName(string $fileId): string
{
$file = $this->drive->files->get($fileId, [
'supportsAllDrives' => true,
'fields' => 'name',
]);
return $file->getName() ?? '';
}
/* ================= URL ================= */
public function sheetUrl(string $sheetId): string

View File

@ -109,6 +109,7 @@ class LeadsModel extends Model
'acm_id',
'policy_with_correction',
'agreed_percentage', 'misc',
'rfq_qcr_viewers',
];
// Callbacks

View File

@ -164,6 +164,9 @@ if (isset($selected_lead_type)) {
$('#client_id').select2();
$('#client_branch_id').select2();
$('#rfq_qcr_viewers').select2({
placeholder: "Select Viewers",
});
$('#salse_person_id').select2({
placeholder: "Select Sales Person",
});

View File

@ -236,13 +236,13 @@ table.dataTable tbody td {
<a href="#" class="dropdown-item btnEdit2 btnRfqSheetList" data-id="<?php echo $row['id']; ?>">
<i class="mdi mdi-note-text mr-2 text-muted font-18 vertical-middle"></i>RFQ
</a>
<!-- <?php if (! in_array($row['status'], ['queued', 'rfq_created', 'rfq_sent'])) {?>
<?php if (! in_array($row['status'], ['queued', 'rfq_created', 'rfq_sent'])) {?>
<?php if (in_array(get_role_id(), [1, 5, 2, 3]) || in_array(BUSINESS_SUPPORT_TEAM_ID, user_team())) {?>
<a href="<?php echo base_url('/rfq/nonEB/qcr/') . $row['id']; ?>" class="dropdown-item btnEdit3" data-id="<?php echo $row['id']; ?>">
<a href="#" class="dropdown-item btnEdit3 btnQcrSheetList" data-id="<?php echo $row['id']; ?>">
<i class="mdi mdi-note-text mr-2 text-muted font-18 vertical-middle"></i>QCR
</a>
<?php }?>
<?php }?> -->
<?php }?>
<?php if ($row['status'] != 'queued' ){?>
<a href="#" class="dropdown-item btnInternalMailList" data-id="<?php echo $row['id']; ?>">
@ -295,9 +295,34 @@ table.dataTable tbody td {
</div><!-- end col -->
</div>
<!-- External CC Disclaimer Confirmation Modal (z-index elevated to appear above other open modals) -->
<div class="modal fade" id="external_cc_disclaimer_modal" tabindex="-1" role="dialog" aria-hidden="true" data-backdrop="static" data-keyboard="false" style="z-index: 1070;">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="mdi mdi-alert-outline text-warning mr-2"></i>External Mail Warning</h5>
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
</div>
<div class="modal-body">
<p>This mail will be sent to <strong>external email address(es)</strong>. Please review before proceeding.</p>
<p id="external_cc_disclaimer_list" class="text-muted small mb-0"></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="confirm_external_send_btn">Yes, Send Mail</button>
</div>
</div>
</div>
</div>
<!-- Hidden field to hold selected lead id for mail modals -->
<input type="hidden" id="lead_id" value="">
<!-- Sheet creation wait message (shown over loader overlay) -->
<div id="sheet-wait-msg" style="display:none; position:fixed; z-index:99999; left:0; right:0; bottom:calc(50% - 70px); text-align:center; color:#fff; font-size:14px; pointer-events:none;">
Creating RFQ/QCR file, this may take a while, please wait...
</div>
<div class="modal fade" id="New_Lead_modal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
@ -486,10 +511,28 @@ table.dataTable tbody td {
<h4>Attachments Files</h4>
<hr>
<div class="form-group" id="placement_mail_attachment">
<!-- <div class="form-row">
<?php echo isset($attachment_html) && !empty($attachment_html) ? $attachment_html : ''; ?>
</div> -->
<div class="attachment-upload-wrap" data-attachment-target="#placement_mail_attachment">
<div class="form-row align-items-end">
<div class="form-group col-md-4 mb-2">
<label>Document Label <span class="text-danger">*</span></label>
<input type="text" class="form-control att-docs-name" placeholder="Enter document label">
</div>
<div class="form-group col-md-5 mb-2">
<label>File <span class="text-danger">*</span></label>
<input type="file" class="form-control att-file-input">
</div>
<div class="form-group col-md-3 mb-2">
<label>&nbsp;</label><br>
<button type="button" class="btn btn-secondary btn-sm btn-upload-att">Upload & Add</button>
</div>
</div>
</div>
<div class="form-group" id="placement_mail_attachment"></div>
<div class="form-group" id="external_cc_placement_wrap">
<label for="external_cc_placement">External CC <small class="text-muted">(comma-separated email addresses)</small></label>
<input type="text" class="form-control" id="external_cc_placement" placeholder="e.g. john@external.com, jane@external.com">
</div>
<!-- if status is won disable the button -->
@ -522,12 +565,34 @@ table.dataTable tbody td {
<h4>Attachments Files</h4>
<hr>
<div class="attachment-upload-wrap" data-attachment-target="#insurer_or_clinet_mail_attachment">
<div class="form-row align-items-end">
<div class="form-group col-md-4 mb-2">
<label>Document Label <span class="text-danger">*</span></label>
<input type="text" class="form-control att-docs-name" placeholder="Enter document label">
</div>
<div class="form-group col-md-5 mb-2">
<label>File <span class="text-danger">*</span></label>
<input type="file" class="form-control att-file-input">
</div>
<div class="form-group col-md-3 mb-2">
<label>&nbsp;</label><br>
<button type="button" class="btn btn-secondary btn-sm btn-upload-att">Upload & Add</button>
</div>
</div>
</div>
<div class="form-group" id="insurer_or_clinet_mail_attachment">
<div class="form-row attachmet_row">
<?php echo isset($attachment_html) && ! empty($attachment_html) ? $attachment_html : ''; ?>
</div>
</div>
<div class="form-group" id="external_cc_client_wrap" style="display:none;">
<label for="external_cc_client">External CC <small class="text-muted">(comma-separated email addresses)</small></label>
<input type="text" class="form-control" id="external_cc_client" placeholder="e.g. john@external.com, jane@external.com">
</div>
<div class="form-group text-right m-b-0" id="hide_smbt_btn">
<button type="submit" class="btn btn-primary" onclick="constructURL(1)">Send Mail</button>
</div>
@ -595,6 +660,23 @@ table.dataTable tbody td {
<h4>Attachments Files</h4>
<hr>
<div class="attachment-upload-wrap" data-attachment-target="#internal_mail_attachment">
<div class="form-row align-items-end">
<div class="form-group col-md-4 mb-2">
<label>Document Label <span class="text-danger">*</span></label>
<input type="text" class="form-control att-docs-name" placeholder="Enter document label">
</div>
<div class="form-group col-md-5 mb-2">
<label>File <span class="text-danger">*</span></label>
<input type="file" class="form-control att-file-input">
</div>
<div class="form-group col-md-3 mb-2">
<label>&nbsp;</label><br>
<button type="button" class="btn btn-secondary btn-sm btn-upload-att">Upload & Add</button>
</div>
</div>
</div>
<div class="form-group" id="internal_mail_attachment">
<div class="form-row attachmet_row">
<?php echo isset($attachment_html) && ! empty($attachment_html) ? $attachment_html : ''; ?>
@ -602,7 +684,7 @@ table.dataTable tbody td {
</div>
<br>
<div class="form-group text-right m-b-0" id="hide_smbt_btn">
<button type="submit" class="btn btn-primary" onclick="constructURL(2)">Send Mail</button>
</div>
@ -768,6 +850,12 @@ table.dataTable tbody td {
createRfqSheetFromList(leadId);
}
}else if (this.classList.contains('btnQcrSheetList')) {
const leadId = this.getAttribute('data-id');
if (leadId) {
createQcrSheetFromList(leadId);
}
}else if (this.classList.contains('btnInternalMailList')) {
const leadId = this.getAttribute('data-id');
if (leadId) {
@ -807,6 +895,7 @@ table.dataTable tbody td {
href !== '#' &&
!this.classList.contains('btnEdit') &&
!this.classList.contains('btnRfqSheetList') &&
!this.classList.contains('btnQcrSheetList') &&
!this.classList.contains('btnInternalMailList') &&
!this.classList.contains('btnInsurerMailList') &&
!this.classList.contains('btnClientMailList') &&
@ -1123,11 +1212,15 @@ table.dataTable tbody td {
$('#myCenterModalLabelForClientAndInsurer').html(modalTitle);
console.log('modalTitle' + modalTitle);
url = '<?php echo base_url('leads/list/') ?>' + lead_id;
$('#external_cc_client_wrap').show();
$('#external_cc_client').val('');
} else {
modalTitle = 'Insurer Mail';
$('#myCenterModalLabelForClientAndInsurer').html(modalTitle);
console.log('modalTitle' + modalTitle);
url = '<?php echo base_url('util/getLevelContects') ?>';
$('#external_cc_client_wrap').hide();
$('#external_cc_client').val('');
}
ajaxRequestForGetMailData(url);
@ -1336,20 +1429,84 @@ table.dataTable tbody td {
}
}
function validateExternalCC(rawValue) {
if (!rawValue || rawValue.trim() === '') return { valid: true, emails: [] };
var emails = rawValue.split(',').map(function(e) { return e.trim(); }).filter(function(e) { return e !== ''; });
var emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
var invalid = emails.filter(function(e) { return !emailRegex.test(e); });
if (invalid.length > 0) {
return { valid: false, emails: emails, invalid: invalid };
}
return { valid: true, emails: emails };
}
function showExternalCCDisclaimer(emails, onConfirmCallback) {
var listHtml = 'Sending to: <strong>' + emails.join(', ') + '</strong>';
$('#external_cc_disclaimer_list').html(listHtml);
window._externalCCSendCallback = onConfirmCallback;
var disclaimerModalEl = document.getElementById('external_cc_disclaimer_modal');
var disclaimerModal = new bootstrap.Modal(disclaimerModalEl);
disclaimerModal.show();
// Elevate the latest backdrop so it sits above the parent mail modal
setTimeout(function() {
$('.modal-backdrop').last().css('z-index', 1065);
}, 150);
}
$(document).on('click', '#confirm_external_send_btn', function () {
// Close disclaimer modal first
$('#external_cc_disclaimer_modal').modal('hide');
// Execute the pending send callback
if (typeof window._externalCCSendCallback === 'function') {
window._externalCCSendCallback();
window._externalCCSendCallback = null;
}
});
function constructURL(url_type) {
if (url_type == 1) {
var validationStatus = checkMailValidation();
if (validationStatus) {
constructURL_ForInsurerAndClientMailSend();
} else {
if (!validationStatus) {
toastr.error("All Client Mail ID's Should Contain Same Domain", "Error");
return;
}
if (RFQ_or_QCR == 2) {
var externalCCRaw = $('#external_cc_client').val().trim();
var result = validateExternalCC(externalCCRaw);
if (!result.valid) {
toastr.error('Invalid email(s) in External CC: ' + result.invalid.join(', '), 'Error');
return;
}
if (result.emails.length > 0) {
showExternalCCDisclaimer(result.emails, function() {
constructURL_ForInsurerAndClientMailSend();
});
} else {
constructURL_ForInsurerAndClientMailSend();
}
} else {
constructURL_ForInsurerAndClientMailSend();
}
} else if (url_type == 2) {
constructURL_ForInternalMailSend();
} else if (url_type == 3) {
constructURL_ForPlacementMailSend();
var externalCCRaw = $('#external_cc_placement').val().trim();
var result = validateExternalCC(externalCCRaw);
if (!result.valid) {
toastr.error('Invalid email(s) in External CC: ' + result.invalid.join(', '), 'Error');
return;
}
if (result.emails.length > 0) {
showExternalCCDisclaimer(result.emails, function() {
constructURL_ForPlacementMailSend();
});
} else {
constructURL_ForPlacementMailSend();
}
}
}
@ -1386,6 +1543,8 @@ table.dataTable tbody td {
formData.append('selected_attachment_files', JSON.stringify(selectedFiles));
if (RFQ_or_QCR == 2) {
var externalCCClient = validateExternalCC($('#external_cc_client').val().trim()).emails;
formData.append('external_cc', JSON.stringify(externalCCClient));
formData.append('contact_mail', contact_mail);
formData.append('file_type', 'qcr');
formData.append('recipient_type', 'client');
@ -1519,6 +1678,10 @@ table.dataTable tbody td {
formData.append('policy_end_date', policy_end_date);
formData.append('policy_start_date', policy_start_date);
var externalCCPlacement = validateExternalCC($('#external_cc_placement').val().trim()).emails;
var mergedPlacementCC = (cc || []).concat(externalCCPlacement);
formData.set('cc', JSON.stringify(mergedPlacementCC));
ajaxRequest(formData);
}
@ -1526,6 +1689,7 @@ table.dataTable tbody td {
var apiURL = '<?php echo base_url("leads/createRfqSheet") ?>';
$('#sheet-wait-msg').fadeIn();
$('.loader').fadeIn();
$('.loader-mask').fadeIn();
@ -1536,6 +1700,7 @@ table.dataTable tbody td {
dataType: 'json',
success: function(res) {
$('#sheet-wait-msg').fadeOut();
$('.loader').fadeOut();
$('.loader-mask').delay(350).fadeOut('slow');
@ -1547,6 +1712,7 @@ table.dataTable tbody td {
},
error: function(xhr, status, error) {
$('#sheet-wait-msg').fadeOut();
$('.loader').fadeOut();
$('.loader-mask').delay(350).fadeOut('slow');
console.error(xhr.responseText);
@ -1556,6 +1722,101 @@ table.dataTable tbody td {
});
}
function createQcrSheetFromList(leadId) {
var apiURL = '<?php echo base_url("leads/createQcrSheet") ?>';
$('#sheet-wait-msg').fadeIn();
$('.loader').fadeIn();
$('.loader-mask').fadeIn();
$.ajax({
url: apiURL,
type: "GET",
data: { lead_id: leadId },
dataType: 'json',
success: function(res) {
$('#sheet-wait-msg').fadeOut();
$('.loader').fadeOut();
$('.loader-mask').delay(350).fadeOut('slow');
if (res.status === 'success' && res.code === 200 && res.sheet_url) {
window.open(res.sheet_url, '_blank');
} else {
toastr.error(res.message || 'Failed to create QCR sheet', 'Error');
}
},
error: function(xhr, status, error) {
$('#sheet-wait-msg').fadeOut();
$('.loader').fadeOut();
$('.loader-mask').delay(350).fadeOut('slow');
console.error(xhr.responseText);
console.error(status, error);
toastr.error('Failed to create QCR sheet', 'Error');
}
});
}
// Delegated click handler for all modal upload buttons
$(document).on('click', '.btn-upload-att', function () {
var wrap = $(this).closest('.attachment-upload-wrap');
var attachTarget = wrap.data('attachment-target');
var docsName = wrap.find('.att-docs-name').val().trim();
var fileInput = wrap.find('.att-file-input')[0];
var file = fileInput && fileInput.files[0];
var leadId = $('#lead_id').val();
if (!leadId) {
toastr.error('Lead not selected.', 'Error');
return;
}
if (!docsName) {
toastr.error('Please enter a document label.', 'Error');
return;
}
if (!file) {
toastr.error('Please select a file to upload.', 'Error');
return;
}
var formData = new FormData();
formData.append('lead_id', leadId);
formData.append('docs_name', docsName);
formData.append('attachment_file', file);
$('.loader').fadeIn();
$('.loader-mask').fadeIn();
$.ajax({
url: '<?php echo base_url("leads/uploadLeadAttachment") ?>',
type: 'POST',
data: formData,
processData: false,
contentType: false,
dataType: 'json',
success: function (res) {
$('.loader').fadeOut();
$('.loader-mask').delay(350).fadeOut('slow');
if (res.status === 'success') {
$(attachTarget).html(res.attachment_html || '');
wrap.find('.att-docs-name').val('');
wrap.find('.att-file-input').val('');
toastr.success('File uploaded successfully.', 'Success');
} else {
toastr.error(res.message || 'Upload failed.', 'Error');
}
},
error: function (xhr, status, error) {
$('.loader').fadeOut();
$('.loader-mask').delay(350).fadeOut('slow');
console.error(xhr.responseText, status, error);
toastr.error('Upload failed.', 'Error');
}
});
});
function fetchPlacementData(leadId, callback) {
var apiURL = '<?php echo base_url("rfq/placementData") ?>/' + leadId;
@ -1698,33 +1959,19 @@ table.dataTable tbody td {
$('#placement_mail_btn').prop('disabled', false);
}
// Populate TO with insurer contacts (id -> contact id)
// Populate TO with client contact from lead data
var $placementTo = $('#placement_to');
$placementTo.empty().append('<option value="">Select TO Mail</option>');
if (Array.isArray(data.insurer_contacts)) {
data.insurer_contacts.forEach(function (c) {
if (c.id) {
$placementTo.append(
$('<option>', {
value: c.id,
text: c.display || c.email || ''
})
);
}
});
}
// Select all insurer contacts by default, if any
var defaultTo = [];
if (Array.isArray(data.insurer_contacts)) {
data.insurer_contacts.forEach(function (c) {
if (c.id) {
defaultTo.push(String(c.id));
}
});
}
if (defaultTo.length) {
$placementTo.val(defaultTo);
if (data.client_contact && data.client_contact.email) {
var displayText = (data.client_contact.name || '') + ' - ' + data.client_contact.email;
$placementTo.append(
$('<option>', {
value: data.client_contact.email,
text: displayText.replace(/^- /, '')
})
);
// Select by default
$placementTo.val(data.client_contact.email);
}
window.RFQ_or_QCR = 2; // Placement behaves like QCR for file_type

View File

@ -371,6 +371,29 @@ var pageBackButton = '<a href="<?= base_url('leads/list') ?>" data-toggle="toolt
<!-- other row -->
<div class="card" id="collapseOne">
<div class="card-body">
<div class="form-row">
<?php
$savedViewers = [];
if (!empty($lead_edit_data['rfq_qcr_viewers'] ?? null)) {
$decoded = json_decode($lead_edit_data['rfq_qcr_viewers'], true);
if (is_array($decoded)) $savedViewers = $decoded;
}
?>
<div class="form-group col-md-12">
<label for="rfq_qcr_viewers">RFQ/QCR File Viewers <span class="text-danger">*</span></label>
<select class="form-control" id="rfq_qcr_viewers" name="rfq_qcr_viewers" multiple required>
<?php if (isset($salse_team)) { ?>
<?php foreach ($salse_team as $member) { ?>
<?php $email = $member['email'] ?? ''; if (empty($email)) continue; ?>
<option value="<?= htmlspecialchars($email) ?>" <?= in_array($email, $savedViewers) ? 'selected' : '' ?>>
<?= htmlspecialchars($member['first_name']) ?>
</option>
<?php } ?>
<?php } ?>
</select>
</div>
</div>
<div class="form-row">
<!-- <div class="form-group col-md-3">
@ -443,6 +466,14 @@ var pageBackButton = '<a href="<?= base_url('leads/list') ?>" data-toggle="toolt
<script>
$(document).ready(function() {
$('#rfq_qcr_viewers').parsley({
errorsContainer: function(ParsleyField) {
if (ParsleyField.$element.hasClass('select2-hidden-accessible')) {
return ParsleyField.$element.siblings('.select2-container');
}
return ParsleyField.$element;
}
});
$('#salse_person_id').parsley({
errorsContainer: function(ParsleyField) {
if (ParsleyField.$element.hasClass('select2-hidden-accessible')) {
@ -596,6 +627,9 @@ var pageBackButton = '<a href="<?= base_url('leads/list') ?>" data-toggle="toolt
return;
}
var rfq_qcr_viewers = $("#rfq_qcr_viewers").val();
console.log('rfq_qcr_viewers : ', rfq_qcr_viewers);
var salse_person_id = $("#salse_person_id").val();
console.log('salse_person_id : ', salse_person_id);
@ -639,6 +673,7 @@ var pageBackButton = '<a href="<?= base_url('leads/list') ?>" data-toggle="toolt
formData.append('claim_history',cliam_history_status);
// Append the JSON string to the FormData object
formData.append('rfq_qcr_viewers', JSON.stringify(rfq_qcr_viewers));
formData.append('salse_person_id', jsonString);
formData.append('custom_fields', JSON.stringify(customFieldData));

View File

@ -0,0 +1,61 @@
# Release Summary — 2026-03-20
---
## 1. QCR Sheet Creation (Non-EB Lead)
- Added **QCR** button in the Non-EB lead row dropdown (leads list page).
- Clicking QCR copies the existing RFQ Google Sheet for that lead, renames the copy by replacing "RFQ" with "QCR" in the filename, and places it in the same parent folder.
- The created sheet's Google File ID is saved to `leads.misc` (as `qcr_sheet_id`).
- Lead status is updated to `qcr_created` upon successful creation.
- If a QCR sheet already exists for the lead, the existing sheet URL is returned directly (no duplicate creation).
- A full-screen "Creating RFQ/QCR file, this may take a while, please wait..." overlay is shown during the AJAX call and hidden on completion.
---
## 2. RFQ/QCR Parent Folder ID — Environment Variable
- `RFQ_PARENT_FOLDER_ID` is now loaded from the `.env` file instead of being hardcoded in `RfqConfig.php`.
- Placeholder key added to `.env.sample` for environment setup reference.
---
## 3. RFQ/QCR File Viewers (Non-EB Lead Form)
- Added a **"RFQ/QCR File Viewers"** searchable multi-select field (Select2) on the Non-EB lead create/edit form, positioned before the Sales Person and Status fields.
- Populated with sales team email addresses.
- At least one viewer must be selected (validated on submit).
- Selected viewer emails are stored as a JSON array in a new database column `leads.rfq_qcr_viewers`.
- When an RFQ or QCR sheet is created, the selected viewer emails are applied as Google Sheet **viewer** permissions automatically.
> **DB Change Required:**
> ```sql
> ALTER TABLE leads ADD COLUMN rfq_qcr_viewers TEXT NULL;
> ```
---
## 4. Lead Attachment File Upload (All Mail Modals)
- Added a **file upload control** (label, file input, "Upload & Add" button) inside the attachment section of all four mail modals: Internal Mail, Client Mail, Insurer Mail, and Placement Mail.
- On upload:
- File is saved to the server under `uploads/lead_files/`.
- A record is inserted into the `lead_files` table.
- The attachment selection list in the modal is refreshed automatically to include the newly uploaded file.
---
## 5. External CC Field (Client Mail & Placement Mail)
- Added a manual **External CC** input field (comma-separated email addresses) to:
- **Client Mail modal** — visible only when sending a Client Mail (hidden for Insurer Mail).
- **Placement Mail modal** — always visible.
- On send:
1. The field value is validated as a comma-separated list of valid email addresses. Invalid entries show an error toast.
2. If valid external emails are present, a **Bootstrap confirmation modal** warns the user that the mail will be sent to external addresses.
3. On confirmation, the mail is sent; on cancel, the action is aborted.
- **Merge strategy:**
- **Placement Mail**: external CC emails are merged directly into the CC array (both are email strings) on the client side before the AJAX call.
- **Client Mail**: external CC emails are kept as a separate field and merged into the CC list on the server side, after internal user CC addresses are resolved from user IDs.
---

334
public/sheetid.json Normal file
View File

@ -0,0 +1,334 @@
[
{
"name": "Office Package",
"sheetId": "1TNYCzLLjC-RoEBXBKHwYMAeku5wU-_2UVSkhhPDD6hw"
},
{
"name": "RFQ - Aviation Loss of License Insurance Policy",
"sheetId": "1MQAzdirR-ecthYcmEuDSJ0CPgrSwMJgGfZaQAVTK1VE"
},
{
"name": "RFQ_Marine Hull - All Risk Insurance Policy",
"sheetId": "10_0j4IAC4IhFD216FqnNb41aUiuJIQUnJEhtncMnRzk"
},
{
"name": "RFQ_HH Policy",
"sheetId": "1k-wNlYx1TwCc9GDSlZ1pO3ZxMzQNFj32u2YOY-f7A88"
},
{
"name": "RFQ_Industrial All Risk",
"sheetId": "1Fnh0Qz1T9isSc2tFqgP4BK6Om6PJkMwWJTZowt1RTyU"
},
{
"name": "RFQ_FLOP",
"sheetId": "1SA4TP7qEkOugkacapzB5vQ8Odn5nXiTxaaGmix0p0mo"
},
{
"name": "RFQ_Boiler",
"sheetId": "1rLl0u7rYpeRXcZirxISTVORdsEGq-CRc7N5R8VlUh_g"
},
{
"name": "RFQ_Aviation Hull & War Risk",
"sheetId": "1tvZVH6o_YjEpehbG5FsZaMy61dH02uUJ2y2rcQFRp_M"
},
{
"name": "RFQ_Public Liability",
"sheetId": "117HIyRMctJbLtfll_wdVsEt5iKYRJjCwsZvmv_CBm74"
},
{
"name": "RFQ_Marine STOP",
"sheetId": "16WYQS1Xr9ssolXDJepL0UfQi6v1kS4FFMEjI8DdDnKA"
},
{
"name": "RFQ_Fidelity",
"sheetId": "1QGsLNYiQPxhk3Q3Jzz3HcFAxevvU0HuUbHhNtjmPuCs"
},
{
"name": "RFQ - Windmill Package Insurance",
"sheetId": "1UIMfsByDM_MhjC3bpSzX9GmQcvU2g8zzJQmQteDJKqM"
},
{
"name": "RFQ - Transport Operators Liability Insurance",
"sheetId": "14TEVfNDRSKDSkVpiAfRPYoNOAEnMX9dbd5tkdtmnNyA"
},
{
"name": "RFQ - Weather & Climate Risk Insurance Policy",
"sheetId": "1nEUmqks08CUDG3UH0LYH4C_BRlILDfVltaRNaAxDHCs"
},
{
"name": "RFQ - Speciality Insurance Policy",
"sheetId": "1c8h64urogtBlUOITJ678KxZCZjduw5lUW7QYCNFsqpI"
},
{
"name": "RFQ - Student Travel Insurance Policy",
"sheetId": "10EaGQSU7162pe-BqB8wX9MIQJaCu6YCHEfivMzvBzmI"
},
{
"name": "RFQ - Students Safety Insurance Policy",
"sheetId": "1s39fQO0yjJytMJOU5l_hqUqA7cYrIFUds59M8448Gww"
},
{
"name": "RFQ - Trade Credit Insurance Policy",
"sheetId": "1u74-M497HRUrq-bqksc2ISzSN87uMlerHNaRXAvEejY"
},
{
"name": "RFQ - Title & Deed Insurance Policy",
"sheetId": "16nK9DDYY2JGedCVDIipoA2IWxRMF9RC-W6T6kS7fZjQ"
},
{
"name": "RFQ - Special Contingency Insurance Policy",
"sheetId": "1SLqEpsHTImR7weNh4NT3euL_ojG6x2kSw2P3O_nepss"
},
{
"name": "RFQ - Surety Bond Insurance",
"sheetId": "1Yp1tvIuv4FQeW4bf011XIrepZLAtM1YZHLlSuWP_5WE"
},
{
"name": "RFQ_All Risk",
"sheetId": "1V-GfgiGMUR3sEbELbmmBLRqP7IBqEuEbuvrq-ue62Fs"
},
{
"name": "RFQ - Solar Panel Insurance Policy",
"sheetId": "1rl_d1zCGZWWvaiTDiT14MaSsGvR9AT26x67zAxMGaJA"
},
{
"name": "RFQ - Senior Citizen Travel Insurance Policy",
"sheetId": "1F7_bWtZVPOrC0DXSPjoklMZvWzW_LAqmC7EdWgnKhe8"
},
{
"name": "RFQ - Raasta Aapatti Kavach Personal Accident & Hospitalization Policy",
"sheetId": "1fG9QknL7E08VvJfbj61pb6pgBfY5wOaUM23HoVF_1X0"
},
{
"name": "RFQ - SAT Policy",
"sheetId": "18lk62vz7-6NiO1-brjYvNKZmE1T-H9usZx43yMhIHx8"
},
{
"name": "RFQ - Product Recall Insurance Policy",
"sheetId": "1wkn13u-2kfWkN1W7JhSc9x5MN6bXLmrD3fnG08_opxs"
},
{
"name": "RFQ - Pet Insurance Policy",
"sheetId": "1Jz7uRMd6Ln-8iKIj2hwVM7dfkarPbGcS8U2NHUP9hmo"
},
{
"name": "RFQ - Mobile & Smart Device Insurance",
"sheetId": "1n7HVOMzBciHLpcykLUkYSgAg4SrcjuG6598VS6SVbyI"
},
{
"name": "RFQ - Neon SIgn",
"sheetId": "1kBv9hFWh1-IIgvXXHhcxUj7aqP4E8rWrAdmcS7lVSpU"
},
{
"name": "RFQ - Port Package Insurance Policy",
"sheetId": "1hXA2RYCeUiMDFLBbNs_C3rdA2eIUlKBf63QiB2sbqJo"
},
{
"name": "RFQ - Shopkeeper_s Insurance Policy",
"sheetId": "1-l5VAocxtI1VXBESHeK9jC4nQ32Ifvbgv_zlCc71shM"
},
{
"name": "RFQ - Pedal Cycle",
"sheetId": "1lNRU0JQaIhR-cqICKAa9HMnaCkFM6si6szpZGCCggdg"
},
{
"name": "RFQ - Plate Glass",
"sheetId": "19Rt8pGT1FD2yAukecDR_10LZ1PQSbqrw2T8NCvms1Tk"
},
{
"name": "RFQ - MLOP Insurance",
"sheetId": "12Fdo42afIqvZcUCMOlcn2Gd-1CDHAfvF6CLDH1xGMBg"
},
{
"name": "RFQ - Mega Risk Insurance Policy",
"sheetId": "17a1eg9FghbrF2PRK-hMXs8Zm46VynW7ACRqjjh1WXcM"
},
{
"name": "RFQ - MBD",
"sheetId": "1EtZY6PqEvDUjIhi78_Bfr4IULk8IcJA3zE6FC508Sk0"
},
{
"name": "RFQ - Protection and Indemnity Insurance Policy",
"sheetId": "1RtesIhxwWq67lClvNYpVJyKCC6N2AETe_CEjRteChzw"
},
{
"name": "RFQ - Poultry Insurance Policy",
"sheetId": "1GuxPTtbYXSlktOLJEO6vyiEsBs-ic2v9dMV7IgH2Ecg"
},
{
"name": "RFQ - Marine Specific Voyage",
"sheetId": "1PlIEq-BoHKYAxIw6mWv0nyWE6QCmH8ZO16Tvi8mTTaU"
},
{
"name": "RFQ - Marine Open Policy",
"sheetId": "1ynm1JQAXczbka1ry3gRPghfGBnjnwgW3bfj0oJEtaq4"
},
{
"name": "RFQ - Marine Hull and Machinery Insurance Policy",
"sheetId": "1kP_4Esgq_5rxCAS-dRw5cdJl3njLcoKfHW20B4x5Xs8"
},
{
"name": "RFQ - Marine Hull - War Risk Insurance Policy",
"sheetId": "1NoLy0yHdPMnhPKPBwdfB9RCjqrNCdrcRD7eBRKqgO5Q"
},
{
"name": "RFQ - Marine Cum Erection Insurance Policy",
"sheetId": "1lNfSpu2ME30ALlAT6WDgeHf-DuCNQ5VyrS6kjiNUOOc"
},
{
"name": "RFQ - Marine Builders Risk Insurance Policy",
"sheetId": "1L5ndbcuH52pVbo3nMTtzXlKcmAQLavOg8v72x4fwd5g"
},
{
"name": "RFQ - Lift Insurance",
"sheetId": "19yECPX4-O0ZwyzfOr4IxKKnqFMbL64qYDoDwKbGKzHg"
},
{
"name": "RFQ - Group Travel Insurance Policy",
"sheetId": "1awP1gvx4tN33jDJa6eNBqoR6w10w7EbLxK5n6ff_pmE"
},
{
"name": "RFQ - Individual Travel Insurance Policy",
"sheetId": "1GFPUHB_cC6gFNX_guPphnZwqx-DJWBxv0NfgWyXOoZU"
},
{
"name": "RFQ - Home Insurance",
"sheetId": "1E-L0kyD2J7pXEBl5VIdallcDJpnSvE-LkcmOYA4nNTM"
},
{
"name": "RFQ - Livestock Insurance Policy",
"sheetId": "1OYLD5SclbhoDzIrY5f9Mbj-jAP107dsItPnT9a3bJGk"
},
{
"name": "RFQ - Kidnap and Ransom Insurance Policy",
"sheetId": "1HPt2xYCvFV-6Rw_hStiinvmxdJpXsHhm08vgP-SuoIo"
},
{
"name": "RFQ - Jewellery Package Policy",
"sheetId": "1cEr1IJos0N8Z2VXkR8vtfyP8yaw2qe5XFQHm-C0GbPk"
},
{
"name": "RFQ - FLOP",
"sheetId": "1_SDiTcBRMDTtuze2uPMq96s7ikl4IEje5aMvAbjaCuY"
},
{
"name": "RFQ - Gratuity Insurance Policy",
"sheetId": "1Db7c18YBzDDJQ2M7ZyzJaN-yUSfhYO4A90SZaQJ9b9M"
},
{
"name": "RFQ - Farmer_s Package Insurance Policy",
"sheetId": "10sNZmmfpyIfXzAZZr8mt5DrVR4OFttkVBbkKhLAytqE"
},
{
"name": "RFQ - Exhibition Insurance Policy",
"sheetId": "1fANuP8iKjGvdYkRCdXjLKfxt3jTNorXEOnmX23FHJ5w"
},
{
"name": "RFQ - Event Insurance",
"sheetId": "1IU-0FzPTy5JpL8HDSB0rfVlh-_AYwEGRAn35YhUh6F4"
},
{
"name": "RFQ - EEI",
"sheetId": "1Pp5tmJBDIwGnTK2qCMHBeXDL0Du6BM7njVIDH-s_5KE"
},
{
"name": "RFQ - Duty Package Policy",
"sheetId": "1j6aLHKLfG3JVRIxBqd8i57Q4FE_ZMoUbrUEGTC5vnKU"
},
{
"name": "RFQ - Drone Insurance",
"sheetId": "1lyq2oZqEDDncdFzNwYB5hJFrn4cO7AlOULbkNnJBZDw"
},
{
"name": "RFQ - Custom Duty",
"sheetId": "1YbfmBAHknv-GL8Ng0za7AjIW1mRJhfbABpOmeijDqws"
},
{
"name": "RFQ - Commercial Crime Policy",
"sheetId": "1CW9mbxomw16TLwtioypbn5pxIdDxWhJyNIYBNMNmHX0"
},
{
"name": "RFQ - Cattle Insurance Policy",
"sheetId": "1sb0zzEV_jTZoevVzVTXwkUZU2FXwVw4hC99cwwYjtRY"
},
{
"name": "RFQ - Charters Liability Insurance Policy",
"sheetId": "1Z1b4h_SqUC-NNFTFePdJucRD3uIswp-dIQyVMv5fibg"
},
{
"name": "RFQ - Compact Insurance Policy",
"sheetId": "1IgfwJkfsIBGzvXm-RWIVW1e8QRGDIyz-pYrzdxn7xzI"
},
{
"name": "RFQ - Burglary",
"sheetId": "1k5DPWbH6G3vBNKyueNjV9WJ02JzjoPWcwkNHwKyDnsU"
},
{
"name": "RFQ - Crop Insurance",
"sheetId": "1pv66ONaiDm-Q6_DdKl4HOKX_dgEy58Zx3eFDWxcDtdA"
},
{
"name": "RFQ - Clinical Trials Liability Insurance Policy",
"sheetId": "10LdVu03QEBUhTrKsfhXnsJMEfaypLGXrKrOtD1t2Kds"
},
{
"name": "RFQ - Bankers Indemnity Insurance Policy",
"sheetId": "1cDUKTDt6NfReGAdu_wx2BVJ0ymu1d1MVdpT8g8-eWzs"
},
{
"name": "RFQ - Cyber Policy",
"sheetId": "1dwRvokO1Y9JezANq-2_3jmEqxPX_p3qtqhhvUd4HsIA"
},
{
"name": "RFQ - Affinity Insurance Policy",
"sheetId": "1THrtbhZDrg7aAxSPqcOvHCowngg5xg8RDy1s53Z8tPk"
},
{
"name": "RFQ - Carriers Legal Liability Insurance Policy",
"sheetId": "10QvpiJHI96p4vPL-byzUvgiHB8rGlbUmc2IjsZmT6N0"
},
{
"name": "RFQ - Baggage Insurance Policy",
"sheetId": "1eFVCtWrUOB98Nl74cKXerlaw-odpotmE-KbaS9aLEPo"
},
{
"name": "RFQ - Aviation Spares All Risk Insurance Policy",
"sheetId": "1gVh0Qe-ZOJKavi6XSWVbCQ2OqOA6r3wGcimIAnXQv80"
},
{
"name": "RFQ - Advance Loss of Profit (ALOP) Insurance",
"sheetId": "12Gg94H4JeWc_QexrSoJB7hIhxyVg216cd5uvE3kj_8Y"
},
{
"name": "RFQ - Agriculture Pumpset Insurance Policy",
"sheetId": "1HwaJQqBLHvo9THSNPkHnZEDSPlJoO-c9ob4u0ImKrds"
},
{
"name": "RFQ - Aviation Airline Hull Spares Liability Insurance Policy",
"sheetId": "1rZvQJtOudmEwnyRZ_gHK0Yo57qXhYnjCPblpurD3PPs"
},
{
"name": "RFQ - Aviation Liability Insurance Policy",
"sheetId": "11OcS0uBo8I881T9DmxNE96s5W_30ktFQ2JB4l9b5bg8"
},
{
"name": "RFQ - Aviation Airline Hull War Excess Liability Insurance Policy",
"sheetId": "1NHnpzhRCkw09oGcZnDXbg_C6WPQAxsoPxqnJanZgSrQ"
},
{
"name": "FAR format(2)",
"sheetId": "1uHtzIna1z2FazkRNrj1Zh7qnt0LXYJko0BLO_jBBEwI"
},
{
"name": "RFQ - Aviation Hull Deductible Insurance Policy",
"sheetId": "1jJhP02u_tGYVzHwv9Xz8M4blf9031KMqsx6D5QWtBTk"
},
{
"name": "RFQ - Aviation - Personal Accident",
"sheetId": "1ytviSYcnf2GjJM-lmrGGDVFxMx0Wx9bwACap4FHVw3Y"
},
{
"name": "RFQ - Art & Valuables",
"sheetId": "1NFTDv4hGAKXEXyk3bnOjOVu4p1EVWp0RsGdSPylgPdk"
}
]