FreightFlow
Customer Portal
Sign In
Access your shipments, invoices, and quotes.
Password
Welcome
Your live shipment tracker
📋 Request a Quote
// ════════════════════════════════════════════════
let allQuoteRequests = [];
let activeQuotesTab = 'pending';
let portalSession = null;
// ── LOAD QUOTE REQUESTS ───────────────────────
async function loadQuoteRequests() {
const { data, error } = await db.from('quote_requests')
.select('*, customers(name, assigned_to)')
.order('created_at', { ascending: false });
if (!error) allQuoteRequests = data || [];
updateQuotesBadge();
}
function updateQuotesBadge() {
const badge = document.getElementById('quotes-sidebar-badge');
if (!badge) return;
const pending = allQuoteRequests.filter(q => q.status === 'Pending').length;
badge.textContent = pending;
badge.style.display = pending > 0 ? 'flex' : 'none';
}
// ── QUOTES PAGE ───────────────────────────────
function switchQuoteTab(tab) {
activeQuotesTab = tab;
['pending','all'].forEach(t => {
const btn = document.getElementById('qtab-' + t);
if (btn) { btn.style.background = t === tab ? 'var(--accent)' : 'transparent'; btn.style.color = t === tab ? 'white' : 'var(--text2)'; }
});
renderQuotesPage();
}
function renderQuotesPage() {
const pending = allQuoteRequests.filter(q => q.status === 'Pending');
const quoted = allQuoteRequests.filter(q => q.status === 'Quoted');
const approved = allQuoteRequests.filter(q => q.status === 'Approved');
const total = allQuoteRequests.length;
const winRate = total > 0 ? ((approved.length / total) * 100).toFixed(0) + '%' : '—';
const setEl = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
setEl('qkpi-pending', pending.length);
setEl('qkpi-quoted', quoted.length);
setEl('qkpi-approved', approved.length);
setEl('qkpi-winrate', winRate);
let toShow = activeQuotesTab === 'pending'
? allQuoteRequests.filter(q => q.status === 'Pending')
: allQuoteRequests;
// Broker visibility filter
if (currentUserRole !== 'admin') {
toShow = toShow.filter(q => {
const customer = allCustomers.find(c => c.id === q.customer_id);
return !customer?.assigned_to || customer.assigned_to === currentUser?.id;
});
}
const container = document.getElementById('quotes-list-container');
if (!container) return;
if (toShow.length === 0) {
container.innerHTML = `
📋
${activeQuotesTab === 'pending' ? 'No pending quote requests.' : 'No quote requests yet.'}
`;
return;
}
container.innerHTML = toShow.map(q => renderQuoteCard(q)).join('');
}
function renderQuoteCard(q) {
const customer = allCustomers.find(c => c.id === q.customer_id);
const statusColors = { 'Pending':'badge-amber','Quoted':'badge-blue','Approved':'badge-green','Customer Declined':'badge-red','Declined':'badge-gray' };
const isPending = q.status === 'Pending';
const isQuoted = q.status === 'Quoted';
return `
${isPending ? `
` : ''}
${isQuoted ? `
Quoted Rate
${fmt$(q.quoted_rate)}
${q.broker_note?`
${q.broker_note}
`:''}
Waiting on customer response
Sent ${fmtDate(q.quoted_at)}
` : ''}
${q.status === 'Approved' ? `
✅ Customer AcceptedRate: ${fmt$(q.quoted_rate)} · Carrier Pay: ${fmt$(q.quoted_carrier_pay)}
` : ''}
${q.status === 'Customer Declined' ? `
❌ Customer declined${q.declined_reason?': '+q.declined_reason:'.'}
` : ''}
`;
}
function calcQuoteMarginInline(quoteId) {
const rate = Number(document.getElementById('qprice-rate-' + quoteId)?.value) || 0;
const pay = Number(document.getElementById('qprice-pay-' + quoteId)?.value) || 0;
const el = document.getElementById('qmargin-' + quoteId);
if (!el) return;
if (rate > 0 && pay > 0) {
const gp = rate - pay, pct = gp / rate * 100;
const color = pct >= 18 ? 'var(--green)' : pct >= 12 ? 'var(--amber)' : 'var(--red)';
el.innerHTML = `
Margin: ${fmt$(gp)} (${pct.toFixed(1)}%)`;
} else { el.innerHTML = ''; }
}
function showDeclineForm(quoteId) {
const f = document.getElementById('decline-form-' + quoteId);
if (f) f.style.display = f.style.display === 'none' ? 'block' : 'none';
}
async function sendPricedQuote(quoteId) {
const rate = document.getElementById('qprice-rate-' + quoteId)?.value;
const pay = document.getElementById('qprice-pay-' + quoteId)?.value;
const note = document.getElementById('qprice-note-' + quoteId)?.value;
if (!rate) { toast('Please enter a customer rate', 'error'); return; }
const { error } = await db.from('quote_requests').update({
status: 'Quoted',
quoted_rate: parseFloat(rate),
quoted_carrier_pay: pay ? parseFloat(pay) : null,
broker_note: note || null,
quoted_at: new Date().toISOString(),
}).eq('id', quoteId);
if (error) { toast('Error: ' + error.message, 'error'); return; }
toast('Quote sent to customer!', 'success');
await loadQuoteRequests();
renderQuotesPage();
renderDashboardQuoteQueue();
}
async function declineQuote(quoteId) {
const reason = document.getElementById('decline-reason-' + quoteId)?.value?.trim();
const { error } = await db.from('quote_requests').update({ status: 'Declined', declined_reason: reason||null }).eq('id', quoteId);
if (error) { toast('Error: ' + error.message, 'error'); return; }
toast('Quote declined', 'info');
await loadQuoteRequests();
renderQuotesPage();
renderDashboardQuoteQueue();
}
async function convertQuoteToLoad(quoteId) {
const q = allQuoteRequests.find(x => x.id === quoteId);
if (!q) return;
const sv = (id, val) => { const el=document.getElementById(id); if(el) el.value=val||''; };
document.getElementById('load-id').value = '';
document.getElementById('load-modal-title').textContent = 'New Load from Quote';
sv('load-origin-city', q.origin_city); sv('load-origin-state', q.origin_state);
sv('load-dest-city', q.destination_city); sv('load-dest-state', q.destination_state);
sv('load-equipment', q.equipment_type); sv('load-commodity', q.commodity);
sv('load-weight', q.weight); sv('load-pickup', q.pickup_date);
sv('load-customer', q.customer_id); sv('load-rate', q.quoted_rate);
sv('load-carrier-pay', q.quoted_carrier_pay); sv('load-status', 'Available');
sv('load-notes', 'Converted from Quote ' + q.id + (q.notes?'\nCustomer notes: '+q.notes:''));
switchPage('loads');
setTimeout(() => openModal('load'), 200);
toast('Pre-filled from quote — review and save', 'info');
}
// ── DASHBOARD QUOTE QUEUE ─────────────────────
function renderDashboardQuoteQueue() {
let pending = allQuoteRequests.filter(q => q.status === 'Pending');
if (currentUserRole !== 'admin') {
pending = pending.filter(q => {
const c = allCustomers.find(x => x.id === q.customer_id);
return !c?.assigned_to || c.assigned_to === currentUser?.id;
});
}
const el = document.getElementById('dash-quote-queue');
if (!el) return;
if (pending.length === 0) { el.style.display = 'none'; return; }
el.style.display = 'block';
el.innerHTML = `
${pending.slice(0,3).map(q => {
const c = allCustomers.find(x => x.id === q.customer_id);
return `
${q.origin_city||'?'}, ${q.origin_state||''} → ${q.destination_city||'?'}, ${q.destination_state||''}
${c?.name||'Unknown'} · ${q.equipment_type||'—'} · ${fmtDate(q.pickup_date)}
`;
}).join('')}
${pending.length > 3 ? `
+${pending.length-3} more pending
` : ''}
`;
}
// ── PORTAL AUTH ───────────────────────────────
function showPortalLogin() {
const el = document.getElementById('portal-login-overlay');
if (el) el.style.display = 'flex';
}
function hidePortalLogin() {
const el = document.getElementById('portal-login-overlay');
if (el) el.style.display = 'none';
}
async function portalSignIn() {
const email = document.getElementById('portal-login-email')?.value?.trim();
const password = document.getElementById('portal-login-password')?.value;
const errorEl = document.getElementById('portal-login-error');
const btn = document.getElementById('portal-login-btn');
if (!email || !password) { errorEl.textContent='Please enter your email and password.'; errorEl.style.display='block'; return; }
if (btn) { btn.disabled=true; btn.textContent='Signing in…'; }
errorEl.style.display = 'none';
const { data, error } = await db.auth.signInWithPassword({ email, password });
if (error) {
errorEl.textContent = 'Incorrect email or password.'; errorEl.style.display='block';
if (btn) { btn.disabled=false; btn.textContent='Sign In →'; } return;
}
const { data: customer } = await db.from('customers').select('*').eq('portal_user_id', data.user.id).single();
if (!customer) {
errorEl.textContent = 'No customer portal account found for this email.'; errorEl.style.display='block';
if (btn) { btn.disabled=false; btn.textContent='Sign In →'; }
await db.auth.signOut(); return;
}
portalSession = { user: data.user, customer };
hidePortalLogin();
openCustomerPortal(customer.id);
}
async function portalForgotPassword() {
const email = document.getElementById('portal-login-email')?.value?.trim();
if (!email) { alert('Please enter your email address first.'); return; }
const { error } = await db.auth.resetPasswordForEmail(email);
if (error) { alert('Error: ' + error.message); return; }
alert('Reset link sent to ' + email + '. Check your inbox.');
}
// ── PORTAL QUOTE RESPONSE ─────────────────────
async function customerRespondToQuote(quoteId, response) {
const newStatus = response === 'accept' ? 'Approved' : 'Customer Declined';
const { error } = await db.from('quote_requests').update({
status: newStatus,
customer_response: response,
customer_responded_at: new Date().toISOString(),
}).eq('id', quoteId);
if (error) { alert('Error: ' + error.message); return; }
await loadQuoteRequests();
renderPortalQuotes();
renderDashboardQuoteQueue();
if (response === 'accept') alert('Quote accepted! Your broker will be in touch shortly.');
}
// ── PORTAL CITY AUTOCOMPLETE FIX ─────────────
// US_CITIES is array of arrays ['City','ST'] — this replaces the broken version
function portalCityAutoFixed(inputId, stateId, dropId, val) {
const drop = document.getElementById(dropId);
if (!drop) return;
if (!val || val.length < 2) { drop.innerHTML=''; drop.classList.remove('visible'); return; }
const matches = US_CITIES
.filter(([city]) => city.toLowerCase().startsWith(val.toLowerCase()))
.slice(0, 8);
if (!matches.length) { drop.innerHTML=''; drop.classList.remove('visible'); return; }
drop.innerHTML = matches.map(([city, state]) =>
`
${city}, ${state}
`
).join('');
drop.classList.add('visible');
}
// ── SUBMIT QUOTE FROM PORTAL ──────────────────
async function submitQuoteRequest() {
const originCity = document.getElementById('quote-origin-city')?.value?.trim();
const originState = document.getElementById('quote-origin-state')?.value?.trim();
const destCity = document.getElementById('quote-dest-city')?.value?.trim();
const destState = document.getElementById('quote-dest-state')?.value?.trim();
if (!originCity || !destCity) { alert('Please enter origin and destination cities.'); return; }
let customerId = portalSession?.customer?.id || allCustomers[0]?.id || null;
const payload = {
customer_id: customerId,
origin_city: originCity, origin_state: originState?.toUpperCase(),
destination_city: destCity, destination_state: destState?.toUpperCase(),
equipment_type: document.getElementById('quote-equipment')?.value,
pickup_date: document.getElementById('quote-pickup')?.value || null,
commodity: document.getElementById('quote-commodity')?.value || null,
weight: document.getElementById('quote-weight')?.value || null,
notes: document.getElementById('quote-notes')?.value || null,
status: 'Pending',
submitted_at: new Date().toISOString(),
};
const { error } = await db.from('quote_requests').insert(payload);
if (error) {
// Demo fallback
if (!window.portalQuotes) window.portalQuotes = [];
window.portalQuotes.unshift({ ...payload, id: 'Q-' + Date.now() });
} else {
await loadQuoteRequests();
renderDashboardQuoteQueue();
}
togglePortalQuoteForm();
['quote-origin-city','quote-origin-state','quote-dest-city','quote-dest-state',
'quote-commodity','quote-weight','quote-notes'].forEach(id => { const el=document.getElementById(id); if(el) el.value=''; });
toast('Quote request submitted!', 'success');
renderPortalQuotes();
}
// ── INVITE CUSTOMER TO PORTAL ─────────────────
async function inviteCustomerToPortal(customerId) {
if (!customerId) return;
const customer = allCustomers.find(c => c.id === customerId);
if (!customer) { toast('Customer not found', 'error'); return; }
if (!customer.email) { toast('Customer has no email on file', 'error'); return; }
if (!confirm(`Send portal invite to ${customer.name} (${customer.email})?`)) return;
try {
const { error } = await db.auth.admin.inviteUserByEmail(customer.email, {
data: { role: 'customer', customer_name: customer.name },
redirectTo: window.location.origin + '/?portal=true',
});
if (error) { toast('Use Supabase Dashboard → Auth → Users → Invite User to send the invite manually', 'info'); return; }
toast('Portal invite sent to ' + customer.email + '!', 'success');
await loadData();
} catch(e) { toast('Use Supabase Dashboard to invite: ' + e.message, 'info'); }
}
// ── WIRE UP EXISTING renderDashboard ─────────
const _ffOrigRenderDashboard = renderDashboard;
renderDashboard = function() {
_ffOrigRenderDashboard();
setTimeout(renderDashboardQuoteQueue, 150);
};
// ── WIRE UP loadData ──────────────────────────
const _ffOrigLoadData = loadData;
loadData = async function() {
await _ffOrigLoadData();
await loadQuoteRequests();
};
// ── WIRE UP renderCurrentPage ─────────────────
const _ffOrigRenderCurrentPage = renderCurrentPage;
renderCurrentPage = function() {
if (currentPage === 'quotes') { renderQuotesPage(); return; }
_ffOrigRenderCurrentPage();
};
// ── SHOW INVITE BUTTON WHEN EDITING EXISTING CUSTOMER ──
const _ffOrigEditCustomer = typeof editCustomer === 'function' ? editCustomer : null;
if (_ffOrigEditCustomer) {
editCustomer = async function(id) {
await _ffOrigEditCustomer(id);
const invBtn = document.getElementById('invite-portal-btn');
if (invBtn) invBtn.style.display = 'inline-flex';
};
}
// ── LOG LOAD EVENTS (if table missing, silently skip) ──
async function logLoadEvent(loadId, eventType, description) {
try {
await db.from('load_events').insert({
load_id: loadId, event_type: eventType, event_description: description,
performed_by: currentUser?.id,
performed_by_name: currentUser?.user_metadata?.full_name || currentUser?.email,
});
} catch(e) { /* non-critical */ }
}
async function loadEventHistory() {
if (!currentDetailLoadId) return;
const { data } = await db.from('load_events').select('*').eq('load_id', currentDetailLoadId).order('created_at', {ascending:false}).limit(20);
const el = document.getElementById('load-event-timeline');
if (!el) return;
if (!data || !data.length) { el.innerHTML='
No history yet
'; return; }
el.innerHTML = data.map(e => `
${new Date(e.created_at).toLocaleString('en-US',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
${e.event_description||e.event_type}
${e.performed_by_name?`
by ${e.performed_by_name}
`:''}
`).join('');
}
// ── RENDER PORTAL QUOTES (updated version) ────
function renderPortalQuotes() {
const el = document.getElementById('portal-quotes-list');
if (!el) return;
const quotes = window.portalCustomerQuotes || window.portalQuotes || [];
if (!quotes.length) { el.innerHTML='
No quote requests yet
'; return; }
el.innerHTML = quotes.map(q => {
const isQuoted = q.status === 'Quoted';
const statusColors = {'Pending':'#D97706','Quoted':'#2563EB','Approved':'#059669','Customer Declined':'#DC2626','Declined':'#64748b'};
const color = statusColors[q.status]||'#D97706';
return `
${isQuoted&&q.quoted_rate?`
Your Quote
$${Number(q.quoted_rate).toLocaleString()}
${q.broker_note?`
${q.broker_note}
`:''}
`:''}
Submitted ${fmtDate(q.submitted_at||q.created_at)}
`;
}).join('');
}
// ── MARK INVOICE PAID ─────────────────────────
async function markInvoicePaid(id) {
await db.from('invoices').update({ status:'Paid', paid_at:new Date().toISOString() }).eq('id',id);
toast('Invoice marked as paid!','success');
await loadData();
}
// ── INVOICE AGING ─────────────────────────────
function renderInvoiceAging() {
const now = new Date();
const bands = {current:[],d30:[],d60:[],d90:[]};
allInvoices.filter(i=>i.status!=='Paid').forEach(inv => {
if (!inv.due_date) { bands.current.push(inv); return; }
const days = Math.floor((now - new Date(inv.due_date)) / 86400000);
if (days<=0) bands.current.push(inv);
else if (days<=30) bands.d30.push(inv);
else if (days<=60) bands.d60.push(inv);
else bands.d90.push(inv);
});
const sum = arr => arr.reduce((s,i)=>s+(Number(i.amount)||0),0);
const setEl = (id,val)=>{const el=document.getElementById(id);if(el)el.textContent=val;};
setEl('aging-current',fmt$(sum(bands.current))); setEl('aging-current-count',bands.current.length+' invoices');
setEl('aging-30',fmt$(sum(bands.d30))); setEl('aging-30-count',bands.d30.length+' invoices');
setEl('aging-60',fmt$(sum(bands.d60))); setEl('aging-60-count',bands.d60.length+' invoices');
setEl('aging-90',fmt$(sum(bands.d90))); setEl('aging-90-count',bands.d90.length+' invoices');
}
function exportAging() { exportData('invoices'); }
// ── RENDER LOADS TABLE (for filterLoads) ──────
function renderLoadsTable(loads) {
const body = document.getElementById('loads-body'); if(!body) return;
if (!loads.length) { body.innerHTML=`
📦
No loads match your filters.
`; return; }
body.innerHTML=`
| Load # | Customer | Origin | Destination | Equipment | Pickup | Carrier | Rate | Margin | Status | |
${loads.map(l=>{const isPOD=l.status==='Delivered'&&!allInvoices.find(i=>i.load_id===l.id);return`| ${l.load_number} | ${l.customers?.name||'—'} | ${l.origin_city||'—'}, ${l.origin_state||''} | ${l.destination_city||'—'}, ${l.destination_state||''} | ${l.equipment_type||'—'} | ${fmtDate(l.pickup_date)} | ${l.carriers?.name||'—'} | ${fmt$(l.customer_rate)} | ${margin(l.customer_rate,l.carrier_pay)} | ${statusBadge(l.status)}${isPOD?' POD':''} | |
`;}).join('')}
`;
}
// ── SQL SCHEMA NOTE ───────────────────────────
/*
Run this in Supabase SQL editor to enable quote_requests:
create table if not exists quote_requests (
id uuid default gen_random_uuid() primary key,
customer_id uuid references customers(id) on delete cascade,
origin_city text, origin_state text,
destination_city text, destination_state text,
equipment_type text, pickup_date date,
commodity text, weight numeric, notes text,
status text default 'Pending',
quoted_rate numeric, quoted_carrier_pay numeric,
broker_note text, declined_reason text,
customer_response text, customer_responded_at timestamptz,
quoted_at timestamptz,
submitted_at timestamptz default now(),
created_at timestamptz default now()
);
alter table quote_requests enable row level security;
create policy "Allow all" on quote_requests for all using (true);
alter table customers
add column if not exists portal_user_id uuid references auth.users(id),
add column if not exists portal_invited_at timestamptz,
add column if not exists portal_status text default 'not_invited';
*/
applyTheme();
initRolePerms();
checkAuth();
loadRememberedEmail();
// Init city autocomplete after modals are in DOM
document.addEventListener('DOMContentLoaded', initCityAutocomplete);
setTimeout(initCityAutocomplete, 500);