#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>
243 lines
8.5 KiB
TypeScript
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>
|
|
);
|
|
}
|