From e8e79c3cea23f9bc2ebd34408e078630ef413b62 Mon Sep 17 00:00:00 2001 From: velz Date: Mon, 23 Mar 2026 18:33:11 +0530 Subject: [PATCH] FEAT_NON_EB_RFQ/QCR --- .env.sample | 3 +- app/Config/RfqConfig.php | 35 +- app/Config/Routes.php | 4 + app/Controllers/GoogleSheetController.php | 20 + app/Controllers/LeadsController.php | 692 ++++++++++++++++++++-- app/Libraries/GoogleSheetLib.php | 237 ++++++++ app/Models/LeadsModel.php | 1 + app/Views/leads_form_handler.php | 3 + app/Views/leads_list.php | 323 ++++++++-- app/Views/leads_non_eb.php | 35 ++ public/dev_logs/2026-03-20.md | 61 ++ public/sheetid.json | 334 +++++++++++ 12 files changed, 1672 insertions(+), 76 deletions(-) create mode 100644 public/dev_logs/2026-03-20.md create mode 100644 public/sheetid.json diff --git a/.env.sample b/.env.sample index 5c3f1bf8..d9ee1cd0 100755 --- a/.env.sample +++ b/.env.sample @@ -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 = diff --git a/app/Config/RfqConfig.php b/app/Config/RfqConfig.php index ca536901..0d0268cc 100644 --- a/app/Config/RfqConfig.php +++ b/app/Config/RfqConfig.php @@ -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 + */ + 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', + ]; } diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 09d117f3..779aabce 100755 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -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 diff --git a/app/Controllers/GoogleSheetController.php b/app/Controllers/GoogleSheetController.php index cfdd98fc..4a47f0c7 100644 --- a/app/Controllers/GoogleSheetController.php +++ b/app/Controllers/GoogleSheetController.php @@ -1,6 +1,7 @@ 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; + } } diff --git a/app/Controllers/LeadsController.php b/app/Controllers/LeadsController.php index fd736c2a..4047eec4 100644 --- a/app/Controllers/LeadsController.php +++ b/app/Controllers/LeadsController.php @@ -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, claims_table: ?list>, 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> $finyear + * + * @return list> + */ + 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(); diff --git a/app/Libraries/GoogleSheetLib.php b/app/Libraries/GoogleSheetLib.php index e5eb8caf..a44a86ba 100644 --- a/app/Libraries/GoogleSheetLib.php +++ b/app/Libraries/GoogleSheetLib.php @@ -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 $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> $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 diff --git a/app/Models/LeadsModel.php b/app/Models/LeadsModel.php index 507786c6..5ed42522 100644 --- a/app/Models/LeadsModel.php +++ b/app/Models/LeadsModel.php @@ -109,6 +109,7 @@ class LeadsModel extends Model 'acm_id', 'policy_with_correction', 'agreed_percentage', 'misc', + 'rfq_qcr_viewers', ]; // Callbacks diff --git a/app/Views/leads_form_handler.php b/app/Views/leads_form_handler.php index c9efd20a..578b1e1e 100644 --- a/app/Views/leads_form_handler.php +++ b/app/Views/leads_form_handler.php @@ -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", }); diff --git a/app/Views/leads_list.php b/app/Views/leads_list.php index ba238b68..2485c68e 100644 --- a/app/Views/leads_list.php +++ b/app/Views/leads_list.php @@ -236,13 +236,13 @@ table.dataTable tbody td { RFQ - + @@ -295,9 +295,34 @@ table.dataTable tbody td { + + + + + +