Groups
Written April 2026 — read this first
Group capacity is no longer a stored number a screen can type: it is recounted by the database from active non-infant passengers, checked under a row lock on every path, and cannot be lowered below the booked count (INV-001, INV-002). Hotel rooms and beds are derived together so they can no longer disagree. Cloning a group no longer copies rooms or beds. Read Bookings and Holds, deadlines and seat releases.
The Groups module manages group tours — a package that multiple customers travel together on: Umrah batches, Hajj cohorts, Ziyarat trips, domestic and international tour packages. A TravelGroup is the container; Bookings attach to it.
Where it lives
- List + detail page:
src/pages/groups/Groups.tsx(theGroupscomponent) - Group-level detail route:
src/pages/groups/GroupDetailPage.tsx - Overview → Sales status card:
src/components/groups/GroupSalesStatusCard.tsx - Pricing tab (lazy):
src/components/groups/GroupPricingTab.tsx - Website tab (lazy):
src/components/groups/GroupPosterTab.tsx - Invoices tab (lazy):
src/components/groups/GroupInvoicesTab.tsx - Group invoice dialog:
src/components/invoices/GroupInvoiceDialog.tsx - Math helpers + filters:
src/pages/groups/groupsMath.ts - Readiness panel:
src/components/groups/GroupReadinessPanel.tsx - Movement chart (itinerary preview):
src/components/groups/MovementChart.tsx - Route:
/groupsand/groups/:id— gated bygroups.view(src/App.tsx)
This page names components and sections rather than line numbers, which go stale with
every change to Groups.tsx.
Departure posters and the website
A departure is shown to the public as a poster. Its wording starts from a template for that kind of trip — one Umrah, one Hajj, one Ziyarat, one for each of the six the business sells — and a departure overrides whatever differs. A departure that overrides nothing follows its template for ever, so correcting a template corrects every departure that never changed it.
Every part is optional (the owner's decision, 2026-09-23). A poster may show:
| Part | Shown when | Where it comes from |
|---|---|---|
| Headline, tagline, dates, nights | always | the poster, then the template, then the departure's name |
| Hotels — name, city, nights, distance from the Haram | showHotels |
the hotels actually allocated to the departure |
| Day-by-day plan | showItinerary |
the departure's own itinerary |
| Included and not included | showInclusions |
the poster, then the template |
| A price to start from | showPriceFrom (off by default) |
the departure's rate sheet, adult |
| Seats left | showSeatsLeft (off by default) |
capacity less bookings |
The website reads GET /public/departures, which calls public_departures(). That function
exists because TravelGroup is staff-only — both its row policies are granted to signed-in
users, so the home page's old read returned an empty list to every visitor, which is why no
departure ever appeared there. The function returns a poster and nothing else: no cost, no
margin, no rate sheet, no customer, and a price shown is the selling price the operator
set. Past and closed departures are never advertised: a departure marked cancelled, completed
or departed is left out of public_departures() and public_groups() alike
(TRV-008), so no booking request can name it either.
Editing a departure's poster is on the group's Website tab (groups.edit): headline,
tagline, notes, inclusions and exclusions, and a switch for each optional part. A field left
alone says which template it comes from; a changed field says so and can be put back to the
template. Editing the template itself (the wording every departure of a kind starts from)
has no screen: PATCH /package-poster-templates/:groupType (groups.edit) exists, but no page
calls it.
1. What a group tour is
A group tour is a single departure with a shared itinerary and a shared rate sheet that multiple bookings can attach to. The group owns:
| Slot | Purpose |
|---|---|
groupCode |
Auto-generated, server-assigned sequential code (INV-004) |
operatorGroupCode |
The code the business itself uses — 29Aug-19D-6E-A, on every workbook, receipt and WhatsApp message. Searchable, shown wherever the departure is named, printed on the roster, manifest and group report, unique per season, editable (INV-005) |
groupType |
HAJ, HAJ_ZIYARAT, UMRAH, UMRAH_ZIYARAT, ZIYARAT, GENERAL_DOMESTIC, GENERAL_INTERNATIONAL |
departureDate / returnDate |
Travel window; constrains inventory search |
totalCapacity / bookedCount |
Seat math — drives the Open / Full / Planning status |
status |
planning, open, full, departed, completed, cancelled — worked out from the dates and seats, or set by hand with a reason on the Overview tab or in the edit dialog (INV-006, §4.1) |
serviceChargePerPax / serviceChargeLabel |
The company's own flat charge a head (FIN-039) — set in the edit dialog, shown on Overview (§3) |
itinerary |
The planned itinerary JSON: flights[], hotels[], ground[], edited in the Edit Group dialog. Its flights, hotels and transfers show only while the group has no linked ones of that kind (§4.5). Its activities[] list is kept but no longer read or written: the programme lives in GroupActivity since 6 Oct 2026 (§4.5) |
pricing (rate sheet) |
Four line items (flight, hotel, visa, groundServices) × three pax tiers + tax lines — lives on the Pricing tab |
| Linked inventory | Airline blocks, FIT entries, hotels, food, ground transfers — joined via dedicated assignment tables |
Group-wide operations (flights, hotels, ground transfers) cascade to every booking in the group; per-passenger overrides are supported via dedicated UIs (PassengerFlightsPanel, PassengerHotelsPanel).
2. List view (/groups)
The landing page is a dual-view list — card grid and virtualized table — toggled from the header.
Each GroupCard (src/components/groups/GroupCard.tsx) surfaces:
- Name + auto-generated
groupCode - Departure / return dates
- Capacity bar (
bookedCount/totalCapacity) viagetGroupSeatSnapshot(src/pages/groups/groupsMath.ts) - Status pill —
<StatusBadge domain="group"> - Quick KPIs (charges / collected / balance)
Filter bar supports search + status filter via filterGroups (groupsMath.ts).
Past departures. The View filter switches between Active (the default), Archived and All Groups. A departure drops out of Active once its return date has passed or it is closed — it is hidden, never gone. Opening one group by its URL (/groups/:id) works whatever its dates, because GET /groups/:id has no date filter at all (LC-042); the detail view labels a flown departure rather than hiding it, and carries the cancellation policy the departure is on.
How fast it loads (PRF-010). The list, its Hotels and Ground chips, the airline on each card and the new-group dialog's flight pickers come from one database call (GET /groups/screen). It used to be ten requests and 22 round trips, one of them a currency list the page never showed. Switching View keeps the current list on screen until the new one arrives, and coming back to the page shows the last list at once while it refreshes. Two things read correctly now that did not: the Hotels chip said Pending for every group, because the page only knew the hotels of a group that was open; and the airline badges on the cards were empty, because the read behind them answered 404. The service-city list is read when the edit dialog opens, not with the page.
3. Group creation
"Create Group" (gated by <PermissionGate permission="groups.create">) opens a multi-section dialog:
- Name + type — tour category picker (the same
GROUP_TYPE_OPTIONSas the wizard) - Sub-type —
PILGRIMAGEshows the pilgrimage sub-type picker;GENERALshowsDOMESTIC/INTERNATIONAL - Geography — Ziyarat countries, Indian states, or International countries + cities
- Dates — Departure + Return via
DateInput(DD/MM/YYYY) - Capacity
- Auto-generated code preview —
previewGroupCodeshows the pattern; the server assigns the final sequential letter on save
On submit, groupService.createGroup calls POST /groups.
The company's own per-traveller charge. POST /groups and PATCH /groups/:id
take serviceChargePerPax (and serviceChargeLabel) — the flat amount the
departure charges a traveller for taking the booking, ₹700 a head on the
29 Aug 2026 departure. It is inside the price the customer pays, every booking
taken on the departure inherits perPax × travellers, and it is recognised on
4150 Service Charges rather than as the sale of a trip. A cancellation does not
give it back. See FIN-039.
Edit group (groups.edit) has Service charge per traveller (₹) and
Called on the invoice (the label; Service charge when blank), with the
explanation beside them. Changing either asks for a reason and shows the old
and the new amount; an edit that does not touch them leaves them alone. Bookings
taken from then on are charged the new amount; bookings already taken keep
theirs. The group's Overview shows the amount a head (or none).
Templates
A template is a starting point for a new departure. It is not a departure and holds no services or bookings.
- Save as template (detail header → More,
groups.create) — name and description; it stores the group type, capacity, metadata (countries, cities, custom fields) and the current rate sheet with its currency (POST /group-templates). - Create from template (list header,
groups.create) — pick a template, give dates, a name and the capacity; a new group is made with the template's type, metadata, capacity and rate sheet (POST /group-templates/:id/instantiate). Flights, hotels, meals, transfers and bookings are not part of a template — use Clone for those. - Delete a template from the list (
groups.create, confirmed). Groups made from it keep what they were given.
The table is GroupTemplate (the GroupTemplate type in src/types).
A group template holds no programme. Programmes have their own templates, saved and applied on the Itinerary tab (§4.5).
Services stay inside the departure
A departure's hotel stays and meal plans must fall inside its dates, and its flights and transfers within one day of them. Anything outside is refused with both sets of dates named (INV-032). A return date before the departure date is refused.
Moving a departure's dates is allowed. If that leaves a hotel, meal plan, transfer or flight outside the new dates,
one job appears in the Work inbox operations pool, naming each one to fix (INV-003).
GET /inventory/outside-trip-dates lists every such case, including ones from before these checks. It has no screen yet.
Cloning a departure
Clone on a group makes a new group on a new departure date. You can bring its services (flights, hotels, meals, ground) and its bookings with it. Copied bookings start as DRAFT with a new booking number and no approvals. Cancelled passengers are not copied. The planned itinerary JSON moves with the dates: every hotel check-in and check-out, transfer time and flight-leg time in it shifts by the same number of days as the departure (INV-003). The programme comes too, every activity on the same day of the trip, back to planned; cancelled activities stay behind (INV-008). If the programme cannot be copied, the clone says so under what was skipped.
A clone can be partial. A service is left out when its contract has no room on the new date (INV-012). A booking is left out when it cannot be copied, for example when no booking number can be reserved. A hotel or a flight is left out when the new dates do not fit it (INV-032). The screen then shows a warning that names each item left out and why, instead of a plain "Cloned" message (PLT-033). Add what is missing by hand, or clone again.
4. Tabs on the group detail view
The header
The top of an open group shows, in this order:
- Groups — back to the list (on
/groups/:id). - The group's name, and one status badge beside it.
- One line of facts: the code (the business's own code when set — INV-005), the dates DD/MM/YYYY, the route and airline of the first linked flight, the trip type, and the generated name when a display name is set.
- The seats: sold (travellers on bookings finance has confirmed — LC-004), held (travellers on bookings not yet confirmed) and available, of the total, with a bar. Sold plus held is the group's own seat count (live travellers; infants take no seat). Overbooked by N shows when there are more travellers than seats. While the bookings are loading it shows booked and available only.
- A notice when the status is set by hand: why, what the automatic status would be, who set it and when.
- One action button: Edit group (
groups.edit); without that right, Print report. - More: Print group report; the service exports (Rooming list, Flight manifest, Meal count —
groups.view); Clone and Save as template (groups.create); Delete group (groups.delete).
Until 28 Sep 2026 these were a row of five buttons above the name, a second copy in the dialog, and Edit / Delete / Back buttons at the foot of the page.
Once a group is picked, the detail view has nine tabs, in one underlined tab bar that scrolls sideways on a narrow screen. Passengers shows how many live travellers the group has.
| Tab | Default? | Purpose |
|---|---|---|
| Overview | ✓ | Sales status and status actions (§4.1), service charge, readiness panel, last tour-leader check-in, the group leader (one traveller), tour leader, the money summary (charges, collected, balance) and who pays (bookings paid by their own customer, and payers paying for others — not families; those are on Passengers) |
| Operations | Flights, Hotels, Meals, Ground transfers (see §5) | |
| Itinerary | The day-by-day planner: one card per day of the trip with its flights, hotel stays and transfers, and the programme's activities, which staff with groups.edit add and change. A Timeline view and a traveller preview; Export, Copy and Edit planned stays (§4.5) |
|
| Passengers | One row per traveller, by family (default) or by booking; search and filter; families, guardians, make leader, transfer and remove per traveller; bulk rooms, meals, transfers, flights and visas for the travellers picked (see §4.2, §4.3); traveller journeys under the table | |
| Pricing | Lazy-loaded (GroupPricingTab) — the rate sheet, with inventory cost and margin on request, and copy from another group (§6) |
|
| Website | Lazy-loaded (GroupPosterTab) — the departure's poster on the public site (see Departure posters above) |
|
| Invoices | Lazy-loaded (GroupInvoicesTab) — payer invoices (§7) |
|
| Financials | Other expenses (add and remove — groups.edit; they were on the Passengers tab until 28 Sep 2026), per-group P&L (charges, collected, balance); drill into supplier payables |
|
| Activity | Every change across the group — bookings, passengers, flights, hotels, meals, transport, visas and finance |
Pricing, Website and Invoices are lazy-loaded: their code downloads only when the tab is opened.
4.1 Sales status on Overview
The Sales status card at the top of Overview shows the status and whether it is
automatic (from the dates and the seats booked) or set by hand. When set by hand it shows
why, when (DD/MM/YYYY HH:mm), a short id of who set it (the name is on the Activity tab) and
what the automatic status would be. With groups.edit it offers, as they apply:
| Action | Shown when | Sets |
|---|---|---|
| Stop selling | not already stopped, and not departed, completed, closed or cancelled | Full |
| Reopen sales | sales were stopped | back to automatic |
| Mark departed | on or after the departure date, and not yet departed | Departed |
| Mark completed | the departure is Departed | Completed |
| Back to automatic | another hand-set status is in place | back to automatic |
Each asks for a reason and shows the status before and after. The reason is stored on the
departure and in the audit log (status_override_set / status_override_cleared).
What each status does to partners, travellers and the website is in
INV-006. Stop selling stops selling: partners are no longer offered the departure, the traveller app shows no seats left, and the database refuses any traveller joining it — a new booking or traveller, a transfer in, a restored cancellation — until sales are reopened. The website poster still shows it. Cancelled is set only from the edit dialog. The card also shows the service charge
per traveller (§3).
Group documents. Itineraries, brochures, terms and contracts attached to a departure are
stored on the company Shared Drive under Groups / the group code (drive-upload, kind
group_doc, groups.edit) and open in a viewer on the page (drive-file, groups.view); the
group keeps the Drive reference, never a URL (ACC-074). Removing one from the list leaves
the file on the drive.
What opening a group reads (PRF-010). Opening a group — the dialog, the itinerary preview or /groups/:id — makes one database call (GET /groups/:id/screen) for the overview, the readiness panel, the passengers and manifest, the Operations tab (flights, hotels, meals, transfers), the itinerary, the documents, the last tour-leader check-ins, the incident banner, the expenses list and the cancellation-policy picker. It used to be thirteen requests and 81 round trips. On /groups/:id this call and the list call go at the same time; the page used to wait for the list first.
- Financials reads the cost report and the P&L in one call (
GET /groups/:id/financials) when the tab is opened, or when the itinerary preview shows the totals. They used to be read — 53 round trips — every time any group was opened. - Invoices reads its invoices, bookings and payer names in one call (
GET /groups/:id/invoices-screen). It used to read every partner and the newest 999 customers in the company to find a few names, so a payer outside those 999 showed as Customer. - The visa markers use this group's visa cases only (with
visa.view). They used to be looked up in the newest 999 visa cases in the company, so an older case was missed. - The Operations tab's transfer list shows this group's transfers. It used to list every group's transfers while the group was open. The transfer picker reads the ground-transfer inventory when it opens.
- The readiness panel counts the meal plans from the first load. It used to see them only after the edit dialog had been opened.
- Not converted yet: the Passengers tab's traveller journeys, and the Pricing, Website and Activity tabs, still read through their own routes when opened.
4.2 Passengers tab — one row per traveller
A booking of four is four rows (PAX-035). By booking, each booking has a header — booking number, the customer's name and how many travel on it, the partner's code and name when a partner sold it, the customer code, and the balance (in amber while something is due) — and under it one row per traveller. By family is in §4.3. Both views use the same table and the same columns:
| Column | What it shows |
|---|---|
| Traveller | Name; Group leader on the one traveller who leads the group; under the name, the family and relationship (by booking) or the relationship (by family), and a minor's guardian |
| Type | Adult, Child or Infant |
| Booking | The booking number |
| Passport | Number and expiry; the expiry is red when it runs out less than 185 days after departure |
| Visa | The traveller's own visa case status (visa.view); "(booking)" when only a booking-level case exists |
| Ticket | Issued (with the number), cancellation pending, a seat with its PNR, no seat, or not bought |
| Room, Meal, Transport | The hotel and room or room-share, the meal plan, the transfer — or "Not bought" (PAX-034) |
| Status | One status: Cancellation requested (red); otherwise the first thing still to do, in amber, with +N when there is more — no guardian (PAX-021), no passport number, passport runs out too soon, no visa case, no flight seat, no room, no meal plan, no transport (each only when the departure has that service); hover for the full list. Ready when nothing is left |
Search and filter. The box above the table finds a traveller by name, booking number or passport number. Show keeps all travellers, only those that need attention, only those with a cancellation requested, or only those whose family is not recorded. The bulk bar appears on the table only while travellers are ticked. With a search or a filter on, ticking a header ticks the travellers shown.
Everything on the tab comes from the group's one call (GET /groups/:id/screen); nothing is
read per row. Clicking a booking header, or Booking details on a row, opens the booking's
own dialog (payments, ledger, and moving or dropping the whole booking).
Left the group. Travellers who were cancelled, or transferred to another group, are listed in a collapsed section under the table with the date and the reason (and, for a transfer, where they went: the group and the booking, "Transferred to 12Sep-14D · Umrah 12 Sep (BK-00050)"). Nobody is removed from the record.
Came in by transfer. A traveller who was transferred onto this group says so under their name: "Transferred from BK-00012 · 29Aug-19D · Umrah 29 Aug · 28/09/2026" (LC-031).
Actions on one traveller (the ⋯ on the row):
| Action | Permission | What happens |
|---|---|---|
| Make group leader / Remove as group leader | groups.edit |
POST /groups/:id/leader → set_group_leader. One traveller per group; naming another replaces the first. Audited |
| Transfer to another group | booking.transfer |
Opens the booking page's one-passenger transfer, filled in for this traveller (Bookings §8). The traveller lands on the booking already made for their booking on that group, or a new one with the same customer, partner and sales owner; the money paid for them goes with them; a booking left with nobody on it becomes Transferred and read-only (LC-031, LC-032, LC-033) |
| Remove from group… | bookings.cancel or booking.transfer |
A choice, never a delete: Cancelling their trip opens the booking page's passenger cancellation — a request with a reason that someone else approves (bookings.cancel.approve), with the charge and refund from the policy (LC-020, PRC-020); Moving to another group opens the transfer. The dialog says, before either, what happens to the seat, room, meal plan, transfer seat, ticket and visa case, and that a leader who leaves leaves the group without one |
Bulk actions work on the travellers picked, not on whole bookings. Tick travellers, or a booking header to tick everyone on it, then:
| Action | Permission | How |
|---|---|---|
| Assign hotel / Assign meal / Assign ground service | bookings.edit |
One call, POST /groups/:id/travellers/place → place_travellers. Each traveller is placed, skipped (already there) or refused with the reason — did not buy it, passport too short, no room or portion left, booking not confirmed (LC-004). The toast says so in one line |
| Link flight | bookings.edit |
Seats each selected traveller who bought a ticket on the flight (INV-012) — one request per traveller |
| Request visa | visa.create |
Opens one visa case per selected traveller who bought a visa and has none — one request per traveller |
The group leader is a person. The Overview's Group Leader card names the traveller and their booking. Its picker lists only this group's travellers. Someone who is not travelling with the group is not added from there: add them through a booking first (Import passengers or Assign existing customer), then make them leader. Until 27 Sep 2026 the leader was a flag on a booking — a family of four was four "leaders" — and naming an outsider created a customer and a ₹0 booking without saying so. Existing flags were carried over to the first traveller of the earliest flagged booking. The group leader is not the tour leader below (FLD-007).
Not built: moving a traveller between two bookings of the same group from this tab, and a per-traveller undo of a room, meal or transfer from the bulk bar (do it on the booking page).
4.3 Families on the departure
A booking is not a family (PAX-036). A family is recorded on the departure: a name, a head, and its members with their relationship to the head. It may take in travellers from several bookings — even different partners' — and one booking may hold several families and people in none. Nothing is assumed: every traveller is in a family, not family, or not recorded.
By family / By booking. The Passengers tab opens By family (the choice is remembered on this computer). One header row per family: its name, Head: … or Needs a new head, how many, the booking numbers it spans, and Edit family / Dissolve. Each member row has the same columns and actions as §4.2, with the relationship under the name and the member's own booking in the Booking column. Then Not family, then Family not recorded — grouped by booking, each with Treat this booking as one family… — then Solo: travellers alone on their booking, who need no family decision. By booking is the §4.2 table, with each traveller's family under their name. Children and infants show Guardian: …, or No guardian as their status (PAX-021).
Until 28 Sep 2026 the group page lost each traveller's family and guardian between the
database and the screen, so every traveller showed as family not recorded and every child as
no guardian, on this tab and in the Overview's readiness panel. The booking reader
(mapBookingApiRow) now carries familyStatus, family, guardianPassengerId and
guardianName through.
Actions (all bookings.edit; the database checks each one and records who, when and why):
| Action | Where | What happens |
|---|---|---|
| New family | above the table | Pick travellers from any booking of the departure (the ticked ones start the list), the head and each member's relationship; the name is suggested from the head's surname. A traveller already in a family is not offered |
| Suggested (same booking and surname) | above the table | Opens New family with those travellers. A suggestion only — nothing is saved until you save |
| Treat this booking as one family | booking header, or a traveller's ⋯ | Pre-fills the form: the primary adult as head, relationships read from what was typed at booking where certain ("Wife" → Spouse); the rest are blank and must be chosen |
| Edit family | family header, or a member's ⋯ | Rename, change the head, change relationships, add or take out members |
| Add to family | a traveller's ⋯ | Into an existing family, with a relationship |
| Remove from family | a member's ⋯ | Optionally marking them Not family. Removing the head leaves the family needing a new head; removing the last member ends it |
| Dissolve | family header | A reason is required; the record is kept; members go back to not recorded |
| Mark not family / Undo not family | a traveller's ⋯ | For a traveller in no family |
| Set guardian | a child's or infant's ⋯ | An adult on the same departure, on any booking; or No guardian |
It keeps itself correct. A traveller who is cancelled, transferred to another departure, or whose booking moves, is cancelled or deleted leaves their family. If that was the head, the family shows Needs a new head — nobody is picked for you. If that was a child's guardian, the child shows No guardian again.
Readiness. The readiness panel on Overview warns — never blocks — about travellers with
family not recorded (bookings of two or more), children and infants without a guardian
travelling, and families needing a new head. Families are recorded before departure; they are
not a condition of booking approval. Infants roomed apart from their guardian (PAX-012) are counted
by family_readiness and on the phone's manifest, not yet on this panel.
Exports. The rooming list and the flight manifest have a Family column ("Rashid family · Spouse").
Not built: marking a whole booking Not family in one step from this tab (use the booking page's None are family), refusing to room an infant apart from the guardian, and moving a whole family to another departure in one step.
4.4 Printing the group report
More → Print group report… (or Print report for someone without groups.edit) opens
the print options: tick the sections — header and group details, financials, passengers,
flights, hotels, ground services, itinerary, meal plan, other expenses — and Print. The
report opens in a new window, A4, dates DD/MM/YYYY.
Include money (INV-007)
is off unless ticked, and is offered only to staff with bookings.view or finance.view. With it on:
- A Payments by booking table: booking, customer, travellers, Total, Paid, Due (amber when something is due), and a totals row — "Total (3 of 7 bookings)" — for the group.
- The passenger list gains Booking, Total, Paid and Due columns. A booking's figures are printed once, beside its first traveller, spanning the rest — never per person.
- Show: All bookings, Fully paid (nothing due) or Has balance due. The travellers listed follow the choice, and the section titles say which. A booking awaiting finance approval is never Fully paid; it is kept under Has balance due.
- A booking finance has not approved yet (FIN-033,
owner 01/10/2026) prints Awaiting finance approval — ₹X will be due in the Due cell, in amber,
in the booking's currency — not ₹0. The totals row's Due stays the sum of balances; under
it the report adds ₹B more once finance approves (N bookings), never mixed into the total.
When Print is pressed with money on, the page reads each booking's agreed due and approval
state (
booking_agreed_due/booking_awaiting_approval). If that read fails, a warning says so and the report prints the balances only. - The top of the printout says Includes payment details — internal. Not for hotels, agents or suppliers, and the window title ends (internal).
The figures are each booking's own total, paid and balance — the same as its booking page and the Overview's money summary; the report works nothing out. Currency is the booking's (₹ when none is set).
Not built: money on the rooming list, flight manifest and meal count (they go to suppliers),
and a share of the booking's total per traveller. The group screen itself — the Passengers
roster's Balance column, the travellers panel and the Overview's money summary — still shows
the stored balance, so a booking awaiting finance approval reads ₹0 there: the group's one-call
read (group_screen) does not carry the agreed due yet.
4.5 Itinerary tab — the day-by-day planner
The tab is where the programme of a departure is planned (INV-008). It shows the itinerary travellers see in the app, on Customer 360 and in the field app, one day at a time.
The days. One card per day, from the departure date to the return date: "Day 1 · Sat 17 Oct · Makkah". The city is that of the hotel stay covering that night. The return day takes the hotel checked out that morning. A night with no hotel stay shows no city.
Fixed items. Each day lists its flights, hotel check-ins and check-outs and transfers. They are read-only here. Each has a link, Change on Operations → Flights / Hotels / Ground, that opens the Operations tab. Each kind comes from one place:
| Kind | Comes from | Falls back to |
|---|---|---|
| Flights | the airline blocks and FIT entries linked on Operations → Flights | the planned flights in the itinerary JSON, only when no flight is linked |
| Hotel stays | the hotel assignments on Operations → Hotels, with their own check-in and check-out | the planned hotel stops in the JSON, only when no hotel is assigned |
| Transfers | the ground transfers linked on Operations → Ground | the planned transfers in the JSON, only when none is linked |
| Activities | the programme (GroupActivity), the same rows the tour leader edits on the phone |
— |
A hotel in inventory whose contract covers the trip is not shown unless it is assigned to the group.
Activities. Each day lists its activities in time order. Untimed ones come last, in the order staff set. Each shows its time, title, kind, place and notes. A status other than planned (live, done, cancelled — set by the tour leader on the phone) shows as a badge.
Views. Day by day (the planner), Timeline (the same items as one chart in date order) and Preview as traveller — what a traveller sees on the app's Programme: "Day N" labels, flights and hotels without the PNR, and the activities.
Who can change it. Staff with groups.edit add, edit, copy, reorder, move and delete
activities, and save or start from a template (below). Everyone else with groups.view sees the planner read only. The tour leader of the
group edits the same programme on the phone (TRV-003).
Export, Copy and Print read the same flights, hotels, transfers and activities. Dates are
kept as yyyy-MM-dd and shown DD/MM/YYYY.
Edit planned stays opens the Edit Group dialog. There you edit the planned hotel stops and transfers, which show only while nothing of that kind is linked. The dialog no longer has a Program & Activities section; it says "Plan the programme in the Itinerary tab".
Where the old activities went. Activities typed in the Edit Group dialog before 6 Oct 2026 were copied into the programme once. Their type became a kind (Umrah, Tawaf and Rawdah → Ritual; an excursion to a holy site → Ziyarat, any other excursion → Other; Custom → Other). Their city became the place. One without a date went on Day 1 with "(date to confirm)" in its notes — move it to its day. The old list stays in the group's data but nothing reads it.
How to plan a programme
- Open the group and go to Itinerary.
- No programme yet? Click Start from the trip dates. The empty days appear, with the flights and hotel stays already on them.
- On a day, click Add activity. The day is already picked. Choose the kind (Ritual — Umrah/ Tawaf, Ziyarat, Meal, Meeting point, Free time, Other), type a title, and add a time (HH:MM, optional), a place (pick a city or hotel of the group, or type one) and notes.
- To repeat an activity, use Duplicate on the same day or Duplicate on the next day.
- To change the order of untimed activities, use the up and down arrows. Activities with a time sort by their time; change the time to move them.
- To move an activity, use Move to another day and pick the day.
- Delete asks you to confirm first. The activity leaves the programme for staff, the tour leader and the travellers.
- Click Preview as traveller to check what travellers will see.
The tour leader and travellers see a change the next time their Programme refreshes (it is live on the phone).
How to save a programme as a template
A template lets another departure start from this programme
(INV-009).
You need groups.edit.
- Open a group whose programme is planned and go to Itinerary.
- Click Save as template (top right of the planner).
- Type a name, for example "Umrah 15 days — standard". Names must be unique.
- Check the Trip type. It starts as the group's type; pick Any trip type for a template that suits every trip.
- The dialog says how many activities it saves and over how many days. Cancelled activities, and any dated before the departure, are left out.
- Click Save template.
Each activity is kept by its day of the trip (Day 1 is the departure date), with its kind, time, place and notes. Later changes to this departure do not change the template.
How to start a departure from a template
- Open the new departure and go to Itinerary. It must have its dates.
- With no programme yet, click Start from a template. On a departure that already has activities, click Add from a template instead.
- The templates for the group's trip type are listed first. Click Show … for other trip types to see the rest.
- Click a template to pick it. See items shows its activities day by day.
- Read the preview. It says how many activities are added from Day 1. If the template is longer than this trip, it lists the activities that fall after the return date: they go on the last day, with "(outside the trip — check)" in their notes. Move or delete them after.
- Click Start from this template (or Add to the programme). Adding to an existing programme asks you to confirm first; nothing already there is changed or removed.
Every activity added starts as planned and goes at the end of its day. Applying the same template again adds nothing — except an activity you deleted, which comes back.
Rename or delete a template in the same list: the pencil renames it (and changes its trip type), the bin deletes it after a confirm. Departures that started from it keep their activities.
Not built: editing a template's activities in place (save the programme again under a new name and delete the old template), starting a programme from a template on the phone, and dragging an activity between days. An activity left outside the trip dates after the departure moves shows at the end of the planner, marked "Outside the trip dates", until it is moved back. Undated planned hotel stops and transfers still show on the Timeline at the end, marked TBD, though travellers do not see them.
5. Operations tab — Flights, Hotels, Ground
5.1 Flights panel
The Flights panel on the Operations tab renders every airline block or FIT inventory entry linked to the group, one row each.
- Add Flight dialog — pick type (Airline Block or FIT), then the specific inventory row. Only flights whose
departureDatefalls within the group's window are shown, and already-linked blocks / FITs (taken by any group, company-wide) are hidden. - PNR auto-fill — when an inventory row has a PNR, the dialog prefills it; a green hint appears confirming the source. Operators can override.
- Multi-city route display —
chainLegs(legs[], fallbackFrom, fallbackTo)chains every leg's origin/destination into a single string:SXR → DEL → JEDfor a three-leg outbound. If the block is round-trip, the return route is appended:SXR → DEL → JED · JED → DEL → SXR. - PNR editing — per-flight PNR input at the right of each row:
The styling rules are:
<Input value={pnrEditState[gf.id] || ''} onChange={(e) => setPnrEditState((prev) => ({ ...prev, [gf.id]: e.target.value.toUpperCase() }))} placeholder="PNR" maxLength={8} className="h-8 w-[110px] text-xs font-mono uppercase tracking-wider" autoComplete="off" />- Uppercased on input (
.toUpperCase()) maxLength={8}— IATA PNRs are always 6–8 charactersfont-mono uppercase tracking-wider— fixed-width visual alignmentautoComplete="off"— prevents Chrome from mixing unrelated saved values A dirty-check flag shows a Save button only when the field value differs from the server value.handleSaveGroupFlightPnrcalls the backend.
- Uppercased on input (
Per-passenger PNR override
The group-level PNR is the default. Per-booking PNR overrides live on the booking's PassengerFlightsPanel (src/components/bookings/PassengerFlightsPanel.tsx) — used when one passenger's ticket got re-booked on a different PNR. The group shows the master; the passenger's value wins when ticketing runs.
5.2 Hotels panel
The Hotels panel on the Operations tab. "Link Hotel" opens an assignment dialog (rooms allocated, check-in / check-out, room type, price per night, meal plan, meal-day calculation). Rooms are assigned at the passenger level from the Passengers tab.
5.3 Meals panel
"Add Meal Plan" links a catering contract to the departure. Catering is bought at a rate per pilgrim per day, so the dialog shows the head-count before you save: how many travellers are fed and how many are charged. Travellers fed is counted from the departure's own passengers unless you type a number over it — and, left blank, the plan keeps following the manifest after you save: joins, cancellations, transfers and opt-outs move the head-count, the stock and the cost voucher on their own (INV-042). Each linked plan shows fed / billed, whether it follows the manifest, and a red note when its contract is over what it bought. The panel lists this departure's plans only. "Exclude children/infants" and "exclude group leader" spare those people the bill — the leader's whole booking, as before (PAX-035); they still eat and still appear on the meal-count export (INV-040).
5.4 Ground transfers panel
Analogous to hotels — pick an inventory entry, assign to the group, then per-passenger assignments.
5.5 Services from the app
The staff app (apps/mobile) has the Operations tab's hotels, meals and transfers as one
screen: Group → Services (groups.edit). It lists what is assigned to the departure
with the capacity each uses — rooms allocated and how many have travellers placed, a meal
plan's fed / billed / days / meal-days and whether it follows the manifest, a transfer's
seats and what is left on the vehicle — and the cost line each one posted.
Assign opens a picker that offers only inventory with free capacity whose window covers the departure's dates (a hotel's lease, a contract's dates, a transfer's departure). Each assignment is one database function that reserves the capacity under a row lock and posts the money in the same transaction, so the app never writes a counter and never builds a voucher (INV-012, FIN-030):
| Action | Permission | Function | What it does |
|---|---|---|---|
| Assign rooms | hotels.edit |
assign_hotel_rooms |
The desktop's checks (rooms and beds free, dates inside the departure) and its Stock-in-Hand → Hotel Expense voucher, derived in the database and handed to create_hotel_assignment — one transaction, as on the desktop |
| Remove rooms | hotels.edit |
unassign_hotel_rooms |
Reverses the voucher, takes the travellers out of the rooms, deletes the row; the rooms and beds come back |
| Assign a meal plan | food.edit |
assign_food_plan |
Fed and billed from the manifest, or a typed head-count checked against the contract and fixed (INV-040, INV-042); one plan per contract per departure; the stock and the cost voucher are the triggers' |
| Remove a meal plan | food.edit |
unassign_food_plan |
Reverses the consumption voucher, takes the travellers off the plan, deletes it; the meal-days come back |
| Assign seats | inventory.edit |
assign_ground_transfer |
The seats and the Dr Ground Transport Expenses / Cr operator voucher, in one transaction (INV-041); a refused voucher is queued and the screen says so |
| Remove seats | inventory.edit |
unassign_ground_transfer |
Reverses the voucher, takes the travellers off, deletes the row; the seats come back |
The sheet says what will happen before you save — nights × rooms × rate, fed and billed and the meal-days drawn, seats × price — and a refusal from the database is shown in its own words ("Only 10 rooms available", "Insufficient available capacity", the over-allocation sentence from INV-012). Every voucher lands pending a second person in finance (FIN-032).
Place travellers on each hotel, meal plan and transfer (bookings.edit) puts the travellers
ticked in a list grouped by booking into it, in one place_travellers call — the same function
as the desktop's bulk bar (PAX-035).
Not built in the app: linking flights, taking one traveller off a room, plan or seat, editing an assignment in place (remove it and assign again), and price overrides on rooms and meals beyond the rate typed at assignment.
6. Pricing tab (lazy)
The group's rate sheet is the source of truth the wizard's Sync button reads from (see Booking Wizard §5.1).
Shape:
lineItems: {
flight: { adult, child, infant }
hotel: { adult, child, infant }
visa: { adult, child, infant }
groundServices:{ adult, child, infant }
}
taxLines: [ { id, name, rate, mode: 'inclusive' | 'exclusive', appliesTo: LineKey[] } ]
See src/types/index.ts → GroupPricing and recomputeLineAmount in GroupInvoiceDialog.tsx.
Cost and margin (PRC-007). Compare with
inventory cost reads what the linked flights, hotels, meals and transfers cost per traveller
(POST /groups/:id/pricing/refresh-from-inventory, in rupees; a foreign contract at its own
exchange rate — a contract with no exchange rate stops the read with a message naming it).
It changes no price. Under each price it shows cost 32,000 · margin 8,000 (20%), with a
Total (4 lines) row per category; a price below cost is shown in red. When the rate sheet is
in another currency (SAR, say) only the cost is shown, in ₹, and the screen says why there is
no margin. Use cost as price copies the costs into the flight, hotel, visa and ground
prices after a confirmation that shows the per-adult price now and at cost; custom and tax
lines are kept, and the sheet's currency becomes INR. Nothing is saved until Save, which
asks for a reason as before. Hide cost takes the figures away.
Before 27 Sep 2026 the button was Refresh from inventory and it replaced every selling price with its cost.
Permissions:
| Action | Permission |
|---|---|
| View rate sheet | groups.view |
| Save edits (line items + tax lines) | group_pricing.edit |
| Compare with inventory cost | group_pricing.edit |
| Use cost as price | group_pricing.edit |
| Copy from another group | group_pricing.edit (the function also needs groups.view) |
API: PATCH /groups/:id/pricing; copy: GET /groups/:id/pricing/copy-sources, POST /groups/:id/pricing/copy (API → Group pricing).
6.1 Copy from another group (PRC-006)
Copy from another group on the Pricing tab takes another departure's rate sheet (PRC-006):
- Search by group code, operator code, name or departure date. Only departures with a rate sheet are listed, each with its adult / child / infant totals and currency.
- Choose what to copy: Everything (the whole sheet), or any of Flight, Hotel, Visa, Ground services, Custom lines, Taxes.
- The dialog shows the per-person totals before tax now and after the copy, and how many custom and tax lines the sheet will have.
- Copy rate sheet asks Yes/No with a reason, and says that existing bookings keep their prices. Yes saves the copied sheet at once — there is no separate Save — and replaces any unsaved edits on the tab.
The database refuses a copy onto the same departure, into a departure that is archived, cancelled, departed or completed, from a departure with no rate sheet, and between two currencies (nothing is converted). A departure with live bookings copies only after the confirmation. Existing bookings keep the prices they were sold at: a booking holds its own rates and total, and the copy never changes them. New bookings, the wizard's Sync, new group invoices and the portals read the copied sheet. A copied tax line that named a custom line the new sheet does not have no longer names it. Each copy is in the audit trail.
Not built: copying a single custom line or a single tax line (the custom lines and the tax lines copy as a set), and copying from a departure in another currency with a conversion.
7. Invoices tab (lazy)
Groups bill per payer — often a family head covers multiple bookings. The Invoices tab lists every payer attached to the group and their invoices.
Invoice lifecycle: DRAFT → ISSUED → PAID (or CANCELLED via a reversing credit-note journal).
On each payer's row, Create draft invoice (group_invoices.create) makes a DRAFT from the group rate sheet and opens it — it does not issue anything. Until October 2026 this button was labelled Issue invoice. Internal invoice makes a free-form draft for in-house records that is never posted to finance. A billed payer gets Supplementary instead of Create draft invoice.
GroupInvoiceDialog (src/components/invoices/GroupInvoiceDialog.tsx) is the edit / issue / post / cancel surface. A line at the top names the steps: 1. Draft — 2. Issue — 3. Post to Finance (an internal invoice: draft and issue only). Each is a separate button; see Invoices (finance lens).
- Draft edits — per-tier rate (adult/child/infant) editing recomputes
amount = rate × qtyviarecomputeLineAmount; operators can also override theamountcolumn directly for flat-fee negotiations. - HSN / SAC codes — India GST compliance;
updateLineCodeclears the other field when one is filled (they are mutually exclusive per line). - Tax lines — array of named taxes, each with
rate,mode(inclusive / exclusive), andappliesTo(which line keys). Preview computes:exclusive = base × rate/100,inclusive = base × rate/(100+rate). The server recomputes on save. - Issue —
POST /group-invoices/:id/issue. Numbers and locks the invoice (DRAFT → ISSUED). It posts nothing to the ledger. - Post to finance — a separate step:
POST /group-invoices/:id/postposts the Dr receivable / Cr revenue + tax journal, so finance can review an issued invoice before it reaches the books. The title shows Posted or Not posted. When the bookings on the group already posted their own revenue, Post posts nothing — a sale is recognised once (FIN-036) — and the dialog says Nothing to post: the bookings on this group already posted their revenue; the title then reads Revenue posted on the bookings. An internal invoice cannot be posted. - Cancel / Cancel (credit note) —
POST /group-invoices/:id/cancel. The button reads Cancel (credit note) on a posted invoice, which gets a reversing credit-note journal (pending unless the invoice's voucher is approved and the person cancelling may approve it); one never posted reads Cancel and needs none. The payer can then be re-invoiced. - Supplementary — an additional invoice against an already-issued invoice (e.g. for a price adjustment).
- Print —
printGroupInvoice({ invoice, groupName, payerName }).
Permissions (see docs/PERMISSIONS.md §6.5):
| Action | Permission |
|---|---|
| View payers + invoices tab | group_invoices.view |
| Create draft invoice | group_invoices.create |
| Edit draft (line items, taxes, due date) | group_invoices.edit |
| Issue draft → ISSUED | group_invoices.issue |
| Post an issued invoice to finance | group_invoices.issue |
| Cancel issued invoice | group_invoices.cancel |
| Create supplementary | group_invoices.create |
8. Group P&L report
The Financials tab (and the Reports module) expose a group-level P&L: revenue (from issued invoices) − cost (from supplier invoices, hotel nights, ground, airline block costs) = margin.
Cross-group P&L lives on the Reports page under "Group Profitability" — gated by finance.reports.group_profit_loss.view and finance.reports.group_profit_loss.export for download. See docs/PERMISSIONS.md §4.4.
9. Group-wide actions + permissions summary
See docs/PERMISSIONS.md §6.5 for the canonical matrix. Quick reference:
| Action | Permission | Where |
|---|---|---|
| View page | groups.view |
Route |
| Create group | groups.create |
"Create Group" button |
| Save as template / create from template / delete template | groups.create |
Detail header / list header / templates list |
| Edit group (dates, capacity, codes, status override, service charge) | groups.edit |
Edit dialog |
| Stop / reopen sales, mark departed / completed | groups.edit |
Overview → Sales status |
| Edit the departure's poster | groups.edit |
Website tab |
| Delete group | groups.delete |
Card / row delete |
| Clone group (with services / bookings) | groups.create |
Clone dialog |
| Name or remove the group leader — one of the group's own travellers (PAX-035) | groups.edit |
Overview → Group Leader picker (this group's travellers only), or Passengers → a traveller → Make group leader |
| Appoint or dismiss the tour leader (anyone — an employee, a partner's login or a traveller's — who travels with the group and uses the field app; FLD-007) | groups.edit |
Tour Leader panel on Overview tab: search by name, phone, email or agency (GET /groups/tour-leader-candidates?q=), appoint (POST /groups/:id/tour-leader → appoint_tour_leader, grants the Tour leader role), dismiss (DELETE → dismiss_tour_leader, removes it when no other group is led). Someone with no login yet is added under Admin → Users first |
| See the last tour-leader check-in and who was missing | groups.view |
Overview tab, above the Group Leader card (FLD-002) |
| Transfer one traveller to another group | booking.transfer |
Passengers → a traveller → Transfer to another group (the booking page's transfer) |
| Remove one traveller from the group — a cancellation request, or a transfer | bookings.cancel (request; approved with bookings.cancel.approve) or booking.transfer |
Passengers → a traveller → Remove from group… |
Move or drop a whole booking (move_booking_to_group, reason) |
groups.edit |
Passengers → booking header → booking dialog → Move whole booking / Drop whole booking |
| Put the selected travellers in a hotel, meal plan or transfer | bookings.edit |
Passengers → tick travellers → Assign Hotel / Meal / Ground Service |
| Seat the selected travellers on a flight | bookings.edit |
Passengers → tick travellers → Link Flight |
| Open visa cases for the selected travellers | visa.create |
Passengers → tick travellers → Request Visa |
| Link / unlink flight | groups.edit |
Operations → Flights |
| Assign hotel rooms | groups.edit |
Operations → Hotels |
| Assign ground transfer | groups.edit |
Operations → Ground |
| Edit rate sheet | group_pricing.edit |
Pricing tab |
| Compare with inventory cost / use cost as price | group_pricing.edit |
Pricing tab |
| Create / edit draft invoices | group_invoices.create / .edit |
Invoices tab |
| Issue / post / cancel invoices | group_invoices.issue / .issue / .cancel |
Invoice dialog |
Roles that hold groups.*: CEO, GM, IT_ADMIN, ADMIN_HR (full); SALES_MANAGER, B2B_MANAGER, OPS_MANAGER hold groups.create; others hold groups.view only (docs/PERMISSIONS.md §5).
10. Group lifecycle diagram
stateDiagram-v2
[*] --> planning: Create group
planning --> open: Inventory + pricing ready
open --> full: bookedCount == totalCapacity
full --> open: Capacity increased or booking cancelled
open --> departed: After departureDate (auto)
full --> departed: After departureDate (auto)
departed --> completed: After returnDate (auto)
planning --> cancelled: Manual cancel
open --> cancelled: Manual cancel
full --> cancelled: Manual cancel
completed --> [*]
cancelled --> [*]
Manual status override
The Overview tab's Sales status card covers the everyday cases — Stop selling, Reopen sales, Mark departed, Mark completed (§4.1). The edit dialog's Status Control dropdown offers every value, including Planning and Cancelled: Automatic lets the status follow the dates and seats; any other value pins it and needs a reason (INV-006).
Finding a departure
The list filters by view, status and type — the departure's own type (Umrah, Hajj, Umrah with Ziyarat, Ziyarat, Hajj with Ziyarat, tour within India, tour abroad). The type filter used to compare a field every departure carried as "umrah", so Hajj found nothing and Umrah found everything. A Departs date range (both ends included) and a sort — departure (soonest first), name, seats left, booked — sit on the same toolbar and stay in the page address (UX-030).