From 6068cc026ec6bda34619089841a6f1b5d052df8c Mon Sep 17 00:00:00 2001 From: Gustavo B Date: Tue, 4 Aug 2026 22:47:44 -0300 Subject: [PATCH 1/6] fix: wrong endpoint when loading room types --- apps/dashboard/src/pages/FrontDesk.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard/src/pages/FrontDesk.tsx b/apps/dashboard/src/pages/FrontDesk.tsx index 14f939a..840dff8 100644 --- a/apps/dashboard/src/pages/FrontDesk.tsx +++ b/apps/dashboard/src/pages/FrontDesk.tsx @@ -267,7 +267,7 @@ export default function FrontDesk() { const { data: roomTypes } = useQuery({ queryKey: ['room-types', propertyId], - queryFn: () => api.get('/v1/room-types', { params: { propertyId } }).then((r) => r.data), + queryFn: () => api.get('/v1/rooms/types', { params: { propertyId } }).then((r) => r.data), enabled: !!propertyId && walkInOpen, }); From 8056e6a2723771ea80421980e3817841bd21defc Mon Sep 17 00:00:00 2001 From: Gustavo B Date: Wed, 5 Aug 2026 00:55:18 -0300 Subject: [PATCH 2/6] feat: FrontDesk walk-in form UX improvements --- .../dto/add-reservation-guest.dto.ts | 12 +- .../reservation/reservation-party.service.ts | 4 +- .../src/components/guests/FindGuest.tsx | 14 +- apps/dashboard/src/locales/en.json | 4 + apps/dashboard/src/locales/pt-BR.json | 4 + apps/dashboard/src/pages/FrontDesk.tsx | 202 ++++++++++++++++-- 6 files changed, 220 insertions(+), 20 deletions(-) diff --git a/apps/api/src/modules/reservation/dto/add-reservation-guest.dto.ts b/apps/api/src/modules/reservation/dto/add-reservation-guest.dto.ts index cc6d3fb..b890fc0 100644 --- a/apps/api/src/modules/reservation/dto/add-reservation-guest.dto.ts +++ b/apps/api/src/modules/reservation/dto/add-reservation-guest.dto.ts @@ -1,8 +1,16 @@ -import { IsUUID } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; +import { IsUUID, IsOptional, IsBoolean } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class AddReservationGuestDto { @ApiProperty({ description: 'Guest profile to attach as an accompanying occupant' }) @IsUUID() guestId!: string; + + @ApiPropertyOptional({ + description: + 'Explicit staff override to exceed the room type\'s configured maxOccupancy (e.g. extra bed/crib for a family)', + }) + @IsOptional() + @IsBoolean() + overrideMaxOccupancy?: boolean; } diff --git a/apps/api/src/modules/reservation/reservation-party.service.ts b/apps/api/src/modules/reservation/reservation-party.service.ts index d9ce940..eb760a0 100644 --- a/apps/api/src/modules/reservation/reservation-party.service.ts +++ b/apps/api/src/modules/reservation/reservation-party.service.ts @@ -116,7 +116,9 @@ export class ReservationPartyService { await this.assertNotOnSibling(reservation.bookingId, propertyId, dto.guestId); const occupants = await this.loadOccupants(reservationId, propertyId); - await this.assertWithinMaxOccupancy(reservation.roomTypeId, propertyId, occupants.length + 1); + if (!dto.overrideMaxOccupancy) { + await this.assertWithinMaxOccupancy(reservation.roomTypeId, propertyId, occupants.length + 1); + } const [row] = await this.db .insert(reservationGuests) diff --git a/apps/dashboard/src/components/guests/FindGuest.tsx b/apps/dashboard/src/components/guests/FindGuest.tsx index b21822f..ad8da9a 100644 --- a/apps/dashboard/src/components/guests/FindGuest.tsx +++ b/apps/dashboard/src/components/guests/FindGuest.tsx @@ -13,6 +13,10 @@ export interface FindGuestProps { onSelectGuest: (guest: Guest | null) => void; placeholder?: string; label?: string; + /** Guest IDs already picked elsewhere in the same form — hidden from search results. */ + excludeGuestIds?: string[]; + /** Flags this picker as missing a required selection (e.g. after a failed submit). */ + error?: boolean; } export default function FindGuest({ @@ -20,6 +24,8 @@ export default function FindGuest({ onSelectGuest, placeholder, label, + excludeGuestIds, + error, }: FindGuestProps) { const { t } = useTranslation(); const { propertyId } = useProperty(); @@ -65,7 +71,9 @@ export default function FindGuest({ enabled: !!propertyId && dropdownOpen, }); - const guests: Guest[] = data?.data ?? data ?? []; + const guests: Guest[] = (data?.data ?? data ?? []).filter( + (g: Guest) => !excludeGuestIds?.includes(g.id), + ); return (
@@ -126,7 +134,9 @@ export default function FindGuest({ }} onFocus={() => setDropdownOpen(true)} placeholder={placeholder || t('guests.searchOrRegister')} - className="w-full border border-gray-200 rounded-lg pl-9 pr-24 py-2 text-sm focus:outline-none focus:border-telivity-teal bg-white" + className={`w-full border rounded-lg pl-9 pr-24 py-2 text-sm focus:outline-none focus:border-telivity-teal bg-white ${ + error ? 'border-telivity-orange ring-1 ring-telivity-orange' : 'border-gray-200' + }`} />
@@ -1593,6 +1678,8 @@ export default function FrontDesk() { label={t('frontDesk.partyGuest', { room: idx + 2 })} selectedGuest={extra.guest} onSelectGuest={(guest) => updateExtraRoom(extra.key, { guest })} + excludeGuestIds={wiSelectedGuestIds} + error={walkInMutation.isError && !extra.guest} />
@@ -1642,7 +1729,11 @@ export default function FrontDesk() {
+ updateExtraRoom(extra.key, { overrideOccupancy: checked })} + onChange={(guests) => updateExtraRoom(extra.key, { additionalGuests: guests })} + excludeGuestIds={wiSelectedGuestIds} + t={t} + /> ); })} @@ -1666,8 +1766,9 @@ export default function FrontDesk() { emptyWalkInExtraRoom({ roomTypeId: wiRoomTypeId, ratePlanId: wiRatePlanId }), ]) } - className="text-sm font-semibold text-telivity-teal hover:underline" + className="w-full flex items-center justify-center gap-1.5 border-2 border-dashed border-telivity-teal/40 text-telivity-teal rounded-xl py-2.5 text-sm font-semibold hover:bg-telivity-teal/5 hover:border-telivity-teal/60 transition-colors" > + {t('frontDesk.addAnotherRoom')} ) : ( @@ -1860,3 +1961,74 @@ function formatLabel(s: string, t: (key: string, options?: Record c.toUpperCase()), }); } + +/** Pickers for extra occupants sharing one room (up to the room type's max occupancy). */ +function AdditionalGuestsSection({ + guests, + max, + overrideChecked, + onOverrideChange, + onChange, + excludeGuestIds, + t, +}: { + guests: Guest[]; + max?: number; + overrideChecked: boolean; + onOverrideChange: (checked: boolean) => void; + onChange: (guests: Guest[]) => void; + excludeGuestIds?: string[]; + t: (key: string, options?: Record) => string; +}) { + const currentCount = 1 + guests.length; + const overCapacity = max != null && currentCount > max; + return ( +
+
+ + {max != null && ( + + {currentCount}/{max} + + )} +
+ {guests.map((guest, i) => ( + + // Clearing a picker (next === null) drops it from the list — no separate remove button. + onChange( + next + ? guests.map((g, idx) => (idx === i ? next : g)) + : guests.filter((_, idx) => idx !== i), + ) + } + /> + ))} + { + if (guest) onChange([...guests, guest]); + }} + /> + {overCapacity && ( + + )} +
+ ); +} From c8b52500ac178fe109cef224bbec6f8ced9219c5 Mon Sep 17 00:00:00 2001 From: Gustavo B Date: Thu, 6 Aug 2026 14:01:18 -0300 Subject: [PATCH 3/6] feat: improved translations --- apps/dashboard/src/locales/en.json | 30 ++++++++++++++++++++++++++ apps/dashboard/src/locales/pt-BR.json | 4 +++- apps/dashboard/src/pages/FrontDesk.tsx | 7 ++---- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/apps/dashboard/src/locales/en.json b/apps/dashboard/src/locales/en.json index aae8993..0d82155 100644 --- a/apps/dashboard/src/locales/en.json +++ b/apps/dashboard/src/locales/en.json @@ -563,6 +563,15 @@ "ratePlan": "Rate plan", "regAddress": "Address", "regNationality": "Nationality", + "destinationCity": "Next city", + "destinationCountry": "Next country", + "destinationState": "Next state", + "fnrhCollapse": "Collapse -", + "fnrhExpand": "Expand +", + "fnrhStayDetails": "Travel & Origin Details", + "originCity": "City of origin", + "originCountry": "Country of origin", + "originState": "State of origin", "registrationSigned": "Guest signed registration card", "room": "Room", "roomNumber": "Room {{number}}", @@ -572,6 +581,27 @@ "selectRoom": "Select room", "selectRoomType": "Select room type", "title": "Front Desk", + "transportModes": { + "bus": "Bus", + "car": "Car", + "motorcycle": "Motorcycle", + "other": "Other", + "plane": "Plane", + "ship": "Ship / Boat", + "train": "Train" + }, + "transportationMode": "Mode of transportation", + "travelReason": "Reason for travel", + "travelReasons": { + "business": "Business / Convention", + "congress": "Congress / Trade show", + "health": "Health", + "leisure": "Leisure / Vacation", + "other": "Other", + "relatives": "Relatives / Friends", + "shopping": "Shopping", + "studies": "Studies / Courses" + }, "unassignedBadge": "{{count}} unassigned", "unknownGuest": "Unknown Guest", "usePreAssignedRoom": "Use pre-assigned room", diff --git a/apps/dashboard/src/locales/pt-BR.json b/apps/dashboard/src/locales/pt-BR.json index b5e4902..d829123 100644 --- a/apps/dashboard/src/locales/pt-BR.json +++ b/apps/dashboard/src/locales/pt-BR.json @@ -502,7 +502,9 @@ "destinationCity": "Próxima cidade", "destinationCountry": "Próximo país", "destinationState": "Próxima UF", - "fnrhStayDetails": "FNRH - Registro da Viagem", + "fnrhCollapse": "Recolher -", + "fnrhExpand": "Expandir +", + "fnrhStayDetails": "Detalhes da Viagem & Procedência", "originCity": "Cidade de procedência", "originCountry": "País de procedência", "originState": "UF de procedência", diff --git a/apps/dashboard/src/pages/FrontDesk.tsx b/apps/dashboard/src/pages/FrontDesk.tsx index 59becc0..50bfc3d 100644 --- a/apps/dashboard/src/pages/FrontDesk.tsx +++ b/apps/dashboard/src/pages/FrontDesk.tsx @@ -1129,9 +1129,6 @@ export default function FrontDesk() {

- - FNRH Ativa - @@ -1229,14 +1226,14 @@ export default function FrontDesk() {

- FNRH — Detalhes da Viagem & Procedência + {t('frontDesk.fnrhStayDetails')}

From 72cd2eb4b42a0c66a1c3a98b3d912481fdda8b3a Mon Sep 17 00:00:00 2001 From: Gustavo B Date: Thu, 6 Aug 2026 17:36:29 -0300 Subject: [PATCH 4/6] fix: merge blocker, split party --- .../reservation/dto/split-reservation.dto.ts | 9 + .../reservation-party.service.spec.ts | 202 ++++++++++++++++++ .../reservation/reservation-party.service.ts | 3 + .../reservations/ReservationPartyPanel.tsx | 43 +++- apps/dashboard/src/locales/en.json | 1 + apps/dashboard/src/locales/pt-BR.json | 1 + apps/dashboard/src/pages/FrontDesk.tsx | 10 +- 7 files changed, 263 insertions(+), 6 deletions(-) diff --git a/apps/api/src/modules/reservation/dto/split-reservation.dto.ts b/apps/api/src/modules/reservation/dto/split-reservation.dto.ts index e9bed83..7f8d789 100644 --- a/apps/api/src/modules/reservation/dto/split-reservation.dto.ts +++ b/apps/api/src/modules/reservation/dto/split-reservation.dto.ts @@ -7,6 +7,7 @@ import { Min, IsString, MaxLength, + IsBoolean, } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsMoneyString } from '../../../common/validation/is-money-string.validator'; @@ -58,4 +59,12 @@ export class SplitReservationDto { @IsInt() @Min(0) children?: number; + + @ApiPropertyOptional({ + description: + 'Explicit staff override to exceed the destination room type\'s configured maxOccupancy (e.g. extra bed/crib for a family)', + }) + @IsOptional() + @IsBoolean() + overrideMaxOccupancy?: boolean; } diff --git a/apps/api/src/modules/reservation/reservation-party.service.spec.ts b/apps/api/src/modules/reservation/reservation-party.service.spec.ts index 180583a..48aab64 100644 --- a/apps/api/src/modules/reservation/reservation-party.service.spec.ts +++ b/apps/api/src/modules/reservation/reservation-party.service.spec.ts @@ -160,6 +160,78 @@ describe('ReservationPartyService', () => { BadRequestException, ); }); + + it('rejects when adding would exceed max occupancy and no override is given', async () => { + mockSelectSequence([ + [sourceReservation], // requireReservation + [{ id: GUEST_NEW, isDnr: false, isDeleted: false }], // guest profile + [], // existing on this res + [], // not on sibling + [ + { + id: 'rg-1', + propertyId: PROPERTY, + reservationId: RES_A, + guestId: GUEST_PRIMARY, + role: 'primary', + firstName: 'Pat', + lastName: 'Primary', + email: null, + }, + ], // loadOccupants (1 occupant already) + [{ maxOccupancy: 1 }], // room type cap — adding a 2nd named guest exceeds it + ]); + + await expect(svc.addGuest(RES_A, PROPERTY, { guestId: GUEST_NEW })).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('skips the max occupancy check when overrideMaxOccupancy is true', async () => { + mockSelectSequence([ + [sourceReservation], // requireReservation + [{ id: GUEST_NEW, isDnr: false, isDeleted: false }], // guest profile + [], // existing on this res + [], // not on sibling + [ + { + id: 'rg-1', + propertyId: PROPERTY, + reservationId: RES_A, + guestId: GUEST_PRIMARY, + role: 'primary', + firstName: 'Pat', + lastName: 'Primary', + email: null, + }, + ], // loadOccupants (1 occupant already) + // No room type cap lookup should occur — assertWithinMaxOccupancy is skipped entirely. + ]); + + const inserted = { + id: 'rg-new', + propertyId: PROPERTY, + reservationId: RES_A, + guestId: GUEST_NEW, + role: 'accompanying', + }; + db.insert.mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([inserted]), + }), + }); + db.update.mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([]), + }), + }); + + const result = await svc.addGuest(RES_A, PROPERTY, { + guestId: GUEST_NEW, + overrideMaxOccupancy: true, + }); + expect(result.guestId).toBe(GUEST_NEW); + }); }); describe('removeGuest', () => { @@ -295,6 +367,136 @@ describe('ReservationPartyService', () => { }), ).rejects.toBeInstanceOf(BadRequestException); }); + + it('rejects when the destination room type max occupancy is exceeded and no override is given', async () => { + const guestAcc2 = 'guest-a2'; + mockSelectSequence([ + [sourceReservation], // requireReservation + [ + { + id: 'rg-1', + propertyId: PROPERTY, + reservationId: RES_A, + guestId: GUEST_PRIMARY, + role: 'primary', + firstName: 'Pat', + lastName: 'Primary', + email: null, + }, + { + id: 'rg-2', + propertyId: PROPERTY, + reservationId: RES_A, + guestId: GUEST_ACC, + role: 'accompanying', + firstName: 'Ann', + lastName: 'Acc', + email: null, + }, + { + id: 'rg-3', + propertyId: PROPERTY, + reservationId: RES_A, + guestId: guestAcc2, + role: 'accompanying', + firstName: 'Ann2', + lastName: 'Acc2', + email: null, + }, + ], // occupants + [{ id: 'rt-002' }], // room type fk + [{ id: 'rp-002' }], // rate plan fk + [{ maxOccupancy: 1 }], // destination room type cap — moving 2 guests exceeds it + ]); + + await expect( + svc.split(RES_A, PROPERTY, { + guestIds: [GUEST_ACC, guestAcc2], + roomTypeId: 'rt-002', + ratePlanId: 'rp-002', + totalAmount: '200.00', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('allows exceeding the destination max occupancy when overrideMaxOccupancy is true', async () => { + const guestAcc2 = 'guest-a2'; + mockSelectSequence([ + [sourceReservation], // requireReservation + [ + { + id: 'rg-1', + propertyId: PROPERTY, + reservationId: RES_A, + guestId: GUEST_PRIMARY, + role: 'primary', + firstName: 'Pat', + lastName: 'Primary', + email: null, + }, + { + id: 'rg-2', + propertyId: PROPERTY, + reservationId: RES_A, + guestId: GUEST_ACC, + role: 'accompanying', + firstName: 'Ann', + lastName: 'Acc', + email: null, + }, + { + id: 'rg-3', + propertyId: PROPERTY, + reservationId: RES_A, + guestId: guestAcc2, + role: 'accompanying', + firstName: 'Ann2', + lastName: 'Acc2', + email: null, + }, + ], // occupants + [{ id: 'rt-002' }], // room type fk + [{ id: 'rp-002' }], // rate plan fk + // No room type cap lookup should occur — assertWithinMaxOccupancy is skipped entirely. + ]); + + const created = { + id: 'res-new', + propertyId: PROPERTY, + bookingId: BOOKING, + guestId: GUEST_ACC, + roomTypeId: 'rt-002', + arrivalDate: '2026-08-01', + departureDate: '2026-08-03', + status: 'confirmed', + }; + + db.transaction.mockImplementation(async (cb: any) => { + const tx = { + insert: vi.fn().mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([created]), + }), + }), + update: vi.fn().mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([]), + }), + }), + }; + return cb(tx); + }); + + const result = await svc.split(RES_A, PROPERTY, { + guestIds: [GUEST_ACC, guestAcc2], + roomTypeId: 'rt-002', + ratePlanId: 'rp-002', + totalAmount: '200.00', + overrideMaxOccupancy: true, + }); + + expect(result.reservation.id).toBe('res-new'); + }); }); describe('moveGuest', () => { diff --git a/apps/api/src/modules/reservation/reservation-party.service.ts b/apps/api/src/modules/reservation/reservation-party.service.ts index eb760a0..fa79b5a 100644 --- a/apps/api/src/modules/reservation/reservation-party.service.ts +++ b/apps/api/src/modules/reservation/reservation-party.service.ts @@ -217,6 +217,9 @@ export class ReservationPartyService { await this.assertSamePropertyFk(roomTypes, dto.roomTypeId, propertyId, 'room type'); await this.assertSamePropertyFk(ratePlans, dto.ratePlanId, propertyId, 'rate plan'); + if (!dto.overrideMaxOccupancy) { + await this.assertWithinMaxOccupancy(dto.roomTypeId, propertyId, moving.length); + } await this.ratePlanService.assertSellable( propertyId, dto.ratePlanId, diff --git a/apps/dashboard/src/components/reservations/ReservationPartyPanel.tsx b/apps/dashboard/src/components/reservations/ReservationPartyPanel.tsx index 2e0062d..4a111d9 100644 --- a/apps/dashboard/src/components/reservations/ReservationPartyPanel.tsx +++ b/apps/dashboard/src/components/reservations/ReservationPartyPanel.tsx @@ -67,6 +67,7 @@ export default function ReservationPartyPanel({ const { toast } = useToast(); const queryClient = useQueryClient(); const [addOpen, setAddOpen] = useState(false); + const [addOverrideOccupancy, setAddOverrideOccupancy] = useState(false); const [splitOpen, setSplitOpen] = useState(false); const [moveGuestId, setMoveGuestId] = useState(null); const [selectedGuest, setSelectedGuest] = useState(null); @@ -77,6 +78,7 @@ export default function ReservationPartyPanel({ const [splitAmount, setSplitAmount] = useState( totalAmount ? String(Number(totalAmount) / 2) : '0.00', ); + const [splitOverrideOccupancy, setSplitOverrideOccupancy] = useState(false); const [moveTargetId, setMoveTargetId] = useState(''); const invalidate = () => { @@ -136,7 +138,7 @@ export default function ReservationPartyPanel({ if (!selectedGuest) throw new Error('guest required'); return api.post( `/v1/reservations/${reservationId}/guests`, - { guestId: selectedGuest.id }, + { guestId: selectedGuest.id, overrideMaxOccupancy: addOverrideOccupancy }, { params: { propertyId } }, ); }, @@ -144,6 +146,7 @@ export default function ReservationPartyPanel({ toast('success', t('reservations.guestAdded')); setAddOpen(false); setSelectedGuest(null); + setAddOverrideOccupancy(false); invalidate(); }, }); @@ -172,6 +175,7 @@ export default function ReservationPartyPanel({ currencyCode, roomId: splitRoomId || undefined, adults: Math.max(1, splitGuestIds.length), + overrideMaxOccupancy: splitOverrideOccupancy, }, { params: { propertyId } }, ); @@ -180,6 +184,7 @@ export default function ReservationPartyPanel({ toast('success', t('reservations.splitSuccess')); setSplitOpen(false); setSplitGuestIds([]); + setSplitOverrideOccupancy(false); invalidate(); }, }); @@ -328,10 +333,26 @@ export default function ReservationPartyPanel({
)} - { setAddOpen(false); setSelectedGuest(null); }} title={t('reservations.addGuest')}> + { + setAddOpen(false); + setSelectedGuest(null); + setAddOverrideOccupancy(false); + }} + title={t('reservations.addGuest')} + >

{t('reservations.addGuestHint')}

+
+