release/v0.1.0 #24
32 changed files with 2468 additions and 108 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,3 +2,4 @@
|
||||||
/docker/data
|
/docker/data
|
||||||
/.claude
|
/.claude
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
/coverage
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\CalendarSlot\Policies;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\CalendarSlot;
|
||||||
|
use App\Models\Trip;
|
||||||
|
|
||||||
|
class CalendarSlotPolicy
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user can view calendar slots for the given trip
|
||||||
|
*/
|
||||||
|
public function viewAny(User $user, Trip $trip): bool
|
||||||
|
{
|
||||||
|
return $trip->created_by_user_id === $user->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if the user can update the calendar slot
|
||||||
|
*/
|
||||||
|
public function update(User $user, CalendarSlot $calendarSlot): bool
|
||||||
|
{
|
||||||
|
return $calendarSlot->trip->created_by_user_id === $user->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if the user can reorder items in the calendar slot
|
||||||
|
*/
|
||||||
|
public function reorder(User $user, CalendarSlot $calendarSlot): bool
|
||||||
|
{
|
||||||
|
return $calendarSlot->trip->created_by_user_id === $user->id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\PlannableItem\Policies;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\PlannableItem;
|
||||||
|
use App\Models\Trip;
|
||||||
|
|
||||||
|
class PlannableItemPolicy
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user can create a plannable item for the given trip
|
||||||
|
*/
|
||||||
|
public function create(User $user, Trip $trip): bool
|
||||||
|
{
|
||||||
|
return $trip->created_by_user_id === $user->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if the user can view the plannable item
|
||||||
|
*/
|
||||||
|
public function view(User $user, PlannableItem $plannableItem): bool
|
||||||
|
{
|
||||||
|
return $plannableItem->trip->created_by_user_id === $user->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if the user can update the plannable item
|
||||||
|
*/
|
||||||
|
public function update(User $user, PlannableItem $plannableItem): bool
|
||||||
|
{
|
||||||
|
return $plannableItem->trip->created_by_user_id === $user->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if the user can delete the plannable item
|
||||||
|
*/
|
||||||
|
public function delete(User $user, PlannableItem $plannableItem): bool
|
||||||
|
{
|
||||||
|
return $plannableItem->trip->created_by_user_id === $user->id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\PlannedItem\Actions;
|
||||||
|
|
||||||
|
use App\Domain\Trip\Services\CalendarSlotService;
|
||||||
|
use App\Models\PlannableItem;
|
||||||
|
use App\Models\CalendarSlot;
|
||||||
|
use App\Models\PlannedItem;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
class CreatePlannedItemAction
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private CalendarSlotService $calendarSlotService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new planned item from datetime parameters
|
||||||
|
*
|
||||||
|
* @param array $data ['plannable_item_id', 'trip_id', 'start_datetime', 'end_datetime', 'sort_order'?]
|
||||||
|
* @return CalendarSlot The calendar slot with loaded planned items
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
public function execute(array $data): CalendarSlot
|
||||||
|
{
|
||||||
|
$startDatetime = Carbon::parse($data['start_datetime']);
|
||||||
|
$endDatetime = Carbon::parse($data['end_datetime']);
|
||||||
|
$slotDate = $startDatetime->toDateString();
|
||||||
|
|
||||||
|
// Verify the plannable item belongs to the trip
|
||||||
|
$plannableItem = PlannableItem::findOrFail($data['plannable_item_id']);
|
||||||
|
if ($plannableItem->trip_id !== $data['trip_id']) {
|
||||||
|
throw new \Exception('Plannable item does not belong to this trip');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use database transaction to prevent race conditions
|
||||||
|
$calendarSlot = \DB::transaction(function () use ($data, $plannableItem, $startDatetime, $endDatetime, $slotDate) {
|
||||||
|
// Use firstOrCreate to prevent duplicate slots with same times
|
||||||
|
$calendarSlot = CalendarSlot::firstOrCreate(
|
||||||
|
[
|
||||||
|
'trip_id' => $data['trip_id'],
|
||||||
|
'datetime_start' => $startDatetime,
|
||||||
|
'datetime_end' => $endDatetime,
|
||||||
|
'slot_date' => $slotDate,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => $plannableItem->name,
|
||||||
|
'slot_order' => 0, // Will be recalculated
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Recalculate slot orders to ensure consistency
|
||||||
|
$this->calendarSlotService->recalculateSlotOrdersForDate(
|
||||||
|
$data['trip_id'],
|
||||||
|
$slotDate
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create PlannedItem link (use firstOrCreate to prevent race condition duplicates)
|
||||||
|
PlannedItem::firstOrCreate(
|
||||||
|
[
|
||||||
|
'plannable_item_id' => $data['plannable_item_id'],
|
||||||
|
'calendar_slot_id' => $calendarSlot->id,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'sort_order' => $data['sort_order'] ?? 0,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return $calendarSlot;
|
||||||
|
});
|
||||||
|
|
||||||
|
return $calendarSlot->load(['plannedItems.plannableItem']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create planned item from existing calendar slot (legacy flow)
|
||||||
|
*
|
||||||
|
* @param array $data ['plannable_item_id', 'calendar_slot_id', 'sort_order'?]
|
||||||
|
* @return PlannedItem
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
public function executeFromSlot(array $data): PlannedItem
|
||||||
|
{
|
||||||
|
// Validate that calendar slot and plannable item belong to the same trip
|
||||||
|
$plannableItem = PlannableItem::findOrFail($data['plannable_item_id']);
|
||||||
|
$calendarSlot = CalendarSlot::findOrFail($data['calendar_slot_id']);
|
||||||
|
|
||||||
|
if ($plannableItem->trip_id !== $calendarSlot->trip_id) {
|
||||||
|
throw new \Exception('Calendar slot and plannable item must belong to the same trip');
|
||||||
|
}
|
||||||
|
|
||||||
|
$plannedItem = PlannedItem::updateOrCreate(
|
||||||
|
[
|
||||||
|
'plannable_item_id' => $data['plannable_item_id'],
|
||||||
|
'calendar_slot_id' => $data['calendar_slot_id'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'sort_order' => $data['sort_order'] ?? 0,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return $plannedItem->load(['plannableItem', 'calendarSlot']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\PlannedItem\Actions;
|
||||||
|
|
||||||
|
use App\Models\PlannedItem;
|
||||||
|
|
||||||
|
class DeletePlannedItemAction
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Delete a planned item
|
||||||
|
*
|
||||||
|
* @param PlannedItem $plannedItem
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function execute(PlannedItem $plannedItem): void
|
||||||
|
{
|
||||||
|
$plannedItem->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\PlannedItem\Actions;
|
||||||
|
|
||||||
|
use App\Models\PlannedItem;
|
||||||
|
|
||||||
|
class UpdatePlannedItemAction
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Update a planned item's properties
|
||||||
|
*
|
||||||
|
* @param PlannedItem $plannedItem
|
||||||
|
* @param array $data ['calendar_slot_id'?, 'sort_order'?]
|
||||||
|
* @return PlannedItem
|
||||||
|
*/
|
||||||
|
public function execute(PlannedItem $plannedItem, array $data): PlannedItem
|
||||||
|
{
|
||||||
|
$plannedItem->update($data);
|
||||||
|
|
||||||
|
return $plannedItem->load(['plannableItem', 'calendarSlot']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\PlannedItem\Policies;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\PlannedItem;
|
||||||
|
use App\Models\Trip;
|
||||||
|
|
||||||
|
class PlannedItemPolicy
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user can create a planned item for the given trip
|
||||||
|
*/
|
||||||
|
public function create(User $user, int $tripId): bool
|
||||||
|
{
|
||||||
|
return \DB::table('trips')
|
||||||
|
->where('id', $tripId)
|
||||||
|
->where('created_by_user_id', $user->id)
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if the user can update the planned item
|
||||||
|
*/
|
||||||
|
public function update(User $user, PlannedItem $plannedItem): bool
|
||||||
|
{
|
||||||
|
return \DB::table('planned_items')
|
||||||
|
->join('calendar_slots', 'planned_items.calendar_slot_id', '=', 'calendar_slots.id')
|
||||||
|
->join('trips', 'calendar_slots.trip_id', '=', 'trips.id')
|
||||||
|
->where('planned_items.id', $plannedItem->id)
|
||||||
|
->where('trips.created_by_user_id', $user->id)
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if the user can delete the planned item
|
||||||
|
*/
|
||||||
|
public function delete(User $user, PlannedItem $plannedItem): bool
|
||||||
|
{
|
||||||
|
return $this->update($user, $plannedItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if the user can move a planned item to a new calendar slot
|
||||||
|
*/
|
||||||
|
public function moveToSlot(User $user, int $calendarSlotId): bool
|
||||||
|
{
|
||||||
|
return \DB::table('calendar_slots')
|
||||||
|
->join('trips', 'calendar_slots.trip_id', '=', 'trips.id')
|
||||||
|
->where('calendar_slots.id', $calendarSlotId)
|
||||||
|
->where('trips.created_by_user_id', $user->id)
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
}
|
||||||
33
backend/app/Domain/Trip/Policies/TripPolicy.php
Normal file
33
backend/app/Domain/Trip/Policies/TripPolicy.php
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Trip\Policies;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Trip;
|
||||||
|
|
||||||
|
class TripPolicy
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user can view the trip
|
||||||
|
*/
|
||||||
|
public function view(User $user, Trip $trip): bool
|
||||||
|
{
|
||||||
|
return $trip->created_by_user_id === $user->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if the user can update the trip
|
||||||
|
*/
|
||||||
|
public function update(User $user, Trip $trip): bool
|
||||||
|
{
|
||||||
|
return $trip->created_by_user_id === $user->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if the user can delete the trip
|
||||||
|
*/
|
||||||
|
public function delete(User $user, Trip $trip): bool
|
||||||
|
{
|
||||||
|
return $trip->created_by_user_id === $user->id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -69,4 +69,43 @@ public function deleteSlotsForTrip(Trip $trip): void
|
||||||
{
|
{
|
||||||
$trip->calendarSlots()->delete();
|
$trip->calendarSlots()->delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the slot_order for a new slot based on datetime_start
|
||||||
|
* Orders chronologically by start time within the same day
|
||||||
|
*/
|
||||||
|
public function calculateSlotOrder(int $tripId, string $slotDate, Carbon $datetimeStart): int
|
||||||
|
{
|
||||||
|
$maxOrder = CalendarSlot::where('trip_id', $tripId)
|
||||||
|
->where('slot_date', $slotDate)
|
||||||
|
->where('datetime_start', '<', $datetimeStart)
|
||||||
|
->max('slot_order');
|
||||||
|
|
||||||
|
return ($maxOrder ?? -1) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recalculate slot_order for all slots on a given date
|
||||||
|
* Orders by datetime_start ASC
|
||||||
|
* Uses database transaction with individual updates for safety
|
||||||
|
*/
|
||||||
|
public function recalculateSlotOrdersForDate(int $tripId, string $slotDate): void
|
||||||
|
{
|
||||||
|
$slots = CalendarSlot::where('trip_id', $tripId)
|
||||||
|
->where('slot_date', $slotDate)
|
||||||
|
->orderBy('datetime_start')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($slots->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update each slot's order within a transaction
|
||||||
|
\DB::transaction(function () use ($slots) {
|
||||||
|
foreach ($slots as $index => $slot) {
|
||||||
|
$slot->slot_order = $index;
|
||||||
|
$slot->save();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Infrastructure\Http\Controllers\API\CalendarSlot;
|
namespace App\Infrastructure\Http\Controllers\API\CalendarSlot;
|
||||||
|
|
||||||
|
use App\Domain\CalendarSlot\Policies\CalendarSlotPolicy;
|
||||||
use App\Infrastructure\Http\Controllers\Controller;
|
use App\Infrastructure\Http\Controllers\Controller;
|
||||||
use App\Models\CalendarSlot;
|
use App\Models\CalendarSlot;
|
||||||
use App\Models\Trip;
|
use App\Models\Trip;
|
||||||
|
|
@ -11,16 +12,19 @@
|
||||||
|
|
||||||
class CalendarSlotController extends Controller
|
class CalendarSlotController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private CalendarSlotPolicy $policy
|
||||||
|
) {}
|
||||||
public function index(Trip $trip): JsonResponse
|
public function index(Trip $trip): JsonResponse
|
||||||
{
|
{
|
||||||
// Check if user owns the trip
|
if (!$this->policy->viewAny(auth()->user(), $trip)) {
|
||||||
if ($trip->created_by_user_id !== auth()->id()) {
|
|
||||||
return response()->json(['message' => 'Forbidden'], 403);
|
return response()->json(['message' => 'Forbidden'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
$calendarSlots = $trip->calendarSlots()
|
$calendarSlots = $trip->calendarSlots()
|
||||||
->with(['plannableItems'])
|
->with(['plannedItems.plannableItem'])
|
||||||
->orderBy('slot_order')
|
->orderBy('slot_date')
|
||||||
|
->orderBy('datetime_start')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
return response()->json(['data' => $calendarSlots]);
|
return response()->json(['data' => $calendarSlots]);
|
||||||
|
|
@ -28,8 +32,7 @@ public function index(Trip $trip): JsonResponse
|
||||||
|
|
||||||
public function update(Request $request, CalendarSlot $calendarSlot): JsonResponse
|
public function update(Request $request, CalendarSlot $calendarSlot): JsonResponse
|
||||||
{
|
{
|
||||||
// Check if user owns the trip
|
if (!$this->policy->update(auth()->user(), $calendarSlot)) {
|
||||||
if ($calendarSlot->trip->created_by_user_id !== auth()->id()) {
|
|
||||||
return response()->json(['message' => 'Forbidden'], 403);
|
return response()->json(['message' => 'Forbidden'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,18 +47,35 @@ public function update(Request $request, CalendarSlot $calendarSlot): JsonRespon
|
||||||
|
|
||||||
public function reorder(Request $request, CalendarSlot $calendarSlot): JsonResponse
|
public function reorder(Request $request, CalendarSlot $calendarSlot): JsonResponse
|
||||||
{
|
{
|
||||||
|
if (!$this->policy->reorder(auth()->user(), $calendarSlot)) {
|
||||||
|
return response()->json(['message' => 'Forbidden'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'items' => 'required|array',
|
'items' => 'required|array',
|
||||||
'items.*.plannable_item_id' => 'required|exists:plannable_items,id',
|
'items.*.plannable_item_id' => 'required|exists:plannable_items,id',
|
||||||
'items.*.sort_order' => 'required|integer',
|
'items.*.sort_order' => 'required|integer',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Validate all plannable items belong to the same trip
|
||||||
|
$trip = $calendarSlot->trip;
|
||||||
|
$validPlannableItemIds = $trip->plannableItems()->pluck('id')->toArray();
|
||||||
|
|
||||||
foreach ($validated['items'] as $item) {
|
foreach ($validated['items'] as $item) {
|
||||||
PlannedItem::where('calendar_slot_id', $calendarSlot->id)
|
if (!in_array($item['plannable_item_id'], $validPlannableItemIds)) {
|
||||||
->where('plannable_item_id', $item['plannable_item_id'])
|
return response()->json(['message' => 'Invalid plannable item for this trip'], 422);
|
||||||
->update(['sort_order' => $item['sort_order']]);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update sort orders in transaction
|
||||||
|
\DB::transaction(function () use ($validated, $calendarSlot) {
|
||||||
|
foreach ($validated['items'] as $item) {
|
||||||
|
PlannedItem::where('calendar_slot_id', $calendarSlot->id)
|
||||||
|
->where('plannable_item_id', $item['plannable_item_id'])
|
||||||
|
->update(['sort_order' => $item['sort_order']]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return response()->json(['message' => 'Items reordered successfully']);
|
return response()->json(['message' => 'Items reordered successfully']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Infrastructure\Http\Controllers\API\PlannableItem;
|
namespace App\Infrastructure\Http\Controllers\API\PlannableItem;
|
||||||
|
|
||||||
|
use App\Domain\PlannableItem\Policies\PlannableItemPolicy;
|
||||||
use App\Infrastructure\Http\Controllers\Controller;
|
use App\Infrastructure\Http\Controllers\Controller;
|
||||||
use App\Models\PlannableItem;
|
use App\Models\PlannableItem;
|
||||||
use App\Models\Trip;
|
use App\Models\Trip;
|
||||||
|
|
@ -10,10 +11,12 @@
|
||||||
|
|
||||||
class PlannableItemController extends Controller
|
class PlannableItemController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private PlannableItemPolicy $policy
|
||||||
|
) {}
|
||||||
public function index(Trip $trip): JsonResponse
|
public function index(Trip $trip): JsonResponse
|
||||||
{
|
{
|
||||||
// Check if user owns the trip
|
if (!$this->policy->create(auth()->user(), $trip)) {
|
||||||
if ($trip->created_by_user_id !== auth()->id()) {
|
|
||||||
return response()->json(['message' => 'Forbidden'], 403);
|
return response()->json(['message' => 'Forbidden'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -26,8 +29,7 @@ public function index(Trip $trip): JsonResponse
|
||||||
|
|
||||||
public function store(Request $request, Trip $trip): JsonResponse
|
public function store(Request $request, Trip $trip): JsonResponse
|
||||||
{
|
{
|
||||||
// Check if user owns the trip
|
if (!$this->policy->create(auth()->user(), $trip)) {
|
||||||
if ($trip->created_by_user_id !== auth()->id()) {
|
|
||||||
return response()->json(['message' => 'Forbidden'], 403);
|
return response()->json(['message' => 'Forbidden'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,14 +48,17 @@ public function store(Request $request, Trip $trip): JsonResponse
|
||||||
|
|
||||||
public function show(PlannableItem $plannableItem): JsonResponse
|
public function show(PlannableItem $plannableItem): JsonResponse
|
||||||
{
|
{
|
||||||
|
if (!$this->policy->view(auth()->user(), $plannableItem)) {
|
||||||
|
return response()->json(['message' => 'Forbidden'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
$plannableItem->load(['calendarSlots', 'trip']);
|
$plannableItem->load(['calendarSlots', 'trip']);
|
||||||
return response()->json($plannableItem);
|
return response()->json($plannableItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(Request $request, PlannableItem $plannableItem): JsonResponse
|
public function update(Request $request, PlannableItem $plannableItem): JsonResponse
|
||||||
{
|
{
|
||||||
// Check if user owns the trip
|
if (!$this->policy->update(auth()->user(), $plannableItem)) {
|
||||||
if ($plannableItem->trip->created_by_user_id !== auth()->id()) {
|
|
||||||
return response()->json(['message' => 'Forbidden'], 403);
|
return response()->json(['message' => 'Forbidden'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,6 +77,10 @@ public function update(Request $request, PlannableItem $plannableItem): JsonResp
|
||||||
|
|
||||||
public function destroy(PlannableItem $plannableItem): JsonResponse
|
public function destroy(PlannableItem $plannableItem): JsonResponse
|
||||||
{
|
{
|
||||||
|
if (!$this->policy->delete(auth()->user(), $plannableItem)) {
|
||||||
|
return response()->json(['message' => 'Forbidden'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
$plannableItem->delete();
|
$plannableItem->delete();
|
||||||
|
|
||||||
return response()->json(null, 204);
|
return response()->json(null, 204);
|
||||||
|
|
|
||||||
|
|
@ -2,51 +2,90 @@
|
||||||
|
|
||||||
namespace App\Infrastructure\Http\Controllers\API\PlannedItem;
|
namespace App\Infrastructure\Http\Controllers\API\PlannedItem;
|
||||||
|
|
||||||
|
use App\Domain\PlannedItem\Actions\CreatePlannedItemAction;
|
||||||
|
use App\Domain\PlannedItem\Actions\UpdatePlannedItemAction;
|
||||||
|
use App\Domain\PlannedItem\Actions\DeletePlannedItemAction;
|
||||||
|
use App\Domain\PlannedItem\Policies\PlannedItemPolicy;
|
||||||
use App\Infrastructure\Http\Controllers\Controller;
|
use App\Infrastructure\Http\Controllers\Controller;
|
||||||
use App\Models\PlannedItem;
|
use App\Models\PlannedItem;
|
||||||
use App\Models\PlannableItem;
|
|
||||||
use App\Models\CalendarSlot;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
class PlannedItemController extends Controller
|
class PlannedItemController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private CreatePlannedItemAction $createAction,
|
||||||
|
private UpdatePlannedItemAction $updateAction,
|
||||||
|
private DeletePlannedItemAction $deleteAction,
|
||||||
|
private PlannedItemPolicy $policy
|
||||||
|
) {}
|
||||||
|
|
||||||
public function store(Request $request): JsonResponse
|
public function store(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'plannable_item_id' => 'required|exists:plannable_items,id',
|
'plannable_item_id' => 'required|exists:plannable_items,id',
|
||||||
'calendar_slot_id' => 'required|exists:calendar_slots,id',
|
'trip_id' => 'required|exists:trips,id',
|
||||||
|
'start_datetime' => 'required|date',
|
||||||
|
'end_datetime' => 'required|date|after:start_datetime',
|
||||||
|
'calendar_slot_id' => 'sometimes|exists:calendar_slots,id',
|
||||||
'sort_order' => 'nullable|integer',
|
'sort_order' => 'nullable|integer',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$plannedItem = PlannedItem::updateOrCreate(
|
// Check authorization using Policy
|
||||||
[
|
if (!$this->policy->create(auth()->user(), $validated['trip_id'])) {
|
||||||
'plannable_item_id' => $validated['plannable_item_id'],
|
return response()->json(['message' => 'Forbidden'], 403);
|
||||||
'calendar_slot_id' => $validated['calendar_slot_id'],
|
}
|
||||||
],
|
|
||||||
[
|
|
||||||
'sort_order' => $validated['sort_order'] ?? 0,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json($plannedItem->load(['plannableItem', 'calendarSlot']), 201);
|
// If calendar_slot_id is provided, use existing flow (backward compatibility)
|
||||||
|
if (isset($validated['calendar_slot_id'])) {
|
||||||
|
$plannedItem = $this->createAction->executeFromSlot($validated);
|
||||||
|
return response()->json($plannedItem, 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
// New flow: Create CalendarSlot from datetime using Action
|
||||||
|
try {
|
||||||
|
$calendarSlot = $this->createAction->execute($validated);
|
||||||
|
return response()->json($calendarSlot, 201);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return response()->json(['message' => $e->getMessage()], 422);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(Request $request, PlannedItem $plannedItem): JsonResponse
|
public function update(Request $request, PlannedItem $plannedItem): JsonResponse
|
||||||
{
|
{
|
||||||
|
// Check authorization using Policy
|
||||||
|
if (!$this->policy->update(auth()->user(), $plannedItem)) {
|
||||||
|
return response()->json(['message' => 'Forbidden'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'calendar_slot_id' => 'sometimes|required|exists:calendar_slots,id',
|
'calendar_slot_id' => 'sometimes|required|exists:calendar_slots,id',
|
||||||
'sort_order' => 'nullable|integer',
|
'sort_order' => 'nullable|integer',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$plannedItem->update($validated);
|
// If changing calendar slot, verify user owns the new slot's trip
|
||||||
|
if (isset($validated['calendar_slot_id'])) {
|
||||||
|
if (!$this->policy->moveToSlot(auth()->user(), $validated['calendar_slot_id'])) {
|
||||||
|
return response()->json(['message' => 'Forbidden'], 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json($plannedItem->load(['plannableItem', 'calendarSlot']));
|
// Execute update using Action
|
||||||
|
$updatedItem = $this->updateAction->execute($plannedItem, $validated);
|
||||||
|
|
||||||
|
return response()->json($updatedItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(PlannedItem $plannedItem): JsonResponse
|
public function destroy(PlannedItem $plannedItem): JsonResponse
|
||||||
{
|
{
|
||||||
$plannedItem->delete();
|
// Check authorization using Policy
|
||||||
|
if (!$this->policy->delete(auth()->user(), $plannedItem)) {
|
||||||
|
return response()->json(['message' => 'Forbidden'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute delete using Action
|
||||||
|
$this->deleteAction->execute($plannedItem);
|
||||||
|
|
||||||
return response()->json(null, 204);
|
return response()->json(null, 204);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,17 @@
|
||||||
|
|
||||||
namespace App\Infrastructure\Http\Controllers\API\Trip;
|
namespace App\Infrastructure\Http\Controllers\API\Trip;
|
||||||
|
|
||||||
|
use App\Domain\Trip\Policies\TripPolicy;
|
||||||
use App\Infrastructure\Http\Controllers\Controller;
|
use App\Infrastructure\Http\Controllers\Controller;
|
||||||
use App\Models\Trip;
|
use App\Models\Trip;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Validation\ValidationException;
|
|
||||||
|
|
||||||
class TripController extends Controller
|
class TripController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private TripPolicy $policy
|
||||||
|
) {}
|
||||||
/**
|
/**
|
||||||
* Display a listing of the resource.
|
* Display a listing of the resource.
|
||||||
*/
|
*/
|
||||||
|
|
@ -44,11 +47,11 @@ public function store(Request $request): JsonResponse
|
||||||
/**
|
/**
|
||||||
* Display the specified resource.
|
* Display the specified resource.
|
||||||
*/
|
*/
|
||||||
public function show(Request $request, string $id): JsonResponse
|
public function show(Request $request, Trip $trip): JsonResponse
|
||||||
{
|
{
|
||||||
$trip = Trip::where('id', $id)
|
if (!$this->policy->view($request->user(), $trip)) {
|
||||||
->where('created_by_user_id', $request->user()->id)
|
return response()->json(['message' => 'Forbidden'], 403);
|
||||||
->firstOrFail();
|
}
|
||||||
|
|
||||||
return response()->json(['data' => $trip]);
|
return response()->json(['data' => $trip]);
|
||||||
}
|
}
|
||||||
|
|
@ -56,11 +59,11 @@ public function show(Request $request, string $id): JsonResponse
|
||||||
/**
|
/**
|
||||||
* Update the specified resource in storage.
|
* Update the specified resource in storage.
|
||||||
*/
|
*/
|
||||||
public function update(Request $request, string $id): JsonResponse
|
public function update(Request $request, Trip $trip): JsonResponse
|
||||||
{
|
{
|
||||||
$trip = Trip::where('id', $id)
|
if (!$this->policy->update($request->user(), $trip)) {
|
||||||
->where('created_by_user_id', $request->user()->id)
|
return response()->json(['message' => 'Forbidden'], 403);
|
||||||
->firstOrFail();
|
}
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => 'required|string|max:255',
|
'name' => 'required|string|max:255',
|
||||||
|
|
@ -77,11 +80,11 @@ public function update(Request $request, string $id): JsonResponse
|
||||||
/**
|
/**
|
||||||
* Remove the specified resource from storage.
|
* Remove the specified resource from storage.
|
||||||
*/
|
*/
|
||||||
public function destroy(Request $request, string $id): JsonResponse
|
public function destroy(Request $request, Trip $trip): JsonResponse
|
||||||
{
|
{
|
||||||
$trip = Trip::where('id', $id)
|
if (!$this->policy->delete($request->user(), $trip)) {
|
||||||
->where('created_by_user_id', $request->user()->id)
|
return response()->json(['message' => 'Forbidden'], 403);
|
||||||
->firstOrFail();
|
}
|
||||||
|
|
||||||
$trip->delete();
|
$trip->delete();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,9 @@ public function up(): void
|
||||||
$table->integer('slot_order')->default(0);
|
$table->integer('slot_order')->default(0);
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
|
|
||||||
$table->index(['trip_id', 'slot_date']);
|
// Composite indexes for performance
|
||||||
$table->index(['trip_id', 'slot_order']);
|
$table->index(['trip_id', 'slot_date', 'datetime_start']);
|
||||||
|
$table->index(['trip_id', 'slot_date', 'slot_order']);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -227,4 +227,110 @@ public function test_slots_created_when_dates_added_to_trip()
|
||||||
$trip->refresh();
|
$trip->refresh();
|
||||||
$this->assertCount(2, $trip->calendarSlots);
|
$this->assertCount(2, $trip->calendarSlots);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_calendar_slots_include_planned_items_with_plannable_item()
|
||||||
|
{
|
||||||
|
$trip = Trip::factory()->create([
|
||||||
|
'created_by_user_id' => $this->user->id,
|
||||||
|
'start_date' => '2024-01-01',
|
||||||
|
'end_date' => '2024-01-01'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create a plannable item and schedule it
|
||||||
|
$plannableItem = \App\Models\PlannableItem::factory()->create([
|
||||||
|
'trip_id' => $trip->id,
|
||||||
|
'name' => 'Eiffel Tower'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'Authorization' => 'Bearer ' . $this->token,
|
||||||
|
'Accept' => 'application/json'
|
||||||
|
])->postJson('/api/planned-items', [
|
||||||
|
'plannable_item_id' => $plannableItem->id,
|
||||||
|
'trip_id' => $trip->id,
|
||||||
|
'start_datetime' => '2024-01-01 14:00:00',
|
||||||
|
'end_datetime' => '2024-01-01 16:00:00',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(201);
|
||||||
|
|
||||||
|
// Fetch calendar slots and verify relationships are loaded
|
||||||
|
$slotsResponse = $this->withHeaders([
|
||||||
|
'Authorization' => 'Bearer ' . $this->token,
|
||||||
|
'Accept' => 'application/json'
|
||||||
|
])->getJson("/api/trips/{$trip->id}/calendar-slots");
|
||||||
|
|
||||||
|
$slotsResponse->assertStatus(200);
|
||||||
|
|
||||||
|
// Find the slot we just created
|
||||||
|
$slots = $slotsResponse->json('data');
|
||||||
|
$scheduledSlot = collect($slots)->firstWhere('name', 'Eiffel Tower');
|
||||||
|
|
||||||
|
$this->assertNotNull($scheduledSlot);
|
||||||
|
$this->assertArrayHasKey('planned_items', $scheduledSlot);
|
||||||
|
$this->assertCount(1, $scheduledSlot['planned_items']);
|
||||||
|
$this->assertArrayHasKey('plannable_item', $scheduledSlot['planned_items'][0]);
|
||||||
|
$this->assertEquals('Eiffel Tower', $scheduledSlot['planned_items'][0]['plannable_item']['name']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_calendar_slots_ordered_by_date_and_time()
|
||||||
|
{
|
||||||
|
$trip = Trip::factory()->create([
|
||||||
|
'created_by_user_id' => $this->user->id,
|
||||||
|
'start_date' => '2024-01-01',
|
||||||
|
'end_date' => '2024-01-02'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create plannable items
|
||||||
|
$item1 = \App\Models\PlannableItem::factory()->create(['trip_id' => $trip->id, 'name' => 'Breakfast']);
|
||||||
|
$item2 = \App\Models\PlannableItem::factory()->create(['trip_id' => $trip->id, 'name' => 'Lunch']);
|
||||||
|
$item3 = \App\Models\PlannableItem::factory()->create(['trip_id' => $trip->id, 'name' => 'Dinner Day 1']);
|
||||||
|
$item4 = \App\Models\PlannableItem::factory()->create(['trip_id' => $trip->id, 'name' => 'Breakfast Day 2']);
|
||||||
|
|
||||||
|
// Schedule in non-chronological order
|
||||||
|
$this->actingAs($this->user)->postJson('/api/planned-items', [
|
||||||
|
'plannable_item_id' => $item3->id,
|
||||||
|
'trip_id' => $trip->id,
|
||||||
|
'start_datetime' => '2024-01-01 19:00:00',
|
||||||
|
'end_datetime' => '2024-01-01 21:00:00',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)->postJson('/api/planned-items', [
|
||||||
|
'plannable_item_id' => $item1->id,
|
||||||
|
'trip_id' => $trip->id,
|
||||||
|
'start_datetime' => '2024-01-01 08:00:00',
|
||||||
|
'end_datetime' => '2024-01-01 09:00:00',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)->postJson('/api/planned-items', [
|
||||||
|
'plannable_item_id' => $item4->id,
|
||||||
|
'trip_id' => $trip->id,
|
||||||
|
'start_datetime' => '2024-01-02 08:00:00',
|
||||||
|
'end_datetime' => '2024-01-02 09:00:00',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)->postJson('/api/planned-items', [
|
||||||
|
'plannable_item_id' => $item2->id,
|
||||||
|
'trip_id' => $trip->id,
|
||||||
|
'start_datetime' => '2024-01-01 12:00:00',
|
||||||
|
'end_datetime' => '2024-01-01 13:00:00',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Fetch slots and verify they're ordered correctly
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'Authorization' => 'Bearer ' . $this->token,
|
||||||
|
'Accept' => 'application/json'
|
||||||
|
])->getJson("/api/trips/{$trip->id}/calendar-slots");
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
|
||||||
|
$slots = $response->json('data');
|
||||||
|
$scheduledSlots = collect($slots)->filter(fn($slot) => $slot['datetime_start'] !== null)->values();
|
||||||
|
|
||||||
|
// Should be ordered: Breakfast (Day 1, 8am), Lunch (Day 1, 12pm), Dinner (Day 1, 7pm), Breakfast (Day 2, 8am)
|
||||||
|
$this->assertEquals('Breakfast', $scheduledSlots[0]['name']);
|
||||||
|
$this->assertEquals('Lunch', $scheduledSlots[1]['name']);
|
||||||
|
$this->assertEquals('Dinner Day 1', $scheduledSlots[2]['name']);
|
||||||
|
$this->assertEquals('Breakfast Day 2', $scheduledSlots[3]['name']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
321
backend/tests/Feature/PlannedItemTest.php
Normal file
321
backend/tests/Feature/PlannedItemTest.php
Normal file
|
|
@ -0,0 +1,321 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Trip;
|
||||||
|
use App\Models\PlannableItem;
|
||||||
|
use App\Models\CalendarSlot;
|
||||||
|
use App\Models\PlannedItem;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class PlannedItemTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private User $user;
|
||||||
|
private Trip $trip;
|
||||||
|
private PlannableItem $plannableItem;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->user = User::factory()->create();
|
||||||
|
$this->trip = Trip::factory()->create([
|
||||||
|
'created_by_user_id' => $this->user->id,
|
||||||
|
'start_date' => '2025-01-15',
|
||||||
|
'end_date' => '2025-01-17',
|
||||||
|
]);
|
||||||
|
$this->plannableItem = PlannableItem::factory()->create([
|
||||||
|
'trip_id' => $this->trip->id,
|
||||||
|
'name' => 'Eiffel Tower',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_creates_calendar_slot_when_scheduling_item_with_datetime()
|
||||||
|
{
|
||||||
|
$response = $this->actingAs($this->user)->postJson('/api/planned-items', [
|
||||||
|
'plannable_item_id' => $this->plannableItem->id,
|
||||||
|
'trip_id' => $this->trip->id,
|
||||||
|
'start_datetime' => '2025-01-15 14:00:00',
|
||||||
|
'end_datetime' => '2025-01-15 16:00:00',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(201);
|
||||||
|
$this->assertDatabaseHas('calendar_slots', [
|
||||||
|
'trip_id' => $this->trip->id,
|
||||||
|
'name' => 'Eiffel Tower',
|
||||||
|
'slot_date' => '2025-01-15',
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('planned_items', [
|
||||||
|
'plannable_item_id' => $this->plannableItem->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_calculates_slot_order_correctly()
|
||||||
|
{
|
||||||
|
// Create first slot at 10:00
|
||||||
|
$this->actingAs($this->user)->postJson('/api/planned-items', [
|
||||||
|
'plannable_item_id' => $this->plannableItem->id,
|
||||||
|
'trip_id' => $this->trip->id,
|
||||||
|
'start_datetime' => '2025-01-15 10:00:00',
|
||||||
|
'end_datetime' => '2025-01-15 11:00:00',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create another plannable item
|
||||||
|
$secondItem = PlannableItem::factory()->create([
|
||||||
|
'trip_id' => $this->trip->id,
|
||||||
|
'name' => 'Louvre',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create second slot at 14:00
|
||||||
|
$this->actingAs($this->user)->postJson('/api/planned-items', [
|
||||||
|
'plannable_item_id' => $secondItem->id,
|
||||||
|
'trip_id' => $this->trip->id,
|
||||||
|
'start_datetime' => '2025-01-15 14:00:00',
|
||||||
|
'end_datetime' => '2025-01-15 16:00:00',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create third slot at 08:00 (should have slot_order 0 after recalculation)
|
||||||
|
$thirdItem = PlannableItem::factory()->create([
|
||||||
|
'trip_id' => $this->trip->id,
|
||||||
|
'name' => 'Breakfast',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($this->user)->postJson('/api/planned-items', [
|
||||||
|
'plannable_item_id' => $thirdItem->id,
|
||||||
|
'trip_id' => $this->trip->id,
|
||||||
|
'start_datetime' => '2025-01-15 08:00:00',
|
||||||
|
'end_datetime' => '2025-01-15 09:00:00',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$slots = CalendarSlot::where('trip_id', $this->trip->id)
|
||||||
|
->where('slot_date', '2025-01-15')
|
||||||
|
->orderBy('slot_order')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// After recalculation, order should be by datetime_start
|
||||||
|
$this->assertEquals('Breakfast', $slots[0]->name);
|
||||||
|
$this->assertEquals(0, $slots[0]->slot_order);
|
||||||
|
$this->assertEquals('Eiffel Tower', $slots[1]->name);
|
||||||
|
$this->assertEquals(1, $slots[1]->slot_order);
|
||||||
|
$this->assertEquals('Louvre', $slots[2]->name);
|
||||||
|
$this->assertEquals(2, $slots[2]->slot_order);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_validates_end_time_is_after_start_time()
|
||||||
|
{
|
||||||
|
$response = $this->actingAs($this->user)->postJson('/api/planned-items', [
|
||||||
|
'plannable_item_id' => $this->plannableItem->id,
|
||||||
|
'trip_id' => $this->trip->id,
|
||||||
|
'start_datetime' => '2025-01-15 16:00:00',
|
||||||
|
'end_datetime' => '2025-01-15 14:00:00',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(422);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_requires_plannable_item_to_belong_to_trip()
|
||||||
|
{
|
||||||
|
$otherTrip = Trip::factory()->create([
|
||||||
|
'created_by_user_id' => $this->user->id,
|
||||||
|
]);
|
||||||
|
$otherItem = PlannableItem::factory()->create([
|
||||||
|
'trip_id' => $otherTrip->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->user)->postJson('/api/planned-items', [
|
||||||
|
'plannable_item_id' => $otherItem->id,
|
||||||
|
'trip_id' => $this->trip->id,
|
||||||
|
'start_datetime' => '2025-01-15 14:00:00',
|
||||||
|
'end_datetime' => '2025-01-15 16:00:00',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(422);
|
||||||
|
$response->assertJson([
|
||||||
|
'error' => 'Plannable item does not belong to this trip'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_supports_backward_compatible_calendar_slot_id_flow()
|
||||||
|
{
|
||||||
|
$calendarSlot = CalendarSlot::factory()->create([
|
||||||
|
'trip_id' => $this->trip->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->user)->postJson('/api/planned-items', [
|
||||||
|
'plannable_item_id' => $this->plannableItem->id,
|
||||||
|
'calendar_slot_id' => $calendarSlot->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(201);
|
||||||
|
$this->assertDatabaseHas('planned_items', [
|
||||||
|
'plannable_item_id' => $this->plannableItem->id,
|
||||||
|
'calendar_slot_id' => $calendarSlot->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_requires_authentication_to_create_planned_item()
|
||||||
|
{
|
||||||
|
$response = $this->postJson('/api/planned-items', [
|
||||||
|
'plannable_item_id' => $this->plannableItem->id,
|
||||||
|
'trip_id' => $this->trip->id,
|
||||||
|
'start_datetime' => '2025-01-15 14:00:00',
|
||||||
|
'end_datetime' => '2025-01-15 16:00:00',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_validates_required_fields()
|
||||||
|
{
|
||||||
|
$response = $this->actingAs($this->user)->postJson('/api/planned-items', []);
|
||||||
|
|
||||||
|
$response->assertStatus(422);
|
||||||
|
$response->assertJsonValidationErrors(['plannable_item_id', 'trip_id', 'start_datetime', 'end_datetime']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_validates_plannable_item_exists()
|
||||||
|
{
|
||||||
|
$response = $this->actingAs($this->user)->postJson('/api/planned-items', [
|
||||||
|
'plannable_item_id' => 99999,
|
||||||
|
'trip_id' => $this->trip->id,
|
||||||
|
'start_datetime' => '2025-01-15 14:00:00',
|
||||||
|
'end_datetime' => '2025-01-15 16:00:00',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(422);
|
||||||
|
$response->assertJsonValidationErrors(['plannable_item_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_validates_trip_exists()
|
||||||
|
{
|
||||||
|
$response = $this->actingAs($this->user)->postJson('/api/planned-items', [
|
||||||
|
'plannable_item_id' => $this->plannableItem->id,
|
||||||
|
'trip_id' => 99999,
|
||||||
|
'start_datetime' => '2025-01-15 14:00:00',
|
||||||
|
'end_datetime' => '2025-01-15 16:00:00',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(422);
|
||||||
|
$response->assertJsonValidationErrors(['trip_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_returns_calendar_slot_with_relationships()
|
||||||
|
{
|
||||||
|
$response = $this->actingAs($this->user)->postJson('/api/planned-items', [
|
||||||
|
'plannable_item_id' => $this->plannableItem->id,
|
||||||
|
'trip_id' => $this->trip->id,
|
||||||
|
'start_datetime' => '2025-01-15 14:00:00',
|
||||||
|
'end_datetime' => '2025-01-15 16:00:00',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(201);
|
||||||
|
$response->assertJsonStructure([
|
||||||
|
'id',
|
||||||
|
'trip_id',
|
||||||
|
'name',
|
||||||
|
'datetime_start',
|
||||||
|
'datetime_end',
|
||||||
|
'slot_date',
|
||||||
|
'slot_order',
|
||||||
|
'planned_items' => [
|
||||||
|
'*' => [
|
||||||
|
'id',
|
||||||
|
'plannable_item_id',
|
||||||
|
'calendar_slot_id',
|
||||||
|
'plannable_item' => [
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'trip_id',
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_can_update_planned_item_slot()
|
||||||
|
{
|
||||||
|
$calendarSlot1 = CalendarSlot::factory()->create(['trip_id' => $this->trip->id]);
|
||||||
|
$calendarSlot2 = CalendarSlot::factory()->create(['trip_id' => $this->trip->id]);
|
||||||
|
|
||||||
|
$plannedItem = PlannedItem::create([
|
||||||
|
'plannable_item_id' => $this->plannableItem->id,
|
||||||
|
'calendar_slot_id' => $calendarSlot1->id,
|
||||||
|
'sort_order' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->user)->putJson("/api/planned-items/{$plannedItem->id}", [
|
||||||
|
'calendar_slot_id' => $calendarSlot2->id,
|
||||||
|
'sort_order' => 5,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$this->assertDatabaseHas('planned_items', [
|
||||||
|
'id' => $plannedItem->id,
|
||||||
|
'calendar_slot_id' => $calendarSlot2->id,
|
||||||
|
'sort_order' => 5,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_can_delete_planned_item()
|
||||||
|
{
|
||||||
|
$calendarSlot = CalendarSlot::factory()->create(['trip_id' => $this->trip->id]);
|
||||||
|
$plannedItem = PlannedItem::create([
|
||||||
|
'plannable_item_id' => $this->plannableItem->id,
|
||||||
|
'calendar_slot_id' => $calendarSlot->id,
|
||||||
|
'sort_order' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->user)->deleteJson("/api/planned-items/{$plannedItem->id}");
|
||||||
|
|
||||||
|
$response->assertStatus(204);
|
||||||
|
$this->assertDatabaseMissing('planned_items', [
|
||||||
|
'id' => $plannedItem->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_requires_authentication_to_update_planned_item()
|
||||||
|
{
|
||||||
|
$calendarSlot = CalendarSlot::factory()->create(['trip_id' => $this->trip->id]);
|
||||||
|
$plannedItem = PlannedItem::create([
|
||||||
|
'plannable_item_id' => $this->plannableItem->id,
|
||||||
|
'calendar_slot_id' => $calendarSlot->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->putJson("/api/planned-items/{$plannedItem->id}", [
|
||||||
|
'sort_order' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_requires_authentication_to_delete_planned_item()
|
||||||
|
{
|
||||||
|
$calendarSlot = CalendarSlot::factory()->create(['trip_id' => $this->trip->id]);
|
||||||
|
$plannedItem = PlannedItem::create([
|
||||||
|
'plannable_item_id' => $this->plannableItem->id,
|
||||||
|
'calendar_slot_id' => $calendarSlot->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->deleteJson("/api/planned-items/{$plannedItem->id}");
|
||||||
|
|
||||||
|
$response->assertStatus(401);
|
||||||
|
}
|
||||||
|
}
|
||||||
235
backend/tests/Unit/CalendarSlotServiceTest.php
Normal file
235
backend/tests/Unit/CalendarSlotServiceTest.php
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use App\Domain\Trip\Services\CalendarSlotService;
|
||||||
|
use App\Models\Trip;
|
||||||
|
use App\Models\CalendarSlot;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
class CalendarSlotServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private CalendarSlotService $service;
|
||||||
|
private Trip $trip;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->service = new CalendarSlotService();
|
||||||
|
$this->trip = Trip::factory()->create([
|
||||||
|
'start_date' => '2024-01-01',
|
||||||
|
'end_date' => '2024-01-03',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_calculates_slot_order_for_first_slot_on_date()
|
||||||
|
{
|
||||||
|
$order = $this->service->calculateSlotOrder(
|
||||||
|
$this->trip->id,
|
||||||
|
'2024-01-01',
|
||||||
|
Carbon::parse('2024-01-01 10:00:00')
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(0, $order);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_calculates_slot_order_based_on_chronological_position()
|
||||||
|
{
|
||||||
|
// Create existing slots
|
||||||
|
CalendarSlot::create([
|
||||||
|
'trip_id' => $this->trip->id,
|
||||||
|
'name' => 'Morning',
|
||||||
|
'datetime_start' => '2024-01-01 08:00:00',
|
||||||
|
'datetime_end' => '2024-01-01 09:00:00',
|
||||||
|
'slot_date' => '2024-01-01',
|
||||||
|
'slot_order' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
CalendarSlot::create([
|
||||||
|
'trip_id' => $this->trip->id,
|
||||||
|
'name' => 'Afternoon',
|
||||||
|
'datetime_start' => '2024-01-01 14:00:00',
|
||||||
|
'datetime_end' => '2024-01-01 15:00:00',
|
||||||
|
'slot_date' => '2024-01-01',
|
||||||
|
'slot_order' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Calculate order for noon slot (should be between morning and afternoon)
|
||||||
|
$order = $this->service->calculateSlotOrder(
|
||||||
|
$this->trip->id,
|
||||||
|
'2024-01-01',
|
||||||
|
Carbon::parse('2024-01-01 12:00:00')
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(1, $order);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_calculates_slot_order_for_earliest_time()
|
||||||
|
{
|
||||||
|
// Create existing slot
|
||||||
|
CalendarSlot::create([
|
||||||
|
'trip_id' => $this->trip->id,
|
||||||
|
'name' => 'Late Morning',
|
||||||
|
'datetime_start' => '2024-01-01 10:00:00',
|
||||||
|
'datetime_end' => '2024-01-01 11:00:00',
|
||||||
|
'slot_date' => '2024-01-01',
|
||||||
|
'slot_order' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Calculate order for earlier time
|
||||||
|
$order = $this->service->calculateSlotOrder(
|
||||||
|
$this->trip->id,
|
||||||
|
'2024-01-01',
|
||||||
|
Carbon::parse('2024-01-01 08:00:00')
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(0, $order);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_calculates_slot_order_for_latest_time()
|
||||||
|
{
|
||||||
|
// Create existing slots
|
||||||
|
CalendarSlot::create([
|
||||||
|
'trip_id' => $this->trip->id,
|
||||||
|
'name' => 'Morning',
|
||||||
|
'datetime_start' => '2024-01-01 08:00:00',
|
||||||
|
'datetime_end' => '2024-01-01 09:00:00',
|
||||||
|
'slot_date' => '2024-01-01',
|
||||||
|
'slot_order' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
CalendarSlot::create([
|
||||||
|
'trip_id' => $this->trip->id,
|
||||||
|
'name' => 'Noon',
|
||||||
|
'datetime_start' => '2024-01-01 12:00:00',
|
||||||
|
'datetime_end' => '2024-01-01 13:00:00',
|
||||||
|
'slot_date' => '2024-01-01',
|
||||||
|
'slot_order' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Calculate order for later time
|
||||||
|
$order = $this->service->calculateSlotOrder(
|
||||||
|
$this->trip->id,
|
||||||
|
'2024-01-01',
|
||||||
|
Carbon::parse('2024-01-01 20:00:00')
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(2, $order);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_only_considers_slots_from_same_date()
|
||||||
|
{
|
||||||
|
// Create slot on different date
|
||||||
|
CalendarSlot::create([
|
||||||
|
'trip_id' => $this->trip->id,
|
||||||
|
'name' => 'Other Day',
|
||||||
|
'datetime_start' => '2024-01-02 08:00:00',
|
||||||
|
'datetime_end' => '2024-01-02 09:00:00',
|
||||||
|
'slot_date' => '2024-01-02',
|
||||||
|
'slot_order' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Calculate order for Jan 1 (should start at 0, not affected by Jan 2)
|
||||||
|
$order = $this->service->calculateSlotOrder(
|
||||||
|
$this->trip->id,
|
||||||
|
'2024-01-01',
|
||||||
|
Carbon::parse('2024-01-01 10:00:00')
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(0, $order);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_recalculates_slot_orders_for_date()
|
||||||
|
{
|
||||||
|
// Create slots in non-chronological slot_order
|
||||||
|
CalendarSlot::create([
|
||||||
|
'trip_id' => $this->trip->id,
|
||||||
|
'name' => 'Evening',
|
||||||
|
'datetime_start' => '2024-01-01 20:00:00',
|
||||||
|
'datetime_end' => '2024-01-01 21:00:00',
|
||||||
|
'slot_date' => '2024-01-01',
|
||||||
|
'slot_order' => 5, // Wrong order
|
||||||
|
]);
|
||||||
|
|
||||||
|
CalendarSlot::create([
|
||||||
|
'trip_id' => $this->trip->id,
|
||||||
|
'name' => 'Morning',
|
||||||
|
'datetime_start' => '2024-01-01 08:00:00',
|
||||||
|
'datetime_end' => '2024-01-01 09:00:00',
|
||||||
|
'slot_date' => '2024-01-01',
|
||||||
|
'slot_order' => 3, // Wrong order
|
||||||
|
]);
|
||||||
|
|
||||||
|
CalendarSlot::create([
|
||||||
|
'trip_id' => $this->trip->id,
|
||||||
|
'name' => 'Afternoon',
|
||||||
|
'datetime_start' => '2024-01-01 14:00:00',
|
||||||
|
'datetime_end' => '2024-01-01 15:00:00',
|
||||||
|
'slot_date' => '2024-01-01',
|
||||||
|
'slot_order' => 1, // Wrong order
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Recalculate
|
||||||
|
$this->service->recalculateSlotOrdersForDate($this->trip->id, '2024-01-01');
|
||||||
|
|
||||||
|
// Verify correct chronological order
|
||||||
|
$slots = CalendarSlot::where('trip_id', $this->trip->id)
|
||||||
|
->where('slot_date', '2024-01-01')
|
||||||
|
->orderBy('slot_order')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$this->assertEquals('Morning', $slots[0]->name);
|
||||||
|
$this->assertEquals(0, $slots[0]->slot_order);
|
||||||
|
|
||||||
|
$this->assertEquals('Afternoon', $slots[1]->name);
|
||||||
|
$this->assertEquals(1, $slots[1]->slot_order);
|
||||||
|
|
||||||
|
$this->assertEquals('Evening', $slots[2]->name);
|
||||||
|
$this->assertEquals(2, $slots[2]->slot_order);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_only_recalculates_slots_for_specified_date()
|
||||||
|
{
|
||||||
|
// Create slots on Jan 1
|
||||||
|
CalendarSlot::create([
|
||||||
|
'trip_id' => $this->trip->id,
|
||||||
|
'name' => 'Jan 1 Slot',
|
||||||
|
'datetime_start' => '2024-01-01 10:00:00',
|
||||||
|
'datetime_end' => '2024-01-01 11:00:00',
|
||||||
|
'slot_date' => '2024-01-01',
|
||||||
|
'slot_order' => 5,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create slots on Jan 2
|
||||||
|
CalendarSlot::create([
|
||||||
|
'trip_id' => $this->trip->id,
|
||||||
|
'name' => 'Jan 2 Slot',
|
||||||
|
'datetime_start' => '2024-01-02 10:00:00',
|
||||||
|
'datetime_end' => '2024-01-02 11:00:00',
|
||||||
|
'slot_date' => '2024-01-02',
|
||||||
|
'slot_order' => 7,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Recalculate only Jan 1
|
||||||
|
$this->service->recalculateSlotOrdersForDate($this->trip->id, '2024-01-01');
|
||||||
|
|
||||||
|
// Jan 1 should be recalculated to 0
|
||||||
|
$jan1Slot = CalendarSlot::where('slot_date', '2024-01-01')->first();
|
||||||
|
$this->assertEquals(0, $jan1Slot->slot_order);
|
||||||
|
|
||||||
|
// Jan 2 should remain unchanged
|
||||||
|
$jan2Slot = CalendarSlot::where('slot_date', '2024-01-02')->first();
|
||||||
|
$this->assertEquals(7, $jan2Slot->slot_order);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -41,4 +41,4 @@ RUN mkdir -p storage/app/public storage/framework/cache storage/framework/sessio
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# Start Laravel development server with composer install
|
# Start Laravel development server with composer install
|
||||||
CMD sh -c "composer install && php artisan key:generate --force && php artisan serve --host=0.0.0.0 --port=8000"
|
CMD sh -c "composer install && php artisan key:generate --force && php artisan migrate --force && php artisan serve --host=0.0.0.0 --port=8000"
|
||||||
|
|
@ -67,22 +67,26 @@
|
||||||
/* Content Layout */
|
/* Content Layout */
|
||||||
.trip-detail-content {
|
.trip-detail-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: 1fr var(--sidebar-width);
|
||||||
gap: 0;
|
gap: 0;
|
||||||
height: calc(100vh - var(--header-height));
|
height: calc(100vh - var(--header-height));
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trip-detail-main {
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
order: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.trip-detail-sidebar {
|
.trip-detail-sidebar {
|
||||||
width: var(--sidebar-width);
|
width: var(--sidebar-width);
|
||||||
background: var(--color-bg-primary);
|
background: var(--color-bg-primary);
|
||||||
border-right: 1px solid var(--color-border);
|
border-left: 1px solid var(--color-border);
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trip-detail-main {
|
|
||||||
flex: 1;
|
|
||||||
padding: var(--spacing-xl);
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
order: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Calendar Placeholder */
|
/* Calendar Placeholder */
|
||||||
|
|
@ -165,19 +169,23 @@
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.trip-detail-content {
|
.trip-detail-content {
|
||||||
flex-direction: column;
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.trip-detail-sidebar {
|
.trip-detail-main {
|
||||||
width: 100%;
|
order: 2;
|
||||||
border-right: none;
|
padding: var(--spacing-md);
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
min-height: 400px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.trip-detail-main {
|
.trip-detail-sidebar {
|
||||||
padding: var(--spacing-md);
|
order: 1;
|
||||||
|
width: 100%;
|
||||||
|
border-left: none;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
min-height: 300px;
|
||||||
|
max-height: 50vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.trip-detail-header {
|
.trip-detail-header {
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,17 @@ import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
import { formatDate } from '../utils/dateFormatter';
|
import { formatDate } from '../utils/dateFormatter';
|
||||||
import { useTrip } from '../hooks/useTrip';
|
import { useTrip } from '../hooks/useTrip';
|
||||||
import PlannablesList from './plannables/PlannablesList';
|
import PlannablesList from './plannables/PlannablesList';
|
||||||
|
import TripTimeline from './timeline/TripTimeline';
|
||||||
|
import axios from 'axios';
|
||||||
import './TripDetail.css';
|
import './TripDetail.css';
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
||||||
|
|
||||||
const TripDetail = () => {
|
const TripDetail = () => {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [trip, setTrip] = useState(null);
|
const [trip, setTrip] = useState(null);
|
||||||
|
const [plannableItems, setPlannableItems] = useState([]);
|
||||||
const { fetchTrip, loading, error } = useTrip();
|
const { fetchTrip, loading, error } = useTrip();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -24,6 +29,24 @@ const TripDetail = () => {
|
||||||
loadTrip();
|
loadTrip();
|
||||||
}, [id, fetchTrip]);
|
}, [id, fetchTrip]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadPlannableItems = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const response = await axios.get(`${API_URL}/api/trips/${id}/plannables`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
setPlannableItems(response.data.data || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading plannable items:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
loadPlannableItems();
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
// Memoize trip dates display to prevent unnecessary re-renders
|
// Memoize trip dates display to prevent unnecessary re-renders
|
||||||
const tripDatesDisplay = useMemo(() => {
|
const tripDatesDisplay = useMemo(() => {
|
||||||
if (!trip) return null;
|
if (!trip) return null;
|
||||||
|
|
@ -79,13 +102,16 @@ const TripDetail = () => {
|
||||||
|
|
||||||
<div className="trip-detail-content">
|
<div className="trip-detail-content">
|
||||||
<div className="trip-detail-sidebar">
|
<div className="trip-detail-sidebar">
|
||||||
<PlannablesList tripId={trip.id} />
|
<PlannablesList tripId={trip.id} onItemsChange={(items) => setPlannableItems(items)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="trip-detail-main">
|
<div className="trip-detail-main">
|
||||||
<div className="calendar-placeholder">
|
<TripTimeline
|
||||||
<h2>Calendar View</h2>
|
trip={trip}
|
||||||
<p>Calendar view will be implemented here in the future</p>
|
plannableItems={plannableItems}
|
||||||
</div>
|
onScheduleSuccess={() => {
|
||||||
|
// Optional: refresh plannable items if needed
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import PlannableForm from './PlannableForm';
|
||||||
import ConfirmDialog from '../common/ConfirmDialog';
|
import ConfirmDialog from '../common/ConfirmDialog';
|
||||||
import './PlannablesList.css';
|
import './PlannablesList.css';
|
||||||
|
|
||||||
const PlannablesList = ({ tripId }) => {
|
const PlannablesList = ({ tripId, onItemsChange }) => {
|
||||||
const { showSuccess, showError } = useToast();
|
const { showSuccess, showError } = useToast();
|
||||||
const {
|
const {
|
||||||
fetchBothData,
|
fetchBothData,
|
||||||
|
|
@ -46,6 +46,11 @@ const PlannablesList = ({ tripId }) => {
|
||||||
const limitedCalendarSlots = calendarSlots.slice(0, 365); // Max 1 year of slots
|
const limitedCalendarSlots = calendarSlots.slice(0, 365); // Max 1 year of slots
|
||||||
setCalendarSlots(limitedCalendarSlots);
|
setCalendarSlots(limitedCalendarSlots);
|
||||||
|
|
||||||
|
// Notify parent component of items change
|
||||||
|
if (onItemsChange) {
|
||||||
|
onItemsChange(plannables);
|
||||||
|
}
|
||||||
|
|
||||||
if (errors.plannables) {
|
if (errors.plannables) {
|
||||||
console.error('Failed to fetch plannables:', errors.plannables);
|
console.error('Failed to fetch plannables:', errors.plannables);
|
||||||
showError('Failed to load plannable items');
|
showError('Failed to load plannable items');
|
||||||
|
|
@ -90,7 +95,11 @@ const PlannablesList = ({ tripId }) => {
|
||||||
const performDelete = async (itemId) => {
|
const performDelete = async (itemId) => {
|
||||||
try {
|
try {
|
||||||
await deletePlannable(itemId);
|
await deletePlannable(itemId);
|
||||||
setPlannables(plannables.filter(item => item.id !== itemId));
|
const updatedPlannables = plannables.filter(item => item.id !== itemId);
|
||||||
|
setPlannables(updatedPlannables);
|
||||||
|
if (onItemsChange) {
|
||||||
|
onItemsChange(updatedPlannables);
|
||||||
|
}
|
||||||
showSuccess('Item deleted successfully');
|
showSuccess('Item deleted successfully');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error deleting item:', err);
|
console.error('Error deleting item:', err);
|
||||||
|
|
@ -104,19 +113,26 @@ const PlannablesList = ({ tripId }) => {
|
||||||
try {
|
try {
|
||||||
const isEditing = !!editingItem;
|
const isEditing = !!editingItem;
|
||||||
let savedItem;
|
let savedItem;
|
||||||
|
let updatedPlannables;
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
savedItem = await updatePlannable(editingItem.id, formData);
|
savedItem = await updatePlannable(editingItem.id, formData);
|
||||||
setPlannables(plannables.map(item =>
|
updatedPlannables = plannables.map(item =>
|
||||||
item.id === editingItem.id ? savedItem : item
|
item.id === editingItem.id ? savedItem : item
|
||||||
));
|
);
|
||||||
|
setPlannables(updatedPlannables);
|
||||||
showSuccess('Item updated successfully');
|
showSuccess('Item updated successfully');
|
||||||
} else {
|
} else {
|
||||||
savedItem = await createPlannable(tripId, formData);
|
savedItem = await createPlannable(tripId, formData);
|
||||||
setPlannables([...plannables, savedItem]);
|
updatedPlannables = [...plannables, savedItem];
|
||||||
|
setPlannables(updatedPlannables);
|
||||||
showSuccess('Item added successfully');
|
showSuccess('Item added successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (onItemsChange) {
|
||||||
|
onItemsChange(updatedPlannables);
|
||||||
|
}
|
||||||
|
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -195,40 +211,6 @@ const PlannablesList = ({ tripId }) => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Calendar Slots Sections */}
|
|
||||||
{calendarSlots.map(slot => {
|
|
||||||
const items = plannedItemsBySlot[slot.id] || [];
|
|
||||||
const slotDate = new Date(slot.slot_date);
|
|
||||||
const dayName = slotDate.toLocaleDateString('en-US', { weekday: 'long' });
|
|
||||||
const dateStr = slotDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={slot.id} className="plannables-section">
|
|
||||||
<h3 className="section-title">
|
|
||||||
📅 {slot.name}
|
|
||||||
<span className="section-date">{dayName}, {dateStr}</span>
|
|
||||||
{items.length > 0 && (
|
|
||||||
<span className="item-count">{items.length}</span>
|
|
||||||
)}
|
|
||||||
</h3>
|
|
||||||
<div className="section-items">
|
|
||||||
{items.length === 0 ? (
|
|
||||||
<p className="empty-message">No items planned for this day</p>
|
|
||||||
) : (
|
|
||||||
items.map(item => (
|
|
||||||
<PlannableItem
|
|
||||||
key={item.id}
|
|
||||||
item={item}
|
|
||||||
onEdit={handleEditItem}
|
|
||||||
onDelete={handleDeleteItem}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
|
|
|
||||||
32
frontend/src/components/timeline/DaySection.css
Normal file
32
frontend/src/components/timeline/DaySection.css
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
.day-section {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--primary-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-header {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 2px solid rgba(228, 93, 4, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-header h4 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-header p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hours-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
66
frontend/src/components/timeline/DaySection.jsx
Normal file
66
frontend/src/components/timeline/DaySection.jsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import HourRow from './HourRow';
|
||||||
|
import './DaySection.css';
|
||||||
|
|
||||||
|
const START_HOUR = 6; // 6 AM
|
||||||
|
const END_HOUR = 23; // 11 PM
|
||||||
|
|
||||||
|
function DaySection({ date, dayNumber, slots, plannableItems, onScheduleItem }) {
|
||||||
|
const dayName = date.toLocaleDateString('en-US', { weekday: 'long' });
|
||||||
|
const dateString = date.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Memoize slot-to-hour mapping to avoid recalculating on every render
|
||||||
|
const slotsByHour = useMemo(() => {
|
||||||
|
const mapping = {};
|
||||||
|
|
||||||
|
for (let hour = START_HOUR; hour <= END_HOUR; hour++) {
|
||||||
|
// Pre-compute hour boundaries once per hour (not per slot)
|
||||||
|
// Create hour boundaries explicitly to avoid DST issues
|
||||||
|
const hourStart = new Date(date.getFullYear(), date.getMonth(), date.getDate(), hour, 0, 0, 0);
|
||||||
|
const hourEnd = new Date(date.getFullYear(), date.getMonth(), date.getDate(), hour, 59, 59, 999);
|
||||||
|
|
||||||
|
mapping[hour] = slots.filter(slot => {
|
||||||
|
if (!slot.datetime_start) return false;
|
||||||
|
|
||||||
|
const slotStart = new Date(slot.datetime_start);
|
||||||
|
const slotEnd = new Date(slot.datetime_end);
|
||||||
|
|
||||||
|
// Check if slot overlaps with this hour (strict comparison)
|
||||||
|
return slotStart < hourEnd && slotEnd > hourStart;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapping;
|
||||||
|
}, [slots, date.getTime()]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="day-section">
|
||||||
|
<div className="day-header">
|
||||||
|
<h4>
|
||||||
|
Day {dayNumber}: {dayName}
|
||||||
|
</h4>
|
||||||
|
<p>{dateString}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hours-grid">
|
||||||
|
{Object.keys(slotsByHour).map(hour => (
|
||||||
|
<HourRow
|
||||||
|
key={hour}
|
||||||
|
hour={parseInt(hour)}
|
||||||
|
date={date}
|
||||||
|
slots={slotsByHour[hour]}
|
||||||
|
plannableItems={plannableItems}
|
||||||
|
onScheduleItem={onScheduleItem}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DaySection;
|
||||||
98
frontend/src/components/timeline/HourRow.css
Normal file
98
frontend/src/components/timeline/HourRow.css
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
.hour-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
border-left: 2px solid var(--primary-color);
|
||||||
|
min-height: 60px;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hour-row:hover {
|
||||||
|
background-color: rgba(228, 93, 4, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hour-label {
|
||||||
|
width: 100px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hour-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduled-slot {
|
||||||
|
background: rgba(59, 130, 246, 0.08);
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduled-slot-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduled-slot-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduled-slot-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e40af;
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduled-slot-time {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #1d4ed8;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduled-slot-items {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-button {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.5rem;
|
||||||
|
top: 0.5rem;
|
||||||
|
padding: 0.375rem;
|
||||||
|
background-color: #22c55e;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 2px 6px rgba(34, 197, 94, 0.3);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
opacity: 0;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hour-row:hover .add-button {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-button:hover {
|
||||||
|
background-color: #16a34a;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-button svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
100
frontend/src/components/timeline/HourRow.jsx
Normal file
100
frontend/src/components/timeline/HourRow.jsx
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { PlusIcon } from '@heroicons/react/24/outline';
|
||||||
|
import ScheduleItemModal from './ScheduleItemModal';
|
||||||
|
import './HourRow.css';
|
||||||
|
|
||||||
|
function HourRow({ hour, date, slots, plannableItems, onScheduleItem }) {
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
|
||||||
|
const formatHour = (h) => {
|
||||||
|
return `${h.toString().padStart(2, '0')}:00`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddClick = () => {
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSchedule = async (plannableItemId, startDatetime, endDatetime) => {
|
||||||
|
try {
|
||||||
|
await onScheduleItem(plannableItemId, startDatetime, endDatetime);
|
||||||
|
setShowModal(false);
|
||||||
|
} catch (error) {
|
||||||
|
// Error is already logged in parent, just rethrow to let modal handle it
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if there's a slot that starts at this hour
|
||||||
|
const slotStartsHere = slots.find(slot => {
|
||||||
|
// Parse the datetime string and get the local hours
|
||||||
|
const slotStart = new Date(slot.datetime_start);
|
||||||
|
const startHour = slotStart.getHours();
|
||||||
|
return startHour === hour;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="hour-row">
|
||||||
|
{/* Hour label */}
|
||||||
|
<div className="hour-label">
|
||||||
|
{formatHour(hour)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content area */}
|
||||||
|
<div className="hour-content">
|
||||||
|
{/* Scheduled items that start at this hour */}
|
||||||
|
{slotStartsHere && (
|
||||||
|
<div className="scheduled-slot">
|
||||||
|
<div className="scheduled-slot-header">
|
||||||
|
<div className="scheduled-slot-content">
|
||||||
|
<div className="scheduled-slot-title">
|
||||||
|
{slotStartsHere.name}
|
||||||
|
</div>
|
||||||
|
<div className="scheduled-slot-time">
|
||||||
|
{new Date(slotStartsHere.datetime_start).toLocaleTimeString('en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
})}
|
||||||
|
{' - '}
|
||||||
|
{new Date(slotStartsHere.datetime_end).toLocaleTimeString('en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{slotStartsHere.planned_items && slotStartsHere.planned_items.length > 0 && (
|
||||||
|
<div className="scheduled-slot-items">
|
||||||
|
{slotStartsHere.planned_items.map(pi => pi.plannable_item?.name).join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add button - CSS-only hover */}
|
||||||
|
<button
|
||||||
|
onClick={handleAddClick}
|
||||||
|
className="add-button"
|
||||||
|
title="Schedule an item"
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showModal && (
|
||||||
|
<ScheduleItemModal
|
||||||
|
date={date}
|
||||||
|
hour={hour}
|
||||||
|
plannableItems={plannableItems}
|
||||||
|
onSchedule={handleSchedule}
|
||||||
|
onClose={() => setShowModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HourRow;
|
||||||
98
frontend/src/components/timeline/ScheduleItemModal.css
Normal file
98
frontend/src/components/timeline/ScheduleItemModal.css
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
.schedule-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-form-error {
|
||||||
|
background-color: #fee;
|
||||||
|
border: 1px solid #fcc;
|
||||||
|
color: #c33;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-form-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-form-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
font-size: 1rem;
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
transition: border-color var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-form-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 3px rgba(228, 93, 4, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-time-group {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-btn-cancel {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background-color var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-btn-cancel:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-btn-cancel:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-btn-submit {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
color: white;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background-color var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-btn-submit:hover:not(:disabled) {
|
||||||
|
background-color: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-btn-submit:disabled {
|
||||||
|
background-color: var(--color-border);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
159
frontend/src/components/timeline/ScheduleItemModal.jsx
Normal file
159
frontend/src/components/timeline/ScheduleItemModal.jsx
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import BaseModal from '../BaseModal';
|
||||||
|
import './ScheduleItemModal.css';
|
||||||
|
|
||||||
|
function ScheduleItemModal({ date, hour, plannableItems, onSchedule, onClose }) {
|
||||||
|
const [selectedItemId, setSelectedItemId] = useState('');
|
||||||
|
const [startTime, setStartTime] = useState('');
|
||||||
|
const [endTime, setEndTime] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Set default times based on the clicked hour
|
||||||
|
const startHour = hour.toString().padStart(2, '0');
|
||||||
|
const endHour = (hour + 1).toString().padStart(2, '0');
|
||||||
|
setStartTime(`${startHour}:00`);
|
||||||
|
setEndTime(`${endHour}:00`);
|
||||||
|
}, [hour]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
if (!selectedItemId) {
|
||||||
|
setError('Please select an item to schedule');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!startTime || !endTime) {
|
||||||
|
setError('Please select start and end times');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct full datetime strings using local date
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const dateString = `${year}-${month}-${day}`;
|
||||||
|
const startDatetime = `${dateString} ${startTime}:00`;
|
||||||
|
const endDatetime = `${dateString} ${endTime}:00`;
|
||||||
|
|
||||||
|
// Validate end time is after start time
|
||||||
|
if (endTime <= startTime) {
|
||||||
|
setError('End time must be after start time');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
await onSchedule(selectedItemId, startDatetime, endDatetime);
|
||||||
|
// Modal will be closed by parent (HourRow) on success
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in modal submit:', err);
|
||||||
|
setError(err.response?.data?.message || err.message || 'Failed to schedule item');
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate time options (every 30 minutes) - memoized to avoid recreation
|
||||||
|
const timeOptions = useMemo(() => {
|
||||||
|
const options = [];
|
||||||
|
for (let h = 0; h < 24; h++) {
|
||||||
|
for (let m = 0; m < 60; m += 30) {
|
||||||
|
const timeStr = `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`;
|
||||||
|
options.push(timeStr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseModal isOpen={true} onClose={onClose} title="Schedule Item">
|
||||||
|
<form onSubmit={handleSubmit} className="schedule-form">
|
||||||
|
{error && (
|
||||||
|
<div className="schedule-form-error">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="schedule-form-group">
|
||||||
|
<label className="schedule-form-label">
|
||||||
|
Select Item
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedItemId}
|
||||||
|
onChange={(e) => setSelectedItemId(e.target.value)}
|
||||||
|
className="schedule-form-select"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">-- Choose an item --</option>
|
||||||
|
{plannableItems.map(item => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.name} {item.type ? `(${item.type})` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="schedule-time-group">
|
||||||
|
<div className="schedule-form-group">
|
||||||
|
<label className="schedule-form-label">
|
||||||
|
Start Time
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={startTime}
|
||||||
|
onChange={(e) => setStartTime(e.target.value)}
|
||||||
|
className="schedule-form-select"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
{timeOptions.map(time => (
|
||||||
|
<option key={time} value={time}>
|
||||||
|
{time}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="schedule-form-group">
|
||||||
|
<label className="schedule-form-label">
|
||||||
|
End Time
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={endTime}
|
||||||
|
onChange={(e) => setEndTime(e.target.value)}
|
||||||
|
className="schedule-form-select"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
{timeOptions.map(time => (
|
||||||
|
<option key={time} value={time}>
|
||||||
|
{time}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="schedule-form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="schedule-btn-cancel"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="schedule-btn-submit"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting ? 'Scheduling...' : 'Schedule'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</BaseModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ScheduleItemModal;
|
||||||
24
frontend/src/components/timeline/TripTimeline.css
Normal file
24
frontend/src/components/timeline/TripTimeline.css
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
.trip-timeline {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trip-timeline h3 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-days-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-loading {
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
113
frontend/src/components/timeline/TripTimeline.jsx
Normal file
113
frontend/src/components/timeline/TripTimeline.jsx
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import DaySection from './DaySection';
|
||||||
|
import axios from 'axios';
|
||||||
|
import './TripTimeline.css';
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
||||||
|
|
||||||
|
function TripTimeline({ trip, plannableItems, onScheduleSuccess }) {
|
||||||
|
const [calendarSlots, setCalendarSlots] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCalendarSlots();
|
||||||
|
}, [trip.id]);
|
||||||
|
|
||||||
|
const fetchCalendarSlots = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const response = await axios.get(`${API_URL}/api/trips/${trip.id}/calendar-slots`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
const slots = response.data.data || [];
|
||||||
|
setCalendarSlots(slots);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching calendar slots:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScheduleItem = async (plannableItemId, startDatetime, endDatetime) => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const payload = {
|
||||||
|
plannable_item_id: plannableItemId,
|
||||||
|
trip_id: trip.id,
|
||||||
|
start_datetime: startDatetime,
|
||||||
|
end_datetime: endDatetime,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await axios.post(
|
||||||
|
`${API_URL}/api/planned-items`,
|
||||||
|
payload,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await fetchCalendarSlots();
|
||||||
|
if (onScheduleSuccess) {
|
||||||
|
onScheduleSuccess();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error scheduling item:', error);
|
||||||
|
console.error('Error response:', error.response?.data);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateDays = () => {
|
||||||
|
const days = [];
|
||||||
|
const start = new Date(trip.start_date);
|
||||||
|
const end = new Date(trip.end_date);
|
||||||
|
|
||||||
|
for (let d = new Date(start); d <= end; ) {
|
||||||
|
days.push(new Date(d));
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return days;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSlotsForDay = (date) => {
|
||||||
|
// Format date as YYYY-MM-DD in local timezone, not UTC
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const dateString = `${year}-${month}-${day}`;
|
||||||
|
|
||||||
|
const slots = calendarSlots.filter(slot => {
|
||||||
|
// slot_date comes as "2025-12-12T00:00:00.000000Z", extract just the date part
|
||||||
|
const slotDateString = slot.slot_date.split('T')[0];
|
||||||
|
return slotDateString === dateString;
|
||||||
|
});
|
||||||
|
return slots;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="timeline-loading">Loading timeline...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = generateDays();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="trip-timeline">
|
||||||
|
<h3>Trip Timeline</h3>
|
||||||
|
<div className="timeline-days-container">
|
||||||
|
{days.map((day, index) => (
|
||||||
|
<DaySection
|
||||||
|
key={`${day.getFullYear()}-${day.getMonth()}-${day.getDate()}`}
|
||||||
|
date={day}
|
||||||
|
dayNumber={index + 1}
|
||||||
|
slots={getSlotsForDay(day)}
|
||||||
|
plannableItems={plannableItems}
|
||||||
|
onScheduleItem={handleScheduleItem}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TripTimeline;
|
||||||
1
tests/.gitignore
vendored
1
tests/.gitignore
vendored
|
|
@ -5,3 +5,4 @@ screenshots/
|
||||||
.env.local
|
.env.local
|
||||||
coverage/
|
coverage/
|
||||||
test-results/
|
test-results/
|
||||||
|
/.trees
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,5 @@
|
||||||
"testEnvironment": "node",
|
"testEnvironment": "node",
|
||||||
"testMatch": ["**/specs/**/*.test.js"],
|
"testMatch": ["**/specs/**/*.test.js"],
|
||||||
"setupFilesAfterEnv": ["<rootDir>/support/config/jest.setup.local.js"],
|
"setupFilesAfterEnv": ["<rootDir>/support/config/jest.setup.local.js"],
|
||||||
"testTimeout": 30000
|
"testTimeout": 60000
|
||||||
}
|
}
|
||||||
571
tests/specs/integration/timeline-scheduling.test.js
Normal file
571
tests/specs/integration/timeline-scheduling.test.js
Normal file
|
|
@ -0,0 +1,571 @@
|
||||||
|
const { By, until } = require('selenium-webdriver');
|
||||||
|
const RegistrationPage = require('../../support/pages/RegistrationPage');
|
||||||
|
const LoginPage = require('../../support/pages/LoginPage');
|
||||||
|
const DashboardPage = require('../../support/pages/DashboardPage');
|
||||||
|
const TripPage = require('../../support/pages/TripPage');
|
||||||
|
|
||||||
|
describe('Timeline Scheduling Feature Test', () => {
|
||||||
|
let driver;
|
||||||
|
let registrationPage;
|
||||||
|
let loginPage;
|
||||||
|
let dashboardPage;
|
||||||
|
let tripPage;
|
||||||
|
let testUser;
|
||||||
|
let testTrip;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
driver = await global.createDriver();
|
||||||
|
registrationPage = new RegistrationPage(driver);
|
||||||
|
loginPage = new LoginPage(driver);
|
||||||
|
dashboardPage = new DashboardPage(driver);
|
||||||
|
tripPage = new TripPage(driver);
|
||||||
|
|
||||||
|
// Create unique test data
|
||||||
|
const timestamp = Date.now();
|
||||||
|
testUser = {
|
||||||
|
name: `Timeline Test User ${timestamp}`,
|
||||||
|
email: `timeline.test.${timestamp}@example.com`,
|
||||||
|
password: 'TimelineTest123!'
|
||||||
|
};
|
||||||
|
|
||||||
|
testTrip = {
|
||||||
|
name: `Timeline Test Trip ${timestamp}`,
|
||||||
|
description: 'Testing timeline scheduling feature',
|
||||||
|
startDate: '2025-03-01',
|
||||||
|
endDate: '2025-03-03'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
global.timelineTestsInitialized = false;
|
||||||
|
await global.quitDriver(driver);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// For individual test runs or when starting fresh, ensure we have proper setup
|
||||||
|
if (!global.timelineTestsInitialized) {
|
||||||
|
// Clear storage and cookies
|
||||||
|
await Promise.all([
|
||||||
|
driver.manage().deleteAllCookies().catch(() => {}),
|
||||||
|
driver.executeScript('try { localStorage.clear(); sessionStorage.clear(); } catch(e) {}')
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Navigate to base URL
|
||||||
|
await driver.get(process.env.APP_URL || 'http://localhost:5173');
|
||||||
|
await driver.wait(until.urlContains('/'), 5000);
|
||||||
|
|
||||||
|
// Register new user
|
||||||
|
try {
|
||||||
|
await driver.wait(until.elementLocated(By.css('.auth-toggle button:last-child')), 2000);
|
||||||
|
await driver.findElement(By.css('.auth-toggle button:last-child')).click();
|
||||||
|
|
||||||
|
await registrationPage.register(testUser.name, testUser.email, testUser.password);
|
||||||
|
|
||||||
|
// Wait for dashboard to load
|
||||||
|
await driver.wait(until.elementLocated(By.className('dashboard')), 10000);
|
||||||
|
await driver.wait(until.elementLocated(By.css('.add-trip-card')), 10000);
|
||||||
|
|
||||||
|
// Create a trip
|
||||||
|
const addTripCard = await driver.findElement(By.css('.add-trip-card'));
|
||||||
|
await addTripCard.click();
|
||||||
|
await driver.wait(until.elementLocated(By.css('.trip-modal')), 10000);
|
||||||
|
|
||||||
|
await tripPage.fillTripForm(testTrip);
|
||||||
|
await tripPage.submitTripForm();
|
||||||
|
|
||||||
|
// Wait for trip to be created
|
||||||
|
await driver.wait(until.elementLocated(By.xpath(`//h3[contains(text(), "${testTrip.name}")]`)), 10000);
|
||||||
|
|
||||||
|
global.timelineTestsInitialized = true;
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Failed to initialize timeline test environment:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to navigate to trip detail page
|
||||||
|
async function navigateToTripDetail() {
|
||||||
|
const currentUrl = await driver.getCurrentUrl();
|
||||||
|
|
||||||
|
if (global.timelineTripDetailUrl && currentUrl === global.timelineTripDetailUrl) {
|
||||||
|
await driver.wait(until.elementLocated(By.className('trip-timeline')), 10000);
|
||||||
|
await driver.sleep(500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (global.timelineTripDetailUrl) {
|
||||||
|
await driver.get(global.timelineTripDetailUrl);
|
||||||
|
await driver.sleep(1000);
|
||||||
|
} else {
|
||||||
|
await driver.wait(until.elementLocated(By.className('dashboard')), 10000);
|
||||||
|
const tripCard = await driver.findElement(By.xpath(`//h3[contains(text(), "${testTrip.name}")]/ancestor::div[contains(@class, 'trip-card')]`));
|
||||||
|
await tripCard.click();
|
||||||
|
await driver.wait(until.urlContains('/trip/'), 10000);
|
||||||
|
global.timelineTripDetailUrl = await driver.getCurrentUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
await driver.wait(until.elementLocated(By.className('trip-timeline')), 10000);
|
||||||
|
await driver.sleep(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to create a plannable item
|
||||||
|
async function createPlannableItem(itemData) {
|
||||||
|
// Ensure no modal is already open
|
||||||
|
await driver.sleep(500);
|
||||||
|
|
||||||
|
// Click Add Item button in sidebar using JavaScript click
|
||||||
|
const addItemButton = await driver.findElement(By.xpath('//button[contains(text(), "Add Item")]'));
|
||||||
|
await driver.executeScript("arguments[0].scrollIntoView(true);", addItemButton);
|
||||||
|
await driver.sleep(300);
|
||||||
|
await driver.executeScript("arguments[0].click();", addItemButton);
|
||||||
|
|
||||||
|
// Wait for form modal
|
||||||
|
await driver.wait(until.elementLocated(By.className('plannable-form-modal')), 5000);
|
||||||
|
|
||||||
|
// Fill in the form
|
||||||
|
await driver.findElement(By.name('name')).sendKeys(itemData.name);
|
||||||
|
|
||||||
|
if (itemData.type) {
|
||||||
|
const typeSelect = await driver.findElement(By.name('type'));
|
||||||
|
await typeSelect.findElement(By.xpath(`//option[@value="${itemData.type}"]`)).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemData.address) {
|
||||||
|
await driver.findElement(By.name('address')).sendKeys(itemData.address);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemData.notes) {
|
||||||
|
await driver.findElement(By.name('notes')).sendKeys(itemData.notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit without assigning to a slot (leave in "Unplanned Items")
|
||||||
|
const submitButton = await driver.findElement(By.xpath('//button[contains(text(), "Add Item")]'));
|
||||||
|
await driver.executeScript("arguments[0].scrollIntoView(true);", submitButton);
|
||||||
|
await driver.sleep(500);
|
||||||
|
await driver.executeScript("arguments[0].click();", submitButton);
|
||||||
|
|
||||||
|
// Wait for modal to close
|
||||||
|
await driver.sleep(1500);
|
||||||
|
await driver.wait(async () => {
|
||||||
|
const overlays = await driver.findElements(By.css('.plannable-form-overlay, .plannable-form-modal'));
|
||||||
|
for (const overlay of overlays) {
|
||||||
|
try {
|
||||||
|
if (await overlay.isDisplayed()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Stale element
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
// Additional wait to ensure UI is stable
|
||||||
|
await driver.sleep(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to wait for modal close
|
||||||
|
async function waitForModalClose() {
|
||||||
|
await driver.sleep(1000);
|
||||||
|
await driver.wait(async () => {
|
||||||
|
const modals = await driver.findElements(By.css('.modal, .base-modal'));
|
||||||
|
for (const modal of modals) {
|
||||||
|
try {
|
||||||
|
if (await modal.isDisplayed()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Stale element
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Timeline Display', () => {
|
||||||
|
it('should display timeline with correct structure', async () => {
|
||||||
|
await navigateToTripDetail();
|
||||||
|
|
||||||
|
// Verify timeline container exists
|
||||||
|
const timeline = await driver.findElement(By.className('trip-timeline'));
|
||||||
|
expect(await timeline.isDisplayed()).toBe(true);
|
||||||
|
|
||||||
|
// Verify timeline has header
|
||||||
|
const timelineHeader = await driver.findElement(By.xpath('//h3[contains(text(), "Trip Timeline")]'));
|
||||||
|
expect(await timelineHeader.isDisplayed()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display all days of the trip', async () => {
|
||||||
|
await navigateToTripDetail();
|
||||||
|
|
||||||
|
// Trip is from March 1-3, so we should have 3 day sections
|
||||||
|
const daySections = await driver.findElements(By.className('day-section'));
|
||||||
|
expect(daySections.length).toBe(3);
|
||||||
|
|
||||||
|
// Verify day headers
|
||||||
|
const day1Header = await driver.findElement(By.xpath('//h4[contains(text(), "Day 1")]'));
|
||||||
|
expect(await day1Header.isDisplayed()).toBe(true);
|
||||||
|
|
||||||
|
const day2Header = await driver.findElement(By.xpath('//h4[contains(text(), "Day 2")]'));
|
||||||
|
expect(await day2Header.isDisplayed()).toBe(true);
|
||||||
|
|
||||||
|
const day3Header = await driver.findElement(By.xpath('//h4[contains(text(), "Day 3")]'));
|
||||||
|
expect(await day3Header.isDisplayed()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display hour rows for each day', async () => {
|
||||||
|
await navigateToTripDetail();
|
||||||
|
|
||||||
|
// Get first day section
|
||||||
|
const firstDaySection = await driver.findElement(By.className('day-section'));
|
||||||
|
|
||||||
|
// Should have hour rows (06:00 to 23:00 = 18 hours)
|
||||||
|
const hourRows = await firstDaySection.findElements(By.className('hour-row'));
|
||||||
|
expect(hourRows.length).toBeGreaterThanOrEqual(18);
|
||||||
|
|
||||||
|
// Verify specific hour labels exist
|
||||||
|
const hour6am = await driver.findElement(By.xpath('//div[contains(@class, "hour-label") and contains(text(), "06:00")]'));
|
||||||
|
expect(await hour6am.isDisplayed()).toBe(true);
|
||||||
|
|
||||||
|
const hour11pm = await driver.findElement(By.xpath('//div[contains(@class, "hour-label") and contains(text(), "23:00")]'));
|
||||||
|
expect(await hour11pm.isDisplayed()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Scheduling Items via Timeline', () => {
|
||||||
|
it('should show + button on hover over hour row', async () => {
|
||||||
|
await navigateToTripDetail();
|
||||||
|
|
||||||
|
// Find an hour row
|
||||||
|
const hourRow = await driver.findElement(By.className('hour-row'));
|
||||||
|
|
||||||
|
// Hover over it
|
||||||
|
await driver.actions().move({ origin: hourRow }).perform();
|
||||||
|
await driver.sleep(500);
|
||||||
|
|
||||||
|
// Should see a + button
|
||||||
|
const addButton = await hourRow.findElement(By.css('button'));
|
||||||
|
expect(await addButton.isDisplayed()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should open schedule modal when clicking + button', async () => {
|
||||||
|
await navigateToTripDetail();
|
||||||
|
|
||||||
|
// First create a plannable item to schedule
|
||||||
|
await createPlannableItem({
|
||||||
|
name: 'Breakfast at Cafe',
|
||||||
|
type: 'restaurant',
|
||||||
|
notes: 'Morning coffee'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now find the 08:00 hour row
|
||||||
|
const hour8am = await driver.findElement(By.xpath('//div[contains(@class, "hour-label") and contains(text(), "08:00")]/following-sibling::div[contains(@class, "hour-content")]'));
|
||||||
|
|
||||||
|
// Hover to show + button
|
||||||
|
await driver.actions().move({ origin: hour8am }).perform();
|
||||||
|
await driver.sleep(500);
|
||||||
|
|
||||||
|
// Click the + button
|
||||||
|
const addButton = await hour8am.findElement(By.css('button'));
|
||||||
|
await addButton.click();
|
||||||
|
|
||||||
|
// Wait for schedule modal to appear
|
||||||
|
await driver.wait(until.elementLocated(By.xpath('//*[contains(text(), "Schedule Item")]')), 5000);
|
||||||
|
|
||||||
|
// Verify modal elements
|
||||||
|
const itemSelect = await driver.findElement(By.css('select'));
|
||||||
|
expect(await itemSelect.isDisplayed()).toBe(true);
|
||||||
|
|
||||||
|
// Should have time pickers
|
||||||
|
const timeInputs = await driver.findElements(By.css('select'));
|
||||||
|
expect(timeInputs.length).toBeGreaterThanOrEqual(2); // Start time and end time
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should schedule an item at specific time', async () => {
|
||||||
|
await navigateToTripDetail();
|
||||||
|
|
||||||
|
// Create plannable item
|
||||||
|
await createPlannableItem({
|
||||||
|
name: 'Louvre Museum Visit',
|
||||||
|
type: 'attraction',
|
||||||
|
notes: 'Morning visit to see Mona Lisa'
|
||||||
|
});
|
||||||
|
|
||||||
|
await driver.sleep(1000);
|
||||||
|
|
||||||
|
// Find the 10:00 hour row on Day 1
|
||||||
|
const daySections = await driver.findElements(By.className('day-section'));
|
||||||
|
const day1Section = daySections[0];
|
||||||
|
|
||||||
|
const hour10am = await day1Section.findElement(By.xpath('.//div[contains(@class, "hour-label") and contains(text(), "10:00")]/following-sibling::div[contains(@class, "hour-content")]'));
|
||||||
|
|
||||||
|
// Hover and click +
|
||||||
|
await driver.actions().move({ origin: hour10am }).perform();
|
||||||
|
await driver.sleep(500);
|
||||||
|
|
||||||
|
const addButton = await hour10am.findElement(By.css('button'));
|
||||||
|
await addButton.click();
|
||||||
|
|
||||||
|
// Wait for modal
|
||||||
|
await driver.wait(until.elementLocated(By.xpath('//*[contains(text(), "Schedule Item")]')), 5000);
|
||||||
|
|
||||||
|
// Select the item
|
||||||
|
const itemSelect = await driver.findElement(By.xpath('//label[contains(text(), "Select Item")]/following-sibling::select'));
|
||||||
|
await itemSelect.findElement(By.xpath('//option[contains(text(), "Louvre Museum Visit")]')).click();
|
||||||
|
|
||||||
|
// Start time should default to 10:00
|
||||||
|
const startTimeSelect = await driver.findElement(By.xpath('//label[contains(text(), "Start Time")]/following-sibling::select'));
|
||||||
|
const startTimeValue = await startTimeSelect.getAttribute('value');
|
||||||
|
expect(startTimeValue).toBe('10:00');
|
||||||
|
|
||||||
|
// Set end time to 12:00
|
||||||
|
const endTimeSelect = await driver.findElement(By.xpath('//label[contains(text(), "End Time")]/following-sibling::select'));
|
||||||
|
await endTimeSelect.findElement(By.xpath('//option[@value="12:00"]')).click();
|
||||||
|
|
||||||
|
// Submit
|
||||||
|
const scheduleButton = await driver.findElement(By.xpath('//button[contains(text(), "Schedule")]'));
|
||||||
|
await scheduleButton.click();
|
||||||
|
|
||||||
|
// Wait for modal to close
|
||||||
|
await waitForModalClose();
|
||||||
|
|
||||||
|
// Verify item appears in timeline at 10:00
|
||||||
|
await driver.sleep(1000);
|
||||||
|
const scheduledItem = await driver.findElement(By.xpath('//div[contains(@class, "scheduled-slot") and contains(., "Louvre Museum Visit")]'));
|
||||||
|
expect(await scheduledItem.isDisplayed()).toBe(true);
|
||||||
|
|
||||||
|
// Verify time display shows 10:00 - 12:00
|
||||||
|
const timeDisplay = await scheduledItem.getText();
|
||||||
|
expect(timeDisplay).toContain('10:00');
|
||||||
|
expect(timeDisplay).toContain('12:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should schedule multiple items on the same day', async () => {
|
||||||
|
await navigateToTripDetail();
|
||||||
|
|
||||||
|
// Create multiple plannable items
|
||||||
|
await createPlannableItem({
|
||||||
|
name: 'Morning Croissant',
|
||||||
|
type: 'restaurant'
|
||||||
|
});
|
||||||
|
|
||||||
|
await driver.sleep(500);
|
||||||
|
|
||||||
|
await createPlannableItem({
|
||||||
|
name: 'Afternoon Tea',
|
||||||
|
type: 'restaurant'
|
||||||
|
});
|
||||||
|
|
||||||
|
await driver.sleep(1000);
|
||||||
|
|
||||||
|
// Schedule first item at 08:00
|
||||||
|
const daySections = await driver.findElements(By.className('day-section'));
|
||||||
|
const day2Section = daySections[1]; // Day 2
|
||||||
|
|
||||||
|
const hour8am = await day2Section.findElement(By.xpath('.//div[contains(@class, "hour-label") and contains(text(), "08:00")]/following-sibling::div[contains(@class, "hour-content")]'));
|
||||||
|
|
||||||
|
await driver.actions().move({ origin: hour8am }).perform();
|
||||||
|
await driver.sleep(500);
|
||||||
|
await hour8am.findElement(By.css('button')).click();
|
||||||
|
|
||||||
|
await driver.wait(until.elementLocated(By.xpath('//*[contains(text(), "Schedule Item")]')), 5000);
|
||||||
|
|
||||||
|
const itemSelect1 = await driver.findElement(By.xpath('//label[contains(text(), "Select Item")]/following-sibling::select'));
|
||||||
|
await itemSelect1.findElement(By.xpath('//option[contains(text(), "Morning Croissant")]')).click();
|
||||||
|
|
||||||
|
const scheduleButton1 = await driver.findElement(By.xpath('//button[contains(text(), "Schedule")]'));
|
||||||
|
await scheduleButton1.click();
|
||||||
|
await waitForModalClose();
|
||||||
|
|
||||||
|
await driver.sleep(1000);
|
||||||
|
|
||||||
|
// Schedule second item at 15:00
|
||||||
|
const hour3pm = await day2Section.findElement(By.xpath('.//div[contains(@class, "hour-label") and contains(text(), "15:00")]/following-sibling::div[contains(@class, "hour-content")]'));
|
||||||
|
|
||||||
|
await driver.actions().move({ origin: hour3pm }).perform();
|
||||||
|
await driver.sleep(500);
|
||||||
|
await hour3pm.findElement(By.css('button')).click();
|
||||||
|
|
||||||
|
await driver.wait(until.elementLocated(By.xpath('//*[contains(text(), "Schedule Item")]')), 5000);
|
||||||
|
|
||||||
|
const itemSelect2 = await driver.findElement(By.xpath('//label[contains(text(), "Select Item")]/following-sibling::select'));
|
||||||
|
await itemSelect2.findElement(By.xpath('//option[contains(text(), "Afternoon Tea")]')).click();
|
||||||
|
|
||||||
|
const endTimeSelect = await driver.findElement(By.xpath('//label[contains(text(), "End Time")]/following-sibling::select'));
|
||||||
|
await endTimeSelect.findElement(By.xpath('//option[@value="16:00"]')).click();
|
||||||
|
|
||||||
|
const scheduleButton2 = await driver.findElement(By.xpath('//button[contains(text(), "Schedule")]'));
|
||||||
|
await scheduleButton2.click();
|
||||||
|
await waitForModalClose();
|
||||||
|
|
||||||
|
await driver.sleep(1000);
|
||||||
|
|
||||||
|
// Verify both items appear in Day 2 section
|
||||||
|
const croissantItem = await day2Section.findElement(By.xpath('.//div[contains(., "Morning Croissant")]'));
|
||||||
|
expect(await croissantItem.isDisplayed()).toBe(true);
|
||||||
|
|
||||||
|
const teaItem = await day2Section.findElement(By.xpath('.//div[contains(., "Afternoon Tea")]'));
|
||||||
|
expect(await teaItem.isDisplayed()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate end time is after start time', async () => {
|
||||||
|
await navigateToTripDetail();
|
||||||
|
|
||||||
|
// Create plannable item
|
||||||
|
await createPlannableItem({
|
||||||
|
name: 'Invalid Time Test',
|
||||||
|
type: 'other'
|
||||||
|
});
|
||||||
|
|
||||||
|
await driver.sleep(1000);
|
||||||
|
|
||||||
|
// Open schedule modal at 14:00
|
||||||
|
const daySections = await driver.findElements(By.className('day-section'));
|
||||||
|
const day3Section = daySections[2]; // Day 3
|
||||||
|
|
||||||
|
const hour2pm = await day3Section.findElement(By.xpath('.//div[contains(@class, "hour-label") and contains(text(), "14:00")]/following-sibling::div[contains(@class, "hour-content")]'));
|
||||||
|
|
||||||
|
await driver.actions().move({ origin: hour2pm }).perform();
|
||||||
|
await driver.sleep(500);
|
||||||
|
await hour2pm.findElement(By.css('button')).click();
|
||||||
|
|
||||||
|
await driver.wait(until.elementLocated(By.xpath('//*[contains(text(), "Schedule Item")]')), 5000);
|
||||||
|
|
||||||
|
// Select item
|
||||||
|
const itemSelect = await driver.findElement(By.xpath('//label[contains(text(), "Select Item")]/following-sibling::select'));
|
||||||
|
await itemSelect.findElement(By.xpath('//option[contains(text(), "Invalid Time Test")]')).click();
|
||||||
|
|
||||||
|
// Try to set end time before start time (e.g., start 14:00, end 13:00)
|
||||||
|
const endTimeSelect = await driver.findElement(By.xpath('//label[contains(text(), "End Time")]/following-sibling::select'));
|
||||||
|
await endTimeSelect.findElement(By.xpath('//option[@value="13:00"]')).click();
|
||||||
|
|
||||||
|
// Submit
|
||||||
|
const scheduleButton = await driver.findElement(By.xpath('//button[contains(text(), "Schedule")]'));
|
||||||
|
await scheduleButton.click();
|
||||||
|
|
||||||
|
await driver.sleep(1000);
|
||||||
|
|
||||||
|
// Should show error message
|
||||||
|
const errorMessage = await driver.findElement(By.xpath('//*[contains(text(), "End time must be after start time")]'));
|
||||||
|
expect(await errorMessage.isDisplayed()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Timeline Integration', () => {
|
||||||
|
it('should display item in both sidebar and timeline after scheduling', async () => {
|
||||||
|
await navigateToTripDetail();
|
||||||
|
|
||||||
|
// Create plannable item
|
||||||
|
await createPlannableItem({
|
||||||
|
name: 'Arc de Triomphe',
|
||||||
|
type: 'attraction'
|
||||||
|
});
|
||||||
|
|
||||||
|
await driver.sleep(1000);
|
||||||
|
|
||||||
|
// Verify it appears in sidebar under "Unplanned Items"
|
||||||
|
const sidebarItem = await driver.findElement(By.xpath('//h4[contains(text(), "Arc de Triomphe")]'));
|
||||||
|
expect(await sidebarItem.isDisplayed()).toBe(true);
|
||||||
|
|
||||||
|
// Schedule it via timeline
|
||||||
|
const daySections = await driver.findElements(By.className('day-section'));
|
||||||
|
const day1Section = daySections[0];
|
||||||
|
|
||||||
|
const hour11am = await day1Section.findElement(By.xpath('.//div[contains(@class, "hour-label") and contains(text(), "11:00")]/following-sibling::div[contains(@class, "hour-content")]'));
|
||||||
|
|
||||||
|
await driver.actions().move({ origin: hour11am }).perform();
|
||||||
|
await driver.sleep(500);
|
||||||
|
await hour11am.findElement(By.css('button')).click();
|
||||||
|
|
||||||
|
await driver.wait(until.elementLocated(By.xpath('//*[contains(text(), "Schedule Item")]')), 5000);
|
||||||
|
|
||||||
|
const itemSelect = await driver.findElement(By.xpath('//label[contains(text(), "Select Item")]/following-sibling::select'));
|
||||||
|
await itemSelect.findElement(By.xpath('//option[contains(text(), "Arc de Triomphe")]')).click();
|
||||||
|
|
||||||
|
const scheduleButton = await driver.findElement(By.xpath('//button[contains(text(), "Schedule")]'));
|
||||||
|
await scheduleButton.click();
|
||||||
|
await waitForModalClose();
|
||||||
|
|
||||||
|
await driver.sleep(1000);
|
||||||
|
|
||||||
|
// Verify it now appears in timeline
|
||||||
|
const timelineItem = await driver.findElement(By.xpath('//div[contains(@class, "scheduled-slot") and contains(., "Arc de Triomphe")]'));
|
||||||
|
expect(await timelineItem.isDisplayed()).toBe(true);
|
||||||
|
|
||||||
|
// Note: The sidebar behavior might change - item could stay in unplanned or move to a day section
|
||||||
|
// For now, we just verify it's in the timeline
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should maintain chronological order when items are scheduled out of order', async () => {
|
||||||
|
await navigateToTripDetail();
|
||||||
|
|
||||||
|
// Create items
|
||||||
|
await createPlannableItem({ name: 'Dinner', type: 'restaurant' });
|
||||||
|
await driver.sleep(500);
|
||||||
|
await createPlannableItem({ name: 'Breakfast', type: 'restaurant' });
|
||||||
|
await driver.sleep(500);
|
||||||
|
await createPlannableItem({ name: 'Lunch', type: 'restaurant' });
|
||||||
|
await driver.sleep(1000);
|
||||||
|
|
||||||
|
const daySections = await driver.findElements(By.className('day-section'));
|
||||||
|
const day1Section = daySections[0];
|
||||||
|
|
||||||
|
// Schedule Dinner first (19:00)
|
||||||
|
const hour7pm = await day1Section.findElement(By.xpath('.//div[contains(@class, "hour-label") and contains(text(), "19:00")]/following-sibling::div[contains(@class, "hour-content")]'));
|
||||||
|
await driver.actions().move({ origin: hour7pm }).perform();
|
||||||
|
await driver.sleep(500);
|
||||||
|
await hour7pm.findElement(By.css('button')).click();
|
||||||
|
await driver.wait(until.elementLocated(By.xpath('//*[contains(text(), "Schedule Item")]')), 5000);
|
||||||
|
let itemSelect = await driver.findElement(By.xpath('//label[contains(text(), "Select Item")]/following-sibling::select'));
|
||||||
|
await itemSelect.findElement(By.xpath('//option[contains(text(), "Dinner")]')).click();
|
||||||
|
let scheduleButton = await driver.findElement(By.xpath('//button[contains(text(), "Schedule")]'));
|
||||||
|
await scheduleButton.click();
|
||||||
|
await waitForModalClose();
|
||||||
|
await driver.sleep(1000);
|
||||||
|
|
||||||
|
// Schedule Breakfast (07:00)
|
||||||
|
const hour7am = await day1Section.findElement(By.xpath('.//div[contains(@class, "hour-label") and contains(text(), "07:00")]/following-sibling::div[contains(@class, "hour-content")]'));
|
||||||
|
await driver.actions().move({ origin: hour7am }).perform();
|
||||||
|
await driver.sleep(500);
|
||||||
|
await hour7am.findElement(By.css('button')).click();
|
||||||
|
await driver.wait(until.elementLocated(By.xpath('//*[contains(text(), "Schedule Item")]')), 5000);
|
||||||
|
itemSelect = await driver.findElement(By.xpath('//label[contains(text(), "Select Item")]/following-sibling::select'));
|
||||||
|
await itemSelect.findElement(By.xpath('//option[contains(text(), "Breakfast")]')).click();
|
||||||
|
scheduleButton = await driver.findElement(By.xpath('//button[contains(text(), "Schedule")]'));
|
||||||
|
await scheduleButton.click();
|
||||||
|
await waitForModalClose();
|
||||||
|
await driver.sleep(1000);
|
||||||
|
|
||||||
|
// Schedule Lunch (12:00)
|
||||||
|
const hour12pm = await day1Section.findElement(By.xpath('.//div[contains(@class, "hour-label") and contains(text(), "12:00")]/following-sibling::div[contains(@class, "hour-content")]'));
|
||||||
|
await driver.actions().move({ origin: hour12pm }).perform();
|
||||||
|
await driver.sleep(500);
|
||||||
|
await hour12pm.findElement(By.css('button')).click();
|
||||||
|
await driver.wait(until.elementLocated(By.xpath('//*[contains(text(), "Schedule Item")]')), 5000);
|
||||||
|
itemSelect = await driver.findElement(By.xpath('//label[contains(text(), "Select Item")]/following-sibling::select'));
|
||||||
|
await itemSelect.findElement(By.xpath('//option[contains(text(), "Lunch")]')).click();
|
||||||
|
scheduleButton = await driver.findElement(By.xpath('//button[contains(text(), "Schedule")]'));
|
||||||
|
await scheduleButton.click();
|
||||||
|
await waitForModalClose();
|
||||||
|
await driver.sleep(1000);
|
||||||
|
|
||||||
|
// Verify items appear in chronological order in Day 1 section
|
||||||
|
const scheduledSlots = await day1Section.findElements(By.className('scheduled-slot'));
|
||||||
|
|
||||||
|
// Get text of all scheduled items to verify order
|
||||||
|
const itemTexts = [];
|
||||||
|
for (const slot of scheduledSlots) {
|
||||||
|
const text = await slot.getText();
|
||||||
|
itemTexts.push(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Breakfast should appear before Lunch, and Lunch before Dinner
|
||||||
|
const breakfastIndex = itemTexts.findIndex(text => text.includes('Breakfast'));
|
||||||
|
const lunchIndex = itemTexts.findIndex(text => text.includes('Lunch'));
|
||||||
|
const dinnerIndex = itemTexts.findIndex(text => text.includes('Dinner'));
|
||||||
|
|
||||||
|
expect(breakfastIndex).toBeLessThan(lunchIndex);
|
||||||
|
expect(lunchIndex).toBeLessThan(dinnerIndex);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue