Add trips to UI
lib/types.ts — TripRecord and TripEvent types lib/c2api.ts — getTrips, getTrip, createTrip, deleteTrip, createTripEvent, deleteTripEvent lib/useTrips.ts — Firestore realtime hook on the trips collection, ordered by start date app/trips/page.tsx — List page split into Upcoming / Past sections, card click navigates to detail, "+ New Trip" modal for admins with all fields including date range and maps link app/trips/[id]/page.tsx — Detail page fetched via C2 API (gets trip + events in one call), day-by-day itinerary with time, location, maps link, notes, and Discord attendees. Add Event modal (date constrained to trip range). Admin-only delete trip + remove event. components/Nav.tsx — Trips link added to the nav
This commit is contained in:
@@ -0,0 +1,403 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import type { TripRecord, TripEvent } from "@/lib/types";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Formatting helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function fmtDate(iso: string) {
|
||||||
|
return new Date(`${iso}T12:00:00`).toLocaleDateString("en-US", {
|
||||||
|
month: "short", day: "numeric", year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDayLabel(iso: string) {
|
||||||
|
return new Date(`${iso}T12:00:00`).toLocaleDateString("en-US", {
|
||||||
|
weekday: "long", month: "short", day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(t: string | null) {
|
||||||
|
if (!t) return null;
|
||||||
|
const [h, m] = t.split(":").map(Number);
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(h, m, 0, 0);
|
||||||
|
return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateRange(start: string, end: string): string[] {
|
||||||
|
const dates: string[] = [];
|
||||||
|
const cur = new Date(`${start}T12:00:00`);
|
||||||
|
const endDate = new Date(`${end}T12:00:00`);
|
||||||
|
while (cur <= endDate) {
|
||||||
|
dates.push(cur.toISOString().slice(0, 10));
|
||||||
|
cur.setDate(cur.getDate() + 1);
|
||||||
|
}
|
||||||
|
return dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Add Event modal
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function AddEventModal({ trip, onClose, onAdd }: {
|
||||||
|
trip: TripRecord;
|
||||||
|
onClose: () => void;
|
||||||
|
onAdd: (body: object) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [date, setDate] = useState(trip.start_date);
|
||||||
|
const [time, setTime] = useState("");
|
||||||
|
const [location, setLocation] = useState("");
|
||||||
|
const [mapsLink, setMapsLink] = useState("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await onAdd({
|
||||||
|
title,
|
||||||
|
date,
|
||||||
|
time: time || null,
|
||||||
|
location: location || null,
|
||||||
|
maps_link: mapsLink || null,
|
||||||
|
notes: notes || null,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
setError("Failed to add event. Make sure the date is within the trip range.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4"
|
||||||
|
>
|
||||||
|
<h2 className="text-white font-bold">Add Event</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">Title</label>
|
||||||
|
<input
|
||||||
|
required value={title} onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Dinner on Broadway"
|
||||||
|
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">Date</label>
|
||||||
|
<input
|
||||||
|
required type="date"
|
||||||
|
min={trip.start_date} max={trip.end_date}
|
||||||
|
value={date} onChange={(e) => setDate(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">Time (optional)</label>
|
||||||
|
<input
|
||||||
|
type="time" value={time} onChange={(e) => setTime(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>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">
|
||||||
|
Location (optional — inherits trip location)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={location} onChange={(e) => setLocation(e.target.value)}
|
||||||
|
placeholder={trip.location}
|
||||||
|
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">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>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">Notes (optional)</label>
|
||||||
|
<textarea
|
||||||
|
value={notes} onChange={(e) => setNotes(e.target.value)} rows={2}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none resize-none"
|
||||||
|
/>
|
||||||
|
</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 ? "Adding…" : "Add Event"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Event card
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function EventCard({ event, isAdmin, onDelete }: {
|
||||||
|
event: TripEvent;
|
||||||
|
isAdmin: boolean;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const time = fmtTime(event.time);
|
||||||
|
const attendees = Object.values(event.attendees ?? {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4 items-start">
|
||||||
|
<div className="w-20 shrink-0 text-right">
|
||||||
|
{time ? (
|
||||||
|
<span className="text-gray-400 text-xs font-mono">{time}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-700 text-xs font-mono">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 pb-4 border-b border-gray-800">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<p className="text-white text-sm font-medium">{event.title}</p>
|
||||||
|
{isAdmin && (
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(event.event_id)}
|
||||||
|
className="text-xs text-red-500 hover:text-red-400 transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!event.location_inherited && (
|
||||||
|
<div className="flex items-center gap-1 mt-0.5">
|
||||||
|
<p className="text-gray-500 text-xs">{event.location}</p>
|
||||||
|
{event.maps_link && (
|
||||||
|
<a
|
||||||
|
href={event.maps_link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
|
||||||
|
>
|
||||||
|
Maps
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{event.location_inherited && event.maps_link && (
|
||||||
|
<a
|
||||||
|
href={event.maps_link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors mt-0.5 block"
|
||||||
|
>
|
||||||
|
Maps
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{event.notes && (
|
||||||
|
<p className="text-gray-500 text-xs mt-1 italic">{event.notes}</p>
|
||||||
|
)}
|
||||||
|
{attendees.length > 0 && (
|
||||||
|
<p className="text-gray-600 text-xs mt-1">{attendees.join(", ")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Page
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function TripDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const router = useRouter();
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
|
||||||
|
const [trip, setTrip] = useState<(TripRecord & { events: TripEvent[] }) | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const data = await c2api.getTrip(id);
|
||||||
|
setTrip(data as TripRecord & { events: TripEvent[] });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [id]);
|
||||||
|
|
||||||
|
async function handleDeleteTrip() {
|
||||||
|
if (!trip) return;
|
||||||
|
try {
|
||||||
|
await c2api.deleteTrip(trip.trip_id);
|
||||||
|
router.push("/trips");
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteEvent(eventId: string) {
|
||||||
|
if (!trip) return;
|
||||||
|
try {
|
||||||
|
await c2api.deleteTripEvent(trip.trip_id, eventId);
|
||||||
|
setTrip((prev) => prev ? { ...prev, events: prev.events.filter((e) => e.event_id !== eventId) } : prev);
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddEvent(body: object) {
|
||||||
|
if (!trip) return;
|
||||||
|
const event = await c2api.createTripEvent(trip.trip_id, body);
|
||||||
|
setTrip((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const events = [...prev.events, event].sort((a, b) =>
|
||||||
|
a.date.localeCompare(b.date) || (a.time ?? "").localeCompare(b.time ?? "")
|
||||||
|
);
|
||||||
|
return { ...prev, events };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <p className="text-gray-500 text-sm font-mono">Loading…</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trip) {
|
||||||
|
return <p className="text-gray-500 text-sm font-mono">Trip not found.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attendees = Object.values(trip.attendees ?? {});
|
||||||
|
const eventsByDate: Record<string, TripEvent[]> = {};
|
||||||
|
for (const e of trip.events ?? []) {
|
||||||
|
(eventsByDate[e.date] ??= []).push(e);
|
||||||
|
}
|
||||||
|
const days = dateRange(trip.start_date, trip.end_date).filter((d) => eventsByDate[d]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 max-w-2xl">
|
||||||
|
{/* Back */}
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/trips")}
|
||||||
|
className="text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
← Trips
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<h1 className="text-white text-2xl font-bold">{trip.name}</h1>
|
||||||
|
{isAdmin && (
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteTrip}
|
||||||
|
className="text-xs text-red-500 hover:text-red-400 transition-colors shrink-0 mt-1"
|
||||||
|
>
|
||||||
|
Delete trip
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-gray-400">
|
||||||
|
<span>{trip.location}</span>
|
||||||
|
{trip.maps_link && (
|
||||||
|
<a
|
||||||
|
href={trip.maps_link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-indigo-400 hover:text-indigo-300 text-xs transition-colors"
|
||||||
|
>
|
||||||
|
View on Maps
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<span className="text-gray-600">•</span>
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
{fmtDate(trip.start_date)} — {fmtDate(trip.end_date)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{attendees.length > 0 && (
|
||||||
|
<p className="text-gray-500 text-xs">
|
||||||
|
Going: {attendees.join(", ")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Itinerary */}
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider">Itinerary</h2>
|
||||||
|
{isAdmin && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAdd(true)}
|
||||||
|
className="text-sm bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg px-3 py-1.5 transition-colors"
|
||||||
|
>
|
||||||
|
+ Add Event
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{days.length === 0 ? (
|
||||||
|
<p className="text-gray-600 text-sm font-mono">
|
||||||
|
No events yet.{isAdmin ? " Use \"Add Event\" to build the itinerary." : ""}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{days.map((day) => (
|
||||||
|
<div key={day}>
|
||||||
|
<p className="text-xs font-mono text-gray-500 uppercase tracking-wider mb-3">
|
||||||
|
{fmtDayLabel(day)}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-0">
|
||||||
|
{(eventsByDate[day] ?? []).map((event) => (
|
||||||
|
<EventCard
|
||||||
|
key={event.event_id}
|
||||||
|
event={event}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
onDelete={handleDeleteEvent}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAdd && trip && (
|
||||||
|
<AddEventModal
|
||||||
|
trip={trip}
|
||||||
|
onClose={() => setShowAdd(false)}
|
||||||
|
onAdd={handleAddEvent}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
"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);
|
||||||
|
const upcoming = trip.start_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">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ const links = [
|
|||||||
{ href: "/incidents", label: "Incidents" },
|
{ href: "/incidents", label: "Incidents" },
|
||||||
{ href: "/map", label: "Map" },
|
{ href: "/map", label: "Map" },
|
||||||
{ href: "/alerts", label: "Alerts" },
|
{ href: "/alerts", label: "Alerts" },
|
||||||
|
{ href: "/trips", label: "Trips" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const adminLinks = [
|
const adminLinks = [
|
||||||
|
|||||||
@@ -134,6 +134,19 @@ export const c2api = {
|
|||||||
setPreferredToken: (tokenId: string, systemId: string) =>
|
setPreferredToken: (tokenId: string, systemId: string) =>
|
||||||
request<{ ok: boolean; preferred_for_system_id: string | null }>(`/tokens/${tokenId}/prefer/${systemId}`, { method: "PUT" }),
|
request<{ ok: boolean; preferred_for_system_id: string | null }>(`/tokens/${tokenId}/prefer/${systemId}`, { method: "PUT" }),
|
||||||
|
|
||||||
|
// Trips
|
||||||
|
getTrips: () => request<import("@/lib/types").TripRecord[]>("/trips"),
|
||||||
|
getTrip: (id: string) =>
|
||||||
|
request<import("@/lib/types").TripRecord & { events: import("@/lib/types").TripEvent[] }>(`/trips/${id}`),
|
||||||
|
createTrip: (body: object) =>
|
||||||
|
request<import("@/lib/types").TripRecord>("/trips", { method: "POST", body: JSON.stringify(body) }),
|
||||||
|
deleteTrip: (id: string) =>
|
||||||
|
request(`/trips/${id}`, { method: "DELETE" }),
|
||||||
|
createTripEvent: (tripId: string, body: object) =>
|
||||||
|
request<import("@/lib/types").TripEvent>(`/trips/${tripId}/events`, { method: "POST", body: JSON.stringify(body) }),
|
||||||
|
deleteTripEvent: (tripId: string, eventId: string) =>
|
||||||
|
request(`/trips/${tripId}/events/${eventId}`, { method: "DELETE" }),
|
||||||
|
|
||||||
// Per-system AI flag overrides
|
// Per-system AI flag overrides
|
||||||
setSystemAiFlags: (systemId: string, flags: { stt_enabled?: boolean | null; correlation_enabled?: boolean | null }) =>
|
setSystemAiFlags: (systemId: string, flags: { stt_enabled?: boolean | null; correlation_enabled?: boolean | null }) =>
|
||||||
request<{ ok: boolean; ai_flags: Record<string, boolean> }>(`/systems/${systemId}/ai-flags`, {
|
request<{ ok: boolean; ai_flags: Record<string, boolean> }>(`/systems/${systemId}/ai-flags`, {
|
||||||
|
|||||||
@@ -98,6 +98,32 @@ export interface AlertRule {
|
|||||||
created_at?: string;
|
created_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TripEvent {
|
||||||
|
event_id: string;
|
||||||
|
trip_id: string;
|
||||||
|
title: string;
|
||||||
|
date: string;
|
||||||
|
time: string | null;
|
||||||
|
location: string;
|
||||||
|
location_inherited: boolean;
|
||||||
|
maps_link: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
attendees: Record<string, string>;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TripRecord {
|
||||||
|
trip_id: string;
|
||||||
|
name: string;
|
||||||
|
location: string;
|
||||||
|
maps_link: string | null;
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
attendees: Record<string, string>;
|
||||||
|
created_at: string;
|
||||||
|
events?: TripEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface AlertEvent {
|
export interface AlertEvent {
|
||||||
alert_id: string;
|
alert_id: string;
|
||||||
rule_id: string;
|
rule_id: string;
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { collection, onSnapshot, query, orderBy } from "firebase/firestore";
|
||||||
|
import { onAuthStateChanged } from "firebase/auth";
|
||||||
|
import { db, auth } from "@/lib/firebase";
|
||||||
|
import type { TripRecord } from "@/lib/types";
|
||||||
|
|
||||||
|
export function useTrips() {
|
||||||
|
const [trips, setTrips] = useState<TripRecord[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let unsubFirestore: (() => void) | undefined;
|
||||||
|
|
||||||
|
const unsubAuth = onAuthStateChanged(auth, (user) => {
|
||||||
|
if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; }
|
||||||
|
if (!user) { setTrips([]); setLoading(false); return; }
|
||||||
|
|
||||||
|
const q = query(collection(db, "trips"), orderBy("start_date", "asc"));
|
||||||
|
unsubFirestore = onSnapshot(
|
||||||
|
q,
|
||||||
|
(snap) => {
|
||||||
|
setTrips(snap.docs.map((d) => d.data() as TripRecord));
|
||||||
|
setLoading(false);
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
console.error("useTrips:", err);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => { unsubAuth(); if (unsubFirestore) unsubFirestore(); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { trips, loading };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user