@burdenoff/microfe-ggnomad 2026.1016.1 → 2026.1029.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ggnomad/GGNomadRoot.js +4 -2
- package/dist/ggnomad/GGNomadRoot.js.map +1 -1
- package/dist/ggnomad/GGNomadRoutes.js.map +1 -1
- package/dist/ggnomad/components/AuditTrailDialog.js.map +1 -1
- package/dist/ggnomad/components/AvailabilityPanel.js.map +1 -1
- package/dist/ggnomad/components/BookingPanel.d.ts.map +1 -1
- package/dist/ggnomad/components/BookingPanel.js +53 -70
- package/dist/ggnomad/components/BookingPanel.js.map +1 -1
- package/dist/ggnomad/components/CreateProviderDialog.js.map +1 -1
- package/dist/ggnomad/components/CreateTravelPolicyDialog.js.map +1 -1
- package/dist/ggnomad/components/GroupTripPanel.js.map +1 -1
- package/dist/ggnomad/components/ListingEditDialog.js.map +1 -1
- package/dist/ggnomad/components/ListingFilters.js.map +1 -1
- package/dist/ggnomad/components/PhotoGallery.js.map +1 -1
- package/dist/ggnomad/components/ProviderCard.js.map +1 -1
- package/dist/ggnomad/components/ProviderDashboardCard.js.map +1 -1
- package/dist/ggnomad/components/RatingSummary.js.map +1 -1
- package/dist/ggnomad/components/RedeemTicketPanel.js.map +1 -1
- package/dist/ggnomad/components/ReviewsSection.js.map +1 -1
- package/dist/ggnomad/components/StatusBadge.js.map +1 -1
- package/dist/ggnomad/components/TicketDialog.js +1 -1
- package/dist/ggnomad/components/TicketDialog.js.map +1 -1
- package/dist/ggnomad/components/index.js +26 -25
- package/dist/ggnomad/index.js +3 -2
- package/dist/ggnomad/pages/ActivityDetailsPage.js.map +1 -1
- package/dist/ggnomad/pages/AnalyticsPage.js.map +1 -1
- package/dist/ggnomad/pages/BookingsPage.d.ts.map +1 -1
- package/dist/ggnomad/pages/BookingsPage.js +54 -52
- package/dist/ggnomad/pages/BookingsPage.js.map +1 -1
- package/dist/ggnomad/pages/DashboardPage.js.map +1 -1
- package/dist/ggnomad/pages/EntertainmentPage.js.map +1 -1
- package/dist/ggnomad/pages/EventDetailsPage.js.map +1 -1
- package/dist/ggnomad/pages/FavoritesPage.js.map +1 -1
- package/dist/ggnomad/pages/HospitalityPage.js.map +1 -1
- package/dist/ggnomad/pages/OperationsQueuePage.js.map +1 -1
- package/dist/ggnomad/pages/PageShell.js.map +1 -1
- package/dist/ggnomad/pages/PropertyDetailsPage.js.map +1 -1
- package/dist/ggnomad/pages/ProviderAnalyticsPage.js.map +1 -1
- package/dist/ggnomad/pages/SearchPage.js +1 -1
- package/dist/ggnomad/pages/SearchPage.js.map +1 -1
- package/dist/ggnomad/pages/StorefrontPage.js.map +1 -1
- package/dist/ggnomad/pages/StorefrontPublicPage.js.map +1 -1
- package/dist/ggnomad/pages/TourDetailsPage.js.map +1 -1
- package/dist/ggnomad/pages/TourismPage.js.map +1 -1
- package/dist/ggnomad/pages/TravelPage.js +1 -1
- package/dist/ggnomad/pages/TravelPage.js.map +1 -1
- package/dist/ggnomad/pages/TravelPolicyPage.js.map +1 -1
- package/dist/ggnomad/pages/TripPlanDetailPage.d.ts.map +1 -1
- package/dist/ggnomad/pages/TripPlanDetailPage.js +278 -273
- package/dist/ggnomad/pages/TripPlanDetailPage.js.map +1 -1
- package/dist/ggnomad/pages/TripsPage.js.map +1 -1
- package/dist/ggnomad/providers/GGNomadProvider.js.map +1 -1
- package/dist/ggnomad/utils/bookingDates.d.ts +21 -0
- package/dist/ggnomad/utils/bookingDates.d.ts.map +1 -0
- package/dist/ggnomad/utils/bookingDates.js +22 -0
- package/dist/ggnomad/utils/bookingDates.js.map +1 -0
- package/dist/ggnomad/utils/format.js.map +1 -1
- package/dist/ggnomad/utils/notify.js +1 -1
- package/dist/ggnomad/utils/providerListControls.js +1 -1
- package/dist/ggnomad/utils/providerListControls.js.map +1 -1
- package/dist/ggnomad/utils/providerTypeMeta.js.map +1 -1
- package/dist/utils/pageContext.js +1 -1
- package/dist/utils/pageContext.js.map +1 -1
- package/package.json +18 -17
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TravelPage.js","names":[],"sources":["../../../src/ggnomad/pages/TravelPage.tsx"],"sourcesContent":["import { useState } from 'react';\nimport { useMutation, useQuery } from '@apollo/client/react';\nimport type { TypedDocumentNode } from '@apollo/client';\nimport { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';\nimport { ArrowRight, CalendarClock, Clock, Leaf, Plane, Plus, Search, Users } from 'lucide-react';\nimport { Button, GlassCard, IllustratedEmptyState, PagePurpose } from '@burdenoff/fe-libs/ui';\n\nimport {\n CREATE_GGNOMAD_TRIP_MUTATION,\n GGNOMAD_TRANSPORT_SEARCH_QUERY,\n GGNOMAD_TRIPS_QUERY,\n} from '../graphql';\nimport {\n formatDateTime,\n formatDurationMinutes,\n humanizeEnum,\n jsonArrayLength,\n jsonLabel,\n} from '../utils/format';\nimport { notify } from '../utils/notify';\nimport { PageShell } from './PageShell';\nimport { SectionCard, StatusBadge } from '../components';\n\ntype TripRow = {\n id: string;\n name: string;\n transportMode: string;\n status: string;\n origin?: unknown;\n destination?: unknown;\n departureAt: string;\n arrivalAt: string;\n isRoundTrip: boolean;\n carrier?: string | null;\n carrierCode?: string | null;\n class?: string | null;\n price: number;\n currency: string;\n passengers: number;\n ecoFriendly: boolean;\n co2Emissions?: number | null;\n stops?: unknown;\n returnDepartureAt?: string | null;\n returnArrivalAt?: string | null;\n};\n\ntype TransportOffer = {\n offerId: string;\n origin?: unknown;\n destination?: unknown;\n transportMode: string;\n departureAt: string;\n arrivalAt: string;\n returnDepartureAt?: string | null;\n returnArrivalAt?: string | null;\n isRoundTrip: boolean;\n carrier: string;\n carrierCode?: string | null;\n class?: string | null;\n price: number;\n currency: string;\n passengers: number;\n stops?: unknown;\n durationMinutes: number;\n ecoFriendly: boolean;\n co2Emissions?: number | null;\n providerName: string;\n};\n\ntype SearchCriteria = {\n origin: string;\n destination: string;\n departureDate: string;\n returnDate?: string;\n passengers?: number;\n transportMode?: string;\n};\n\nconst INPUT_CLASSES =\n 'mt-1 w-full rounded-md border border-border-seam bg-bg-canvas px-3 py-2 text-sm text-text-primary focus:border-action-primary-bg focus:outline-none focus:ring-1 focus:ring-action-primary-bg';\n\n/** `transportMode` on the search input is free-text; these are the modes the service resolves. */\nconst TRANSPORT_MODES = ['FLIGHT', 'TRAIN', 'BUS', 'FERRY'];\n\nconst ACTIVE_TRIP_STATUSES = new Set(['SEARCHING', 'BOOKED', 'CONFIRMED', 'IN_PROGRESS']);\n\nfunction routeLabel(origin: unknown, destination: unknown, fallback: string): string {\n const from = jsonLabel(origin);\n const to = jsonLabel(destination);\n if (from && to) return `${from} → ${to}`;\n return fallback;\n}\n\nexport function TravelPage() {\n const { t } = useI18n();\n const [form, setForm] = useState({\n origin: '',\n destination: '',\n departureDate: '',\n returnDate: '',\n passengers: 1,\n transportMode: '',\n });\n const [criteria, setCriteria] = useState<SearchCriteria | null>(null);\n\n const { data, refetch } = useQuery(\n GGNOMAD_TRIPS_QUERY as TypedDocumentNode<\n { ggnomadTrips: TripRow[] },\n { limit: number; offset: number }\n >,\n { variables: { limit: 20, offset: 0 } }\n );\n const trips = data?.ggnomadTrips ?? [];\n\n const offersQuery = useQuery(\n GGNOMAD_TRANSPORT_SEARCH_QUERY as TypedDocumentNode<\n { searchGgnomadTransport: TransportOffer[] },\n { input: SearchCriteria }\n >,\n {\n variables: { input: criteria ?? { origin: '', destination: '', departureDate: '' } },\n skip: criteria === null,\n }\n );\n const offers = offersQuery.data?.searchGgnomadTransport ?? [];\n\n const [createTrip, { loading: adding }] = useMutation(\n CREATE_GGNOMAD_TRIP_MUTATION as TypedDocumentNode<\n { createGgnomadTrip: { id: string; name: string; status: string } },\n {\n input: {\n name: string;\n transportMode: string;\n origin: unknown;\n destination: unknown;\n departureAt: string;\n arrivalAt: string;\n returnDepartureAt?: string | null;\n returnArrivalAt?: string | null;\n isRoundTrip?: boolean;\n carrier?: string | null;\n carrierCode?: string | null;\n class?: string | null;\n price: number;\n currency?: string;\n passengers?: number;\n stops?: unknown;\n co2Emissions?: number | null;\n ecoFriendly?: boolean;\n };\n }\n >\n );\n\n const handleSearch = () => {\n if (!form.origin.trim() || !form.destination.trim() || !form.departureDate) {\n notify.warning(\n t('ggnomad.travel.search.missingTitle', 'Route and date required'),\n t(\n 'ggnomad.travel.search.missingBody',\n 'Enter an origin, a destination and a departure date to search transport inventory.'\n )\n );\n return;\n }\n setCriteria({\n origin: form.origin.trim(),\n destination: form.destination.trim(),\n departureDate: new Date(form.departureDate).toISOString(),\n returnDate: form.returnDate ? new Date(form.returnDate).toISOString() : undefined,\n passengers: Math.max(1, form.passengers),\n transportMode: form.transportMode || undefined,\n });\n };\n\n const handleAddOffer = async (offer: TransportOffer) => {\n const name = routeLabel(offer.origin, offer.destination, offer.carrier);\n try {\n const result = await createTrip({\n variables: {\n input: {\n name,\n transportMode: offer.transportMode,\n origin: offer.origin ?? {},\n destination: offer.destination ?? {},\n departureAt: offer.departureAt,\n arrivalAt: offer.arrivalAt,\n returnDepartureAt: offer.returnDepartureAt ?? undefined,\n returnArrivalAt: offer.returnArrivalAt ?? undefined,\n isRoundTrip: offer.isRoundTrip,\n carrier: offer.carrier,\n carrierCode: offer.carrierCode ?? undefined,\n class: offer.class ?? undefined,\n price: offer.price,\n currency: offer.currency,\n passengers: offer.passengers,\n stops: offer.stops ?? undefined,\n co2Emissions: offer.co2Emissions ?? undefined,\n ecoFriendly: offer.ecoFriendly,\n },\n },\n });\n if (result.error) {\n notify.error(\n t('ggnomad.travel.toast.addErrorTitle', 'Could not add this route'),\n result.error.message\n );\n return;\n }\n notify.success(\n t('ggnomad.travel.toast.addSuccessTitle', 'Route added to trip inventory'),\n name\n );\n await refetch();\n } catch (err) {\n notify.error(\n t('ggnomad.travel.toast.addErrorTitle', 'Could not add this route'),\n err instanceof Error ? err.message : String(err)\n );\n }\n };\n\n return (\n <PageShell\n title={t('ggnomad.travel.title', 'Travel')}\n titleAccent={t('ggnomad.travel.title.accent', 'Travel')}\n subtitle={t('ggnomad.travel.subtitle', 'Live trip inventory and route management')}\n >\n <PagePurpose icon={<Plane className=\"size-4\" />}>\n {t(\n 'ggnomad.travel.purpose',\n 'Browse the live trip catalogue — flights, rail and road routes published by providers — to compare transport modes, prices and availability before turning a trip into a booking.'\n )}\n </PagePurpose>\n\n <SectionCard title={t('ggnomad.travel.search.title', 'Search transport')}>\n <div className=\"grid gap-3 sm:grid-cols-2 xl:grid-cols-6\">\n <div className=\"xl:col-span-1\">\n <label\n htmlFor=\"ggnomad-travel-origin\"\n className=\"block text-sm font-medium text-text-primary\"\n >\n {t('ggnomad.travel.search.origin', 'Origin')}\n </label>\n <input\n id=\"ggnomad-travel-origin\"\n type=\"text\"\n value={form.origin}\n onChange={(event) => setForm({ ...form, origin: event.target.value })}\n placeholder={t('ggnomad.travel.search.originPlaceholder', 'City or station')}\n className={INPUT_CLASSES}\n />\n </div>\n <div className=\"xl:col-span-1\">\n <label\n htmlFor=\"ggnomad-travel-destination\"\n className=\"block text-sm font-medium text-text-primary\"\n >\n {t('ggnomad.travel.search.destination', 'Destination')}\n </label>\n <input\n id=\"ggnomad-travel-destination\"\n type=\"text\"\n value={form.destination}\n onChange={(event) => setForm({ ...form, destination: event.target.value })}\n placeholder={t('ggnomad.travel.search.destinationPlaceholder', 'City or station')}\n className={INPUT_CLASSES}\n />\n </div>\n <div>\n <label\n htmlFor=\"ggnomad-travel-departure\"\n className=\"block text-sm font-medium text-text-primary\"\n >\n {t('ggnomad.travel.search.departure', 'Departure')}\n </label>\n <input\n id=\"ggnomad-travel-departure\"\n type=\"date\"\n value={form.departureDate}\n onChange={(event) => setForm({ ...form, departureDate: event.target.value })}\n className={INPUT_CLASSES}\n />\n </div>\n <div>\n <label\n htmlFor=\"ggnomad-travel-return\"\n className=\"block text-sm font-medium text-text-primary\"\n >\n {t('ggnomad.travel.search.return', 'Return (optional)')}\n </label>\n <input\n id=\"ggnomad-travel-return\"\n type=\"date\"\n min={form.departureDate || undefined}\n value={form.returnDate}\n onChange={(event) => setForm({ ...form, returnDate: event.target.value })}\n className={INPUT_CLASSES}\n />\n </div>\n <div>\n <label\n htmlFor=\"ggnomad-travel-passengers\"\n className=\"block text-sm font-medium text-text-primary\"\n >\n {t('ggnomad.travel.search.passengers', 'Travellers')}\n </label>\n <input\n id=\"ggnomad-travel-passengers\"\n type=\"number\"\n min={1}\n value={form.passengers}\n onChange={(event) =>\n setForm({ ...form, passengers: Math.max(1, Number(event.target.value) || 1) })\n }\n className={INPUT_CLASSES}\n />\n </div>\n <div>\n <label\n htmlFor=\"ggnomad-travel-mode\"\n className=\"block text-sm font-medium text-text-primary\"\n >\n {t('ggnomad.travel.search.mode', 'Mode')}\n </label>\n <select\n id=\"ggnomad-travel-mode\"\n value={form.transportMode}\n onChange={(event) => setForm({ ...form, transportMode: event.target.value })}\n className={INPUT_CLASSES}\n >\n <option value=\"\">{t('ggnomad.travel.search.anyMode', 'Any')}</option>\n {TRANSPORT_MODES.map((mode) => (\n <option key={mode} value={mode}>\n {humanizeEnum(mode)}\n </option>\n ))}\n </select>\n </div>\n </div>\n <div className=\"mt-4 flex flex-wrap items-center gap-3\">\n <Button onClick={handleSearch} disabled={offersQuery.loading}>\n <Search className=\"size-4\" aria-hidden=\"true\" />\n {offersQuery.loading\n ? t('ggnomad.travel.search.searching', 'Searching…')\n : t('ggnomad.travel.search.submit', 'Search routes')}\n </Button>\n <p className=\"text-xs text-text-secondary\">\n {t(\n 'ggnomad.travel.search.note',\n 'Returns live offers from the configured transport provider. Adding a route creates a trip in your inventory.'\n )}\n </p>\n </div>\n </SectionCard>\n\n {criteria !== null ? (\n <SectionCard title={t('ggnomad.travel.offers.title', 'Search results')}>\n {offersQuery.error ? (\n <p className=\"text-sm text-status-error-text\">\n {t('ggnomad.travel.offers.error', 'Could not search transport: {{message}}').replace(\n '{{message}}',\n offersQuery.error.message\n )}\n </p>\n ) : offersQuery.loading ? (\n <p className=\"text-sm text-text-secondary\">\n {t('ggnomad.travel.offers.loading', 'Searching transport inventory…')}\n </p>\n ) : offers.length === 0 ? (\n <p className=\"text-sm text-text-secondary\">\n {t(\n 'ggnomad.travel.offers.empty',\n 'No routes came back for that origin, destination and date.'\n )}\n </p>\n ) : (\n <ul className=\"space-y-3\">\n {offers.map((offer) => {\n const stopCount = jsonArrayLength(offer.stops);\n return (\n <li\n key={offer.offerId}\n className=\"flex flex-wrap items-center justify-between gap-4 rounded-lg border border-border-seam bg-bg-surface/40 p-4\"\n >\n <div className=\"min-w-0 space-y-1\">\n <p className=\"flex flex-wrap items-center gap-2 font-medium text-text-primary\">\n <span>{jsonLabel(offer.origin) ?? '—'}</span>\n <ArrowRight className=\"size-4 text-text-secondary\" aria-hidden=\"true\" />\n <span>{jsonLabel(offer.destination) ?? '—'}</span>\n </p>\n <div className=\"flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-text-secondary\">\n <span>{humanizeEnum(offer.transportMode)}</span>\n <span className=\"inline-flex items-center gap-1\">\n <Clock className=\"size-3.5\" aria-hidden=\"true\" />\n {formatDurationMinutes(offer.durationMinutes)}\n </span>\n <span className=\"inline-flex items-center gap-1\">\n <CalendarClock className=\"size-3.5\" aria-hidden=\"true\" />\n {formatDateTime(offer.departureAt) ?? offer.departureAt}\n </span>\n <span>\n {offer.carrierCode\n ? `${offer.carrier} · ${offer.carrierCode}`\n : offer.carrier}\n </span>\n {offer.class ? <span>{humanizeEnum(offer.class)}</span> : null}\n {stopCount !== null ? (\n <span>\n {stopCount === 0\n ? t('ggnomad.travel.offers.nonStop', 'Non-stop')\n : t('ggnomad.travel.offers.stops', '{{n}} stops').replace(\n '{{n}}',\n String(stopCount)\n )}\n </span>\n ) : null}\n <span className=\"inline-flex items-center gap-1\">\n <Users className=\"size-3.5\" aria-hidden=\"true\" />\n {offer.passengers}\n </span>\n {offer.ecoFriendly ? (\n <span className=\"inline-flex items-center gap-1 text-status-success-text\">\n <Leaf className=\"size-3.5\" aria-hidden=\"true\" />\n {t('ggnomad.travel.ecoFriendly', 'Lower-emission mode')}\n </span>\n ) : null}\n {offer.co2Emissions !== null && offer.co2Emissions !== undefined ? (\n <span className=\"tabular-nums\">\n {t('ggnomad.travel.co2', '≈{{kg}} kg CO₂e').replace(\n '{{kg}}',\n offer.co2Emissions.toLocaleString()\n )}\n </span>\n ) : null}\n </div>\n </div>\n <div className=\"flex items-center gap-3\">\n <span className=\"text-lg font-semibold tabular-nums text-text-primary\">\n {offer.currency} {Number(offer.price).toFixed(2)}\n </span>\n <Button\n variant=\"outline\"\n size=\"sm\"\n disabled={adding}\n onClick={() => void handleAddOffer(offer)}\n >\n <Plus className=\"size-4\" aria-hidden=\"true\" />\n {t('ggnomad.travel.offers.add', 'Add to trips')}\n </Button>\n </div>\n </li>\n );\n })}\n </ul>\n )}\n </SectionCard>\n ) : null}\n\n {trips.length === 0 ? (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title={t('ggnomad.travel.emptyTitle', 'No trips in inventory')}\n description={t(\n 'ggnomad.travel.emptyDescription',\n 'Routes and trips added by providers will appear here.'\n )}\n />\n ) : (\n <GlassCard treatment=\"glass\" glow className=\"border-border-seam p-5\">\n <h2 className=\"mb-4 text-lg font-medium tracking-tight text-text-primary\">\n {t('ggnomad.travel.availableTrips', 'Available trips')}\n </h2>\n <ul className=\"grid gap-3\">\n {trips.map((trip) => {\n const stopCount = jsonArrayLength(trip.stops);\n return (\n <li\n key={trip.id}\n className=\"flex flex-wrap items-start justify-between gap-4 rounded-lg border border-border-seam bg-bg-surface/40 p-4\"\n >\n <div className=\"min-w-0 space-y-1\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <p className=\"font-medium text-text-primary\">\n {routeLabel(trip.origin, trip.destination, trip.name)}\n </p>\n <StatusBadge\n status={trip.status}\n live={ACTIVE_TRIP_STATUSES.has(trip.status.toUpperCase())}\n />\n {trip.isRoundTrip ? (\n <span className=\"rounded-full bg-bg-sunken px-2.5 py-0.5 text-xs font-medium text-text-secondary\">\n {t('ggnomad.travel.roundTrip', 'Round trip')}\n </span>\n ) : null}\n </div>\n <div className=\"flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-text-secondary\">\n <span>{humanizeEnum(trip.transportMode)}</span>\n <span className=\"inline-flex items-center gap-1\">\n <CalendarClock className=\"size-3.5\" aria-hidden=\"true\" />\n {formatDateTime(trip.departureAt) ?? trip.departureAt}\n {' → '}\n {formatDateTime(trip.arrivalAt) ?? trip.arrivalAt}\n </span>\n {trip.carrier ? (\n <span>\n {trip.carrierCode\n ? `${trip.carrier} · ${trip.carrierCode}`\n : trip.carrier}\n </span>\n ) : null}\n {trip.class ? <span>{humanizeEnum(trip.class)}</span> : null}\n {stopCount !== null && stopCount > 0 ? (\n <span>\n {t('ggnomad.travel.offers.stops', '{{n}} stops').replace(\n '{{n}}',\n String(stopCount)\n )}\n </span>\n ) : null}\n <span className=\"inline-flex items-center gap-1\">\n <Users className=\"size-3.5\" aria-hidden=\"true\" />\n {trip.passengers}\n </span>\n {trip.ecoFriendly ? (\n <span className=\"inline-flex items-center gap-1 text-status-success-text\">\n <Leaf className=\"size-3.5\" aria-hidden=\"true\" />\n {t('ggnomad.travel.ecoFriendly', 'Lower-emission mode')}\n </span>\n ) : null}\n {trip.co2Emissions !== null && trip.co2Emissions !== undefined ? (\n <span className=\"tabular-nums\">\n {t('ggnomad.travel.co2', '≈{{kg}} kg CO₂e').replace(\n '{{kg}}',\n trip.co2Emissions.toLocaleString()\n )}\n </span>\n ) : null}\n </div>\n {trip.returnDepartureAt ? (\n <p className=\"text-sm text-text-secondary\">\n {t('ggnomad.travel.returnLeg', 'Return: {{depart}} → {{arrive}}')\n .replace(\n '{{depart}}',\n formatDateTime(trip.returnDepartureAt) ?? trip.returnDepartureAt\n )\n .replace('{{arrive}}', formatDateTime(trip.returnArrivalAt) ?? '—')}\n </p>\n ) : null}\n </div>\n <span className=\"text-lg font-semibold tabular-nums text-text-primary\">\n {trip.currency} {Number(trip.price).toFixed(2)}\n </span>\n </li>\n );\n })}\n </ul>\n </GlassCard>\n )}\n </PageShell>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"TravelPage.js","names":[],"sources":["../../../src/ggnomad/pages/TravelPage.tsx"],"sourcesContent":["import { useState } from 'react';\nimport { useMutation, useQuery } from '@apollo/client/react';\nimport type { TypedDocumentNode } from '@apollo/client';\nimport { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';\nimport { ArrowRight, CalendarClock, Clock, Leaf, Plane, Plus, Search, Users } from 'lucide-react';\nimport { Button, GlassCard, IllustratedEmptyState, PagePurpose } from '@burdenoff/fe-libs/ui';\n\nimport {\n CREATE_GGNOMAD_TRIP_MUTATION,\n GGNOMAD_TRANSPORT_SEARCH_QUERY,\n GGNOMAD_TRIPS_QUERY,\n} from '../graphql';\nimport {\n formatDateTime,\n formatDurationMinutes,\n humanizeEnum,\n jsonArrayLength,\n jsonLabel,\n} from '../utils/format';\nimport { notify } from '../utils/notify';\nimport { PageShell } from './PageShell';\nimport { SectionCard, StatusBadge } from '../components';\n\ntype TripRow = {\n id: string;\n name: string;\n transportMode: string;\n status: string;\n origin?: unknown;\n destination?: unknown;\n departureAt: string;\n arrivalAt: string;\n isRoundTrip: boolean;\n carrier?: string | null;\n carrierCode?: string | null;\n class?: string | null;\n price: number;\n currency: string;\n passengers: number;\n ecoFriendly: boolean;\n co2Emissions?: number | null;\n stops?: unknown;\n returnDepartureAt?: string | null;\n returnArrivalAt?: string | null;\n};\n\ntype TransportOffer = {\n offerId: string;\n origin?: unknown;\n destination?: unknown;\n transportMode: string;\n departureAt: string;\n arrivalAt: string;\n returnDepartureAt?: string | null;\n returnArrivalAt?: string | null;\n isRoundTrip: boolean;\n carrier: string;\n carrierCode?: string | null;\n class?: string | null;\n price: number;\n currency: string;\n passengers: number;\n stops?: unknown;\n durationMinutes: number;\n ecoFriendly: boolean;\n co2Emissions?: number | null;\n providerName: string;\n};\n\ntype SearchCriteria = {\n origin: string;\n destination: string;\n departureDate: string;\n returnDate?: string;\n passengers?: number;\n transportMode?: string;\n};\n\nconst INPUT_CLASSES =\n 'mt-1 w-full rounded-md border border-border-seam bg-bg-canvas px-3 py-2 text-sm text-text-primary focus:border-action-primary-bg focus:outline-none focus:ring-1 focus:ring-action-primary-bg';\n\n/** `transportMode` on the search input is free-text; these are the modes the service resolves. */\nconst TRANSPORT_MODES = ['FLIGHT', 'TRAIN', 'BUS', 'FERRY'];\n\nconst ACTIVE_TRIP_STATUSES = new Set(['SEARCHING', 'BOOKED', 'CONFIRMED', 'IN_PROGRESS']);\n\nfunction routeLabel(origin: unknown, destination: unknown, fallback: string): string {\n const from = jsonLabel(origin);\n const to = jsonLabel(destination);\n if (from && to) return `${from} → ${to}`;\n return fallback;\n}\n\nexport function TravelPage() {\n const { t } = useI18n();\n const [form, setForm] = useState({\n origin: '',\n destination: '',\n departureDate: '',\n returnDate: '',\n passengers: 1,\n transportMode: '',\n });\n const [criteria, setCriteria] = useState<SearchCriteria | null>(null);\n\n const { data, refetch } = useQuery(\n GGNOMAD_TRIPS_QUERY as TypedDocumentNode<\n { ggnomadTrips: TripRow[] },\n { limit: number; offset: number }\n >,\n { variables: { limit: 20, offset: 0 } }\n );\n const trips = data?.ggnomadTrips ?? [];\n\n const offersQuery = useQuery(\n GGNOMAD_TRANSPORT_SEARCH_QUERY as TypedDocumentNode<\n { searchGgnomadTransport: TransportOffer[] },\n { input: SearchCriteria }\n >,\n {\n variables: { input: criteria ?? { origin: '', destination: '', departureDate: '' } },\n skip: criteria === null,\n }\n );\n const offers = offersQuery.data?.searchGgnomadTransport ?? [];\n\n const [createTrip, { loading: adding }] = useMutation(\n CREATE_GGNOMAD_TRIP_MUTATION as TypedDocumentNode<\n { createGgnomadTrip: { id: string; name: string; status: string } },\n {\n input: {\n name: string;\n transportMode: string;\n origin: unknown;\n destination: unknown;\n departureAt: string;\n arrivalAt: string;\n returnDepartureAt?: string | null;\n returnArrivalAt?: string | null;\n isRoundTrip?: boolean;\n carrier?: string | null;\n carrierCode?: string | null;\n class?: string | null;\n price: number;\n currency?: string;\n passengers?: number;\n stops?: unknown;\n co2Emissions?: number | null;\n ecoFriendly?: boolean;\n };\n }\n >\n );\n\n const handleSearch = () => {\n if (!form.origin.trim() || !form.destination.trim() || !form.departureDate) {\n notify.warning(\n t('ggnomad.travel.search.missingTitle', 'Route and date required'),\n t(\n 'ggnomad.travel.search.missingBody',\n 'Enter an origin, a destination and a departure date to search transport inventory.'\n )\n );\n return;\n }\n setCriteria({\n origin: form.origin.trim(),\n destination: form.destination.trim(),\n departureDate: new Date(form.departureDate).toISOString(),\n returnDate: form.returnDate ? new Date(form.returnDate).toISOString() : undefined,\n passengers: Math.max(1, form.passengers),\n transportMode: form.transportMode || undefined,\n });\n };\n\n const handleAddOffer = async (offer: TransportOffer) => {\n const name = routeLabel(offer.origin, offer.destination, offer.carrier);\n try {\n const result = await createTrip({\n variables: {\n input: {\n name,\n transportMode: offer.transportMode,\n origin: offer.origin ?? {},\n destination: offer.destination ?? {},\n departureAt: offer.departureAt,\n arrivalAt: offer.arrivalAt,\n returnDepartureAt: offer.returnDepartureAt ?? undefined,\n returnArrivalAt: offer.returnArrivalAt ?? undefined,\n isRoundTrip: offer.isRoundTrip,\n carrier: offer.carrier,\n carrierCode: offer.carrierCode ?? undefined,\n class: offer.class ?? undefined,\n price: offer.price,\n currency: offer.currency,\n passengers: offer.passengers,\n stops: offer.stops ?? undefined,\n co2Emissions: offer.co2Emissions ?? undefined,\n ecoFriendly: offer.ecoFriendly,\n },\n },\n });\n if (result.error) {\n notify.error(\n t('ggnomad.travel.toast.addErrorTitle', 'Could not add this route'),\n result.error.message\n );\n return;\n }\n notify.success(\n t('ggnomad.travel.toast.addSuccessTitle', 'Route added to trip inventory'),\n name\n );\n await refetch();\n } catch (err) {\n notify.error(\n t('ggnomad.travel.toast.addErrorTitle', 'Could not add this route'),\n err instanceof Error ? err.message : String(err)\n );\n }\n };\n\n return (\n <PageShell\n title={t('ggnomad.travel.title', 'Travel')}\n titleAccent={t('ggnomad.travel.title.accent', 'Travel')}\n subtitle={t('ggnomad.travel.subtitle', 'Live trip inventory and route management')}\n >\n <PagePurpose icon={<Plane className=\"size-4\" />}>\n {t(\n 'ggnomad.travel.purpose',\n 'Browse the live trip catalogue — flights, rail and road routes published by providers — to compare transport modes, prices and availability before turning a trip into a booking.'\n )}\n </PagePurpose>\n\n <SectionCard title={t('ggnomad.travel.search.title', 'Search transport')}>\n <div className=\"grid gap-3 sm:grid-cols-2 xl:grid-cols-6\">\n <div className=\"xl:col-span-1\">\n <label\n htmlFor=\"ggnomad-travel-origin\"\n className=\"block text-sm font-medium text-text-primary\"\n >\n {t('ggnomad.travel.search.origin', 'Origin')}\n </label>\n <input\n id=\"ggnomad-travel-origin\"\n type=\"text\"\n value={form.origin}\n onChange={(event) => setForm({ ...form, origin: event.target.value })}\n placeholder={t('ggnomad.travel.search.originPlaceholder', 'City or station')}\n className={INPUT_CLASSES}\n />\n </div>\n <div className=\"xl:col-span-1\">\n <label\n htmlFor=\"ggnomad-travel-destination\"\n className=\"block text-sm font-medium text-text-primary\"\n >\n {t('ggnomad.travel.search.destination', 'Destination')}\n </label>\n <input\n id=\"ggnomad-travel-destination\"\n type=\"text\"\n value={form.destination}\n onChange={(event) => setForm({ ...form, destination: event.target.value })}\n placeholder={t('ggnomad.travel.search.destinationPlaceholder', 'City or station')}\n className={INPUT_CLASSES}\n />\n </div>\n <div>\n <label\n htmlFor=\"ggnomad-travel-departure\"\n className=\"block text-sm font-medium text-text-primary\"\n >\n {t('ggnomad.travel.search.departure', 'Departure')}\n </label>\n <input\n id=\"ggnomad-travel-departure\"\n type=\"date\"\n value={form.departureDate}\n onChange={(event) => setForm({ ...form, departureDate: event.target.value })}\n className={INPUT_CLASSES}\n />\n </div>\n <div>\n <label\n htmlFor=\"ggnomad-travel-return\"\n className=\"block text-sm font-medium text-text-primary\"\n >\n {t('ggnomad.travel.search.return', 'Return (optional)')}\n </label>\n <input\n id=\"ggnomad-travel-return\"\n type=\"date\"\n min={form.departureDate || undefined}\n value={form.returnDate}\n onChange={(event) => setForm({ ...form, returnDate: event.target.value })}\n className={INPUT_CLASSES}\n />\n </div>\n <div>\n <label\n htmlFor=\"ggnomad-travel-passengers\"\n className=\"block text-sm font-medium text-text-primary\"\n >\n {t('ggnomad.travel.search.passengers', 'Travellers')}\n </label>\n <input\n id=\"ggnomad-travel-passengers\"\n type=\"number\"\n min={1}\n value={form.passengers}\n onChange={(event) =>\n setForm({ ...form, passengers: Math.max(1, Number(event.target.value) || 1) })\n }\n className={INPUT_CLASSES}\n />\n </div>\n <div>\n <label\n htmlFor=\"ggnomad-travel-mode\"\n className=\"block text-sm font-medium text-text-primary\"\n >\n {t('ggnomad.travel.search.mode', 'Mode')}\n </label>\n <select\n id=\"ggnomad-travel-mode\"\n value={form.transportMode}\n onChange={(event) => setForm({ ...form, transportMode: event.target.value })}\n className={INPUT_CLASSES}\n >\n <option value=\"\">{t('ggnomad.travel.search.anyMode', 'Any')}</option>\n {TRANSPORT_MODES.map((mode) => (\n <option key={mode} value={mode}>\n {humanizeEnum(mode)}\n </option>\n ))}\n </select>\n </div>\n </div>\n <div className=\"mt-4 flex flex-wrap items-center gap-3\">\n <Button onClick={handleSearch} disabled={offersQuery.loading}>\n <Search className=\"size-4\" aria-hidden=\"true\" />\n {offersQuery.loading\n ? t('ggnomad.travel.search.searching', 'Searching…')\n : t('ggnomad.travel.search.submit', 'Search routes')}\n </Button>\n <p className=\"text-xs text-text-secondary\">\n {t(\n 'ggnomad.travel.search.note',\n 'Returns live offers from the configured transport provider. Adding a route creates a trip in your inventory.'\n )}\n </p>\n </div>\n </SectionCard>\n\n {criteria !== null ? (\n <SectionCard title={t('ggnomad.travel.offers.title', 'Search results')}>\n {offersQuery.error ? (\n <p className=\"text-sm text-status-error-text\">\n {t('ggnomad.travel.offers.error', 'Could not search transport: {{message}}').replace(\n '{{message}}',\n offersQuery.error.message\n )}\n </p>\n ) : offersQuery.loading ? (\n <p className=\"text-sm text-text-secondary\">\n {t('ggnomad.travel.offers.loading', 'Searching transport inventory…')}\n </p>\n ) : offers.length === 0 ? (\n <p className=\"text-sm text-text-secondary\">\n {t(\n 'ggnomad.travel.offers.empty',\n 'No routes came back for that origin, destination and date.'\n )}\n </p>\n ) : (\n <ul className=\"space-y-3\">\n {offers.map((offer) => {\n const stopCount = jsonArrayLength(offer.stops);\n return (\n <li\n key={offer.offerId}\n className=\"flex flex-wrap items-center justify-between gap-4 rounded-lg border border-border-seam bg-bg-surface/40 p-4\"\n >\n <div className=\"min-w-0 space-y-1\">\n <p className=\"flex flex-wrap items-center gap-2 font-medium text-text-primary\">\n <span>{jsonLabel(offer.origin) ?? '—'}</span>\n <ArrowRight className=\"size-4 text-text-secondary\" aria-hidden=\"true\" />\n <span>{jsonLabel(offer.destination) ?? '—'}</span>\n </p>\n <div className=\"flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-text-secondary\">\n <span>{humanizeEnum(offer.transportMode)}</span>\n <span className=\"inline-flex items-center gap-1\">\n <Clock className=\"size-3.5\" aria-hidden=\"true\" />\n {formatDurationMinutes(offer.durationMinutes)}\n </span>\n <span className=\"inline-flex items-center gap-1\">\n <CalendarClock className=\"size-3.5\" aria-hidden=\"true\" />\n {formatDateTime(offer.departureAt) ?? offer.departureAt}\n </span>\n <span>\n {offer.carrierCode\n ? `${offer.carrier} · ${offer.carrierCode}`\n : offer.carrier}\n </span>\n {offer.class ? <span>{humanizeEnum(offer.class)}</span> : null}\n {stopCount !== null ? (\n <span>\n {stopCount === 0\n ? t('ggnomad.travel.offers.nonStop', 'Non-stop')\n : t('ggnomad.travel.offers.stops', '{{n}} stops').replace(\n '{{n}}',\n String(stopCount)\n )}\n </span>\n ) : null}\n <span className=\"inline-flex items-center gap-1\">\n <Users className=\"size-3.5\" aria-hidden=\"true\" />\n {offer.passengers}\n </span>\n {offer.ecoFriendly ? (\n <span className=\"inline-flex items-center gap-1 text-status-success-text\">\n <Leaf className=\"size-3.5\" aria-hidden=\"true\" />\n {t('ggnomad.travel.ecoFriendly', 'Lower-emission mode')}\n </span>\n ) : null}\n {offer.co2Emissions !== null && offer.co2Emissions !== undefined ? (\n <span className=\"tabular-nums\">\n {t('ggnomad.travel.co2', '≈{{kg}} kg CO₂e').replace(\n '{{kg}}',\n offer.co2Emissions.toLocaleString()\n )}\n </span>\n ) : null}\n </div>\n </div>\n <div className=\"flex items-center gap-3\">\n <span className=\"text-lg font-semibold tabular-nums text-text-primary\">\n {offer.currency} {Number(offer.price).toFixed(2)}\n </span>\n <Button\n variant=\"outline\"\n size=\"sm\"\n disabled={adding}\n onClick={() => void handleAddOffer(offer)}\n >\n <Plus className=\"size-4\" aria-hidden=\"true\" />\n {t('ggnomad.travel.offers.add', 'Add to trips')}\n </Button>\n </div>\n </li>\n );\n })}\n </ul>\n )}\n </SectionCard>\n ) : null}\n\n {trips.length === 0 ? (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title={t('ggnomad.travel.emptyTitle', 'No trips in inventory')}\n description={t(\n 'ggnomad.travel.emptyDescription',\n 'Routes and trips added by providers will appear here.'\n )}\n />\n ) : (\n <GlassCard treatment=\"glass\" glow className=\"border-border-seam p-5\">\n <h2 className=\"mb-4 text-lg font-medium tracking-tight text-text-primary\">\n {t('ggnomad.travel.availableTrips', 'Available trips')}\n </h2>\n <ul className=\"grid gap-3\">\n {trips.map((trip) => {\n const stopCount = jsonArrayLength(trip.stops);\n return (\n <li\n key={trip.id}\n className=\"flex flex-wrap items-start justify-between gap-4 rounded-lg border border-border-seam bg-bg-surface/40 p-4\"\n >\n <div className=\"min-w-0 space-y-1\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <p className=\"font-medium text-text-primary\">\n {routeLabel(trip.origin, trip.destination, trip.name)}\n </p>\n <StatusBadge\n status={trip.status}\n live={ACTIVE_TRIP_STATUSES.has(trip.status.toUpperCase())}\n />\n {trip.isRoundTrip ? (\n <span className=\"rounded-full bg-bg-sunken px-2.5 py-0.5 text-xs font-medium text-text-secondary\">\n {t('ggnomad.travel.roundTrip', 'Round trip')}\n </span>\n ) : null}\n </div>\n <div className=\"flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-text-secondary\">\n <span>{humanizeEnum(trip.transportMode)}</span>\n <span className=\"inline-flex items-center gap-1\">\n <CalendarClock className=\"size-3.5\" aria-hidden=\"true\" />\n {formatDateTime(trip.departureAt) ?? trip.departureAt}\n {' → '}\n {formatDateTime(trip.arrivalAt) ?? trip.arrivalAt}\n </span>\n {trip.carrier ? (\n <span>\n {trip.carrierCode\n ? `${trip.carrier} · ${trip.carrierCode}`\n : trip.carrier}\n </span>\n ) : null}\n {trip.class ? <span>{humanizeEnum(trip.class)}</span> : null}\n {stopCount !== null && stopCount > 0 ? (\n <span>\n {t('ggnomad.travel.offers.stops', '{{n}} stops').replace(\n '{{n}}',\n String(stopCount)\n )}\n </span>\n ) : null}\n <span className=\"inline-flex items-center gap-1\">\n <Users className=\"size-3.5\" aria-hidden=\"true\" />\n {trip.passengers}\n </span>\n {trip.ecoFriendly ? (\n <span className=\"inline-flex items-center gap-1 text-status-success-text\">\n <Leaf className=\"size-3.5\" aria-hidden=\"true\" />\n {t('ggnomad.travel.ecoFriendly', 'Lower-emission mode')}\n </span>\n ) : null}\n {trip.co2Emissions !== null && trip.co2Emissions !== undefined ? (\n <span className=\"tabular-nums\">\n {t('ggnomad.travel.co2', '≈{{kg}} kg CO₂e').replace(\n '{{kg}}',\n trip.co2Emissions.toLocaleString()\n )}\n </span>\n ) : null}\n </div>\n {trip.returnDepartureAt ? (\n <p className=\"text-sm text-text-secondary\">\n {t('ggnomad.travel.returnLeg', 'Return: {{depart}} → {{arrive}}')\n .replace(\n '{{depart}}',\n formatDateTime(trip.returnDepartureAt) ?? trip.returnDepartureAt\n )\n .replace('{{arrive}}', formatDateTime(trip.returnArrivalAt) ?? '—')}\n </p>\n ) : null}\n </div>\n <span className=\"text-lg font-semibold tabular-nums text-text-primary\">\n {trip.currency} {Number(trip.price).toFixed(2)}\n </span>\n </li>\n );\n })}\n </ul>\n </GlassCard>\n )}\n </PageShell>\n );\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TravelPolicyPage.js","names":[],"sources":["../../../src/ggnomad/pages/TravelPolicyPage.tsx"],"sourcesContent":["import { useMemo, useState } from 'react';\nimport { useMutation, useQuery } from '@apollo/client/react';\nimport type { TypedDocumentNode } from '@apollo/client';\nimport { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';\nimport { AlertCircle, Check, DollarSign, Plus, ShieldCheck, Trash2, Wallet, X } from 'lucide-react';\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogHeader,\n AlertDialogTitle,\n CTAOverflowMenu,\n IllustratedEmptyState,\n PagePurpose,\n} from '@burdenoff/fe-libs/ui';\n\nimport {\n APPROVE_GGNOMAD_BOOKING_APPROVAL_MUTATION,\n DELETE_GGNOMAD_TRAVEL_POLICY_MUTATION,\n GGNOMAD_BOOKING_APPROVALS_QUERY,\n GGNOMAD_SPEND_SUMMARY_QUERY,\n GGNOMAD_TRAVEL_POLICIES_QUERY,\n REJECT_GGNOMAD_BOOKING_APPROVAL_MUTATION,\n} from '../graphql';\nimport { PageShell } from './PageShell';\nimport {\n CreateTravelPolicyDialog,\n EntityCard,\n SectionCard,\n StatCard,\n StatusBadge,\n} from '../components';\nimport { notify } from '../utils/notify';\n\ntype BookingApproval = {\n id: string;\n bookingId: string;\n requestedBy: string;\n costCenter: string | null;\n reasons: string[];\n status: string;\n reviewedBy: string | null;\n reviewedAt: string | null;\n reviewNote: string | null;\n booking: {\n id: string;\n referenceCode: string;\n itemName: string;\n totalPrice: number;\n currency: string;\n status: string;\n paymentStatus: string;\n } | null;\n};\n\ntype TravelPolicy = {\n id: string;\n name: string;\n isActive: boolean;\n costCenter: string | null;\n currency: string;\n maxBookingAmount: number | null;\n maxMonthlyPerTraveler: number | null;\n blockedDestinations: string[];\n allowedTravelClasses: string[];\n requireApprovalAlways: boolean;\n};\n\ntype SpendSummary = {\n totalSpend: number;\n bookingCount: number;\n pendingApprovalCount: number;\n pendingApprovalAmount: number;\n byCostCenter: Record<string, { spend: number; count: number }>;\n};\n\n/**\n * Manager dashboard for corporate travel governance (BOFF-4365): consolidated\n * spend, the pending-approval queue for bookings that violated policy, and\n * the travel policies themselves (budget caps, blocked destinations, class\n * rules). Approve/reject and policy CRUD are gated server-side by the\n * `ggnomad:manage` RBAC action — a traveler or host-operator cannot act here.\n */\nexport function TravelPolicyPage() {\n const { t } = useI18n();\n const [createPolicyOpen, setCreatePolicyOpen] = useState(false);\n const [rejectTarget, setRejectTarget] = useState<BookingApproval | null>(null);\n const [rejectReason, setRejectReason] = useState('');\n const [deleteTarget, setDeleteTarget] = useState<TravelPolicy | null>(null);\n\n const { data: summaryData, refetch: refetchSummary } = useQuery(\n GGNOMAD_SPEND_SUMMARY_QUERY as TypedDocumentNode<\n { ggnomadSpendSummary: SpendSummary },\n { costCenter?: string | null }\n >\n );\n const summary = summaryData?.ggnomadSpendSummary;\n\n const {\n data: approvalsData,\n refetch: refetchApprovals,\n loading: approvalsLoading,\n } = useQuery(\n GGNOMAD_BOOKING_APPROVALS_QUERY as TypedDocumentNode<\n { ggnomadBookingApprovals: BookingApproval[] },\n { status?: string | null; limit?: number; offset?: number }\n >,\n { variables: { status: 'PENDING', limit: 20, offset: 0 } }\n );\n const pendingApprovals = useMemo(\n () => approvalsData?.ggnomadBookingApprovals ?? [],\n [approvalsData?.ggnomadBookingApprovals]\n );\n\n const { data: policiesData, refetch: refetchPolicies } = useQuery(\n GGNOMAD_TRAVEL_POLICIES_QUERY as TypedDocumentNode<\n { ggnomadTravelPolicies: TravelPolicy[] },\n { limit?: number; offset?: number }\n >,\n { variables: { limit: 50, offset: 0 } }\n );\n const policies = useMemo(\n () => policiesData?.ggnomadTravelPolicies ?? [],\n [policiesData?.ggnomadTravelPolicies]\n );\n\n const [approveApproval, { loading: approving }] = useMutation(\n APPROVE_GGNOMAD_BOOKING_APPROVAL_MUTATION as TypedDocumentNode<\n { approveGgnomadBookingApproval: { id: string; status: string } },\n { id: string; note?: string }\n >\n );\n const [rejectApproval, { loading: rejecting }] = useMutation(\n REJECT_GGNOMAD_BOOKING_APPROVAL_MUTATION as TypedDocumentNode<\n { rejectGgnomadBookingApproval: { id: string; status: string } },\n { id: string; reason: string }\n >\n );\n const [deleteTravelPolicy, { loading: deletingPolicy }] = useMutation(\n DELETE_GGNOMAD_TRAVEL_POLICY_MUTATION as TypedDocumentNode<\n { deleteGgnomadTravelPolicy: boolean },\n { id: string }\n >\n );\n\n const refetchAll = async () => {\n await Promise.all([refetchSummary(), refetchApprovals(), refetchPolicies()]);\n };\n\n const handleApprove = async (approval: BookingApproval) => {\n try {\n const result = await approveApproval({ variables: { id: approval.id } });\n if (result.error) {\n notify.error(\n t('ggnomad.travelPolicy.approve.errorTitle', 'Could not approve booking'),\n result.error.message\n );\n return;\n }\n notify.success(\n t('ggnomad.travelPolicy.approve.successTitle', 'Booking approved'),\n approval.booking?.referenceCode ?? approval.bookingId\n );\n await refetchAll();\n } catch (err) {\n notify.error(\n t('ggnomad.travelPolicy.approve.errorTitle', 'Could not approve booking'),\n err instanceof Error ? err.message : String(err)\n );\n }\n };\n\n const confirmReject = async () => {\n if (!rejectTarget) return;\n if (!rejectReason.trim()) {\n notify.warning(\n t('ggnomad.travelPolicy.reject.reasonRequiredTitle', 'A reason is required'),\n t(\n 'ggnomad.travelPolicy.reject.reasonRequiredBody',\n 'Let the traveler know why this was rejected.'\n )\n );\n return;\n }\n try {\n const result = await rejectApproval({\n variables: { id: rejectTarget.id, reason: rejectReason.trim() },\n });\n if (result.error) {\n notify.error(\n t('ggnomad.travelPolicy.reject.errorTitle', 'Could not reject booking'),\n result.error.message\n );\n return;\n }\n notify.success(\n t('ggnomad.travelPolicy.reject.successTitle', 'Booking rejected'),\n rejectTarget.booking?.referenceCode ?? rejectTarget.bookingId\n );\n await refetchAll();\n } catch (err) {\n notify.error(\n t('ggnomad.travelPolicy.reject.errorTitle', 'Could not reject booking'),\n err instanceof Error ? err.message : String(err)\n );\n } finally {\n setRejectTarget(null);\n setRejectReason('');\n }\n };\n\n const confirmDeletePolicy = async () => {\n if (!deleteTarget) return;\n try {\n const result = await deleteTravelPolicy({ variables: { id: deleteTarget.id } });\n if (result.error) {\n notify.error(\n t('ggnomad.travelPolicy.delete.errorTitle', 'Could not remove policy'),\n result.error.message\n );\n return;\n }\n notify.success(\n t('ggnomad.travelPolicy.delete.successTitle', 'Policy removed'),\n deleteTarget.name\n );\n await refetchPolicies();\n } catch (err) {\n notify.error(\n t('ggnomad.travelPolicy.delete.errorTitle', 'Could not remove policy'),\n err instanceof Error ? err.message : String(err)\n );\n } finally {\n setDeleteTarget(null);\n }\n };\n\n return (\n <PageShell\n title={t('ggnomad.travelPolicy.title', 'Travel Policy')}\n titleAccent={t('ggnomad.travelPolicy.title.accent', 'Policy')}\n subtitle={t(\n 'ggnomad.travelPolicy.subtitle',\n 'Corporate travel guardrails: budgets, blocked destinations, class rules, and booking approvals'\n )}\n actions={\n <CTAOverflowMenu\n primary={{\n label: t('ggnomad.travelPolicy.actions.newPolicy', 'New policy'),\n onSelect: () => setCreatePolicyOpen(true),\n icon: <Plus />,\n }}\n actions={[]}\n />\n }\n >\n <PagePurpose icon={<ShieldCheck className=\"size-4\" />}>\n {t(\n 'ggnomad.travelPolicy.purpose',\n 'Managers define budget caps, blocked destinations and class rules here. A booking that violates the applicable policy is held for your approval instead of confirming automatically — travelers and hosts cannot approve their own over-budget bookings.'\n )}\n </PagePurpose>\n\n <section className=\"grid gap-4 sm:grid-cols-2 xl:grid-cols-4\">\n <StatCard\n hero\n label={t('ggnomad.travelPolicy.stats.totalSpend', 'Total spend')}\n value={`$${Number(summary?.totalSpend ?? 0).toLocaleString()}`}\n icon={<DollarSign />}\n />\n <StatCard\n label={t('ggnomad.travelPolicy.stats.bookingCount', 'Bookings')}\n value={Number(summary?.bookingCount ?? 0).toLocaleString()}\n icon={<Wallet />}\n />\n <StatCard\n label={t('ggnomad.travelPolicy.stats.pendingCount', 'Pending approvals')}\n value={Number(summary?.pendingApprovalCount ?? 0).toLocaleString()}\n icon={<AlertCircle />}\n />\n <StatCard\n label={t('ggnomad.travelPolicy.stats.pendingAmount', 'Pending amount')}\n value={`$${Number(summary?.pendingApprovalAmount ?? 0).toLocaleString()}`}\n icon={<ShieldCheck />}\n />\n </section>\n\n <SectionCard title={t('ggnomad.travelPolicy.approvalsTitle', 'Pending approvals')}>\n {!approvalsLoading && pendingApprovals.length === 0 ? (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title={t('ggnomad.travelPolicy.approvalsEmptyTitle', 'Nothing waiting on you')}\n description={t(\n 'ggnomad.travelPolicy.approvalsEmptyDescription',\n 'Bookings that exceed a travel policy will show up here for your decision.'\n )}\n />\n ) : (\n <div className=\"grid gap-3 sm:grid-cols-2\">\n {pendingApprovals.map((approval) => (\n <EntityCard\n key={approval.id}\n title={approval.booking?.referenceCode ?? approval.bookingId}\n meta={approval.reasons.join(' · ') || approval.booking?.itemName}\n price={\n approval.booking\n ? `${approval.booking.currency} ${Number(approval.booking.totalPrice).toFixed(2)}`\n : undefined\n }\n badge={<StatusBadge status={approval.status} live />}\n footer={\n <CTAOverflowMenu\n align=\"end\"\n actions={[\n {\n label: t('ggnomad.travelPolicy.actions.approve', 'Approve'),\n onSelect: () => void handleApprove(approval),\n disabled: approving,\n icon: <Check />,\n },\n {\n label: t('ggnomad.travelPolicy.actions.reject', 'Reject'),\n onSelect: () => {\n setRejectTarget(approval);\n setRejectReason('');\n },\n intent: 'destructive',\n icon: <X />,\n },\n ]}\n />\n }\n />\n ))}\n </div>\n )}\n </SectionCard>\n\n <SectionCard title={t('ggnomad.travelPolicy.policiesTitle', 'Travel policies')}>\n {policies.length === 0 ? (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title={t('ggnomad.travelPolicy.policiesEmptyTitle', 'No travel policy defined yet')}\n description={t(\n 'ggnomad.travelPolicy.policiesEmptyDescription',\n 'Create one to enforce budget caps, blocked destinations, and class rules on future bookings.'\n )}\n />\n ) : (\n <div className=\"grid gap-3 sm:grid-cols-2\">\n {policies.map((policy) => (\n <EntityCard\n key={policy.id}\n title={policy.name}\n meta={\n policy.costCenter\n ? t('ggnomad.travelPolicy.card.costCenter', 'Cost center: {{cc}}').replace(\n '{{cc}}',\n policy.costCenter\n )\n : t('ggnomad.travelPolicy.card.workspaceWide', 'Workspace-wide default')\n }\n price={\n policy.maxBookingAmount != null\n ? `${policy.currency} ${Number(policy.maxBookingAmount).toFixed(2)} / booking`\n : t('ggnomad.travelPolicy.card.noCap', 'No per-booking cap')\n }\n badge={\n <StatusBadge\n status={policy.isActive ? 'ACTIVE' : 'PAUSED'}\n live={policy.isActive}\n />\n }\n footer={\n <CTAOverflowMenu\n align=\"end\"\n actions={[\n {\n label: t('ggnomad.travelPolicy.actions.deletePolicy', 'Remove policy'),\n onSelect: () => setDeleteTarget(policy),\n intent: 'destructive',\n icon: <Trash2 />,\n },\n ]}\n />\n }\n />\n ))}\n </div>\n )}\n </SectionCard>\n\n <CreateTravelPolicyDialog\n open={createPolicyOpen}\n onOpenChange={setCreatePolicyOpen}\n onCreated={() => void refetchPolicies()}\n />\n\n <AlertDialog\n open={rejectTarget !== null}\n onOpenChange={(open) => {\n if (!open) {\n setRejectTarget(null);\n setRejectReason('');\n }\n }}\n >\n <AlertDialogContent>\n <AlertDialogHeader>\n <AlertDialogTitle>\n {t('ggnomad.travelPolicy.rejectDialog.title', 'Reject this booking?')}\n </AlertDialogTitle>\n <AlertDialogDescription>\n {t(\n 'ggnomad.travelPolicy.rejectDialog.body',\n 'This cancels {{ref}} and revokes any issued ticket. The traveler sees your reason.'\n ).replace(\n '{{ref}}',\n rejectTarget?.booking?.referenceCode ?? rejectTarget?.bookingId ?? ''\n )}\n </AlertDialogDescription>\n </AlertDialogHeader>\n <div>\n <label htmlFor=\"reject-reason\" className=\"block text-sm font-medium text-text-primary\">\n {t('ggnomad.travelPolicy.rejectDialog.reasonLabel', 'Reason')}\n </label>\n <textarea\n id=\"reject-reason\"\n value={rejectReason}\n onChange={(e) => setRejectReason(e.target.value)}\n disabled={rejecting}\n rows={3}\n className=\"mt-1 w-full rounded-md border border-border-seam bg-bg-canvas px-3 py-2 text-sm text-text-primary placeholder-text-secondary focus:border-action-primary-bg focus:outline-none focus:ring-1 focus:ring-action-primary-bg disabled:opacity-50\"\n placeholder={t(\n 'ggnomad.travelPolicy.rejectDialog.reasonPlaceholder',\n 'e.g. Over quarterly budget'\n )}\n />\n </div>\n <AlertDialogFooter>\n <AlertDialogCancel disabled={rejecting}>\n {t('ggnomad.travelPolicy.rejectDialog.cancel', 'Keep pending')}\n </AlertDialogCancel>\n <AlertDialogAction\n className=\"bg-action-danger-bg text-action-danger-text hover:bg-action-danger-bgHover\"\n disabled={rejecting}\n onClick={(event) => {\n event.preventDefault();\n void confirmReject();\n }}\n >\n {rejecting\n ? t('ggnomad.travelPolicy.rejectDialog.rejecting', 'Rejecting…')\n : t('ggnomad.travelPolicy.rejectDialog.confirm', 'Reject booking')}\n </AlertDialogAction>\n </AlertDialogFooter>\n </AlertDialogContent>\n </AlertDialog>\n\n <AlertDialog\n open={deleteTarget !== null}\n onOpenChange={(open) => {\n if (!open) setDeleteTarget(null);\n }}\n >\n <AlertDialogContent>\n <AlertDialogHeader>\n <AlertDialogTitle>\n {t('ggnomad.travelPolicy.deleteDialog.title', 'Remove this policy?')}\n </AlertDialogTitle>\n <AlertDialogDescription>\n {t(\n 'ggnomad.travelPolicy.deleteDialog.body',\n 'Bookings already approved or rejected under {{name}} are unaffected. New bookings stop being checked against it.'\n ).replace('{{name}}', deleteTarget?.name ?? '')}\n </AlertDialogDescription>\n </AlertDialogHeader>\n <AlertDialogFooter>\n <AlertDialogCancel disabled={deletingPolicy}>\n {t('ggnomad.travelPolicy.deleteDialog.cancel', 'Keep policy')}\n </AlertDialogCancel>\n <AlertDialogAction\n className=\"bg-action-danger-bg text-action-danger-text hover:bg-action-danger-bgHover\"\n disabled={deletingPolicy}\n onClick={(event) => {\n event.preventDefault();\n void confirmDeletePolicy();\n }}\n >\n {deletingPolicy\n ? t('ggnomad.travelPolicy.deleteDialog.removing', 'Removing…')\n : t('ggnomad.travelPolicy.deleteDialog.confirm', 'Remove policy')}\n </AlertDialogAction>\n </AlertDialogFooter>\n </AlertDialogContent>\n </AlertDialog>\n </PageShell>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"TravelPolicyPage.js","names":[],"sources":["../../../src/ggnomad/pages/TravelPolicyPage.tsx"],"sourcesContent":["import { useMemo, useState } from 'react';\nimport { useMutation, useQuery } from '@apollo/client/react';\nimport type { TypedDocumentNode } from '@apollo/client';\nimport { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';\nimport { AlertCircle, Check, DollarSign, Plus, ShieldCheck, Trash2, Wallet, X } from 'lucide-react';\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogHeader,\n AlertDialogTitle,\n CTAOverflowMenu,\n IllustratedEmptyState,\n PagePurpose,\n} from '@burdenoff/fe-libs/ui';\n\nimport {\n APPROVE_GGNOMAD_BOOKING_APPROVAL_MUTATION,\n DELETE_GGNOMAD_TRAVEL_POLICY_MUTATION,\n GGNOMAD_BOOKING_APPROVALS_QUERY,\n GGNOMAD_SPEND_SUMMARY_QUERY,\n GGNOMAD_TRAVEL_POLICIES_QUERY,\n REJECT_GGNOMAD_BOOKING_APPROVAL_MUTATION,\n} from '../graphql';\nimport { PageShell } from './PageShell';\nimport {\n CreateTravelPolicyDialog,\n EntityCard,\n SectionCard,\n StatCard,\n StatusBadge,\n} from '../components';\nimport { notify } from '../utils/notify';\n\ntype BookingApproval = {\n id: string;\n bookingId: string;\n requestedBy: string;\n costCenter: string | null;\n reasons: string[];\n status: string;\n reviewedBy: string | null;\n reviewedAt: string | null;\n reviewNote: string | null;\n booking: {\n id: string;\n referenceCode: string;\n itemName: string;\n totalPrice: number;\n currency: string;\n status: string;\n paymentStatus: string;\n } | null;\n};\n\ntype TravelPolicy = {\n id: string;\n name: string;\n isActive: boolean;\n costCenter: string | null;\n currency: string;\n maxBookingAmount: number | null;\n maxMonthlyPerTraveler: number | null;\n blockedDestinations: string[];\n allowedTravelClasses: string[];\n requireApprovalAlways: boolean;\n};\n\ntype SpendSummary = {\n totalSpend: number;\n bookingCount: number;\n pendingApprovalCount: number;\n pendingApprovalAmount: number;\n byCostCenter: Record<string, { spend: number; count: number }>;\n};\n\n/**\n * Manager dashboard for corporate travel governance (BOFF-4365): consolidated\n * spend, the pending-approval queue for bookings that violated policy, and\n * the travel policies themselves (budget caps, blocked destinations, class\n * rules). Approve/reject and policy CRUD are gated server-side by the\n * `ggnomad:manage` RBAC action — a traveler or host-operator cannot act here.\n */\nexport function TravelPolicyPage() {\n const { t } = useI18n();\n const [createPolicyOpen, setCreatePolicyOpen] = useState(false);\n const [rejectTarget, setRejectTarget] = useState<BookingApproval | null>(null);\n const [rejectReason, setRejectReason] = useState('');\n const [deleteTarget, setDeleteTarget] = useState<TravelPolicy | null>(null);\n\n const { data: summaryData, refetch: refetchSummary } = useQuery(\n GGNOMAD_SPEND_SUMMARY_QUERY as TypedDocumentNode<\n { ggnomadSpendSummary: SpendSummary },\n { costCenter?: string | null }\n >\n );\n const summary = summaryData?.ggnomadSpendSummary;\n\n const {\n data: approvalsData,\n refetch: refetchApprovals,\n loading: approvalsLoading,\n } = useQuery(\n GGNOMAD_BOOKING_APPROVALS_QUERY as TypedDocumentNode<\n { ggnomadBookingApprovals: BookingApproval[] },\n { status?: string | null; limit?: number; offset?: number }\n >,\n { variables: { status: 'PENDING', limit: 20, offset: 0 } }\n );\n const pendingApprovals = useMemo(\n () => approvalsData?.ggnomadBookingApprovals ?? [],\n [approvalsData?.ggnomadBookingApprovals]\n );\n\n const { data: policiesData, refetch: refetchPolicies } = useQuery(\n GGNOMAD_TRAVEL_POLICIES_QUERY as TypedDocumentNode<\n { ggnomadTravelPolicies: TravelPolicy[] },\n { limit?: number; offset?: number }\n >,\n { variables: { limit: 50, offset: 0 } }\n );\n const policies = useMemo(\n () => policiesData?.ggnomadTravelPolicies ?? [],\n [policiesData?.ggnomadTravelPolicies]\n );\n\n const [approveApproval, { loading: approving }] = useMutation(\n APPROVE_GGNOMAD_BOOKING_APPROVAL_MUTATION as TypedDocumentNode<\n { approveGgnomadBookingApproval: { id: string; status: string } },\n { id: string; note?: string }\n >\n );\n const [rejectApproval, { loading: rejecting }] = useMutation(\n REJECT_GGNOMAD_BOOKING_APPROVAL_MUTATION as TypedDocumentNode<\n { rejectGgnomadBookingApproval: { id: string; status: string } },\n { id: string; reason: string }\n >\n );\n const [deleteTravelPolicy, { loading: deletingPolicy }] = useMutation(\n DELETE_GGNOMAD_TRAVEL_POLICY_MUTATION as TypedDocumentNode<\n { deleteGgnomadTravelPolicy: boolean },\n { id: string }\n >\n );\n\n const refetchAll = async () => {\n await Promise.all([refetchSummary(), refetchApprovals(), refetchPolicies()]);\n };\n\n const handleApprove = async (approval: BookingApproval) => {\n try {\n const result = await approveApproval({ variables: { id: approval.id } });\n if (result.error) {\n notify.error(\n t('ggnomad.travelPolicy.approve.errorTitle', 'Could not approve booking'),\n result.error.message\n );\n return;\n }\n notify.success(\n t('ggnomad.travelPolicy.approve.successTitle', 'Booking approved'),\n approval.booking?.referenceCode ?? approval.bookingId\n );\n await refetchAll();\n } catch (err) {\n notify.error(\n t('ggnomad.travelPolicy.approve.errorTitle', 'Could not approve booking'),\n err instanceof Error ? err.message : String(err)\n );\n }\n };\n\n const confirmReject = async () => {\n if (!rejectTarget) return;\n if (!rejectReason.trim()) {\n notify.warning(\n t('ggnomad.travelPolicy.reject.reasonRequiredTitle', 'A reason is required'),\n t(\n 'ggnomad.travelPolicy.reject.reasonRequiredBody',\n 'Let the traveler know why this was rejected.'\n )\n );\n return;\n }\n try {\n const result = await rejectApproval({\n variables: { id: rejectTarget.id, reason: rejectReason.trim() },\n });\n if (result.error) {\n notify.error(\n t('ggnomad.travelPolicy.reject.errorTitle', 'Could not reject booking'),\n result.error.message\n );\n return;\n }\n notify.success(\n t('ggnomad.travelPolicy.reject.successTitle', 'Booking rejected'),\n rejectTarget.booking?.referenceCode ?? rejectTarget.bookingId\n );\n await refetchAll();\n } catch (err) {\n notify.error(\n t('ggnomad.travelPolicy.reject.errorTitle', 'Could not reject booking'),\n err instanceof Error ? err.message : String(err)\n );\n } finally {\n setRejectTarget(null);\n setRejectReason('');\n }\n };\n\n const confirmDeletePolicy = async () => {\n if (!deleteTarget) return;\n try {\n const result = await deleteTravelPolicy({ variables: { id: deleteTarget.id } });\n if (result.error) {\n notify.error(\n t('ggnomad.travelPolicy.delete.errorTitle', 'Could not remove policy'),\n result.error.message\n );\n return;\n }\n notify.success(\n t('ggnomad.travelPolicy.delete.successTitle', 'Policy removed'),\n deleteTarget.name\n );\n await refetchPolicies();\n } catch (err) {\n notify.error(\n t('ggnomad.travelPolicy.delete.errorTitle', 'Could not remove policy'),\n err instanceof Error ? err.message : String(err)\n );\n } finally {\n setDeleteTarget(null);\n }\n };\n\n return (\n <PageShell\n title={t('ggnomad.travelPolicy.title', 'Travel Policy')}\n titleAccent={t('ggnomad.travelPolicy.title.accent', 'Policy')}\n subtitle={t(\n 'ggnomad.travelPolicy.subtitle',\n 'Corporate travel guardrails: budgets, blocked destinations, class rules, and booking approvals'\n )}\n actions={\n <CTAOverflowMenu\n primary={{\n label: t('ggnomad.travelPolicy.actions.newPolicy', 'New policy'),\n onSelect: () => setCreatePolicyOpen(true),\n icon: <Plus />,\n }}\n actions={[]}\n />\n }\n >\n <PagePurpose icon={<ShieldCheck className=\"size-4\" />}>\n {t(\n 'ggnomad.travelPolicy.purpose',\n 'Managers define budget caps, blocked destinations and class rules here. A booking that violates the applicable policy is held for your approval instead of confirming automatically — travelers and hosts cannot approve their own over-budget bookings.'\n )}\n </PagePurpose>\n\n <section className=\"grid gap-4 sm:grid-cols-2 xl:grid-cols-4\">\n <StatCard\n hero\n label={t('ggnomad.travelPolicy.stats.totalSpend', 'Total spend')}\n value={`$${Number(summary?.totalSpend ?? 0).toLocaleString()}`}\n icon={<DollarSign />}\n />\n <StatCard\n label={t('ggnomad.travelPolicy.stats.bookingCount', 'Bookings')}\n value={Number(summary?.bookingCount ?? 0).toLocaleString()}\n icon={<Wallet />}\n />\n <StatCard\n label={t('ggnomad.travelPolicy.stats.pendingCount', 'Pending approvals')}\n value={Number(summary?.pendingApprovalCount ?? 0).toLocaleString()}\n icon={<AlertCircle />}\n />\n <StatCard\n label={t('ggnomad.travelPolicy.stats.pendingAmount', 'Pending amount')}\n value={`$${Number(summary?.pendingApprovalAmount ?? 0).toLocaleString()}`}\n icon={<ShieldCheck />}\n />\n </section>\n\n <SectionCard title={t('ggnomad.travelPolicy.approvalsTitle', 'Pending approvals')}>\n {!approvalsLoading && pendingApprovals.length === 0 ? (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title={t('ggnomad.travelPolicy.approvalsEmptyTitle', 'Nothing waiting on you')}\n description={t(\n 'ggnomad.travelPolicy.approvalsEmptyDescription',\n 'Bookings that exceed a travel policy will show up here for your decision.'\n )}\n />\n ) : (\n <div className=\"grid gap-3 sm:grid-cols-2\">\n {pendingApprovals.map((approval) => (\n <EntityCard\n key={approval.id}\n title={approval.booking?.referenceCode ?? approval.bookingId}\n meta={approval.reasons.join(' · ') || approval.booking?.itemName}\n price={\n approval.booking\n ? `${approval.booking.currency} ${Number(approval.booking.totalPrice).toFixed(2)}`\n : undefined\n }\n badge={<StatusBadge status={approval.status} live />}\n footer={\n <CTAOverflowMenu\n align=\"end\"\n actions={[\n {\n label: t('ggnomad.travelPolicy.actions.approve', 'Approve'),\n onSelect: () => void handleApprove(approval),\n disabled: approving,\n icon: <Check />,\n },\n {\n label: t('ggnomad.travelPolicy.actions.reject', 'Reject'),\n onSelect: () => {\n setRejectTarget(approval);\n setRejectReason('');\n },\n intent: 'destructive',\n icon: <X />,\n },\n ]}\n />\n }\n />\n ))}\n </div>\n )}\n </SectionCard>\n\n <SectionCard title={t('ggnomad.travelPolicy.policiesTitle', 'Travel policies')}>\n {policies.length === 0 ? (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title={t('ggnomad.travelPolicy.policiesEmptyTitle', 'No travel policy defined yet')}\n description={t(\n 'ggnomad.travelPolicy.policiesEmptyDescription',\n 'Create one to enforce budget caps, blocked destinations, and class rules on future bookings.'\n )}\n />\n ) : (\n <div className=\"grid gap-3 sm:grid-cols-2\">\n {policies.map((policy) => (\n <EntityCard\n key={policy.id}\n title={policy.name}\n meta={\n policy.costCenter\n ? t('ggnomad.travelPolicy.card.costCenter', 'Cost center: {{cc}}').replace(\n '{{cc}}',\n policy.costCenter\n )\n : t('ggnomad.travelPolicy.card.workspaceWide', 'Workspace-wide default')\n }\n price={\n policy.maxBookingAmount != null\n ? `${policy.currency} ${Number(policy.maxBookingAmount).toFixed(2)} / booking`\n : t('ggnomad.travelPolicy.card.noCap', 'No per-booking cap')\n }\n badge={\n <StatusBadge\n status={policy.isActive ? 'ACTIVE' : 'PAUSED'}\n live={policy.isActive}\n />\n }\n footer={\n <CTAOverflowMenu\n align=\"end\"\n actions={[\n {\n label: t('ggnomad.travelPolicy.actions.deletePolicy', 'Remove policy'),\n onSelect: () => setDeleteTarget(policy),\n intent: 'destructive',\n icon: <Trash2 />,\n },\n ]}\n />\n }\n />\n ))}\n </div>\n )}\n </SectionCard>\n\n <CreateTravelPolicyDialog\n open={createPolicyOpen}\n onOpenChange={setCreatePolicyOpen}\n onCreated={() => void refetchPolicies()}\n />\n\n <AlertDialog\n open={rejectTarget !== null}\n onOpenChange={(open) => {\n if (!open) {\n setRejectTarget(null);\n setRejectReason('');\n }\n }}\n >\n <AlertDialogContent>\n <AlertDialogHeader>\n <AlertDialogTitle>\n {t('ggnomad.travelPolicy.rejectDialog.title', 'Reject this booking?')}\n </AlertDialogTitle>\n <AlertDialogDescription>\n {t(\n 'ggnomad.travelPolicy.rejectDialog.body',\n 'This cancels {{ref}} and revokes any issued ticket. The traveler sees your reason.'\n ).replace(\n '{{ref}}',\n rejectTarget?.booking?.referenceCode ?? rejectTarget?.bookingId ?? ''\n )}\n </AlertDialogDescription>\n </AlertDialogHeader>\n <div>\n <label htmlFor=\"reject-reason\" className=\"block text-sm font-medium text-text-primary\">\n {t('ggnomad.travelPolicy.rejectDialog.reasonLabel', 'Reason')}\n </label>\n <textarea\n id=\"reject-reason\"\n value={rejectReason}\n onChange={(e) => setRejectReason(e.target.value)}\n disabled={rejecting}\n rows={3}\n className=\"mt-1 w-full rounded-md border border-border-seam bg-bg-canvas px-3 py-2 text-sm text-text-primary placeholder-text-secondary focus:border-action-primary-bg focus:outline-none focus:ring-1 focus:ring-action-primary-bg disabled:opacity-50\"\n placeholder={t(\n 'ggnomad.travelPolicy.rejectDialog.reasonPlaceholder',\n 'e.g. Over quarterly budget'\n )}\n />\n </div>\n <AlertDialogFooter>\n <AlertDialogCancel disabled={rejecting}>\n {t('ggnomad.travelPolicy.rejectDialog.cancel', 'Keep pending')}\n </AlertDialogCancel>\n <AlertDialogAction\n className=\"bg-action-danger-bg text-action-danger-text hover:bg-action-danger-bgHover\"\n disabled={rejecting}\n onClick={(event) => {\n event.preventDefault();\n void confirmReject();\n }}\n >\n {rejecting\n ? t('ggnomad.travelPolicy.rejectDialog.rejecting', 'Rejecting…')\n : t('ggnomad.travelPolicy.rejectDialog.confirm', 'Reject booking')}\n </AlertDialogAction>\n </AlertDialogFooter>\n </AlertDialogContent>\n </AlertDialog>\n\n <AlertDialog\n open={deleteTarget !== null}\n onOpenChange={(open) => {\n if (!open) setDeleteTarget(null);\n }}\n >\n <AlertDialogContent>\n <AlertDialogHeader>\n <AlertDialogTitle>\n {t('ggnomad.travelPolicy.deleteDialog.title', 'Remove this policy?')}\n </AlertDialogTitle>\n <AlertDialogDescription>\n {t(\n 'ggnomad.travelPolicy.deleteDialog.body',\n 'Bookings already approved or rejected under {{name}} are unaffected. New bookings stop being checked against it.'\n ).replace('{{name}}', deleteTarget?.name ?? '')}\n </AlertDialogDescription>\n </AlertDialogHeader>\n <AlertDialogFooter>\n <AlertDialogCancel disabled={deletingPolicy}>\n {t('ggnomad.travelPolicy.deleteDialog.cancel', 'Keep policy')}\n </AlertDialogCancel>\n <AlertDialogAction\n className=\"bg-action-danger-bg text-action-danger-text hover:bg-action-danger-bgHover\"\n disabled={deletingPolicy}\n onClick={(event) => {\n event.preventDefault();\n void confirmDeletePolicy();\n }}\n >\n {deletingPolicy\n ? t('ggnomad.travelPolicy.deleteDialog.removing', 'Removing…')\n : t('ggnomad.travelPolicy.deleteDialog.confirm', 'Remove policy')}\n </AlertDialogAction>\n </AlertDialogFooter>\n </AlertDialogContent>\n </AlertDialog>\n </PageShell>\n );\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TripPlanDetailPage.d.ts","sourceRoot":"","sources":["../../../src/ggnomad/pages/TripPlanDetailPage.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"TripPlanDetailPage.d.ts","sourceRoot":"","sources":["../../../src/ggnomad/pages/TripPlanDetailPage.tsx"],"names":[],"mappings":"AA+DA,wBAAgB,kBAAkB,gCA83BjC"}
|