add event editing
This commit is contained in:
@@ -161,6 +161,18 @@ class TripEventCreate(BaseModel):
|
|||||||
tags: List[str] = [] # tag labels applied to this event
|
tags: List[str] = [] # tag labels applied to this event
|
||||||
|
|
||||||
|
|
||||||
|
class TripEventUpdate(BaseModel):
|
||||||
|
title: Optional[str] = None
|
||||||
|
date: Optional[str] = None
|
||||||
|
start_time: Optional[str] = None
|
||||||
|
end_time: Optional[str] = None
|
||||||
|
location: Optional[str] = None
|
||||||
|
maps_link: Optional[str] = None
|
||||||
|
place_id: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
class AttendeeAction(BaseModel):
|
class AttendeeAction(BaseModel):
|
||||||
discord_user_id: str
|
discord_user_id: str
|
||||||
discord_username: Optional[str] = None
|
discord_username: Optional[str] = None
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from datetime import datetime, timezone
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import APIRouter, HTTPException, Depends
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from app.models import TripCreate, TripEventCreate, AttendeeAction
|
from app.models import TripCreate, TripEventCreate, TripEventUpdate, AttendeeAction
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.internal.logger import logger
|
from app.internal.logger import logger
|
||||||
@@ -319,6 +319,45 @@ async def create_event(trip_id: str, body: TripEventCreate):
|
|||||||
return doc
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{trip_id}/events/{event_id}")
|
||||||
|
async def update_event(trip_id: str, event_id: str, body: TripEventUpdate):
|
||||||
|
event = await fstore.doc_get("trip_events", event_id)
|
||||||
|
if not event or event.get("trip_id") != trip_id:
|
||||||
|
raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.")
|
||||||
|
|
||||||
|
trip = await fstore.doc_get("trips", trip_id)
|
||||||
|
if not trip:
|
||||||
|
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||||
|
|
||||||
|
updates: dict = {}
|
||||||
|
if body.title is not None:
|
||||||
|
updates["title"] = body.title
|
||||||
|
if body.date is not None:
|
||||||
|
if not (trip["start_date"] <= body.date <= trip["end_date"]):
|
||||||
|
raise HTTPException(400, f"Event date {body.date} is outside the trip range.")
|
||||||
|
updates["date"] = body.date
|
||||||
|
if body.start_time is not None:
|
||||||
|
updates["start_time"] = body.start_time or None
|
||||||
|
if body.end_time is not None:
|
||||||
|
updates["end_time"] = body.end_time or None
|
||||||
|
if body.location is not None:
|
||||||
|
updates["location"] = body.location
|
||||||
|
updates["location_inherited"] = False
|
||||||
|
if body.maps_link is not None:
|
||||||
|
updates["maps_link"] = body.maps_link or None
|
||||||
|
if body.place_id is not None:
|
||||||
|
updates["place_id"] = body.place_id or None
|
||||||
|
if body.notes is not None:
|
||||||
|
updates["notes"] = body.notes or None
|
||||||
|
if body.tags is not None:
|
||||||
|
updates["tags"] = body.tags
|
||||||
|
|
||||||
|
if updates:
|
||||||
|
await fstore.doc_update("trip_events", event_id, updates)
|
||||||
|
|
||||||
|
return {**event, **updates}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{trip_id}/events/{event_id}")
|
@router.delete("/{trip_id}/events/{event_id}")
|
||||||
async def delete_event(
|
async def delete_event(
|
||||||
trip_id: str,
|
trip_id: str,
|
||||||
|
|||||||
@@ -180,12 +180,15 @@ function AddEventModal({
|
|||||||
onClose,
|
onClose,
|
||||||
onAdd,
|
onAdd,
|
||||||
prefill,
|
prefill,
|
||||||
|
editEventId,
|
||||||
}: {
|
}: {
|
||||||
trip: TripRecord;
|
trip: TripRecord;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onAdd: (body: object) => Promise<void>;
|
onAdd: (body: object) => Promise<void>;
|
||||||
prefill?: Partial<TripEvent>;
|
prefill?: Partial<TripEvent>;
|
||||||
|
editEventId?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const isEdit = !!editEventId;
|
||||||
const availableTags = trip.available_tags ?? [];
|
const availableTags = trip.available_tags ?? [];
|
||||||
const [title, setTitle] = useState(prefill?.title ?? "");
|
const [title, setTitle] = useState(prefill?.title ?? "");
|
||||||
const [date, setDate] = useState(prefill?.date ?? trip.start_date);
|
const [date, setDate] = useState(prefill?.date ?? trip.start_date);
|
||||||
@@ -224,7 +227,7 @@ function AddEventModal({
|
|||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to add event. Check the date is within the trip range.");
|
setError(isEdit ? "Failed to save changes." : "Failed to add event. Check the date is within the trip range.");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -236,7 +239,7 @@ function AddEventModal({
|
|||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-lg space-y-4 max-h-[90vh] overflow-y-auto"
|
className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-lg space-y-4 max-h-[90vh] overflow-y-auto"
|
||||||
>
|
>
|
||||||
<h2 className="text-white font-bold text-lg">Add Event</h2>
|
<h2 className="text-white font-bold text-lg">{isEdit ? "Edit Event" : "Add Event"}</h2>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Title</label>
|
<label className="text-xs text-gray-400 block mb-1">Title</label>
|
||||||
@@ -341,7 +344,7 @@ function AddEventModal({
|
|||||||
type="submit" disabled={saving}
|
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"
|
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"}
|
{saving ? (isEdit ? "Saving…" : "Adding…") : (isEdit ? "Save Changes" : "Add Event")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -359,12 +362,14 @@ function DayTimeline({
|
|||||||
events,
|
events,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onEdit,
|
||||||
driveSegments,
|
driveSegments,
|
||||||
availableTags,
|
availableTags,
|
||||||
}: {
|
}: {
|
||||||
events: TripEvent[];
|
events: TripEvent[];
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
|
onEdit: (event: TripEvent) => void;
|
||||||
driveSegments: { fromId: string; toId: string; text: string }[];
|
driveSegments: { fromId: string; toId: string; text: string }[];
|
||||||
availableTags: string[];
|
availableTags: string[];
|
||||||
}) {
|
}) {
|
||||||
@@ -472,12 +477,20 @@ function DayTimeline({
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => onEdit(e)}
|
||||||
|
className="text-gray-600 hover:text-indigo-400 text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onDelete(e.event_id)}
|
onClick={() => onDelete(e.event_id)}
|
||||||
className="text-gray-600 hover:text-red-400 text-base leading-none opacity-0 group-hover:opacity-100 transition-opacity"
|
className="text-gray-600 hover:text-red-400 text-base leading-none opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -526,10 +539,16 @@ function DayTimeline({
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<button onClick={() => onEdit(e)}
|
||||||
|
className="text-xs text-gray-600 hover:text-indigo-400 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
<button onClick={() => onDelete(e.event_id)}
|
<button onClick={() => onDelete(e.event_id)}
|
||||||
className="text-gray-600 hover:text-red-400 text-sm opacity-0 group-hover:opacity-100 transition-opacity">
|
className="text-gray-600 hover:text-red-400 text-sm opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -842,6 +861,7 @@ export default function TripDetailPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selectedDay, setSelectedDay] = useState<string>("");
|
const [selectedDay, setSelectedDay] = useState<string>("");
|
||||||
const [showAdd, setShowAdd] = useState(false);
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
const [editEvent, setEditEvent] = useState<TripEvent | null>(null);
|
||||||
const [driveTimes, setDriveTimes] = useState<Record<string, { fromId: string; toId: string; text: string }[]>>({});
|
const [driveTimes, setDriveTimes] = useState<Record<string, { fromId: string; toId: string; text: string }[]>>({});
|
||||||
const [tagInput, setTagInput] = useState("");
|
const [tagInput, setTagInput] = useState("");
|
||||||
|
|
||||||
@@ -901,6 +921,18 @@ export default function TripDetailPage() {
|
|||||||
} catch (e) { console.error(e); }
|
} catch (e) { console.error(e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleUpdateEvent(body: object) {
|
||||||
|
if (!trip || !editEvent) return;
|
||||||
|
const updated = await c2api.updateTripEvent(trip.trip_id, editEvent.event_id, body);
|
||||||
|
setTrip((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const events = prev.events.map((e) => e.event_id === updated.event_id ? updated : e)
|
||||||
|
.sort((a, b) => a.date.localeCompare(b.date) || (a.start_time ?? "").localeCompare(b.start_time ?? ""));
|
||||||
|
return { ...prev, events };
|
||||||
|
});
|
||||||
|
setEditEvent(null);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleAddEvent(body: object) {
|
async function handleAddEvent(body: object) {
|
||||||
if (!trip) return;
|
if (!trip) return;
|
||||||
const event = await c2api.createTripEvent(trip.trip_id, body);
|
const event = await c2api.createTripEvent(trip.trip_id, body);
|
||||||
@@ -1079,6 +1111,7 @@ export default function TripDetailPage() {
|
|||||||
events={dayEvents}
|
events={dayEvents}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
onDelete={handleDeleteEvent}
|
onDelete={handleDeleteEvent}
|
||||||
|
onEdit={setEditEvent}
|
||||||
driveSegments={driveTimes[selectedDay] ?? []}
|
driveSegments={driveTimes[selectedDay] ?? []}
|
||||||
availableTags={trip.available_tags ?? []}
|
availableTags={trip.available_tags ?? []}
|
||||||
/>
|
/>
|
||||||
@@ -1100,6 +1133,17 @@ export default function TripDetailPage() {
|
|||||||
prefill={selectedDay ? { date: selectedDay } : undefined}
|
prefill={selectedDay ? { date: selectedDay } : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Edit event modal */}
|
||||||
|
{editEvent && (
|
||||||
|
<AddEventModal
|
||||||
|
trip={trip}
|
||||||
|
onClose={() => setEditEvent(null)}
|
||||||
|
onAdd={handleUpdateEvent}
|
||||||
|
prefill={editEvent}
|
||||||
|
editEventId={editEvent.event_id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,6 +146,8 @@ export const c2api = {
|
|||||||
request<{ available_tags: string[] }>(`/trips/${id}/tags`, { method: "PUT", body: JSON.stringify({ available_tags }) }),
|
request<{ available_tags: string[] }>(`/trips/${id}/tags`, { method: "PUT", body: JSON.stringify({ available_tags }) }),
|
||||||
createTripEvent: (tripId: string, body: object) =>
|
createTripEvent: (tripId: string, body: object) =>
|
||||||
request<import("@/lib/types").TripEvent>(`/trips/${tripId}/events`, { method: "POST", body: JSON.stringify(body) }),
|
request<import("@/lib/types").TripEvent>(`/trips/${tripId}/events`, { method: "POST", body: JSON.stringify(body) }),
|
||||||
|
updateTripEvent: (tripId: string, eventId: string, body: object) =>
|
||||||
|
request<import("@/lib/types").TripEvent>(`/trips/${tripId}/events/${eventId}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||||
deleteTripEvent: (tripId: string, eventId: string) =>
|
deleteTripEvent: (tripId: string, eventId: string) =>
|
||||||
request(`/trips/${tripId}/events/${eventId}`, { method: "DELETE" }),
|
request(`/trips/${tripId}/events/${eventId}`, { method: "DELETE" }),
|
||||||
tripChat: (tripId: string, message: string, history: { role: string; content: string }[]) =>
|
tripChat: (tripId: string, message: string, history: { role: string; content: string }[]) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user