generated sotries

This commit is contained in:
Naser Mansour 2025-12-20 21:04:23 +02:00
parent d889b24f12
commit 8f95089814
65 changed files with 9501 additions and 0 deletions

View File

@ -0,0 +1,116 @@
# Story 1.1: Project Setup & Database Schema
## Epic Reference
**Epic 1:** Core Foundation & Infrastructure
## User Story
As a **developer**,
I want **the Laravel 12 project configured with all required packages and complete database schema**,
So that **the foundation is established for all subsequent feature development**.
## Story Context
### Existing System Integration
- **Integrates with:** New project setup (greenfield)
- **Technology:** Laravel 12, PHP 8.4, Livewire 3, Volt, Flux UI Free, Tailwind CSS 4
- **Follows pattern:** Laravel 12 streamlined file structure
- **Touch points:** Database migrations, model factories, development environment
## Acceptance Criteria
### Functional Requirements
- [ ] Laravel 12 project created with Livewire 3, Volt, Flux UI
- [ ] Tailwind CSS 4 configured with `@theme` directive
- [ ] Database migrations for all core tables:
- `users` (with user_type, national_id, company fields)
- `consultations`
- `timelines`
- `timeline_updates`
- `posts`
- `working_hours`
- `blocked_times`
- `notifications`
- `admin_logs`
- [ ] Model factories created for testing
- [ ] Development environment working (`composer run dev`)
### Integration Requirements
- [ ] SQLite configured for development database
- [ ] All migrations run without errors
- [ ] Factories generate valid test data
- [ ] Composer and npm scripts functional
### Quality Requirements
- [ ] All database tables have proper indexes
- [ ] Foreign key constraints properly defined
- [ ] Factories cover all required fields
- [ ] Development server starts without errors
## Technical Notes
- **Database:** Use SQLite for development (configurable for MariaDB in production)
- **Reference:** PRD Section 16.1 for complete schema details
- **Pattern:** Follow existing Volt class-based component pattern
- **Structure:** Use Laravel 12 streamlined file structure (no app/Http/Middleware, bootstrap/app.php for config)
### Database Schema Reference
```
users:
- id, name, email, password, user_type (enum: admin/individual/company)
- national_id (nullable), company_name (nullable), company_registration (nullable)
- phone, preferred_language (enum: ar/en), status (enum: active/deactivated)
- timestamps, email_verified_at
consultations:
- id, user_id, scheduled_date, scheduled_time, duration (default 45)
- status (enum: pending/approved/completed/cancelled/no_show)
- type (enum: free/paid), payment_amount (nullable), payment_status
- problem_summary, admin_notes, timestamps
timelines:
- id, user_id, case_name, case_reference (unique nullable)
- status (enum: active/archived), timestamps
timeline_updates:
- id, timeline_id, admin_id, update_text, timestamps
posts:
- id, title_ar, title_en, body_ar, body_en
- status (enum: draft/published), timestamps
working_hours:
- id, day_of_week (0-6), start_time, end_time, is_active, timestamps
blocked_times:
- id, block_date, start_time (nullable), end_time (nullable)
- reason, timestamps
admin_logs:
- id, admin_id, action_type, target_type, target_id
- old_values (json), new_values (json), ip_address, timestamps
```
## Definition of Done
- [ ] All database migrations created and tested
- [ ] All model factories functional
- [ ] Development environment runs with `composer run dev`
- [ ] Tests pass for model creation using factories
- [ ] Code formatted with Pint
- [ ] No errors on fresh install
## Dependencies
- None (this is the foundation story)
## Risk Assessment
- **Primary Risk:** Schema design changes required later
- **Mitigation:** Follow PRD specifications closely, validate with stakeholder
- **Rollback:** Fresh migration reset possible in development
## Estimation
**Complexity:** Medium
**Estimated Effort:** 4-6 hours

View File

@ -0,0 +1,112 @@
# Story 1.2: Authentication & Role System
## Epic Reference
**Epic 1:** Core Foundation & Infrastructure
## User Story
As an **admin**,
I want **a secure authentication system with Admin/Client roles**,
So that **only authorized users can access the platform with appropriate permissions**.
## Story Context
### Existing System Integration
- **Integrates with:** Fortify authentication, users table
- **Technology:** Laravel Fortify, Livewire Volt
- **Follows pattern:** Existing `app/Actions/Fortify/` for custom logic
- **Touch points:** FortifyServiceProvider, login views, middleware
## Acceptance Criteria
### Functional Requirements
- [ ] Fortify configured with custom Volt views
- [ ] Login page with bilingual support (Arabic/English)
- [ ] Session timeout after 2 hours of inactivity
- [ ] Rate limiting on login attempts (5 attempts per minute)
- [ ] Admin role with full access to all features
- [ ] Client role with restricted access (own data only)
- [ ] Registration feature DISABLED (admin creates all accounts)
### Security Requirements
- [ ] CSRF protection enabled on all forms
- [ ] Password hashing using bcrypt
- [ ] Gates/Policies for authorization checks
- [ ] Secure session configuration
- [ ] Remember me functionality (optional)
### Integration Requirements
- [ ] Login redirects to appropriate dashboard (admin vs client)
- [ ] Logout clears session properly
- [ ] Middleware protects admin-only routes
- [ ] Failed login attempts logged
### Quality Requirements
- [ ] Login form validates inputs properly
- [ ] Error messages are clear and bilingual
- [ ] Tests cover authentication flow
- [ ] No security vulnerabilities
## Technical Notes
### Fortify Configuration
```php
// config/fortify.php
'features' => [
// Features::registration(), // DISABLED
Features::resetPasswords(),
Features::emailVerification(),
Features::updateProfileInformation(),
Features::updatePasswords(),
],
```
### Custom Views Setup
```php
// FortifyServiceProvider boot()
Fortify::loginView(fn () => view('auth.login'));
// No registerView - registration disabled
```
### Role Implementation
- Use `user_type` column: 'admin', 'individual', 'company'
- Admin check: `$user->user_type === 'admin'`
- Client check: `in_array($user->user_type, ['individual', 'company'])`
### Gate Definitions
```php
// AuthServiceProvider or AppServiceProvider
Gate::define('admin', fn (User $user) => $user->user_type === 'admin');
Gate::define('client', fn (User $user) => $user->user_type !== 'admin');
```
### Middleware
- `auth` - Require authentication
- `can:admin` - Require admin role
- Custom middleware for session timeout if needed
## Definition of Done
- [ ] Login page renders correctly in both languages
- [ ] Users can log in with valid credentials
- [ ] Invalid credentials show proper error
- [ ] Rate limiting prevents brute force
- [ ] Session expires after 2 hours inactivity
- [ ] Admin routes protected from clients
- [ ] Tests pass for authentication flow
- [ ] Code formatted with Pint
## Dependencies
- **Story 1.1:** Database schema (users table)
- **Story 1.3:** Bilingual infrastructure (for login page translations)
## Risk Assessment
- **Primary Risk:** Security misconfiguration
- **Mitigation:** Use Laravel's built-in security features, no custom auth logic
- **Rollback:** Restore Fortify defaults
## Estimation
**Complexity:** Medium
**Estimated Effort:** 3-4 hours

View File

@ -0,0 +1,154 @@
# Story 1.3: Bilingual Infrastructure (Arabic/English)
## Epic Reference
**Epic 1:** Core Foundation & Infrastructure
## User Story
As a **user (admin or client)**,
I want **full bilingual support with Arabic as primary and English as secondary language**,
So that **I can use the platform in my preferred language with proper RTL/LTR layout**.
## Story Context
### Existing System Integration
- **Integrates with:** Laravel localization, Tailwind CSS, user preferences
- **Technology:** Laravel lang files, Tailwind RTL, Google Fonts
- **Follows pattern:** Laravel localization best practices
- **Touch points:** All views, navigation, date/time formatting
## Acceptance Criteria
### Functional Requirements
- [ ] Language files for Arabic (ar) and English (en)
- [ ] Language toggle in navigation (visible on all pages)
- [ ] User language preference stored in `users.preferred_language`
- [ ] Guest language stored in session
- [ ] RTL layout for Arabic, LTR for English
- [ ] All UI elements translatable via `__()` helper
### Date/Time Formatting
- [ ] Arabic: DD/MM/YYYY format
- [ ] English: MM/DD/YYYY format
- [ ] Both: 12-hour time format (AM/PM)
- [ ] Both: Western numerals (123) - no Arabic numerals
### Typography
- [ ] Arabic fonts: Cairo or Tajawal (Google Fonts)
- [ ] English fonts: Montserrat or Lato (Google Fonts)
- [ ] Font weights: 300, 400, 600, 700
- [ ] font-display: swap for performance
### Integration Requirements
- [ ] Language middleware sets locale from user preference or session
- [ ] Direction attribute (`dir="rtl"` or `dir="ltr"`) on HTML element
- [ ] Tailwind RTL utilities working
- [ ] Forms align correctly in both directions
### Quality Requirements
- [ ] No hardcoded strings in views
- [ ] All translation keys organized by feature
- [ ] Tests verify language switching
- [ ] No layout breaks when switching languages
## Technical Notes
### Language Middleware
```php
// Middleware to set locale
public function handle($request, Closure $next)
{
$locale = session('locale',
auth()->user()?->preferred_language ?? 'ar'
);
app()->setLocale($locale);
return $next($request);
}
```
### Translation File Structure
```
resources/lang/
ar/
auth.php
pagination.php
validation.php
messages.php
navigation.php
en/
auth.php
pagination.php
validation.php
messages.php
navigation.php
```
### RTL Support with Tailwind 4
```css
/* In app.css */
@import "tailwindcss";
@theme {
/* RTL support via logical properties */
}
```
### Font Configuration
```css
/* Google Fonts import */
@import url('https://fonts.googleapis.com/css2?family=Cairo:wght@300;400;600;700&family=Montserrat:wght@300;400;600;700&display=swap');
@theme {
--font-arabic: 'Cairo', 'Tajawal', sans-serif;
--font-english: 'Montserrat', 'Lato', sans-serif;
}
```
### Layout Template
```blade
<html lang="{{ app()->getLocale() }}" dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}">
<head>
<style>
body { font-family: var(--font-{{ app()->getLocale() === 'ar' ? 'arabic' : 'english' }}); }
</style>
</head>
```
### Date Formatting Helper
```php
// In a helper or service
public function formatDate($date, $locale = null): string
{
$locale = $locale ?? app()->getLocale();
$format = $locale === 'ar' ? 'd/m/Y' : 'm/d/Y';
return Carbon::parse($date)->format($format);
}
```
## Definition of Done
- [ ] Language toggle works in navigation
- [ ] Arabic and English translations complete for core UI
- [ ] RTL layout renders correctly for Arabic
- [ ] LTR layout renders correctly for English
- [ ] User preference persists in database
- [ ] Guest preference persists in session
- [ ] Dates format correctly per language
- [ ] Fonts load properly for both languages
- [ ] Tests pass for language switching
- [ ] Code formatted with Pint
## Dependencies
- **Story 1.1:** Database schema (users.preferred_language column)
- **Story 1.4:** Base UI (navigation for language toggle)
## Risk Assessment
- **Primary Risk:** RTL edge cases in complex layouts
- **Mitigation:** Use Tailwind logical properties (start/end vs left/right), test early
- **Rollback:** Fallback to LTR-only temporarily
## Estimation
**Complexity:** Medium-High
**Estimated Effort:** 4-5 hours

View File

@ -0,0 +1,181 @@
# Story 1.4: Base UI & Navigation
## Epic Reference
**Epic 1:** Core Foundation & Infrastructure
## User Story
As a **website visitor or logged-in user**,
I want **a professional, responsive navigation system with brand colors**,
So that **I can easily navigate the platform on any device**.
## Story Context
### Existing System Integration
- **Integrates with:** Flux UI Free, Tailwind CSS 4, bilingual system
- **Technology:** Livewire Volt, Flux UI components, Alpine.js
- **Follows pattern:** Flux UI navbar patterns, mobile-first design
- **Touch points:** All pages (layout component)
## Acceptance Criteria
### Color Scheme
- [ ] Primary: Dark Navy Blue (#0A1F44) - backgrounds, headers
- [ ] Accent: Gold (#D4AF37) - buttons, links, accents
- [ ] Light Gold: #F4E4B8 - hover states
- [ ] Off-White/Cream: #F9F7F4 - cards, content areas
- [ ] Charcoal Gray: #2C3E50 - secondary text
- [ ] Custom Tailwind colors configured via @theme
### Navigation Bar
- [ ] Fixed top position
- [ ] Navy blue background
- [ ] Logo placement: left on desktop, centered on mobile
- [ ] Main menu items: Home, Booking, Posts, Login/Dashboard
- [ ] Language toggle (Arabic/English) visible
- [ ] Responsive mobile hamburger menu
- [ ] Gold text for links, hover effects
### Mobile Menu
- [ ] Full-width dropdown or slide-in
- [ ] Navy background with gold text
- [ ] Touch-friendly targets (44px+ height)
- [ ] Smooth open/close animation
- [ ] Close on outside click or navigation
### Footer
- [ ] Navy blue background
- [ ] Libra logo (smaller version)
- [ ] Firm contact information
- [ ] Links: Terms of Service, Privacy Policy
- [ ] Copyright notice with current year
- [ ] Sticky footer (always at bottom of viewport)
### Layout Components
- [ ] Card-based layouts with proper shadows and border-radius
- [ ] Consistent spacing using Tailwind utilities
- [ ] Container max-width: 1200px, centered
- [ ] WCAG AA contrast compliance verified
### Integration Requirements
- [ ] Flux UI components used where available
- [ ] Works with RTL and LTR layouts
- [ ] Navigation state reflects current page
- [ ] Login/logout state reflected in menu
### Quality Requirements
- [ ] Responsive on all breakpoints (mobile, tablet, desktop)
- [ ] No horizontal scroll on any viewport
- [ ] Fast loading (minimal CSS/JS)
- [ ] Tests verify navigation rendering
## Technical Notes
### Tailwind Color Configuration
```css
/* In resources/css/app.css */
@import "tailwindcss";
@theme {
--color-navy: #0A1F44;
--color-gold: #D4AF37;
--color-gold-light: #F4E4B8;
--color-cream: #F9F7F4;
--color-charcoal: #2C3E50;
--color-success: #27AE60;
--color-danger: #E74C3C;
--color-warning: #F39C12;
}
```
### Layout Component Structure
```blade
<!-- resources/views/components/layouts/app.blade.php -->
<!DOCTYPE html>
<html lang="{{ app()->getLocale() }}" dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}">
<head>...</head>
<body class="bg-cream min-h-screen flex flex-col">
<x-navigation />
<main class="flex-1 container mx-auto px-4 py-8">
{{ $slot }}
</main>
<x-footer />
</body>
</html>
```
### Navigation Component
```blade
<!-- Using Flux UI navbar -->
<flux:navbar class="bg-navy">
<flux:brand href="/" class="text-gold">
<x-logo />
</flux:brand>
<flux:navbar.links class="text-gold">
<flux:navbar.link href="/" :active="request()->is('/')">
{{ __('navigation.home') }}
</flux:navbar.link>
<!-- More links -->
</flux:navbar.links>
<x-language-toggle />
</flux:navbar>
```
### Mobile Menu with Alpine.js
```blade
<div x-data="{ open: false }">
<button @click="open = !open" class="md:hidden">
<flux:icon name="menu" />
</button>
<div x-show="open" x-transition @click.away="open = false">
<!-- Mobile menu content -->
</div>
</div>
```
### Logo Component
```blade
<!-- resources/views/components/logo.blade.php -->
@props(['size' => 'default'])
<img
src="{{ asset('images/logo.svg') }}"
alt="{{ __('Libra Law Firm') }}"
@class([
'h-8' => $size === 'small',
'h-12' => $size === 'default',
'h-16' => $size === 'large',
])
/>
```
## Definition of Done
- [ ] Navigation renders correctly on all viewports
- [ ] Color scheme matches brand guidelines
- [ ] Mobile menu opens/closes smoothly
- [ ] Footer sticks to bottom of page
- [ ] Language toggle functional
- [ ] RTL/LTR layouts correct
- [ ] All navigation links work
- [ ] Login state reflected in menu
- [ ] Tests pass for navigation
- [ ] Code formatted with Pint
## Dependencies
- **Story 1.1:** Database schema (for user authentication state)
- **Story 1.2:** Authentication (for login/logout state in nav)
- **Story 1.3:** Bilingual infrastructure (for language toggle and translations)
## Risk Assessment
- **Primary Risk:** Flux UI limitations for custom styling
- **Mitigation:** Extend Flux components with custom Tailwind classes
- **Rollback:** Build custom navigation if Flux doesn't meet needs
## Estimation
**Complexity:** Medium
**Estimated Effort:** 4-5 hours

View File

@ -0,0 +1,165 @@
# Story 2.1: Individual Client Account Management
## Epic Reference
**Epic 2:** User Management System
## User Story
As an **admin**,
I want **to create, view, edit, and search individual client accounts**,
So that **I can manage client information and provide them platform access**.
## Story Context
### Existing System Integration
- **Integrates with:** Users table, Fortify authentication
- **Technology:** Livewire Volt, Flux UI forms
- **Follows pattern:** Admin CRUD patterns, class-based Volt components
- **Touch points:** User model, admin dashboard
## Acceptance Criteria
### Create Individual Client
- [ ] Form with required fields:
- Full Name (required)
- National ID Number (required, unique)
- Email Address (required, unique)
- Phone Number (required)
- Password (admin-set, required)
- Preferred Language (Arabic/English dropdown)
- [ ] Validation for all required fields
- [ ] Duplicate email/National ID prevention with clear error message
- [ ] Password strength indicator (optional)
- [ ] Success message on creation
### List View
- [ ] Display all individual clients (user_type = 'individual')
- [ ] Columns: Name, Email, National ID, Phone, Status, Created Date
- [ ] Pagination (10/25/50 per page)
- [ ] Default sort by created date (newest first)
### Search & Filter
- [ ] Search by name, email, or National ID
- [ ] Filter by status (active/deactivated/all)
- [ ] Real-time search with debounce (300ms)
- [ ] Clear filters button
### Edit Client
- [ ] Edit all client information
- [ ] Cannot change user_type from this form
- [ ] Validation same as create
- [ ] Success message on update
### View Client Profile
- [ ] Display all client information
- [ ] Show consultation history summary
- [ ] Show timeline history summary
- [ ] Quick links to related records
### Quality Requirements
- [ ] Bilingual form labels and messages
- [ ] Proper form validation with error display
- [ ] Audit log entries for all operations
- [ ] Tests for CRUD operations
## Technical Notes
### User Model Scope
```php
// In User model
public function scopeIndividual($query)
{
return $query->where('user_type', 'individual');
}
```
### Volt Component Structure
```php
<?php
use App\Models\User;
use Livewire\Volt\Component;
use Livewire\WithPagination;
new class extends Component {
use WithPagination;
public string $search = '';
public string $statusFilter = '';
public function updatedSearch()
{
$this->resetPage();
}
public function with(): array
{
return [
'clients' => User::individual()
->when($this->search, fn($q) => $q->where(function($q) {
$q->where('name', 'like', "%{$this->search}%")
->orWhere('email', 'like', "%{$this->search}%")
->orWhere('national_id', 'like', "%{$this->search}%");
}))
->when($this->statusFilter, fn($q) => $q->where('status', $this->statusFilter))
->latest()
->paginate(10),
];
}
};
```
### Validation Rules
```php
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'national_id' => ['required', 'string', 'unique:users,national_id'],
'email' => ['required', 'email', 'unique:users,email'],
'phone' => ['required', 'string'],
'password' => ['required', 'string', 'min:8'],
'preferred_language' => ['required', 'in:ar,en'],
];
}
```
### Admin Logging
```php
// After creating user
AdminLog::create([
'admin_id' => auth()->id(),
'action_type' => 'create',
'target_type' => 'user',
'target_id' => $user->id,
'new_values' => $user->only(['name', 'email', 'national_id']),
'ip_address' => request()->ip(),
]);
```
## Definition of Done
- [ ] Create individual client form works
- [ ] List view displays all individual clients
- [ ] Search and filter functional
- [ ] Edit client works with validation
- [ ] View profile shows complete information
- [ ] Duplicate prevention works
- [ ] Audit logging implemented
- [ ] Bilingual support complete
- [ ] Tests pass for all CRUD operations
- [ ] Code formatted with Pint
## Dependencies
- **Epic 1:** Authentication system, database schema, bilingual support
## Risk Assessment
- **Primary Risk:** Duplicate National ID from different sources
- **Mitigation:** Database unique constraint + form validation
- **Rollback:** Remove user and notify if duplicate discovered
## Estimation
**Complexity:** Medium
**Estimated Effort:** 4-5 hours

View File

@ -0,0 +1,187 @@
# Story 2.2: Company/Corporate Client Account Management
## Epic Reference
**Epic 2:** User Management System
## User Story
As an **admin**,
I want **to create, view, edit, and manage company/corporate client accounts**,
So that **I can serve corporate clients with their unique data requirements**.
## Story Context
### Existing System Integration
- **Integrates with:** Users table, potential contact_persons table
- **Technology:** Livewire Volt, Flux UI forms
- **Follows pattern:** Same CRUD pattern as individual clients
- **Touch points:** User model, admin dashboard
## Acceptance Criteria
### Create Company Client
- [ ] Form with required fields:
- Company Name (required)
- Company Registration Number (required, unique)
- Contact Person Name (required)
- Contact Person ID (required)
- Email Address (required, unique)
- Phone Number (required)
- Password (admin-set, required)
- Preferred Language (Arabic/English dropdown)
- [ ] Validation for all required fields
- [ ] Duplicate email/registration number prevention
- [ ] Success message on creation
### Multiple Contact Persons (Optional Enhancement)
- [ ] Support unlimited contact persons per company
- [ ] Each contact: Name, ID, Phone, Email
- [ ] Primary contact indicator
- [ ] Add/remove contacts dynamically
### List View
- [ ] Display all company clients (user_type = 'company')
- [ ] Columns: Company Name, Contact Person, Email, Reg #, Status, Created Date
- [ ] Pagination (10/25/50 per page)
- [ ] Default sort by created date
### Search & Filter
- [ ] Search by company name, email, or registration number
- [ ] Filter by status (active/deactivated/all)
- [ ] Real-time search with debounce
### Edit Company
- [ ] Edit all company information
- [ ] Update contact person details
- [ ] Validation same as create
- [ ] Success message on update
### View Company Profile
- [ ] Display all company information
- [ ] List all contact persons
- [ ] Show consultation history summary
- [ ] Show timeline history summary
### Quality Requirements
- [ ] Bilingual form labels and messages
- [ ] Proper form validation
- [ ] Audit log entries for all operations
- [ ] Tests for CRUD operations
## Technical Notes
### User Model Scope
```php
public function scopeCompany($query)
{
return $query->where('user_type', 'company');
}
```
### Database Fields for Company
```
users table:
- company_name (nullable, required for company type)
- company_registration (nullable, unique when not null)
- contact_person_name (nullable, required for company)
- contact_person_id (nullable, required for company)
```
### Alternative: Separate Contact Persons Table
```php
// contact_persons migration
Schema::create('contact_persons', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('national_id');
$table->string('phone')->nullable();
$table->string('email')->nullable();
$table->boolean('is_primary')->default(false);
$table->timestamps();
});
```
### Validation Rules
```php
public function rules(): array
{
return [
'company_name' => ['required', 'string', 'max:255'],
'company_registration' => ['required', 'string', 'unique:users,company_registration'],
'contact_person_name' => ['required', 'string', 'max:255'],
'contact_person_id' => ['required', 'string'],
'email' => ['required', 'email', 'unique:users,email'],
'phone' => ['required', 'string'],
'password' => ['required', 'string', 'min:8'],
'preferred_language' => ['required', 'in:ar,en'],
];
}
```
### Volt Component for Create
```php
<?php
use App\Models\User;
use Livewire\Volt\Component;
use Illuminate\Support\Facades\Hash;
new class extends Component {
public string $company_name = '';
public string $company_registration = '';
public string $contact_person_name = '';
public string $contact_person_id = '';
public string $email = '';
public string $phone = '';
public string $password = '';
public string $preferred_language = 'ar';
public function create(): void
{
$validated = $this->validate();
$user = User::create([
...$validated,
'user_type' => 'company',
'name' => $this->company_name, // For display purposes
'password' => Hash::make($this->password),
'status' => 'active',
]);
// Log action
// Send welcome email
session()->flash('success', __('messages.company_created'));
$this->redirect(route('admin.users.index'));
}
};
```
## Definition of Done
- [ ] Create company client form works
- [ ] List view displays all company clients
- [ ] Search and filter functional
- [ ] Edit company works with validation
- [ ] View profile shows complete information
- [ ] Duplicate prevention works
- [ ] Audit logging implemented
- [ ] Bilingual support complete
- [ ] Tests pass for all CRUD operations
- [ ] Code formatted with Pint
## Dependencies
- **Epic 1:** Authentication system, database schema
- **Story 2.1:** Same CRUD patterns
## Risk Assessment
- **Primary Risk:** Complex contact persons relationship
- **Mitigation:** Start simple (single contact), enhance later if needed
- **Rollback:** Use simple fields on users table
## Estimation
**Complexity:** Medium
**Estimated Effort:** 4-5 hours

View File

@ -0,0 +1,215 @@
# Story 2.3: Account Type Conversion
## Epic Reference
**Epic 2:** User Management System
## User Story
As an **admin**,
I want **to convert individual accounts to company accounts and vice versa**,
So that **I can accommodate clients whose business structure changes**.
## Story Context
### Existing System Integration
- **Integrates with:** Users table, consultations, timelines
- **Technology:** Livewire Volt, modal dialogs
- **Follows pattern:** Admin action pattern with confirmation
- **Touch points:** User model, related records
## Acceptance Criteria
### Convert Individual to Company
- [ ] "Convert to Company" action available on individual profiles
- [ ] Modal/form prompts for additional company fields:
- Company Name (required)
- Company Registration Number (required)
- Contact Person Name (pre-filled with current name)
- Contact Person ID (pre-filled with National ID)
- [ ] Preserve existing data (email, phone, password)
- [ ] Preserve all consultation history
- [ ] Preserve all timeline history
- [ ] Confirmation dialog before conversion
- [ ] Success message after conversion
### Convert Company to Individual
- [ ] "Convert to Individual" action available on company profiles
- [ ] Modal/form prompts for individual-specific fields:
- Full Name (pre-filled with contact person or company name)
- National ID (pre-filled with contact person ID if available)
- [ ] Handle company-specific data:
- Company fields set to null
- Clear company registration
- [ ] Preserve all consultation history
- [ ] Preserve all timeline history
- [ ] Confirmation dialog before conversion
### Notifications & Logging
- [ ] Audit log entry capturing:
- Old user_type
- New user_type
- Old values
- New values
- Timestamp
- Admin who performed action
- [ ] Email notification to user about account type change
### Quality Requirements
- [ ] Bilingual confirmation dialogs
- [ ] Bilingual email notification
- [ ] All data preserved (verify in tests)
- [ ] No broken relationships after conversion
## Technical Notes
### Conversion Logic
```php
<?php
use App\Models\User;
use Livewire\Volt\Component;
new class extends Component {
public User $user;
// For individual -> company conversion
public string $company_name = '';
public string $company_registration = '';
public string $contact_person_name = '';
public string $contact_person_id = '';
public function mount(User $user): void
{
$this->user = $user;
// Pre-fill for company conversion
if ($user->user_type === 'individual') {
$this->contact_person_name = $user->name;
$this->contact_person_id = $user->national_id ?? '';
}
}
public function convertToCompany(): void
{
$this->validate([
'company_name' => 'required|string|max:255',
'company_registration' => 'required|string|unique:users,company_registration',
'contact_person_name' => 'required|string|max:255',
'contact_person_id' => 'required|string',
]);
$oldValues = $this->user->only([
'user_type', 'name', 'national_id',
'company_name', 'company_registration'
]);
$this->user->update([
'user_type' => 'company',
'name' => $this->company_name,
'company_name' => $this->company_name,
'company_registration' => $this->company_registration,
'contact_person_name' => $this->contact_person_name,
'contact_person_id' => $this->contact_person_id,
'national_id' => null, // Company doesn't have individual national ID
]);
$this->logConversion($oldValues);
$this->notifyUser();
session()->flash('success', __('messages.account_converted'));
}
public function convertToIndividual(): void
{
// Similar logic for company -> individual
}
private function logConversion(array $oldValues): void
{
AdminLog::create([
'admin_id' => auth()->id(),
'action_type' => 'convert_account',
'target_type' => 'user',
'target_id' => $this->user->id,
'old_values' => $oldValues,
'new_values' => $this->user->fresh()->only([
'user_type', 'name', 'national_id',
'company_name', 'company_registration'
]),
'ip_address' => request()->ip(),
]);
}
private function notifyUser(): void
{
$this->user->notify(new AccountTypeChangedNotification(
$this->user->user_type
));
}
};
```
### Verification Query
```php
// Verify relationships preserved
public function testConversionPreservesRelationships(): void
{
$user = User::factory()->individual()->create();
$consultation = Consultation::factory()->for($user)->create();
$timeline = Timeline::factory()->for($user)->create();
// Perform conversion
$user->update(['user_type' => 'company', ...]);
// Verify relationships
expect($user->consultations)->toHaveCount(1);
expect($user->timelines)->toHaveCount(1);
expect($consultation->fresh()->user_id)->toBe($user->id);
}
```
### Email Template
```blade
<!-- resources/views/emails/account-type-changed.blade.php -->
@component('mail::message')
# {{ __('emails.account_type_changed.title') }}
{{ __('emails.account_type_changed.body', ['type' => $newType]) }}
@component('mail::button', ['url' => route('login')])
{{ __('emails.login_now') }}
@endcomponent
@endcomponent
```
## Definition of Done
- [ ] Convert individual to company works
- [ ] Convert company to individual works
- [ ] All existing data preserved
- [ ] Consultation history intact
- [ ] Timeline history intact
- [ ] Confirmation dialog shows before action
- [ ] Audit log entry created
- [ ] Email notification sent
- [ ] Bilingual support complete
- [ ] Tests verify data preservation
- [ ] Code formatted with Pint
## Dependencies
- **Story 2.1:** Individual client management
- **Story 2.2:** Company client management
- **Epic 3:** Consultations (for preservation testing)
- **Epic 4:** Timelines (for preservation testing)
## Risk Assessment
- **Primary Risk:** Data loss during conversion
- **Mitigation:** Transaction wrapping, thorough testing, audit log
- **Rollback:** Restore from audit log old_values
## Estimation
**Complexity:** Medium
**Estimated Effort:** 3-4 hours

View File

@ -0,0 +1,285 @@
# Story 2.4: Account Lifecycle Management
## Epic Reference
**Epic 2:** User Management System
## User Story
As an **admin**,
I want **to deactivate, reactivate, and permanently delete client accounts**,
So that **I can manage the full lifecycle of client relationships**.
## Story Context
### Existing System Integration
- **Integrates with:** Users table, consultations, timelines, notifications
- **Technology:** Livewire Volt, confirmation modals
- **Follows pattern:** Soft deactivation, hard deletion with cascade
- **Touch points:** User model, all related models
## Acceptance Criteria
### Deactivate Account
- [ ] "Deactivate" button on user profile and list
- [ ] Confirmation dialog explaining consequences
- [ ] Effects of deactivation:
- User cannot log in
- All data retained (consultations, timelines)
- Status changes to 'deactivated'
- Can be reactivated by admin
- [ ] Visual indicator in user list (grayed out, badge)
- [ ] Audit log entry created
### Reactivate Account
- [ ] "Reactivate" button on deactivated profiles
- [ ] Confirmation dialog
- [ ] Effects of reactivation:
- Restore login ability
- Status changes to 'active'
- All data intact
- [ ] Email notification sent to user
- [ ] Audit log entry created
### Delete Account (Permanent)
- [ ] "Delete" button (with danger styling)
- [ ] Confirmation dialog with strong warning:
- "This action cannot be undone"
- Lists what will be deleted
- Requires typing confirmation (e.g., user email)
- [ ] Effects of deletion:
- User record permanently removed
- Cascades to: consultations, timelines, timeline_updates, notifications
- Cannot be recovered
- [ ] Audit log entry preserved (for audit trail)
- [ ] No email sent (user no longer exists)
### Password Reset
- [ ] "Reset Password" action on user profile
- [ ] Options:
- Generate random password
- Set specific password manually
- [ ] Email new credentials to client
- [ ] Force password change on next login (optional)
- [ ] Audit log entry created
### Quality Requirements
- [ ] All actions logged in admin_logs table
- [ ] Bilingual confirmation messages
- [ ] Clear visual states for account status
- [ ] Tests for all lifecycle operations
## Technical Notes
### User Status Management
```php
// User model
public function isActive(): bool
{
return $this->status === 'active';
}
public function isDeactivated(): bool
{
return $this->status === 'deactivated';
}
public function deactivate(): void
{
$this->update(['status' => 'deactivated']);
}
public function reactivate(): void
{
$this->update(['status' => 'active']);
}
```
### Authentication Check
```php
// In FortifyServiceProvider or custom auth
Fortify::authenticateUsing(function (Request $request) {
$user = User::where('email', $request->email)->first();
if ($user &&
$user->isActive() &&
Hash::check($request->password, $user->password)) {
return $user;
}
// Optionally: different error for deactivated
return null;
});
```
### Cascade Deletion
```php
// In User model
protected static function booted(): void
{
static::deleting(function (User $user) {
// Log before deletion
AdminLog::create([
'admin_id' => auth()->id(),
'action_type' => 'delete',
'target_type' => 'user',
'target_id' => $user->id,
'old_values' => $user->toArray(),
'ip_address' => request()->ip(),
]);
// Cascade delete (or use foreign key CASCADE)
$user->consultations()->delete();
$user->timelines->each(function ($timeline) {
$timeline->updates()->delete();
$timeline->delete();
});
$user->notifications()->delete();
});
}
```
### Volt Component for Lifecycle Actions
```php
<?php
use App\Models\User;
use Livewire\Volt\Component;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Hash;
new class extends Component {
public User $user;
public string $deleteConfirmation = '';
public bool $showDeleteModal = false;
public function deactivate(): void
{
$this->user->deactivate();
AdminLog::create([
'admin_id' => auth()->id(),
'action_type' => 'deactivate',
'target_type' => 'user',
'target_id' => $this->user->id,
'old_values' => ['status' => 'active'],
'new_values' => ['status' => 'deactivated'],
'ip_address' => request()->ip(),
]);
session()->flash('success', __('messages.user_deactivated'));
}
public function reactivate(): void
{
$this->user->reactivate();
// Send notification
$this->user->notify(new AccountReactivatedNotification());
// Log action
AdminLog::create([...]);
session()->flash('success', __('messages.user_reactivated'));
}
public function delete(): void
{
if ($this->deleteConfirmation !== $this->user->email) {
$this->addError('deleteConfirmation', __('validation.email_confirmation'));
return;
}
$this->user->delete();
session()->flash('success', __('messages.user_deleted'));
$this->redirect(route('admin.users.index'));
}
public function resetPassword(): void
{
$newPassword = Str::random(12);
$this->user->update([
'password' => Hash::make($newPassword),
]);
// Send new credentials email
$this->user->notify(new PasswordResetByAdminNotification($newPassword));
// Log action
AdminLog::create([
'admin_id' => auth()->id(),
'action_type' => 'password_reset',
'target_type' => 'user',
'target_id' => $this->user->id,
'ip_address' => request()->ip(),
]);
session()->flash('success', __('messages.password_reset_sent'));
}
};
```
### Delete Confirmation Modal
```blade
<flux:modal wire:model="showDeleteModal">
<flux:heading>{{ __('messages.confirm_delete') }}</flux:heading>
<flux:callout variant="danger">
{{ __('messages.delete_warning') }}
</flux:callout>
<p>{{ __('messages.will_be_deleted') }}</p>
<ul class="list-disc ps-5 mt-2">
<li>{{ __('messages.all_consultations') }}</li>
<li>{{ __('messages.all_timelines') }}</li>
<li>{{ __('messages.all_notifications') }}</li>
</ul>
<flux:field>
<flux:label>{{ __('messages.type_email_to_confirm', ['email' => $user->email]) }}</flux:label>
<flux:input wire:model="deleteConfirmation" />
<flux:error name="deleteConfirmation" />
</flux:field>
<div class="flex gap-3 mt-4">
<flux:button wire:click="$set('showDeleteModal', false)">
{{ __('messages.cancel') }}
</flux:button>
<flux:button variant="danger" wire:click="delete">
{{ __('messages.delete_permanently') }}
</flux:button>
</div>
</flux:modal>
```
## Definition of Done
- [ ] Deactivate prevents login but preserves data
- [ ] Reactivate restores login ability
- [ ] Delete permanently removes all user data
- [ ] Delete requires email confirmation
- [ ] Password reset sends new credentials
- [ ] Visual indicators show account status
- [ ] Audit logging for all actions
- [ ] Email notifications sent appropriately
- [ ] Bilingual support complete
- [ ] Tests for all lifecycle states
- [ ] Code formatted with Pint
## Dependencies
- **Story 2.1:** Individual client management
- **Story 2.2:** Company client management
- **Epic 3:** Consultations (cascade delete)
- **Epic 4:** Timelines (cascade delete)
## Risk Assessment
- **Primary Risk:** Accidental permanent deletion
- **Mitigation:** Strong confirmation dialog, email confirmation, audit log
- **Rollback:** Not possible for delete - warn user clearly
## Estimation
**Complexity:** Medium
**Estimated Effort:** 4-5 hours

View File

@ -0,0 +1,278 @@
# Story 2.5: Account Creation Email Notification
## Epic Reference
**Epic 2:** User Management System
## User Story
As an **admin**,
I want **welcome emails sent automatically when I create client accounts**,
So that **clients receive their login credentials and know how to access the platform**.
## Story Context
### Existing System Integration
- **Integrates with:** User creation flow, Laravel Mail
- **Technology:** Laravel Mailable, queued jobs
- **Follows pattern:** Laravel notification/mailable patterns
- **Touch points:** User model events, email templates
## Acceptance Criteria
### Welcome Email Trigger
- [ ] Email sent automatically on account creation
- [ ] Works for both individual and company accounts
- [ ] Queued for performance (async sending)
- [ ] No email sent for admin accounts
### Email Content
- [ ] Personalized greeting:
- Individual: "Dear [Name]"
- Company: "Dear [Company Name]"
- [ ] Message: "Your account has been created"
- [ ] Login credentials:
- Email address
- Password (shown in email)
- [ ] Login URL (clickable button/link)
- [ ] Brief platform introduction
- [ ] Contact information for questions
### Email Design
- [ ] Professional template with Libra branding
- [ ] Colors: Navy blue (#0A1F44) and Gold (#D4AF37)
- [ ] Libra logo in header
- [ ] Footer with firm information
- [ ] Mobile-responsive layout
### Sender Configuration
- [ ] From: no-reply@libra.ps
- [ ] From Name: Libra Law Firm / مكتب ليبرا للمحاماة
- [ ] Reply-To: (firm contact email)
### Language Support
- [ ] Email in user's preferred_language
- [ ] Arabic email for Arabic preference
- [ ] English email for English preference
- [ ] All text translated
### Plain Text Fallback
- [ ] Plain text version generated
- [ ] All essential information included
- [ ] Readable without HTML
### Quality Requirements
- [ ] Email passes spam filters
- [ ] Links work correctly
- [ ] Password visible but not overly prominent
- [ ] Tests verify email sending
## Technical Notes
### Mailable Class
```php
<?php
namespace App\Mail;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class WelcomeEmail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public User $user,
public string $password
) {}
public function envelope(): Envelope
{
$locale = $this->user->preferred_language ?? 'ar';
return new Envelope(
subject: $locale === 'ar'
? 'مرحباً بك في مكتب ليبرا للمحاماة'
: 'Welcome to Libra Law Firm',
);
}
public function content(): Content
{
$locale = $this->user->preferred_language ?? 'ar';
return new Content(
markdown: "emails.welcome.{$locale}",
with: [
'user' => $this->user,
'password' => $this->password,
'loginUrl' => route('login'),
],
);
}
}
```
### Email Template (Arabic)
```blade
<!-- resources/views/emails/welcome/ar.blade.php -->
<x-mail::message>
# مرحباً بك في مكتب ليبرا للمحاماة
@if($user->user_type === 'company')
عزيزي {{ $user->company_name }},
@else
عزيزي {{ $user->name }},
@endif
تم إنشاء حسابك بنجاح على منصة مكتب ليبرا للمحاماة.
**بيانات تسجيل الدخول:**
- **البريد الإلكتروني:** {{ $user->email }}
- **كلمة المرور:** {{ $password }}
<x-mail::button :url="$loginUrl">
تسجيل الدخول
</x-mail::button>
يمكنك الآن الوصول إلى:
- حجز المواعيد
- متابعة قضاياك
- عرض التحديثات
إذا كان لديك أي استفسار، لا تتردد في التواصل معنا.
مع أطيب التحيات,<br>
مكتب ليبرا للمحاماة
</x-mail::message>
```
### Email Template (English)
```blade
<!-- resources/views/emails/welcome/en.blade.php -->
<x-mail::message>
# Welcome to Libra Law Firm
@if($user->user_type === 'company')
Dear {{ $user->company_name }},
@else
Dear {{ $user->name }},
@endif
Your account has been successfully created on the Libra Law Firm platform.
**Login Credentials:**
- **Email:** {{ $user->email }}
- **Password:** {{ $password }}
<x-mail::button :url="$loginUrl">
Login Now
</x-mail::button>
You can now access:
- Book consultations
- Track your cases
- View updates
If you have any questions, please don't hesitate to contact us.
Best regards,<br>
Libra Law Firm
</x-mail::message>
```
### Trigger on User Creation
```php
// In User creation flow (Story 2.1/2.2)
public function create(): void
{
$validated = $this->validate();
$plainPassword = $this->password;
$user = User::create([
...$validated,
'password' => Hash::make($this->password),
]);
// Send welcome email with plain password
Mail::to($user)->queue(new WelcomeEmail($user, $plainPassword));
session()->flash('success', __('messages.user_created'));
}
```
### Email Theme Customization
```php
// In AppServiceProvider boot()
use Illuminate\Support\Facades\View;
View::composer('vendor.mail.*', function ($view) {
$view->with('logoUrl', asset('images/logo.png'));
$view->with('primaryColor', '#0A1F44');
$view->with('accentColor', '#D4AF37');
});
```
### Testing
```php
use App\Mail\WelcomeEmail;
use Illuminate\Support\Facades\Mail;
it('sends welcome email on user creation', function () {
Mail::fake();
// Create user through admin flow
// ...
Mail::assertQueued(WelcomeEmail::class, function ($mail) use ($user) {
return $mail->user->id === $user->id;
});
});
it('sends email in user preferred language', function () {
Mail::fake();
$user = User::factory()->create(['preferred_language' => 'ar']);
Mail::to($user)->send(new WelcomeEmail($user, 'password123'));
Mail::assertSent(WelcomeEmail::class, function ($mail) {
return str_contains($mail->envelope()->subject, 'مرحباً');
});
});
```
## Definition of Done
- [ ] Welcome email sent on user creation
- [ ] Email contains all required information
- [ ] Login credentials included
- [ ] Branding matches design guidelines
- [ ] Arabic email for Arabic preference
- [ ] English email for English preference
- [ ] Plain text fallback works
- [ ] Email queued (not blocking)
- [ ] Tests verify email sending
- [ ] Code formatted with Pint
## Dependencies
- **Story 2.1:** Individual client creation
- **Story 2.2:** Company client creation
- **Epic 8:** Full email infrastructure (shared base template)
## Risk Assessment
- **Primary Risk:** Email delivery failures
- **Mitigation:** Queue with retry, logging, admin notification on failure
- **Rollback:** Manual credential sharing if email fails
## Estimation
**Complexity:** Medium
**Estimated Effort:** 3-4 hours

View File

@ -0,0 +1,261 @@
# Story 3.1: Working Hours Configuration
## Epic Reference
**Epic 3:** Booking & Consultation System
## User Story
As an **admin**,
I want **to configure available working hours for each day of the week**,
So that **clients can only book consultations during my available times**.
## Story Context
### Existing System Integration
- **Integrates with:** working_hours table, availability calendar
- **Technology:** Livewire Volt, Flux UI forms
- **Follows pattern:** Admin settings pattern
- **Touch points:** Booking availability calculation
## Acceptance Criteria
### Working Hours Management
- [ ] Set available days (enable/disable each day of week)
- [ ] Set start time for each enabled day
- [ ] Set end time for each enabled day
- [ ] Support different hours for different days
- [ ] 15-minute buffer automatically applied between appointments
- [ ] 12-hour time format display (AM/PM)
### Configuration Interface
- [ ] Visual weekly schedule view
- [ ] Toggle for each day (Sunday-Saturday)
- [ ] Time pickers for start/end times
- [ ] Preview of available slots per day
- [ ] Save button with confirmation
### Behavior
- [ ] Changes take effect immediately for new bookings
- [ ] Existing approved bookings NOT affected by changes
- [ ] Warning if changing hours that have pending bookings
- [ ] Validation: end time must be after start time
### Quality Requirements
- [ ] Bilingual labels and messages
- [ ] Default working hours on initial setup
- [ ] Audit log entry on changes
- [ ] Tests for configuration logic
## Technical Notes
### Database Schema
```php
// working_hours table
Schema::create('working_hours', function (Blueprint $table) {
$table->id();
$table->tinyInteger('day_of_week'); // 0=Sunday, 6=Saturday
$table->time('start_time');
$table->time('end_time');
$table->boolean('is_active')->default(true);
$table->timestamps();
});
```
### Model
```php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class WorkingHour extends Model
{
protected $fillable = [
'day_of_week',
'start_time',
'end_time',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
];
public static function getDayName(int $dayOfWeek, string $locale = null): string
{
$locale = $locale ?? app()->getLocale();
$days = [
'en' => ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
'ar' => ['الأحد', 'الإثنين', 'الثلاثاء', 'الأربعاء', 'الخميس', 'الجمعة', 'السبت'],
];
return $days[$locale][$dayOfWeek] ?? $days['en'][$dayOfWeek];
}
public function getSlots(int $duration = 60): array
{
$slots = [];
$start = Carbon::parse($this->start_time);
$end = Carbon::parse($this->end_time);
while ($start->copy()->addMinutes($duration)->lte($end)) {
$slots[] = $start->format('H:i');
$start->addMinutes($duration);
}
return $slots;
}
}
```
### Volt Component
```php
<?php
use App\Models\WorkingHour;
use Livewire\Volt\Component;
new class extends Component {
public array $schedule = [];
public function mount(): void
{
// Initialize with existing or default schedule
for ($day = 0; $day <= 6; $day++) {
$workingHour = WorkingHour::where('day_of_week', $day)->first();
$this->schedule[$day] = [
'is_active' => $workingHour?->is_active ?? false,
'start_time' => $workingHour?->start_time ?? '09:00',
'end_time' => $workingHour?->end_time ?? '17:00',
];
}
}
public function save(): void
{
foreach ($this->schedule as $day => $config) {
WorkingHour::updateOrCreate(
['day_of_week' => $day],
[
'is_active' => $config['is_active'],
'start_time' => $config['start_time'],
'end_time' => $config['end_time'],
]
);
}
// Log action
AdminLog::create([
'admin_id' => auth()->id(),
'action_type' => 'update',
'target_type' => 'working_hours',
'new_values' => $this->schedule,
'ip_address' => request()->ip(),
]);
session()->flash('success', __('messages.working_hours_saved'));
}
};
```
### Blade Template
```blade
<div>
<flux:heading>{{ __('admin.working_hours') }}</flux:heading>
@foreach(range(0, 6) as $day)
<div class="flex items-center gap-4 py-3 border-b">
<flux:switch
wire:model.live="schedule.{{ $day }}.is_active"
/>
<span class="w-24">
{{ \App\Models\WorkingHour::getDayName($day) }}
</span>
@if($schedule[$day]['is_active'])
<flux:input
type="time"
wire:model="schedule.{{ $day }}.start_time"
/>
<span>{{ __('common.to') }}</span>
<flux:input
type="time"
wire:model="schedule.{{ $day }}.end_time"
/>
@else
<span class="text-charcoal/50">{{ __('admin.closed') }}</span>
@endif
</div>
@endforeach
<flux:button wire:click="save" class="mt-4">
{{ __('common.save') }}
</flux:button>
</div>
```
### Slot Calculation Service
```php
<?php
namespace App\Services;
class AvailabilityService
{
public function getAvailableSlots(Carbon $date): array
{
$dayOfWeek = $date->dayOfWeek;
$workingHour = WorkingHour::where('day_of_week', $dayOfWeek)
->where('is_active', true)
->first();
if (!$workingHour) {
return [];
}
// Get all slots for the day
$slots = $workingHour->getSlots(60); // 1 hour slots (45min + 15min buffer)
// Remove already booked slots
$bookedSlots = Consultation::where('scheduled_date', $date->toDateString())
->whereIn('status', ['pending', 'approved'])
->pluck('scheduled_time')
->map(fn($time) => Carbon::parse($time)->format('H:i'))
->toArray();
// Remove blocked times
$blockedSlots = $this->getBlockedSlots($date);
return array_diff($slots, $bookedSlots, $blockedSlots);
}
}
```
## Definition of Done
- [ ] Can enable/disable each day of week
- [ ] Can set start/end times per day
- [ ] Changes save correctly to database
- [ ] Existing bookings not affected
- [ ] Preview shows available slots
- [ ] 12-hour time format displayed
- [ ] Audit log created on save
- [ ] Bilingual support complete
- [ ] Tests for configuration
- [ ] Code formatted with Pint
## Dependencies
- **Epic 1:** Database schema, admin authentication
## Risk Assessment
- **Primary Risk:** Changing hours affects availability incorrectly
- **Mitigation:** Clear separation between existing bookings and new availability
- **Rollback:** Restore previous working hours from audit log
## Estimation
**Complexity:** Medium
**Estimated Effort:** 3-4 hours

View File

@ -0,0 +1,328 @@
# 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

View File

@ -0,0 +1,407 @@
# Story 3.3: Availability Calendar Display
## Epic Reference
**Epic 3:** Booking & Consultation System
## User Story
As a **client**,
I want **to see a calendar with available time slots**,
So that **I can choose a convenient time for my consultation**.
## Story Context
### Existing System Integration
- **Integrates with:** working_hours, blocked_times, consultations tables
- **Technology:** Livewire Volt, JavaScript calendar library
- **Follows pattern:** Real-time availability checking
- **Touch points:** Booking submission flow
## Acceptance Criteria
### Calendar Display
- [ ] Monthly calendar view showing available dates
- [ ] Visual distinction for date states:
- Available (has open slots)
- Partially available (some slots taken)
- Unavailable (fully booked or blocked)
- Past dates (grayed out)
- [ ] Navigate between months
- [ ] Current month shown by default
### Time Slot Display
- [ ] Clicking a date shows available time slots
- [ ] 1-hour slots (45min consultation + 15min buffer)
- [ ] Clear indication of slot availability
- [ ] Unavailable reasons (optional):
- Already booked
- Outside working hours
- Blocked by admin
### Real-time Updates
- [ ] Availability checked on date selection
- [ ] Prevent double-booking (race condition handling)
- [ ] Refresh availability when navigating months
### Responsive Design
- [ ] Mobile-friendly calendar
- [ ] Touch-friendly slot selection
- [ ] Proper RTL support for Arabic
### Quality Requirements
- [ ] Fast loading (eager load data)
- [ ] Language-appropriate date formatting
- [ ] Accessible (keyboard navigation)
- [ ] Tests for availability logic
## Technical Notes
### Availability Service
```php
<?php
namespace App\Services;
use App\Models\{WorkingHour, BlockedTime, Consultation};
use Carbon\Carbon;
class AvailabilityService
{
public function getMonthAvailability(int $year, int $month): array
{
$startOfMonth = Carbon::create($year, $month, 1)->startOfMonth();
$endOfMonth = $startOfMonth->copy()->endOfMonth();
$availability = [];
$current = $startOfMonth->copy();
while ($current->lte($endOfMonth)) {
$availability[$current->format('Y-m-d')] = $this->getDateStatus($current);
$current->addDay();
}
return $availability;
}
public function getDateStatus(Carbon $date): string
{
// Past date
if ($date->lt(today())) {
return 'past';
}
// Check if fully blocked
if ($this->isDateFullyBlocked($date)) {
return 'blocked';
}
// Check working hours
$workingHour = WorkingHour::where('day_of_week', $date->dayOfWeek)
->where('is_active', true)
->first();
if (!$workingHour) {
return 'closed';
}
// Get available slots
$availableSlots = $this->getAvailableSlots($date);
if (empty($availableSlots)) {
return 'full';
}
$totalSlots = count($workingHour->getSlots(60));
if (count($availableSlots) < $totalSlots) {
return 'partial';
}
return 'available';
}
public function getAvailableSlots(Carbon $date): array
{
$workingHour = WorkingHour::where('day_of_week', $date->dayOfWeek)
->where('is_active', true)
->first();
if (!$workingHour) {
return [];
}
// All possible slots
$allSlots = $workingHour->getSlots(60);
// Booked slots
$bookedSlots = Consultation::where('scheduled_date', $date->toDateString())
->whereIn('status', ['pending', 'approved'])
->pluck('scheduled_time')
->map(fn($t) => Carbon::parse($t)->format('H:i'))
->toArray();
// Blocked slots
$blockedSlots = $this->getBlockedSlots($date);
return array_values(array_diff($allSlots, $bookedSlots, $blockedSlots));
}
private function getBlockedSlots(Carbon $date): array
{
$blockedTimes = BlockedTime::where('block_date', $date->toDateString())->get();
$blockedSlots = [];
foreach ($blockedTimes as $blocked) {
if ($blocked->isAllDay()) {
// Block all slots
$workingHour = WorkingHour::where('day_of_week', $date->dayOfWeek)->first();
return $workingHour ? $workingHour->getSlots(60) : [];
}
// Calculate blocked slots from time 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);
}
private function isDateFullyBlocked(Carbon $date): bool
{
return BlockedTime::where('block_date', $date->toDateString())
->whereNull('start_time')
->exists();
}
}
```
### Volt Component
```php
<?php
use App\Services\AvailabilityService;
use Livewire\Volt\Component;
use Carbon\Carbon;
new class extends Component {
public int $year;
public int $month;
public ?string $selectedDate = null;
public array $monthAvailability = [];
public array $availableSlots = [];
public function mount(): void
{
$this->year = now()->year;
$this->month = now()->month;
$this->loadMonthAvailability();
}
public function loadMonthAvailability(): void
{
$service = app(AvailabilityService::class);
$this->monthAvailability = $service->getMonthAvailability($this->year, $this->month);
}
public function previousMonth(): void
{
$date = Carbon::create($this->year, $this->month, 1)->subMonth();
$this->year = $date->year;
$this->month = $date->month;
$this->selectedDate = null;
$this->availableSlots = [];
$this->loadMonthAvailability();
}
public function nextMonth(): void
{
$date = Carbon::create($this->year, $this->month, 1)->addMonth();
$this->year = $date->year;
$this->month = $date->month;
$this->selectedDate = null;
$this->availableSlots = [];
$this->loadMonthAvailability();
}
public function selectDate(string $date): void
{
$status = $this->monthAvailability[$date] ?? 'unavailable';
if (in_array($status, ['available', 'partial'])) {
$this->selectedDate = $date;
$this->loadAvailableSlots();
}
}
public function loadAvailableSlots(): void
{
if (!$this->selectedDate) {
$this->availableSlots = [];
return;
}
$service = app(AvailabilityService::class);
$this->availableSlots = $service->getAvailableSlots(
Carbon::parse($this->selectedDate)
);
}
public function with(): array
{
return [
'monthName' => Carbon::create($this->year, $this->month, 1)->translatedFormat('F Y'),
'calendarDays' => $this->buildCalendarDays(),
];
}
private function buildCalendarDays(): array
{
$firstDay = Carbon::create($this->year, $this->month, 1);
$lastDay = $firstDay->copy()->endOfMonth();
// Pad start of month
$startPadding = $firstDay->dayOfWeek;
$days = array_fill(0, $startPadding, null);
// Fill month days
$current = $firstDay->copy();
while ($current->lte($lastDay)) {
$dateStr = $current->format('Y-m-d');
$days[] = [
'date' => $dateStr,
'day' => $current->day,
'status' => $this->monthAvailability[$dateStr] ?? 'unavailable',
];
$current->addDay();
}
return $days;
}
};
```
### Blade Template
```blade
<div>
<!-- Calendar Header -->
<div class="flex items-center justify-between mb-4">
<flux:button size="sm" wire:click="previousMonth">
<flux:icon name="chevron-{{ app()->getLocale() === 'ar' ? 'right' : 'left' }}" />
</flux:button>
<flux:heading size="lg">{{ $monthName }}</flux:heading>
<flux:button size="sm" wire:click="nextMonth">
<flux:icon name="chevron-{{ app()->getLocale() === 'ar' ? 'left' : 'right' }}" />
</flux:button>
</div>
<!-- Day Headers -->
<div class="grid grid-cols-7 gap-1 mb-2">
@foreach(['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as $day)
<div class="text-center text-sm font-semibold text-charcoal">
{{ __("calendar.{$day}") }}
</div>
@endforeach
</div>
<!-- Calendar Grid -->
<div class="grid grid-cols-7 gap-1">
@foreach($calendarDays as $dayData)
@if($dayData === null)
<div class="h-12"></div>
@else
<button
wire:click="selectDate('{{ $dayData['date'] }}')"
@class([
'h-12 rounded-lg text-center transition-colors',
'bg-success/20 text-success hover:bg-success/30' => $dayData['status'] === 'available',
'bg-warning/20 text-warning hover:bg-warning/30' => $dayData['status'] === 'partial',
'bg-charcoal/10 text-charcoal/50 cursor-not-allowed' => in_array($dayData['status'], ['past', 'closed', 'blocked', 'full']),
'ring-2 ring-gold' => $selectedDate === $dayData['date'],
])
@disabled(in_array($dayData['status'], ['past', 'closed', 'blocked', 'full']))
>
{{ $dayData['day'] }}
</button>
@endif
@endforeach
</div>
<!-- Time Slots -->
@if($selectedDate)
<div class="mt-6">
<flux:heading size="sm" class="mb-3">
{{ __('booking.available_times') }} -
{{ \Carbon\Carbon::parse($selectedDate)->translatedFormat('d M Y') }}
</flux:heading>
@if(count($availableSlots) > 0)
<div class="grid grid-cols-3 sm:grid-cols-4 gap-2">
@foreach($availableSlots as $slot)
<button
wire:click="$parent.selectSlot('{{ $selectedDate }}', '{{ $slot }}')"
class="p-3 rounded-lg border border-gold text-gold hover:bg-gold hover:text-navy transition-colors"
>
{{ \Carbon\Carbon::parse($slot)->format('g:i A') }}
</button>
@endforeach
</div>
@else
<p class="text-charcoal/70">{{ __('booking.no_slots_available') }}</p>
@endif
</div>
@endif
<!-- Legend -->
<div class="flex gap-4 mt-6 text-sm">
<div class="flex items-center gap-2">
<div class="w-4 h-4 rounded bg-success/20"></div>
<span>{{ __('booking.available') }}</span>
</div>
<div class="flex items-center gap-2">
<div class="w-4 h-4 rounded bg-warning/20"></div>
<span>{{ __('booking.partial') }}</span>
</div>
<div class="flex items-center gap-2">
<div class="w-4 h-4 rounded bg-charcoal/10"></div>
<span>{{ __('booking.unavailable') }}</span>
</div>
</div>
</div>
```
## Definition of Done
- [ ] Calendar displays current month
- [ ] Can navigate between months
- [ ] Available dates clearly indicated
- [ ] Clicking date shows time slots
- [ ] Time slots in 1-hour increments
- [ ] Prevents selecting unavailable dates/times
- [ ] Real-time availability updates
- [ ] Mobile responsive
- [ ] RTL support for Arabic
- [ ] Tests for availability logic
- [ ] Code formatted with Pint
## Dependencies
- **Story 3.1:** Working hours (defines available time)
- **Story 3.2:** Blocked times (removes availability)
- **Story 3.4:** Booking submission (consumes selected slot)
## Risk Assessment
- **Primary Risk:** Race condition on slot selection
- **Mitigation:** Database-level unique constraint, check on submission
- **Rollback:** Refresh availability if booking fails
## Estimation
**Complexity:** High
**Estimated Effort:** 5-6 hours

View File

@ -0,0 +1,334 @@
# Story 3.4: Booking Request Submission
## Epic Reference
**Epic 3:** Booking & Consultation System
## User Story
As a **client**,
I want **to submit a consultation booking request**,
So that **I can schedule a meeting with the lawyer**.
## Story Context
### Existing System Integration
- **Integrates with:** consultations table, availability calendar, notifications
- **Technology:** Livewire Volt, form validation
- **Follows pattern:** Form submission with confirmation
- **Touch points:** Client dashboard, admin notifications
## Acceptance Criteria
### Booking Form
- [ ] Client must be logged in
- [ ] Select date from availability calendar
- [ ] Select available time slot
- [ ] Problem summary field (required, textarea)
- [ ] Confirmation before submission
### Validation & Constraints
- [ ] Validate: no more than 1 booking per day for this client
- [ ] Validate: selected slot is still available
- [ ] Validate: problem summary is not empty
- [ ] Show clear error messages for violations
### Submission Flow
- [ ] Booking enters "pending" status
- [ ] Client sees "Pending Review" confirmation
- [ ] Admin receives email notification
- [ ] Client receives submission confirmation email
- [ ] Redirect to consultations list after submission
### UI/UX
- [ ] Clear step-by-step flow
- [ ] Loading state during submission
- [ ] Success message with next steps
- [ ] Bilingual labels and messages
### Quality Requirements
- [ ] Prevent double-booking (race condition)
- [ ] Audit log entry for booking creation
- [ ] Tests for submission flow
- [ ] Tests for validation rules
## Technical Notes
### Database Record
```php
// consultations table fields on creation
$consultation = Consultation::create([
'user_id' => auth()->id(),
'scheduled_date' => $selectedDate,
'scheduled_time' => $selectedTime,
'duration' => 45, // default
'status' => 'pending',
'type' => null, // admin sets this later
'payment_amount' => null,
'payment_status' => 'not_applicable',
'problem_summary' => $problemSummary,
]);
```
### Volt Component
```php
<?php
use App\Models\Consultation;
use App\Services\AvailabilityService;
use App\Notifications\BookingSubmittedClient;
use App\Notifications\NewBookingAdmin;
use Livewire\Volt\Component;
use Carbon\Carbon;
new class extends Component {
public ?string $selectedDate = null;
public ?string $selectedTime = null;
public string $problemSummary = '';
public bool $showConfirmation = false;
public function selectSlot(string $date, string $time): void
{
$this->selectedDate = $date;
$this->selectedTime = $time;
}
public function clearSelection(): void
{
$this->selectedDate = null;
$this->selectedTime = null;
}
public function showConfirm(): void
{
$this->validate([
'selectedDate' => ['required', 'date', 'after_or_equal:today'],
'selectedTime' => ['required'],
'problemSummary' => ['required', 'string', 'min:20', 'max:2000'],
]);
// Check 1-per-day limit
$existingBooking = Consultation::where('user_id', auth()->id())
->where('scheduled_date', $this->selectedDate)
->whereIn('status', ['pending', 'approved'])
->exists();
if ($existingBooking) {
$this->addError('selectedDate', __('booking.already_booked_this_day'));
return;
}
// Verify slot still available
$service = app(AvailabilityService::class);
$availableSlots = $service->getAvailableSlots(Carbon::parse($this->selectedDate));
if (!in_array($this->selectedTime, $availableSlots)) {
$this->addError('selectedTime', __('booking.slot_no_longer_available'));
return;
}
$this->showConfirmation = true;
}
public function submit(): void
{
// Double-check availability with lock
DB::transaction(function () {
// Check slot one more time with lock
$exists = Consultation::where('scheduled_date', $this->selectedDate)
->where('scheduled_time', $this->selectedTime)
->whereIn('status', ['pending', 'approved'])
->lockForUpdate()
->exists();
if ($exists) {
throw new \Exception(__('booking.slot_taken'));
}
// Check 1-per-day again
$userBooking = Consultation::where('user_id', auth()->id())
->where('scheduled_date', $this->selectedDate)
->whereIn('status', ['pending', 'approved'])
->lockForUpdate()
->exists();
if ($userBooking) {
throw new \Exception(__('booking.already_booked_this_day'));
}
// Create booking
$consultation = Consultation::create([
'user_id' => auth()->id(),
'scheduled_date' => $this->selectedDate,
'scheduled_time' => $this->selectedTime,
'duration' => 45,
'status' => 'pending',
'problem_summary' => $this->problemSummary,
]);
// Send notifications
auth()->user()->notify(new BookingSubmittedClient($consultation));
// Notify admin
$admin = User::where('user_type', 'admin')->first();
$admin?->notify(new NewBookingAdmin($consultation));
// Log action
AdminLog::create([
'admin_id' => null, // Client action
'action_type' => 'create',
'target_type' => 'consultation',
'target_id' => $consultation->id,
'new_values' => $consultation->toArray(),
'ip_address' => request()->ip(),
]);
});
session()->flash('success', __('booking.submitted_successfully'));
$this->redirect(route('client.consultations.index'));
}
};
```
### Blade Template
```blade
<div class="max-w-4xl mx-auto">
<flux:heading>{{ __('booking.request_consultation') }}</flux:heading>
@if(!$selectedDate || !$selectedTime)
<!-- Step 1: Calendar Selection -->
<div class="mt-6">
<p class="mb-4">{{ __('booking.select_date_time') }}</p>
<livewire:booking.availability-calendar
@slot-selected="selectSlot($event.detail.date, $event.detail.time)"
/>
</div>
@else
<!-- Step 2: Problem Summary -->
<div class="mt-6">
<!-- Selected Time Display -->
<div class="bg-gold/10 p-4 rounded-lg mb-6">
<div class="flex justify-between items-center">
<div>
<p class="font-semibold">{{ __('booking.selected_time') }}</p>
<p>{{ \Carbon\Carbon::parse($selectedDate)->translatedFormat('l, d M Y') }}</p>
<p>{{ \Carbon\Carbon::parse($selectedTime)->format('g:i A') }}</p>
</div>
<flux:button size="sm" wire:click="clearSelection">
{{ __('common.change') }}
</flux:button>
</div>
</div>
@if(!$showConfirmation)
<!-- Problem Summary Form -->
<flux:field>
<flux:label>{{ __('booking.problem_summary') }} *</flux:label>
<flux:textarea
wire:model="problemSummary"
rows="6"
placeholder="{{ __('booking.problem_summary_placeholder') }}"
/>
<flux:description>
{{ __('booking.problem_summary_help') }}
</flux:description>
<flux:error name="problemSummary" />
</flux:field>
<flux:button
wire:click="showConfirm"
class="mt-4"
wire:loading.attr="disabled"
>
<span wire:loading.remove>{{ __('booking.continue') }}</span>
<span wire:loading>{{ __('common.loading') }}</span>
</flux:button>
@else
<!-- Confirmation Step -->
<flux:callout>
<flux:heading size="sm">{{ __('booking.confirm_booking') }}</flux:heading>
<p>{{ __('booking.confirm_message') }}</p>
<div class="mt-4 space-y-2">
<p><strong>{{ __('booking.date') }}:</strong>
{{ \Carbon\Carbon::parse($selectedDate)->translatedFormat('l, d M Y') }}</p>
<p><strong>{{ __('booking.time') }}:</strong>
{{ \Carbon\Carbon::parse($selectedTime)->format('g:i A') }}</p>
<p><strong>{{ __('booking.duration') }}:</strong> 45 {{ __('common.minutes') }}</p>
</div>
<div class="mt-4">
<p><strong>{{ __('booking.problem_summary') }}:</strong></p>
<p class="mt-1 text-sm">{{ $problemSummary }}</p>
</div>
</flux:callout>
<div class="flex gap-3 mt-4">
<flux:button wire:click="$set('showConfirmation', false)">
{{ __('common.back') }}
</flux:button>
<flux:button
wire:click="submit"
variant="primary"
wire:loading.attr="disabled"
>
<span wire:loading.remove>{{ __('booking.submit_request') }}</span>
<span wire:loading>{{ __('common.submitting') }}</span>
</flux:button>
</div>
@endif
</div>
@endif
</div>
```
### 1-Per-Day Validation Rule
```php
// Custom validation rule
use Illuminate\Contracts\Validation\ValidationRule;
class OneBookingPerDay implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$exists = Consultation::where('user_id', auth()->id())
->where('scheduled_date', $value)
->whereIn('status', ['pending', 'approved'])
->exists();
if ($exists) {
$fail(__('booking.already_booked_this_day'));
}
}
}
```
## Definition of Done
- [ ] Can select date from calendar
- [ ] Can select time slot
- [ ] Problem summary required
- [ ] 1-per-day limit enforced
- [ ] Race condition prevented
- [ ] Confirmation step before submission
- [ ] Booking created with "pending" status
- [ ] Client notification sent
- [ ] Admin notification sent
- [ ] Bilingual support complete
- [ ] Tests for submission flow
- [ ] Code formatted with Pint
## Dependencies
- **Story 3.3:** Availability calendar
- **Epic 2:** User authentication
- **Epic 8:** Email notifications (partial)
## Risk Assessment
- **Primary Risk:** Double-booking from concurrent submissions
- **Mitigation:** Database transaction with row locking
- **Rollback:** Return to calendar with error message
## Estimation
**Complexity:** Medium-High
**Estimated Effort:** 4-5 hours

View File

@ -0,0 +1,328 @@
# Story 3.5: Admin Booking Review & Approval
## Epic Reference
**Epic 3:** Booking & Consultation System
## User Story
As an **admin**,
I want **to review, categorize, and approve or reject booking requests**,
So that **I can manage my consultation schedule and set appropriate consultation types**.
## Story Context
### Existing System Integration
- **Integrates with:** consultations table, notifications, .ics generation
- **Technology:** Livewire Volt, Flux UI
- **Follows pattern:** Admin action workflow
- **Touch points:** Client notifications, calendar file
## Acceptance Criteria
### Pending Bookings List
- [ ] View all pending booking requests
- [ ] Display: client name, requested date/time, submission date
- [ ] Show problem summary preview
- [ ] Click to view full details
- [ ] Sort by date (oldest first default)
- [ ] Filter by date range
### Booking Details View
- [ ] Full client information
- [ ] Complete problem summary
- [ ] Client consultation history
- [ ] Requested date and time
### Approval Workflow
- [ ] Set consultation type:
- Free consultation
- Paid consultation
- [ ] If paid: set payment amount
- [ ] If paid: add payment instructions (optional)
- [ ] Approve button with confirmation
- [ ] On approval:
- Status changes to 'approved'
- Client notified via email
- .ics calendar file attached to email
- Payment instructions included if paid
### Rejection Workflow
- [ ] Optional rejection reason field
- [ ] Reject button with confirmation
- [ ] On rejection:
- Status changes to 'rejected'
- Client notified via email with reason
### Quick Actions
- [ ] Quick approve (free) button on list
- [ ] Quick reject button on list
- [ ] Bulk actions (optional)
### Quality Requirements
- [ ] Audit log for all decisions
- [ ] Bilingual notifications
- [ ] Tests for approval/rejection flow
## Technical Notes
### Consultation Status Flow
```
pending -> approved (admin approves)
pending -> rejected (admin rejects)
approved -> completed (after consultation)
approved -> no_show (client didn't attend)
approved -> cancelled (admin cancels)
```
### Volt Component for Review
```php
<?php
use App\Models\Consultation;
use App\Notifications\BookingApproved;
use App\Notifications\BookingRejected;
use App\Services\CalendarService;
use Livewire\Volt\Component;
new class extends Component {
public Consultation $consultation;
public string $consultationType = 'free';
public ?float $paymentAmount = null;
public string $paymentInstructions = '';
public string $rejectionReason = '';
public bool $showApproveModal = false;
public bool $showRejectModal = false;
public function mount(Consultation $consultation): void
{
$this->consultation = $consultation;
}
public function approve(): void
{
$this->validate([
'consultationType' => ['required', 'in:free,paid'],
'paymentAmount' => ['required_if:consultationType,paid', 'nullable', 'numeric', 'min:0'],
'paymentInstructions' => ['nullable', 'string', 'max:1000'],
]);
$this->consultation->update([
'status' => 'approved',
'type' => $this->consultationType,
'payment_amount' => $this->consultationType === 'paid' ? $this->paymentAmount : null,
'payment_status' => $this->consultationType === 'paid' ? 'pending' : 'not_applicable',
]);
// Generate calendar file
$calendarService = app(CalendarService::class);
$icsContent = $calendarService->generateIcs($this->consultation);
// Send notification with .ics attachment
$this->consultation->user->notify(
new BookingApproved(
$this->consultation,
$icsContent,
$this->paymentInstructions
)
);
// Log action
AdminLog::create([
'admin_id' => auth()->id(),
'action_type' => 'approve',
'target_type' => 'consultation',
'target_id' => $this->consultation->id,
'old_values' => ['status' => 'pending'],
'new_values' => [
'status' => 'approved',
'type' => $this->consultationType,
'payment_amount' => $this->paymentAmount,
],
'ip_address' => request()->ip(),
]);
session()->flash('success', __('messages.booking_approved'));
$this->redirect(route('admin.bookings.pending'));
}
public function reject(): void
{
$this->validate([
'rejectionReason' => ['nullable', 'string', 'max:1000'],
]);
$this->consultation->update([
'status' => 'rejected',
]);
// Send rejection notification
$this->consultation->user->notify(
new BookingRejected($this->consultation, $this->rejectionReason)
);
// Log action
AdminLog::create([
'admin_id' => auth()->id(),
'action_type' => 'reject',
'target_type' => 'consultation',
'target_id' => $this->consultation->id,
'old_values' => ['status' => 'pending'],
'new_values' => [
'status' => 'rejected',
'reason' => $this->rejectionReason,
],
'ip_address' => request()->ip(),
]);
session()->flash('success', __('messages.booking_rejected'));
$this->redirect(route('admin.bookings.pending'));
}
};
```
### Blade Template for Approval Modal
```blade
<flux:modal wire:model="showApproveModal">
<flux:heading>{{ __('admin.approve_booking') }}</flux:heading>
<div class="space-y-4 mt-4">
<!-- Client Info -->
<div class="bg-cream p-3 rounded-lg">
<p><strong>{{ __('admin.client') }}:</strong> {{ $consultation->user->name }}</p>
<p><strong>{{ __('admin.date') }}:</strong>
{{ $consultation->scheduled_date->translatedFormat('l, d M Y') }}</p>
<p><strong>{{ __('admin.time') }}:</strong>
{{ Carbon::parse($consultation->scheduled_time)->format('g:i A') }}</p>
</div>
<!-- Consultation Type -->
<flux:field>
<flux:label>{{ __('admin.consultation_type') }}</flux:label>
<flux:radio.group wire:model.live="consultationType">
<flux:radio value="free" label="{{ __('admin.free_consultation') }}" />
<flux:radio value="paid" label="{{ __('admin.paid_consultation') }}" />
</flux:radio.group>
</flux:field>
<!-- Payment Amount (if paid) -->
@if($consultationType === 'paid')
<flux:field>
<flux:label>{{ __('admin.payment_amount') }} *</flux:label>
<flux:input
type="number"
wire:model="paymentAmount"
step="0.01"
min="0"
/>
<flux:error name="paymentAmount" />
</flux:field>
<flux:field>
<flux:label>{{ __('admin.payment_instructions') }}</flux:label>
<flux:textarea
wire:model="paymentInstructions"
rows="3"
placeholder="{{ __('admin.payment_instructions_placeholder') }}"
/>
</flux:field>
@endif
</div>
<div class="flex gap-3 mt-6">
<flux:button wire:click="$set('showApproveModal', false)">
{{ __('common.cancel') }}
</flux:button>
<flux:button variant="primary" wire:click="approve">
{{ __('admin.approve') }}
</flux:button>
</div>
</flux:modal>
```
### Pending Bookings List Component
```php
<?php
use App\Models\Consultation;
use Livewire\Volt\Component;
use Livewire\WithPagination;
new class extends Component {
use WithPagination;
public string $dateFrom = '';
public string $dateTo = '';
public function with(): array
{
return [
'bookings' => Consultation::where('status', 'pending')
->when($this->dateFrom, fn($q) => $q->where('scheduled_date', '>=', $this->dateFrom))
->when($this->dateTo, fn($q) => $q->where('scheduled_date', '<=', $this->dateTo))
->with('user')
->orderBy('scheduled_date')
->orderBy('scheduled_time')
->paginate(15),
];
}
public function quickApprove(int $id): void
{
$consultation = Consultation::findOrFail($id);
$consultation->update([
'status' => 'approved',
'type' => 'free',
'payment_status' => 'not_applicable',
]);
// Generate and send notification with .ics
// ...
session()->flash('success', __('messages.booking_approved'));
}
public function quickReject(int $id): void
{
$consultation = Consultation::findOrFail($id);
$consultation->update(['status' => 'rejected']);
// Send rejection notification
// ...
session()->flash('success', __('messages.booking_rejected'));
}
};
```
## Definition of Done
- [ ] Pending bookings list displays correctly
- [ ] Can view booking details
- [ ] Can approve as free consultation
- [ ] Can approve as paid with amount
- [ ] Can reject with optional reason
- [ ] Approval sends email with .ics file
- [ ] Rejection sends email with reason
- [ ] Quick actions work from list
- [ ] Audit log entries created
- [ ] Bilingual support complete
- [ ] Tests for approval/rejection
- [ ] Code formatted with Pint
## Dependencies
- **Story 3.4:** Booking submission (creates pending bookings)
- **Story 3.6:** Calendar file generation (.ics)
- **Epic 8:** Email notifications
## Risk Assessment
- **Primary Risk:** Approving wrong booking
- **Mitigation:** Confirmation dialog, clear booking details display
- **Rollback:** Admin can cancel approved booking
## Estimation
**Complexity:** Medium
**Estimated Effort:** 4-5 hours

View File

@ -0,0 +1,309 @@
# Story 3.6: Calendar File Generation (.ics)
## Epic Reference
**Epic 3:** Booking & Consultation System
## User Story
As a **client**,
I want **to receive a calendar file when my booking is approved**,
So that **I can easily add the consultation to my calendar app**.
## Story Context
### Existing System Integration
- **Integrates with:** Consultation model, email attachments
- **Technology:** iCalendar format (RFC 5545)
- **Follows pattern:** Service class for generation
- **Touch points:** Approval email, client dashboard download
## Acceptance Criteria
### Calendar File Generation
- [ ] Generate valid .ics file on booking approval
- [ ] File follows iCalendar specification (RFC 5545)
- [ ] Compatible with major calendar apps:
- Google Calendar
- Apple Calendar
- Microsoft Outlook
- Other standard clients
### Event Details
- [ ] Event title: "Consultation with Libra Law Firm" (bilingual)
- [ ] Date and time (correct timezone)
- [ ] Duration: 45 minutes
- [ ] Location (office address or "Phone consultation")
- [ ] Description with:
- Booking reference
- Consultation type (free/paid)
- Contact information
- [ ] Reminder: 1 hour before
### Delivery
- [ ] Attach to approval email
- [ ] Available for download from client dashboard
- [ ] Proper MIME type (text/calendar)
- [ ] Correct filename (consultation-{date}.ics)
### Language Support
- [ ] Event title in client's preferred language
- [ ] Description in client's preferred language
### Quality Requirements
- [ ] Valid iCalendar format (passes validators)
- [ ] Tests for file generation
- [ ] Tests for calendar app compatibility
## Technical Notes
### Calendar Service
```php
<?php
namespace App\Services;
use App\Models\Consultation;
use Carbon\Carbon;
class CalendarService
{
public function generateIcs(Consultation $consultation): string
{
$user = $consultation->user;
$locale = $user->preferred_language ?? 'ar';
$startDateTime = Carbon::parse(
$consultation->scheduled_date->format('Y-m-d') . ' ' . $consultation->scheduled_time
);
$endDateTime = $startDateTime->copy()->addMinutes($consultation->duration);
$title = $locale === 'ar'
? 'استشارة مع مكتب ليبرا للمحاماة'
: 'Consultation with Libra Law Firm';
$description = $this->buildDescription($consultation, $locale);
$location = $this->getLocation($locale);
$uid = sprintf(
'consultation-%d@libra.ps',
$consultation->id
);
$ics = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Libra Law Firm//Consultation Booking//EN',
'CALSCALE:GREGORIAN',
'METHOD:REQUEST',
'BEGIN:VTIMEZONE',
'TZID:Asia/Jerusalem',
'BEGIN:STANDARD',
'DTSTART:19701025T020000',
'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU',
'TZOFFSETFROM:+0300',
'TZOFFSETTO:+0200',
'END:STANDARD',
'BEGIN:DAYLIGHT',
'DTSTART:19700329T020000',
'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1FR',
'TZOFFSETFROM:+0200',
'TZOFFSETTO:+0300',
'END:DAYLIGHT',
'END:VTIMEZONE',
'BEGIN:VEVENT',
'UID:' . $uid,
'DTSTAMP:' . gmdate('Ymd\THis\Z'),
'DTSTART;TZID=Asia/Jerusalem:' . $startDateTime->format('Ymd\THis'),
'DTEND;TZID=Asia/Jerusalem:' . $endDateTime->format('Ymd\THis'),
'SUMMARY:' . $this->escapeIcs($title),
'DESCRIPTION:' . $this->escapeIcs($description),
'LOCATION:' . $this->escapeIcs($location),
'STATUS:CONFIRMED',
'SEQUENCE:0',
'BEGIN:VALARM',
'TRIGGER:-PT1H',
'ACTION:DISPLAY',
'DESCRIPTION:Reminder',
'END:VALARM',
'END:VEVENT',
'END:VCALENDAR',
];
return implode("\r\n", $ics);
}
private function buildDescription(Consultation $consultation, string $locale): string
{
$lines = [];
if ($locale === 'ar') {
$lines[] = 'رقم الحجز: ' . $consultation->id;
$lines[] = 'نوع الاستشارة: ' . ($consultation->type === 'free' ? 'مجانية' : 'مدفوعة');
if ($consultation->type === 'paid') {
$lines[] = 'المبلغ: ' . number_format($consultation->payment_amount, 2) . ' شيكل';
}
$lines[] = '';
$lines[] = 'للاستفسارات:';
$lines[] = 'مكتب ليبرا للمحاماة';
$lines[] = 'libra.ps';
} else {
$lines[] = 'Booking Reference: ' . $consultation->id;
$lines[] = 'Consultation Type: ' . ucfirst($consultation->type);
if ($consultation->type === 'paid') {
$lines[] = 'Amount: ' . number_format($consultation->payment_amount, 2) . ' ILS';
}
$lines[] = '';
$lines[] = 'For inquiries:';
$lines[] = 'Libra Law Firm';
$lines[] = 'libra.ps';
}
return implode('\n', $lines);
}
private function getLocation(string $locale): string
{
// Configure in config/libra.php
return config('libra.office_address.' . $locale, 'Libra Law Firm');
}
private function escapeIcs(string $text): string
{
return str_replace(
[',', ';', '\\'],
['\,', '\;', '\\\\'],
$text
);
}
public function generateDownloadResponse(Consultation $consultation): \Symfony\Component\HttpFoundation\Response
{
$content = $this->generateIcs($consultation);
$filename = sprintf(
'consultation-%s.ics',
$consultation->scheduled_date->format('Y-m-d')
);
return response($content)
->header('Content-Type', 'text/calendar; charset=utf-8')
->header('Content-Disposition', 'attachment; filename="' . $filename . '"');
}
}
```
### Email Attachment
```php
// In BookingApproved notification
public function toMail(object $notifiable): MailMessage
{
$locale = $notifiable->preferred_language ?? 'ar';
return (new MailMessage)
->subject($this->getSubject($locale))
->markdown('emails.booking.approved.' . $locale, [
'consultation' => $this->consultation,
'paymentInstructions' => $this->paymentInstructions,
])
->attachData(
$this->icsContent,
'consultation.ics',
['mime' => 'text/calendar']
);
}
```
### Download Route
```php
// routes/web.php
Route::middleware(['auth'])->group(function () {
Route::get('/consultations/{consultation}/calendar', function (Consultation $consultation) {
// Verify user owns this consultation
abort_unless($consultation->user_id === auth()->id(), 403);
abort_unless($consultation->status === 'approved', 404);
return app(CalendarService::class)->generateDownloadResponse($consultation);
})->name('client.consultations.calendar');
});
```
### Client Dashboard Button
```blade
@if($consultation->status === 'approved')
<flux:button
size="sm"
href="{{ route('client.consultations.calendar', $consultation) }}"
>
<flux:icon name="calendar" class="w-4 h-4 me-1" />
{{ __('client.add_to_calendar') }}
</flux:button>
@endif
```
### Testing Calendar File
```php
use App\Services\CalendarService;
use App\Models\Consultation;
it('generates valid ics file', function () {
$consultation = Consultation::factory()->approved()->create([
'scheduled_date' => '2024-03-15',
'scheduled_time' => '10:00:00',
'duration' => 45,
]);
$service = new CalendarService();
$ics = $service->generateIcs($consultation);
expect($ics)
->toContain('BEGIN:VCALENDAR')
->toContain('BEGIN:VEVENT')
->toContain('DTSTART')
->toContain('DTEND')
->toContain('END:VCALENDAR');
});
it('includes correct duration', function () {
$consultation = Consultation::factory()->approved()->create([
'scheduled_date' => '2024-03-15',
'scheduled_time' => '10:00:00',
'duration' => 45,
]);
$service = new CalendarService();
$ics = $service->generateIcs($consultation);
// Start at 10:00, end at 10:45
expect($ics)
->toContain('DTSTART;TZID=Asia/Jerusalem:20240315T100000')
->toContain('DTEND;TZID=Asia/Jerusalem:20240315T104500');
});
```
## Definition of Done
- [ ] .ics file generated on approval
- [ ] File follows iCalendar RFC 5545
- [ ] Works with Google Calendar
- [ ] Works with Apple Calendar
- [ ] Works with Microsoft Outlook
- [ ] Attached to approval email
- [ ] Downloadable from client dashboard
- [ ] Bilingual event details
- [ ] Includes 1-hour reminder
- [ ] Tests pass for generation
- [ ] Code formatted with Pint
## Dependencies
- **Story 3.5:** Booking approval (triggers generation)
- **Epic 8:** Email system (for attachment)
## Risk Assessment
- **Primary Risk:** Calendar app compatibility issues
- **Mitigation:** Test with multiple calendar apps, follow RFC strictly
- **Rollback:** Provide manual calendar details if .ics fails
## Estimation
**Complexity:** Medium
**Estimated Effort:** 3-4 hours

View File

@ -0,0 +1,384 @@
# Story 3.7: Consultation Management
## Epic Reference
**Epic 3:** Booking & Consultation System
## User Story
As an **admin**,
I want **to manage consultations throughout their lifecycle**,
So that **I can track completed sessions, handle no-shows, and maintain accurate records**.
## Story Context
### Existing System Integration
- **Integrates with:** consultations table, notifications
- **Technology:** Livewire Volt, Flux UI
- **Follows pattern:** Admin management dashboard
- **Touch points:** Consultation status, payment tracking, admin notes
## Acceptance Criteria
### Consultations List View
- [ ] View all consultations with filters:
- Status (pending/approved/completed/cancelled/no_show)
- Type (free/paid)
- Payment status (pending/received/not_applicable)
- Date range
- Client name/email search
- [ ] Sort by date, status, client name
- [ ] Pagination (15/25/50 per page)
- [ ] Quick status indicators
### Status Management
- [ ] Mark consultation as completed
- [ ] Mark consultation as no-show
- [ ] Cancel booking on behalf of client
- [ ] Status change confirmation
### Rescheduling
- [ ] Reschedule appointment to new date/time
- [ ] Validate new slot availability
- [ ] Send notification to client
- [ ] Generate new .ics file
### Payment Tracking
- [ ] Mark payment as received (for paid consultations)
- [ ] Payment date recorded
- [ ] Payment status visible in list
### Admin Notes
- [ ] Add internal admin notes
- [ ] Notes not visible to client
- [ ] View notes in consultation detail
- [ ] Edit/delete notes
### Client History
- [ ] View all consultations for a specific client
- [ ] Linked from user profile
- [ ] Summary statistics per client
### Quality Requirements
- [ ] Audit log for all status changes
- [ ] Bilingual labels
- [ ] Tests for status transitions
## Technical Notes
### Status Enum
```php
enum ConsultationStatus: string
{
case Pending = 'pending';
case Approved = 'approved';
case Completed = 'completed';
case Cancelled = 'cancelled';
case NoShow = 'no_show';
}
enum PaymentStatus: string
{
case Pending = 'pending';
case Received = 'received';
case NotApplicable = 'not_applicable';
}
```
### Consultation Model Methods
```php
class Consultation extends Model
{
public function markAsCompleted(): void
{
$this->update(['status' => ConsultationStatus::Completed]);
}
public function markAsNoShow(): void
{
$this->update(['status' => ConsultationStatus::NoShow]);
}
public function cancel(): void
{
$this->update(['status' => ConsultationStatus::Cancelled]);
}
public function markPaymentReceived(): void
{
$this->update([
'payment_status' => PaymentStatus::Received,
'payment_received_at' => now(),
]);
}
public function reschedule(string $newDate, string $newTime): void
{
$this->update([
'scheduled_date' => $newDate,
'scheduled_time' => $newTime,
]);
}
// Scopes
public function scopeUpcoming($query)
{
return $query->where('scheduled_date', '>=', today())
->where('status', ConsultationStatus::Approved);
}
public function scopePast($query)
{
return $query->where('scheduled_date', '<', today())
->orWhereIn('status', [
ConsultationStatus::Completed,
ConsultationStatus::Cancelled,
ConsultationStatus::NoShow,
]);
}
}
```
### Volt Component for Management
```php
<?php
use App\Models\Consultation;
use Livewire\Volt\Component;
use Livewire\WithPagination;
new class extends Component {
use WithPagination;
public string $search = '';
public string $statusFilter = '';
public string $typeFilter = '';
public string $paymentFilter = '';
public string $dateFrom = '';
public string $dateTo = '';
public string $sortBy = 'scheduled_date';
public string $sortDir = 'desc';
public function updatedSearch()
{
$this->resetPage();
}
public function markCompleted(int $id): void
{
$consultation = Consultation::findOrFail($id);
$oldStatus = $consultation->status;
$consultation->markAsCompleted();
$this->logStatusChange($consultation, $oldStatus, 'completed');
session()->flash('success', __('messages.marked_completed'));
}
public function markNoShow(int $id): void
{
$consultation = Consultation::findOrFail($id);
$oldStatus = $consultation->status;
$consultation->markAsNoShow();
$this->logStatusChange($consultation, $oldStatus, 'no_show');
session()->flash('success', __('messages.marked_no_show'));
}
public function cancel(int $id): void
{
$consultation = Consultation::findOrFail($id);
$oldStatus = $consultation->status;
$consultation->cancel();
// Notify client
$consultation->user->notify(new ConsultationCancelled($consultation));
$this->logStatusChange($consultation, $oldStatus, 'cancelled');
session()->flash('success', __('messages.consultation_cancelled'));
}
public function markPaymentReceived(int $id): void
{
$consultation = Consultation::findOrFail($id);
$consultation->markPaymentReceived();
AdminLog::create([
'admin_id' => auth()->id(),
'action_type' => 'payment_received',
'target_type' => 'consultation',
'target_id' => $consultation->id,
'ip_address' => request()->ip(),
]);
session()->flash('success', __('messages.payment_marked_received'));
}
private function logStatusChange(Consultation $consultation, string $oldStatus, string $newStatus): void
{
AdminLog::create([
'admin_id' => auth()->id(),
'action_type' => 'status_change',
'target_type' => 'consultation',
'target_id' => $consultation->id,
'old_values' => ['status' => $oldStatus],
'new_values' => ['status' => $newStatus],
'ip_address' => request()->ip(),
]);
}
public function with(): array
{
return [
'consultations' => Consultation::query()
->with('user')
->when($this->search, fn($q) => $q->whereHas('user', fn($uq) =>
$uq->where('name', 'like', "%{$this->search}%")
->orWhere('email', 'like', "%{$this->search}%")
))
->when($this->statusFilter, fn($q) => $q->where('status', $this->statusFilter))
->when($this->typeFilter, fn($q) => $q->where('type', $this->typeFilter))
->when($this->paymentFilter, fn($q) => $q->where('payment_status', $this->paymentFilter))
->when($this->dateFrom, fn($q) => $q->where('scheduled_date', '>=', $this->dateFrom))
->when($this->dateTo, fn($q) => $q->where('scheduled_date', '<=', $this->dateTo))
->orderBy($this->sortBy, $this->sortDir)
->paginate(15),
];
}
};
```
### Reschedule Component
```php
<?php
use App\Models\Consultation;
use App\Services\{AvailabilityService, CalendarService};
use App\Notifications\ConsultationRescheduled;
use Livewire\Volt\Component;
new class extends Component {
public Consultation $consultation;
public string $newDate = '';
public string $newTime = '';
public array $availableSlots = [];
public function mount(Consultation $consultation): void
{
$this->consultation = $consultation;
$this->newDate = $consultation->scheduled_date->format('Y-m-d');
}
public function updatedNewDate(): void
{
if ($this->newDate) {
$service = app(AvailabilityService::class);
$this->availableSlots = $service->getAvailableSlots(
Carbon::parse($this->newDate)
);
$this->newTime = '';
}
}
public function reschedule(): void
{
$this->validate([
'newDate' => ['required', 'date', 'after_or_equal:today'],
'newTime' => ['required'],
]);
// Verify slot available
$service = app(AvailabilityService::class);
$slots = $service->getAvailableSlots(Carbon::parse($this->newDate));
if (!in_array($this->newTime, $slots)) {
$this->addError('newTime', __('booking.slot_not_available'));
return;
}
$oldDate = $this->consultation->scheduled_date;
$oldTime = $this->consultation->scheduled_time;
$this->consultation->reschedule($this->newDate, $this->newTime);
// Generate new .ics
$calendarService = app(CalendarService::class);
$icsContent = $calendarService->generateIcs($this->consultation->fresh());
// Notify client
$this->consultation->user->notify(
new ConsultationRescheduled($this->consultation, $oldDate, $oldTime, $icsContent)
);
// Log
AdminLog::create([
'admin_id' => auth()->id(),
'action_type' => 'reschedule',
'target_type' => 'consultation',
'target_id' => $this->consultation->id,
'old_values' => ['date' => $oldDate, 'time' => $oldTime],
'new_values' => ['date' => $this->newDate, 'time' => $this->newTime],
'ip_address' => request()->ip(),
]);
session()->flash('success', __('messages.consultation_rescheduled'));
$this->redirect(route('admin.consultations.index'));
}
};
```
### Admin Notes
```php
// Add admin_notes column to consultations or separate table
// In Consultation model:
protected $casts = [
'admin_notes' => 'array', // [{text, admin_id, created_at}]
];
public function addNote(string $note): void
{
$notes = $this->admin_notes ?? [];
$notes[] = [
'text' => $note,
'admin_id' => auth()->id(),
'created_at' => now()->toISOString(),
];
$this->update(['admin_notes' => $notes]);
}
```
## Definition of Done
- [ ] List view with all filters working
- [ ] Can mark consultation as completed
- [ ] Can mark consultation as no-show
- [ ] Can cancel consultation
- [ ] Can reschedule consultation
- [ ] Can mark payment as received
- [ ] Can add admin notes
- [ ] Client notified on reschedule/cancel
- [ ] New .ics sent on reschedule
- [ ] Audit logging complete
- [ ] Bilingual support
- [ ] Tests for all status changes
- [ ] Code formatted with Pint
## Dependencies
- **Story 3.5:** Booking approval
- **Story 3.6:** Calendar file generation
- **Epic 8:** Email notifications
## Risk Assessment
- **Primary Risk:** Status change on wrong consultation
- **Mitigation:** Confirmation dialogs, clear identification
- **Rollback:** Manual status correction, audit log
## Estimation
**Complexity:** Medium-High
**Estimated Effort:** 5-6 hours

View File

@ -0,0 +1,378 @@
# Story 3.8: Consultation Reminders
## Epic Reference
**Epic 3:** Booking & Consultation System
## User Story
As a **client**,
I want **to receive reminder emails before my consultation**,
So that **I don't forget my appointment and can prepare accordingly**.
## Story Context
### Existing System Integration
- **Integrates with:** consultations table, Laravel scheduler, email notifications
- **Technology:** Laravel Queue, Scheduler, Mailable
- **Follows pattern:** Scheduled job pattern
- **Touch points:** Email system, consultation status
## Acceptance Criteria
### 24-Hour Reminder
- [ ] Sent 24 hours before consultation
- [ ] Includes:
- Consultation date and time
- Type (free/paid)
- Payment reminder if paid and not received
- Calendar file link
- Any special instructions
### 2-Hour Reminder
- [ ] Sent 2 hours before consultation
- [ ] Includes:
- Consultation date and time
- Final payment reminder if applicable
- Contact information for last-minute issues
### Reminder Logic
- [ ] Only for approved consultations
- [ ] Skip cancelled consultations
- [ ] Skip no-show consultations
- [ ] Don't send duplicate reminders
- [ ] Handle timezone correctly
### Language Support
- [ ] Email in client's preferred language
- [ ] Arabic template for Arabic preference
- [ ] English template for English preference
### Quality Requirements
- [ ] Scheduled jobs run reliably
- [ ] Retry on failure
- [ ] Logging for debugging
- [ ] Tests for reminder logic
## Technical Notes
### Reminder Commands
#### 24-Hour Reminder Command
```php
<?php
namespace App\Console\Commands;
use App\Models\Consultation;
use App\Notifications\ConsultationReminder24h;
use Illuminate\Console\Command;
use Carbon\Carbon;
class Send24HourReminders extends Command
{
protected $signature = 'reminders:send-24h';
protected $description = 'Send consultation reminders 24 hours before appointment';
public function handle(): int
{
$targetTime = now()->addHours(24);
$windowStart = $targetTime->copy()->subMinutes(30);
$windowEnd = $targetTime->copy()->addMinutes(30);
$consultations = Consultation::where('status', 'approved')
->whereNull('reminder_24h_sent_at')
->whereDate('scheduled_date', $targetTime->toDateString())
->get()
->filter(function ($consultation) use ($windowStart, $windowEnd) {
$consultationDateTime = Carbon::parse(
$consultation->scheduled_date->format('Y-m-d') . ' ' .
$consultation->scheduled_time
);
return $consultationDateTime->between($windowStart, $windowEnd);
});
$count = 0;
foreach ($consultations as $consultation) {
try {
$consultation->user->notify(new ConsultationReminder24h($consultation));
$consultation->update(['reminder_24h_sent_at' => now()]);
$count++;
$this->info("Sent 24h reminder for consultation #{$consultation->id}");
} catch (\Exception $e) {
$this->error("Failed to send reminder for consultation #{$consultation->id}: {$e->getMessage()}");
\Log::error('24h reminder failed', [
'consultation_id' => $consultation->id,
'error' => $e->getMessage(),
]);
}
}
$this->info("Sent {$count} 24-hour reminders");
return Command::SUCCESS;
}
}
```
#### 2-Hour Reminder Command
```php
<?php
namespace App\Console\Commands;
use App\Models\Consultation;
use App\Notifications\ConsultationReminder2h;
use Illuminate\Console\Command;
use Carbon\Carbon;
class Send2HourReminders extends Command
{
protected $signature = 'reminders:send-2h';
protected $description = 'Send consultation reminders 2 hours before appointment';
public function handle(): int
{
$targetTime = now()->addHours(2);
$windowStart = $targetTime->copy()->subMinutes(15);
$windowEnd = $targetTime->copy()->addMinutes(15);
$consultations = Consultation::where('status', 'approved')
->whereNull('reminder_2h_sent_at')
->whereDate('scheduled_date', $targetTime->toDateString())
->get()
->filter(function ($consultation) use ($windowStart, $windowEnd) {
$consultationDateTime = Carbon::parse(
$consultation->scheduled_date->format('Y-m-d') . ' ' .
$consultation->scheduled_time
);
return $consultationDateTime->between($windowStart, $windowEnd);
});
$count = 0;
foreach ($consultations as $consultation) {
try {
$consultation->user->notify(new ConsultationReminder2h($consultation));
$consultation->update(['reminder_2h_sent_at' => now()]);
$count++;
$this->info("Sent 2h reminder for consultation #{$consultation->id}");
} catch (\Exception $e) {
$this->error("Failed to send reminder for consultation #{$consultation->id}: {$e->getMessage()}");
\Log::error('2h reminder failed', [
'consultation_id' => $consultation->id,
'error' => $e->getMessage(),
]);
}
}
$this->info("Sent {$count} 2-hour reminders");
return Command::SUCCESS;
}
}
```
### Scheduler Configuration
```php
// In routes/console.php or app/Console/Kernel.php (Laravel 12)
use Illuminate\Support\Facades\Schedule;
Schedule::command('reminders:send-24h')->hourly();
Schedule::command('reminders:send-2h')->everyFifteenMinutes();
```
### Migration for Tracking
```php
// Add reminder tracking columns to consultations
Schema::table('consultations', function (Blueprint $table) {
$table->timestamp('reminder_24h_sent_at')->nullable();
$table->timestamp('reminder_2h_sent_at')->nullable();
});
```
### 24-Hour Reminder Notification
```php
<?php
namespace App\Notifications;
use App\Models\Consultation;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;
class ConsultationReminder24h extends Notification
{
use Queueable;
public function __construct(
public Consultation $consultation
) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$locale = $notifiable->preferred_language ?? 'ar';
return (new MailMessage)
->subject($this->getSubject($locale))
->markdown('emails.reminders.24h.' . $locale, [
'consultation' => $this->consultation,
'user' => $notifiable,
'showPaymentReminder' => $this->shouldShowPaymentReminder(),
]);
}
private function getSubject(string $locale): string
{
return $locale === 'ar'
? 'تذكير: موعدك غداً مع مكتب ليبرا للمحاماة'
: 'Reminder: Your consultation is tomorrow';
}
private function shouldShowPaymentReminder(): bool
{
return $this->consultation->type === 'paid' &&
$this->consultation->payment_status === 'pending';
}
}
```
### Email Template (24h Arabic)
```blade
<!-- resources/views/emails/reminders/24h/ar.blade.php -->
<x-mail::message>
# تذكير بموعدك
عزيزي {{ $user->name }}،
نود تذكيرك بموعد استشارتك غداً:
**التاريخ:** {{ $consultation->scheduled_date->translatedFormat('l، d F Y') }}
**الوقت:** {{ \Carbon\Carbon::parse($consultation->scheduled_time)->format('g:i A') }}
**المدة:** 45 دقيقة
**النوع:** {{ $consultation->type === 'free' ? 'مجانية' : 'مدفوعة' }}
@if($showPaymentReminder)
<x-mail::panel>
**تذكير بالدفع:** يرجى إتمام عملية الدفع قبل موعد الاستشارة.
المبلغ المطلوب: {{ number_format($consultation->payment_amount, 2) }} شيكل
</x-mail::panel>
@endif
<x-mail::button :url="route('client.consultations.calendar', $consultation)">
تحميل ملف التقويم
</x-mail::button>
إذا كنت بحاجة لإعادة جدولة الموعد، يرجى التواصل معنا في أقرب وقت.
مع أطيب التحيات،
مكتب ليبرا للمحاماة
</x-mail::message>
```
### Email Template (2h Arabic)
```blade
<!-- resources/views/emails/reminders/2h/ar.blade.php -->
<x-mail::message>
# موعدك بعد ساعتين
عزيزي {{ $user->name }}،
تذكير أخير: موعد استشارتك خلال ساعتين.
**الوقت:** {{ \Carbon\Carbon::parse($consultation->scheduled_time)->format('g:i A') }}
@if($showPaymentReminder)
<x-mail::panel>
**هام:** لم نستلم الدفعة بعد. يرجى إتمام الدفع قبل بدء الاستشارة.
</x-mail::panel>
@endif
إذا كان لديك أي استفسار طارئ، يرجى التواصل معنا على:
[رقم الهاتف]
نتطلع للقائك،
مكتب ليبرا للمحاماة
</x-mail::message>
```
### Testing
```php
use App\Console\Commands\Send24HourReminders;
use App\Models\Consultation;
use App\Notifications\ConsultationReminder24h;
use Illuminate\Support\Facades\Notification;
it('sends 24h reminder for upcoming consultation', function () {
Notification::fake();
$consultation = Consultation::factory()->approved()->create([
'scheduled_date' => now()->addHours(24)->toDateString(),
'scheduled_time' => now()->addHours(24)->format('H:i:s'),
'reminder_24h_sent_at' => null,
]);
$this->artisan('reminders:send-24h')
->assertSuccessful();
Notification::assertSentTo(
$consultation->user,
ConsultationReminder24h::class
);
expect($consultation->fresh()->reminder_24h_sent_at)->not->toBeNull();
});
it('does not send reminder for cancelled consultation', function () {
Notification::fake();
$consultation = Consultation::factory()->create([
'status' => 'cancelled',
'scheduled_date' => now()->addHours(24)->toDateString(),
'scheduled_time' => now()->addHours(24)->format('H:i:s'),
]);
$this->artisan('reminders:send-24h')
->assertSuccessful();
Notification::assertNotSentTo($consultation->user, ConsultationReminder24h::class);
});
```
## Definition of Done
- [ ] 24h reminder command works
- [ ] 2h reminder command works
- [ ] Scheduler configured correctly
- [ ] Only approved consultations receive reminders
- [ ] Cancelled/no-show don't receive reminders
- [ ] No duplicate reminders sent
- [ ] Payment reminder included when applicable
- [ ] Arabic emails work correctly
- [ ] English emails work correctly
- [ ] Logging for debugging
- [ ] Tests pass for reminder logic
- [ ] Code formatted with Pint
## Dependencies
- **Story 3.5:** Booking approval (creates approved consultations)
- **Epic 8:** Email infrastructure
## Risk Assessment
- **Primary Risk:** Scheduler not running
- **Mitigation:** Monitor scheduler, alert on failures
- **Rollback:** Manual reminder sending if needed
## Estimation
**Complexity:** Medium
**Estimated Effort:** 3-4 hours

View File

@ -0,0 +1,139 @@
# Story 4.1: Timeline Creation
## Epic Reference
**Epic 4:** Case Timeline System
## User Story
As an **admin**,
I want **to create case timelines for clients**,
So that **I can track and communicate progress on their legal matters**.
## Story Context
### Existing System Integration
- **Integrates with:** timelines table, users table
- **Technology:** Livewire Volt, Flux UI
- **Follows pattern:** Admin CRUD pattern
- **Touch points:** User relationship, client dashboard
## Acceptance Criteria
### Timeline Creation Form
- [ ] Select client (search by name/email)
- [ ] Case name/title (required)
- [ ] Case reference number (optional, unique if provided)
- [ ] Initial notes (optional)
### Behavior
- [ ] Timeline assigned to selected client
- [ ] Creation date automatically recorded
- [ ] Status defaults to 'active'
- [ ] Can create multiple timelines per client
- [ ] Confirmation message on successful creation
- [ ] Timeline immediately visible to client
### Validation
- [ ] Case name required
- [ ] Case reference unique if provided
- [ ] Client must exist
### Quality Requirements
- [ ] Audit log entry created
- [ ] Bilingual labels and messages
- [ ] Tests for creation flow
## Technical Notes
### Database Schema
```php
// timelines table
Schema::create('timelines', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('case_name');
$table->string('case_reference')->nullable()->unique();
$table->enum('status', ['active', 'archived'])->default('active');
$table->timestamps();
});
```
### Volt Component
```php
<?php
use App\Models\{Timeline, User};
use Livewire\Volt\Component;
new class extends Component {
public string $search = '';
public ?int $selectedUserId = null;
public ?User $selectedUser = null;
public string $caseName = '';
public string $caseReference = '';
public string $initialNotes = '';
public function selectUser(int $userId): void
{
$this->selectedUserId = $userId;
$this->selectedUser = User::find($userId);
}
public function create(): void
{
$this->validate([
'selectedUserId' => ['required', 'exists:users,id'],
'caseName' => ['required', 'string', 'max:255'],
'caseReference' => ['nullable', 'string', 'max:50', 'unique:timelines,case_reference'],
]);
$timeline = Timeline::create([
'user_id' => $this->selectedUserId,
'case_name' => $this->caseName,
'case_reference' => $this->caseReference ?: null,
'status' => 'active',
]);
// Add initial notes as first update if provided
if ($this->initialNotes) {
$timeline->updates()->create([
'admin_id' => auth()->id(),
'update_text' => $this->initialNotes,
]);
}
AdminLog::create([
'admin_id' => auth()->id(),
'action_type' => 'create',
'target_type' => 'timeline',
'target_id' => $timeline->id,
'new_values' => $timeline->toArray(),
'ip_address' => request()->ip(),
]);
session()->flash('success', __('messages.timeline_created'));
$this->redirect(route('admin.timelines.show', $timeline));
}
};
```
## Definition of Done
- [ ] Can search and select client
- [ ] Can enter case name and reference
- [ ] Timeline created with correct data
- [ ] Initial notes saved as first update
- [ ] Unique reference validation works
- [ ] Client can view timeline immediately
- [ ] Audit log created
- [ ] Tests pass
- [ ] Code formatted with Pint
## Dependencies
- **Epic 1:** Database schema, authentication
- **Epic 2:** User accounts
## Estimation
**Complexity:** Low-Medium
**Estimated Effort:** 2-3 hours

View File

@ -0,0 +1,157 @@
# Story 4.2: Timeline Updates Management
## Epic Reference
**Epic 4:** Case Timeline System
## User Story
As an **admin**,
I want **to add and edit updates within a timeline**,
So that **I can keep clients informed about their case progress**.
## Story Context
### Existing System Integration
- **Integrates with:** timeline_updates table, timelines table
- **Technology:** Livewire Volt, rich text editor
- **Follows pattern:** Nested CRUD pattern
- **Touch points:** Client notifications, timeline view
## Acceptance Criteria
### Add Update
- [ ] Add new update to timeline
- [ ] Update text content (required)
- [ ] Rich text formatting supported:
- Bold, italic, underline
- Bullet/numbered lists
- Links
- [ ] Timestamp automatically recorded
- [ ] Admin name automatically recorded
- [ ] Client notified via email on new update
### Edit Update
- [ ] Edit existing update text
- [ ] Edit history preserved (updated_at changes)
- [ ] Cannot change timestamp or admin
### Display
- [ ] Updates displayed in chronological order
- [ ] Each update shows:
- Date/timestamp
- Admin name
- Update content
- [ ] Visual timeline representation
### Quality Requirements
- [ ] HTML sanitization for security
- [ ] Audit log for edits
- [ ] Tests for add/edit operations
## Technical Notes
### Database Schema
```php
Schema::create('timeline_updates', function (Blueprint $table) {
$table->id();
$table->foreignId('timeline_id')->constrained()->cascadeOnDelete();
$table->foreignId('admin_id')->constrained('users');
$table->text('update_text');
$table->timestamps();
});
```
### Volt Component
```php
<?php
use App\Models\{Timeline, TimelineUpdate};
use App\Notifications\TimelineUpdateNotification;
use Livewire\Volt\Component;
new class extends Component {
public Timeline $timeline;
public string $updateText = '';
public ?TimelineUpdate $editingUpdate = null;
public function addUpdate(): void
{
$this->validate([
'updateText' => ['required', 'string', 'min:10'],
]);
$update = $this->timeline->updates()->create([
'admin_id' => auth()->id(),
'update_text' => clean($this->updateText), // Sanitize HTML
]);
// Notify client
$this->timeline->user->notify(new TimelineUpdateNotification($update));
AdminLog::create([
'admin_id' => auth()->id(),
'action_type' => 'create',
'target_type' => 'timeline_update',
'target_id' => $update->id,
'ip_address' => request()->ip(),
]);
$this->updateText = '';
session()->flash('success', __('messages.update_added'));
}
public function editUpdate(TimelineUpdate $update): void
{
$this->editingUpdate = $update;
$this->updateText = $update->update_text;
}
public function saveEdit(): void
{
$this->validate([
'updateText' => ['required', 'string', 'min:10'],
]);
$oldText = $this->editingUpdate->update_text;
$this->editingUpdate->update([
'update_text' => clean($this->updateText),
]);
AdminLog::create([
'admin_id' => auth()->id(),
'action_type' => 'update',
'target_type' => 'timeline_update',
'target_id' => $this->editingUpdate->id,
'old_values' => ['update_text' => $oldText],
'new_values' => ['update_text' => $this->updateText],
'ip_address' => request()->ip(),
]);
$this->editingUpdate = null;
$this->updateText = '';
session()->flash('success', __('messages.update_edited'));
}
};
```
## Definition of Done
- [ ] Can add new updates with rich text
- [ ] Can edit existing updates
- [ ] Updates display chronologically
- [ ] Admin name and timestamp shown
- [ ] Client notification sent
- [ ] HTML properly sanitized
- [ ] Audit log created
- [ ] Tests pass
- [ ] Code formatted with Pint
## Dependencies
- **Story 4.1:** Timeline creation
- **Epic 8:** Email notifications
## Estimation
**Complexity:** Medium
**Estimated Effort:** 3-4 hours

View File

@ -0,0 +1,154 @@
# Story 4.3: Timeline Archiving
## Epic Reference
**Epic 4:** Case Timeline System
## User Story
As an **admin**,
I want **to archive completed cases and unarchive if needed**,
So that **I can organize active and completed case timelines**.
## Story Context
### Existing System Integration
- **Integrates with:** timelines table
- **Technology:** Livewire Volt
- **Follows pattern:** Soft state change pattern
- **Touch points:** Timeline list views
## Acceptance Criteria
### Archive Timeline
- [ ] Archive button on timeline detail
- [ ] Status changes to 'archived'
- [ ] Timeline remains visible to client
- [ ] No further updates can be added (until unarchived)
- [ ] Visual indicator shows archived status
### Unarchive Timeline
- [ ] Unarchive button on archived timelines
- [ ] Status returns to 'active'
- [ ] Updates can be added again
### List Filtering
- [ ] Filter timelines by status (active/archived/all)
- [ ] Archived timelines sorted separately in client view
- [ ] Bulk archive option for multiple timelines
### Quality Requirements
- [ ] Audit log for status changes
- [ ] Bilingual labels
- [ ] Tests for archive/unarchive
## Technical Notes
### Timeline Model Methods
```php
class Timeline extends Model
{
public function archive(): void
{
$this->update(['status' => 'archived']);
}
public function unarchive(): void
{
$this->update(['status' => 'active']);
}
public function isArchived(): bool
{
return $this->status === 'archived';
}
public function scopeActive($query)
{
return $query->where('status', 'active');
}
public function scopeArchived($query)
{
return $query->where('status', 'archived');
}
}
```
### Volt Component Actions
```php
public function archive(): void
{
if ($this->timeline->isArchived()) {
return;
}
$this->timeline->archive();
AdminLog::create([
'admin_id' => auth()->id(),
'action_type' => 'archive',
'target_type' => 'timeline',
'target_id' => $this->timeline->id,
'ip_address' => request()->ip(),
]);
session()->flash('success', __('messages.timeline_archived'));
}
public function unarchive(): void
{
if (!$this->timeline->isArchived()) {
return;
}
$this->timeline->unarchive();
AdminLog::create([
'admin_id' => auth()->id(),
'action_type' => 'unarchive',
'target_type' => 'timeline',
'target_id' => $this->timeline->id,
'ip_address' => request()->ip(),
]);
session()->flash('success', __('messages.timeline_unarchived'));
}
public function bulkArchive(array $ids): void
{
Timeline::whereIn('id', $ids)->update(['status' => 'archived']);
foreach ($ids as $id) {
AdminLog::create([
'admin_id' => auth()->id(),
'action_type' => 'archive',
'target_type' => 'timeline',
'target_id' => $id,
'ip_address' => request()->ip(),
]);
}
session()->flash('success', __('messages.timelines_archived', ['count' => count($ids)]));
}
```
## Definition of Done
- [ ] Can archive active timeline
- [ ] Can unarchive archived timeline
- [ ] Cannot add updates to archived timeline
- [ ] Filter by status works
- [ ] Bulk archive works
- [ ] Visual indicators correct
- [ ] Audit log created
- [ ] Tests pass
- [ ] Code formatted with Pint
## Dependencies
- **Story 4.1:** Timeline creation
- **Story 4.2:** Timeline updates
## Estimation
**Complexity:** Low
**Estimated Effort:** 2 hours

View File

@ -0,0 +1,188 @@
# Story 4.4: Admin Timeline Dashboard
## Epic Reference
**Epic 4:** Case Timeline System
## User Story
As an **admin**,
I want **a central view to manage all timelines across all clients**,
So that **I can efficiently track and update case progress**.
## Story Context
### Existing System Integration
- **Integrates with:** timelines table, users table
- **Technology:** Livewire Volt with pagination
- **Follows pattern:** Admin list/dashboard pattern
- **Touch points:** All timeline operations
## Acceptance Criteria
### List View
- [ ] Display all timelines with:
- Case name
- Client name
- Status (active/archived)
- Last update date
- Update count
- [ ] Pagination (15/25/50 per page)
### Filtering
- [ ] Filter by client (search/select)
- [ ] Filter by status (active/archived/all)
- [ ] Filter by date range (created/updated)
- [ ] Search by case name or reference
### Sorting
- [ ] Sort by client name
- [ ] Sort by case name
- [ ] Sort by last updated
- [ ] Sort by created date
### Quick Actions
- [ ] View timeline details
- [ ] Add update (inline or link)
- [ ] Archive/unarchive toggle
### Quality Requirements
- [ ] Fast loading with eager loading
- [ ] Bilingual support
- [ ] Tests for filtering/sorting
## Technical Notes
### Volt Component
```php
<?php
use App\Models\Timeline;
use Livewire\Volt\Component;
use Livewire\WithPagination;
new class extends Component {
use WithPagination;
public string $search = '';
public string $clientFilter = '';
public string $statusFilter = '';
public string $dateFrom = '';
public string $dateTo = '';
public string $sortBy = 'updated_at';
public string $sortDir = 'desc';
public int $perPage = 15;
public function updatedSearch()
{
$this->resetPage();
}
public function sort(string $column): void
{
if ($this->sortBy === $column) {
$this->sortDir = $this->sortDir === 'asc' ? 'desc' : 'asc';
} else {
$this->sortBy = $column;
$this->sortDir = 'asc';
}
}
public function with(): array
{
return [
'timelines' => Timeline::query()
->with(['user', 'updates' => fn($q) => $q->latest()->limit(1)])
->withCount('updates')
->when($this->search, fn($q) => $q->where(function($q) {
$q->where('case_name', 'like', "%{$this->search}%")
->orWhere('case_reference', 'like', "%{$this->search}%");
}))
->when($this->clientFilter, fn($q) => $q->where('user_id', $this->clientFilter))
->when($this->statusFilter, fn($q) => $q->where('status', $this->statusFilter))
->when($this->dateFrom, fn($q) => $q->where('created_at', '>=', $this->dateFrom))
->when($this->dateTo, fn($q) => $q->where('created_at', '<=', $this->dateTo))
->orderBy($this->sortBy, $this->sortDir)
->paginate($this->perPage),
];
}
};
```
### Template Structure
```blade
<div>
<!-- Filters Row -->
<div class="flex flex-wrap gap-4 mb-6">
<flux:input wire:model.live.debounce="search" placeholder="{{ __('admin.search_cases') }}" />
<flux:select wire:model.live="statusFilter">
<option value="">{{ __('admin.all_statuses') }}</option>
<option value="active">{{ __('admin.active') }}</option>
<option value="archived">{{ __('admin.archived') }}</option>
</flux:select>
<!-- More filters... -->
</div>
<!-- Table -->
<table class="w-full">
<thead>
<tr>
<th wire:click="sort('case_name')">{{ __('admin.case_name') }}</th>
<th wire:click="sort('user_id')">{{ __('admin.client') }}</th>
<th>{{ __('admin.status') }}</th>
<th wire:click="sort('updated_at')">{{ __('admin.last_update') }}</th>
<th>{{ __('admin.actions') }}</th>
</tr>
</thead>
<tbody>
@foreach($timelines as $timeline)
<tr>
<td>{{ $timeline->case_name }}</td>
<td>{{ $timeline->user->name }}</td>
<td>
<flux:badge :variant="$timeline->status === 'active' ? 'success' : 'secondary'">
{{ __('admin.' . $timeline->status) }}
</flux:badge>
</td>
<td>{{ $timeline->updated_at->diffForHumans() }}</td>
<td>
<flux:dropdown>
<flux:button size="sm">{{ __('admin.actions') }}</flux:button>
<flux:menu>
<flux:menu.item href="{{ route('admin.timelines.show', $timeline) }}">
{{ __('admin.view') }}
</flux:menu.item>
<flux:menu.item wire:click="toggleArchive({{ $timeline->id }})">
{{ $timeline->status === 'active' ? __('admin.archive') : __('admin.unarchive') }}
</flux:menu.item>
</flux:menu>
</flux:dropdown>
</td>
</tr>
@endforeach
</tbody>
</table>
{{ $timelines->links() }}
</div>
```
## Definition of Done
- [ ] List displays all timelines
- [ ] All filters working
- [ ] All sorts working
- [ ] Quick actions functional
- [ ] Pagination working
- [ ] No N+1 queries
- [ ] Bilingual support
- [ ] Tests pass
- [ ] Code formatted with Pint
## Dependencies
- **Story 4.1:** Timeline creation
- **Story 4.3:** Archive functionality
## Estimation
**Complexity:** Medium
**Estimated Effort:** 3-4 hours

View File

@ -0,0 +1,174 @@
# Story 4.5: Client Timeline View
## Epic Reference
**Epic 4:** Case Timeline System
## User Story
As a **client**,
I want **to view my case timelines and updates**,
So that **I can track the progress of my legal matters**.
## Story Context
### Existing System Integration
- **Integrates with:** timelines, timeline_updates tables
- **Technology:** Livewire Volt (read-only)
- **Follows pattern:** Client dashboard pattern
- **Touch points:** Client portal navigation
## Acceptance Criteria
### Timeline List
- [ ] Display all client's timelines
- [ ] Active timelines prominently displayed
- [ ] Archived timelines clearly separated
- [ ] Visual distinction (color/icon) for status
- [ ] Show for each:
- Case name and reference
- Status indicator
- Last update date
- Update count
### Individual Timeline View
- [ ] Case name and reference
- [ ] Status indicator
- [ ] All updates in chronological order
- [ ] Each update shows:
- Date and time
- Update content (formatted)
### Restrictions
- [ ] Read-only (no edit/comment)
- [ ] No ability to archive/delete
- [ ] Only see own timelines
### UX Features
- [ ] Recent updates indicator (new since last view, optional)
- [ ] Responsive design for mobile
- [ ] Bilingual labels and dates
## Technical Notes
### Volt Component for List
```php
<?php
use Livewire\Volt\Component;
new class extends Component {
public function with(): array
{
return [
'activeTimelines' => auth()->user()
->timelines()
->active()
->withCount('updates')
->with(['updates' => fn($q) => $q->latest()->limit(1)])
->latest('updated_at')
->get(),
'archivedTimelines' => auth()->user()
->timelines()
->archived()
->withCount('updates')
->latest('updated_at')
->get(),
];
}
};
```
### Timeline Detail View
```php
<?php
use App\Models\Timeline;
use Livewire\Volt\Component;
new class extends Component {
public Timeline $timeline;
public function mount(Timeline $timeline): void
{
// Ensure client owns this timeline
abort_unless($timeline->user_id === auth()->id(), 403);
$this->timeline = $timeline->load(['updates' => fn($q) => $q->oldest()]);
}
};
```
### Template
```blade
<div class="max-w-3xl mx-auto">
<!-- Header -->
<div class="flex justify-between items-start mb-6">
<div>
<flux:heading>{{ $timeline->case_name }}</flux:heading>
@if($timeline->case_reference)
<p class="text-charcoal/70">{{ __('client.reference') }}: {{ $timeline->case_reference }}</p>
@endif
</div>
<flux:badge :variant="$timeline->status === 'active' ? 'success' : 'secondary'">
{{ __('client.' . $timeline->status) }}
</flux:badge>
</div>
<!-- Timeline Updates -->
<div class="relative">
<!-- Vertical line -->
<div class="absolute {{ app()->getLocale() === 'ar' ? 'right-4' : 'left-4' }} top-0 bottom-0 w-0.5 bg-gold/30"></div>
<div class="space-y-6">
@forelse($timeline->updates as $update)
<div class="relative {{ app()->getLocale() === 'ar' ? 'pr-12' : 'pl-12' }}">
<!-- Dot -->
<div class="absolute {{ app()->getLocale() === 'ar' ? 'right-2' : 'left-2' }} top-2 w-4 h-4 rounded-full bg-gold border-4 border-cream"></div>
<div class="bg-white p-4 rounded-lg shadow-sm">
<div class="text-sm text-charcoal/70 mb-2">
{{ $update->created_at->translatedFormat('l, d M Y - g:i A') }}
</div>
<div class="prose prose-sm">
{!! $update->update_text !!}
</div>
</div>
</div>
@empty
<p class="text-center text-charcoal/70 py-8">
{{ __('client.no_updates_yet') }}
</p>
@endforelse
</div>
</div>
<div class="mt-6">
<flux:button href="{{ route('client.timelines.index') }}">
{{ __('client.back_to_cases') }}
</flux:button>
</div>
</div>
```
## Definition of Done
- [ ] Client can view list of their timelines
- [ ] Active/archived clearly separated
- [ ] Can view individual timeline details
- [ ] All updates displayed chronologically
- [ ] Read-only (no edit capabilities)
- [ ] Cannot view other clients' timelines
- [ ] Mobile responsive
- [ ] RTL support
- [ ] Tests pass
- [ ] Code formatted with Pint
## Dependencies
- **Story 4.1-4.3:** Timeline management
- **Epic 7:** Client dashboard structure
## Estimation
**Complexity:** Medium
**Estimated Effort:** 3-4 hours

View File

@ -0,0 +1,220 @@
# Story 4.6: Timeline Update Notifications
## Epic Reference
**Epic 4:** Case Timeline System
## User Story
As a **client**,
I want **to receive email notifications when my timeline is updated**,
So that **I stay informed about my case progress without checking the portal**.
## Story Context
### Existing System Integration
- **Integrates with:** timeline_updates creation, email system
- **Technology:** Laravel Notifications, queued emails
- **Follows pattern:** Event-driven notification pattern
- **Touch points:** Timeline update creation
## Acceptance Criteria
### Notification Trigger
- [ ] Email sent when new update added to timeline
- [ ] Triggered automatically on TimelineUpdate creation
- [ ] Queued for performance
### Email Content
- [ ] Case name and reference
- [ ] Update summary or full text
- [ ] Date of update
- [ ] Link to view timeline
- [ ] Libra branding
### Language Support
- [ ] Email in client's preferred language
- [ ] Arabic template
- [ ] English template
### Exclusions
- [ ] No email for archived timeline reactivation
- [ ] No email if client deactivated
### Quality Requirements
- [ ] Professional email template
- [ ] Tests for notification sending
- [ ] Error handling for failed sends
## Technical Notes
### Notification Class
```php
<?php
namespace App\Notifications;
use App\Models\TimelineUpdate;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;
class TimelineUpdateNotification extends Notification
{
use Queueable;
public function __construct(
public TimelineUpdate $update
) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$locale = $notifiable->preferred_language ?? 'ar';
$timeline = $this->update->timeline;
return (new MailMessage)
->subject($this->getSubject($locale, $timeline->case_name))
->markdown('emails.timeline.update.' . $locale, [
'update' => $this->update,
'timeline' => $timeline,
'user' => $notifiable,
]);
}
private function getSubject(string $locale, string $caseName): string
{
return $locale === 'ar'
? "تحديث جديد على قضيتك: {$caseName}"
: "New update on your case: {$caseName}";
}
}
```
### Email Templates
#### Arabic
```blade
<x-mail::message>
# تحديث جديد على قضيتك
عزيزي {{ $user->name }}،
تم إضافة تحديث جديد على قضيتك:
**القضية:** {{ $timeline->case_name }}
@if($timeline->case_reference)
**الرقم المرجعي:** {{ $timeline->case_reference }}
@endif
**تاريخ التحديث:** {{ $update->created_at->translatedFormat('d M Y - g:i A') }}
---
{!! $update->update_text !!}
---
<x-mail::button :url="route('client.timelines.show', $timeline)">
عرض التفاصيل الكاملة
</x-mail::button>
مع أطيب التحيات،
مكتب ليبرا للمحاماة
</x-mail::message>
```
#### English
```blade
<x-mail::message>
# New Update on Your Case
Dear {{ $user->name }},
A new update has been added to your case:
**Case:** {{ $timeline->case_name }}
@if($timeline->case_reference)
**Reference:** {{ $timeline->case_reference }}
@endif
**Update Date:** {{ $update->created_at->format('M d, Y - g:i A') }}
---
{!! $update->update_text !!}
---
<x-mail::button :url="route('client.timelines.show', $timeline)">
View Full Details
</x-mail::button>
Best regards,
Libra Law Firm
</x-mail::message>
```
### Trigger in Update Creation
```php
// In Story 4.2 addUpdate method
$update = $this->timeline->updates()->create([...]);
// Check if user is active before notifying
if ($this->timeline->user->isActive()) {
$this->timeline->user->notify(new TimelineUpdateNotification($update));
}
```
### Testing
```php
use App\Notifications\TimelineUpdateNotification;
use Illuminate\Support\Facades\Notification;
it('sends notification when timeline update created', function () {
Notification::fake();
$timeline = Timeline::factory()->create();
$update = TimelineUpdate::factory()->create(['timeline_id' => $timeline->id]);
$timeline->user->notify(new TimelineUpdateNotification($update));
Notification::assertSentTo($timeline->user, TimelineUpdateNotification::class);
});
it('does not send notification to deactivated user', function () {
Notification::fake();
$user = User::factory()->create(['status' => 'deactivated']);
$timeline = Timeline::factory()->for($user)->create();
// Add update (should check user status)
// ...
Notification::assertNotSentTo($user, TimelineUpdateNotification::class);
});
```
## Definition of Done
- [ ] Email sent on new update
- [ ] Arabic template works
- [ ] English template works
- [ ] Uses client's preferred language
- [ ] Link to timeline works
- [ ] Queued for performance
- [ ] No email to deactivated users
- [ ] Tests pass
- [ ] Code formatted with Pint
## Dependencies
- **Story 4.2:** Timeline updates management
- **Epic 8:** Email infrastructure
## Estimation
**Complexity:** Low-Medium
**Estimated Effort:** 2-3 hours

View File

@ -0,0 +1,268 @@
# Story 5.1: Post Creation & Editing
## Epic Reference
**Epic 5:** Posts/Blog System
## User Story
As an **admin**,
I want **to create and edit blog posts with rich text formatting**,
So that **I can publish professional legal content for website visitors**.
## Story Context
### Existing System Integration
- **Integrates with:** posts table
- **Technology:** Livewire Volt, TinyMCE or similar rich text editor
- **Follows pattern:** Admin CRUD pattern
- **Touch points:** Public posts display
## Acceptance Criteria
### Post Creation Form
- [ ] Title (required, bilingual: Arabic and English)
- [ ] Body content (required, bilingual)
- [ ] Status (draft/published)
### Rich Text Editor
- [ ] Bold, italic, underline
- [ ] Headings (H2, H3)
- [ ] Bullet and numbered lists
- [ ] Links
- [ ] Blockquotes
### Saving Features
- [ ] Save as draft functionality
- [ ] Preview post before publishing
- [ ] Edit published posts
- [ ] Auto-save draft periodically (every 60 seconds)
- [ ] Immediate publishing (no scheduling)
### Timestamps
- [ ] created_at recorded on creation
- [ ] updated_at updated on edit
### Quality Requirements
- [ ] HTML sanitization for XSS prevention
- [ ] Bilingual form labels
- [ ] Audit log for create/edit
- [ ] Tests for CRUD operations
## Technical Notes
### Database Schema
```php
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->string('title_ar');
$table->string('title_en');
$table->text('body_ar');
$table->text('body_en');
$table->enum('status', ['draft', 'published'])->default('draft');
$table->timestamps();
});
```
### Post Model
```php
class Post extends Model
{
protected $fillable = [
'title_ar', 'title_en', 'body_ar', 'body_en', 'status',
];
public function getTitleAttribute(): string
{
$locale = app()->getLocale();
return $this->{"title_{$locale}"} ?? $this->title_en;
}
public function getBodyAttribute(): string
{
$locale = app()->getLocale();
return $this->{"body_{$locale}"} ?? $this->body_en;
}
public function getExcerptAttribute(): string
{
return Str::limit(strip_tags($this->body), 150);
}
public function scopePublished($query)
{
return $query->where('status', 'published');
}
public function scopeDraft($query)
{
return $query->where('status', 'draft');
}
}
```
### Volt Component
```php
<?php
use App\Models\Post;
use Livewire\Volt\Component;
new class extends Component {
public ?Post $post = null;
public string $title_ar = '';
public string $title_en = '';
public string $body_ar = '';
public string $body_en = '';
public string $status = 'draft';
public function mount(?Post $post = null): void
{
if ($post?->exists) {
$this->post = $post;
$this->fill($post->only([
'title_ar', 'title_en', 'body_ar', 'body_en', 'status'
]));
}
}
public function save(): void
{
$validated = $this->validate([
'title_ar' => ['required', 'string', 'max:255'],
'title_en' => ['required', 'string', 'max:255'],
'body_ar' => ['required', 'string'],
'body_en' => ['required', 'string'],
'status' => ['required', 'in:draft,published'],
]);
// Sanitize HTML
$validated['body_ar'] = clean($validated['body_ar']);
$validated['body_en'] = clean($validated['body_en']);
if ($this->post) {
$this->post->update($validated);
$action = 'update';
} else {
$this->post = Post::create($validated);
$action = 'create';
}
AdminLog::create([
'admin_id' => auth()->id(),
'action_type' => $action,
'target_type' => 'post',
'target_id' => $this->post->id,
'ip_address' => request()->ip(),
]);
session()->flash('success', __('messages.post_saved'));
}
public function saveDraft(): void
{
$this->status = 'draft';
$this->save();
}
public function publish(): void
{
$this->status = 'published';
$this->save();
}
public function autoSave(): void
{
if ($this->post && $this->status === 'draft') {
$this->post->update([
'title_ar' => $this->title_ar,
'title_en' => $this->title_en,
'body_ar' => clean($this->body_ar),
'body_en' => clean($this->body_en),
]);
}
}
};
```
### Template with Rich Text Editor
```blade
<div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Arabic Fields -->
<div class="space-y-4">
<flux:heading size="sm">{{ __('admin.arabic_content') }}</flux:heading>
<flux:field>
<flux:label>{{ __('admin.title') }} (عربي) *</flux:label>
<flux:input wire:model="title_ar" dir="rtl" />
<flux:error name="title_ar" />
</flux:field>
<flux:field>
<flux:label>{{ __('admin.body') }} (عربي) *</flux:label>
<div wire:ignore>
<trix-editor
x-data
x-on:trix-change="$wire.set('body_ar', $event.target.value)"
dir="rtl"
>{!! $body_ar !!}</trix-editor>
</div>
<flux:error name="body_ar" />
</flux:field>
</div>
<!-- English Fields -->
<div class="space-y-4">
<flux:heading size="sm">{{ __('admin.english_content') }}</flux:heading>
<flux:field>
<flux:label>{{ __('admin.title') }} (English) *</flux:label>
<flux:input wire:model="title_en" />
<flux:error name="title_en" />
</flux:field>
<flux:field>
<flux:label>{{ __('admin.body') }} (English) *</flux:label>
<div wire:ignore>
<trix-editor
x-data
x-on:trix-change="$wire.set('body_en', $event.target.value)"
>{!! $body_en !!}</trix-editor>
</div>
<flux:error name="body_en" />
</flux:field>
</div>
</div>
<div class="flex gap-3 mt-6">
<flux:button wire:click="saveDraft">
{{ __('admin.save_draft') }}
</flux:button>
<flux:button variant="primary" wire:click="publish">
{{ __('admin.publish') }}
</flux:button>
</div>
</div>
```
## Definition of Done
- [ ] Can create post with bilingual content
- [ ] Rich text editor works
- [ ] Can save as draft
- [ ] Can publish directly
- [ ] Can edit existing posts
- [ ] Auto-save works for drafts
- [ ] HTML properly sanitized
- [ ] Audit log created
- [ ] Tests pass
- [ ] Code formatted with Pint
## Dependencies
- **Epic 1:** Database schema, admin authentication
## Estimation
**Complexity:** Medium
**Estimated Effort:** 4-5 hours

View File

@ -0,0 +1,243 @@
# Story 5.2: Post Management Dashboard
## Epic Reference
**Epic 5:** Posts/Blog System
## User Story
As an **admin**,
I want **a dashboard to manage all blog posts**,
So that **I can organize, publish, and maintain content efficiently**.
## Story Context
### Existing System Integration
- **Integrates with:** posts table
- **Technology:** Livewire Volt with pagination
- **Follows pattern:** Admin list pattern
- **Touch points:** Post CRUD operations
## Acceptance Criteria
### List View
- [ ] Display all posts with:
- Title (in current admin language)
- Status (draft/published)
- Created date
- Last updated date
- [ ] Pagination (10/25/50 per page)
### Filtering & Search
- [ ] Filter by status (draft/published/all)
- [ ] Search by title or body content
- [ ] Sort by date (newest/oldest)
### Quick Actions
- [ ] Edit post
- [ ] Delete (with confirmation)
- [ ] Publish/unpublish toggle
- [ ] Bulk delete option (optional)
### Quality Requirements
- [ ] Bilingual labels
- [ ] Audit log for delete actions
- [ ] Tests for list operations
## Technical Notes
### Volt Component
```php
<?php
use App\Models\Post;
use Livewire\Volt\Component;
use Livewire\WithPagination;
new class extends Component {
use WithPagination;
public string $search = '';
public string $statusFilter = '';
public string $sortBy = 'created_at';
public string $sortDir = 'desc';
public int $perPage = 10;
public function updatedSearch()
{
$this->resetPage();
}
public function sort(string $column): void
{
if ($this->sortBy === $column) {
$this->sortDir = $this->sortDir === 'asc' ? 'desc' : 'asc';
} else {
$this->sortBy = $column;
$this->sortDir = 'desc';
}
}
public function togglePublish(int $id): void
{
$post = Post::findOrFail($id);
$newStatus = $post->status === 'published' ? 'draft' : 'published';
$post->update(['status' => $newStatus]);
AdminLog::create([
'admin_id' => auth()->id(),
'action_type' => 'status_change',
'target_type' => 'post',
'target_id' => $post->id,
'old_values' => ['status' => $post->getOriginal('status')],
'new_values' => ['status' => $newStatus],
'ip_address' => request()->ip(),
]);
session()->flash('success', __('messages.post_status_updated'));
}
public function delete(int $id): void
{
$post = Post::findOrFail($id);
AdminLog::create([
'admin_id' => auth()->id(),
'action_type' => 'delete',
'target_type' => 'post',
'target_id' => $post->id,
'old_values' => $post->toArray(),
'ip_address' => request()->ip(),
]);
$post->delete();
session()->flash('success', __('messages.post_deleted'));
}
public function with(): array
{
$locale = app()->getLocale();
return [
'posts' => Post::query()
->when($this->search, fn($q) => $q->where(function($q) use ($locale) {
$q->where("title_{$locale}", 'like', "%{$this->search}%")
->orWhere("body_{$locale}", 'like', "%{$this->search}%");
}))
->when($this->statusFilter, fn($q) => $q->where('status', $this->statusFilter))
->orderBy($this->sortBy, $this->sortDir)
->paginate($this->perPage),
];
}
};
```
### Template
```blade
<div>
<div class="flex justify-between items-center mb-6">
<flux:heading>{{ __('admin.posts') }}</flux:heading>
<flux:button href="{{ route('admin.posts.create') }}">
{{ __('admin.create_post') }}
</flux:button>
</div>
<!-- Filters -->
<div class="flex gap-4 mb-4">
<flux:input
wire:model.live.debounce="search"
placeholder="{{ __('admin.search_posts') }}"
class="w-64"
/>
<flux:select wire:model.live="statusFilter">
<option value="">{{ __('admin.all_statuses') }}</option>
<option value="draft">{{ __('admin.draft') }}</option>
<option value="published">{{ __('admin.published') }}</option>
</flux:select>
</div>
<!-- Posts Table -->
<table class="w-full">
<thead>
<tr>
<th wire:click="sort('title_{{ app()->getLocale() }}')" class="cursor-pointer">
{{ __('admin.title') }}
</th>
<th>{{ __('admin.status') }}</th>
<th wire:click="sort('created_at')" class="cursor-pointer">
{{ __('admin.created') }}
</th>
<th wire:click="sort('updated_at')" class="cursor-pointer">
{{ __('admin.updated') }}
</th>
<th>{{ __('admin.actions') }}</th>
</tr>
</thead>
<tbody>
@forelse($posts as $post)
<tr>
<td>{{ $post->title }}</td>
<td>
<flux:badge :variant="$post->status === 'published' ? 'success' : 'secondary'">
{{ __('admin.' . $post->status) }}
</flux:badge>
</td>
<td>{{ $post->created_at->format('d/m/Y') }}</td>
<td>{{ $post->updated_at->diffForHumans() }}</td>
<td>
<div class="flex gap-2">
<flux:button size="sm" href="{{ route('admin.posts.edit', $post) }}">
{{ __('admin.edit') }}
</flux:button>
<flux:button
size="sm"
wire:click="togglePublish({{ $post->id }})"
>
{{ $post->status === 'published' ? __('admin.unpublish') : __('admin.publish') }}
</flux:button>
<flux:button
size="sm"
variant="danger"
wire:click="delete({{ $post->id }})"
wire:confirm="{{ __('admin.confirm_delete_post') }}"
>
{{ __('admin.delete') }}
</flux:button>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="5" class="text-center py-8 text-charcoal/70">
{{ __('admin.no_posts') }}
</td>
</tr>
@endforelse
</tbody>
</table>
{{ $posts->links() }}
</div>
```
## Definition of Done
- [ ] List displays all posts
- [ ] Filter by status works
- [ ] Search by title/body works
- [ ] Sort by date works
- [ ] Quick publish/unpublish toggle works
- [ ] Delete with confirmation works
- [ ] Pagination works
- [ ] Audit log for actions
- [ ] Tests pass
- [ ] Code formatted with Pint
## Dependencies
- **Story 5.1:** Post creation
## Estimation
**Complexity:** Medium
**Estimated Effort:** 3-4 hours

View File

@ -0,0 +1,146 @@
# Story 5.3: Post Deletion
## Epic Reference
**Epic 5:** Posts/Blog System
## User Story
As an **admin**,
I want **to permanently delete posts**,
So that **I can remove outdated or incorrect content from the website**.
## Story Context
### Existing System Integration
- **Integrates with:** posts table
- **Technology:** Livewire Volt
- **Follows pattern:** Permanent delete pattern
- **Touch points:** Post management dashboard
## Acceptance Criteria
### Delete Functionality
- [ ] Delete button on post list and edit page
- [ ] Confirmation dialog before deletion
- [ ] Permanent deletion (no soft delete per PRD)
- [ ] Success message after deletion
### Restrictions
- [ ] Cannot delete while post is being viewed publicly (edge case)
- [ ] Admin-only access
### Audit Trail
- [ ] Audit log entry preserved
- [ ] Old values stored in log
### Quality Requirements
- [ ] Clear warning in confirmation
- [ ] Bilingual messages
- [ ] Tests for deletion
## Technical Notes
### Delete Confirmation Modal
```blade
<flux:modal wire:model="showDeleteModal">
<flux:heading>{{ __('admin.delete_post') }}</flux:heading>
<flux:callout variant="danger">
{{ __('admin.delete_post_warning') }}
</flux:callout>
<p class="my-4">
{{ __('admin.deleting_post', ['title' => $postToDelete?->title]) }}
</p>
<div class="flex gap-3">
<flux:button wire:click="$set('showDeleteModal', false)">
{{ __('common.cancel') }}
</flux:button>
<flux:button variant="danger" wire:click="confirmDelete">
{{ __('admin.delete_permanently') }}
</flux:button>
</div>
</flux:modal>
```
### Delete Logic
```php
public ?Post $postToDelete = null;
public bool $showDeleteModal = false;
public function delete(int $id): void
{
$this->postToDelete = Post::findOrFail($id);
$this->showDeleteModal = true;
}
public function confirmDelete(): void
{
if (!$this->postToDelete) {
return;
}
// Create audit log BEFORE deletion
AdminLog::create([
'admin_id' => auth()->id(),
'action_type' => 'delete',
'target_type' => 'post',
'target_id' => $this->postToDelete->id,
'old_values' => $this->postToDelete->toArray(),
'ip_address' => request()->ip(),
]);
// Permanently delete
$this->postToDelete->delete();
$this->showDeleteModal = false;
$this->postToDelete = null;
session()->flash('success', __('messages.post_deleted'));
}
```
### Testing
```php
it('permanently deletes post', function () {
$post = Post::factory()->create();
$this->actingAs($admin)
->delete(route('admin.posts.destroy', $post));
expect(Post::find($post->id))->toBeNull();
});
it('creates audit log on deletion', function () {
$post = Post::factory()->create();
$this->actingAs($admin)
->delete(route('admin.posts.destroy', $post));
expect(AdminLog::where('target_type', 'post')
->where('target_id', $post->id)
->where('action_type', 'delete')
->exists()
)->toBeTrue();
});
```
## Definition of Done
- [ ] Delete button shows confirmation
- [ ] Confirmation explains permanence
- [ ] Post deleted from database
- [ ] Audit log created with old values
- [ ] Success message displayed
- [ ] Tests pass
- [ ] Code formatted with Pint
## Dependencies
- **Story 5.1:** Post creation
- **Story 5.2:** Post management dashboard
## Estimation
**Complexity:** Low
**Estimated Effort:** 1-2 hours

View File

@ -0,0 +1,198 @@
# Story 5.4: Public Posts Display
## Epic Reference
**Epic 5:** Posts/Blog System
## User Story
As a **website visitor**,
I want **to view published blog posts**,
So that **I can read legal updates and articles from the firm**.
## Story Context
### Existing System Integration
- **Integrates with:** posts table (published only)
- **Technology:** Livewire Volt (public routes)
- **Follows pattern:** Public content display
- **Touch points:** Navigation, homepage
## Acceptance Criteria
### Posts Listing Page
- [ ] Public access (no login required)
- [ ] Display in reverse chronological order
- [ ] Each post card shows:
- Title
- Publication date
- Excerpt (first ~150 characters)
- Read more link
- [ ] Pagination for many posts
### Individual Post Page
- [ ] Full post content displayed
- [ ] Clean, readable typography
- [ ] Publication date shown
- [ ] Back to posts list link
### Language Support
- [ ] Content displayed based on current site language
- [ ] Title and body in selected language
- [ ] Date formatting per locale
### Design Requirements
- [ ] Responsive design (mobile-friendly)
- [ ] Professional typography
- [ ] Consistent with brand colors
### Quality Requirements
- [ ] Only published posts visible
- [ ] Fast loading
- [ ] SEO-friendly URLs (optional per PRD)
- [ ] Tests for display logic
## Technical Notes
### Routes
```php
// routes/web.php (public)
Route::get('/posts', \App\Livewire\Posts\Index::class)->name('posts.index');
Route::get('/posts/{post}', \App\Livewire\Posts\Show::class)->name('posts.show');
```
### Posts Index Component
```php
<?php
use App\Models\Post;
use Livewire\Volt\Component;
use Livewire\WithPagination;
new class extends Component {
use WithPagination;
public function with(): array
{
return [
'posts' => Post::published()
->latest()
->paginate(10),
];
}
}; ?>
<div class="max-w-4xl mx-auto">
<flux:heading>{{ __('posts.title') }}</flux:heading>
<div class="mt-8 space-y-6">
@forelse($posts as $post)
<article class="bg-cream p-6 rounded-lg shadow-sm hover:shadow-md transition-shadow">
<h2 class="text-xl font-semibold text-navy">
<a href="{{ route('posts.show', $post) }}" class="hover:text-gold">
{{ $post->title }}
</a>
</h2>
<time class="text-sm text-charcoal/70 mt-2 block">
{{ $post->created_at->translatedFormat('d F Y') }}
</time>
<p class="mt-3 text-charcoal">
{{ $post->excerpt }}
</p>
<a href="{{ route('posts.show', $post) }}" class="text-gold hover:underline mt-4 inline-block">
{{ __('posts.read_more') }} &rarr;
</a>
</article>
@empty
<p class="text-center text-charcoal/70 py-12">
{{ __('posts.no_posts') }}
</p>
@endforelse
</div>
{{ $posts->links() }}
</div>
```
### Post Show Component
```php
<?php
use App\Models\Post;
use Livewire\Volt\Component;
new class extends Component {
public Post $post;
public function mount(Post $post): void
{
// Only show published posts
abort_unless($post->status === 'published', 404);
$this->post = $post;
}
}; ?>
<article class="max-w-3xl mx-auto">
<header class="mb-8">
<flux:heading>{{ $post->title }}</flux:heading>
<time class="text-charcoal/70 mt-2 block">
{{ $post->created_at->translatedFormat('l, d F Y') }}
</time>
</header>
<div class="prose prose-lg prose-navy max-w-none">
{!! $post->body !!}
</div>
<footer class="mt-12 pt-6 border-t border-charcoal/20">
<a href="{{ route('posts.index') }}" class="text-gold hover:underline">
&larr; {{ __('posts.back_to_posts') }}
</a>
</footer>
</article>
```
### Prose Styling
```css
/* In app.css */
.prose-navy {
--tw-prose-headings: theme('colors.navy');
--tw-prose-links: theme('colors.gold');
--tw-prose-bold: theme('colors.navy');
}
.prose-navy a {
text-decoration: underline;
}
.prose-navy a:hover {
color: theme('colors.gold-light');
}
```
## Definition of Done
- [ ] Posts listing page works
- [ ] Individual post page works
- [ ] Only published posts visible
- [ ] Reverse chronological order
- [ ] Excerpts display correctly
- [ ] Full content renders properly
- [ ] Language-appropriate content shown
- [ ] Mobile responsive
- [ ] RTL support
- [ ] Tests pass
- [ ] Code formatted with Pint
## Dependencies
- **Story 5.1:** Post creation
- **Epic 1:** Base UI, navigation
## Estimation
**Complexity:** Medium
**Estimated Effort:** 3-4 hours

View File

@ -0,0 +1,275 @@
# Story 5.5: Post Search
## Epic Reference
**Epic 5:** Posts/Blog System
## User Story
As a **website visitor**,
I want **to search through blog posts**,
So that **I can find relevant legal articles and information**.
## Story Context
### Existing System Integration
- **Integrates with:** posts table, public posts listing
- **Technology:** Livewire Volt with real-time search
- **Follows pattern:** Search component pattern
- **Touch points:** Posts listing page
## Acceptance Criteria
### Search Functionality
- [ ] Search input on posts listing page
- [ ] Search by title (both languages)
- [ ] Search by body content (both languages)
- [ ] Real-time search results (debounced)
### User Experience
- [ ] "No results found" message when empty
- [ ] Clear search button
- [ ] Search works in both Arabic and English
- [ ] Only searches published posts
### Optional Enhancements
- [ ] Search highlights in results
- [ ] Search suggestions
### Quality Requirements
- [ ] Debounce to reduce server load (300ms)
- [ ] Case-insensitive search
- [ ] Tests for search functionality
## Technical Notes
### Updated Posts Index Component
```php
<?php
use App\Models\Post;
use Livewire\Volt\Component;
use Livewire\WithPagination;
new class extends Component {
use WithPagination;
public string $search = '';
public function updatedSearch()
{
$this->resetPage();
}
public function clearSearch(): void
{
$this->search = '';
$this->resetPage();
}
public function with(): array
{
return [
'posts' => Post::published()
->when($this->search, function ($query) {
$search = $this->search;
$query->where(function ($q) use ($search) {
$q->where('title_ar', 'like', "%{$search}%")
->orWhere('title_en', 'like', "%{$search}%")
->orWhere('body_ar', 'like', "%{$search}%")
->orWhere('body_en', 'like', "%{$search}%");
});
})
->latest()
->paginate(10),
];
}
}; ?>
<div class="max-w-4xl mx-auto">
<flux:heading>{{ __('posts.title') }}</flux:heading>
<!-- Search Bar -->
<div class="mt-6 relative">
<flux:input
wire:model.live.debounce.300ms="search"
placeholder="{{ __('posts.search_placeholder') }}"
class="w-full"
>
<x-slot:leading>
<flux:icon name="magnifying-glass" class="w-5 h-5 text-charcoal/50" />
</x-slot:leading>
</flux:input>
@if($search)
<button
wire:click="clearSearch"
class="absolute {{ app()->getLocale() === 'ar' ? 'left-3' : 'right-3' }} top-1/2 -translate-y-1/2 text-charcoal/50 hover:text-charcoal"
>
<flux:icon name="x-mark" class="w-5 h-5" />
</button>
@endif
</div>
<!-- Search Results Info -->
@if($search)
<p class="mt-4 text-sm text-charcoal/70">
@if($posts->total() > 0)
{{ __('posts.search_results', ['count' => $posts->total(), 'query' => $search]) }}
@else
{{ __('posts.no_results', ['query' => $search]) }}
@endif
</p>
@endif
<!-- Posts List -->
<div class="mt-8 space-y-6">
@forelse($posts as $post)
<article class="bg-cream p-6 rounded-lg shadow-sm hover:shadow-md transition-shadow">
<h2 class="text-xl font-semibold text-navy">
<a href="{{ route('posts.show', $post) }}" class="hover:text-gold">
@if($search)
{!! $this->highlightSearch($post->title, $search) !!}
@else
{{ $post->title }}
@endif
</a>
</h2>
<time class="text-sm text-charcoal/70 mt-2 block">
{{ $post->created_at->translatedFormat('d F Y') }}
</time>
<p class="mt-3 text-charcoal">
@if($search)
{!! $this->highlightSearch($post->excerpt, $search) !!}
@else
{{ $post->excerpt }}
@endif
</p>
<a href="{{ route('posts.show', $post) }}" class="text-gold hover:underline mt-4 inline-block">
{{ __('posts.read_more') }} &rarr;
</a>
</article>
@empty
<div class="text-center py-12">
@if($search)
<flux:icon name="magnifying-glass" class="w-12 h-12 text-charcoal/30 mx-auto mb-4" />
<p class="text-charcoal/70">{{ __('posts.no_results', ['query' => $search]) }}</p>
<flux:button wire:click="clearSearch" class="mt-4">
{{ __('posts.clear_search') }}
</flux:button>
@else
<p class="text-charcoal/70">{{ __('posts.no_posts') }}</p>
@endif
</div>
@endforelse
</div>
{{ $posts->links() }}
</div>
```
### Highlight Helper Method
```php
public function highlightSearch(string $text, string $search): string
{
if (empty($search)) {
return e($text);
}
$escapedText = e($text);
$escapedSearch = e($search);
return preg_replace(
'/(' . preg_quote($escapedSearch, '/') . ')/iu',
'<mark class="bg-gold/30 rounded px-1">$1</mark>',
$escapedText
);
}
```
### Translation Strings
```php
// resources/lang/en/posts.php
return [
'search_placeholder' => 'Search articles...',
'search_results' => 'Found :count results for ":query"',
'no_results' => 'No results found for ":query"',
'clear_search' => 'Clear search',
];
// resources/lang/ar/posts.php
return [
'search_placeholder' => 'البحث في المقالات...',
'search_results' => 'تم العثور على :count نتيجة لـ ":query"',
'no_results' => 'لم يتم العثور على نتائج لـ ":query"',
'clear_search' => 'مسح البحث',
];
```
### Testing
```php
it('searches posts by title', function () {
Post::factory()->published()->create(['title_en' => 'Legal Rights Guide']);
Post::factory()->published()->create(['title_en' => 'Tax Information']);
Volt::test('posts.index')
->set('search', 'Legal')
->assertSee('Legal Rights Guide')
->assertDontSee('Tax Information');
});
it('searches posts by body content', function () {
Post::factory()->published()->create([
'title_en' => 'Post 1',
'body_en' => 'This discusses property law topics.',
]);
Post::factory()->published()->create([
'title_en' => 'Post 2',
'body_en' => 'This is about family matters.',
]);
Volt::test('posts.index')
->set('search', 'property')
->assertSee('Post 1')
->assertDontSee('Post 2');
});
it('shows no results message', function () {
Volt::test('posts.index')
->set('search', 'nonexistent')
->assertSee('No results found');
});
it('only searches published posts', function () {
Post::factory()->create(['status' => 'draft', 'title_en' => 'Draft Post']);
Post::factory()->published()->create(['title_en' => 'Published Post']);
Volt::test('posts.index')
->set('search', 'Post')
->assertSee('Published Post')
->assertDontSee('Draft Post');
});
```
## Definition of Done
- [ ] Search input on posts page
- [ ] Searches title in both languages
- [ ] Searches body in both languages
- [ ] Real-time results with debounce
- [ ] Clear search button works
- [ ] "No results" message shows
- [ ] Only published posts searched
- [ ] Search highlighting works
- [ ] Tests pass
- [ ] Code formatted with Pint
## Dependencies
- **Story 5.4:** Public posts display
## Estimation
**Complexity:** Low-Medium
**Estimated Effort:** 2-3 hours

View File

@ -0,0 +1,75 @@
# Story 6.1: Dashboard Overview & Statistics
## Epic Reference
**Epic 6:** Admin Dashboard
## User Story
As an **admin**,
I want **to see real-time metrics and key statistics at a glance**,
So that **I can understand the current state of my practice**.
## Acceptance Criteria
### User Metrics Card
- [ ] Total active clients
- [ ] Individual vs company breakdown
- [ ] Deactivated clients count
- [ ] New clients this month
### Booking Metrics Card
- [ ] Pending requests count (highlighted)
- [ ] Today's consultations
- [ ] This week's consultations
- [ ] This month's consultations
- [ ] Free vs paid breakdown
- [ ] No-show rate percentage
### Timeline Metrics Card
- [ ] Active case timelines
- [ ] Archived timelines
- [ ] Updates added this week
### Posts Metrics Card
- [ ] Total published posts
- [ ] Posts published this month
### Design
- [ ] Clean card-based layout
- [ ] Color-coded status indicators
- [ ] Responsive grid
## Technical Notes
```php
new class extends Component {
public function with(): array
{
return [
'userMetrics' => $this->getUserMetrics(),
'bookingMetrics' => $this->getBookingMetrics(),
'timelineMetrics' => $this->getTimelineMetrics(),
'postMetrics' => $this->getPostMetrics(),
];
}
private function getUserMetrics(): array
{
return Cache::remember('admin.metrics.users', 300, fn() => [
'total_active' => User::where('status', 'active')->whereIn('user_type', ['individual', 'company'])->count(),
'individual' => User::where('user_type', 'individual')->where('status', 'active')->count(),
'company' => User::where('user_type', 'company')->where('status', 'active')->count(),
'deactivated' => User::where('status', 'deactivated')->count(),
'new_this_month' => User::whereMonth('created_at', now()->month)->count(),
]);
}
};
```
## Definition of Done
- [ ] All metric cards display correctly
- [ ] Data is accurate and cached
- [ ] Responsive layout
- [ ] Tests pass
## Estimation
**Complexity:** Medium | **Effort:** 4-5 hours

View File

@ -0,0 +1,98 @@
# Story 6.10: Audit Log Viewer
## Epic Reference
**Epic 6:** Admin Dashboard
## User Story
As an **admin**,
I want **to view admin action history**,
So that **I can maintain accountability and track changes**.
## Acceptance Criteria
### Display
- [ ] Action type (create, update, delete)
- [ ] Target (user, consultation, timeline, etc.)
- [ ] Old and new values (for updates)
- [ ] Timestamp
- [ ] IP address
### Filtering
- [ ] Filter by action type
- [ ] Filter by target type
- [ ] Filter by date range
### Search
- [ ] Search by target name/ID
### Features
- [ ] Pagination
- [ ] Export audit log (CSV)
## Technical Notes
```php
new class extends Component {
use WithPagination;
public string $actionFilter = '';
public string $targetFilter = '';
public string $dateFrom = '';
public string $dateTo = '';
public string $search = '';
public function with(): array
{
return [
'logs' => AdminLog::query()
->with('admin')
->when($this->actionFilter, fn($q) => $q->where('action_type', $this->actionFilter))
->when($this->targetFilter, fn($q) => $q->where('target_type', $this->targetFilter))
->when($this->dateFrom, fn($q) => $q->whereDate('created_at', '>=', $this->dateFrom))
->when($this->dateTo, fn($q) => $q->whereDate('created_at', '<=', $this->dateTo))
->when($this->search, fn($q) => $q->where('target_id', $this->search))
->latest()
->paginate(25),
'actionTypes' => AdminLog::distinct()->pluck('action_type'),
'targetTypes' => AdminLog::distinct()->pluck('target_type'),
];
}
public function exportCsv()
{
// Export filtered logs to CSV
}
};
```
### Template
```blade
@foreach($logs as $log)
<tr>
<td>{{ $log->created_at->format('d/m/Y H:i') }}</td>
<td>{{ $log->admin?->name ?? __('admin.system') }}</td>
<td>
<flux:badge>{{ $log->action_type }}</flux:badge>
</td>
<td>{{ $log->target_type }} #{{ $log->target_id }}</td>
<td>{{ $log->ip_address }}</td>
<td>
<flux:button size="sm" wire:click="showDetails({{ $log->id }})">
{{ __('admin.details') }}
</flux:button>
</td>
</tr>
@endforeach
```
## Definition of Done
- [ ] Logs display correctly
- [ ] All filters work
- [ ] Search works
- [ ] Pagination works
- [ ] CSV export works
- [ ] Old/new values viewable
- [ ] Tests pass
## Estimation
**Complexity:** Medium | **Effort:** 3-4 hours

View File

@ -0,0 +1,49 @@
# Story 6.2: Analytics Charts
## Epic Reference
**Epic 6:** Admin Dashboard
## User Story
As an **admin**,
I want **visual charts showing trends and historical data**,
So that **I can analyze business patterns over time**.
## Acceptance Criteria
### Charts Required
- [ ] Monthly Trends (Line chart): New clients and consultations per month
- [ ] Consultation Breakdown (Pie/Donut): Free vs paid ratio
- [ ] No-show Rate (Line chart): Monthly no-show trend
### Features
- [ ] Date range selector (6 months, 12 months, custom)
- [ ] Chart tooltips with exact values
- [ ] Responsive charts
- [ ] Bilingual labels
## Technical Notes
Use Chart.js for visualizations. Aggregate data server-side.
```php
public function getChartData(string $period = '6m'): array
{
$months = $period === '6m' ? 6 : 12;
return [
'labels' => collect(range($months - 1, 0))->map(fn($i) => now()->subMonths($i)->format('M Y'))->toArray(),
'clients' => $this->getMonthlyClients($months),
'consultations' => $this->getMonthlyConsultations($months),
];
}
```
## Definition of Done
- [ ] All charts render correctly
- [ ] Date range selector works
- [ ] Tooltips functional
- [ ] Mobile responsive
- [ ] Tests pass
## Estimation
**Complexity:** Medium-High | **Effort:** 4-5 hours

View File

@ -0,0 +1,55 @@
# Story 6.3: Quick Actions Panel
## Epic Reference
**Epic 6:** Admin Dashboard
## User Story
As an **admin**,
I want **quick access to pending items and common tasks**,
So that **I can efficiently manage my daily workflow**.
## Acceptance Criteria
### Pending Bookings Widget
- [ ] Count badge with urgent indicator
- [ ] Link to booking management
- [ ] Mini list of recent pending (3-5)
### Today's Schedule Widget
- [ ] List of today's consultations
- [ ] Time and client name
- [ ] Quick status update buttons
### Recent Timeline Updates Widget
- [ ] Last 5 updates made
- [ ] Quick link to timeline
### Quick Action Buttons
- [ ] Create user
- [ ] Create post
- [ ] Block time slot
### Notification Bell
- [ ] Pending items count
## Technical Notes
```php
public function with(): array
{
return [
'pendingBookings' => Consultation::pending()->latest()->take(5)->get(),
'todaySchedule' => Consultation::approved()->whereDate('scheduled_date', today())->orderBy('scheduled_time')->get(),
'recentUpdates' => TimelineUpdate::latest()->take(5)->with('timeline.user')->get(),
];
}
```
## Definition of Done
- [ ] All widgets display correctly
- [ ] Quick actions work
- [ ] Real-time updates with polling
- [ ] Tests pass
## Estimation
**Complexity:** Medium | **Effort:** 3-4 hours

View File

@ -0,0 +1,68 @@
# Story 6.4: Data Export - User Lists
## Epic Reference
**Epic 6:** Admin Dashboard
## User Story
As an **admin**,
I want **to export user data in CSV and PDF formats**,
So that **I can generate reports and maintain offline records**.
## Acceptance Criteria
### Export Options
- [ ] Export all users
- [ ] Export individual clients only
- [ ] Export company clients only
### Filters
- [ ] Date range (created)
- [ ] Status (active/deactivated)
### CSV Export Includes
- [ ] Name, email, phone
- [ ] User type
- [ ] National ID / Company registration
- [ ] Status
- [ ] Created date
### PDF Export Includes
- [ ] Same data with professional formatting
- [ ] Libra branding header
- [ ] Generation timestamp
### Bilingual
- [ ] Column headers based on admin language
## Technical Notes
Use league/csv for CSV and barryvdh/laravel-dompdf for PDF.
```php
public function exportCsv(): StreamedResponse
{
return response()->streamDownload(function () {
$csv = Writer::createFromString();
$csv->insertOne([__('export.name'), __('export.email'), ...]);
User::whereIn('user_type', ['individual', 'company'])
->cursor()
->each(fn($user) => $csv->insertOne([
$user->name,
$user->email,
// ...
]));
echo $csv->toString();
}, 'users-export.csv');
}
```
## Definition of Done
- [ ] CSV export works with all filters
- [ ] PDF export works with branding
- [ ] Large datasets handled efficiently
- [ ] Tests pass
## Estimation
**Complexity:** Medium | **Effort:** 3-4 hours

View File

@ -0,0 +1,59 @@
# Story 6.5: Data Export - Consultation Records
## Epic Reference
**Epic 6:** Admin Dashboard
## User Story
As an **admin**,
I want **to export consultation/booking data**,
So that **I can analyze and report on consultation history**.
## Acceptance Criteria
### Export Options
- [ ] Export all consultations
### Filters
- [ ] Date range
- [ ] Consultation type (free/paid)
- [ ] Status (approved/completed/no-show/cancelled)
- [ ] Payment status
### Export Includes
- [ ] Client name
- [ ] Date and time
- [ ] Consultation type
- [ ] Status
- [ ] Payment status
- [ ] Problem summary
### Formats
- [ ] CSV format
- [ ] PDF format with professional layout and branding
## Technical Notes
```php
public function exportConsultationsPdf(Request $request)
{
$consultations = Consultation::query()
->with('user')
->when($request->date_from, fn($q) => $q->where('scheduled_date', '>=', $request->date_from))
->when($request->status, fn($q) => $q->where('status', $request->status))
->get();
$pdf = Pdf::loadView('exports.consultations', compact('consultations'));
return $pdf->download('consultations-export.pdf');
}
```
## Definition of Done
- [ ] All filters work correctly
- [ ] CSV export accurate
- [ ] PDF professionally formatted
- [ ] Large summaries handled in PDF
- [ ] Tests pass
## Estimation
**Complexity:** Medium | **Effort:** 3 hours

View File

@ -0,0 +1,65 @@
# Story 6.6: Data Export - Timeline Reports
## Epic Reference
**Epic 6:** Admin Dashboard
## User Story
As an **admin**,
I want **to export timeline and case data**,
So that **I can maintain records and generate case reports**.
## Acceptance Criteria
### Export Options
- [ ] Export all timelines (across all clients)
- [ ] Export timelines for specific client
### Filters
- [ ] Status (active/archived)
- [ ] Date range
### Export Includes
- [ ] Case name and reference
- [ ] Client name
- [ ] Status
- [ ] Created date
- [ ] Number of updates
- [ ] Last update date
### Formats
- [ ] CSV format
- [ ] PDF format
### Optional
- [ ] Include update content or summary only toggle
## Technical Notes
```php
public function exportTimelinesPdf(Request $request)
{
$timelines = Timeline::query()
->with(['user', 'updates'])
->withCount('updates')
->when($request->client_id, fn($q) => $q->where('user_id', $request->client_id))
->when($request->status, fn($q) => $q->where('status', $request->status))
->get();
$pdf = Pdf::loadView('exports.timelines', [
'timelines' => $timelines,
'includeUpdates' => $request->boolean('include_updates'),
]);
return $pdf->download('timelines-export.pdf');
}
```
## Definition of Done
- [ ] All filters work
- [ ] CSV export works
- [ ] PDF with branding works
- [ ] Optional update content toggle works
- [ ] Tests pass
## Estimation
**Complexity:** Medium | **Effort:** 3 hours

View File

@ -0,0 +1,70 @@
# Story 6.7: Monthly Statistics Report
## Epic Reference
**Epic 6:** Admin Dashboard
## User Story
As an **admin**,
I want **to generate comprehensive monthly PDF reports**,
So that **I have professional summaries of business performance**.
## Acceptance Criteria
### Generation
- [ ] "Generate Monthly Report" button
- [ ] Select month/year
### PDF Report Includes
- [ ] Overview of key metrics
- [ ] Charts (rendered as images)
- [ ] User statistics
- [ ] Consultation statistics
- [ ] Timeline statistics
- [ ] Post statistics
### Design
- [ ] Professional layout with branding
- [ ] Table of contents
- [ ] Printable format
- [ ] Bilingual based on admin preference
### UX
- [ ] Loading indicator during generation
- [ ] Download on completion
## Technical Notes
Pre-render charts as base64 images for PDF inclusion.
```php
public function generateMonthlyReport(int $year, int $month)
{
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
$endDate = $startDate->copy()->endOfMonth();
$data = [
'period' => $startDate->format('F Y'),
'userStats' => $this->getUserStatsForPeriod($startDate, $endDate),
'consultationStats' => $this->getConsultationStatsForPeriod($startDate, $endDate),
'timelineStats' => $this->getTimelineStatsForPeriod($startDate, $endDate),
'postStats' => $this->getPostStatsForPeriod($startDate, $endDate),
'charts' => $this->renderChartsAsImages($startDate, $endDate),
];
$pdf = Pdf::loadView('exports.monthly-report', $data)
->setPaper('a4', 'portrait');
return $pdf->download("monthly-report-{$year}-{$month}.pdf");
}
```
## Definition of Done
- [ ] Month/year selector works
- [ ] All statistics accurate
- [ ] Charts rendered in PDF
- [ ] Professional branding
- [ ] Bilingual support
- [ ] Tests pass
## Estimation
**Complexity:** High | **Effort:** 5-6 hours

View File

@ -0,0 +1,99 @@
# Story 6.8: System Settings
## Epic Reference
**Epic 6:** Admin Dashboard
## User Story
As an **admin**,
I want **to configure system-wide settings**,
So that **I can customize the platform to my needs**.
## Acceptance Criteria
### Profile Settings
- [ ] Admin name
- [ ] Email
- [ ] Password change
- [ ] Preferred language
### Email Settings
- [ ] View current sender email
- [ ] Test email functionality
### Notification Preferences (Optional)
- [ ] Toggle admin notifications
- [ ] Summary email frequency
### Behavior
- [ ] Settings saved and applied immediately
- [ ] Validation for all inputs
## Technical Notes
```php
new class extends Component {
public string $name = '';
public string $email = '';
public string $current_password = '';
public string $password = '';
public string $password_confirmation = '';
public string $preferred_language = 'ar';
public function mount(): void
{
$user = auth()->user();
$this->name = $user->name;
$this->email = $user->email;
$this->preferred_language = $user->preferred_language;
}
public function updateProfile(): void
{
$this->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', Rule::unique('users')->ignore(auth()->id())],
'preferred_language' => ['required', 'in:ar,en'],
]);
auth()->user()->update([
'name' => $this->name,
'email' => $this->email,
'preferred_language' => $this->preferred_language,
]);
session()->flash('success', __('messages.profile_updated'));
}
public function updatePassword(): void
{
$this->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
]);
auth()->user()->update([
'password' => Hash::make($this->password),
]);
$this->reset(['current_password', 'password', 'password_confirmation']);
session()->flash('success', __('messages.password_updated'));
}
public function sendTestEmail(): void
{
Mail::to(auth()->user())->send(new TestEmail());
session()->flash('success', __('messages.test_email_sent'));
}
};
```
## Definition of Done
- [ ] Profile updates work
- [ ] Password change works
- [ ] Language preference persists
- [ ] Test email sends
- [ ] Validation complete
- [ ] Tests pass
## Estimation
**Complexity:** Medium | **Effort:** 3-4 hours

View File

@ -0,0 +1,79 @@
# Story 6.9: Legal Pages Editor
## Epic Reference
**Epic 6:** Admin Dashboard
## User Story
As an **admin**,
I want **to edit Terms of Service and Privacy Policy pages**,
So that **I can maintain legal compliance and update policies**.
## Acceptance Criteria
### Pages to Edit
- [ ] Terms of Service
- [ ] Privacy Policy
### Editor Features
- [ ] Rich text editor
- [ ] Bilingual content (Arabic/English)
- [ ] Save and publish
- [ ] Preview before publishing
### Public Display
- [ ] Pages accessible from footer (public)
- [ ] Last updated timestamp displayed
## Technical Notes
Store in database settings table or dedicated pages table.
```php
// Migration
Schema::create('pages', function (Blueprint $table) {
$table->id();
$table->string('slug')->unique();
$table->string('title_ar');
$table->string('title_en');
$table->text('content_ar');
$table->text('content_en');
$table->timestamps();
});
// Seeder
Page::create([
'slug' => 'terms',
'title_ar' => 'شروط الخدمة',
'title_en' => 'Terms of Service',
'content_ar' => '',
'content_en' => '',
]);
Page::create([
'slug' => 'privacy',
'title_ar' => 'سياسة الخصوصية',
'title_en' => 'Privacy Policy',
'content_ar' => '',
'content_en' => '',
]);
```
### Public Route
```php
Route::get('/page/{slug}', function (string $slug) {
$page = Page::where('slug', $slug)->firstOrFail();
return view('pages.show', compact('page'));
})->name('page.show');
```
## Definition of Done
- [ ] Can edit Terms of Service
- [ ] Can edit Privacy Policy
- [ ] Bilingual content works
- [ ] Preview works
- [ ] Public pages accessible
- [ ] Last updated shows
- [ ] Tests pass
## Estimation
**Complexity:** Medium | **Effort:** 3-4 hours

View File

@ -0,0 +1,79 @@
# Story 7.1: Client Dashboard Overview
## Epic Reference
**Epic 7:** Client Dashboard
## User Story
As a **client**,
I want **a dashboard showing my key information at a glance**,
So that **I can quickly see upcoming consultations and case updates**.
## Acceptance Criteria
### Welcome Section
- [ ] Welcome message with client name
### Upcoming Consultations Widget
- [ ] Next consultation date/time
- [ ] Type (free/paid)
- [ ] Status
- [ ] Quick link to details
### Active Cases Widget
- [ ] Count of active timelines
- [ ] Latest update preview
- [ ] Link to full list
### Recent Updates Widget
- [ ] Last 3 timeline updates (across all cases)
- [ ] Case name and date
- [ ] Link to full timeline
### Booking Status Widget
- [ ] Pending booking requests
- [ ] Daily booking limit indicator
- [ ] Quick book button
### Design
- [ ] Clean, card-based layout
- [ ] Mobile-first responsive
- [ ] Bilingual content
## Technical Notes
```php
new class extends Component {
public function with(): array
{
$user = auth()->user();
return [
'upcomingConsultation' => $user->consultations()
->approved()
->upcoming()
->first(),
'activeTimelinesCount' => $user->timelines()->active()->count(),
'recentUpdates' => TimelineUpdate::whereHas('timeline', fn($q) => $q->where('user_id', $user->id))
->latest()
->take(3)
->with('timeline')
->get(),
'pendingBookings' => $user->consultations()->pending()->count(),
'canBookToday' => !$user->consultations()
->whereDate('scheduled_date', today())
->whereIn('status', ['pending', 'approved'])
->exists(),
];
}
};
```
## Definition of Done
- [ ] All widgets display correctly
- [ ] Data scoped to logged-in user
- [ ] Mobile responsive
- [ ] Bilingual
- [ ] Tests pass
## Estimation
**Complexity:** Medium | **Effort:** 4-5 hours

View File

@ -0,0 +1,77 @@
# Story 7.2: My Consultations View
## Epic Reference
**Epic 7:** Client Dashboard
## User Story
As a **client**,
I want **to view all my consultations**,
So that **I can track upcoming appointments and review past sessions**.
## Acceptance Criteria
### Upcoming Consultations Section
- [ ] Date and time
- [ ] Consultation type (free/paid)
- [ ] Status (approved/pending)
- [ ] Payment status (for paid)
- [ ] Download .ics calendar file button
### Pending Requests Section
- [ ] Submitted bookings awaiting approval
- [ ] Submission date
- [ ] Problem summary preview
- [ ] Status: "Pending Review"
### Past Consultations Section
- [ ] Historical consultations
- [ ] Status (completed/cancelled/no-show)
- [ ] Date and type
### Features
- [ ] Visual status indicators
- [ ] Sort by date (default: newest first for past)
- [ ] Pagination if many consultations
- [ ] No edit/cancel capabilities (read-only)
## Technical Notes
```php
new class extends Component {
use WithPagination;
public function with(): array
{
$user = auth()->user();
return [
'upcoming' => $user->consultations()
->approved()
->where('scheduled_date', '>=', today())
->orderBy('scheduled_date')
->orderBy('scheduled_time')
->get(),
'pending' => $user->consultations()
->pending()
->latest()
->get(),
'past' => $user->consultations()
->whereIn('status', ['completed', 'cancelled', 'no_show'])
->orWhere(fn($q) => $q->approved()->where('scheduled_date', '<', today()))
->latest('scheduled_date')
->paginate(10),
];
}
};
```
## Definition of Done
- [ ] All sections display correctly
- [ ] Calendar download works
- [ ] Status indicators clear
- [ ] Read-only (no actions)
- [ ] Pagination works
- [ ] Tests pass
## Estimation
**Complexity:** Medium | **Effort:** 3-4 hours

View File

@ -0,0 +1,73 @@
# Story 7.3: My Cases/Timelines View
## Epic Reference
**Epic 7:** Client Dashboard
## User Story
As a **client**,
I want **to view my case timelines and their updates**,
So that **I can track the progress of my legal matters**.
## Acceptance Criteria
### Active Cases Section
- [ ] List of active timelines
- [ ] Case name and reference
- [ ] Last update date
- [ ] Update count
- [ ] "View" button
### Archived Cases Section
- [ ] Clearly separated from active
- [ ] Different visual styling (muted)
- [ ] Still accessible for viewing
### Individual Timeline View
- [ ] Case name and reference
- [ ] Status badge (active/archived)
- [ ] All updates in chronological order
- [ ] Each update shows:
- Date and time
- Update content
- [ ] Read-only (no interactions)
### Navigation
- [ ] Back to cases list
- [ ] Responsive layout
## Technical Notes
Reuse components from Story 4.5.
```php
new class extends Component {
public function with(): array
{
return [
'activeTimelines' => auth()->user()
->timelines()
->active()
->withCount('updates')
->latest('updated_at')
->get(),
'archivedTimelines' => auth()->user()
->timelines()
->archived()
->withCount('updates')
->latest('updated_at')
->get(),
];
}
};
```
## Definition of Done
- [ ] Active cases display correctly
- [ ] Archived cases separated
- [ ] Timeline detail view works
- [ ] Updates display chronologically
- [ ] Read-only enforced
- [ ] Tests pass
## Estimation
**Complexity:** Medium | **Effort:** 3-4 hours

View File

@ -0,0 +1,98 @@
# Story 7.4: My Profile View
## Epic Reference
**Epic 7:** Client Dashboard
## User Story
As a **client**,
I want **to view my profile information**,
So that **I can verify my account details are correct**.
## Acceptance Criteria
### Individual Client Profile
- [ ] Full name
- [ ] National ID
- [ ] Email address
- [ ] Phone number
- [ ] Preferred language
- [ ] Account created date
### Company Client Profile
- [ ] Company name
- [ ] Registration number
- [ ] Contact person name
- [ ] Contact person ID
- [ ] Email address
- [ ] Phone number
- [ ] Preferred language
- [ ] Account created date
### Features
- [ ] Account type indicator
- [ ] No edit capabilities (read-only)
- [ ] Message: "Contact admin to update your information"
- [ ] Logout button
## Technical Notes
```php
new class extends Component {
public function with(): array
{
return [
'user' => auth()->user(),
];
}
public function logout(): void
{
auth()->logout();
session()->invalidate();
session()->regenerateToken();
$this->redirect(route('login'));
}
}; ?>
<div class="max-w-2xl mx-auto">
<flux:heading>{{ __('client.my_profile') }}</flux:heading>
<div class="bg-cream rounded-lg p-6 mt-6">
@if($user->user_type === 'individual')
<dl class="space-y-4">
<div>
<dt class="text-sm text-charcoal/70">{{ __('profile.full_name') }}</dt>
<dd class="text-lg font-medium">{{ $user->name }}</dd>
</div>
<div>
<dt class="text-sm text-charcoal/70">{{ __('profile.national_id') }}</dt>
<dd>{{ $user->national_id }}</dd>
</div>
<!-- More fields... -->
</dl>
@else
<!-- Company fields -->
@endif
</div>
<flux:callout class="mt-6">
{{ __('client.contact_admin_to_update') }}
</flux:callout>
<flux:button wire:click="logout" variant="danger" class="mt-6">
{{ __('auth.logout') }}
</flux:button>
</div>
```
## Definition of Done
- [ ] Individual profile displays correctly
- [ ] Company profile displays correctly
- [ ] No edit functionality
- [ ] Contact admin message shown
- [ ] Logout works
- [ ] Tests pass
## Estimation
**Complexity:** Low | **Effort:** 2 hours

View File

@ -0,0 +1,71 @@
# Story 7.5: New Booking Interface
## Epic Reference
**Epic 7:** Client Dashboard
## User Story
As a **client**,
I want **to submit new consultation booking requests**,
So that **I can schedule meetings with the lawyer**.
## Acceptance Criteria
### Access
- [ ] Quick access from dashboard
- [ ] Book Now button in navigation
### Calendar View
- [ ] Calendar showing available dates
- [ ] Available time slots for selected date
### Booking Form
- [ ] Selected date/time (display)
- [ ] Problem summary (required, textarea)
- [ ] Submit button
### Validation
- [ ] Enforce 1 booking per day limit
- [ ] Show warning if already booked that day
- [ ] Prevent booking on unavailable slots
### Submission Flow
- [ ] Confirmation before submission
- [ ] Success message with "Pending Review" status
- [ ] Redirect to consultations list
## Technical Notes
Reuse availability calendar from Story 3.3 and booking submission from Story 3.4.
```php
new class extends Component {
public ?string $selectedDate = null;
public ?string $selectedTime = null;
public string $problemSummary = '';
public function submit(): void
{
// Validation and submission logic from Story 3.4
}
public function canBookDate(string $date): bool
{
return !auth()->user()->consultations()
->whereDate('scheduled_date', $date)
->whereIn('status', ['pending', 'approved'])
->exists();
}
};
```
## Definition of Done
- [ ] Calendar displays correctly
- [ ] Time slots selectable
- [ ] 1-per-day limit enforced
- [ ] Problem summary required
- [ ] Confirmation shown
- [ ] Booking submitted successfully
- [ ] Tests pass
## Estimation
**Complexity:** Medium | **Effort:** 3-4 hours

View File

@ -0,0 +1,104 @@
# Story 7.6: Booking Limit Indicator
## Epic Reference
**Epic 7:** Client Dashboard
## User Story
As a **client**,
I want **to see my booking status and limits clearly**,
So that **I understand when I can book consultations**.
## Acceptance Criteria
### Display Locations
- [ ] Dashboard widget
- [ ] Booking page
### Status Messages
- [ ] "You can book a consultation today"
- [ ] "You already have a booking for today"
- [ ] "You have a pending request for [date]"
### Calendar Integration
- [ ] Calendar shows booked days as unavailable
- [ ] Visual indicator for user's booked dates
### Information
- [ ] Clear messaging about 1-per-day limit
- [ ] Bilingual messages
## Technical Notes
```php
new class extends Component {
public function getBookingStatus(): array
{
$user = auth()->user();
$todayBooking = $user->consultations()
->whereDate('scheduled_date', today())
->whereIn('status', ['pending', 'approved'])
->first();
$pendingRequests = $user->consultations()
->pending()
->get();
$upcomingApproved = $user->consultations()
->approved()
->where('scheduled_date', '>=', today())
->get();
return [
'canBookToday' => is_null($todayBooking),
'todayBooking' => $todayBooking,
'pendingRequests' => $pendingRequests,
'upcomingApproved' => $upcomingApproved,
'bookedDates' => $user->consultations()
->whereIn('status', ['pending', 'approved'])
->where('scheduled_date', '>=', today())
->pluck('scheduled_date')
->map(fn($d) => $d->format('Y-m-d'))
->toArray(),
];
}
};
```
### Template
```blade
<div class="bg-cream rounded-lg p-4">
@if($canBookToday)
<div class="flex items-center gap-2 text-success">
<flux:icon name="check-circle" class="w-5 h-5" />
<span>{{ __('booking.can_book_today') }}</span>
</div>
@else
<div class="flex items-center gap-2 text-warning">
<flux:icon name="exclamation-circle" class="w-5 h-5" />
<span>{{ __('booking.already_booked_today') }}</span>
</div>
@endif
@if($pendingRequests->isNotEmpty())
<p class="mt-2 text-sm text-charcoal/70">
{{ __('booking.pending_for_date', ['date' => $pendingRequests->first()->scheduled_date->format('d/m/Y')]) }}
</p>
@endif
<p class="mt-2 text-sm text-charcoal/70">
{{ __('booking.limit_message') }}
</p>
</div>
```
## Definition of Done
- [ ] Status displays on dashboard
- [ ] Status displays on booking page
- [ ] Calendar highlights booked dates
- [ ] Messages are accurate
- [ ] Bilingual support
- [ ] Tests pass
## Estimation
**Complexity:** Low | **Effort:** 2 hours

View File

@ -0,0 +1,76 @@
# Story 8.1: Email Infrastructure Setup
## Epic Reference
**Epic 8:** Email Notification System
## User Story
As a **developer**,
I want **to configure email sending infrastructure and base templates**,
So that **all emails have consistent branding and reliable delivery**.
## Acceptance Criteria
### SMTP Configuration
- [ ] MAIL_MAILER configured via .env
- [ ] MAIL_HOST, MAIL_PORT, MAIL_USERNAME, MAIL_PASSWORD
- [ ] MAIL_ENCRYPTION (TLS)
- [ ] MAIL_FROM_ADDRESS: no-reply@libra.ps
- [ ] MAIL_FROM_NAME: Libra Law Firm / مكتب ليبرا للمحاماة
### Base Email Template
- [ ] Libra logo in header
- [ ] Navy blue (#0A1F44) and gold (#D4AF37) colors
- [ ] Professional typography
- [ ] Footer with firm contact info
- [ ] Mobile-responsive layout
### Technical Setup
- [ ] Plain text fallback generation
- [ ] Queue configuration for async sending
- [ ] Email logging for debugging
## Technical Notes
```php
// config/mail.php - from .env
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'no-reply@libra.ps'),
'name' => env('MAIL_FROM_NAME', 'Libra Law Firm'),
],
// resources/views/vendor/mail/html/header.blade.php
<tr>
<td class="header" style="background-color: #0A1F44; padding: 25px;">
<a href="{{ config('app.url') }}">
<img src="{{ asset('images/logo-email.png') }}" alt="Libra Law Firm" height="45">
</a>
</td>
</tr>
// Base Mailable
abstract class BaseMailable extends Mailable
{
use Queueable, SerializesModels;
public function build()
{
return $this->from(config('mail.from.address'), $this->getFromName());
}
protected function getFromName(): string
{
$locale = $this->getLocale();
return $locale === 'ar' ? 'مكتب ليبرا للمحاماة' : 'Libra Law Firm';
}
}
```
## Definition of Done
- [ ] SMTP sending works
- [ ] Base template with branding
- [ ] Plain text fallback
- [ ] Queued delivery works
- [ ] Tests pass
## Estimation
**Complexity:** Medium | **Effort:** 3-4 hours

View File

@ -0,0 +1,79 @@
# Story 8.10: Admin Notification - System Events
## Epic Reference
**Epic 8:** Email Notification System
## User Story
As an **admin**,
I want **to be notified of critical system events**,
So that **I can address issues promptly**.
## Acceptance Criteria
### Events to Notify
- [ ] Email delivery failures
- [ ] Scheduled job failures
- [ ] Critical application errors
### Content
- [ ] Event type and description
- [ ] Timestamp
- [ ] Relevant details
- [ ] Recommended action (if any)
### Delivery
- [ ] Sent immediately (not queued)
- [ ] Clear subject line indicating urgency
## Technical Notes
```php
// In App\Exceptions\Handler or bootstrap/app.php
->withExceptions(function (Exceptions $exceptions) {
$exceptions->reportable(function (Throwable $e) {
if ($this->shouldNotifyAdmin($e)) {
$admin = User::where('user_type', 'admin')->first();
$admin?->notify(new SystemErrorNotification($e));
}
});
});
class SystemErrorNotification extends Notification
{
public function __construct(
public Throwable $exception
) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('[URGENT] System Error - Libra Law Firm')
->line('A critical error occurred on the platform.')
->line('Error: ' . $this->exception->getMessage())
->line('Time: ' . now()->format('Y-m-d H:i:s'))
->line('Please check the logs for more details.');
}
}
// Queue failure notification
Queue::failing(function (JobFailed $event) {
$admin = User::where('user_type', 'admin')->first();
$admin?->notify(new QueueFailureNotification($event));
});
```
## Definition of Done
- [ ] Email failures notified
- [ ] Job failures notified
- [ ] Critical errors notified
- [ ] Sent immediately
- [ ] Clear urgency indication
- [ ] Tests pass
## Estimation
**Complexity:** Medium | **Effort:** 3 hours

View File

@ -0,0 +1,73 @@
# Story 8.2: Welcome Email (Account Created)
## Epic Reference
**Epic 8:** Email Notification System
## User Story
As a **new client**,
I want **to receive a welcome email with my login credentials**,
So that **I can access the platform**.
## Acceptance Criteria
### Trigger
- [ ] Sent automatically on user creation by admin
- [ ] Queued for performance
### Content
- [ ] Personalized greeting (name/company)
- [ ] "Your account has been created" message
- [ ] Login credentials (email, password)
- [ ] Login URL link with button
- [ ] Brief platform introduction
- [ ] Contact info for questions
### Language
- [ ] Email in user's preferred_language
- [ ] Arabic template
- [ ] English template
### Design
- [ ] Professional branding
- [ ] Call-to-action button: "Login Now"
## Technical Notes
```php
class WelcomeEmail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public User $user,
public string $password
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: $this->user->preferred_language === 'ar'
? 'مرحباً بك في مكتب ليبرا للمحاماة'
: 'Welcome to Libra Law Firm',
);
}
public function content(): Content
{
return new Content(
markdown: 'emails.welcome.' . ($this->user->preferred_language ?? 'ar'),
);
}
}
```
## Definition of Done
- [ ] Email sent on user creation
- [ ] Credentials included
- [ ] Arabic template works
- [ ] English template works
- [ ] Login button works
- [ ] Tests pass
## Estimation
**Complexity:** Low | **Effort:** 2-3 hours

View File

@ -0,0 +1,66 @@
# Story 8.3: Booking Submitted Confirmation
## Epic Reference
**Epic 8:** Email Notification System
## User Story
As a **client**,
I want **to receive confirmation when I submit a booking request**,
So that **I know my request was received**.
## Acceptance Criteria
### Trigger
- [ ] Sent on booking submission
- [ ] Status: pending
### Content
- [ ] "Your consultation request has been submitted"
- [ ] Requested date and time
- [ ] Problem summary preview
- [ ] "Pending Review" status note
- [ ] Expected response timeframe (general)
### Language
- [ ] Email in client's preferred language
### Design
- [ ] No action required message
- [ ] Professional template
## Technical Notes
```php
class BookingSubmittedEmail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public Consultation $consultation
) {}
public function content(): Content
{
$locale = $this->consultation->user->preferred_language ?? 'ar';
return new Content(
markdown: "emails.booking.submitted.{$locale}",
with: [
'consultation' => $this->consultation,
'user' => $this->consultation->user,
],
);
}
}
```
## Definition of Done
- [ ] Email sent on submission
- [ ] Date/time included
- [ ] Summary preview shown
- [ ] Pending status clear
- [ ] Bilingual templates
- [ ] Tests pass
## Estimation
**Complexity:** Low | **Effort:** 2 hours

View File

@ -0,0 +1,77 @@
# Story 8.4: Booking Approved Email
## Epic Reference
**Epic 8:** Email Notification System
## User Story
As a **client**,
I want **to receive notification when my booking is approved**,
So that **I can confirm the appointment and add it to my calendar**.
## Acceptance Criteria
### Trigger
- [ ] Sent on booking approval by admin
### Content
- [ ] "Your consultation has been approved"
- [ ] Confirmed date and time
- [ ] Duration (45 minutes)
- [ ] Consultation type (free/paid)
- [ ] If paid: amount and payment instructions
- [ ] .ics calendar file attached
- [ ] "Add to Calendar" button
- [ ] Location/contact information
### Language
- [ ] Email in client's preferred language
### Attachment
- [ ] Valid .ics calendar file
## Technical Notes
```php
class BookingApprovedEmail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public Consultation $consultation,
public string $icsContent,
public ?string $paymentInstructions = null
) {}
public function content(): Content
{
$locale = $this->consultation->user->preferred_language ?? 'ar';
return new Content(
markdown: "emails.booking.approved.{$locale}",
with: [
'consultation' => $this->consultation,
'paymentInstructions' => $this->paymentInstructions,
],
);
}
public function attachments(): array
{
return [
Attachment::fromData(fn() => $this->icsContent, 'consultation.ics')
->withMime('text/calendar'),
];
}
}
```
## Definition of Done
- [ ] Email sent on approval
- [ ] All details included
- [ ] Payment info for paid consultations
- [ ] .ics file attached
- [ ] Bilingual templates
- [ ] Tests pass
## Estimation
**Complexity:** Medium | **Effort:** 3 hours

View File

@ -0,0 +1,64 @@
# Story 8.5: Booking Rejected Email
## Epic Reference
**Epic 8:** Email Notification System
## User Story
As a **client**,
I want **to be notified when my booking is rejected**,
So that **I can understand why and request a new consultation if needed**.
## Acceptance Criteria
### Trigger
- [ ] Sent on booking rejection by admin
### Content
- [ ] "Your consultation request could not be approved"
- [ ] Original requested date and time
- [ ] Rejection reason (if provided by admin)
- [ ] Invitation to request new consultation
- [ ] Contact info for questions
### Tone
- [ ] Empathetic, professional
### Language
- [ ] Email in client's preferred language
## Technical Notes
```php
class BookingRejectedEmail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public Consultation $consultation,
public ?string $reason = null
) {}
public function content(): Content
{
$locale = $this->consultation->user->preferred_language ?? 'ar';
return new Content(
markdown: "emails.booking.rejected.{$locale}",
with: [
'consultation' => $this->consultation,
'reason' => $this->reason,
],
);
}
}
```
## Definition of Done
- [ ] Email sent on rejection
- [ ] Reason included if provided
- [ ] Empathetic tone
- [ ] Bilingual templates
- [ ] Tests pass
## Estimation
**Complexity:** Low | **Effort:** 2 hours

View File

@ -0,0 +1,64 @@
# Story 8.6: Consultation Reminder (24 Hours)
## Epic Reference
**Epic 8:** Email Notification System
## User Story
As a **client**,
I want **to receive a reminder 24 hours before my consultation**,
So that **I don't forget my appointment**.
## Acceptance Criteria
### Trigger
- [ ] Scheduled job runs daily
- [ ] Find consultations 24 hours away
- [ ] Only for approved consultations
- [ ] Skip cancelled/no-show
### Content
- [ ] "Reminder: Your consultation is tomorrow"
- [ ] Date and time
- [ ] Consultation type
- [ ] Payment reminder (if paid and not received)
- [ ] Calendar file link
- [ ] Any preparation notes
### Language
- [ ] Email in client's preferred language
## Technical Notes
```php
// Command: php artisan reminders:send-24h
class Send24HourReminders extends Command
{
public function handle(): int
{
$targetTime = now()->addHours(24);
Consultation::where('status', 'approved')
->whereNull('reminder_24h_sent_at')
->whereDate('scheduled_date', $targetTime->toDateString())
->each(function ($consultation) {
$consultation->user->notify(new ConsultationReminder24h($consultation));
$consultation->update(['reminder_24h_sent_at' => now()]);
});
return Command::SUCCESS;
}
}
// Schedule: hourly
```
## Definition of Done
- [ ] Command runs successfully
- [ ] Reminders sent to correct consultations
- [ ] Payment reminder for unpaid
- [ ] No duplicate reminders
- [ ] Bilingual templates
- [ ] Tests pass
## Estimation
**Complexity:** Medium | **Effort:** 3 hours

View File

@ -0,0 +1,68 @@
# Story 8.7: Consultation Reminder (2 Hours)
## Epic Reference
**Epic 8:** Email Notification System
## User Story
As a **client**,
I want **to receive a reminder 2 hours before my consultation**,
So that **I'm prepared and ready**.
## Acceptance Criteria
### Trigger
- [ ] Scheduled job runs every 15 minutes
- [ ] Find consultations 2 hours away
- [ ] Only for approved consultations
- [ ] Skip cancelled/no-show
### Content
- [ ] "Your consultation is in 2 hours"
- [ ] Date and time
- [ ] Final payment reminder (if applicable)
- [ ] Contact info for last-minute issues
### Language
- [ ] Email in client's preferred language
## Technical Notes
```php
// Command: php artisan reminders:send-2h
// Schedule: everyFifteenMinutes
class Send2HourReminders extends Command
{
public function handle(): int
{
$targetTime = now()->addHours(2);
$windowStart = $targetTime->copy()->subMinutes(7);
$windowEnd = $targetTime->copy()->addMinutes(7);
Consultation::where('status', 'approved')
->whereNull('reminder_2h_sent_at')
->whereDate('scheduled_date', today())
->get()
->filter(function ($c) use ($windowStart, $windowEnd) {
$time = Carbon::parse($c->scheduled_date->format('Y-m-d') . ' ' . $c->scheduled_time);
return $time->between($windowStart, $windowEnd);
})
->each(function ($consultation) {
$consultation->user->notify(new ConsultationReminder2h($consultation));
$consultation->update(['reminder_2h_sent_at' => now()]);
});
return Command::SUCCESS;
}
}
```
## Definition of Done
- [ ] Command runs successfully
- [ ] Correct timing (2 hours before)
- [ ] Payment reminder if unpaid
- [ ] No duplicate reminders
- [ ] Bilingual templates
- [ ] Tests pass
## Estimation
**Complexity:** Medium | **Effort:** 2-3 hours

View File

@ -0,0 +1,70 @@
# Story 8.8: Timeline Update Notification
## Epic Reference
**Epic 8:** Email Notification System
## User Story
As a **client**,
I want **to be notified when my case timeline is updated**,
So that **I stay informed about my case progress**.
## Acceptance Criteria
### Trigger
- [ ] Sent on timeline update creation
- [ ] Queued for performance
### Content
- [ ] "Update on your case: [Case Name]"
- [ ] Case reference number
- [ ] Update content (full or summary)
- [ ] Date of update
- [ ] "View Timeline" button/link
### Language
- [ ] Email in client's preferred language
### Design
- [ ] Professional, informative tone
## Technical Notes
```php
class TimelineUpdateNotification extends Notification
{
use Queueable;
public function __construct(
public TimelineUpdate $update
) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$locale = $notifiable->preferred_language ?? 'ar';
$timeline = $this->update->timeline;
return (new MailMessage)
->subject($this->getSubject($locale, $timeline->case_name))
->markdown("emails.timeline.update.{$locale}", [
'update' => $this->update,
'timeline' => $timeline,
]);
}
}
```
## Definition of Done
- [ ] Email sent on update
- [ ] Case info included
- [ ] Update content shown
- [ ] View link works
- [ ] Bilingual templates
- [ ] Tests pass
## Estimation
**Complexity:** Low | **Effort:** 2 hours

View File

@ -0,0 +1,74 @@
# Story 8.9: Admin Notification - New Booking
## Epic Reference
**Epic 8:** Email Notification System
## User Story
As an **admin**,
I want **to be notified when a client submits a booking request**,
So that **I can review and respond promptly**.
## Acceptance Criteria
### Trigger
- [ ] Sent on booking submission by client
### Recipient
- [ ] Admin email address
### Content
- [ ] "New Consultation Request"
- [ ] Client name (individual or company)
- [ ] Requested date and time
- [ ] Problem summary (full)
- [ ] Client contact info
- [ ] "Review Request" button/link
### Priority
- [ ] Clear indicator in subject line
### Language
- [ ] Admin language preference (or default)
## Technical Notes
```php
class NewBookingAdminNotification extends Notification
{
use Queueable;
public function __construct(
public Consultation $consultation
) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('[Action Required] New Consultation Request')
->markdown('emails.admin.new-booking', [
'consultation' => $this->consultation,
'client' => $this->consultation->user,
]);
}
}
// Trigger in booking submission
$admin = User::where('user_type', 'admin')->first();
$admin?->notify(new NewBookingAdminNotification($consultation));
```
## Definition of Done
- [ ] Email sent to admin on new booking
- [ ] All client info included
- [ ] Problem summary shown
- [ ] Review link works
- [ ] Priority clear in subject
- [ ] Tests pass
## Estimation
**Complexity:** Low | **Effort:** 2 hours

View File

@ -0,0 +1,67 @@
# Story 9.1: Color System Implementation
## Epic Reference
**Epic 9:** Design & Branding Implementation
## User Story
As a **developer**,
I want **to implement the complete color palette as Tailwind CSS theme**,
So that **brand colors are consistently applied throughout the application**.
## Acceptance Criteria
### Primary Colors
- [ ] Dark Navy Blue: #0A1F44 (backgrounds, headers)
- [ ] Gold/Brass: #D4AF37 (accents, buttons, links)
### Supporting Colors
- [ ] Light Gold: #F4E4B8 (hover states)
- [ ] Off-White/Cream: #F9F7F4 (text, cards)
- [ ] Charcoal Gray: #2C3E50 (secondary text)
- [ ] Success Green: #27AE60 (approved status)
- [ ] Warning Red: #E74C3C (rejected, errors)
- [ ] Pending Yellow: #F39C12 (pending status)
### Implementation
- [ ] Custom Tailwind colors configured via @theme
- [ ] Color variables for easy maintenance
- [ ] Semantic color aliases (primary, accent, etc.)
## Technical Notes
```css
/* resources/css/app.css */
@import "tailwindcss";
@theme {
/* Primary */
--color-navy: #0A1F44;
--color-gold: #D4AF37;
/* Supporting */
--color-gold-light: #F4E4B8;
--color-cream: #F9F7F4;
--color-charcoal: #2C3E50;
/* Status */
--color-success: #27AE60;
--color-danger: #E74C3C;
--color-warning: #F39C12;
/* Semantic aliases */
--color-primary: var(--color-navy);
--color-accent: var(--color-gold);
--color-background: var(--color-cream);
--color-text: var(--color-charcoal);
}
```
## Definition of Done
- [ ] All colors defined in Tailwind
- [ ] Colors work with utility classes (bg-navy, text-gold)
- [ ] Dark mode consideration documented
- [ ] Tests pass
- [ ] Code formatted with Pint
## Estimation
**Complexity:** Low | **Effort:** 2 hours

View File

@ -0,0 +1,99 @@
# Story 9.10: Accessibility Compliance
## Epic Reference
**Epic 9:** Design & Branding Implementation
## User Story
As a **user with disabilities**,
I want **the platform to be accessible**,
So that **I can use it regardless of my abilities**.
## Acceptance Criteria
### Color Contrast
- [ ] Body text: 4.5:1 minimum
- [ ] Large text: 3:1 minimum
- [ ] UI elements: 3:1 minimum
### Focus Indicators
- [ ] Visible focus outline (gold)
- [ ] Not just color change
### Keyboard Navigation
- [ ] All interactive elements reachable
- [ ] Logical tab order
- [ ] Skip to main content link
### Screen Readers
- [ ] Proper heading hierarchy (h1 > h2 > h3)
- [ ] Alt text for images
- [ ] ARIA labels where needed
- [ ] Form labels associated
### Motion
- [ ] Respect prefers-reduced-motion
- [ ] No auto-playing animations
## Technical Notes
```css
/* Focus styles */
:focus-visible {
@apply outline-2 outline-offset-2 outline-gold;
}
/* Skip link */
.skip-link {
@apply sr-only focus:not-sr-only focus:absolute focus:top-4 focus:start-4
focus:bg-gold focus:text-navy focus:px-4 focus:py-2 focus:rounded focus:z-50;
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
```
```blade
<!-- Skip link -->
<a href="#main-content" class="skip-link">
{{ __('accessibility.skip_to_content') }}
</a>
<!-- Main content landmark -->
<main id="main-content" role="main">
{{ $slot }}
</main>
<!-- Proper labeling -->
<label for="email">{{ __('form.email') }}</label>
<input id="email" type="email" aria-required="true" />
<!-- Image with alt text -->
<img src="{{ asset('images/logo.svg') }}" alt="{{ __('Libra Law Firm Logo') }}" />
```
### Testing Tools
- axe DevTools
- WAVE
- Lighthouse
- Screen reader (VoiceOver/NVDA)
## Definition of Done
- [ ] Color contrast passes
- [ ] Focus indicators visible
- [ ] Keyboard navigation works
- [ ] Screen reader friendly
- [ ] Reduced motion respected
- [ ] Skip link works
- [ ] Lighthouse accessibility > 90
- [ ] Tests pass
## Estimation
**Complexity:** Medium | **Effort:** 4-5 hours

View File

@ -0,0 +1,131 @@
# Story 9.11: Animations & Micro-interactions
## Epic Reference
**Epic 9:** Design & Branding Implementation
## User Story
As a **user**,
I want **subtle, professional animations**,
So that **the interface feels polished and responsive**.
## Acceptance Criteria
### Transitions
- [ ] Button hover: 150ms ease
- [ ] Card hover: 200ms ease
- [ ] Modal open/close: 200ms
- [ ] Page transitions (optional)
### Loading States
- [ ] Skeleton loaders for content
- [ ] Spinner for actions
- [ ] Progress indicators
### Feedback Animations
- [ ] Success checkmark
- [ ] Error shake
- [ ] Toast slide-in
### Hover Effects
- [ ] Links: Color transition
- [ ] Cards: Lift effect
- [ ] Buttons: Background transition
### Requirements
- [ ] All animations subtle, professional
- [ ] Under 300ms duration
- [ ] Respect prefers-reduced-motion
## Technical Notes
```css
/* Base transitions */
.transition-default {
@apply transition-all duration-150 ease-in-out;
}
.transition-slow {
@apply transition-all duration-200 ease-in-out;
}
/* Button hover */
.btn {
@apply transition-colors duration-150;
}
/* Card lift */
.card-hover {
@apply transition-all duration-200;
}
.card-hover:hover {
@apply -translate-y-0.5 shadow-md;
}
/* Skeleton loader */
.skeleton {
@apply animate-pulse bg-charcoal/10 rounded;
}
/* Toast animation */
.toast-enter {
@apply transform translate-x-full opacity-0;
}
.toast-enter-active {
@apply transform translate-x-0 opacity-100 transition-all duration-200;
}
/* Success checkmark */
@keyframes checkmark {
0% { stroke-dashoffset: 100; }
100% { stroke-dashoffset: 0; }
}
.checkmark-animated path {
stroke-dasharray: 100;
animation: checkmark 0.3s ease-in-out forwards;
}
/* Error shake */
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
.shake {
animation: shake 0.3s ease-in-out;
}
```
```blade
<!-- Skeleton loader component -->
@props(['lines' => 3])
<div class="space-y-3">
@for($i = 0; $i < $lines; $i++)
<div class="skeleton h-4 {{ $i === $lines - 1 ? 'w-2/3' : 'w-full' }}"></div>
@endfor
</div>
<!-- Loading spinner -->
<div wire:loading class="flex items-center gap-2">
<svg class="animate-spin h-5 w-5 text-gold" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<span>{{ __('common.loading') }}</span>
</div>
```
## Definition of Done
- [ ] Button transitions work
- [ ] Card hover effects work
- [ ] Skeleton loaders work
- [ ] Spinners work
- [ ] Toast animations work
- [ ] All animations subtle
- [ ] Reduced motion respected
- [ ] Tests pass
## Estimation
**Complexity:** Medium | **Effort:** 4 hours

View File

@ -0,0 +1,72 @@
# Story 9.2: Typography System
## Epic Reference
**Epic 9:** Design & Branding Implementation
## User Story
As a **user**,
I want **professional, readable typography**,
So that **the platform feels polished and content is easy to read**.
## Acceptance Criteria
### Arabic Fonts
- [ ] Primary: Cairo or Tajawal (Google Fonts)
- [ ] Weights: Light (300), Regular (400), SemiBold (600), Bold (700)
### English Fonts
- [ ] Primary: Montserrat or Lato
- [ ] Weights: Light (300), Regular (400), SemiBold (600), Bold (700)
### Font Hierarchy
- [ ] H1: Bold, 2.5rem (40px)
- [ ] H2: SemiBold, 2rem (32px)
- [ ] H3: SemiBold, 1.5rem (24px)
- [ ] Body: Regular, 1rem (16px)
- [ ] Small: Regular, 0.875rem (14px)
### Performance
- [ ] Line height: 1.6 body, 1.3 headings
- [ ] font-display: swap
- [ ] Preload critical fonts
## Technical Notes
```css
/* Google Fonts import */
@import url('https://fonts.googleapis.com/css2?family=Cairo:wght@300;400;600;700&family=Montserrat:wght@300;400;600;700&display=swap');
@theme {
--font-arabic: 'Cairo', 'Tajawal', sans-serif;
--font-english: 'Montserrat', 'Lato', sans-serif;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
--font-size-3xl: 2rem;
--font-size-4xl: 2.5rem;
}
/* Dynamic font selection */
html[lang="ar"] body {
font-family: var(--font-arabic);
}
html[lang="en"] body {
font-family: var(--font-english);
}
```
## Definition of Done
- [ ] Fonts load correctly
- [ ] Arabic fonts work with RTL
- [ ] English fonts work with LTR
- [ ] Font hierarchy applied
- [ ] Performance optimized
- [ ] Tests pass
## Estimation
**Complexity:** Medium | **Effort:** 3 hours

View File

@ -0,0 +1,76 @@
# Story 9.3: Logo Integration
## Epic Reference
**Epic 9:** Design & Branding Implementation
## User Story
As a **visitor**,
I want **to see the Libra scales logo prominently displayed**,
So that **I recognize the firm's branding**.
## Acceptance Criteria
### Logo Placement
- [ ] Navigation: Top left (desktop), centered (mobile)
- [ ] Footer: Smaller version
- [ ] Email templates: Header
- [ ] PDF exports: Header
### Logo Specifications
- [ ] Minimum size: 120px (desktop), 80px (mobile)
- [ ] Clear space: 20px padding minimum
### Format Support
- [ ] SVG primary (scalable)
- [ ] PNG fallback
### Color Variations
- [ ] Full color (gold on navy)
- [ ] Reversed (navy on light)
- [ ] Monochrome gold
### Features
- [ ] Responsive sizing
- [ ] Accessible alt text
## Technical Notes
```blade
<!-- resources/views/components/logo.blade.php -->
@props([
'size' => 'default',
'variant' => 'full'
])
@php
$sizes = [
'small' => 'h-8',
'default' => 'h-12',
'large' => 'h-16',
];
$variants = [
'full' => 'logo.svg',
'reversed' => 'logo-reversed.svg',
'mono' => 'logo-mono.svg',
];
@endphp
<img
src="{{ asset('images/' . $variants[$variant]) }}"
alt="{{ __('Libra Law Firm') }}"
{{ $attributes->merge(['class' => $sizes[$size]]) }}
/>
```
## Definition of Done
- [ ] Logo displays in navigation
- [ ] Logo displays in footer
- [ ] Logo in email templates
- [ ] Responsive sizing works
- [ ] All color variants available
- [ ] Alt text correct
- [ ] Tests pass
## Estimation
**Complexity:** Low | **Effort:** 2 hours

View File

@ -0,0 +1,79 @@
# Story 9.4: Component Styling - Buttons
## Epic Reference
**Epic 9:** Design & Branding Implementation
## User Story
As a **user**,
I want **consistent, professional button styling**,
So that **interactive elements are clear and visually appealing**.
## Acceptance Criteria
### Primary Button
- [ ] Background: Gold (#D4AF37)
- [ ] Text: Dark Navy Blue
- [ ] Hover: Light Gold (#F4E4B8)
- [ ] Border-radius: 6px
- [ ] Padding: 12px 24px
### Secondary Button
- [ ] Background: Transparent
- [ ] Border: 2px solid Gold
- [ ] Text: Gold
- [ ] Hover: Gold background, Navy text
### Disabled State
- [ ] Background: #CCCCCC
- [ ] Text: #666666
- [ ] No hover effect
- [ ] Cursor: not-allowed
### Danger Button
- [ ] Background: #E74C3C
- [ ] Text: White
### Features
- [ ] Loading states with spinner
- [ ] Focus states for accessibility
- [ ] Flux UI button integration
## Technical Notes
```css
/* Extend Flux UI buttons */
.btn-primary {
@apply bg-gold text-navy hover:bg-gold-light rounded-md px-6 py-3 font-semibold transition-colors;
}
.btn-secondary {
@apply bg-transparent border-2 border-gold text-gold hover:bg-gold hover:text-navy rounded-md px-6 py-3 font-semibold transition-colors;
}
.btn-danger {
@apply bg-danger text-white hover:bg-danger/90 rounded-md px-6 py-3 font-semibold transition-colors;
}
/* Loading state */
.btn-loading {
@apply relative pointer-events-none;
}
.btn-loading::after {
content: '';
@apply absolute inset-0 flex items-center justify-center;
/* Spinner styles */
}
```
## Definition of Done
- [ ] Primary button styled
- [ ] Secondary button styled
- [ ] Danger button styled
- [ ] Disabled states work
- [ ] Loading states work
- [ ] Focus states accessible
- [ ] Tests pass
## Estimation
**Complexity:** Medium | **Effort:** 3 hours

View File

@ -0,0 +1,86 @@
# Story 9.5: Component Styling - Forms
## Epic Reference
**Epic 9:** Design & Branding Implementation
## User Story
As a **user**,
I want **consistent, accessible form styling**,
So that **data entry is intuitive and error states are clear**.
## Acceptance Criteria
### Input Fields
- [ ] Border: Charcoal Gray
- [ ] Focus: Gold border
- [ ] Border-radius: 6px
- [ ] Padding: 12px 16px
### Textareas
- [ ] Same styling as inputs
- [ ] Minimum height: 120px
### Select Dropdowns
- [ ] Custom styled (not native)
- [ ] Consistent with inputs
### Checkboxes & Radios
- [ ] Custom styled with gold accent
- [ ] Clear checked state
### Labels
- [ ] SemiBold weight
- [ ] Required indicator (*)
### Error States
- [ ] Red border
- [ ] Error message below field
### RTL Support
- [ ] All fields align correctly in RTL
## Technical Notes
```css
/* Form field styling */
.input-field {
@apply w-full border border-charcoal/30 rounded-md px-4 py-3
focus:border-gold focus:ring-2 focus:ring-gold/20
transition-colors outline-none;
}
.input-error {
@apply border-danger focus:border-danger focus:ring-danger/20;
}
.form-label {
@apply block text-sm font-semibold text-charcoal mb-2;
}
.form-label-required::after {
content: ' *';
@apply text-danger;
}
.error-message {
@apply text-sm text-danger mt-1;
}
/* Custom checkbox */
.checkbox-custom {
@apply w-5 h-5 rounded border-charcoal/30 text-gold
focus:ring-gold focus:ring-offset-0;
}
```
## Definition of Done
- [ ] Input styling consistent
- [ ] Textarea styling consistent
- [ ] Select styling works
- [ ] Checkbox/radio styled
- [ ] Error states clear
- [ ] RTL alignment correct
- [ ] Tests pass
## Estimation
**Complexity:** Medium | **Effort:** 3-4 hours

View File

@ -0,0 +1,92 @@
# Story 9.6: Component Styling - Cards & Containers
## Epic Reference
**Epic 9:** Design & Branding Implementation
## User Story
As a **user**,
I want **consistent card and container styling**,
So that **content is well-organized and visually appealing**.
## Acceptance Criteria
### Card Styling
- [ ] Background: Off-white/cream
- [ ] Box-shadow: 0 2px 8px rgba(0,0,0,0.1)
- [ ] Border-radius: 8px
- [ ] Padding: 24px
### Gold Border Highlight
- [ ] Optional gold top/left border
- [ ] For featured/important cards
### Hover States
- [ ] Subtle lift effect
- [ ] Shadow increase
### Dashboard Stat Cards
- [ ] Icon with gold accent
- [ ] Large number display
- [ ] Trend indicator
### List Cards
- [ ] Consistent item spacing
- [ ] Clear click targets
### Containers
- [ ] Max-width: 1200px, centered
## Technical Notes
```blade
<!-- resources/views/components/card.blade.php -->
@props([
'variant' => 'default',
'hover' => false,
'highlight' => false
])
<div {{ $attributes->merge([
'class' => collect([
'bg-cream rounded-lg p-6',
'shadow-sm' => $variant === 'default',
'shadow-md' => $variant === 'elevated',
'hover:shadow-md hover:-translate-y-0.5 transition-all cursor-pointer' => $hover,
'border-s-4 border-gold' => $highlight,
])->filter()->implode(' ')
]) }}>
{{ $slot }}
</div>
<!-- Stat card component -->
@props(['icon', 'value', 'label', 'trend' => null])
<x-card>
<div class="flex items-center gap-4">
<div class="p-3 bg-gold/10 rounded-lg">
<flux:icon :name="$icon" class="w-6 h-6 text-gold" />
</div>
<div>
<div class="text-2xl font-bold text-navy">{{ $value }}</div>
<div class="text-sm text-charcoal/70">{{ $label }}</div>
@if($trend)
<div class="text-xs {{ $trend > 0 ? 'text-success' : 'text-danger' }}">
{{ $trend > 0 ? '+' : '' }}{{ $trend }}%
</div>
@endif
</div>
</div>
</x-card>
```
## Definition of Done
- [ ] Card component created
- [ ] Shadow and radius consistent
- [ ] Hover effects work
- [ ] Stat cards work
- [ ] Highlight variant works
- [ ] Container max-width applied
- [ ] Tests pass
## Estimation
**Complexity:** Medium | **Effort:** 3 hours

View File

@ -0,0 +1,104 @@
# Story 9.7: Navigation & Footer Styling
## Epic Reference
**Epic 9:** Design & Branding Implementation
## User Story
As a **user**,
I want **professional navigation and footer styling**,
So that **I can easily navigate the site and find information**.
## Acceptance Criteria
### Navigation Bar
- [ ] Fixed top position
- [ ] Navy blue background
- [ ] Logo left (desktop), centered (mobile)
- [ ] Gold text for links
- [ ] Active link indicator
- [ ] Language toggle styled
- [ ] Responsive mobile menu
### Mobile Menu
- [ ] Full-width dropdown/slide
- [ ] Navy background
- [ ] Clear touch targets (44px+)
- [ ] Smooth animation
### Footer
- [ ] Navy blue background
- [ ] Logo and firm info
- [ ] Contact details
- [ ] Links to Terms/Privacy
- [ ] Copyright notice
- [ ] Sticky footer (always at bottom)
## Technical Notes
```blade
<!-- Navigation -->
<nav class="fixed top-0 inset-x-0 bg-navy z-50">
<div class="container mx-auto px-4">
<div class="flex items-center justify-between h-16">
<!-- Logo -->
<a href="/" class="flex items-center">
<x-logo size="small" />
</a>
<!-- Desktop Links -->
<div class="hidden md:flex items-center gap-6">
<x-nav-link href="/" :active="request()->is('/')">
{{ __('nav.home') }}
</x-nav-link>
<x-nav-link href="/posts" :active="request()->is('posts*')">
{{ __('nav.posts') }}
</x-nav-link>
<!-- More links -->
</div>
<!-- Mobile Toggle -->
<button class="md:hidden text-gold" x-on:click="mobileMenu = !mobileMenu">
<flux:icon name="bars-3" class="w-6 h-6" />
</button>
</div>
</div>
</nav>
<!-- Footer -->
<footer class="bg-navy text-cream mt-auto">
<div class="container mx-auto px-4 py-12">
<div class="grid md:grid-cols-3 gap-8">
<div>
<x-logo variant="reversed" />
<p class="mt-4 text-sm opacity-80">{{ __('footer.description') }}</p>
</div>
<div>
<h4 class="font-semibold text-gold mb-4">{{ __('footer.links') }}</h4>
<ul class="space-y-2 text-sm">
<li><a href="{{ route('page.show', 'terms') }}" class="hover:text-gold">{{ __('footer.terms') }}</a></li>
<li><a href="{{ route('page.show', 'privacy') }}" class="hover:text-gold">{{ __('footer.privacy') }}</a></li>
</ul>
</div>
<div>
<h4 class="font-semibold text-gold mb-4">{{ __('footer.contact') }}</h4>
<!-- Contact info -->
</div>
</div>
<div class="border-t border-cream/20 mt-8 pt-8 text-sm text-center opacity-60">
&copy; {{ date('Y') }} {{ __('footer.copyright') }}
</div>
</div>
</footer>
```
## Definition of Done
- [ ] Navigation styled correctly
- [ ] Mobile menu works
- [ ] Footer styled correctly
- [ ] Sticky footer works
- [ ] Links functional
- [ ] RTL layout works
- [ ] Tests pass
## Estimation
**Complexity:** Medium | **Effort:** 4 hours

View File

@ -0,0 +1,90 @@
# Story 9.8: RTL/LTR Layout Perfection
## Epic Reference
**Epic 9:** Design & Branding Implementation
## User Story
As a **user**,
I want **perfect RTL layout for Arabic and LTR for English**,
So that **the content is natural to read in my language**.
## Acceptance Criteria
### RTL (Arabic)
- [ ] Text aligns right
- [ ] Navigation mirrors (logo right)
- [ ] Form labels on right
- [ ] Icons/arrows flip appropriately
- [ ] Margins/paddings swap
### LTR (English)
- [ ] Standard left-to-right layout
- [ ] Proper text alignment
### Transitions
- [ ] Seamless language toggle
- [ ] No layout breaks on switch
### Component Support
- [ ] Calendar RTL support
- [ ] Tables RTL support
- [ ] All components tested in both modes
## Technical Notes
```css
/* Use logical properties */
.card {
margin-inline-start: 1rem; /* margin-left in LTR, margin-right in RTL */
padding-inline-end: 1rem; /* padding-right in LTR, padding-left in RTL */
}
/* RTL-aware utilities */
[dir="rtl"] .flip-rtl {
transform: scaleX(-1);
}
/* Tailwind RTL plugin configuration */
@theme {
/* Use logical properties by default */
}
```
```blade
<!-- RTL-aware icon -->
<flux:icon
name="arrow-right"
@class(['flip-rtl' => app()->getLocale() === 'ar'])
/>
<!-- RTL-aware positioning -->
<div class="{{ app()->getLocale() === 'ar' ? 'right-0' : 'left-0' }} absolute">
<!-- Content -->
</div>
<!-- Better: Use logical properties -->
<div class="start-0 absolute">
<!-- Content -->
</div>
```
### Testing Checklist
- [ ] Navigation layout
- [ ] Form layouts
- [ ] Card layouts
- [ ] Table layouts
- [ ] Modal layouts
- [ ] Dropdown menus
- [ ] Pagination
## Definition of Done
- [ ] RTL renders correctly
- [ ] LTR renders correctly
- [ ] Language switch seamless
- [ ] Icons flip correctly
- [ ] All components tested
- [ ] No layout breaks
- [ ] Tests pass
## Estimation
**Complexity:** High | **Effort:** 5-6 hours

View File

@ -0,0 +1,91 @@
# Story 9.9: Responsive Design Implementation
## Epic Reference
**Epic 9:** Design & Branding Implementation
## User Story
As a **user**,
I want **the platform to work perfectly on all device sizes**,
So that **I can use it on my phone, tablet, or desktop**.
## Acceptance Criteria
### Breakpoints
- [ ] Mobile: < 576px
- [ ] Tablet: 576px - 991px
- [ ] Desktop: 992px - 1199px
- [ ] Large Desktop: >= 1200px
### Mobile Optimizations
- [ ] Touch-friendly targets (44px+)
- [ ] Readable font sizes
- [ ] Single column layouts
- [ ] Collapsible sections
### Tablet Optimizations
- [ ] Two-column where appropriate
- [ ] Sidebar collapsible
### Desktop Optimizations
- [ ] Full layouts with sidebars
- [ ] Multi-column grids
### Specific Features
- [ ] All forms usable on mobile
- [ ] Calendar usable on mobile
- [ ] Tables scroll horizontally
- [ ] No horizontal scroll on any viewport
## Technical Notes
```css
/* Mobile-first approach */
.dashboard-grid {
@apply grid gap-4;
@apply grid-cols-1; /* Mobile */
@apply sm:grid-cols-2; /* Tablet */
@apply lg:grid-cols-3; /* Desktop */
@apply xl:grid-cols-4; /* Large */
}
/* Touch targets */
.touch-target {
@apply min-h-[44px] min-w-[44px];
}
/* Responsive table wrapper */
.table-responsive {
@apply overflow-x-auto -mx-4 px-4;
}
/* Collapsible sidebar */
@media (max-width: 991px) {
.sidebar {
@apply fixed inset-y-0 start-0 w-64 transform -translate-x-full transition-transform z-40;
}
.sidebar.open {
@apply translate-x-0;
}
}
```
### Testing Devices
- iPhone SE (375px)
- iPhone 14 (390px)
- iPad (768px)
- iPad Pro (1024px)
- Desktop (1280px)
- Large Desktop (1920px)
## Definition of Done
- [ ] Mobile layout works
- [ ] Tablet layout works
- [ ] Desktop layout works
- [ ] No horizontal scroll
- [ ] Touch targets 44px+
- [ ] Forms usable on mobile
- [ ] Calendar usable on mobile
- [ ] Tests pass
## Estimation
**Complexity:** High | **Effort:** 5-6 hours