Files
server-26/drb-frontend/app/trips/page.tsx
Logan CusanoandClaude Sonnet 5 d67b2057e6 frontend: safe fixes from the #109 punch-list
- CallSpineEntry.tsx: drop the dead `hasAudio` prop + the early `return null`
  that sat between hooks in InlinePlayer (React #310 risk). Parent already
  gates the mount on audio presence.
- NodeCard.tsx + nodes/page.tsx: pending-node card no longer double-fires.
  NodeCard gains `linkToDetail` (default true); the pending branch passes
  false so the wrapping onClick (open config modal) isn't swallowed by the
  inner <Link> navigation. List view unchanged.
- trips/page.tsx: TripCard badge now buckets on end_date >= today, matching
  the list's own upcoming/past split — an in-progress trip no longer shows a
  "Past" badge under "Upcoming".
- trips/page.tsx, NodeConfigModal.tsx, nodes/[id]/page.tsx: tall modals get
  `p-4` on the overlay + `max-h-[90vh] overflow-y-auto` on the panel so they
  don't clip on short viewports (incidents' CreateModal pattern).
- lib/types.ts: IncidentRecord.units / vehicles are optional now, matching
  Firestore (older docs omit them); incidents/[id] gains a `?? []` guard.

Untypechecked (no node/npm locally). next build in deploy.yml gates it.
Full list of remaining items in server-26 #109.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 00:07:54 -04:00

241 lines
8.4 KiB
TypeScript

"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/components/AuthProvider";
import { useTrips } from "@/lib/useTrips";
import { c2api } from "@/lib/c2api";
import type { TripRecord } from "@/lib/types";
function fmtDate(iso: string) {
return new Date(`${iso}T12:00:00`).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
function TripCard({ trip, isAdmin, onDelete }: {
trip: TripRecord;
isAdmin: boolean;
onDelete: (id: string) => void;
}) {
const router = useRouter();
const today = new Date().toISOString().slice(0, 10);
// Bucket and badge must agree: the list groups on end_date (page.tsx ~L176),
// so a trip isn't "Past" until it's over, not when it starts.
const upcoming = trip.end_date >= today;
const attendeeCount = Object.keys(trip.attendees ?? {}).length;
return (
<div
className="bg-gray-900 border border-gray-800 rounded-xl p-5 cursor-pointer hover:border-gray-700 transition-colors"
onClick={() => router.push(`/trips/${trip.trip_id}`)}
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs font-mono px-2 py-0.5 rounded-full ${
upcoming ? "bg-indigo-900 text-indigo-300" : "bg-gray-800 text-gray-500"
}`}>
{upcoming ? "Upcoming" : "Past"}
</span>
</div>
<h3 className="text-white font-semibold text-sm leading-snug">{trip.name}</h3>
<p className="text-gray-400 text-xs mt-1">{trip.location}</p>
<p className="text-gray-500 text-xs font-mono mt-1">
{fmtDate(trip.start_date)} — {fmtDate(trip.end_date)}
</p>
{attendeeCount > 0 && (
<p className="text-gray-500 text-xs mt-1">
{attendeeCount} going
</p>
)}
</div>
{isAdmin && (
<button
onClick={(e) => { e.stopPropagation(); onDelete(trip.trip_id); }}
className="text-xs text-red-500 hover:text-red-400 transition-colors shrink-0"
>
Delete
</button>
)}
</div>
</div>
);
}
function CreateModal({ onClose, onCreate }: {
onClose: () => void;
onCreate: (body: object) => Promise<void>;
}) {
const [name, setName] = useState("");
const [location, setLocation] = useState("");
const [start, setStart] = useState("");
const [end, setEnd] = useState("");
const [mapsLink, setMapsLink] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (end < start) { setError("End date must be on or after start date."); return; }
setSaving(true);
setError(null);
try {
await onCreate({
name,
location,
start_date: start,
end_date: end,
maps_link: mapsLink || null,
});
onClose();
} catch {
setError("Failed to create trip.");
} finally {
setSaving(false);
}
}
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<form
onSubmit={handleSubmit}
className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4 max-h-[90vh] overflow-y-auto"
>
<h2 className="text-white font-bold">New Trip</h2>
<div>
<label className="text-xs text-gray-400 block mb-1">Name</label>
<input
required value={name} onChange={(e) => setName(e.target.value)}
placeholder="Road trip to Nashville"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Location</label>
<input
required value={location} onChange={(e) => setLocation(e.target.value)}
placeholder="Nashville, TN"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-gray-400 block mb-1">Start date</label>
<input
required type="date" value={start} onChange={(e) => setStart(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">End date</label>
<input
required type="date" value={end} onChange={(e) => setEnd(e.target.value)}
min={start}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Google Maps link (optional)</label>
<input
type="url" value={mapsLink} onChange={(e) => setMapsLink(e.target.value)}
placeholder="https://maps.google.com/…"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
{error && <p className="text-red-400 text-xs">{error}</p>}
<div className="flex gap-3 justify-end">
<button type="button" onClick={onClose} className="text-sm text-gray-400 hover:text-gray-200 px-4 py-2">
Cancel
</button>
<button
type="submit" disabled={saving}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm rounded-lg px-4 py-2"
>
{saving ? "Creating…" : "Create Trip"}
</button>
</div>
</form>
</div>
);
}
export default function TripsPage() {
const { isAdmin } = useAuth();
const { trips, loading } = useTrips();
const [showCreate, setShowCreate] = useState(false);
const today = new Date().toISOString().slice(0, 10);
const upcoming = trips.filter((t) => t.end_date >= today);
const past = trips.filter((t) => t.end_date < today);
async function handleDelete(id: string) {
try { await c2api.deleteTrip(id); }
catch (e) { console.error(e); }
}
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
<h1 className="text-white text-xl font-bold font-mono">Trips</h1>
{isAdmin && (
<button
onClick={() => setShowCreate(true)}
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm rounded-lg px-4 py-2 transition-colors"
>
+ New Trip
</button>
)}
</div>
{loading ? (
<p className="text-gray-500 text-sm font-mono">Loading…</p>
) : (
<>
{upcoming.length > 0 && (
<section>
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Upcoming</h2>
<div className="space-y-3">
{upcoming.map((t) => (
<TripCard key={t.trip_id} trip={t} isAdmin={isAdmin} onDelete={handleDelete} />
))}
</div>
</section>
)}
{past.length > 0 && (
<section>
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Past</h2>
<div className="space-y-3">
{past.map((t) => (
<TripCard key={t.trip_id} trip={t} isAdmin={isAdmin} onDelete={handleDelete} />
))}
</div>
</section>
)}
{trips.length === 0 && (
<p className="text-gray-600 text-sm font-mono">No trips yet.</p>
)}
</>
)}
{showCreate && (
<CreateModal
onClose={() => setShowCreate(false)}
onCreate={async (body) => { await c2api.createTrip(body); }}
/>
)}
</div>
);
}