2026-02-06 11:07:45 +01:00
|
|
|
import { useState, useMemo } from 'react'
|
2026-02-05 15:28:50 +01:00
|
|
|
import { useParams, Link } from 'react-router-dom'
|
|
|
|
|
import { useRun } from '../hooks/useRuns'
|
|
|
|
|
import { useGameRoutes } from '../hooks/useGames'
|
|
|
|
|
import { useCreateEncounter, useUpdateEncounter } from '../hooks/useEncounters'
|
|
|
|
|
import { EncounterModal } from '../components'
|
2026-02-06 11:07:45 +01:00
|
|
|
import type {
|
|
|
|
|
Route,
|
|
|
|
|
RouteWithChildren,
|
|
|
|
|
EncounterDetail,
|
|
|
|
|
EncounterStatus,
|
|
|
|
|
} from '../types'
|
2026-02-05 15:28:50 +01:00
|
|
|
|
|
|
|
|
type RouteStatus = 'caught' | 'fainted' | 'missed' | 'none'
|
|
|
|
|
|
|
|
|
|
function getRouteStatus(encounter?: EncounterDetail): RouteStatus {
|
|
|
|
|
if (!encounter) return 'none'
|
|
|
|
|
return encounter.status
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const statusIndicator: Record<
|
|
|
|
|
RouteStatus,
|
|
|
|
|
{ dot: string; label: string; bg: string }
|
|
|
|
|
> = {
|
|
|
|
|
caught: {
|
|
|
|
|
dot: 'bg-green-500',
|
|
|
|
|
label: 'Caught',
|
|
|
|
|
bg: 'bg-green-50 dark:bg-green-900/10',
|
|
|
|
|
},
|
|
|
|
|
fainted: {
|
|
|
|
|
dot: 'bg-red-500',
|
|
|
|
|
label: 'Fainted',
|
|
|
|
|
bg: 'bg-red-50 dark:bg-red-900/10',
|
|
|
|
|
},
|
|
|
|
|
missed: {
|
|
|
|
|
dot: 'bg-gray-400',
|
|
|
|
|
label: 'Missed',
|
|
|
|
|
bg: 'bg-gray-50 dark:bg-gray-900/10',
|
|
|
|
|
},
|
|
|
|
|
none: { dot: 'bg-gray-300 dark:bg-gray-600', label: '', bg: '' },
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-06 11:07:45 +01:00
|
|
|
/**
|
|
|
|
|
* Organize flat routes into hierarchical structure.
|
|
|
|
|
* Routes with parentRouteId are grouped under their parent.
|
|
|
|
|
*/
|
|
|
|
|
function organizeRoutes(routes: Route[]): RouteWithChildren[] {
|
|
|
|
|
const childrenByParent = new Map<number, Route[]>()
|
|
|
|
|
const topLevel: Route[] = []
|
|
|
|
|
|
|
|
|
|
for (const route of routes) {
|
|
|
|
|
if (route.parentRouteId === null) {
|
|
|
|
|
topLevel.push(route)
|
|
|
|
|
} else {
|
|
|
|
|
const children = childrenByParent.get(route.parentRouteId) ?? []
|
|
|
|
|
children.push(route)
|
|
|
|
|
childrenByParent.set(route.parentRouteId, children)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return topLevel.map((route) => ({
|
|
|
|
|
...route,
|
|
|
|
|
children: childrenByParent.get(route.id) ?? [],
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if any child route in a group has an encounter.
|
|
|
|
|
* Returns the encounter if found, null otherwise.
|
|
|
|
|
*/
|
|
|
|
|
function getGroupEncounter(
|
|
|
|
|
group: RouteWithChildren,
|
|
|
|
|
encounterByRoute: Map<number, EncounterDetail>,
|
|
|
|
|
): EncounterDetail | null {
|
|
|
|
|
for (const child of group.children) {
|
|
|
|
|
const enc = encounterByRoute.get(child.id)
|
|
|
|
|
if (enc) return enc
|
|
|
|
|
}
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface RouteGroupProps {
|
|
|
|
|
group: RouteWithChildren
|
|
|
|
|
encounterByRoute: Map<number, EncounterDetail>
|
|
|
|
|
isExpanded: boolean
|
|
|
|
|
onToggleExpand: () => void
|
|
|
|
|
onRouteClick: (route: Route) => void
|
|
|
|
|
filter: 'all' | RouteStatus
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function RouteGroup({
|
|
|
|
|
group,
|
|
|
|
|
encounterByRoute,
|
|
|
|
|
isExpanded,
|
|
|
|
|
onToggleExpand,
|
|
|
|
|
onRouteClick,
|
|
|
|
|
filter,
|
|
|
|
|
}: RouteGroupProps) {
|
|
|
|
|
const groupEncounter = getGroupEncounter(group, encounterByRoute)
|
|
|
|
|
const groupStatus = groupEncounter ? groupEncounter.status : 'none'
|
|
|
|
|
const si = statusIndicator[groupStatus]
|
|
|
|
|
|
|
|
|
|
// For groups, check if it matches the filter
|
|
|
|
|
if (filter !== 'all' && groupStatus !== filter) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const hasGroupEncounter = groupEncounter !== null
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
|
|
|
|
{/* Group header */}
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={onToggleExpand}
|
|
|
|
|
className={`w-full flex items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-gray-100 dark:hover:bg-gray-700/50 ${si.bg}`}
|
|
|
|
|
>
|
|
|
|
|
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`} />
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
|
|
|
|
{group.name}
|
|
|
|
|
<span className="text-xs text-gray-400 dark:text-gray-500">
|
|
|
|
|
({group.children.length} areas)
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
{groupEncounter && (
|
|
|
|
|
<div className="flex items-center gap-2 mt-0.5">
|
|
|
|
|
{groupEncounter.pokemon.spriteUrl && (
|
|
|
|
|
<img
|
|
|
|
|
src={groupEncounter.pokemon.spriteUrl}
|
|
|
|
|
alt={groupEncounter.pokemon.name}
|
|
|
|
|
className="w-5 h-5"
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
<span className="text-xs text-gray-500 dark:text-gray-400 capitalize">
|
|
|
|
|
{groupEncounter.nickname ?? groupEncounter.pokemon.name}
|
|
|
|
|
{groupEncounter.status === 'caught' &&
|
|
|
|
|
groupEncounter.faintLevel !== null &&
|
|
|
|
|
(groupEncounter.deathCause
|
|
|
|
|
? ` — ${groupEncounter.deathCause}`
|
|
|
|
|
: ' (dead)')}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">
|
|
|
|
|
{si.label}
|
|
|
|
|
</span>
|
|
|
|
|
<svg
|
|
|
|
|
className={`w-4 h-4 text-gray-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
|
|
|
|
fill="none"
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
viewBox="0 0 24 24"
|
|
|
|
|
>
|
|
|
|
|
<path
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
strokeLinejoin="round"
|
|
|
|
|
strokeWidth={2}
|
|
|
|
|
d="M19 9l-7 7-7-7"
|
|
|
|
|
/>
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{/* Expanded children */}
|
|
|
|
|
{isExpanded && (
|
|
|
|
|
<div className="border-t border-gray-200 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-800/50">
|
|
|
|
|
{group.children.map((child) => {
|
|
|
|
|
const childEncounter = encounterByRoute.get(child.id)
|
|
|
|
|
const childStatus = getRouteStatus(childEncounter)
|
|
|
|
|
const childSi = statusIndicator[childStatus]
|
|
|
|
|
const isDisabled = hasGroupEncounter && !childEncounter
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={child.id}
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => !isDisabled && onRouteClick(child)}
|
|
|
|
|
disabled={isDisabled}
|
|
|
|
|
className={`w-full flex items-center gap-3 px-4 py-2 pl-8 text-left transition-colors ${
|
|
|
|
|
isDisabled
|
|
|
|
|
? 'opacity-50 cursor-not-allowed'
|
|
|
|
|
: 'hover:bg-gray-100 dark:hover:bg-gray-700/50'
|
|
|
|
|
} ${childSi.bg}`}
|
|
|
|
|
>
|
|
|
|
|
<span
|
|
|
|
|
className={`w-2 h-2 rounded-full shrink-0 ${childSi.dot}`}
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<div className="text-sm text-gray-700 dark:text-gray-300">
|
|
|
|
|
{child.name}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{childEncounter && (
|
|
|
|
|
<span className="text-xs text-gray-400 dark:text-gray-500">
|
|
|
|
|
{childSi.label}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{isDisabled && (
|
|
|
|
|
<span className="text-xs text-gray-400 dark:text-gray-500 italic">
|
|
|
|
|
(locked)
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-05 15:28:50 +01:00
|
|
|
export function RunEncounters() {
|
|
|
|
|
const { runId } = useParams<{ runId: string }>()
|
|
|
|
|
const runIdNum = Number(runId)
|
|
|
|
|
const { data: run, isLoading, error } = useRun(runIdNum)
|
|
|
|
|
const { data: routes, isLoading: loadingRoutes } = useGameRoutes(
|
|
|
|
|
run?.gameId ?? null,
|
|
|
|
|
)
|
|
|
|
|
const createEncounter = useCreateEncounter(runIdNum)
|
|
|
|
|
const updateEncounter = useUpdateEncounter(runIdNum)
|
|
|
|
|
|
|
|
|
|
const [selectedRoute, setSelectedRoute] = useState<Route | null>(null)
|
|
|
|
|
const [editingEncounter, setEditingEncounter] =
|
|
|
|
|
useState<EncounterDetail | null>(null)
|
|
|
|
|
const [filter, setFilter] = useState<'all' | RouteStatus>('all')
|
2026-02-06 11:07:45 +01:00
|
|
|
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set())
|
|
|
|
|
|
|
|
|
|
// Organize routes into hierarchical structure
|
|
|
|
|
const organizedRoutes = useMemo(() => {
|
|
|
|
|
if (!routes) return []
|
|
|
|
|
return organizeRoutes(routes)
|
|
|
|
|
}, [routes])
|
2026-02-05 15:28:50 +01:00
|
|
|
|
|
|
|
|
if (isLoading || loadingRoutes) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-center justify-center py-16">
|
|
|
|
|
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (error || !run) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="max-w-4xl mx-auto p-8">
|
|
|
|
|
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-red-700 dark:text-red-400">
|
|
|
|
|
Failed to load run.
|
|
|
|
|
</div>
|
|
|
|
|
<Link
|
|
|
|
|
to="/runs"
|
|
|
|
|
className="inline-block mt-4 text-blue-600 hover:underline"
|
|
|
|
|
>
|
|
|
|
|
Back to runs
|
|
|
|
|
</Link>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Map routeId → encounter for quick lookup
|
|
|
|
|
const encounterByRoute = new Map<number, EncounterDetail>()
|
|
|
|
|
for (const enc of run.encounters) {
|
|
|
|
|
encounterByRoute.set(enc.routeId, enc)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-06 11:07:45 +01:00
|
|
|
// Count completed locations (groups count as 1, standalone routes count as 1)
|
|
|
|
|
const completedCount = organizedRoutes.filter((r) => {
|
|
|
|
|
if (r.children.length > 0) {
|
|
|
|
|
// It's a group - check if any child has an encounter
|
|
|
|
|
return getGroupEncounter(r, encounterByRoute) !== null
|
|
|
|
|
}
|
|
|
|
|
// Standalone route
|
|
|
|
|
return encounterByRoute.has(r.id)
|
|
|
|
|
}).length
|
2026-02-05 15:28:50 +01:00
|
|
|
|
2026-02-06 11:07:45 +01:00
|
|
|
const totalLocations = organizedRoutes.length
|
|
|
|
|
|
|
|
|
|
const toggleGroup = (groupId: number) => {
|
|
|
|
|
setExpandedGroups((prev) => {
|
|
|
|
|
const next = new Set(prev)
|
|
|
|
|
if (next.has(groupId)) {
|
|
|
|
|
next.delete(groupId)
|
|
|
|
|
} else {
|
|
|
|
|
next.add(groupId)
|
|
|
|
|
}
|
|
|
|
|
return next
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-02-05 15:28:50 +01:00
|
|
|
|
|
|
|
|
const handleRouteClick = (route: Route) => {
|
|
|
|
|
const existing = encounterByRoute.get(route.id)
|
|
|
|
|
if (existing) {
|
|
|
|
|
setEditingEncounter(existing)
|
|
|
|
|
} else {
|
|
|
|
|
setEditingEncounter(null)
|
|
|
|
|
}
|
|
|
|
|
setSelectedRoute(route)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleCreate = (data: {
|
|
|
|
|
routeId: number
|
|
|
|
|
pokemonId: number
|
|
|
|
|
nickname?: string
|
|
|
|
|
status: EncounterStatus
|
|
|
|
|
catchLevel?: number
|
|
|
|
|
}) => {
|
|
|
|
|
createEncounter.mutate(data, {
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
setSelectedRoute(null)
|
|
|
|
|
setEditingEncounter(null)
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleUpdate = (data: {
|
|
|
|
|
id: number
|
2026-02-05 18:36:08 +01:00
|
|
|
data: {
|
|
|
|
|
nickname?: string
|
|
|
|
|
status?: EncounterStatus
|
|
|
|
|
faintLevel?: number
|
|
|
|
|
deathCause?: string
|
|
|
|
|
}
|
2026-02-05 15:28:50 +01:00
|
|
|
}) => {
|
|
|
|
|
updateEncounter.mutate(data, {
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
setSelectedRoute(null)
|
|
|
|
|
setEditingEncounter(null)
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-06 11:07:45 +01:00
|
|
|
// Filter routes
|
|
|
|
|
const filteredRoutes = organizedRoutes.filter((r) => {
|
|
|
|
|
if (filter === 'all') return true
|
|
|
|
|
|
|
|
|
|
if (r.children.length > 0) {
|
|
|
|
|
// It's a group
|
|
|
|
|
const groupEnc = getGroupEncounter(r, encounterByRoute)
|
|
|
|
|
return getRouteStatus(groupEnc ?? undefined) === filter
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Standalone route
|
|
|
|
|
const enc = encounterByRoute.get(r.id)
|
|
|
|
|
return getRouteStatus(enc) === filter
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-05 15:28:50 +01:00
|
|
|
return (
|
|
|
|
|
<div className="max-w-4xl mx-auto p-8">
|
|
|
|
|
{/* Header */}
|
|
|
|
|
<div className="mb-6">
|
|
|
|
|
<Link
|
|
|
|
|
to={`/runs/${runId}`}
|
|
|
|
|
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 mb-2 inline-block"
|
|
|
|
|
>
|
|
|
|
|
← {run.name}
|
|
|
|
|
</Link>
|
|
|
|
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
|
|
|
|
Encounters
|
|
|
|
|
</h1>
|
|
|
|
|
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
2026-02-06 11:07:45 +01:00
|
|
|
{run.game.name} · {completedCount} / {totalLocations} locations
|
2026-02-05 15:28:50 +01:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Progress bar */}
|
|
|
|
|
<div className="mb-6">
|
|
|
|
|
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
|
|
|
|
<div
|
|
|
|
|
className="h-full bg-blue-500 rounded-full transition-all"
|
|
|
|
|
style={{
|
2026-02-06 11:07:45 +01:00
|
|
|
width: `${totalLocations > 0 ? (completedCount / totalLocations) * 100 : 0}%`,
|
2026-02-05 15:28:50 +01:00
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Filter tabs */}
|
|
|
|
|
<div className="flex gap-2 mb-4 flex-wrap">
|
|
|
|
|
{(
|
|
|
|
|
[
|
|
|
|
|
{ key: 'all', label: 'All' },
|
|
|
|
|
{ key: 'none', label: 'Unvisited' },
|
|
|
|
|
{ key: 'caught', label: 'Caught' },
|
|
|
|
|
{ key: 'fainted', label: 'Fainted' },
|
|
|
|
|
{ key: 'missed', label: 'Missed' },
|
|
|
|
|
] as const
|
|
|
|
|
).map(({ key, label }) => (
|
|
|
|
|
<button
|
|
|
|
|
key={key}
|
|
|
|
|
onClick={() => setFilter(key)}
|
|
|
|
|
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
|
|
|
|
|
filter === key
|
|
|
|
|
? 'bg-blue-600 text-white'
|
|
|
|
|
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{label}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Route list */}
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
{filteredRoutes.length === 0 && (
|
|
|
|
|
<p className="text-gray-500 dark:text-gray-400 text-sm py-4 text-center">
|
|
|
|
|
No routes match this filter
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
{filteredRoutes.map((route) => {
|
2026-02-06 11:07:45 +01:00
|
|
|
// Render as group if it has children
|
|
|
|
|
if (route.children.length > 0) {
|
|
|
|
|
return (
|
|
|
|
|
<RouteGroup
|
|
|
|
|
key={route.id}
|
|
|
|
|
group={route}
|
|
|
|
|
encounterByRoute={encounterByRoute}
|
|
|
|
|
isExpanded={expandedGroups.has(route.id)}
|
|
|
|
|
onToggleExpand={() => toggleGroup(route.id)}
|
|
|
|
|
onRouteClick={handleRouteClick}
|
|
|
|
|
filter={filter}
|
|
|
|
|
/>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Standalone route (no children)
|
2026-02-05 15:28:50 +01:00
|
|
|
const encounter = encounterByRoute.get(route.id)
|
|
|
|
|
const rs = getRouteStatus(encounter)
|
|
|
|
|
const si = statusIndicator[rs]
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={route.id}
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => handleRouteClick(route)}
|
|
|
|
|
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left transition-colors hover:bg-gray-100 dark:hover:bg-gray-700/50 ${si.bg}`}
|
|
|
|
|
>
|
|
|
|
|
<span
|
|
|
|
|
className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`}
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
|
|
|
{route.name}
|
|
|
|
|
</div>
|
|
|
|
|
{encounter && (
|
|
|
|
|
<div className="flex items-center gap-2 mt-0.5">
|
|
|
|
|
{encounter.pokemon.spriteUrl && (
|
|
|
|
|
<img
|
|
|
|
|
src={encounter.pokemon.spriteUrl}
|
|
|
|
|
alt={encounter.pokemon.name}
|
|
|
|
|
className="w-5 h-5"
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
<span className="text-xs text-gray-500 dark:text-gray-400 capitalize">
|
|
|
|
|
{encounter.nickname ?? encounter.pokemon.name}
|
|
|
|
|
{encounter.status === 'caught' &&
|
|
|
|
|
encounter.faintLevel !== null &&
|
2026-02-05 18:36:08 +01:00
|
|
|
(encounter.deathCause
|
|
|
|
|
? ` — ${encounter.deathCause}`
|
|
|
|
|
: ' (dead)')}
|
2026-02-05 15:28:50 +01:00
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">
|
|
|
|
|
{si.label}
|
|
|
|
|
</span>
|
|
|
|
|
</button>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Encounter Modal */}
|
|
|
|
|
{selectedRoute && (
|
|
|
|
|
<EncounterModal
|
|
|
|
|
route={selectedRoute}
|
|
|
|
|
existing={editingEncounter ?? undefined}
|
|
|
|
|
onSubmit={handleCreate}
|
|
|
|
|
onUpdate={handleUpdate}
|
|
|
|
|
onClose={() => {
|
|
|
|
|
setSelectedRoute(null)
|
|
|
|
|
setEditingEncounter(null)
|
|
|
|
|
}}
|
|
|
|
|
isPending={createEncounter.isPending || updateEncounter.isPending}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|