Skip to content

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 (the Groups component)
  • 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: /groups and /groups/:id — gated by groups.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) via getGroupSeatSnapshot (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:

  1. Name + type — tour category picker (the same GROUP_TYPE_OPTIONS as the wizard)
  2. Sub-type — PILGRIMAGE shows the pilgrimage sub-type picker; GENERAL shows DOMESTIC / INTERNATIONAL
  3. Geography — Ziyarat countries, Indian states, or International countries + cities
  4. Dates — Departure + Return via DateInput (DD/MM/YYYY)
  5. Capacity
  6. Auto-generated code preview — previewGroupCode shows 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

  1. Open the group and go to Itinerary.
  2. No programme yet? Click Start from the trip dates. The empty days appear, with the flights and hotel stays already on them.
  3. 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.
  4. To repeat an activity, use Duplicate on the same day or Duplicate on the next day.
  5. 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.
  6. To move an activity, use Move to another day and pick the day.
  7. Delete asks you to confirm first. The activity leaves the programme for staff, the tour leader and the travellers.
  8. 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.

  1. Open a group whose programme is planned and go to Itinerary.
  2. Click Save as template (top right of the planner).
  3. Type a name, for example "Umrah 15 days — standard". Names must be unique.
  4. Check the Trip type. It starts as the group's type; pick Any trip type for a template that suits every trip.
  5. The dialog says how many activities it saves and over how many days. Cancelled activities, and any dated before the departure, are left out.
  6. 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

  1. Open the new departure and go to Itinerary. It must have its dates.
  2. With no programme yet, click Start from a template. On a departure that already has activities, click Add from a template instead.
  3. The templates for the group's trip type are listed first. Click Show … for other trip types to see the rest.
  4. Click a template to pick it. See items shows its activities day by day.
  5. 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.
  6. 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 departureDate falls 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 → JED for 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:
    <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"
    />
    
    The styling rules are:
    • Uppercased on input (.toUpperCase())
    • maxLength={8} — IATA PNRs are always 6–8 characters
    • font-mono uppercase tracking-wider — fixed-width visual alignment
    • autoComplete="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. handleSaveGroupFlightPnr calls the backend.

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):

  1. 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.
  2. Choose what to copy: Everything (the whole sheet), or any of Flight, Hotel, Visa, Ground services, Custom lines, Taxes.
  3. 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.
  4. 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 × qty via recomputeLineAmount; operators can also override the amount column directly for flat-fee negotiations.
  • HSN / SAC codes — India GST compliance; updateLineCode clears 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), and appliesTo (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/post posts 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).