Skip to content

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 — partial or 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).

  • Customers — created implicitly by convertLeadToBooking / convertLeadToQuotation.
  • Quotations — destination of convertLeadToQuotation.
  • Bookings — destination of convertLeadToBooking.
  • PERMISSIONS.md §6.2 — authoritative action matrix.