-
+
All
-
Pending
-
Completed
+
Pending
+
Completed
- +
@@ -448,12 +482,15 @@ document.addEventListener('DOMContentLoaded', function () { // Single selects document.querySelectorAll('.searchable:not(.multi-searchable)').forEach(function(el) { let parentModal = el.closest('.modal'); - $(el).select2({ - placeholder: "Select..", + const select2Options = { allowClear: false, minimumResultsForSearch: 0, dropdownParent: parentModal ? $(parentModal) : $(document.body) - }); + }; + if (el.id !== 'memberFilter') { + select2Options.placeholder = "Select.."; + } + $(el).select2(select2Options); }); // Multi selects @@ -509,6 +546,7 @@ const activityIcons = { const salesManagerWithHeadIds = ; const API = ''; +const defaultFinancialYear = ''; let filter = 'all'; let lead_id = null; let global_lead_assigned_to = null; @@ -519,6 +557,58 @@ let limit = 10; let currentOffset = 0; const department = ''; +function getCurrentFY() { + const today = new Date(); + const year = today.getFullYear(); + const month = today.getMonth(); + const startYear = month >= 3 ? year : year - 1; + return startYear + '-' + String(startYear + 1); +} + +function initSalesToolbarFilters() { + const fySelect = document.getElementById('financialYearFilter'); + const params = new URLSearchParams(window.location.search); + + if (fySelect) { + let fy = params.get('fy') || defaultFinancialYear || getCurrentFY(); + const matched = Array.from(fySelect.options).some(opt => opt.value === fy); + if (!matched && fySelect.options.length > 0) { + fy = fySelect.options[0].value; + } + fySelect.value = fy; + } + + const statusParam = (params.get('status') || 'all').toLowerCase(); + const statusTab = Array.from(document.querySelectorAll('.filter-tabs .tab')) + .find(tab => (tab.dataset.filter || '').toLowerCase() === statusParam); + if (statusTab) { + document.querySelectorAll('.filter-tabs .tab').forEach(tab => tab.classList.remove('active')); + statusTab.classList.add('active'); + filter = statusParam; + } + + const memberSelect = document.getElementById('memberFilter'); + const memberId = params.get('member'); + if (memberSelect && memberId && Array.from(memberSelect.options).some(opt => opt.value === memberId)) { + memberSelect.value = memberId; + if (window.jQuery) { + $(memberSelect).trigger('change.select2'); + } + } +} + +function getSelectedMemberIds(defaultIds) { + const memberFilter = document.getElementById('memberFilter'); + if (memberFilter && memberFilter.value) { + return [memberFilter.value]; + } + return Array.isArray(defaultIds) ? defaultIds : []; +} + +function getSelectedFinancialYear() { + return document.getElementById('financialYearFilter')?.value || defaultFinancialYear || getCurrentFY(); +} + function openModal(id) { document.getElementById(id).classList.add('active'); resetFlatpicker(); } @@ -583,7 +673,11 @@ function setFilter(val, el) { document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); el.classList.add('active'); filter = val; - fetchActivities(); + const searchInput = document.getElementById('mainSearch'); + if (searchInput) { + searchInput.value = ''; + } + fetchActivities(false); } async function fetchActivities(isLoadMore = false) { @@ -592,6 +686,7 @@ async function fetchActivities(isLoadMore = false) { const btnLoadMore = document.getElementById('btn-load-more'); const spinner = document.getElementById('load-more-spinner'); const text = document.getElementById('load-more-text'); + const selectedMemberIds = getSelectedMemberIds(salesManagerWithHeadIds); // 1. Define the empty state HTML early so we can use it immediately if needed @@ -606,7 +701,7 @@ async function fetchActivities(isLoadMore = false) { console.log(salesManagerWithHeadIds); // return; // 2. 🛑 SHORT-CIRCUIT: If no sales manager IDs exist, show empty state and stop! - if (typeof salesManagerWithHeadIds === 'undefined' || salesManagerWithHeadIds.length === 0) { + if (selectedMemberIds.length === 0) { console.log("No Sales Manager IDs found. Skipping API call."); grid.innerHTML = emptyStateHTML; btnLoadMore.style.display = 'none'; // Hide the load more button @@ -631,11 +726,15 @@ async function fetchActivities(isLoadMore = false) { } // 5. Build URL with dynamic offset - let url = `${API}/activities?status=${filter === 'all' ? '' : filter}&search=${q}&limit=${limit}&offset=${currentOffset}`; - // url += `&assigned_to=${salesManagerIds.join(',')}`; // We already proved it exists above! - if (typeof salesManagerWithHeadIds !== 'undefined' && salesManagerWithHeadIds.length > 0) { - url += `&assigned_to=${salesManagerWithHeadIds.join(',')}`; - } + const params = new URLSearchParams({ + status: filter === 'all' ? '' : filter, + search: q, + limit, + offset: currentOffset, + assigned_to: selectedMemberIds.join(','), + financial_year: getSelectedFinancialYear() + }); + let url = `${API}/activities?${params.toString()}`; console.log("Fetching API:", url); @@ -1289,6 +1388,68 @@ function convertDBFormatted(input) { ); } +function csvEscape(value) { + const text = String(value ?? '').replace(/"/g, '""'); + return `"${text}"`; +} + +function formatIndianDate(value) { + if (!value) return ''; + const date = new Date(String(value).replace(' ', 'T')); + if (Number.isNaN(date.getTime())) return value; + + return date.toLocaleString('en-IN', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: true + }).replace(',', '').toUpperCase(); +} + +function downloadCsv(filename, rows) { + const csv = rows.map(row => row.map(csvEscape).join(',')).join('\n'); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = filename; + link.click(); + URL.revokeObjectURL(link.href); +} + +async function exportActivitiesCsv() { + const selectedMemberIds = getSelectedMemberIds(salesManagerWithHeadIds); + if (selectedMemberIds.length === 0) return toastr.warning('No members available to export'); + + const params = new URLSearchParams({ + status: filter === 'all' ? '' : filter, + search: document.getElementById('mainSearch')?.value || '', + limit: 10000, + offset: 0, + assigned_to: selectedMemberIds.join(','), + financial_year: getSelectedFinancialYear() + }); + + const res = await fetch(`${API}/activities?${params.toString()}`); + const json = await res.json(); + const rows = [ + ['Company', 'Activity Type', 'Status', 'Assigned To', 'Additional Members', 'Scheduled Date', 'Created At', 'Notes'], + ...(json.data || []).map(a => [ + a.company_name || '', + a.activity_type || '', + capitalize(a.status), + a.assigned_to_name || '', + a.additional_assigned_names || '', + formatIndianDate(a.scheduled_date), + formatIndianDate(a.created_at), + a.notes || a.completion_notes || '' + ]) + ]; + downloadCsv(`sales-activities-${getSelectedFinancialYear()}.csv`, rows); +} + +initSalesToolbarFilters(); fetchActivities(); \ No newline at end of file diff --git a/app/Views/sales/branch_level_dashboard_view.php b/app/Views/sales/branch_level_dashboard_view.php index 6fec92ed..74b44c21 100644 --- a/app/Views/sales/branch_level_dashboard_view.php +++ b/app/Views/sales/branch_level_dashboard_view.php @@ -67,6 +67,7 @@ .card-hero:nth-child(3)::before { background: linear-gradient(90deg, #fda085, #f6d365); } .card-hero:nth-child(4)::before { background: linear-gradient(90deg, #43e97b, #38f9d7); } .card-hero:hover { transform: translateY(-4px); box-shadow: 0 12px 24px rgba(0,0,0,0.09); } + .card-hero.stat-link { cursor: pointer; } .stat-icon { width: 42px; height: 42px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 18px; margin-bottom: 14px; } .stat-val { font-size: 30px; font-weight: 800; color: #1a202c; line-height: 1; } .stat-label { color: #8896aa; font-size: 13px; margin-top: 6px; font-weight: 600; } @@ -114,6 +115,8 @@ .status-not-a-prospects { background: #ffebee; color: #d32f2f; } .leads-table-wrap { max-height: 480px; overflow-y: auto; } .leads-table-wrap thead th { position: sticky; top: 0; background: white; z-index: 1; } + .activity-count-link { display: inline-block; color: #2563eb; cursor: pointer; text-decoration: underline; text-underline-offset: 2px; } + .activity-count-muted { display: inline-block; color: #8896aa; cursor: help; } .empty-state { text-align: center; padding: 50px 20px; color: #aaa; } .empty-icon { width: 70px; height: 70px; margin: 0 auto 16px; background: #f5f5f5; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 32px; } @@ -178,6 +181,14 @@ .ms-num { font-size: 19px; font-weight: 800; } .ms-lbl { font-size: 10px; color: #7c8db0; font-weight: 700; letter-spacing: .05em; margin-top: 2px; } .modal-body { padding: 22px 24px; overflow-y: auto; flex: 1; } + .target-edit-panel { display: grid; grid-template-columns: 1fr 170px 120px; gap: 12px; align-items: end; padding: 16px; margin-bottom: 18px; border: 1px solid #bfdbfe; border-radius: 14px; background: #eff6ff; } + .target-edit-title { font-size: 13px; font-weight: 800; color: #1e3a8a; margin-bottom: 4px; } + .target-edit-help { font-size: 11px; color: #64748b; font-weight: 600; } + .target-edit-input { width: 100%; height: 38px; border: 1px solid #93c5fd; border-radius: 9px; padding: 8px 12px; font-size: 14px; font-weight: 700; color: #0f172a; background: #fff; } + .target-edit-input:focus { outline: none; border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.14); } + .target-edit-btn { height: 38px; border: none; border-radius: 9px; background: #2563eb; color: #fff; font-size: 13px; font-weight: 800; cursor: pointer; transition: background .2s, transform .2s; } + .target-edit-btn:hover { background: #1d4ed8; transform: translateY(-1px); } + .target-edit-btn:disabled { background: #94a3b8; cursor: not-allowed; transform: none; } /* ── Modal Tabs ── */ .modal-tabs { display: flex; gap: 4px; background: #f1f5f9; border-radius: 10px; padding: 4px; margin-bottom: 20px; } @@ -219,7 +230,7 @@ .opp-card-lbl { font-size: 11px; color: #7c8db0; font-weight: 700; margin-top: 4px; letter-spacing: .04em; } @media(max-width:900px) { .list-col-head,.achieve-row { grid-template-columns: 220px 1fr 110px 110px 110px; } .col-lbl:nth-child(6),.col-lbl:nth-child(7),.achieve-row>*:nth-child(6),.achieve-row>*:nth-child(7) { display: none; } } - @media(max-width:640px) { .list-col-head { display: none; } .achieve-row { grid-template-columns: 1fr auto; gap: 12px; padding: 14px 16px; } .achieve-row>*:not(:nth-child(1)):not(:nth-child(7)) { display: none; } .section-head { flex-direction: column; align-items: flex-start; gap: 12px; } .modal-summary { grid-template-columns: repeat(2,1fr); } .opp-summary-cards { grid-template-columns: 1fr 1fr; } } + @media(max-width:640px) { .list-col-head { display: none; } .achieve-row { grid-template-columns: 1fr auto; gap: 12px; padding: 14px 16px; } .achieve-row>*:not(:nth-child(1)):not(:nth-child(7)) { display: none; } .section-head { flex-direction: column; align-items: flex-start; gap: 12px; } .modal-summary { grid-template-columns: repeat(2,1fr); } .target-edit-panel { grid-template-columns: 1fr; } .opp-summary-cards { grid-template-columns: 1fr 1fr; } }
@@ -243,22 +254,22 @@
-
+ -
+ -
+ -
+
- + + + + + + + -
+ +
+ 0): ?> + + + + +
+
@@ -470,11 +505,155 @@ const OPP_DATA = ; /* ── JS Helpers ── */ const ACT_ICONS = {Call:'📞',Email:'✉️',Meeting:'📅',Visit:'🚗',Demo:'🖥️','Share Docs':'📄','To Do':'✓'}; const ACT_COLORS = {Call:'#4f46e5',Email:'#10b981',Meeting:'#06b6d4',Visit:'#f59e0b',Demo:'#ec4899','Share Docs':'#f97316','To Do':'#64748b'}; -const fmt = v => '₹' + (v / 100000).toFixed(1) + 'L'; +const fmtIndianFull = value => '₹' + (Number(value) || 0).toLocaleString('en-IN', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 +}); +const fmt = value => { + const amount = Number(value) || 0; + let display = '₹0'; + if (Math.abs(amount) >= 100000) { + display = '₹' + (amount / 100000).toFixed(1) + 'L'; + } else if (Math.abs(amount) >= 1000) { + display = '₹' + (amount / 1000).toFixed(1) + 'K'; + } else if (amount !== 0) { + display = '₹' + amount.toLocaleString('en-IN', { maximumFractionDigits: 0 }); + } + return '' + display + ''; +}; const fmtFull = v => '₹' + Number(v).toLocaleString('en-IN'); const pct = (a, t) => t > 0 ? Math.min(100, Math.round(a / t * 100)) : 0; const badge = p => p >= 100 ? ['b-achieved','🏆 Achieved'] : p >= 75 ? ['b-ontrack','🎯 On Track'] : p >= 50 ? ['b-behind','⚡ Behind'] : ['b-atrisk','⚠️ At Risk']; const progColor= p => p >= 100 ? 'linear-gradient(90deg,#10b981,#34d399)' : p >= 75 ? 'linear-gradient(90deg,#06b6d4,#67e8f9)' : p >= 50 ? 'linear-gradient(90deg,#f97316,#fbbf24)' : 'linear-gradient(90deg,#ef4444,#fca5a5)'; +const SALES_LEADS_URL = ''; +const SALES_ACTIVITIES_URL = ''; +const SALES_TARGETS_URL = ''; + +function getSelectedDashboardFY() { + return document.getElementById('financial_year')?.value || getCurrentFY(); +} + +function goSalesPage(page, status, memberId) { + const url = new URL(page === 'activities' ? SALES_ACTIVITIES_URL : SALES_LEADS_URL, window.location.origin); + url.searchParams.set('fy', getSelectedDashboardFY()); + url.searchParams.set('status', status || 'all'); + if (memberId) { + url.searchParams.set('member', memberId); + } + window.location.href = url.toString(); +} + +function goLeadActivities(memberId) { + if (!memberId) return; + goSalesPage('activities', 'all', memberId); +} + +function goLeadDetail(leadId) { + if (!leadId) return; + const url = new URL(SALES_LEADS_URL, window.location.origin); + url.searchParams.set('fy', getSelectedDashboardFY()); + url.searchParams.set('status', 'all'); + url.searchParams.set('lead_id', leadId); + window.location.href = url.toString(); +} + +function showToast(type, message) { + if (window.toastr && typeof toastr[type] === 'function') { + toastr[type](message); + return; + } + alert(message); +} + +function refreshTargetInDashboard(userId, targetId, targetAmount) { + const member = TEAM.find(item => item.id == userId); + if (!member) return; + + member.target_id = targetId || member.target_id; + member.target_amt = targetAmount; + if (Array.isArray(member.splits)) { + member.splits.forEach(split => { + split.target = Number((targetAmount / 4).toFixed(2)); + }); + } + + if (OPP_DATA[userId]) { + OPP_DATA[userId].target_amt = targetAmount; + } + + renderBranchSummary(); + renderList(); +} + +async function getSavedTargetRecord(userId, fyYear, fallbackTarget, forceFetch = false) { + if (!forceFetch && fallbackTarget?.id && fallbackTarget?.target_amount) { + return fallbackTarget; + } + + const res = await fetch(`${SALES_TARGETS_URL}/user/${userId}`); + if (!res.ok) { + return fallbackTarget || {}; + } + + const json = await res.json(); + return (json.data || []).find(record => record.fy_year === fyYear) || fallbackTarget || {}; +} + +async function saveModalTarget(targetId, userId, fyYear) { + const input = document.getElementById('modalTargetAmount'); + const button = document.getElementById('btnSaveModalTarget'); + const amount = parseInt(input?.value || '0', 10); + + if (!amount || amount <= 0) { + input?.focus(); + showToast('warning', 'Please enter a valid target amount.'); + return; + } + + const originalText = button ? button.textContent : ''; + if (button) { + button.disabled = true; + button.textContent = 'Saving...'; + } + + try { + const isUpdate = !!targetId; + const res = await fetch(isUpdate ? `${SALES_TARGETS_URL}/${targetId}` : SALES_TARGETS_URL, { + method: isUpdate ? 'PUT' : 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user_id: userId, + fy_year: fyYear, + target_amount: amount + }) + }); + const result = await res.json(); + + if (!res.ok) { + throw new Error(result?.message || 'Unable to update target amount.'); + } + + const activeTabIndex = Array.from(document.querySelectorAll('.modal-tab')) + .findIndex(tab => tab.classList.contains('active')); + const updatedTarget = await getSavedTargetRecord(userId, fyYear, result?.data || {}, !isUpdate); + const updatedAmount = Number(updatedTarget.target_amount ?? amount); + + refreshTargetInDashboard(userId, updatedTarget.id || targetId, updatedAmount); + openModal(userId); + if (activeTabIndex > 0) { + const tabButton = document.querySelectorAll('.modal-tab')[activeTabIndex]; + if (tabButton) switchTab(activeTabIndex, tabButton); + } + + showToast('success', result?.message || (isUpdate ? 'Target amount updated successfully.' : 'Target amount created successfully.')); + } catch (err) { + showToast('error', err.message || 'Unable to save target amount.'); + if (button) { + button.disabled = false; + button.textContent = originalText || 'Save Target'; + } + } +} /* ── Date formatters ── */ const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; @@ -608,6 +787,19 @@ function openModal(id) { + '
' + fmt(rem) + '
REMAINING
' + '
' + p + '%
ACHIEVED %
'; + const hasTargetRecord = !!m.target_id; + const targetEditorHtml = + '
' + + '
' + + '
' + (hasTargetRecord ? 'Edit Target Amount' : 'Create Target Amount') + '
' + + '
' + (hasTargetRecord ? 'Update' : 'Create') + ' target for ' + m.first_name + ' - ' + currentFY + '
' + + '
' + + '
' + + '' + + '
' + + '' + + '
'; + /* ── Overall progress block (shared) ── */ const overallHtml = '
' @@ -767,7 +959,8 @@ function openModal(id) { /* ── Inject tabs ── */ document.getElementById('modalBody').innerHTML = - ' -->
-
+
@@ -160,9 +180,23 @@
- +
@@ -729,12 +763,15 @@ document.addEventListener('DOMContentLoaded', function () { // Single selects document.querySelectorAll('.searchable:not(.multi-searchable)').forEach(function(el) { let parentModal = el.closest('.modal'); - $(el).select2({ - placeholder: "Select..", + const select2Options = { allowClear: false, minimumResultsForSearch: 0, dropdownParent: parentModal ? $(parentModal) : $(document.body) - }); + }; + if (el.id !== 'memberFilter') { + select2Options.placeholder = "Select.."; + } + $(el).select2(select2Options); }); // Multi selects @@ -771,6 +808,7 @@ const API = ''; const SALES_CHECK_DUPLICATE_URL = ''; /** Normalized company name when edit modal opened — same name retyped after clear must not count as duplicate */ let trackerEditLeadOriginalCompanyNorm = ''; +const defaultFinancialYear = ''; let filter = 'all'; let lead_id = null; let global_lead_assigned_to = null; @@ -780,6 +818,58 @@ let currentPage = 1; let limit = 9; let currentOffset = 0; +function getCurrentFY() { + const today = new Date(); + const year = today.getFullYear(); + const month = today.getMonth(); + const startYear = month >= 3 ? year : year - 1; + return startYear + '-' + String(startYear + 1); +} + +function initSalesToolbarFilters() { + const fySelect = document.getElementById('financialYearFilter'); + const params = new URLSearchParams(window.location.search); + + if (fySelect) { + let fy = params.get('fy') || defaultFinancialYear || getCurrentFY(); + const matched = Array.from(fySelect.options).some(opt => opt.value === fy); + if (!matched && fySelect.options.length > 0) { + fy = fySelect.options[0].value; + } + fySelect.value = fy; + } + + const statusParam = (params.get('status') || 'all').toLowerCase(); + const statusTab = Array.from(document.querySelectorAll('.filter-tabs .tab')) + .find(tab => (tab.dataset.filter || '').toLowerCase() === statusParam); + if (statusTab) { + document.querySelectorAll('.filter-tabs .tab').forEach(tab => tab.classList.remove('active')); + statusTab.classList.add('active'); + filter = statusTab.dataset.filter || 'all'; + } + + const memberSelect = document.getElementById('memberFilter'); + const memberId = params.get('member'); + if (memberSelect && memberId && Array.from(memberSelect.options).some(opt => opt.value === memberId)) { + memberSelect.value = memberId; + if (window.jQuery) { + $(memberSelect).trigger('change.select2'); + } + } +} + +function getSelectedMemberIds(defaultIds) { + const memberFilter = document.getElementById('memberFilter'); + if (memberFilter && memberFilter.value) { + return [memberFilter.value]; + } + return Array.isArray(defaultIds) ? defaultIds : []; +} + +function getSelectedFinancialYear() { + return document.getElementById('financialYearFilter')?.value || defaultFinancialYear || getCurrentFY(); +} + function openModal(id) { document.getElementById(id).classList.add('active'); resetFlatpicker(); } // function closeModal(id) { document.getElementById(id).classList.remove('active'); } @@ -940,7 +1030,11 @@ function setFilter(val, el) { document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); el.classList.add('active'); filter = val; - fetchLeads(); + const searchInput = document.getElementById('mainSearch'); + if (searchInput) { + searchInput.value = ''; + } + fetchLeads(false); } // 1. Leads Grid Logic @@ -970,6 +1064,7 @@ async function fetchLeads(isLoadMore = false) { const btnLoadMore = document.getElementById('btn-load-more'); const spinner = document.getElementById('load-more-spinner'); const text = document.getElementById('load-more-text'); + const selectedMemberIds = getSelectedMemberIds(salesManagerHeadIds); // 1. Define the empty state HTML early so we can use it immediately if needed const emptyStateHTML = `
@@ -980,7 +1075,7 @@ async function fetchLeads(isLoadMore = false) {
`; // 2. 🛑 SHORT-CIRCUIT: If no sales manager IDs exist, show empty state and stop! - if (typeof salesManagerHeadIds === 'undefined' || salesManagerHeadIds.length === 0) { + if (selectedMemberIds.length === 0) { console.log("No Sales Manager IDs found. Skipping API call."); grid.innerHTML = emptyStateHTML; btnLoadMore.style.display = 'none'; // Hide the load more button @@ -1005,11 +1100,15 @@ async function fetchLeads(isLoadMore = false) { } // 5. Build URL with dynamic offset - let url = `${API}/leads?status=${filter === 'all' ? '' : filter}&search=${q}&limit=${limit}&offset=${currentOffset}`; - // url += `&assigned_to=${salesManagerHeadIds.join(',')}`; // We already proved it exists above! - if (typeof salesManagerHeadIds !== 'undefined' && salesManagerHeadIds.length > 0) { - url += `&assigned_to=${salesManagerHeadIds.join(',')}`; - } + const params = new URLSearchParams({ + status: filter === 'all' ? '' : filter, + search: q, + limit, + offset: currentOffset, + assigned_to: selectedMemberIds.join(','), + financial_year: getSelectedFinancialYear() + }); + let url = `${API}/leads?${params.toString()}`; console.log("Fetching API:", url); @@ -2394,7 +2493,78 @@ function convertDBFormatted(input) { seconds.padStart(2, '0') ); } -fetchLeads(); + +function csvEscape(value) { + const text = String(value ?? '').replace(/"/g, '""'); + return `"${text}"`; +} + +function capitalizeStatus(value) { + const text = String(value ?? '').trim(); + return text ? text.charAt(0).toUpperCase() + text.slice(1).toLowerCase() : ''; +} + +function formatIndianDate(value) { + if (!value) return ''; + const date = new Date(String(value).replace(' ', 'T')); + if (Number.isNaN(date.getTime())) return value; + + return date.toLocaleString('en-IN', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: true + }).replace(',', '').toUpperCase(); +} + +function downloadCsv(filename, rows) { + const csv = rows.map(row => row.map(csvEscape).join(',')).join('\n'); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = filename; + link.click(); + URL.revokeObjectURL(link.href); +} + +async function exportLeadsCsv() { + const selectedMemberIds = getSelectedMemberIds(salesManagerHeadIds); + if (selectedMemberIds.length === 0) return toastr.warning('No members available to export'); + + const params = new URLSearchParams({ + status: filter === 'all' ? '' : filter, + search: document.getElementById('mainSearch')?.value || '', + limit: 10000, + offset: 0, + assigned_to: selectedMemberIds.join(','), + financial_year: getSelectedFinancialYear() + }); + + const res = await fetch(`${API}/leads?${params.toString()}`); + const json = await res.json(); + const rows = [ + ['Company', 'Email', 'Phone', 'Status', 'Assigned To', 'Created At'], + ...(json.data || []).map(l => [ + l.company_name || '', + l.email || '', + l.phone || '', + capitalizeStatus(l.status), + l.assigned_to_name || '', + formatIndianDate(l.created_at) + ]) + ]; + downloadCsv(`sales-leads-${getSelectedFinancialYear()}.csv`, rows); +} + +initSalesToolbarFilters(); +fetchLeads().then(() => { + const leadId = new URLSearchParams(window.location.search).get('lead_id'); + if (leadId) { + viewDetail(leadId); + } +}); // ================================================================ // COMPANY SEARCH — Odoo-style autocomplete From a41d390768f9d3ef7f637cca49742d60e26460d8 Mon Sep 17 00:00:00 2001 From: Gowtham M Date: Mon, 11 May 2026 17:51:52 +0530 Subject: [PATCH 3/3] GWM : megre pdf --- app/Helpers/merge_pdf_helper.php | 101 ++++++++++++++++++++++++------- 1 file changed, 79 insertions(+), 22 deletions(-) diff --git a/app/Helpers/merge_pdf_helper.php b/app/Helpers/merge_pdf_helper.php index 9316017f..063de050 100644 --- a/app/Helpers/merge_pdf_helper.php +++ b/app/Helpers/merge_pdf_helper.php @@ -13,14 +13,14 @@ if (! defined('MERGED_CLAIM_FILE_TYPE')) { if (! function_exists('merge_ticket_pdfs')) { /** - * Merge all active PDF rows in claim_files for a given ticket_master id + * Merge all active PDF/image rows in claim_files for a given ticket_master id * into one combined PDF and register that PDF as a new claim_files row * with file_type = MERGED_CLAIM_FILE_TYPE. * * Source rows are picked from claim_files where: * - ticket_id = $ticket_master_id * - is_active = 1 - * - mime_type = 'application/pdf' + * - mime_type IN $opts['include_mime_types'] (default PDF/JPG/PNG) * - file_type IN $opts['include_file_types'] (default [2, 3]) * * @param int $ticket_master_id @@ -29,6 +29,7 @@ if (! function_exists('merge_ticket_pdfs')) { * @var int $created_by Override created_by user id on the inserted row. * @var int $ticket_type Default 1. Stored on the inserted claim_files row. * @var array $include_file_types Default [2, 3]. + * @var array $include_mime_types Default ['application/pdf', 'image/jpeg', 'image/png']. * } * @return array {status, merged_file_id, file_name, pages, source_count, message} */ @@ -39,6 +40,7 @@ if (! function_exists('merge_ticket_pdfs')) { 'created_by' => null, 'ticket_type' => 1, 'include_file_types' => [2, 3], + 'include_mime_types' => ['application/pdf', 'image/jpeg', 'image/png'], ]; $result = [ @@ -60,14 +62,14 @@ if (! function_exists('merge_ticket_pdfs')) { $rows = $claimFiles ->where('ticket_id', $ticket_master_id) ->where('is_active', 1) - ->where('mime_type', 'application/pdf') + ->whereIn('mime_type', $opts['include_mime_types']) ->whereIn('file_type', $opts['include_file_types']) ->orderBy('id', 'ASC') ->findAll(); if (empty($rows)) { $result['status'] = true; - $result['message'] = 'No PDF files to merge'; + $result['message'] = 'No PDF/image files to merge'; return $result; } @@ -75,7 +77,7 @@ if (! function_exists('merge_ticket_pdfs')) { . 'uploads' . DIRECTORY_SEPARATOR . 'claim_files' . DIRECTORY_SEPARATOR; - $sourcePaths = []; + $sourceFiles = []; foreach ($rows as $row) { $name = ! empty($row['url']) ? $row['url'] : ($row['file_name'] ?? ''); if (empty($name)) { @@ -84,18 +86,22 @@ if (! function_exists('merge_ticket_pdfs')) { // url column may sometimes hold a full URL; we only care about the file basename on disk. $full = $uploadDir . basename($name); if (is_file($full) && is_readable($full)) { - $sourcePaths[] = $full; + $sourceFiles[] = [ + 'path' => $full, + 'mime' => strtolower((string) ($row['mime_type'] ?? '')), + 'id' => $row['id'] ?? null, + ]; } else { - log_message('error', "merge_ticket_pdfs | missing PDF on disk | claim_file_id={$row['id']} | path={$full}"); + log_message('error', "merge_ticket_pdfs | missing file on disk | claim_file_id={$row['id']} | path={$full}"); } } - if (empty($sourcePaths)) { - $result['message'] = 'No readable PDF files on disk'; + if (empty($sourceFiles)) { + $result['message'] = 'No readable PDF/image files on disk'; return $result; } - $result['source_count'] = count($sourcePaths); + $result['source_count'] = count($sourceFiles); $tempDir = rtrim(WRITEPATH, '/\\') . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR . 'mpdf'; if (! is_dir($tempDir)) { @@ -112,18 +118,24 @@ if (! function_exists('merge_ticket_pdfs')) { ]); $totalPages = 0; - foreach ($sourcePaths as $src) { + foreach ($sourceFiles as $sourceFile) { + $src = $sourceFile['path']; try { - $pageCount = $mpdf->setSourceFile($src); - for ($p = 1; $p <= $pageCount; $p++) { - $tplId = $mpdf->importPage($p); - $size = $mpdf->getTemplateSize($tplId); - $mpdf->AddPageByArray([ - 'orientation' => ($size['width'] > $size['height']) ? 'L' : 'P', - 'sheet-size' => [$size['width'], $size['height']], - ]); - $mpdf->useTemplate($tplId); - $totalPages++; + if ($sourceFile['mime'] === 'application/pdf') { + $pageCount = $mpdf->setSourceFile($src); + for ($p = 1; $p <= $pageCount; $p++) { + $tplId = $mpdf->importPage($p); + $mpdf->AddPage(); + // adjustPageSize=true makes the output page match the imported page. + $mpdf->useTemplate($tplId, 0, 0, null, null, true); + $totalPages++; + } + } elseif (in_array($sourceFile['mime'], ['image/jpeg', 'image/png'], true)) { + if (merge_ticket_pdf_add_image_page($mpdf, $src)) { + $totalPages++; + } + } else { + log_message('error', "merge_ticket_pdfs | unsupported source mime {$sourceFile['mime']} | {$src}"); } } catch (\Throwable $e) { log_message('error', "merge_ticket_pdfs | failed to import {$src} | " . $e->getMessage()); @@ -131,7 +143,7 @@ if (! function_exists('merge_ticket_pdfs')) { } if ($totalPages === 0) { - $result['message'] = 'All source PDFs failed to import'; + $result['message'] = 'All source PDF/image files failed to import'; return $result; } @@ -192,3 +204,48 @@ if (! function_exists('merge_ticket_pdfs')) { return $result; } } + +if (! function_exists('merge_ticket_pdf_add_image_page')) { + /** + * Add an uploaded image as a single PDF page, preserving portrait/landscape + * orientation and fitting the image proportionally inside the page. + */ + function merge_ticket_pdf_add_image_page(Mpdf $mpdf, string $imagePath): bool + { + $imageInfo = @getimagesize($imagePath); + if (empty($imageInfo[0]) || empty($imageInfo[1])) { + log_message('error', "merge_ticket_pdfs | invalid image | {$imagePath}"); + return false; + } + + $imageWidthPx = (int) $imageInfo[0]; + $imageHeightPx = (int) $imageInfo[1]; + $isLandscape = $imageWidthPx > $imageHeightPx; + + $pageWidth = $isLandscape ? 297 : 210; + $pageHeight = $isLandscape ? 210 : 297; + $margin = 0; + + $availableWidth = $pageWidth - ($margin * 2); + $availableHeight = $pageHeight - ($margin * 2); + $scale = min($availableWidth / $imageWidthPx, $availableHeight / $imageHeightPx); + $drawWidth = $imageWidthPx * $scale; + $drawHeight = $imageHeightPx * $scale; + $x = ($pageWidth - $drawWidth) / 2; + $y = ($pageHeight - $drawHeight) / 2; + + $mpdf->AddPageByArray([ + 'orientation' => $isLandscape ? 'L' : 'P', + 'sheet-size' => 'A4', + 'margin-left' => 0, + 'margin-right' => 0, + 'margin-top' => 0, + 'margin-bottom' => 0, + 'margin-header' => 0, + 'margin-footer' => 0, + ]); + + $mpdf->Image($imagePath, $x, $y, $drawWidth, $drawHeight); + return true; + } +}