generated sotries
This commit is contained in:
parent
d889b24f12
commit
8f95089814
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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') }} →
|
||||||
|
</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">
|
||||||
|
← {{ __('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
|
||||||
|
|
@ -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') }} →
|
||||||
|
</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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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">
|
||||||
|
© {{ 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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue