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

329 lines
9.5 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 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\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'));
}
};
```
### 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>
```
## 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
- **Story 3.3:** Availability calendar (consumes blocked times)
## 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