420 lines
13 KiB
Markdown
420 lines
13 KiB
Markdown
# 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
|
|
- [ ] Visual distinction from "already booked" slots
|
|
- [ ] 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
|
|
```php
|
|
// 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
|
|
<?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
|
|
<?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
|
|
```php
|
|
// 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
|
|
```php
|
|
// 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
|
|
```blade
|
|
<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
|
|
|
|
```php
|
|
// 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/BlockedTimeTest.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
|
|
- [ ] `getBlockedSlots()` returns correct slots for partial day blocks
|
|
|
|
**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/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
|
|
- [ ] 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
|