Leads
Written April 2026 — read this first
Still broadly right. Lead and quotation lists now page and search in the database (PLT-051). A lead has no owner column yet, so the Mine/All scope and the follow-up signals of INT-110 are not built — the sales dashboard says so where the data is missing.
Inbound inquiries that haven't become customers yet — website form submissions, WhatsApp pings, referrals. Tracked in one list, convertible to a quotation or a booking.
Scope
Only one page in this module: src/pages/leads/Leads.tsx (route /sales/leads). No detail route — the full edit experience is a dialog opened from the list.
Page — Leads.tsx
Route /sales/leads, gated by leads.view (src/App.tsx:159).
List
DataTable with search (name, phone, email, city), status, a Received date range and a sort (received, last updated, name, status); the name, status and received columns sort on a click (UX-030). All of it runs in the database and stays in the page address. Columns: lead name + phone, travel type + group size, linked group name, status badge, received date, view action. The paging bar under the list shows rows per page (25, 50 or 100), which leads are on screen out of how many ("1–25 of 312") and first, previous, next and last page — even when they all fit on one page.
The list opens with one request to the database, leads_list_page
(PRF-010): the first page, the
total, the names of whoever marked a lead lost, and the departures the convert dialog
offers. Before, it made 5 requests, 3 one after another. A lead opened from a link
(?lead=, as the work inbox does) is one more request beside it, not after it (before: 8
requests, 3 one after another). Rows are read as you, through the same row security as
before.
Status enum: new, contacted, qualified, converted, closed, lost.
lost was added by SAL-001. Before it there was no
word for a lead that went nowhere, so a dead enquiry was filed as "closed" and looked the
same as one worked to a finish.
What a customer can ask for is one list, ENQUIRY_TRIP_TYPES in
src/data/tourCategories.ts, shared by this screen and the public enquiry form: Umrah, Hajj,
Umrah with Ziyarat, Ziyarat, a tour within India, a tour abroad. The stored value is the
group type itself (UMRAH, HAJ, UMRAH_ZIYARAT, ZIYARAT, GENERAL_DOMESTIC,
GENERAL_INTERNATIONAL), so an enquiry is asked for in the same words the departure it
becomes is recorded in.
The public form used to offer umrah, hajj and umrah_plus — words that existed nowhere
else in the system, so a ziyarat or a domestic tour could not be asked for at all. Leads
stored with the old words still read correctly; umrah_plus shows as "Umrah (better grade
asked for)", which is what the owner confirmed it meant (2026-09-23). A package grade is not
modelled yet, so that is recorded as a request in the enquiry and nothing more.
Create lead
Header "Add Lead" button — gated by leads.edit (Leads.tsx:255-260). Fields:
fullName,phone,email,city.travelType(one of the four above).groupSize— integer.preferredMonth— free text.message— long text.
The form has no file uploader and no passenger list — an enquiry made on the website can carry both, and they are created by the public lead-intake edge function.
Detail dialog
Opens when "View" is clicked. Shows all captured fields, the travellers the enquirer named, any files they uploaded, two conversion buttons and an in-dialog status updater.
Passengers named on the enquiry
The website form asks for each traveller — first and last name, passport number, date of birth, gender, phone — and the dialog lists them in the order they were typed (SAL-002). Before this the names were posted and dropped: a family of twelve typed twelve names and the lead kept none, so staff asked again on the phone.
They are stored on the lead as a JSON list (Lead.passengers), which is what they are:
unverified text from a public form. They are not passenger records, hold nothing, and are not
matched to a customer. The passport number is shown by its last 4 characters only — a lead is
not the passenger record (AUD-020).
An enquiry can name at most 20 travellers; supabase/functions/lead-intake/validate.ts trims
and length-caps every field and refuses a list longer than that, and the database carries the
same cap as a CHECK constraint.
Converting the lead to a booking carries these names onto the booking as its travellers, the lead's customer first (SAL-002). They are checked like any traveller before the booking goes for approval. Not built: a named traveller is not matched against existing customers (SAL-OPEN-2). A lead created by hand on this screen has no passenger list at all.
Files the enquirer uploaded
The website sends the files with the enquiry itself; lead-intake stores them on the company
Shared Drive under Enquiries / the enquirer's name, only after the captcha has passed
(ACC-074). Open next to a file shows it in a viewer on this screen; drive-file checks
leads.view and hands out a link that lives ten minutes
(AUD-021). The lead keeps only the Drive
reference, never a URL. A file from before 27 Sep 2026 was never stored (the bucket the website
wrote to did not exist) and says so.
Lead → customer / quotation / booking conversion
Two conversion paths, both live on the detail dialog (Leads.tsx:381-389):
Convert to quotation (convertLeadToQuotation, Leads.tsx:201-209)
Service: src/services/leadService.ts. Fields collected on the convert dialog:
totalAmount,currency.validUntil(date).notes.discount.
Creates a Quotation row linked to the (newly-created or matched) customer. Permission: quotations.create.
Convert to booking (convertLeadToBooking, Leads.tsx:215-223)
Requires a group selection — groupId is required (Leads.tsx:211-214). Fields:
totalAmount,currency.groupId— the travel group this booking belongs to.paymentPolicy—partialor full.paidAmount.notes.
Creates a Booking inside the selected TravelGroup, with an associated customer record. Permission: bookings.create.
The booking is priced at the total less the discount (the quotation's final total), and books
every traveller the enquirer named, the customer first — a family of five is five travellers, not one.
Customer creation is implicit during conversion
Converting a lead to a quotation or booking creates (or matches) a Customer row from the lead's contact fields — the lead itself is not a customer until it converts.
Status updates (updateLeadStatus)
The detail dialog has a Select that updates status independently of conversion. Gated by leads.edit.
Every change is confirmed and asks for a reason. Choosing Lost or Closed asks "Why did
we lose it?" and the API refuses the change without an answer of at least 3 characters
(SAL-001). The answer is stored on the lead as
lostReason, with lostAt and lostBy, and the detail dialog shows it back under
"Why we lost it" with the closer's name and the time. Moving the lead to any other status
clears all three — a lead being worked again is not a lost lead. The status change itself is
written to the audit trail either way.
A loss reason is free text. There is no fixed list of causes, so the losses cannot yet be counted by cause — see SAL-OPEN-1 in the rule file.
Permissions
Canonical reference: docs/PERMISSIONS.md §4.1 and §6.2.
| Action | Permission |
|---|---|
View /sales/leads |
leads.view |
| Create lead | leads.edit |
| Update status | leads.edit |
| Convert to quotation | quotations.create |
| Convert to booking | bookings.create |
No leads.create — creation is gated by leads.edit
The catalog only defines leads.view and leads.edit (PERMISSIONS.md §4.1). Creating a lead and updating its status share the same permission.
Roles holding leads.edit by default: SALES_MANAGER, SALES_EXEC, plus super-admin/admin tier (PERMISSIONS.md §5.2).
Related
- Customers — created implicitly by
convertLeadToBooking/convertLeadToQuotation. - Quotations — destination of
convertLeadToQuotation. - Bookings — destination of
convertLeadToBooking. - PERMISSIONS.md §6.2 — authoritative action matrix.