The Alhuda Travels app (native)
One phone app, built with Expo in apps/mobile, separate from the web ERP. It signs in
through the same door as everything else (auth-login) and shows one of four stacks,
chosen from who signed in: the staff ERP on a phone, the tour leader's field app,
the traveller's portal, and the business partner's portal (§8).
Every screen calls a database function or reads a table under row security — the same functions the desktop calls. The app hides what a person may not do; the database refuses the rest (ACC-001). Nothing is gated more loosely on a phone.
Rules: 20 · The traveller's app (TRV-001 … TRV-014),
FLD-001 … FLD-006. Permissions:
PERMISSIONS.md §6.5d. Build and secrets:
Android app → The native app. The web phone layout /m
(Phone app) stays and is not replaced by this.
1. Signing in
Every date in the app reads DD/MM/YYYY (27/09/2026), with a time DD/MM/YYYY HH:mm; date boxes are typed DD/MM/YYYY and put the slashes in themselves, and a date that does not exist is named under the box (UX-020). Every password box has an eye that shows or hides what was typed (UX-021).
The keyboard never covers the box you are typing in (UX-022). When the keyboard opens, the screen or sheet moves up by the keyboard's height and the box with the cursor is scrolled into the space left above it; moving to the next box scrolls again. A tap on a button while the keyboard is open presses the button; dragging the page puts the keyboard away. This holds on Android and iPhone, on every form screen (sign-in, create an account, register your agency, the authenticator code, my details, delete my account, apply for leave, new booking, partner new booking, passport scan, finance new entry, signing an agreement, new work item, the inventory forms, check-in, the guide editor, pay online) and in every bottom sheet (notes, I have paid, take a payment, requests and replies, decisions, role requests, family and emergency contact, the programme editor). Search boxes at the top of a list are not moved: the keyboard opens below them.
The app draws edge to edge on Android, so the phone no longer shrinks the window for the
keyboard by itself; the app pads the screen instead (src/components/KeyboardSafe.tsx,
src/lib/keyboard.ts). It is JavaScript only and reached installed phones as an
over-the-air update (§10); app.json was not changed.
The welcome screen offers three doors — I am travelling with Alhuda, I am a business
partner and Alhuda staff and tour leaders — and two links under the first: Create an
account and Browse tours without signing in. The partner door is the same portal door
as the traveller's (auth-login, audience portal) with the partner's words on it, and its
"Register your agency" link opens the partner sign-up (§8). The sign-in screen asks for the username or email and the password and calls auth-login
with the app's key (EXPO_PUBLIC_MOBILE_APP_KEY, checked against MOBILE_APP_KEY on the
function). A person with an authenticator app is asked for the code. The session is kept in
the app's own storage on the phone (AsyncStorage — a Supabase session is larger than the
secure store's 2 KB limit; the access token is short-lived and the refresh token rotates);
the profile (roles, permissions, kind) decides the stack:
| Who | Stack |
|---|---|
| Any staff role | Staff |
TOUR_LEADER (and no bookings.view) |
Tour leader |
CUSTOMER with a linked customer record |
Traveller |
AGENT (also when it holds TOUR_LEADER) |
Partner |
A tour leader who is also a traveller, a partner or a member of staff keeps their own stack
and reaches the groups they lead from Groups I lead — on My trip and Me (traveller), More
(partner) and More (staff). The row shows only for a login that holds TOUR_LEADER
(FLD-007). A tour leader with no other role
signs in at Alhuda staff and tour leaders with the email the office set up; that screen
says so.
Create account (TRV-007, TRV-016)
The screen opens on Mobile (WhatsApp); Email is the other tab.
Mobile (WhatsApp) (TRV-016): full name, mobile number (country code and number, sent as one E.164 number) and an optional email → Send code on WhatsApp; the 6-digit code → Continue; a password typed twice (each with the eye) → Create account. The account is made and the app opens the traveller's stack. The app sends its app key instead of the captcha, as for signing in. After "Send code" the screen says the same words whatever the number — a number that already has an account gets no code — and always offers Already have an account? Sign in · Forgot password (both open the sign-in screen, where Forgot password? is). A wrong or old code goes back to the code step. If WhatsApp codes are not switched on, the screen says so and offers Sign up with email instead. The account is a login only: the first Book this trip makes the customer record. No new native module: it ships over the air.
Signing up in the WhatsApp chat (TRV-017)
needs no app: the customer writes "sign up" to the business number and fills the form there.
The link to set the password opens the web page /customer/set-password (in the phone's
browser); the app has no screen of its own for it. After that they sign in to the app with
their mobile number and password.
Email (TRV-007): first and last name, mobile number (country code and number, stored as one E.164 number),
email, password (8 characters or more) and a tick for the privacy terms, which open the
privacy page. The customer-signup function makes the login — the User row with the name
and the number, and the CUSTOMER role — and mails the confirmation link; the screen says
"check your email". An address that already has an account gets the same words. Nothing is
kept on the phone to write later. A new account sees no booking until the office creates one
from its request or links one to it (TRV-007).
No customer record until the first booking (both ways, LC-006). Trips, requests and quotes read empty, and My details says "Your details will be added with your first booking." Book this trip makes the record; any other request before that is refused in the same words.
Browse without signing in (TRV-008)
Browse tours without signing in opens the same tours list as the signed-in Tours tab, with a card at the top that says booking needs an account and offers Sign in and Create account. A departure opens in full. Book this trip on it, with no session, asks the person to sign in or create an account instead of showing the form.
2. Staff
At most five tabs (UX-024): Home and More always, and between them Bookings, Groups, Operations and Approvals, in that order of priority, each only for a person who holds its permission. When a person has all four, Approvals steps down to a row on Home with its count, so the bar reads Home · Bookings · Groups · Operations · More. With room to spare Approvals keeps its tab, between Bookings and Groups. Dashboard is never a tab: Home opens it. Wherever the staff app names a customer, partner, employee or supplier it shows the party's code beside the name, and the users, partners and suppliers lists find a party by its code (PTY-006, Party codes).
- Home — the date and a greeting, a bell that opens Notifications (a burgundy dot
when something is unread), the Find box (below), and two big counts from the desktop's
badges (
badge_counts): Awaiting your approval (the sales check, the burgundy tile) and Payments to verify (the finance check). Each tile opens Approvals at its own section. Then My work (the first three of my jobs, late first — below), Leaving soon (the departures in the next 30 days), Needs my decision, the person's other Daily jobs with their counts, and Shortcuts: New booking (bookings.create), New partner (partners.create, PTR-097), Customers (customers.view) and Scan a passport. A departure's card shows when it leaves, a bar for its readiness and what is still to do; tapping it opens Readiness (below). The bar is readiness, not seats sold: the departures call does not return seat counts. On a tablet the sections sit in two columns (§13). Under the counts, My dashboard (anyone the Dashboard is for) and, when the tab bar has no room for it, Approvals with the number waiting (UX-024). - Dashboard — the numbers of the business for management (below). Opened from Home, with a back button; it is not a button in the tab bar.
- Bookings — the paged list as cards, filters by status; one booking (its edit, cancel and
refund actions are under Bookings below): who (with
customers.viewthe customer's name opens the customer; Call and WhatsApp in the top bar), the departure, total / paid / balance, the travellers (each with Call and WhatsApp when a number is on the traveller — PTY-008), the emergency contact, the passport submissions waiting for review (apply or reject,bookings.edit), Take payment (finance.payments.record, recorded pending — FIN-032). Total, paid, balance and each payment are shown in the booking's currency — ₹ only for a rupee booking, "SAR 50,000" for a riyal one. Take payment asks for the amount in that currency ("Amount received in SAR") and records it in that currency; the phone does not convert. On a booking not priced in rupees it says so in one line: rupees paid on it are recorded on the website (Record payment), which converts them at the rate in force on the date received (FIN-034). There is no Pay online on the staff booking screen: staff never start a Razorpay payment for a customer. Under Take payment the screen says "Customers and partners pay online from their own app or portal. Record a payment the customer made with Take payment." (DECIDED 2026-09-30 — FIN-032, TRV-009). Approve / Send back (approvals.approve), the departure, and Documents — the e-ticket sheet and hotel voucher(s) issued for the booking, with Issue e-ticket (tickets.view+bookings.view) and Issue hotel voucher (hotels.view+bookings.view) through the edge functionissue-document(TRV-013). Each asks Yes/No, shows the function's refusal as it comes (a traveller with no ticket number yet, no rooms assigned, not confirmed by finance), and opens the PDF once issued. A re-issue replaces the previous sheet. See Tickets → Issued documents. Tapping a document opens a sheet with Open, Share and WhatsApp the customer (Sharing a document). Payments — each verified payment has Receipt (finance.vieworbookings.view, FIN-045): the first tap hasissue-documentmake the company's receipt as a PDF (receipt number, date received, received from with the party code, booking, the amount in figures and in words, method, reference, verified by, balance after the payment); later taps hand back the same file. The same sheet then offers Open, Share and WhatsApp. A payment waiting to be verified or rejected has no Receipt. - Approvals — everything
dash_approvals_inboxgives the person, one section per kind, oldest first (ACC-020). The tab shows for anyone holding one of the nine rights the function accepts —approvals.approve,finance.bookings.approve_finance,finance.payments.verify,finance.journals.approve,finance.refunds.approve,bookings.cancel.approve,finance.cancellations.approve_b2b,finance.cancellations.approve_airline,tickets.approve— so an accountant and a ticket manager have it too — as a tab, or as a row on Home when the bar is full (UX-024). On the phone:- Sales check and Finance check open the booking, where the decision is made.
- Receipts to verify — Verify (a note is optional) or Reject (a reason) through
verify_payment. Verifying posts the receipt voucher and moves the booking's paid amount in one step; the person who recorded the receipt never sees it here (FIN-032). - Vouchers to approve — a voucher opens with its lines; Approve or Reject (a
reason) through
approve_journal_entries(approver ≠ maker, period lock — FIN-032). A voucher that carries follow-up steps (a ledger or supplier record, a partner settlement) is approved on the desktop, which runs them; the phone says so and offers only Reject. When the database skips a voucher, the phone says why, in the website's words (see Journals): its debits and credits don't match; it is above your approval limit (the amounts in rupees); it reverses a voucher not approved yet; you made it; its period is locked. Only a voucher someone else already decided says "Someone else has already dealt with this voucher." - Ticket exceptions — Approve or Refuse, with a reason, through
decide_ticket_exception(never the person who asked). - Refunds — Approve (a note is optional) or Reject (a reason) through
approve_refund(finance.refunds.approve; never the person who asked — CXL-003, ACC-020). Approving posts the refund voucher and takes the refund off the booking's paid amount in one step, and e-mails the customer as the website does. A refund for a party with no ledger yet is refused with "Approve this refund on the website". - Cancellations — Open booking and Reject (a reason,
bookings.cancel.approve, throughapp_reject_cancellation— LC-020). The person who asked is told in their inbox. Approving is on the website: it releases the seats and services and posts the credit from there. - Airline / seat-sale refunds are listed and marked "decide on the desktop".
A receipt, voucher or ticket exception leaves the list the moment it is decided; if the database refuses the decision (someone else got there first, a locked period) it comes back and the sheet says why. The list draws only the rows on screen, so a full inbox (100) scrolls smoothly.
A person who holds none of the nine (a sales executive, a cashier, a visa officer) has no
tab; Home's "Needs my decision" is hidden and the Notifications "waiting" lines open
Bookings → Pending instead. A staff login with no permissions at all sees a note on Home
saying so.
- Groups — the departures (groups.view), Active or All groups, with a search box that
finds a departure by name, group code, the operator's own code (dashes optional) or the
departure date typed DD/MM/YYYY, DD/MM or DD-MM-YY (INV-005).
The list is read in one go, so the search runs on the phone over what was loaded; a
departure outside Active is found under All groups. A group opens with its header — phase,
dates, seats — and Operations: Flights, Rooming, Readiness, the ticket sheets, Services and
Notify group (Group operations). Below that is the tour leader's screen:
people, rooms, plan, log, SOS, the programme (edit with groups.edit), and Live —
where the travellers who switched sharing on are. With groups.edit the screen carries
the Tour leader card (FLD-007): who it
is, with a call button; Appoint / Change opens a search over every kind of login
(chips: Employees, Partners, Travellers — a traveller's phone shows its last four only),
each appointment confirmed Yes/No (appoint_tour_leader, which grants the Tour leader
role and nothing else); Dismiss (dismiss_tour_leader, removes the role when no other
group is led); and Invite a new person — name, phone, email — for staff who may create
logins (admin-users create_user, then the appointment), which shows the temporary
password once for the office to pass on, requests a password-reset email for the person
(auth-login reset), and — with communications.send and a phone — sends a WhatsApp note
through whatsapp-send that names the group and says to use Forgot password (never the
password itself). The card says what went and what did not: WhatsApp's own rule skips a
number that has not written to Alhuda in the last 24 hours, and the card says so.
The group screen also carries the Rate sheet card (groups.view): each line's adult /
child / infant rate, any custom lines, the totals before tax and the currency, read only —
the sheet is edited on the desktop. With group_pricing.edit it has Copy pricing from
another group (PRC-006): search the
departures that have a sheet (each with its totals), choose Everything, Lines, Custom or
Taxes, compare now against after, and confirm Yes/No. The confirmation says existing
bookings keep their prices. copy_group_pricing saves it and gives its refusal in its own
words — another currency, a closed or departed departure, one without a sheet
(Groups §6.1). On the phone the four
standard lines copy together; picking one line is desktop only.
- More — who is signed in, the daily jobs (below), Find anyone, Customers
(customers.view), My profile, Users (admin.users.view) and Partners
(agents.view) — each login and each partner with its record (§9); the partner record's
top bar has Call and WhatsApp, and the partners list finds a partner by BP- code
(bp12 is enough), Notifications (the
The manifest is per traveller (PAX-035). One row per traveller, in one section per booking. The section header reads "BK-00012 · Siddiqui family (4) · partner BP-0012": the booking, the party (the booking contact, else the first traveller) with its count, and the partner code when there is one. The Group leader chip sits on the one traveller who leads the group — not on everyone on their booking. The group leader is a traveller; the tour leader is someone else and stays on the Tour leader card. A row says what the traveller is on (room, meal plan, transfer) and shows Cancellation requested while a cancellation waits for approval. Tapping a row still calls the traveller.
Staff get a ⋯ button on each row. A tour leader (field.checkin only) does not. It
offers only what the login may do:
- Make group leader (
groups.edit) — confirmed Yes/No, thenset_group_leader. The previous group leader stops being one. It is not offered on the current leader. - Transfer to another group (
booking.transfer) — choose an active group, an optional new price and a required reason, thentransfer_traveller_to_group(LC-030). A blank price keeps the price they were sold at; a new price lands them at it and the difference is posted with the reason (PRC-004, PRC-005). Seats on the same flight block move with them; other seats are released. Their room, meals and coach seat on this departure are released (INV-020). The visa case and tickets follow them. They land on the booking already made for their booking on the new group, or a new one with the same customer, partner and sales owner, sent for approval; if its details are incomplete it stays a draft for sales to finish (LC-033). The money paid for them goes with them (LC-032).
Transferred bookings (LC-031).
On the staff and partner Bookings lists a booking whose travellers all moved shows a
Transferred chip and Transferred → BK-… where the balance was — never "Paid in
full"; one that lost some shows N transferred out → BK-…. Its booking screen says
"BK-… was transferred to BK-…; it is read-only." and offers no Take payment, Pay online,
I have paid, passport scan or emergency-contact change; the database refuses them anyway.
The booking they went to says Transferred from BK-…, and its PAID includes the money
carried with them (the payment rows stay on the booking that received them).
The staff booking screen lists every traveller moved in or out under Transfers — who,
from which booking and group, to which, when, by whom and why (booking_transfer_trail) —
so a traveller moved into a booking that already existed shows where they came from too.
- Remove from group asks why. Cancelling their trip (bookings.cancel) takes a
reason and calls request_passenger_cancellation. Nothing is cancelled until a manager
approves it on the website (or rejects it, on the phone too); the refund follows the cancellation policy
(LC-020). The approvers get an inbox notification.
Moving to another group opens the transfer above.
Neither Transfer nor Remove is offered while a cancellation is pending. Offline (the copy saved on the phone) the button is hidden. The database checks every permission again.
Not built in the app: choosing an existing booking on the target group (the app always
makes a new one — the desktop can do either); approving a cancellation (website — rejecting
one is on Approvals); putting a traveller on a flight and opening a visa case (website).
Linking a flight to the departure, its PNR and seat numbers are on the phone
(Group operations).
- Operations — everything a departure needs, in one tab (below,
Operations, deadlines and holds).
- More — who is signed in, the daily jobs (below), My profile, Users (admin.users.view) and Partners
(agents.view) — each login and each partner with its record (§9), Add a business partner
(partners.create without agents.view; with both, the Partners screen has Add partner),
Suppliers (whoever may read suppliers — the same set as Operations → Suppliers, below; it
stays in More because a reader through groups.view or finance.view has no Operations tab),
Notifications (the inbox, with an unread count on the row), Scan a passport, Guide for
travellers (programme.manage), the desktop, sign out. The Inventory row that used to sit
here is the Operations tab now. Home carries a partners waiting for approval row when a
registration is pending (agents.view; the count is the Agent rows the login may read with
status pending), and a New partner row under Shortcuts for partners.create.
Find anyone (staff)
One search box — on Home, and More → Find anyone — finds bookings, customers, partners and groups (PTY-007). Each kind is searched only when the login holds its view permission, and the box says what it searches:
| Section | Needs | Matches | Read |
|---|---|---|---|
| Bookings | bookings.view |
booking number, customer or traveller name, passport, phone, PNR | booking_page, then the rows — as the Bookings tab |
| Customers | customers.view |
name, phone, email, passport number, CU- code |
customers_list_page — as the web's Customers list |
| Partners | agents.view |
agency, contact, phone, email, BP- code |
the Agent rows under row security — as More → Partners |
| Groups | groups.view |
group code, operator code, name | the TravelGroup rows under row security — as the Groups tab |
Typing waits a moment, then every permitted section is asked at once: one round trip (the
bookings section is two reads, one after the other, as on its own tab). Each section shows its
first five; See all opens the full list. A short code is read as the stored one — cu123
finds CU-000123, bp12 finds BP-0012 — and its section is listed first. A section that
fails says so; the others still show. A result opens the screen that already exists: the
booking, the customer, the partner record, the group. Customers, bookings and partners carry
Call and WhatsApp on the row.
Not built: suppliers and employees in the box (Operations → Suppliers and More → Users search them); finding a booking by its customer's or partner's code.
Customers (staff)
Customers (Home shortcut and More; customers.view) is the customers list: newest first,
one page at a time as you scroll, searched by name, phone, email, passport number or code
(customers_list_page, one call a page). A row shows the code and the phone, with Call
and WhatsApp.
A customer opens Customer 360 lite: the name and the code (tap to copy), the phone and
email with Call and WhatsApp (PTY-008: a ten-digit number is
dialled with +91; the phone's own dialer and WhatsApp open, nothing is sent by the office),
the money — paid and still due, per booking — the trips (each opens its booking), the current
trip's passports, visas and tickets, the travellers and family (another customer opens that
customer), and the files on the record (each opens through drive-file, kind
customer_file). Reads: customer_360_screen and the customer's CustomerDocument rows,
side by side — one round trip. get_customer_360 decides what comes back: a login without
finance.view or bookings.view sees no money, one without bookings.view no trips, and the
screen says which. The screen changes nothing; edit a customer on the desktop.
Not built on the phone: editing a customer, the requests, messages and activity sections of the web's Customer 360, and adding a file to a customer outside a booking.
Dashboard (role based)
The phone's Dashboard is the website's role dashboard (UX-010, UX-011,
Role dashboards). It is chosen the same way, from the same code: the twelve
role profiles live in src/lib/dashboardProfiles.ts, and the app carries an identical copy
(apps/mobile/src/lib/dashboardProfiles.ts). A test on each side fails when the two differ.
- Who gets the tab: anyone whose permissions match at least one profile — Leadership,
Finance manager, Chartered accountant, Accountant, Cashier, Operations, Ticketing, Visa,
B2B partners, Sales, Auditor, HR & IT. Profiles are detected from action permissions,
never from a role name or a
*.viewpermission (ACC-001). A login that matches none (a tour leader, a view-only account) has no My dashboard row on Home. The Dashboard opens from Home; it is not a button in the tab bar (UX-024). - Tabs: the main profile first, each further match as a chip above the widgets, in the website's priority order. A profile already covered by another is left out, as on the website (a finance manager is not also shown the accountant and cashier dashboards). A super admin lands on Leadership with the others as further tabs.
- Mine / Everyone: shown on the B2B and Sales tabs, as on the website.
- Order: My numbers, then My work now, then Signals; inside each, the profile's order.
Each tab is one call, the website's own dashboard_screen(profile, scope, main)
(PRF-002) — the phone sends exactly what the website sends.
Every tile is the desktop widget's dash_* function under its own permission check; a widget
also needs one of its own permissions to be drawn, and a tile the database leaves out is not
drawn. If one tile fails, it says why and the rest still show. The answer is never kept on the
phone (PRF-013).
How a widget is drawn on the phone, by its kind:
| Kind | On the phone | Examples |
|---|---|---|
| Figure (KPI) | The figure (money as ₹ in Indian grouping), its change against the previous period, a second figure, and a six-month series as small bars | Collections this month (with today's collections under it), Bookings this season, Bookings this month, Refunds requested |
| Figure with rows | The figure, then the rows the function chose (and the split by category where the website shows it) | Waiting for your decision, Departures next 30 days, Partner sales, Seats held / sold / released |
| Figure with a breakdown | The figure, then each part with a bar | Receivables overdue (by age, and customers vs partners under it), Cash & bank, Travelling today, Visa cut-offs, Collected today by mode, Active users by role |
| Breakdown | Each part with a bar; the total beside the title | Cases by stage, TDS to deduct and deposit |
| Queue | The rows; the full count beside the title | Leads to follow up, Tickets to issue, Documents missing, Receipts to verify, Holds expiring, Supplier payments due and the rest |
Open incidents (main tab only, with incidents.view) |
A count per severity and the critical and high ones | — |
Dates read DD/MM/YYYY (UX-020).
Where a tap goes. A widget's title opens the phone screen behind it, only for a person who may open that screen: Approvals (decisions, receipts to verify, refunds), Groups (departures, travelling today, nearly full, rooming), Bookings (bookings this season or month, drafts, partner bookings, dues; sent back opens the Needs correction filter), Visa, Tickets, Airline blocks (deadlines, radar, seats), Seat releases (releases, airline cancellations), Leads, Partners, Finance (collections, cash, period, payables, bank, TDS, receivables; vouchers you prepared open Vouchers), My receipts (the cashier's), Users (two-factor, dormant, by role). A row opens its own record: a decision opens its booking (other decisions open Approvals), a departure opens its readiness, a group opens the group, a booking, airline block, FIT booking, hotel, food or transfer item, supplier, visa case, departure's ticket sheet or task opens its phone screen. Everything else has no link on the phone.
Not on the phone: the shortcut widgets (the tabs and More are the phone's shortcuts), the daily brief, the "why" popovers, the journey board (a separate call on the website), the quotation, customer-request, partner-request, exceptions and privileged-access screens behind their widgets (the numbers show; there is no screen to open), sales targets (none are stored).
Families on a departure
The group's Manifest has a By booking / By family switch (PAX-036). By booking is the list as before. By family groups the travellers into their families — the name, the head (or Needs a new head), and each member with their relationship and booking number — then Not family, then Family not recorded. An amber line at the top counts what is still to decide: travellers not recorded on bookings of two or more, children without a guardian, families needing a head, infants roomed apart from their guardian. It all comes in the manifest's one call.
Staff with bookings.edit get New family (tick travellers from any booking of the
departure, make one the head, a relationship chip for each), Edit on a family (including
Dissolve, with a reason), and a ⋯ on each traveller: Mark not family / Back to not
recorded, Remove from family, Set guardian for a child or infant (PAX-021). The database checks
each change. The tour leader, and anyone without bookings.edit, sees the same grouping with no
buttons.
Not built in the app: marking many travellers Not family at once from the manifest, and
changing the relationship list (that is admin.config.edit on the web).
Group operations (staff)
Issue #559 step 3. A group opens on a light header in the new look
(UX-023):
the departure's code and name, its phase — Leaves in 12 days, Travelling, Returned, or
Closed for a departure the office closed (INV-006) —
the number of travellers and the trip type, then two tiles: Departs (with the return date)
and Seats booked ("32/40 · 8 seats left", amber when full, red when over). A tour leader
without groups.view sees the same header from the manifest, without the seats.
Under it, staff with groups.view get Operations. Each row shows only with the right its
screen or action uses:
- Flights (
groups.view) — below. - Rooming (
groups.viewandbookings.view) — below. - Readiness — opens Readiness for this departure.
- Ticket sheet · … — one row per linked flight, with
tickets.view. It opens that flight's ticket sheet (Home → Tickets). - Services (
groups.edit) — hotel rooms, meal plans and transfers, as before. - Notify group (
groups.edit, online only) — also the send button at the top right. It is the Programme tab's Post a notice: a title and a message, kept in every traveller's notifications and pushed to their phones (trv_post_notice, TRV-004). It goes to the departure's own app users only — no WhatsApp or e-mail is sent, so no marketing consent is involved.
Flights lists each airline block and FIT ticket linked to the departure: the flight and
route, block or FIT and its code, the date, the PNR, the seats free on it and how many
travellers are on it. The count needs bookings.view and is left out without it; a block's
details need inventory.view. Each flight opens Travellers and seats and, with
tickets.view, its Ticket sheet. With groups.edit
(INV-033):
- Link a flight — Airline blocks or FIT tickets, with a search. The list holds only those flying within a day of the departure's dates (INV-032). One linked to another departure, a draft block and one with no seat left are shown greyed, with the reason. Pick one, add the PNR if you have it, confirm. The database refuses a block with fewer free seats than the departure's travellers who need one, and says how many of each (INV-012).
- Add PNR / Change PNR — letters and numbers, saved in capitals; blank clears it.
- Unlink — asks Yes/No. Refused while a traveller is on the flight.
All three are app_link_group_flight, app_set_group_flight_pnr and
app_unlink_group_flight, which check groups.edit in the database and are audited. A second
tap changes nothing.
Travellers and seats lists who is on one flight, by surname, with their PNR (their own, or
the flight's), ticket number and seat. With bookings.edit, a tap sets or clears the seat
("12C") — the same change as the website's per-traveller flight edit.
Share flight manifest (groups.view and bookings.view) hands the phone's share sheet a
plain text: each flight, then its travellers by surname with seat, PNR where it differs and
ticket number.
Rooming shows each hotel stay — hotel, city, room type, dates, rooms used of rooms held —
and its rooms, built as the website's rooming list builds them: travellers with the same room
number share a room; without a number, those with the same share label do; anyone else is a
room of their own (INV-030).
Numbered rooms come first, in number order. With bookings.edit a tap on a traveller sets or
clears their room number; everyone in a room gets the same number one by one. Share rooming
list sends the same layout as plain text.
The shared texts carry names, rooms, seats, PNRs and ticket numbers only — never a passport number or a date of birth. The printed rooming list and flight manifest with passports are on the website (Group → More → exports).
Not built on the phone:
- Putting a traveller on a flight, or taking one off. On the website that also posts the seat's cost to the departure (FIN-035) from the browser. Moving that posting into the database comes first.
- Placing travellers into rooms by share label from this screen. Use Services (it calls
place_travellers), or the website. - Creating a group, cloning one and editing its rate sheet. The website does each from the
browser (a
TravelGroupinsert with the default cancellation policy and GST settings; a long clone that copies services, bookings and the programme; apricingupdate checked in the browser), not through a database function the app could call. The phone keeps Copy pricing from another group (PRC-006). - A PDF of the rooming list or manifest. The website has no generator the app can call; the phone shares text.
- A group message by WhatsApp or e-mail. Notify group reaches the app only. Bulk WhatsApp stays on the website's Communications page, with its consent summary (AUD-022).
Bookings (staff)
Issue #559 step 4, in the new look (UX-023). Every action uses the website route's own permission and the same database function, and the database checks it again (PERMISSIONS.md §6.5d).
The list has a light header with "30+ shown", the search box and the five filters (All, Pending, Approved, Needs correction, Cancelled). Each card shows the customer with their code, the booking number and travellers, the departure and its date, the partner, the total and what is still due (FIN-033). It loads 30 at a time as you scroll. A failed load says why and offers Try again; offline, it says when the list was saved.
The booking opens on the customer (tap to open the customer with customers.view), the
status, Cancellation requested when one waits, the number of travellers, the room choice and
the payment policy. Then the departure, the money and Take payment, as before. Then
Actions, each only for the person who may do it, and none offline:
- Edit room and payment (
bookings.edit) — the room choice (single to quint, general, or not set) and, on a direct booking, part or full payment. A partner's booking stays on account. It callsupdate_bookingwith only what changed. The price, GST, the customer, the partner and the travellers are changed on the website, and once the booking has left sales only by sending it back (PRC-003). Not on a cancelled or transferred booking. - Ask to cancel the booking (
bookings.cancel) — a reason, thenrequest_booking_cancellation. Nothing is cancelled yet: a manager decides (LC-020). Not on a draft (it is deleted instead — LC-021) or while a request waits. - Ask for a refund (
finance.payments.refund) — shown when cancelled travellers' refunds are owed. It says what is owed and how much can be refunded now (refund_preview: what was paid, less refunds already asked for). Amount, how it is paid (bank transfer, UPI, cash, cheque), an optional reference and a reason, thenrequest_booking_refund. It is paid from the account the money came into. Someone in finance approves it on Approvals (CXL-003, PRC-030).
When a whole booking's cancellation waits, a red card at the top says who asked and why. With
bookings.cancel.approve it has Reject (a reason — app_reject_cancellation). Approving
is on the website.
Each traveller is a row: their name, the passport's last four, category, room, visa state, and a chip for Cancellation requested, Cancelled, Cancellation rejected or a passport that expires within six months. A tap opens their sheet: Call and WhatsApp (PTY-008), and
- Open visa case (
visa.view) — opens the traveller's case on the visa case screen. With no case yet the sheet says a case is opened on the website. - Scan passport (
bookings.edit) — as before; the scan waits for review. - Ask to cancel this traveller (
bookings.cancel) — a reason, thenrequest_passenger_cancellation. Their refund is their own share of the booking (PAX-031).
A double tap never sends twice: the button stays disabled while the call runs, and a second request finds the first and says "It was already asked for."
Not built on the phone:
- Approving a cancellation. The website's approval releases the traveller's seats, rooms, meals and transfers, reverses the seat cost, posts the receivable credit, the credit note and the retention from the browser before the database records it (CXL-001, CXL-002, FIN-038). Those steps have to move into one database function first. The phone rejects; the website approves.
- Overriding the refund on an approval. Part of approving.
- Opening a visa case. The website writes the case, its visa group and its first history
row from the browser (
POST /visa). It comes to the phone with step 6 (Visa), through a database function. - Editing the price, GST, customer, partner or travellers.
update_bookingreplaces the whole traveller list when it is sent, and the website checks the passport window and the group's required fields in the browser first. The phone sends no travellers. - A refund paid from another account, or a refund on one payment. The website chooses the
account with a reason (
p_account_id,p_account_reason) and refunds a single receipt (refund_payment). The phone always pays from the account the money came into; with no verified receipt to show that account it says to use the website. - Rejecting a single traveller's request from the booking screen. It is on Approvals.
- Moving a booking to another group, deleting a draft, resubmitting after correction. Website.
Readiness (a departure)
Opened from a departure on Home or from Departures at risk, or from a group's Operations. One call,
app_departure_readiness(), under the permissions of the departures list. It shows the
departure's readiness — the same % as the card, computed as the desktop computes it — with a
bar for each part, and then who is missing what, in the order the office chases it:
- Finance clearance — bookings not yet cleared by finance, with their stage.
- Payment — bookings with an amount still due, largest first. Needs
finance.vieworbookings.view; without it the section is left out and the screen says why. - Visa — travellers who need a visa and have none issued, with the case's stage.
- Ticket — travellers who need a ticket and have none issued.
- Room — travellers who need a hotel and have no room assigned.
A row opens the booking. Call and WhatsApp reach the traveller (or the booking's
customer when the traveller has no number) — only with bookings.view or customers.view,
and only where a number is on file. Open the group needs groups.view.
Daily jobs (staff)
Home and More list the jobs the person's permissions allow, each with the desktop's count
from badge_counts. Every call is the one the desktop's route makes; the phone hides what a
person may not do and the database refuses the rest.
- Tasks (
work.view; Home shows it as My work: how many are open, late and due today, the first three — late, then due today, then later — each opening its job, See all and New task) — the work inbox. Tabs Mine, To pick up, Given by me (work.create,work_given_by_me) and Team (work.assignwithwork.view.queue/work.view.all); filters Late, Due today, Urgent & high; rows grouped Late / Due today / Later / No due time. A task opens its own screen (work_item_get): the details, the linked record, why it exists and What happened (the ledger). Its buttons follow the database's checks: Take it (work_item_claim; the loser of a race is told), Record the first answer / Add a note (work_item_record_response), Park it with who and why / Start it again (work_item_wait/_resume), Hand over to a colleague, least busy first (work_people; a manager also seessuggest_work_assignee), Put it back (work_item_release), Finish (work_item_close,work.close), Cancel (work.assign), Reopen (work.reopen). New task (work.create,work_item_create): title, details, who holds it (me, the pool, or a colleague withwork.assign), due time, priority, and a booking, customer, group or partner found by number, code or name. The person a task is given to gets a note in Notifications, a push and an e-mail; finishing or cancelling a task tells the person who gave it; due-soon and overdue reminders arrive the same way (WRK-009, WRK-018). The app only asks thecommunications-dispatcherto send them now; the server rings the phone (COMM-041). A work note opens its task. A row owned by another screen opens the booking, group or visa screen, or says it is on the desktop. More: Work inbox → On the phone. - My performance (
perf.view.own;perf.view.allto choose a colleague, each look audited) — the desktop's scorecard (perf_scorecard): speed, throughput, outcomes, quality, right now and coverage for this week, this month or last month; each figure opens its jobs (perf_scorecard_rows), and a job opens as a task. No single score (PERF-001 … PERF-012; Performance). - Leads (
leads.view) — To follow up (new and contacted) / Qualified / All, with a search, most recently touched first, 30 at a time as you scroll through the web's own list function (leads_list_page); the header says how many of how many ("30 of 412"). It used to show the first 80 and stop without saying so (PLT-050). Each card calls or WhatsApps the person. Change status (leads.edit) goes throughlead_set_status: lost or closed need a reason, stored with who and when; re-opening clears it; a converted lead keeps its status (SAL-001). Converting a lead into a quotation or booking is done on the desktop. - Visa cases (
visa.view) — In progress / Issued / Rejected / All, a row of departures with visas still open, and a search over traveller, passport, application number, booking number, group code and customer code — one call a page,visa_phone_cases(VISA-032): 30 at a time as you scroll, with the total and the departure row on the first page. It used to show the first 80 and stop without saying so (PLT-050). Each card shows the next step and how many documents are on file, and opens the case: where it stands, the documents (each opens in the app throughdrive-file), the history with each reason, and its booking. Withvisa.editthe next step is one tap and a reason (change_visa_status— only the forward steps of VISA-003). On Issued or Rejected the database e-mails the customer itself (VISA-005); the sheet says "Customer notified by e-mail" only when it was queued, and otherwise why not. Withvisa.edit, Add visa copy or a paper files the visa copy, the passport or another paper from the camera, the gallery or a PDF (drive-uploadkindvisa_doc, thenadd_visa_document— VISA-031). Visa groups, billing, recording an end state, deleting a document and the intake queue stay on the desktop. - Tickets (
tickets.view) — the departures with seated travellers (ticketing_departures): issued of seated, ready, held, exceptions asked. A departure opens its sheet (ticketing_flight_sheet). Withtickets.approve, Enter ticket number saves one ready traveller's number with a reason (issue_tickets— the finance gate, the number format and duplicates are the database's). Withtickets.edit, Ask for an exception on a held traveller (request_ticket_exception); another person decides it on Approvals. Paste from the airline (tickets.approve, shown while a traveller is Ready; issue #559 step 5) takes the whole list the group desk sent: a column of numbers in the order of the Ready travellers, or a name and a number on each line (BHAT/NAZIR AHMAD 176-1234567890, in any order, titles ignored). Read the list shows each number against its traveller, a wrong or repeated number in red, and the lines that matched nobody. Save asks where the numbers came from and sends them all in oneissue_ticketscall: if the database refuses one, none is saved and the reason is shown. The list is read by the website's own reader (src/lib/ticketPaste.ts; the app's copy is held equal by a test), so the phone and the desktop read a paste the same way (AIR §24). - Receipts I recorded (
finance.payments.record) — the money this login took, newest first: waiting for finance, verified with its receipt number, or rejected with the reason (FIN-032). A receipt is taken from its booking (Take payment).
What each phone role does on the app (each role's seeded bundle, PERMISSIONS.md §5):
| Role | On the phone |
|---|---|
| Sales executive | Bookings, new booking, Take payment; leads (call, WhatsApp, status); tasks and my performance; visa cases and tickets to look at |
| Sales manager | As the sales executive; the programme and the travellers' guide; groups (tour leader, services) |
| Ops executive | Bookings; groups — manifest, check-ins, programme, tour leader, rooms / meals / transfers on a departure; inventory; tasks and my performance |
| Ops manager | As the ops executive; the sales and finance checks on Approvals; the cancellation queue (reject on the phone, approve on the website); ask to cancel a booking or a traveller |
| Ticket manager | Tickets — enter ticket numbers one by one or paste the airline's list, exceptions; ticket exceptions on Approvals; airline blocks and seat releases; seat offers to partners |
| Ticket executive | Tickets — the sheet, ask for an exception; airline blocks, block payments and seat releases |
| Visa officer | Visa cases — find a case, the next step with a reason (the customer is e-mailed on issued / refused), the visa copy and papers from the camera or a file; bookings; my work |
| Visa officer | Visa cases — the next step with a reason; bookings; tasks and my performance | | Finance manager | Approvals — the sales and finance checks, receipts, vouchers, refunds (approve or reject); cancellations (reject; approve on the website); ask for a refund on a booking; Take payment | | Accountant | Receipts to verify on Approvals; ask for a refund on a booking; Take payment; receipts I recorded | | Cashier | Take payment on a booking; receipts I recorded | | CEO / GM | Everything above |
Still on the desktop. Quotations (make, send, revise); converting a lead; customers and Customer 360; approving a cancellation, a refund paid from another account, and a refund on a payment rather than a booking; journal entry, reports, periods, TDS, group invoices; deleting a visa document, visa groups and the public visa intake queue; the rooming list (who shares which room); customer requests; WhatsApp inbox and communications; incidents; admin settings and permissions.
Scan a passport. The phone photographs the photo page; the picture goes to the
company's extractor (extract-passport) and comes back as fields the person checks and
corrects. With no network, the two machine-readable lines can be typed and are read on the
phone. The result is either attached to a passenger on an existing booking — a pending
submission that someone with bookings.edit applies or rejects on the booking screen — or
copied for the desktop wizard. A scan never creates a customer
(LC-006, TRV-005).
When the scan is made from a booking for a passenger who is linked to a customer record,
the photo is also saved to that customer's folder on the company Google Drive as a
passport document (upload-customer-doc, the same folder the desktop's drive documents
use); the screen says "Photo saved to the company drive", or that only the details went
through if the drive refused it. A passenger with no customer record gets the details
only. The app keeps no copy of the photo.
Finance (staff)
More → Finance (and the same row on Home) is for the people who write, approve or reverse
vouchers: finance.view and one of finance.create, finance.journals.approve,
finance.journals.reverse. A cashier or a salesperson who only reads finance does not see it.
Everything a voucher is on the website is the same here: it waits for a second person in
finance before it reaches the books (FIN-032).
Detail: Journals → Writing a voucher on the phone.
- Finance — how many of my vouchers wait and their value (
dash_my_journals), how many wait in the queue (badge_counts), and the four jobs below. - New entry (
finance.create) — the type (Journal, Payment, Receipt, Contra), the date, a narration, the lines: each line an account from the picker and a debit or a credit, typed on the number pad. The debits, the credits and the difference update as they are typed. A bill or photo can be attached (finance.edit). Save voucher asks Yes/No with every line, thencreate_manual_journalanswers the voucher number and "Waiting for approval". A double tap or a retry never saves it twice. - Account picker — searches the chart by code or name; offers only active accounts that are
not groups and are in rupees; a party ledger is found by its party code (
BP-,CU-,SU-) (PTY-004). - Vouchers — the recent vouchers, Mine or Everyone, filtered by Waiting, Posted or
Rejected (
JournalEntry,finance.view). A voucher opens with its lines, who wrote and decided it, and its files (finance_journal_screen); a file opens throughdrive-file. Approve / Reject (approve_journal_entries,finance.journals.approve/.reject) show only on a voucher someone else wrote; a voucher with website follow-up steps is approved on the website. Reverse (post_journal_reversal,finance.journals.reverse, a reason) shows on a posted hand-made voucher the person did not write; the reversal waits for approval. Attach a bill or photo needsfinance.edit. - Account ledger — one account between two days (
finance_account_ledger,finance.view): the opening balance, each posted line with the running balance, the closing balance. Vouchers still waiting are not in it.
A receipt against a booking is not entered on the finance screen: open the booking and use Take payment. The finance screen says so.
Not on the phone: foreign-currency vouchers, supplier bills and payments with TDS, bulk approval, and reversing a voucher that a booking, a receipt or a supplier record posted. Use the website.
New booking (staff)
The desktop wizard's four steps — trip, travellers, package, review — on the phone, against
the same database functions and gated on bookings.create. Travellers are typed or filled from
a passport scan; a customer is created only when the booking is created
(LC-006). The submit runs in this order: customers →
create_booking → the revenue entry → submit_booking → the emergency contact
(INC-005) → tour-leader consent → the first
receipt on a direct booking.
What the app posts. Immediately after create_booking, the app calls
post_booking_revenue_from_booking, which derives the booking's accounting entry from the
database — the receivable, package revenue, GST on the ground margin, the per-traveller service
charge and a partner's commission — the same lines the desktop builds, and posts them pending a
second person in finance (FIN-030,
FIN-032). If that entry cannot be posted, the booking
is rolled back and the screen says why: a booking never survives without its voucher. The
estimate on the review step is the price before GST. GST is charged on the ground margin,
which the phone cannot work out, so with GST on the estimate reads "+ GST on the margin, set
when saved" (with the rate when one is typed; a blank rate takes the departure's or Finance
Settings' rate). The phone does not show a GST figure of its own. Scanned passport photos are filed on the company drive once the
booking exists, and the success screen says how many went through.
The office hears about it. Right after submit_booking, the app calls
booking_created_notify(p_booking_id) (LC-007): the
database keeps one inbox row per sales approver ("New booking awaiting sales approval", opening
the booking — TRV-010) and queues the customer's
booking_created e-mail (and a partner's b2b_booking e-mail) for the
communications dispatcher. The app then
hands the approver ids the function returned to push-send with store:false, so their phones
ring. The same function is what the desktop and the partner's screen call, so every door
announces a booking the same way, once. If the announcement fails, the submit still succeeds:
the success screen carries a warning and the booking appears in Approvals regardless.
Guide for travellers. The step-by-step guide per trip type (Umrah, Hajj, ziyarat, Umrah +
ziyarat, Hajj + ziyarat, domestic, international, and ALL for every trip), in phases:
before you go, travel day, arrival, the rituals, the stay, coming home. Add, edit, reorder,
switch a step off, delete. The company starts with a seeded guide
(TRV-002).
- Languages. A switch at the top: English, Urdu, Hindi, Kashmiri, Arabic. English is the master. In another language, each step shows the English for reference and boxes for the title and text, written right to left in Urdu, Kashmiri and Arabic. Each step and part shows its status in that language: Approved, Draft or Not written. Only steps not approved in … narrows the list to what still needs someone. There is no automatic translation: staff write each language by hand (TRV-018).
- Parts of a step. Text, a verse or a dua, in sort order under the step. A verse or dua has the Arabic (pasted from a verified source, kept exactly as entered), the transliteration, the English meaning and the source. Two parts with the same sort order are versions of one another — a Sunni and a Shia wording, say.
- For and Tradition. Every step and part is marked Everyone / Men / Women and Both / Sunni / Shia (TRV-020, TRV-021).
- Approve. Plain English is live when saved. Religious text — a verse, a dua, their
meaning, anything Sunni or Shia — waits for Approve from a holder of
guide.religious.approve; plain text in another language waits for Approve from a holder ofprogramme.manage. Religious text needs a second person: whoever wrote or last changed it sees Approve greyed out, with the reason, and another holder approves it. Plain text may be approved by its writer. The approver's and the writer's names are shown. Changing approved text does not take it away: travellers keep reading the approved version, shown in green as Travellers read now, while the change waits below it marked Change waiting. Approve puts the change in its place; Discard the change drops it (TRV-018, TRV-019). A change of a step's tradition waits the same way and, once approved, moves the step with its parts and translations. Every step in The rituals is religious text, whatever its marking. When an approved change to the English or the Arabic reaches travellers, its approved translations show Source changed — needs review: travellers still read them, and Approve again clears the mark. A save that changes nothing leaves another person's waiting change alone. A holder ofguide.religious.approvealone sees the editor read-only, with the Approve buttons. - Preview as. The guide exactly as a traveller reads it, for a chosen language, Sunni or Shia, a man, a woman or gender not recorded, and Show both.
Notifications. The same screen for every stack (staff, leader, traveller), reached from
More, More and Me with a burgundy unread count on the row. At the top, whether alerts reach
this phone (allow, or open the phone's settings when blocked). Then the inbox: every
notification sent to this login is kept (TRV-010) —
approvals, booking updates, group notices — newest first under Today / Yesterday / This
week / Earlier, unread in bold with a dot, the kind as an icon, how long ago. A tap marks
it read and opens the screen it is about (a booking, a group, the programme, the
approvals) when the app has one; Mark all read clears the lot; pull down to refresh;
"Show older" pages back thirty at a time. The list updates by itself (realtime on
AppNotification) and when a push arrives while the app is open. Offline it shows what it
last loaded and says so. Staff also see Waiting now under the inbox — the desktop's
badge counts. A group notice is in the inbox of everyone on the group the moment it is
posted, whether or not the push went out; the poster does not get their own notice.
Who is family here (PAX-036). After a booking with two or more travellers, the result screen asks Who is family here? — All one family (the pre-filled family form, confirmed), None are family, or Decide later. It never blocks the booking. The booking screen has the same Family card: families, Not family, Not recorded, and each child's guardian with Set guardian (PAX-021).
Operations, deadlines and holds (staff)
Rule: UX-024. The Operations tab shows to everyone who had the old More → Inventory row — any
inventory.*, hotels.*, food.* or suppliers.* permission — and to anyone with
visa.view, tickets.view or a seat-release right (inventory.release.*, finance.create).
A person with no tile sees only the rows they can open: a supplier clerk only Suppliers, a
release approver or a finance filer only Seat releases. Nobody sees an empty tile. A login
whose only inventory permission is one that opens no screen (inventory.edit without
inventory.view) gets the tab with a line saying which permission the screens need.
- At the top, in red (amber when nothing is overdue or due tomorrow): the deadlines of the
next seven days — how many, how many overdue and the next one, e.g. "2 deadlines this week ·
next: SV-OCT-A · Name Submission Thu". It counts only the deadlines nobody has
acknowledged — the same rows the radar lists (at most 50). It opens the deadline radar.
Shown with
inventory.viewortickets.view. - Tiles, two to a row on a phone and three on a wide tablet, each with one line of what
needs attention: Airline blocks ("6 blocks · 41 seats unsold" — live blocks not yet
flown), Hotels ("Makkah 4 · Madinah 3" — blocks still held, live suppliers only),
Food (current meal contracts; red when one is over what it bought), Ground (upcoming
transfers and their free seats), Visa (cases in progress) and Tickets (travellers to
ticket). Each tile shows only with the permission its screen opens with (
inventory.view,hotels.view,food.view,inventory.view,visa.view,tickets.view) and opens the screen the app already had. - Rows under the tiles: Deadlines (
inventory.viewortickets.view), Holds (inventory.view), Seat releases (inventory.viewor a release right), Suppliers (as below), each with its count, and Partner offers (inventory.view, orhotels.viewwithagents.view— below).
The numbers are one call, app_operations_summary() — each section only for the permission
that reads it, counted in the database — plus badge_counts for visa, tickets and suppliers
(the call Home makes). A number the phone does not have is never shown as 0: the tile says
what the area is instead.
Deadlines (inventory.view or tickets.view — the two rights dash_deadline_radar
answers; the website's /inventory/deadlines asks for inventory.view —
INT-120, AIR §21–§22).
The window is 7, 14 or 30 days. The counts by bucket (overdue, today, within 3, within 7 days)
with the money at risk, then What is coming up: every airline-block, FIT and hold deadline
in AIR §21's buckets — Overdue, Today, Tomorrow, Within 3 days, Within 7 days — with its rung
(T-7, T-3, T-1, today, overdue), date, money at risk and message. It is worked out from the
records on every read (dash_deadline_radar), so it does not depend on a scheduler; rows
someone acknowledged are hidden. A row opens its block or FIT, or Holds. Alerts raised are
the durable rungs (InventoryDeadlineAlert): open, escalated and acknowledged, with the owner
role and who it escalates to. Acknowledge asks what is being done about it and calls
acknowledge_inventory_deadline_alert, which records who and when; the card then says
"Acknowledged by … · DD/MM/YYYY HH:mm — the note". A second tap is refused by the database.
Run the ladder (inventory.edit, as on the website) writes the rungs reached so far and
escalates urgent ones nobody acknowledged for a day; each rung fires once. Nothing here is
sent outside the company.
Holds (inventory.view, as /inventory/holds on the website —
INV-011, AIR §14).
Active / Converted / Released / Expired / All, soonest end first. Each card: what is held and
how many, for whom (partner, customer, name or group), who holds it and why, and when it ends
("ends in 5 h", "Expired, not swept"). With inventory.holds.manage:
- Hold seats — airline block, FIT or hotel rooms; which one (the live blocks, the FITs, the
hotel rows), with its position (total, sold, already held, free); how many; ends in 12, 24,
48 or 72 hours (48 is the website's default); for a name, a partner or a group; why. Checked
in the website's words, then
create_inventory_hold, which refuses more than is free (INV-012). - Extend by 48 hours past the current end (
extend_inventory_hold; capped byFinanceConfig.maxHoldExtensions) — only while the hold is still running. A hold past its end says "Expired — hold again" and has no Extend: its seats were free to sell from the moment it expired, so the database refuses to extend it and a new hold checks what is free (INV-012). The website is refused the same way. Convert to the booking the hold names (convert_inventory_hold) and Release (release_inventory_hold). Each asks for a reason, records the person and refuses a second tap.
Not built in the app: the hold expiry sweep (an expired hold frees its units by itself;
the sweep only writes the state change — website), converting a hold that names no booking,
a hold on ground transport (the website's form has no picker for it either), an end date and
time typed exactly (the phone offers 12–72 hours; Extend adds 48), and holds for a login
without inventory.view.
Inventory — hotels and suppliers
Under the Operations tab. Each section shows only to a person who holds its view right; the database decides on every read and write.
Hotels (hotels.view) — the desktop's Hotels page by city: Makkah first,
Madinah second, the rest by name, with search, Upcoming / All, and city chips. A card is the
property, its stars and distance from Haram, the supplier, the dates held, rooms and beds sold
against held with a fill bar, and the same low-rooms warning the desktop shows. Sold and held
come from the database's own counters (INV-013).
- New hotel block (
hotels.create) — the desktop's "Add Hotel" form: supplier from the picker (the property name follows it until typed over), city fromservice_cities, stars, distance, lease or per-stay, room type, rooms and beds per room, the dates, the rate and its currency, the contract exchange rate for a foreign currency (FIN-034), GST input credit, the low-rooms threshold, notes. The card under the price says what will be posted. Save calls one database function,create_hotel_lease, which writes the row and posts its purchase voucher — Dr 1310 Stock-in-Hand, Dr GST input credit, Cr the supplier's ownSUP-ledger, and the supplier transaction in the contract currency — pending a second person in finance (FIN-030, FIN-032). A refused voucher leaves no hotel row behind. A block priced at nought is saved with no voucher, as on the desktop. - A block — the counters, the contract, and the departures holding its rooms
(
GroupHotelAssignment). Edit (hotels.edit) changes the name, city, stars, distance, room type, beds per room, threshold and notes — the fields that do not move the lease cost. Rooms, rate, currency, dates, GST and the supplier are changed on the desktop, which reverses and re-posts the purchase voucher (see Not built). - Assign to a departure (
hotels.edit, issue #559 step 5) — the desktop's Hotels → Assign Rooms. Pick an open departure whose dates touch the block's; the check-in and check-out offered are the nights the two share (the lease's last date is the check-out day — INV-031). Rooms, beds (rooms × beds per room when left blank) and the price per night (the contract's). The sheet says what the stay costs.assign_hotel_roomswrites the stay and moves its cost from stock to the departure in one transaction, pending finance (FIN-030); the database checks every night and names a full one (INV-032). - A departure's row opens a sheet with what the person may do: Open the departure
(
groups.view), Place travellers in this hotel (bookings.edit— the same sheet as Group → Services,place_travellers) and Take these rooms back (hotels.edit,unassign_hotel_rooms): it says how many rooms go back and whose travellers come off, asks Yes/No, and reverses the stay's voucher, pending finance. Changing a stay's rooms, rate or dates stays on the desktop. - Who is staying (
hotels.viewandbookings.view) — the desktop's hotel passenger list: each traveller by departure with their booking, room number or share label, and dates. No passport number, date of birth or phone. - Offered to partners (
hotels.viewandagents.view) — the beds of this block offered to partners: beds left of the offer, the price a bed in the offer's currency, the dates, and whether it is open, reserved by a named partner, or closed. Read only.
Suppliers (More → Suppliers, or Operations → Suppliers) — the desktop's Suppliers page: search, chips by kind (airline /
ticketing, hotel, caterer, transport, visa, other), Active / Include retired. There is no
suppliers.view permission (PERMISSIONS.md §8, drift item 5): the section
opens for the desktop's gate, inventory.view, or any right the Supplier row policy reads
(suppliers.*, finance.view, hotels.view, food.view, groups.view). A supplier shows
contact (tap to call or e-mail), what they sell us (hotel blocks with a link; counts of airline
blocks, FIT seats, catering contracts, transfers), the balance — the desktop ledger's running
figure: purchases and refunds raise it, payments lower it, worded "you owe them / they owe you /
settled" — and the latest fifty transactions. The ledger rows need suppliers.edit,
inventory.view or finance.view; the screen says so when the role has none. The balance
includes vouchers still awaiting approval (the desktop's ledger hides those, but reading a
voucher's status needs finance.view).
-
Add supplier (
suppliers.create; a labelled button above the list, and + in the top bar) and Edit / Retire / Restore (suppliers.edit) — the desktop form's fields: name, kind, currency, city (hotel, caterer, transport), contact, address, website, GSTIN, notes. The desktop's logo URL and airline link are not offered. A duplicate (same kind, same name) is refused and named; the desktop would quietly update it in place. The supplier'sSUP-ledger is made on its first posting (a hotel lease, a bill), as on the desktop. -
Seats to pay for (
inventory.view, issue #559 step 5) — on a supplier that sells us airline blocks or FIT seats: the live ones (not archived, not cancelled, not flown), soonest first. Each opens its block or FIT, whose Payments to the airline card records the payment (inventory.block_payments.record,record_airline_block_payment— pending finance, FIN-044). That is the same function the desktop's supplier screen uses for a payment against a block or FIT.
Not built here. Any other payment to a supplier, a bill, TDS: the desktop posts these from
the browser on finance.create, not through a database function a staff member may call
(FIN-030, "Not covered yet"). Deleting a hotel or a
supplier for good. Re-costing a lease, and changing a stay's rooms, rate or dates. The rooming
list is on each departure (Group → Rooming). The desktop's Excel export and print.
Inventory — partner offers
Operations → Partner offers (issue #559 step 5) — the desktop's B2B Flights page
(/inventory/b2b-flights) on a phone. The row shows with inventory.view, or with hotels.view
and agents.view for the hotel beds. Rule:
INV-016.
Seats (inventory.view): the seats carved from airline blocks for partners to buy, as cards —
the flight and route, seats still open of the offer, the price a seat in its currency, the
departure date, the block code and PNR, and a chip: Open to partners, Sold to the partner,
Closed or Cancelled. Open / Closed / Cancelled / All, and a search by flight, PNR, route,
airline or buyer. Offers carved for a departure are left out, as on the desktop. Partners see the
open offers in their own app, never the cost or the PNR
(PTR-060).
- Offer seats (
inventory.create) — a live block (not archived, not cancelled, not flown, with seats free), seats, the price a seat, the currency (the block's) and notes.app_open_b2b_flight_offercounts what is really free — seats held for someone and seats waiting to go back to the airline are not offered (INV-011, INV-012) — and then carves them with the website's ownb2b_transfer_seats. A second tap answers the first offer. - Close (
inventory.edit) — on an open offer nobody bought: a reason, thenapp_close_b2b_flight_offer. Partners can no longer buy from it; its seats stay with it. A sold offer is not closed: the partner resells those seats (PTR-041), and a sale is cancelled from its block (Third-party sales → Cancel this sale — INV-014).
Hotel beds (hotels.view and agents.view): every bed offer with its hotel and city, beds
left, the price a bed and the dates, open / reserved / closed. Read only.
Not built here: changing an offer's price or reopening a closed one (the desktop has no screen for either), offering hotel beds (the desktop has a route but no screen), and the desktop's "Quick add flight" that makes a block and an offer in one go (make the block under Airline blocks first).
Inventory — airline blocks
Operations → Airline blocks (inventory.view): the quota blocks as cards — airline and
flight, the route chained from the legs, the departure, seats sold of total with a fill bar,
the PNR, and the deadline that needs attention next (AIR §21);
Upcoming / All / Archived; search by flight number, PNR, route or airline. A second tab lists
the FIT seats; one opens to its own read-only screen with the same payments card. A block opens
to a route card (UX-023): the airline and PNR, the status, the two ends of the outbound
journey with their dates and times (SXR → JED), seats / sold / free with a fill bar; then the
deadlines as dated rows (red when missed or due within a day) and what it is linked to; then
its actions as buttons — Release seats, Edit block, Open on desktop, Archive or
Restore, each only with its permission. Below: the legs, the seat position from the database's
counters with holds and requested releases (INV-011,
INV-013), the contract and what the airline is paid after
complimentary seats (AIR §15), the deadlines, the
departures using it, its seat releases and the airline's penalty bands (read-only), and the notes.
New block (inventory.create) is the desktop's form on the phone — trip type, airline,
supplier, PNR, outbound and return legs, seats and complimentary seats, cost per seat, currency
and contract rate, input GST, the payment due date and free-release deadline, notes. A block
starts as a draft (AIR §36):
- Save draft keeps the form as it is, half filled or not (
create_quota_block_draft, thenupdate_quota_block_draft). Nothing is posted and the draft cannot be used anywhere. - Review and submit runs the desktop's checks, then a sheet shows the voucher to be posted
and the deposit. Yes, submit block calls
submit_quota_block: the row and its purchase voucher (Dr Stock-in-Hand for the paid seats, Dr GST Input Credit when there is input GST, Cr the supplier's ledger, the supplier transaction in the contract currency), derived by the database and written in one transaction (FIN-030). The voucher waits for a second person in finance (FIN-032). A refused submit leaves the draft, with the reason. - A deposit paid now (amount, the ledger it left, reference — shown to a person who holds
inventory.block_payments.record) goes with the submit throughrecord_airline_block_paymentand is kept on the row (FIN-044).
Drafts is a third tab on the list for people with inventory.create or inventory.edit:
each draft with its route, seats, PNR and a "Submit refused" chip when the last submit failed.
A draft opens to what the submit would post and what is still missing
(quota_block_draft_preview), with Edit (the form again), Submit block
(inventory.create, Yes/No) and Discard draft (a reason; the audit trail keeps what it was).
Edit (inventory.edit) changes the flight, legs, PNR, supplier, deadlines and notes;
Archive asks a reason and refuses while seats are allocated; Restore undoes it
(AIR §26).
Payments to the airline (inventory.view to read, inventory.block_payments.record to
record — the ticketing manager and executive, finance): paid so far, still owed and the
payments with their finance status; Record payment takes the amount (outstanding prefilled,
contract currency), the bank or cash ledger it left, the method, reference, date and a note,
shows the two lines for a Yes/No, and calls record_airline_block_payment — the desktop's
voucher, pending finance (FIN-044).
"Recorded — pending finance approval." Over the outstanding, a future date, an archived or
cancelled block, and a TDS supplier for a caller who may not deduct are refused in the
database's words. Record refund (the same permission) takes money back from the airline,
up to what was paid so far, into a bank or cash ledger → record_airline_block_refund — Dr the
account / Cr the supplier's ledger, pending finance; no tax on money back. A FIT's screen has
the same card (p_kind: 'fit').
Third-party sales (inventory.view; inventory.edit to sell): the seats sold on to other
agencies; Sell seats to another agency — the buyer from the party ledgers or typed, seats,
margin per seat (GST-inclusive), currency, GST, a reason below cost → Yes/No →
sell_block_seats_to_third_party, the desktop's sale with its vouchers and the buyer's invoice
in one transaction (INV-014); tap a sale for the buyer's
traveller Names, one per seat (INV-015), or to
Cancel this sale (inventory.b2b_cancellations.file or finance.create): seats, the
refund and the buyer's charge, a reason, optionally the airline's side → Yes/No →
file_b2b_cancellation — the seats move and a pending row is written; "Filed — finance
decides". The decision is finance's on the desktop, never the filer's
(FIN-032); the list shows filed / approved / rejected.
Seat releases — Operations → Seat releases, shown with inventory.view or any release
right (inventory.release.request, .approve, .approve_large, .file, or finance.create):
the desktop's seat releases on the phone
(AIR §16–§18, AIR §30). Waiting approval / Approved /
Completed / All. Each card shows the block as "code · PNR · route", the seats, the type (free,
paid, penalty), who asked and when, the penalty and expected refund, the status, and a
Decided by self chip when the requester decided it under inventory.release.approve_own.
- Request a release (
inventory.release.request; also Release seats on a block, with that block chosen) — a live block picked by "code · PNR · route", seats, the type and the deadline acted on (both optional: the preview decides them when left on Auto), a reason. The preview is priced by the database as the seats change (preview_seat_release): seats free, penalty, expected refund, the FOC impact note, the deadline, and the approval level (ACC-030: a manager up to the seat limit, CEO/GM above it). Yes/No names who will decide →request_seat_release. - Approve / Reject (
inventory.release.approve, orinventory.release.approve_largeabove the seat limit) →approve_seat_release(optional remarks) /reject_seat_release(a reason). Your own request shows them only withinventory.release.approve_own, and the confirm says it will be marked "decided by self" and audited; otherwise the card says someone else decides it (ACC-020). - Withdraw (the requester, or an approver) →
cancel_seat_release, with a reason. - File an approved release (
inventory.release.fileorfinance.create): the airline reference, the penalty charged and the refund (prefilled from the expected figures; a difference is kept as a variance, AIR §20) → Yes/No →complete_seat_release. When money moves, an airline cancellation filing opens pending refund; finance settles the refund on that record (CXL-030). This step posts no voucher.
Every refusal is shown in the database's words. The PNR label is the desktop's
(src/lib/blockLabel.ts); the app's copy is blockPickerLabel in apps/mobile/src/lib/seatReleases.ts.
Seat releases on a FIT, and the airline's release-policy bands beside the preview, stay on the desktop.
Not done from the app — each says so on screen and opens the desktop: re-costing a block (the desktop posts the adjustment), an initial-payment adjustment or reversal, penalty quotes, deciding a cancellation, write-offs, buying or editing a FIT, airlines, and sending the name list to the airline. Holds are on their own screen (Operations → Holds). Details: Airline blocks → In the native app.
3. Tour leader
The web field app's screens (Tour leader app), native: My groups, one group (people, rooms, plan, log, SOS, record a check-in with the outbox for no signal), and two tabs that are new:
- Live — the travellers of the current group who switched location sharing on, on a
map (below) and as a list: name, phone, when, accuracy, battery, and the distance and
bearing from the leader's own phone. Each row keeps Open in Maps, which hands over to
the phone's own maps app for directions. Positions update live (realtime on
TravellerLocation) and every 30 seconds regardless. The leader's own position is read on the phone to work out distances and to draw the blue "You" pin; it is not sent anywhere from this screen. - Programme — today's plan for the current group: add, edit, mark live or done, cancel
an activity (the same programme the office plans day by day on the web, the group's Itinerary
tab — INV-008); post a notice, pinned or not. An activity can carry a pin: Pick on map
(tap the map or drag the pin) or Use my position (reads the leader's phone once).
A day whose activities have a pin shows them on a small map — today open, other days
behind Show on map. A notice is pushed to the group's logins
(
trv_group_audience→push-send).
On the manifest a tap on a traveller calls them; when the booking has another contact or an
emergency contact (INC-005) the tap offers each
number by name, and the row names the emergency contact. More has the office, SOS,
Notifications, the outbox of check-ins and The travellers' guide — the step-by-step guide
the travellers read (TRV-002), opened at the trip type of
the group travelling now or next (/trip-guide).
A notification opens in the leader's own stack: a group notice opens the leader's Programme tab, never the traveller's.
The map (no maps SDK)
There is still no Google Maps key, so the app draws its own map: a WebView showing one
self-contained page with Leaflet 1.9.4 (from cdn.jsdelivr.net,
version and integrity hash pinned) on OpenStreetMap tiles (src/lib/liveMap.ts,
src/components/LiveMap.tsx). The same map serves the leader's Live tab, the group screen's
Live segment, the programme (leader and traveller) and the activity picker.
- Pins. Travellers in burgundy; a position older than ten minutes in grey with "Not seen since …"; the leader's own phone in blue; programme places in gold. A tap on a pin shows the name, when it was last seen and the battery. The map fits every pin when it opens; the crosshair button fits them again; Expand opens the same map full screen.
- Nothing leaves the phone. Positions are handed to the page inside the app; the page's content security policy allows only the two Leaflet files and the tile server, and no other request. The page has no file access and cannot ask for location itself.
- Offline. With no connection, or when Leaflet cannot be fetched, the map area shows a plain note and the list under it still works. If the tiles alone fail, the pins are still drawn on a grey background and a line says so.
- OpenStreetMap terms. OSM's tile servers are run by volunteers for light use; the map
shows the attribution they require ("© OpenStreetMap contributors", linking to their
copyright page — the link opens in the phone's browser). If the app grows to heavy use,
or the office wants satellite imagery, a Google Maps or Mapbox tile URL replaces one
constant (
TILE_URL) and the rest stays. - Accessibility. The map itself is labelled ("Map of 4 travellers …"), but a screen reader gets the same information from the list under it, which is why the list stays.
- Not built: routing or directions on the map (Open in Maps does that), a search box,
removing a pin from an activity (it can be moved;
trv_upsert_activitykeeps the last pin when none is sent), offline tiles, a meeting-point map on the My trip card.
4. Traveller
Five tabs (TRV-001):
- My trip — the departure at a glance: dates and the countdown, the tour leader (call,
WhatsApp), SOS (calls the leader and the office — nothing else), what is next on the
programme while travelling, Flights and hotels (every flight with its date and times
and the hotel in each city with its dates, nights and room type, from the office's
itinerary — before departure too, never the PNR —
TRV-014), the passengers with their passports and a
Scan passport button per passenger, the emergency contact, the money (total, paid,
balance, and every payment recorded on the booking: received, being checked, not
accepted — a received one has Receipt, the company's receipt as a PDF to open or share
(FIN-045) —
with Pay or report a payment while a balance is due), a My documents
row with a one-line status (visa granted, ticket issued, hotel confirmed), the location
sharing switch, and The Alhuda office: Call and Write to us (opens a new
request). A traveller with several bookings sees each, including a booking a partner made
for them once their login is linked to that customer record. A traveller with no booking
sees the planner card, Groups I lead if they lead one, and the office card.
A passenger on someone else's booking (TRV-001) — a
family member named on the booking with their own login — sees that trip too, marked
"Passenger on
's booking": the departure, leader, SOS, programme, flights and hotels, guide, their own passenger row with Scan passport, the others by name only, the emergency contact, their documents and their own location switch. The money card, the payments and Pay or report a payment are replaced by a line saying the balance is the booking holder's; the booking is not offered in Payments or in a new request. A traveller the office has appointed tour leader of a departure (FLD-007) also sees Groups I lead under the hero (and under Me → My account): the same list and cards as the tour leader's stack, with the outbox of unsent check-ins above it; a group opens the tour leader's screen (§3). The row appears when the login holds TOUR_LEADER(the profile is re-read on each token refresh, so a fresh appointment shows on the next open) and never for anyone else; their own booking is unchanged. - Tours — every departure on sale, as posters: image, dates, nights, the "from" price and the seats left. A departure the office marked cancelled, completed or departed, one that has already left, or an inactive one is not listed; one marked full shows "Waiting list". A tap opens the departure; Book this trip sends a booking request (below). The inbox icon opens Requests and quotes.
- Programme — the departure's flights and hotels (without the PNR), the activities day by day (each once — the office's planner and the leader's phone write the same list), the notices with the newest and pinned first. A day whose activities carry a pin ends with a small map of them (today open, other days behind Show on map — see The map); each such item also has Open in Maps for directions. Live: a change by the office or the leader appears without a refresh. Opening it schedules the programme reminders on the phone (below).
- Guide — the step-by-step guide for the trip type of the trip the tabs are looking at, and only that: a traveller on a domestic or international tour never sees the Hajj, Umrah or ziyarat guides, and a departure with no trip type shows the steps for every trip. With no trip yet there is no picker: only the steps for every trip, with "Your trip's guide appears here once you are booked" (TRV-022). Ticks live on the phone only, per login; nobody is told what a traveller has read. It is in the traveller's language: a step not translated yet is shown in English with a small Not translated yet note, and Urdu, Kashmiri and Arabic read right to left in their own fonts (Noto Nastaliq Urdu; Amiri). A verse or dua shows the Arabic large, then the transliteration, the meaning and the source. Only approved text is shown. The traveller reads the steps for everyone and for their gender from their record; where something is for one gender, a Show both switch shows the other's too, each marked, and with no gender recorded both are shown, marked. Where practice differs, the traveller reads their tradition's version; where it has not been written or approved, the guide says so and never shows the other tradition's text instead (TRV-018 … TRV-021). The rituals the company started with are written as Sunni practice: a Shia traveller is told "N steps here are written for Sunni travellers only. The Shia version is not available yet." until the office writes and approves the Shia versions. Change at the top opens the language and tradition choice, as in Me.
- Me — who is signed in, Guide (the language, shown in its own script, and the
Tradition: Sunni, the default, or Shia — the traveller's own choice, kept on their
account and not seen by the office), My details, My requests and quotes,
My documents, the location switch again, notifications, the Remind me before each activity switch, what
the company keeps (links to the privacy page), how to reach the office, sign out. Sign out
is one door for every stack (
signOutinsrc/lib/session.tsx): it removes this phone's push token while the session can still say whose it is, stops location sharing, cancels the reminders, drops every per-person copy on the phone (the offline caches, the leader's saved manifests, the guide ticks, the chosen booking) and the in-memory query cache, then ends the session. The leader's outbox of unsent check-ins is kept on purpose (FLD-004).
Tours and Book this trip (TRV-008)
The list is the website's posters (public_departures) merged with every departure on open
sale (public_groups) — both public reads, so either alone is enough to show the list
(src/lib/tours.ts). Neither function answers a departure the office has marked cancelled,
completed or departed (migration 20260930190000_the_traveller_app_works.sql), and a request
for one is refused; the app also drops a departure whose date has passed. A
departure shows the poster image, the trip type, the seats left (or "Waiting list"), departs /
returns / nights, what is included and not, the hotels with their distance from the Haram,
the day-by-day plan and the notes; a departure with no poster yet says the office has not
published its details. A notice on the page says how booking works: the request goes to the
office, the office calls and sends a quotation, the traveller accepts it in the app, and the
office creates the booking. Nothing is charged in the app.
Book this trip opens a sheet: who is travelling (first and last name; date of birth and
passport number optional; the signed-in person is filled in first; add or remove
travellers), the room preference (double, triple, quad, any), a phone the office can call,
and a message. Send booking request calls customer_create_request with type
group_interest and the departure's id — the same request the website's customer portal
sends — and the message lists the travellers, the room, the phone, the message and the
departure date. The sheet says plainly that it is a request and that nothing is booked until
the office has created the booking from the accepted quotation. A traveller never writes a
booking (TRV-008,
LC-006).
Requests and quotes (TRV-009)
Reached from the Tours tab, from Me, and from My trip (Write to us opens a new request; Pay or report a payment opens Payments). Three segments:
- Requests — every request the traveller sent, newest first, with its type (booking
request, question, document, change, cancellation), its status (open, with the office,
approved, declined, closed) and the office's reply. New request sends a question, a
document, a change or a cancellation request, against one of the traveller's bookings if
they have any (
customer_create_request). A cancellation request says that charges follow the policy on the booking and the office confirms the amount first. - Quotes — the quotations the office sent: number, the trip and its dates, the lines,
the discount, the total, the date it is valid until and the office's notes. One that is
waiting for an answer has Accept (a Yes/No with the total; then a message that the
office creates the booking and calls about payment) and Decline (asks why, so the
office can revise) —
customer_respond_quotation. An expired one says so and asks the traveller to request a new one. The segment's label counts the quotations waiting. - Payments — each booking with a balance: total, paid, balance, and two buttons.
Pay online (bookings priced in rupees): the amount (the balance is filled in, capped at
it), then Razorpay Checkout — card, UPI or netbanking — in a full-screen page inside the
app. The app asks
razorpay-orderfor the order (the phone holds no Razorpay secret) and records nothing itself: when Checkout reports success the page says "Payment received — we are confirming it with Razorpay" and watches for up to a minute forrazorpay-webhookto record the verified receipt, then "Confirmed" or "Still confirming; it will show on your trip shortly" with the order number. Closing Checkout records nothing. A payment started and not yet confirmed shows on the card as "started, waiting for Razorpay" for an hour. When the owner has not yet set the Razorpay secrets, the page says "Online payment is not switched on yet — use bank transfer / UPI and tell us the reference" and points to the other button. API → Online payment. I have paid: amount, how (bank, UPI, cheque, cash, card) and the reference the office needs to match it to the bank statement (required except for cash). It is recorded pending throughcustomer_submit_payment; the sheet says it does not move money, and the balance changes only when the office verifies it (FIN-032). The payments already recorded are under Money on My trip.
My details
Your customer code (CU-000123, read only, selectable) sits at the top — the code to quote
to the office (PTY-001). The traveller's own customer record: name (as on the passport; the office prints names in
capitals), mobile number, email (read only — the office changes it), date of birth and
address (district, state, PIN). Only what changed is sent, through
customer_update_profile. Once a visa case has started the database refuses a change of
name, date of birth or passport details and the screen shows that message as it comes.
Request account deletion (AUD-025)
Me → Request account deletion (src/app/delete-account.tsx). The screen says what is erased,
what the law keeps and what happens next (AUD-025 … AUD-027),
then asks for the word DELETE and the password. It reads account_deletion_status()
first:
- The button is always Send the deletion request. Nobody erases their own account
(decided 2026-10-01): the
account-deletefunction checks the password and the database files a request. The screen then reads "Your request has been sent. The office will review your account and contact you." The person stays signed in; nothing is erased until the office approves, within 30 days. - Something the office settles first (a trip not finished, a balance, money the company holds for the person — an overpayment, a refund due or waiting, an unused advance, an overpaid invoice — a payment waiting for the office, an open request, an unpaid invoice, a paused account) — the list is shown in plain words ("We hold ₹5000.00 for you on booking BK-4 — the office will settle it with you first.").
- A declined request shows the office's note and may be sent again.
A wrong password is refused with the function's message and throttled like sign-in. A staff login is not offered this screen's action (the office closes staff accounts). The same card is on the website (Portals).
Location sharing (FLD-006)
The switch is the traveller's and nobody else's. Switching on asks the phone's location permission; with "always" the phone reports in the background (Android shows a persistent notification while it does), with "while using" only while the app is open, and the screen says which. The app says plainly who sees it: your tour leader and the Alhuda office, only during the trip. Off is one tap.
What the database enforces, whatever the phone does: only the traveller's own login can
switch it on, only for their own booking; a position is stored only from two days before
departure to the day after return; one row per person, overwritten, deleted the moment the
switch goes off; the leader and staff with groups.view read it, nobody else. Details and
the migration: FLD-006. Test:
supabase/tests/the_app_for_travellers.sql.
Limits: the phone must have signal to report; a phone that is off, out of battery or has location denied reports nothing, and the leader's list shows the last time it did. Nothing is stored between reports — there is no track.
Passport scanning (TRV-005)
Same scanner as staff. A traveller can scan for a passenger on their own booking only. The
result is a pending submission: the passenger changes only when a person with
bookings.edit applies it on the booking screen (or the desktop). The trip shows
"passport waiting for review" until then. A rejected scan can be redone.
The photo goes to the traveller's own folder on the company drive, and to no other:
upload-customer-doc accepts a traveller's upload only for the customer record whose
login is theirs. So the photo is saved when the scanned passenger is the traveller
themselves — the main passenger, or the only passenger on the booking; for any other
passenger on the booking only the details are sent and the message says the office
attaches the photo. A typed MRZ has no photo to save.
My documents (TRV-011)
Reached from My trip and from Me. One list, grouped Visa · Tickets · Hotel · Passport ·
Other, from trv_my_documents() — the traveller's own and nobody else's
(TRV-011):
- Visa — one line per passenger's visa case with where it stands (not started, applied, in process, with the embassy, granted, refused) and the files on it: the visa PDF and any other file the office attached. A file opens in the in-app browser.
- Tickets — one line per passenger's ticket record: the ticket number once issued and the status (not yet, name being checked, on hold, being issued, issued). No PNR. Once the office has issued the booking's e-ticket sheet (TRV-013) the line opens it as a PDF — Alhuda's own sheet with the flights, the PNR and every traveller's ticket number, which says on its face that it is not the airline's own ticket document. Until then the line says the office has not issued it yet.
- Hotel — each passenger's stay: hotel, city, check-in and check-out, room type and room number, status. No price. Once the office has issued the stay's hotel voucher the line opens it as a PDF; until then it says so.
- Passport — the passport scanned in the app (filed on the company drive under the traveller's own record) and a passport scan the office attached to the visa case.
- Other — any other paper under the traveller's own record (a photo).
Tapping a file calls the edge function drive-file with the document id; the function runs
trv_my_documents() as the caller, refuses anything not on that list, and answers a link to
itself that lives ten minutes, which streams the file from the company Shared Drive
(ACC-074). The in-app browser shows the PDF or image. There is no fallback to a Drive
link: a file on the drive is shared with nobody. The partner's documents card, the employee's
My profile and the staff record screens open their files the same way (kinds
partner_file, employee_file). A line without a file (a ticket record or a hotel stay the
office has not issued yet, the visa case itself) opens a note with the details and where the
paper is. An issued e-ticket sheet or hotel voucher opens a sheet with Open and Share
(Sharing a document). The list is saved on the
phone and opens offline with "Last updated … · offline"; opening a file needs a connection.
Sharing a document (TRV-015)
An issued e-ticket sheet, hotel voucher or payment receipt opens a sheet with three actions (TRV-015):
- Open — the PDF in the in-app browser, from a fresh ten-minute
drive-filelink. - Share the PDF — the app downloads the PDF through that link into its own cache
(
expo-file-system) and hands the file to the phone's share sheet, so it can go to WhatsApp, email or Files as an attachment. The link itself is never shared: it dies in ten minutes and opens nothing for anyone else. On iPhone this works with the current app (React Native's Share with the file). On Android it needsexpo-sharingin the build, which the current APK does not have: there the button reads Share (opens the PDF), says so, and opens the PDF, whose viewer has its own Share. A build that addsexpo-sharingshares the file with no change to the app's code. - WhatsApp (staff and partner, when the booking has a mobile number) — opens WhatsApp on the customer's chat (staff: the booking's customer; partner: the first traveller with a phone) with a short note: which document is ready and where the customer finds it in the app. No link and no attachment: send the PDF with Share.
Programme reminders (TRV-012)
Local notifications, planned on the phone from the programme — no server, no push (TRV-012):
- 30 minutes before every item that has a time (a flight, a bus, a ritual, a meal, a meeting point), with the time and the place: "In 30 minutes: Tawaf together — 21:00 · Masjid al-Haram".
- At 20:00 the evening before a travel day — a day with a flight or a bus — naming that day's first timed items: "Tomorrow: Hotel → Airport — Bus Hotel → Airport at 06:30 · Flight … at 09:40 — Mon 28 Sep. Pack tonight and check the time."
The phone's schedule is kept equal to the programme: a moved, cancelled or finished item's reminder is cancelled and a new one scheduled; the same item is never scheduled twice (one stable id per item and per day); at most the next 64 are held (the iOS limit), nearest first. The plan is refreshed whenever the Programme tab is open and the programme changes (the realtime invalidation), and each time it is opened. Remind me before each activity under Me → Settings switches it off (everything of ours is cancelled) or on (the phone's notification permission is asked for). The switch lives on this phone only. Reminders are scheduled the first time the Programme tab is opened after sign-in; a phone that never opens it holds none.
5. What is live
GroupActivity, GroupNotice, TravellerLocation and TripGuideStep are in the
realtime publication; row security decides what reaches each phone. The programme, the
notices, the guide and the Live list update without a refresh. Saving or approving a part or a
translation touches its step, so an open guide reloads; a phone receives the change of a step
marked Sunni or Shia, or not yet approved, only at its next refresh (row security hides those
steps from a traveller). Everything else refetches when a screen comes into focus and on
pull-to-refresh.
6. Without signal
The last group list and manifest open offline (leader); check-ins queue in the outbox and sync once (FLD-004). The MRZ reader works offline.
For the traveller, My trip (trv_my_trips), the Programme (trv_trip_programme), the
Guide (trv_guide, one copy per language, tradition and Show both) and My documents (trv_my_documents) are saved on the phone
after every successful load, scoped to the login (src/lib/offlineCache.ts, TRV-012). With no
connection the saved copy is shown at once with "Last updated
Opening the app without signal: the last good profile for this login is kept on the phone, so the person lands in their own stack (traveller, partner, leader, staff) rather than a guess — the same copy the app opens from with signal (§11). Without that copy the app stays on its opening screen, says it is loading the account, and keeps trying until the phone is back on the internet.
A session that ends behind the person's back (the refresh token refused, the account disabled) cleans the phone the same way a tap on Sign out does, and the welcome screen says "Your session ended. Please sign in again."
Every failure a screen shows is one plain sentence (src/lib/errors.ts): a database refusal
reads "Your role cannot do this. Ask the office if you need it.", never the permission code;
a network fault reads "No connection…"; nothing shows a stack trace, a code, or the words
null or undefined.
7. Not built
- A maps SDK. There is no Google Maps key; the map is OpenStreetMap in a WebView (§3 · The map) and has no directions, search or offline tiles.
- In the partner stack: partner-branded invoices to their customers, reports, and a PDF upload — the web partner portal does these (More → Web partner portal). Releasing a hotel bed reservation or a seat block: no partner function exists (web or app); the office releases it. A hold expiry countdown: nothing expires until PTR-042 is decided; My holds counts to the check-in or departure and says so. Funds on account without a booking or invoice: recorded by finance, not claimed from the app (PTR-081).
- Incident reporting from the app. SOS calls; the desk records the incident (INC-001).
- Re-costing a hotel lease, and changing a stay's rooms, rate or dates (issue #559 step 5). The desktop reverses and re-posts the voucher from the browser, and its re-posted lease voucher debits Hotel Expenses where the first one debited Stock-in-Hand. Finance has to decide the account before this moves into a database function (FIN-030, "Not covered yet"). The phone assigns rooms to a departure and takes them back; both derive their vouchers in the database.
- A payment to a supplier other than for an airline block or FIT, a supplier bill, TDS. The
desktop posts these from the browser on
finance.create; there is no database function that applies TDS, the approval limits and the foreign-currency settlement (FIN-021, ACC-030). A payment for a block or FIT is recorded from that block (Suppliers → Seats to pay for). - Offering hotel beds to partners, changing a seat offer's price, reopening a closed offer. The desktop has no screen for these either (INV-016).
- The airline's own e-ticket PDF and the hotel's own confirmation. What the app opens is Alhuda's e-ticket sheet and hotel voucher, issued by the office from the ticket numbers and the room allocation (TRV-013); the airline's PDF and the hotel's confirmation number are not stored.
- Sharing a file on Android with the current APK. The build has no
expo-sharing, so Share opens the PDF instead (Sharing a document). Sharing a visa file or a passport copy is not offered on either platform. - A receipt for a payment against a group invoice with no booking (the partner's invoice payments), and a receipt in My documents — it is on the Money card (FIN-045).
- Reminders without opening the Programme tab. They are planned on the phone from the programme when that tab is open; the office's push notices reach the phone regardless.
- An alert when a traveller stops sharing or strays.
- Editing or deleting a notice. A correction is a new notice.
- iOS builds and store listings. Android APKs only: a debug APK from the workflow, a release APK from the office Mac.
- Online payment for a group invoice or in a currency other than rupees. A Razorpay order names one booking in INR; an invoice, a foreign-currency booking and funds on account are paid by transfer and reported with I have paid. Refunds of an online payment go through finance's refund request, not Razorpay from the app.
- Pay online by staff. Deliberately not built (owner, 30 Sep 2026). Only the booking's
customer or payer and its partner start a Razorpay payment;
razorpay-orderrefuses a staff login with "Staff don't pay online. Record the customer's payment instead." (FIN-032). - A receipt or proof with a payment claim.
customer_submit_paymentaccepts a private storage key; the app sends none (p_proof_storage_keynull). The traveller gives the reference instead. - A cancellation with a policy preview. A cancellation is a text request under Requests; the office works out the charge under the booking's policy and confirms it.
- A group notice pushed to a passenger on someone else's booking. They read it on the Programme tab; the push and the inbox row go to the booking's customer and payer (TRV-004).
- An invoice or a statement to print. The web customer portal prints them; the app lists the payments and their state, with the receipt of each verified one (FIN-045).
- A WhatsApp button for the office. The office's WhatsApp number is not set in the app;
Write to us sends a request and Call dials the office line, +91 91073 33333
(
OFFICE_PHONEinsrc/lib/constants.ts, the one place the app keeps it; every "Call the office" button — traveller Me and home, partner home and More, tour leader More, SOS — dials it). - Foreign-currency vouchers from the phone. Finance → New entry takes rupees only; a voucher in another currency, with its rate, is written on the website (FIN-034).
- A booking written by the traveller. By design, not by omission: "Book this trip" is a request, and the office creates the booking (TRV-008).
- Automatic translation of the guide. Staff write every language by hand; nothing is drafted by a machine (owner, 3 Oct 2026). Translations are edited on the phone only — the website has no guide editor. The app's own buttons and labels stay in English: only the guide is translated (TRV-018).
- A list of every translation waiting across trip types. The guide editor narrows one trip type at a time.
8. Partners
The business partner's stack, one login for the agency (PTR-001 … PTR-082).
Every read is the agency's own rows under row security; every write is a portal function
that takes the partner from the session (PTR-010, PTR-011).
Permissions: none — the AGENT role and Agent.userId; PERMISSIONS.md §6.5d.
Migrations 20260929100000_the_partner_in_the_app.sql and 20260930160000_the_partner_app_works.sql;
tests supabase/tests/the_partner_in_the_app.sql, supabase/tests/a_partner_who_leads_a_group.sql
and supabase/tests/the_partner_app_works.sql — the last runs every read the partner screens make
as an active and as a pending AGENT login.
Register your agency (PTR-082)
Agency name, contact person, mobile number, email, password, PAN, GSTIN (optional), office
address and the partnership agreement — the website's partner sign-up. The partner-signup
function creates the login unconfirmed, writes the User row, the AGENT role and a
pending Agent row, and sends the confirmation email itself (the same reasons as
customer-signup: one message for every outcome, throttled, "send the email again"). After
confirming and signing in, the partner lands on Home with the document checklist.
Home
The agency and its status — Pending approval (the office is checking the documents;
what is still needed), Active, Suspended (bookings paused; can still see bookings and
report a payment) or Closed — from partner_my_account. Once approved: outstanding
(unpaid bookings with GST plus open issued invoices) against the credit limit (0 or blank
means no limit is set — PTR-030), Statement and New booking
(active only, PTR-002), and the next departures with their
travellers. A partner who also leads a departure (PTR-004) sees
Groups I lead with the outbox banner above it; a group opens the tour leader's screen (§3).
Only that partner asks for the list: fld_my_groups refuses a login without field.view.
Under the agency's name: Your partner code BP-0012 (selectable, to copy), the code to quote to the office and to put in the booking import (PTY-001, PTY-005). It is also the first row of More → Agency details.
Documents (PTR-080)
On Home while pending and always under More → Documents: the checklist (PAN, identity,
proof of business required; GST certificate when a GSTIN was given; address proof and a
cancelled cheque asked for), what is on file, and Upload per row — a photo from the
camera or the gallery (resized on the phone) or a PDF from the phone's files (under 6 MB),
sent to upload-partner-doc, which files it on the company Google Drive under
Partners / "<agency> (<id>)" and records it as the partner through partner_add_document
(never a link, never public, at most 20). Approval is the office's — on the desktop partner
record or the staff app's Partners screen, where each document is checked and marked OK or
rejected with a note written for the partner. Each file shows that check: accepted,
waiting for the office, or rejected with the office's note in red and "the office asks for
a new one"; a kind whose every file was rejected counts as still needed again
(partner_my_account, 20260930160000). The review notification opens Documents. The app
shows "pending approval" until the office activates the account.
Bookings and New booking (PTR-020 … PTR-031)
The agency's bookings with their stage in the office's checks, total, paid and balance; a
booking shows the travellers with category and rate, the payments (verified and waiting),
the emergency contact (add or change — set_booking_emergency_contact) and the tour-leader
consent answer, and Documents — the e-ticket sheet and hotel voucher(s) the office issued
for the booking (TRV-013), read from IssuedDocument under the
agency's own row policy and opened through drive-file (kind issued); the partner cannot
issue. A document opens a sheet with Open, Share and WhatsApp the first traveller with a phone
(Sharing a document); a verified payment has Receipt
(FIN-045). Ask the office about this booking opens More → Requests with a new request on that
booking already chosen. A booking the office sent back says Sent back for correction on the
list and on the booking, and the booking has Send for approval: the partner writes what was
corrected (three characters or more) and it goes back to the team that sent it back
(partner_resubmit_booking, PTR-023). The database's refusal is shown
in its own words — a booking finance rejected, a price that changed since the send-back, a booking
not ready, or a paused account — and nothing changes. On success the booking and the list reload.
New booking is for an active partner only: a pending, suspended or
closed partner is told why instead (pending: the office is checking the documents; what is still
needed; a notification comes when the account is activated) and nothing is read. It is three
steps: the departure — the same list the web partner wizard offers: every departure
public_groups returns whose status is planning, open or active (the web's rule: not closed,
not full, not departed, or the office's override), each with the adult / child / infant price
from the rate sheet and the seats left (no count when the departure has no capacity set). One
the database would refuse stays on the list, marked, with the reason: No price yet (no
published rate sheet — PTR-020), Leaves today, or Full; it
cannot go past this step. Prices are printed in the rate sheet's own currency — a departure
priced in riyals shows SAR 4,600, never ₹, and says Prices in SAR — because the booking is
made in that currency (PTR-020); the account balance and credit limit
stay in rupees. The travellers (scan a passport or type: name, date of birth, gender, passport,
nationality, phone, email, address — nothing else; the gender is sent as male / female,
the only spelling BookingPassenger holds; the category is previewed from the date of birth
on the departure day), and the review (the price per category and the estimated
total, the credit check previewed, seats left, the emergency contact, the consent switch,
notes). Create booking asks Yes/No (PTR-070) and calls
partner_create_booking, which prices from the rate sheet, reserves the seats, checks the
credit limit and starts the booking PENDING_OPS on account in one transaction — the app's
figures are a preview and the database's are the truth. The booking is then announced by
booking_created_notify (LC-007): the sales approvers
get their inbox row and a push, the agency's b2b_booking and the traveller's
booking_created e-mails are queued for the dispatcher — the same call the desktop and the
partner's web route make, once per booking; a refusal comes back as announceWarning on the
result and never undoes the booking. The emergency contact and consent follow; if either
fails the success screen says so. A second identical submission returns the first booking
and announces nothing twice.
Family (PAX-036, PAX-021). A partner's booking has a Family card: the families its travellers are in (a traveller on another agency's or the office's booking reads "A traveller on another booking"), Not family, Not recorded, and each child's guardian with Set guardian. While anyone is not recorded it asks Who is family here? — the same choices as the office's; the same question follows Create booking when there are two or more travellers. The database lets a partner record families only among their own travellers; a family that includes someone else's traveller cannot be edited here — ask the office. Removing one member or marking one traveller Not family is done by editing the family or through the question.
Money (PTR-030, PTR-081)
Statement — the same rows the web ledger builds: bookings (gross) and issued invoices as
debits, verified invoice payments, approved commissions and receipts on account as credits,
with the running balance. Invoices — the group invoices addressed to the agency.
Waiting — the payments reported and not yet verified. Pay online — pick one of the
agency's bookings with a balance (rupees only; an invoice is paid by transfer), then the same
Razorpay page as the traveller's: razorpay-order checks that the booking is the agency's
(Booking.agentId), caps the amount at the balance and creates the order; razorpay-webhook
records the verified receipt on the booking (API → Online payment).
I have paid — amount, how (bank, UPI, cheque, cash, card), the reference (required except
for cash), against one booking or one issued invoice with a balance: partner_submit_payment
records it pending; the balance changes only when finance verifies it
(FIN-032). The app itself moves no money, records no
payment and takes no proof file.
Offers and seat sales (PTR-040, PTR-041, PTR-060)
From Home and More. Hotels — the beds the office offers to partners (partner_hotel_offers:
city, stars, distance from the Haram, room type, dates, beds left, price per bed; never the
contract cost). Reserve takes the beds and a note, shows beds × price and the account's
outstanding against its credit limit, asks Yes/No and calls partner_reserve_beds, which
takes the beds off the offer's one counter for this agency in one statement
(PTR-040); the office bills them. Seats — Seats on offer
(public_b2b_offers: airline, flight, route, date, seats left, the published seat price):
Buy takes the seat count, previews the total and the credit check, then opens the booking
wizard with the offer — every traveller at the seat price, infants free — and
partner_create_booking with the offer takes the seats, checks credit and starts the booking
PENDING_OPS in one transaction. My seat blocks (partner_seat_inventory): the seats the
agency bought, sold and left from the one counter, the sales under each; Sell seats
(seats, markup, buyer) previews the selling price, total and margin, asks Yes/No and calls
partner_sell_seats (PTR-041). Every offer price is printed in the
offer's own currency (SAR 350, not ₹350, for a riyal offer). My holds — the beds reserved
and the seat blocks held, each with a countdown to the check-in or departure and its value.
Nothing expires (PTR-042 is open) and nothing is released from the
app or the web portal — the screen says so and gives the office number. A refused reservation,
sale or booking shows the database's words with the next step. A partner who is not yet
approved sees the tabs but no open offers (public_b2b_offers refuses; partner_hotel_offers
lists only their own reservations).
More
Agency details (trading name and contact person are the partner's to change —
partner_update_profile; PAN, GSTIN, phone, email, address, commission and credit limit are
the office's), Documents, Offers and seat sales, Requests to the office (a question, change, document,
cancellation or payment query on one of the agency's bookings — partner_create_request; the
office's answer and a declined request's reason show under it), Notifications, the web
partner portal, the privacy page, Request account deletion (the same screen as the
traveller's — always a request the office reviews; once approved the agency's business record stays, AUD-026), and sign out. A notification opens the partner's own screen:
a booking opens the partner's booking (not the staff booking screen), a document review opens
Documents, and a link to an office queue (approvals, finance, the group list) opens nothing.
Partner and tour leader (PTR-004)
Staff appoint a partner's login on a departure from the desktop group page or the app's
group screen; the appointment gives it the TOUR_LEADER role and nothing else, and the
dismissal takes it away (FLD-007). The login stays a partner:
it signs in at the partner door, and auth_is_staff() says no — a login that holds a portal
role is never staff, so no blanket staff policy opens to it. It holds field.view and
field.checkin and nothing else beyond its agency; fld_my_groups, fld_group_manifest and
fld_record_checkin answer for the groups it leads, as for any leader. In the app it reaches
them from More → Groups I lead.
8. Inventory — food, transfers and group services
The staff stack's Operations tab links to Food and Ground transfers; each shows
only when the person holds its view permission (food.view; inventory.view for
transfers — there is no ground.* permission).
- Food — every meal contract with its caterer, city, dates, rate per pilgrim per day and
meal-days drawn / free, red when over what it bought. New contract (
food.create) callscreate_food_inventory, which writes the row and posts its purchase voucher from the database — Dr Stock-in-Hand [+ GST Input Credit] / Cr the caterer's ledger, with the caterer's bill — the same lines the desktop builds in the browser (FIN-030). A riyal contract without a typed rate takes the day's rate from Finance or is refused (FIN-034). A voucher that cannot post leaves the contract standing, is queued for finance, and the screen says so. Edit (food.edit) callsupdate_food_inventory; the free figure is the database's and a total under what is drawn is refused (INV-042). Full page: Hotels → Food in the native app. - Ground transfers — every vehicle on a route with seats taken and free, operator, price
and departure. New transfer (
inventory.create) and Edit (inventory.edit) are direct table writes under the row policies, as on the desktop; nothing posts on creation — the expense and the operator's bill post when a departure is put on the vehicle. - Group → Services (
groups.edit) — the rooms, meal plans and transfers on a departure with the capacity each uses; Assign pickers offer inventory with free capacity inside the departure's dates; Remove gives the capacity back and reverses the voucher. Each is one database function (assign_hotel_rooms,assign_food_plan,assign_ground_transferand theunassign_*pairs) that reserves capacity under a row lock and posts in the same transaction; the app never writes a counter. Full page: Groups → Services from the app. - Place travellers (
bookings.edit) — on each hotel, meal plan and transfer of the departure. It lists the group's travellers by booking with tick boxes, plus "whole booking" and "everyone". Travellers already on that service are shown ticked and cannot be changed; travellers who did not buy it are greyed (PAX-034). A hotel takes an optional room-share label; a meal plan an optional board (full board, half board, bed & breakfast). One call toplace_travellersplaces them all (PAX-035), and the screen says in one line how many were placed, already there and refused, with the first reason — for example a full hotel or a passport that expires too soon.
Not built: deleting a contract or a transfer, the food CSV import, one threshold for every contract, linking flights, and taking one traveller off a room / plan / seat. Holds on hotel rooms are on Operations → Holds.
9. Profiles and pause
My profile (More → My profile, every stack; src/app/my-profile.tsx) is profile_me(): the
login, roles, and one line — Account: active or Account: paused (read-only)
(ACC-070). Staff also see the office's fields (designation, code,
department, reports to, joined; Aadhaar and PAN as the last four only — ACC-071),
edit their own contact fields (profile_update_me: phone, personal email, address, emergency
contact, date of birth, blood group, gender — a Yes/No before saving), and file their documents
by photo (upload-employee-doc → the company drive under Employees / "<name> (<id>)" →
employee_add_document). A partner sees their login and access here and their agency under
More as before. Editing the office's fields and removing a document are the desktop's.
Users (More → Users, admin.users.view; src/app/admin/users.tsx): every login searched by
name, email, username or phone (admin_users_list) with status, access, roles and designation.
Tap one for the employee record (src/app/admin/users/[id].tsx — employee_record,
ACC-071): Overview (identity with Aadhaar and PAN as the last
four only, employment, reports to and who reports to them, roles, permission count, groups led),
Docs (each file with its review; Review with admin.users.edit — accept, or reject with a
note the person reads — employee_document_review), Access (authenticator, sign-ins, sessions,
devices with alerts) and Activity (what they did and what was done to their account); with
admin.users.edit, Pause with a reason or Resume (admin_pause_user /
admin_resume_user). The database refuses pausing yourself, anyone above you, and a paused
administrator; the screen shows the refusal as it comes. Editing the office's fields is the
desktop's.
Role requests (More → Role requests, shown with roles.request, roles.approve or
roles.apply; src/app/admin/role-requests.tsx): a staff role changes only when it is requested,
a CEO approves it and an admin applies it (ACC-077).
Tabs For the CEO (roles.approve), For an admin (roles.apply), Mine and History
(role_change_requests). A CEO taps Approve (Yes/No) or Reject (a reason) —
role_change_ceo_decide; an admin taps Accept and apply (Yes/No; the role changes then) or
Reject — role_change_admin_decide. A button appears only where the database says this login
may decide (ACC-078); otherwise the card says another CEO or admin decides. The
admin's work-inbox job opens this screen. Asking for a change and withdrawing one are on the
website only.
Partners (More → Partners, agents.view; src/app/admin/partners.tsx): every business partner
the login may read, pending registrations first with Approve / Reject on the row
(partners.approve — a GM, CEO or Admin, PTR-095; a reason required —
partner_set_status, which records the reason and tells the partner what changed, never why;
PTR-001).
Add partner (partners.create; a button above the list, + in the top bar, the New
partner card on Home; src/app/admin/partners/new.tsx,
PTR-097) — the desktop's "Add Business Partner" form: company (required),
contact name, phone, e-mail, address, PAN (required, ABCDE1234F), GSTIN (optional, 15 characters),
commission (0–100 %) and credit limit (₹, up to ₹1,000 crore). The commission and the credit
limit show only to someone holding agents.commission.set / agents.credit_limit.set (finance and
leadership, PTR-030); for anyone else the screen says finance or
leadership sets them, and they are saved as 0. It asks Yes/No with the terms, then saves through
partner_staff_create, which checks the permissions and every field again. A PAN or GSTIN already
on another partner is named (to someone who may read partners; anyone else is told only that it is
registered), and Add anyway adds it (two branches of one agency may share a PAN); both then
carry a dim "Shares PAN with …" label on the list. A double tap adds the partner once. The new partner is pending: the app opens its
record and says it waits for approval by a GM, CEO or Admin, and that sales@ has been e-mailed
(PTR-096). Its BP- code and its receivable and payable ledgers are made
at once. Not built here: the desktop's "create a portal login" — add the login from Partners on
the desktop (the row's menu → Access key).
Tap a partner for the partner
record (src/app/admin/partners/[id].tsx — partner_record, PTR-083).
It opens with a profile header (PTR-091): a soft burgundy band, the
agency's initials in a circle, the name, the partner code, the tier, the verified mark (green
when the partner is active and every required document is accepted and in date; the reason is
written under it), the city, "Partner since" (DD/MM/YYYY) and the relationship manager (tap for
their record), with Call and WhatsApp (the primary contact, else the first contact with a
number, else the agency — PTY-008) and Ledger (opens the Money tab).
Under it, the status with Approve / Reject / Suspend / Lift suspension / Deactivate / Reactivate and
Pause / Resume login. The tabs start with Timeline (everything on the agency, newest first,
with Add note for partners.edit), then About (the agency, the login and its access, the status
history with reasons, the groups the login leads), Docs (each registration document with what is
still missing and Review — partner_document_review, PTR-080),
Bookings, Money (outstanding against the credit limit, claims waiting, the last payments) and
Notes. About also shows the partner profile (PTR-084 … PTR-089):
the performance numbers by financial year, the business and registrations (an expired Haj/Umrah
licence says expired), the office relationship (relationship manager — tap for their record —,
tier, region, onboarded on, source, internal notes) and the contacts, each with Call and
WhatsApp (PTY-008). Notes lists the office's notes (kind, text,
follow-up date, who and when, DD/MM/YYYY) with Add note for partners.edit (partner_note_add,
asked Yes/No; a note is never changed or deleted). Editing the agency's fields, the profile and the
contacts, filing a document for the partner, the Access details (sessions, devices), the travellers
list and the full ledger are the desktop's. Data layer: src/lib/records.ts, src/lib/partnerProfile.ts.
A partner's My profile (More → My profile) also shows the address and website with Edit
(partner_update_my_profile), the contacts with Add and, on a contact, change or Remove
contact (partner_contact_save / partner_contact_remove — own agency only, PTR-086),
and, read-only, what the office keeps: legal name, business type, IATA number, the Haj/Umrah
licence and the relationship manager with Call / WhatsApp. Every save asks Yes/No
(src/components/PartnerSelfProfile.tsx).
A paused account keeps its session and its alerts. Every screen carries a banner at the foot
("Read-only: your account is paused. Contact the office." — src/components/ReadOnlyBanner.tsx,
rendered once under the root Stack from User.accessMode). Buttons stay; the database refuses
each write with the same sentence and the screen shows it. The person is told by notification
when paused and when resumed.
A document is a photo from the camera or the gallery (sent as JPEG) or a PDF or image picked
from the phone's files (expo-document-picker); a PDF up to 10 MB, and the screen says so.
upload-employee-doc accepts JPEG, PNG, WebP and PDF only.
Not built: editing the office's profile fields or a partner's agency fields from the phone; uploading a document on someone else's behalf; a traveller's record (Customer 360 is the desktop's).
10. Updates without a new APK
The app takes fixes over the air (PLT-061). Screens,
wording and logic are JavaScript; the office publishes a new bundle with EAS Update and every
installed app on the production channel picks it up. Nobody reinstalls anything.
How a phone gets it. Each time the app is opened from closed, it asks Expo for a newer
bundle for its runtime version (checkAutomatically: ON_LOAD). It does not wait for the
answer (fallbackToCacheTimeout: 0): the app opens at once with what it already has, the new
bundle downloads in the background, and it runs the next time the app is opened from
closed. Switching away and back is not a restart. With no signal the app opens as before.
Which bundle a phone runs. Staff: More → About reads 0.2.0 (as installed) for the JS
that came inside the APK, or 0.2.0, update 1a2b3c4d of 27/09/2026 once an update has been
applied (src/lib/appVersion.ts). The traveller, leader and partner stacks do not show it.
What still needs a new APK:
- a new or upgraded native package (anything added with
npx expo installthat has Android code — a camera, maps or payments library, an Expo SDK upgrade); - a new Android permission, or a changed permission prompt text;
- anything in
app.jsonorapp.config.js— name, icon, splash, package, plugins, the Firebase file for notifications; versioninapp.json. It is the runtime version: an update is only offered to APKs built with the sameversion. After any of the above, raiseversionand install the new APK; updates published after that reach only the new APK.
Not built: a "Restart to update" button or a prompt; updates on iOS (no iPhone build exists yet); updates to the debug APK from the GitHub workflow — a debug build loads its JavaScript from the developer's computer and ignores updates. Only the release APK built as in Android app → Updates takes them.
11. How fast the app opens
A phone that was signed in before opens straight into its stack, without waiting for the network (PRF-013):
- The profile (name, roles, permissions, paused or not) is the last good copy kept on the
phone for the saved login. The fresh one comes back in one call,
app_bootstrap()(it was four requests in two waves), and replaces it: a permission taken away disappears, a paused account gets its banner, a changed role moves the person to their new stack, and a session the server refuses signs the phone out as in §6. - The screens open with what they showed last time — the Home counts, the departures, the approvals inbox, the work and lead lists, inventory, notifications — and refresh at once. These answers are kept for 24 hours and dropped when the app is updated. Anything with a passport, a document, a manifest, a booking or customer record, check-ins, visas, contact details or money records (receipts, payments, vouchers, statements, invoices) is never kept and is read fresh every time. Signing out drops everything kept.
- If a screen breaks while drawing, the app shows "Something went wrong" with Try again, which restarts the app's JavaScript (and picks up a downloaded fix, §10) instead of leaving a blank screen. Nothing saved is lost.
Not built: sending those errors to the office — they are written to the phone's own log only (there is no error reporter in the native build); faster image loading, which needs a new APK and is not in this one.
12. Leave, approvals and my agreement (staff)
A member of staff with leave.apply has leave on the phone. Every call is the same database
function the website's routes call (apps/mobile/src/lib/leave.ts): the database counts the
days, checks the rules and decides who approves; the phone shows its answer in its own words.
Rules: LV-001 … LV-062, EA-001 … EA-011. The web pages:
Leave, Employee agreement.
- Leave — the balances for the current leave year (with the probation note where it
applies), Apply and Report an absence with the database's preview as you type (days
counted, dates skipped and why, who approves, warnings and errors), my applications with
their timelines, Withdraw, Cancel and Ask to cancel, the ledger, and the holiday
calendar with tentative dates marked. A medical certificate is photographed or picked and
goes through
upload-employee-docto the Shared Drive, then is attached to the application (LV-014). - Leave approvals — for whoever the database routes applications to (HR, an HR
colleague's reporting manager, management for the second stage): each application with the
applicant's balances; Approve, or Refuse with a reason; cancellations asked for on
leave that has started (LV-030 … LV-033). Nobody sees
their own. HR with
hr.leave.adminalso sees Cancel this leave on someone else's pending or approved application, with a reason required; the days go back (LV-033). - My agreement — the employee reads their agreement and signs it: tick that they have read
it, type their full name, draw the signature with a finger (drawn with
react-native-svg, kept as the same path data as on the web) and confirm with their password (EA-004). The signature blocks and the hash check are shown as on the web. The authorised signatory countersigns the same way (EA-005).
Notifications about leave and the agreement arrive in the app's Notifications inbox; no push is sent for them (LV-034).
Not built on the phone: Leave admin (settings, leave types, peak seasons, comp-off, adjustments, recording an old absence, year end, the register and its CSV export), editing the holiday calendar, issuing or withdrawing an agreement, and printing an agreement — all on the website.
A closed departure. A group the office deleted (closed) on the web leaves the staff Groups list's Active view, shows Closed under All groups and in search, and leaves a tour leader's My groups (INV-006).
13. Look and feel
Rule: UX-023. The approved design is the mockup canvas of issue #559 (https://claude.ai/artifact/EcLrKURBDC8dbL9gYkMqQY). Step 1 of that issue (the foundation and staff Home) and step 2 (the staff tab bar, the Operations tab, the deadline radar, holds, and the inventory lists and airline block in the new look — UX-024) are built; the other screens follow in steps 3–10.
Tokens (apps/mobile/src/theme.ts). Sand page #F7F5F1, white cards, ink #1C1917,
muted #6B6259, burgundy #7A1A1A for the one main action, gold #C9962F as an accent only
(gold words use the darker goldText), rose tint #F6ECEC behind burgundy icons. Status
chips: green, amber, red, blue and grey, each a dark word on a pale fill. Cards are rounded 20,
buttons and fields 14, chips fully round. Shadows are very soft. Every tap target is at least
44 points.
Type. Manrope (SIL Open Font License, @expo-google-fonts/manrope), five weights. The
font files are part of the JavaScript bundle, so they arrive with an over-the-air update. At
start-up the splash waits for them for at most 2.5 seconds; if they are slow or fail, the
phone's own font is used and nothing breaks. Scale: display 30, title 22–28, heading 17,
body 15, caption 13, labels 11 in capitals; money uses tabular figures.
Building blocks (apps/mobile/src/components/ui.tsx): Screen (sand page, safe area,
content centred at 720 points on a tablet), ScreenHeader (date line and large title), AppBar,
Card, List and Row (leading avatar or icon tile, title, muted line, chevron, hairlines inset
between rows), Button (primary, secondary, soft, ghost, danger, success), Chip, Field (label
above, 48 high, a burgundy edge while typing), Segmented, StatTile (a big number; the
burgundy "hero" tile is the count to act on first), Skeleton and Loading, EmptyState,
ErrorState (the reason and Try again, which reads again) and OfflineBanner ("Offline —
showing what was saved at 10:42", amber).
Loading. A list or card that is waiting for its first answer shows soft grey placeholders the shape of what is coming, never a spinner. A spinner still shows at the foot of a long list while the next page loads.
What every screen gets now. Any screen built from the blocks above takes the new colours, type, corners, rows, buttons, chips, fields, skeletons and sheets without being edited. What is drawn with the screen's own styles keeps its layout and may still use the phone's own font until its step. The bar at the top of the other screens stays burgundy until their step: the buttons on it are drawn white for it. The Operations screens (the hub, deadlines, holds, the airline, hotel, food, ground, supplier and seat-release lists and the airline block) have the light bar, ErrorState with Try again, EmptyState and OfflineBanner, as Home does; the other screens still show a red line without Try again until their step.
Tablets. The iOS app is set to run full size on iPad as well as iPhone (no iOS build has been made yet, §10), and on Android tablets it now uses the whole screen in either direction instead of an upright phone layout. A tablet is a device whose shorter side is 600 points or more. Phones stay upright; tablets turn to any side. On a tablet, content is centred at a readable width — 720 points upright, up to 1,040 on its side — and Home puts its sections in two columns. Turning the tablet re-lays the screen out at once. Sheets stay at most 640 wide, centred.
This needs a new store build (iOS and Android): the tablet and orientation settings are in
app.json, and the orientation lock is a native module (expo-screen-orientation). Until a
phone has that build, the app behaves as now — upright everywhere — even after the
over-the-air update; the update checks for the module and does nothing without it. Everything
else in this section arrives over the air (§10).
Not built: a dark mode; larger text settings beyond what the phone already does; the redesign of every screen other than staff Home and the Operations screens (steps 3–10 of
559); a faster list component
(no new native list library was added). On a phone the screen may turn sideways for a moment before the app has started and locks it upright. Not yet checked sideways on a tablet: the passport camera and the map.