libra/docs/stories/story-3.2-time-slot-blockin...

20 KiB

Story 3.2: Time Slot Blocking

Epic Reference

Epic 3: Booking & Consultation System

User Story

As an admin, I want to block specific dates or time ranges for personal events or holidays, So that clients cannot book during my unavailable times.

Story Context

Existing System Integration

  • Integrates with: blocked_times table, availability calendar
  • Technology: Livewire Volt, Flux UI
  • Follows pattern: CRUD pattern with calendar integration
  • Touch points: Availability calculation service

Acceptance Criteria

Block Time Management

  • Block entire days (all-day events)
  • Block specific time ranges within a day
  • Add reason/note for blocked time
  • View list of all blocked times (upcoming and past)
  • Edit blocked times
  • Delete blocked times

Creating Blocked Time

  • Select date (date picker)
  • Choose: All day OR specific time range
  • If time range: start time and end time
  • Optional reason/note field
  • Confirmation on save

Display & Integration

  • Blocked times show as unavailable in calendar (Story 3.3 dependency)
  • Visual distinction from "already booked" slots (Story 3.3 dependency)
  • Future blocked times don't affect existing approved bookings
  • Warning if blocking time with pending bookings

List View

  • Show all blocked times
  • Sort by date (upcoming first)
  • Filter: past/upcoming/all
  • Quick actions: edit, delete
  • Show reason if provided

Quality Requirements

  • Bilingual support
  • Audit log for create/edit/delete
  • Validation: end time after start time
  • Tests for blocking logic

Technical Notes

Database Schema

// blocked_times table
Schema::create('blocked_times', function (Blueprint $table) {
    $table->id();
    $table->date('block_date');
    $table->time('start_time')->nullable(); // null = all day
    $table->time('end_time')->nullable();   // null = all day
    $table->string('reason')->nullable();
    $table->timestamps();
});

Model

<?php

namespace App\Models;

use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;

class BlockedTime extends Model
{
    protected $fillable = [
        'block_date',
        'start_time',
        'end_time',
        'reason',
    ];

    protected $casts = [
        'block_date' => 'date',
    ];

    public function isAllDay(): bool
    {
        return is_null($this->start_time) && is_null($this->end_time);
    }

    public function scopeUpcoming($query)
    {
        return $query->where('block_date', '>=', today());
    }

    public function scopePast($query)
    {
        return $query->where('block_date', '<', today());
    }

    public function scopeForDate($query, $date)
    {
        return $query->where('block_date', $date);
    }

    public function blocksSlot(string $time): bool
    {
        if ($this->isAllDay()) {
            return true;
        }

        $slotTime = Carbon::parse($time);
        $start = Carbon::parse($this->start_time);
        $end = Carbon::parse($this->end_time);

        return $slotTime->between($start, $end) ||
               $slotTime->eq($start);
    }
}

Volt Component for Create/Edit

<?php

use App\Models\AdminLog;
use App\Models\BlockedTime;
use Livewire\Volt\Component;

new class extends Component {
    public ?BlockedTime $blockedTime = null;

    public string $block_date = '';
    public bool $is_all_day = true;
    public string $start_time = '09:00';
    public string $end_time = '17:00';
    public string $reason = '';

    public function mount(?BlockedTime $blockedTime = null): void
    {
        if ($blockedTime?->exists) {
            $this->blockedTime = $blockedTime;
            $this->block_date = $blockedTime->block_date->format('Y-m-d');
            $this->is_all_day = $blockedTime->isAllDay();
            $this->start_time = $blockedTime->start_time ?? '09:00';
            $this->end_time = $blockedTime->end_time ?? '17:00';
            $this->reason = $blockedTime->reason ?? '';
        } else {
            $this->block_date = today()->format('Y-m-d');
        }
    }

    public function save(): void
    {
        $validated = $this->validate([
            'block_date' => ['required', 'date', 'after_or_equal:today'],
            'is_all_day' => ['boolean'],
            'start_time' => ['required_if:is_all_day,false'],
            'end_time' => ['required_if:is_all_day,false', 'after:start_time'],
            'reason' => ['nullable', 'string', 'max:255'],
        ]);

        $data = [
            'block_date' => $this->block_date,
            'start_time' => $this->is_all_day ? null : $this->start_time,
            'end_time' => $this->is_all_day ? null : $this->end_time,
            'reason' => $this->reason ?: null,
        ];

        if ($this->blockedTime) {
            $this->blockedTime->update($data);
            $action = 'update';
        } else {
            $this->blockedTime = BlockedTime::create($data);
            $action = 'create';
        }

        AdminLog::create([
            'admin_id' => auth()->id(),
            'action_type' => $action,
            'target_type' => 'blocked_time',
            'target_id' => $this->blockedTime->id,
            'new_values' => $data,
            'ip_address' => request()->ip(),
        ]);

        session()->flash('success', __('messages.blocked_time_saved'));
        $this->redirect(route('admin.blocked-times.index'));
    }

    public function delete(): void
    {
        $this->blockedTime->delete();

        AdminLog::create([
            'admin_id' => auth()->id(),
            'action_type' => 'delete',
            'target_type' => 'blocked_time',
            'target_id' => $this->blockedTime->id,
            'ip_address' => request()->ip(),
        ]);

        session()->flash('success', __('messages.blocked_time_deleted'));
        $this->redirect(route('admin.blocked-times.index'));
    }
};

Pending Booking Warning Check

// Add to Volt component - check for pending bookings before save
public function checkPendingBookings(): array
{
    $date = Carbon::parse($this->block_date);

    return Consultation::where('scheduled_date', $date->toDateString())
        ->where('status', 'pending')
        ->when(!$this->is_all_day, function ($query) {
            $query->whereBetween('scheduled_time', [$this->start_time, $this->end_time]);
        })
        ->with('user:id,full_name,company_name')
        ->get()
        ->toArray();
}

Integration with Availability Service

// In AvailabilityService
public function getBlockedSlots(Carbon $date): array
{
    $blockedTimes = BlockedTime::forDate($date)->get();
    $blockedSlots = [];

    foreach ($blockedTimes as $blocked) {
        if ($blocked->isAllDay()) {
            // Return all possible slots as blocked
            $workingHour = WorkingHour::where('day_of_week', $date->dayOfWeek)->first();
            return $workingHour ? $workingHour->getSlots(60) : [];
        }

        // Get slots that fall within blocked range
        $start = Carbon::parse($blocked->start_time);
        $end = Carbon::parse($blocked->end_time);
        $current = $start->copy();

        while ($current->lt($end)) {
            $blockedSlots[] = $current->format('H:i');
            $current->addMinutes(60);
        }
    }

    return array_unique($blockedSlots);
}

public function isDateFullyBlocked(Carbon $date): bool
{
    return BlockedTime::forDate($date)
        ->where(function ($query) {
            $query->whereNull('start_time')
                  ->whereNull('end_time');
        })
        ->exists();
}

List View Component

<div>
    <div class="flex justify-between items-center mb-4">
        <flux:heading>{{ __('admin.blocked_times') }}</flux:heading>
        <flux:button href="{{ route('admin.blocked-times.create') }}">
            {{ __('admin.add_blocked_time') }}
        </flux:button>
    </div>

    <div class="space-y-2">
        @forelse($blockedTimes as $blocked)
            <div class="flex items-center justify-between p-4 bg-cream rounded-lg">
                <div>
                    <div class="font-semibold">
                        {{ $blocked->block_date->format('d/m/Y') }}
                    </div>
                    <div class="text-sm text-charcoal">
                        @if($blocked->isAllDay())
                            {{ __('admin.all_day') }}
                        @else
                            {{ $blocked->start_time }} - {{ $blocked->end_time }}
                        @endif
                    </div>
                    @if($blocked->reason)
                        <div class="text-sm text-charcoal/70">
                            {{ $blocked->reason }}
                        </div>
                    @endif
                </div>
                <div class="flex gap-2">
                    <flux:button size="sm" href="{{ route('admin.blocked-times.edit', $blocked) }}">
                        {{ __('common.edit') }}
                    </flux:button>
                    <flux:button size="sm" variant="danger" wire:click="delete({{ $blocked->id }})">
                        {{ __('common.delete') }}
                    </flux:button>
                </div>
            </div>
        @empty
            <p class="text-charcoal/70">{{ __('admin.no_blocked_times') }}</p>
        @endforelse
    </div>
</div>

Assumptions

  • AdminLog model exists from Epic 1 with fields: admin_id, action_type, target_type, target_id, new_values, ip_address
  • Route naming convention: admin.blocked-times.{index|create|edit}
  • Admin middleware protecting all blocked-times routes
  • Flux UI modal component available for delete confirmation

Required Translation Keys

// lang/en/*.php and lang/ar/*.php
'messages.blocked_time_saved' => 'Blocked time saved successfully',
'messages.blocked_time_deleted' => 'Blocked time deleted successfully',
'admin.blocked_times' => 'Blocked Times',
'admin.add_blocked_time' => 'Add Blocked Time',
'admin.all_day' => 'All Day',
'admin.closed' => 'Closed',
'admin.no_blocked_times' => 'No blocked times found',
'common.edit' => 'Edit',
'common.delete' => 'Delete',
'common.to' => 'to',

Test Scenarios

Feature Tests (tests/Feature/Admin/BlockedTimesTest.php)

CRUD Operations:

  • Admin can create an all-day block
  • Admin can create a time-range block (e.g., 09:00-12:00)
  • Admin can add optional reason to blocked time
  • Admin can edit an existing blocked time
  • Admin can delete a blocked time
  • Non-admin users cannot access blocked time routes

Validation:

  • Cannot create block with end_time before start_time
  • Cannot create block for past dates (new blocks only)
  • Can edit existing blocks for past dates (data integrity)
  • Reason field respects 255 character max length

List View:

  • List displays all blocked times sorted by date (upcoming first)
  • Filter by "upcoming" shows only future blocks
  • Filter by "past" shows only past blocks
  • Filter by "all" shows all blocks

Integration:

  • blocksSlot() returns true for times within blocked range
  • blocksSlot() returns true for all times when all-day block
  • blocksSlot() returns false for times outside blocked range
  • isDateFullyBlocked() correctly identifies all-day blocks (AvailabilityService - Story 3.3)
  • getBlockedSlots() returns correct slots for partial day blocks (AvailabilityService - Story 3.3)

Edge Cases:

  • Multiple blocks on same date handled correctly
  • Block at end of working hours (edge of range)
  • Warning displayed when blocking date with pending consultations

Unit Tests (tests/Unit/Models/BlockedTimeTest.php)

  • isAllDay() returns true when start_time and end_time are null
  • isAllDay() returns false when times are set
  • scopeUpcoming() filters correctly
  • scopePast() filters correctly
  • scopeForDate() filters by exact date

Definition of Done

  • Can create all-day blocks
  • Can create time-range blocks
  • Can add reason to blocked time
  • List view shows all blocked times
  • Can edit blocked times
  • Can delete blocked times
  • Blocked times show as unavailable in calendar (Story 3.3 dependency)
  • Existing bookings not affected
  • Audit logging complete
  • Bilingual support
  • Tests pass
  • Code formatted with Pint

Dependencies

  • Story 3.1: Working hours configuration (docs/stories/story-3.1-working-hours-configuration.md)
    • Provides: WorkingHour model, AvailabilityService base
  • Story 3.3: Availability calendar (docs/stories/story-3.3-availability-calendar-display.md)
    • Consumes: blocked times data for calendar display
  • Epic 1: Core Foundation
    • Provides: AdminLog model for audit logging, admin authentication

Risk Assessment

  • Primary Risk: Blocking times with pending bookings
  • Mitigation: Warning message, don't auto-cancel existing bookings
  • Rollback: Delete blocked time to restore availability

Estimation

Complexity: Medium Estimated Effort: 3-4 hours


Dev Agent Record

Status

Ready for Review

Agent Model Used

Claude Opus 4.5 (claude-opus-4-5-20251101)

File List

New Files:

  • resources/views/livewire/admin/settings/blocked-times.blade.php - Volt component for blocked times CRUD
  • tests/Unit/Models/BlockedTimeTest.php - Unit tests for BlockedTime model
  • tests/Feature/Admin/BlockedTimesTest.php - Feature tests for blocked times CRUD
  • lang/en/common.php - Common translations (save, cancel, edit, delete, optional)
  • lang/ar/common.php - Arabic common translations

Modified Files:

  • app/Models/BlockedTime.php - Added scopes (upcoming, past, forDate) and blocksSlot method
  • database/factories/BlockedTimeFactory.php - Enhanced with allDay, timeRange, upcoming, past, today, withReason states
  • routes/web.php - Added blocked-times route
  • lang/en/admin.php - Added blocked times translations
  • lang/ar/admin.php - Added Arabic blocked times translations
  • lang/en/messages.php - Added blocked_time_saved, blocked_time_deleted messages
  • lang/ar/messages.php - Added Arabic blocked time messages
  • lang/en/validation.php - Added block_date_future validation message
  • lang/ar/validation.php - Added Arabic block_date_future validation message
  • lang/en/clients.php - Added 'unknown' translation key
  • lang/ar/clients.php - Added Arabic 'unknown' translation key

Change Log

  • Implemented full CRUD for blocked times with modal-based create/edit
  • Added all-day and time-range blocking support
  • Added filter for upcoming/past/all blocked times
  • Added pending booking warning when blocking dates with existing consultations
  • Added audit logging for create/update/delete operations
  • Added bilingual support (English/Arabic)
  • Created comprehensive unit and feature tests (46 tests, 106 assertions)

Completion Notes

  • Calendar integration (showing blocked times as unavailable) is deferred to Story 3.3
  • AvailabilityService integration (getBlockedSlots, isDateFullyBlocked) is deferred to Story 3.3
  • All 303 tests in the full test suite pass
  • Code formatted with Pint

QA Results

Review Date: 2025-12-26

Reviewed By: Quinn (Test Architect)

Code Quality Assessment

Overall: Excellent - The implementation demonstrates high-quality code with clean architecture, proper separation of concerns, and comprehensive test coverage. The Volt component follows established patterns in the codebase, and the model implementation is clean with well-designed scopes.

Strengths:

  • Clean class-based Volt component with proper state management
  • Well-structured modal flow for create/edit operations
  • Comprehensive pending booking warning system with reactive updates
  • Proper audit logging with old/new values capture
  • Factory states are comprehensive and follow Laravel conventions
  • blocksSlot() method correctly handles boundary conditions (inclusive start, exclusive end)

Code Quality Highlights:

  • Model scopes (upcoming, past, forDate) are properly typed with Builder return types
  • Carbon usage is appropriate for date/time manipulation
  • Flux UI components are used consistently with the project patterns
  • Proper wire:key usage in loops for optimal Livewire rendering

Refactoring Performed

None required - code quality meets project standards.

Compliance Check

  • Coding Standards: ✓ Code follows Laravel/Pint conventions
  • Project Structure: ✓ Files in correct locations following project patterns
  • Testing Strategy: ✓ Comprehensive unit and feature tests
  • All ACs Met: ✓ All acceptance criteria marked as complete (where applicable to this story)

Improvements Checklist

All items are addressed or appropriately deferred:

  • CRUD operations fully implemented and tested
  • Modal-based UI for create/edit with proper state management
  • Pending booking warning with reactive updates
  • Audit logging for all operations
  • Bilingual support (EN/AR)
  • Filter functionality (upcoming/past/all)
  • Validation rules properly applied
  • Delete confirmation modal
  • Calendar display integration (correctly deferred to Story 3.3)
  • AvailabilityService integration (correctly deferred to Story 3.3)

Security Review

Status: PASS

  • Route properly protected by admin middleware
  • Authorization tests verify non-admin access is forbidden
  • No direct user input used in queries without validation
  • Audit logging captures IP addresses for traceability

Performance Considerations

Status: PASS

  • Efficient queries using scopes
  • Eager loading used for user relation in pending bookings check
  • No N+1 query issues detected
  • List view uses simple pagination pattern (no performance concerns at expected scale)

Files Modified During Review

None - no files were modified during review.

Gate Status

Gate: PASS → docs/qa/gates/3.2-time-slot-blocking.yml

Requirements Traceability

AC# Acceptance Criteria Test Coverage Status
1 Block entire days (all-day events) admin can create an all-day block
2 Block specific time ranges admin can create a time-range block
3 Add reason/note for blocked time admin can create block without reason, list shows reason if provided
4 View list of all blocked times list displays all blocked times sorted by date
5 Edit blocked times admin can edit an existing blocked time, admin can change block from all-day to time-range
6 Delete blocked times admin can delete a blocked time
7 Select date (date picker) Component uses native date input
8 Choose all day or time range is_all_day switch with conditional time fields
9 Time range selection start_time, end_time inputs when not all-day
10 Optional reason field Nullable validation, tested
11 Future blocks don't affect approved bookings Only checks pending status
12 Warning for pending bookings warning displayed when blocking date with pending consultations
13 Sort by date (upcoming first) filter by upcoming shows only future blocks
14 Filter: past/upcoming/all 3 filter tests
15 Quick actions: edit, delete Buttons in list view
16 Bilingual support EN/AR translation files
17 Audit logging 3 audit log tests
18 Validation: end time after start cannot create block with end time before start time
19 Non-admin access forbidden non-admin cannot access blocked times page

Ready for Done - All acceptance criteria are met, tests pass (46 tests, 106 assertions), code quality is high, and calendar integration items are correctly deferred to Story 3.3.