Files
server-26/drb-frontend/app/trips/page.tsx
T
Logan CusanoandClaude Opus 5.5 f16f0a9f93 fix(frontend): readable API errors and no more silent action failures (#284, #286)
#286: request() now throws C2ApiError — message is FastAPI's `detail` or a
plain sentence for the status (401/403/404/429/5xx), with toString() so the
many String(e) call sites read cleanly. status/body stay on the error; the
Discord parsers rebuild the legacy "C2 API error N: body" text via
legacyErrorText(), so their invite/code parsing is unchanged.

#284: Resolve, Regenerate summary, alert-rule toggle/delete/load,
Acknowledge, trip deletes, system delete and Restart OP25 now surface
failures (ErrorBanner where the page has one, alert() where the page
already uses it). Rules tab no longer says "No alert rules configured"
when the load failed. Confirms added for Resolve (no reopen path exists),
rule delete and trip delete. Reopen deliberately not built — manual
resolve doesn't set resolved_at, so reopening is a lifecycle change.

Frontend unverified: no Node on the authoring box (#207).

Closes #286

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:38:13 -04:00

243 lines
8.5 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 { errorMessage } from "@/lib/c2error";
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-surface border border-line rounded-xl p-5 cursor-pointer hover:border-line-strong 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-raised text-ink-muted"
}`}>
{upcoming ? "Upcoming" : "Past"}
</span>
</div>
<h3 className="text-white font-semibold text-sm leading-snug">{trip.name}</h3>
<p className="text-ink-2 text-xs mt-1">{trip.location}</p>
<p className="text-ink-muted text-xs font-mono mt-1">
{fmtDate(trip.start_date)} — {fmtDate(trip.end_date)}
</p>
{attendeeCount > 0 && (
<p className="text-ink-muted 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-surface border border-line-strong 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-ink-2 block mb-1">Name</label>
<input
required value={name} onChange={(e) => setName(e.target.value)}
placeholder="Road trip to Nashville"
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
<div>
<label className="text-xs text-ink-2 block mb-1">Location</label>
<input
required value={location} onChange={(e) => setLocation(e.target.value)}
placeholder="Nashville, TN"
className="w-full bg-raised border border-line-strong 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-ink-2 block mb-1">Start date</label>
<input
required type="date" value={start} onChange={(e) => setStart(e.target.value)}
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
<div>
<label className="text-xs text-ink-2 block mb-1">End date</label>
<input
required type="date" value={end} onChange={(e) => setEnd(e.target.value)}
min={start}
className="w-full bg-raised border border-line-strong 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-ink-2 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-raised border border-line-strong 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-ink-2 hover:text-ink 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) {
if (!confirm("Delete this trip?")) return;
try { await c2api.deleteTrip(id); }
catch (e) { alert(`Couldn't delete the trip: ${errorMessage(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-ink-muted text-sm font-mono">Loading…</p>
) : (
<>
{upcoming.length > 0 && (
<section>
<h2 className="text-sm font-mono text-ink-2 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-ink-2 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-ink-muted text-sm font-mono">No trips yet.</p>
)}
</>
)}
{showCreate && (
<CreateModal
onClose={() => setShowCreate(false)}
onCreate={async (body) => { await c2api.createTrip(body); }}
/>
)}
</div>
);
}