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
AdminLogmodel 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 rangeblocksSlot()returns true for all times when all-day blockblocksSlot()returns false for times outside blocked rangeisDateFullyBlocked()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 nullisAllDay()returns false when times are setscopeUpcoming()filters correctlyscopePast()filters correctlyscopeForDate()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:
WorkingHourmodel,AvailabilityServicebase
- Provides:
- 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:
AdminLogmodel for audit logging, admin authentication
- Provides:
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 CRUDtests/Unit/Models/BlockedTimeTest.php- Unit tests for BlockedTime modeltests/Feature/Admin/BlockedTimesTest.php- Feature tests for blocked times CRUDlang/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 methoddatabase/factories/BlockedTimeFactory.php- Enhanced with allDay, timeRange, upcoming, past, today, withReason statesroutes/web.php- Added blocked-times routelang/en/admin.php- Added blocked times translationslang/ar/admin.php- Added Arabic blocked times translationslang/en/messages.php- Added blocked_time_saved, blocked_time_deleted messageslang/ar/messages.php- Added Arabic blocked time messageslang/en/validation.php- Added block_date_future validation messagelang/ar/validation.php- Added Arabic block_date_future validation messagelang/en/clients.php- Added 'unknown' translation keylang/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 withBuilderreturn types - Carbon usage is appropriate for date/time manipulation
- Flux UI components are used consistently with the project patterns
- Proper
wire:keyusage 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
adminmiddleware - 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
userrelation 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 |
✓ |
Recommended Status
✓ 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.