diff --git a/client/src/api/tourPackingLink.ts b/client/src/api/tourPackingLink.ts new file mode 100644 index 000000000..53766911f --- /dev/null +++ b/client/src/api/tourPackingLink.ts @@ -0,0 +1,20 @@ +import type { TourDetailResponse, TourPackingLinkRequest, TourPackingLinkResponse } from '@trek/shared'; +import apiClient from './client'; + +/** + * Where a tour's packing link lives; a write queued offline replays to the + * same path, `{id}` standing for a tour that has not synced yet. + */ +export function tourPackingLinkPath(tripId: number | string, placeId: number | string): string { + return `/trips/${tripId}/tours/${placeId}/packing-link`; +} + +/** P2D-XD-01: the packing list, or the category of it, a tour points at. */ +export const tourPackingLinkApi = { + read: (tripId: number | string, placeId: number, signal?: AbortSignal): Promise => + apiClient.get(tourPackingLinkPath(tripId, placeId), { signal }).then((r) => r.data), + link: (tripId: number | string, placeId: number, body: TourPackingLinkRequest): Promise => + apiClient.put(tourPackingLinkPath(tripId, placeId), body).then((r) => r.data), + unlink: (tripId: number | string, placeId: number): Promise => + apiClient.delete(tourPackingLinkPath(tripId, placeId)).then((r) => r.data), +}; diff --git a/client/src/components/Packing/packingListPanel.constants.ts b/client/src/components/Packing/packingListPanel.constants.ts index 796606ee5..690bda73a 100644 --- a/client/src/components/Packing/packingListPanel.constants.ts +++ b/client/src/components/Packing/packingListPanel.constants.ts @@ -15,8 +15,9 @@ export const KAT_COLORS = [ export const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b', '#3b82f6', '#84cc16', '#d946ef', '#14b8a6', '#f43f5e', '#a855f7', '#eab308', '#64748b'] // A category's first item is seeded with this sentinel because the server -// rejects empty names. Treat it as a placeholder in the UI. -export const PACKING_PLACEHOLDER_NAME = '...' +// rejects empty names. Treat it as a placeholder in the UI. Shared, because a +// tour's packing link (P2D-XD-01) leaves it out the same way. +export { PACKING_PLACEHOLDER_NAME } from '@trek/shared' /** What the import's file picker offers: CSV rows, plain text, or a Markdown list (#875). */ export const PACKING_IMPORT_ACCEPT = '.csv,.txt,.md,.markdown,text/markdown' diff --git a/client/src/components/Tours/TourDetailDialog.tsx b/client/src/components/Tours/TourDetailDialog.tsx index ccb3ce285..2e3d9c3e3 100644 --- a/client/src/components/Tours/TourDetailDialog.tsx +++ b/client/src/components/Tours/TourDetailDialog.tsx @@ -53,6 +53,7 @@ import TourActivitySection from './activity/TourActivitySection'; import type { PlanValues } from './activity/tourActivityCompare'; import { TourSaveToCollectionButton } from './collection/TourSaveToCollectionButton'; import { PaddleCheckSection } from './paddle/PaddleCheckSection'; +import { TourPackingSection } from './packing/TourPackingSection'; import { PaddleWindSection } from './paddle/PaddleWindCard'; import { TourProvenanceBlock } from './provenance/TourProvenanceSection'; import { RunHeadPills } from './run/RunHeadPills'; @@ -94,6 +95,8 @@ interface TourDetailDialogProps extends TourPermissionProps { onSaveAsNew?: (tour: TourListItem) => Promise | void; /** ACT-01: a recorded track was assigned, taken off or imported; the lists take in these tours. */ onToursChanged?: (placeIds: number[]) => Promise | void; + /** P2D-XD-01: opens the trip's packing list the tour points at. */ + onOpenPacking?: () => void; onAssignToDay?: (placeId: number, dayId?: number) => void; onRemoveAssignment?: (dayId: number, assignmentId: number) => void; onDelete?: () => void; @@ -502,6 +505,7 @@ export default function TourDetailDialog({ onSourceReimported, onSaveAsNew, onToursChanged, + onOpenPacking, onAssignToDay, onRemoveAssignment, onDelete, @@ -756,6 +760,13 @@ export default function TourDetailDialog({ onToursChanged={onToursChanged} /> )} + {description && ( diff --git a/client/src/components/Tours/packing/TourPackingLinkDialog.tsx b/client/src/components/Tours/packing/TourPackingLinkDialog.tsx new file mode 100644 index 000000000..8f42aaaa5 --- /dev/null +++ b/client/src/components/Tours/packing/TourPackingLinkDialog.tsx @@ -0,0 +1,151 @@ +import { Backpack, Link2, ListChecks, Loader2, Unlink } from 'lucide-react'; +import { useId, type ReactNode } from 'react'; +import { useTranslation } from '../../../i18n'; +import { + DialogButton, + DialogFooter, + DialogHeader, + DialogSection, + DialogShell, + DialogTile, + FooterSpacer, + fs, + NEUTRAL_TINT, +} from '../../shared/DialogShell'; +import { Tooltip } from '../../shared/Tooltip'; +import { ChoiceList, ChoiceRow } from '../collection/choiceParts'; +import { CategoryDot, CountBadge } from './packingLinkParts'; +import type { TourPackingLinkController } from './useTourPackingLink'; + +/** One thing to point at: its mark, its name and how many items it holds. */ +function Choice({ + selected, + onChoose, + testId, + mark, + name, + count, +}: { + selected: boolean; + onChoose: () => void; + testId: string; + mark: ReactNode; + name: string; + count: number; +}) { + return ( + + {mark} + + {name} + + + + ); +} + +/** + * P2D-XD-01: the dialog that points a tour at its trip's packing list, as a + * whole or at one of its categories, each with its colour and how many items + * it holds. It writes only through the button in its foot; letting go of a + * link is offered there too, and neither touches the list itself. + */ +export function TourPackingLinkDialog({ packing, tourName }: { packing: TourPackingLinkController; tourName: string }) { + const { t } = useTranslation(); + const labelId = useId(); + if (!packing.open) return null; + return ( + + + + } + tint={NEUTRAL_TINT} + labelId={labelId} + onClose={packing.close} + eyebrow={t('tours.packing.link')} + title={tourName} + sub={t('tours.packing.dialogSub')} + subWraps + /> + } + footer={ + + {packing.link && ( + + + void packing.unlink()} + disabled={packing.saving} + data-testid="tour-packing-unlink" + icon={} + > + {t('tours.packing.unlink')} + + + + )} + + + {t('common.cancel')} + + void packing.confirm()} + icon={ + packing.saving ? ( + + ) : ( + + ) + } + > + {t('tours.packing.confirm')} + + + } + > + + + packing.choose(null)} + testId="tour-packing-choice-whole" + mark={ + + + + } + name={t('tours.packing.whole')} + count={packing.wholeCount} + /> + {packing.categories.map((category) => ( + packing.choose(category.name)} + testId={`tour-packing-choice-${category.name}`} + mark={ + + + + } + name={category.name} + count={category.count} + /> + ))} + + + + ); +} diff --git a/client/src/components/Tours/packing/TourPackingSection.tsx b/client/src/components/Tours/packing/TourPackingSection.tsx new file mode 100644 index 000000000..51c5b2142 --- /dev/null +++ b/client/src/components/Tours/packing/TourPackingSection.tsx @@ -0,0 +1,260 @@ +import type { TourListItem, TourPackingLink } from '@trek/shared'; +import { AlertTriangle, ArrowUpRight, Backpack, Check, Link2, PackageOpen, PowerOff, Unlink } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { useTranslation } from '../../../i18n'; +import type { PackingItem } from '../../../types'; +import { BOX } from '../../Planner/bookings/bookingParts'; +import { SoftPill, WhiteBadge } from '../../Planner/planParts'; +import { DialogSection, fs } from '../../shared/DialogShell'; +import { Tooltip } from '../../shared/Tooltip'; +import { CardAction, TourNotice } from '../tourParts'; +import { CategoryDot } from './packingLinkParts'; +import { TourPackingLinkDialog } from './TourPackingLinkDialog'; +import { useTourPackingLink, type TourPackingLinkController } from './useTourPackingLink'; + +/** How many items of the list the card shows before it says how many more there are. */ +const SHOWN_ITEMS = 5; + +/** The quiet pill beside the section's label, like the upload of a tour's files. */ +function SectionPill({ onClick, icon, children }: { onClick: () => void; icon: ReactNode; children: ReactNode }) { + return ( + + ); +} + +/** The backpack tile with the item count on its corner, the way a waypoint's number carries its marks. */ +function PackTile({ count, color }: { count: number; color: string | null }) { + return ( + + + ); +} + +/** One item as Packing keeps it, read only: ticked off or not, its name and how many. */ +function ItemRow({ item }: { item: PackingItem }) { + return ( +
  • + + {!!item.checked && + + {item.name} + + {(item.quantity ?? 1) > 1 && ×{item.quantity}} +
  • + ); +} + +/** The name of what the tour points at: a category by its own name, otherwise the whole list. */ +function linkName(link: TourPackingLink, t: (key: string) => string): string { + return link.category ?? t('tours.packing.whole'); +} + +/** The linked list as it reads now: its items with how many are packed, or why there are none to show. */ +function LinkedCard({ packing, onOpenPacking }: { packing: TourPackingLinkController; onOpenPacking?: () => void }) { + const { t } = useTranslation(); + const { link, linked } = packing; + if (!link || !linked) return null; + const name = linkName(link, t); + if (linked.state === 'missing') { + return ( + }> + {t('tours.packing.missing', { name })} + + ); + } + const total = linked.items.length; + const allPacked = total > 0 && linked.packed === total; + const more = total - SHOWN_ITEMS; + return ( +
    +
    + +
    +

    + {name} +

    + {total > 0 && ( +

    + {t('tours.packing.packed', { packed: linked.packed, count: total })} +

    + )} +
    + {allPacked && ( + }> + {t('tours.packing.allPacked')} + + )} +
    + {total > 0 ? ( + <> + + ); +} + +/** No link yet: a quiet dashed card, with the way to make one for who may change the tour. */ +function UnlinkedCard({ onLink }: { onLink?: () => void }) { + const { t } = useTranslation(); + return ( +
    + + + + {t('tours.packing.none')} + + {onLink && ( + } onClick={onLink}> + {t('tours.packing.link')} + + )} +
    + ); +} + +/** The section's body for whatever the link reads as now. */ +function SectionBody({ + packing, + canEdit, + onOpenPacking, +}: { + packing: TourPackingLinkController; + canEdit: boolean; + onOpenPacking?: () => void; +}) { + const { t } = useTranslation(); + if (packing.status === 'error') { + return ( + }> + + {t('tours.packing.loadFailed')} + + + + ); + } + if (!packing.link) return ; + if (!packing.packingEnabled) { + // Dimmed, not hidden: the tour still points at the list, it just cannot be shown. + return ( +
    + }> + {linkName(packing.link, t)} + {t('tours.packing.addonOff')} + +
    + ); + } + return ; +} + +/** + * P2D-XD-01 in a tour's detail: the packing list, or the category of it, the + * tour points at, read live from the trip's packing list as this user may see + * it. Who may change the tour links one, changes it or lets go through the + * dialog; the items themselves are only ever changed in Packing, which the + * card opens. Without Packing and without a link there is nothing to show. + */ +export function TourPackingSection({ + tripId, + tour, + tourName, + canEdit, + onOpenPacking, +}: { + tripId: number; + tour: TourListItem; + tourName: string; + canEdit: boolean; + /** Opens the trip's packing list; absent where the detail cannot leave for it. */ + onOpenPacking?: () => void; +}) { + const { t } = useTranslation(); + const packing = useTourPackingLink({ tripId, tour }); + if (packing.status === 'loading' || (!packing.packingEnabled && !packing.link)) return null; + const editable = canEdit && packing.status === 'ready'; + let action: ReactNode = null; + if (editable && packing.link && packing.packingEnabled) { + action = ( + }> + {t('tours.packing.change')} + + ); + } else if (editable && packing.link) { + action = ( + + + void packing.unlink()} icon={}> + {t('tours.packing.unlink')} + + + + ); + } + return ( + +
    + +
    + +
    + ); +} diff --git a/client/src/components/Tours/packing/packingLinkParts.tsx b/client/src/components/Tours/packing/packingLinkParts.tsx new file mode 100644 index 000000000..9ad51e9cb --- /dev/null +++ b/client/src/components/Tours/packing/packingLinkParts.tsx @@ -0,0 +1,26 @@ +import { Package } from 'lucide-react'; +import { useTranslation } from '../../../i18n'; +import { tintOf, WhiteBadge } from '../../Planner/planParts'; + +/** A packing category's own colour, as the packing list gives it, with a soft halo of the same colour. */ +export function CategoryDot({ color }: { color: string }) { + return ( +