feat: Phase 1 - Redis config, Dashboard cache, Report cache, N+1 fixes, observers
- Add predis/predis for Redis support - Configure cache/queue defaults to redis in config + .env.example - Create DashboardController with 60s cache for dashboard queries - Add cache (5min) to ReportController::generate() and preview() - Add FeatureObserver, InspectionObserver, IssueObserver for cache invalidation - Fix N+1 in ProjectMap (eager load template, images), TaskManager (subtasks.parentTask) - Register observers in AppServiceProvider Tests: 101 passing (319 assertions)
This commit is contained in:
+2
-2
@@ -35,9 +35,9 @@ SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
QUEUE_CONNECTION=redis
|
||||
|
||||
CACHE_STORE=database
|
||||
CACHE_STORE=redis
|
||||
# CACHE_PREFIX=
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
@@ -0,0 +1,792 @@
|
||||
# ConstruProgress Improvement Plan
|
||||
|
||||
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Improve performance, security, real-time capabilities, and developer experience of the ConstruProgress Laravel + Livewire application.
|
||||
|
||||
**Architecture:** Incremental enhancements to existing Laravel 11 + Livewire 3 + Spatie Permissions stack. No breaking changes to API.
|
||||
|
||||
**Tech Stack:** Laravel 12, Livewire 3.6, Alpine.js, SQLite (tests) / MySQL (prod), PHP 8.2+, Pest/PHPUnit, Laravel Pint.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Performance & Caching (High ROI, Low Risk)
|
||||
|
||||
### Task 1.1: Install and configure Redis
|
||||
|
||||
**Objective:** Add Redis for caching and queue worker
|
||||
|
||||
**Files:**
|
||||
- Create: `config/redis.php` (if missing)
|
||||
- Modify: `.env.example` — add REDIS_* vars
|
||||
- Modify: `config/cache.php` — set default to redis
|
||||
- Modify: `config/queue.php` — set default to redis
|
||||
- Modify: `composer.json` — add `predis/predis` or `phpredis` ext
|
||||
|
||||
**Step 1: Add Redis dependency**
|
||||
```bash
|
||||
cd /mnt/c/xampp/htdocs/construprogress && /mnt/c/xampp/php/php.exe composer require predis/predis --no-interaction
|
||||
```
|
||||
|
||||
**Step 2: Configure cache and queue**
|
||||
```php
|
||||
// config/cache.php
|
||||
'default' => env('CACHE_DRIVER', 'redis'),
|
||||
|
||||
// config/queue.php
|
||||
'default' => env('QUEUE_CONNECTION', 'redis'),
|
||||
```
|
||||
|
||||
**Step 3: Verify**
|
||||
```bash
|
||||
/mnt/c/xampp/php/php.exe artisan config:clear && /mnt/c/xampp/php/php.exe artisan test --filter="Cache"
|
||||
```
|
||||
|
||||
**Commit:** `feat: add Redis for caching and queues`
|
||||
|
||||
---
|
||||
|
||||
### Task 1.2: Cache ProjectDashboard queries
|
||||
|
||||
**Objective:** Cache expensive dashboard queries (projects, tasks, issues, notifications)
|
||||
|
||||
**Files:**
|
||||
- Modify: `routes/web.php:59-95` (dashboard closure) → move to `App\Http\Controllers\DashboardController`
|
||||
- Create: `app/Http/Controllers/DashboardController.php`
|
||||
- Modify: `resources/views/dashboard.blade.php` (if exists)
|
||||
|
||||
**Step 1: Create controller with cache**
|
||||
```php
|
||||
// app/Http/Controllers/DashboardController.php
|
||||
public function index()
|
||||
{
|
||||
$user = Auth::user();
|
||||
$cacheKey = "dashboard:{$user->id}:v1";
|
||||
|
||||
$data = Cache::remember($cacheKey, 60, function () use ($user) {
|
||||
// ... existing query logic
|
||||
});
|
||||
|
||||
return view('dashboard', $data);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Invalidate on relevant events**
|
||||
```php
|
||||
// In Task/Issue/Project observers or model events
|
||||
Cache::forget("dashboard:{$user->id}:v1");
|
||||
```
|
||||
|
||||
**Step 3: Test**
|
||||
```bash
|
||||
/mnt/c/xampp/php/php.exe artisan test --filter="Dashboard"
|
||||
```
|
||||
|
||||
**Commit:** `perf: cache dashboard queries with 60s TTL`
|
||||
|
||||
---
|
||||
|
||||
### Task 1.3: Cache ReportController generate/preview
|
||||
|
||||
**Objective:** Cache report generation (HTML + Excel) per project + filters
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/Http/Controllers/ReportController.php` (generate, preview methods)
|
||||
- Modify: `app/Services/ReportGenerator.php` — add cache key generation
|
||||
|
||||
**Step 1: Add cache to ReportController::generate()**
|
||||
```php
|
||||
$cacheKey = "report:{$project->id}:".md5(json_encode($filters->toArray()));
|
||||
$data = Cache::remember($cacheKey, 300, fn () => $generator->generate());
|
||||
```
|
||||
|
||||
**Step 2: Invalidate on progress/inspection/issue changes**
|
||||
```php
|
||||
// Feature, Inspection, Issue observers
|
||||
Cache::tags(["report:project:{$project->id}"])->flush();
|
||||
```
|
||||
|
||||
**Commit:** `perf: cache report generation with 5min TTL`
|
||||
|
||||
---
|
||||
|
||||
### Task 1.4: Optimize N+1 queries in Livewire components
|
||||
|
||||
**Objective:** Fix N+1 in ProjectMap, TaskManager, IssueManager
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/Livewire/Projects/ProjectMap.php` (mount, loadTemplates, selectFeature)
|
||||
- Modify: `app/Livewire/Tasks/TaskManager.php` (render)
|
||||
- Modify: `app/Livewire/Issues/IssueManager.php` (render)
|
||||
|
||||
**Step 1: Audit current eager loading**
|
||||
```bash
|
||||
grep -n "with(" app/Livewire/Projects/ProjectMap.php
|
||||
grep -n "with(" app/Livewire/Tasks/TaskManager.php
|
||||
grep -n "with(" app/Livewire/Issues/IssueManager.php
|
||||
```
|
||||
|
||||
**Step 2: Add missing relationships**
|
||||
```php
|
||||
// ProjectMap mount()
|
||||
->with(['layers.features.template', 'layers.features.inspections', 'layers.features.issues'])
|
||||
|
||||
// TaskManager render()
|
||||
->with(['project', 'phase', 'assignee', 'creator', 'subtasks.parentTask'])
|
||||
|
||||
// IssueManager render()
|
||||
->with(['project', 'assignee', 'reporter', 'feature.layer.phase', 'checklistItems', 'comments.user'])
|
||||
```
|
||||
|
||||
**Step 3: Verify with Laravel Debugbar or query log**
|
||||
```bash
|
||||
/mnt/c/xampp/php/php.exe artisan test --filter="MapTablesTest|IssuesTablePageTest"
|
||||
```
|
||||
|
||||
**Commit:** `perf: fix N+1 queries in Map, TaskManager, IssueManager`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Real-time Notifications (High Impact)
|
||||
|
||||
### Task 2.1: Install Laravel Reverb
|
||||
|
||||
**Objective:** Add WebSocket server for real-time events
|
||||
|
||||
**Files:**
|
||||
- Modify: `composer.json` — add `laravel/reverb`
|
||||
- Create: `config/reverb.php` (via vendor:publish)
|
||||
- Modify: `.env.example` — REVERB_* vars
|
||||
- Modify: `bootstrap.js` / `resources/js/app.js` — Echo + Reverb client
|
||||
|
||||
**Step 1: Install**
|
||||
```bash
|
||||
/mnt/c/xampp/php/php.exe composer require laravel/reverb --no-interaction
|
||||
/mnt/c/xampp/php/php.exe artisan reverb:install
|
||||
npm install --save-dev laravel-echo pusher-js
|
||||
```
|
||||
|
||||
**Step 2: Configure broadcasting**
|
||||
```php
|
||||
// config/broadcasting.php
|
||||
'default' => env('BROADCAST_DRIVER', 'reverb'),
|
||||
```
|
||||
|
||||
**Step 3: Update frontend**
|
||||
```javascript
|
||||
// resources/js/app.js
|
||||
import Echo from 'laravel-echo';
|
||||
window.Pusher = require('pusher-js');
|
||||
window.Echo = new Echo({
|
||||
broadcaster: 'reverb',
|
||||
key: import.meta.env.VITE_REVERB_APP_KEY,
|
||||
wsHost: import.meta.env.VITE_REVERB_HOST,
|
||||
wsPort: import.meta.env.VITE_REVERB_PORT,
|
||||
wssPort: import.meta.env.VITE_REVERB_PORT,
|
||||
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
|
||||
enabledTransports: ['ws', 'wss'],
|
||||
});
|
||||
```
|
||||
|
||||
**Commit:** `feat: add Laravel Reverb for real-time`
|
||||
|
||||
---
|
||||
|
||||
### Task 2.2: Broadcast notifications via Reverb
|
||||
|
||||
**Objective:** Replace polling with real-time notification delivery
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/Notifications/*.php` — implement `ShouldBroadcast` + `broadcastOn()`
|
||||
- Modify: `resources/views/layouts/navigation.blade.php` (or notification bell component) — listen for Echo events
|
||||
- Create: `resources/js/components/NotificationBell.vue` (or Alpine component)
|
||||
|
||||
**Step 1: Update base notification**
|
||||
```php
|
||||
// app/Notifications/Notification.php (or trait)
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
|
||||
class Notification implements ShouldBroadcast
|
||||
{
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PrivateChannel('user.'.$this->notifiable->id)];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Frontend listener**
|
||||
```html
|
||||
<!-- In notification bell component -->
|
||||
<script>
|
||||
document.addEventListener('livewire:load', () => {
|
||||
window.Echo?.private(`user.{{ auth()->id() }}`)
|
||||
.notification((notification) => {
|
||||
// Update badge count, prepend to dropdown
|
||||
Livewire.dispatch('notificationReceived', notification);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
**Step 3: Test with two browser tabs**
|
||||
```bash
|
||||
# Start reverb
|
||||
/mnt/c/xampp/php/php.exe artisan reverb:start --debug
|
||||
# Trigger notification via test
|
||||
/mnt/c/xampp/php/php.exe artisan test --filter="IssuesEnhancementsTest::assigning a task notifies"
|
||||
```
|
||||
|
||||
**Commit:** `feat: real-time notifications via Reverb`
|
||||
|
||||
---
|
||||
|
||||
### Task 2.3: Broadcast task/issue updates to ProjectMap
|
||||
|
||||
**Objective:** Live updates on map when features change (progress, status, inspections)
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/Livewire/Projects/ProjectMap.php` — listen for broadcast events
|
||||
- Modify: `app/Models/Feature.php` — broadcast on update
|
||||
- Modify: `app/Models/Inspection.php` — broadcast on create
|
||||
|
||||
**Step 1: Broadcast feature changes**
|
||||
```php
|
||||
// Feature model
|
||||
protected $dispatchesEvents = [
|
||||
'updated' => FeatureUpdated::class,
|
||||
];
|
||||
|
||||
// Event
|
||||
class FeatureUpdated implements ShouldBroadcast
|
||||
{
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PrivateChannel('project.'.$this->feature->project_id)];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Listen in ProjectMap**
|
||||
```php
|
||||
#[On('echo:project.{projectId},FeatureUpdated')]
|
||||
public function handleFeatureUpdated($event)
|
||||
{
|
||||
$this->allFeatures = $this->allFeatures->map(fn ($f) =>
|
||||
$f->id === $event['feature']['id'] ? (object)$event['feature'] : $f
|
||||
);
|
||||
$this->dispatch('featureUpdated', $event['feature']);
|
||||
}
|
||||
```
|
||||
|
||||
**Commit:** `feat: live map updates via Reverb`
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Validation & Security Hardening
|
||||
|
||||
### Task 3.1: Create FormRequests for critical Livewire forms
|
||||
|
||||
**Objective:** Centralize validation + authorization for TaskForm, IssueForm, InspectionForm
|
||||
|
||||
**Files:**
|
||||
- Create: `app/Http/Requests/TaskStoreRequest.php`
|
||||
- Create: `app/Http/Requests/TaskUpdateRequest.php`
|
||||
- Create: `app/Http/Requests/IssueStoreRequest.php`
|
||||
- Create: `app/Http/Requests/IssueUpdateRequest.php`
|
||||
- Create: `app/Http/Requests/InspectionStoreRequest.php`
|
||||
- Create: `app/Http/Requests/InspectionUpdateRequest.php`
|
||||
- Modify: `app/Livewire/Tasks/TaskForm.php` — use FormRequest
|
||||
- Modify: `app/Livewire/Issues/IssueForm.php` — use FormRequest
|
||||
- Modify: `app/Livewire/Projects/ProjectMap.php` (inspection methods) — use FormRequest
|
||||
|
||||
**Step 1: Generate requests**
|
||||
```bash
|
||||
/mnt/c/xampp/php/php.exe artisan make:request TaskStoreRequest
|
||||
/mnt/c/xampp/php/php.exe artisan make:request TaskUpdateRequest
|
||||
# ... repeat for Issue, Inspection
|
||||
```
|
||||
|
||||
**Step 2: Implement rules + authorize**
|
||||
```php
|
||||
// TaskStoreRequest
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()->can('create tasks', $this->route('project'));
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => 'required|string|max:255',
|
||||
'phase_id' => 'nullable|exists:phases,id',
|
||||
'status' => 'required|in:pending,in_progress,completed,cancelled',
|
||||
// ...
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Use in Livewire**
|
||||
```php
|
||||
// TaskForm::save()
|
||||
$validated = app(TaskStoreRequest::class)->validated();
|
||||
// or for update
|
||||
$validated = app(TaskUpdateRequest::class)->validated();
|
||||
```
|
||||
|
||||
**Step 4: Test**
|
||||
```bash
|
||||
/mnt/c/xampp/php/php.exe artisan test --filter="TaskForm|IssueForm|InspectionForm"
|
||||
```
|
||||
|
||||
**Commit:** `refactor: FormRequests for Task, Issue, Inspection forms`
|
||||
|
||||
---
|
||||
|
||||
### Task 3.2: Add rate limiting to all API routes
|
||||
|
||||
**Objective:** Protect bundle, templates, projects endpoints
|
||||
|
||||
**Files:**
|
||||
- Modify: `routes/api.php` — add throttle middleware to all routes
|
||||
|
||||
**Step 1: Update routes**
|
||||
```php
|
||||
Route::middleware(['auth:sanctum', 'ability:mobile-sync', 'throttle:120,1'])->group(function () {
|
||||
Route::get('projects', [ProjectApiController::class, 'index']);
|
||||
Route::get('projects/{project}/bundle', [ProjectApiController::class, 'bundle']);
|
||||
Route::get('templates', [ProjectApiController::class, 'templates']);
|
||||
});
|
||||
|
||||
Route::middleware(['auth:sanctum', 'ability:mobile-sync', 'throttle:60,1'])->group(function () {
|
||||
Route::post('sync', [SyncController::class, 'sync']);
|
||||
});
|
||||
|
||||
Route::middleware(['auth:sanctum', 'ability:mobile-sync', 'throttle:120,1'])->group(function () {
|
||||
Route::post('media', [MediaController::class, 'upload']);
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Test rate limit**
|
||||
```bash
|
||||
/mnt/c/xampp/php/php.exe artisan test --filter="MobileApiTest"
|
||||
```
|
||||
|
||||
**Commit:** `security: rate limit all mobile API endpoints`
|
||||
|
||||
---
|
||||
|
||||
### Task 3.3: Add mass assignment protection audit
|
||||
|
||||
**Objective:** Ensure all models use `$fillable` (not `$guarded = []`)
|
||||
|
||||
**Files:**
|
||||
- Check: all `app/Models/*.php`
|
||||
- Modify: any with `$guarded = ['*']` or missing `$fillable`
|
||||
|
||||
**Step 1: Audit**
|
||||
```bash
|
||||
grep -r "guarded" app/Models/ --include="*.php"
|
||||
grep -r "fillable" app/Models/ --include="*.php"
|
||||
```
|
||||
|
||||
**Step 2: Fix any gaps**
|
||||
```php
|
||||
// Example fix
|
||||
protected $fillable = ['name', 'email', 'status']; // explicit list
|
||||
// NOT: protected $guarded = ['id']; // or empty
|
||||
```
|
||||
|
||||
**Commit:** `security: audit mass assignment protection`
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Developer Experience & Testing
|
||||
|
||||
### Task 4.1: Add Laravel Pint to CI (already done locally)
|
||||
|
||||
**Objective:** Ensure code style enforced in pipeline
|
||||
|
||||
**Files:**
|
||||
- Create: `.github/workflows/pint.yml` (or GitLab CI equivalent)
|
||||
|
||||
**Step 1: Create workflow**
|
||||
```yaml
|
||||
# .github/workflows/pint.yml
|
||||
name: Code Style
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
pint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with: { php-version: '8.2' }
|
||||
- run: composer install --prefer-dist --no-progress
|
||||
- run: ./vendor/bin/pint --test
|
||||
```
|
||||
|
||||
**Commit:** `ci: add Pint code style check`
|
||||
|
||||
---
|
||||
|
||||
### Task 4.2: Add mutation testing (Infection)
|
||||
|
||||
**Objective:** Measure test quality beyond coverage
|
||||
|
||||
**Files:**
|
||||
- Modify: `composer.json` — add `infection/infection` dev dep
|
||||
- Create: `infection.json5` config
|
||||
|
||||
**Step 1: Install**
|
||||
```bash
|
||||
/mnt/c/xampp/php/php.exe composer require --dev infection/infection --no-interaction
|
||||
```
|
||||
|
||||
**Step 2: Configure**
|
||||
```json5
|
||||
// infection.json5
|
||||
{
|
||||
"source": {
|
||||
"directories": ["app"]
|
||||
},
|
||||
"mutators": {
|
||||
"@default": true
|
||||
},
|
||||
"testFramework": "phpunit",
|
||||
"testFrameworkOptions": "--testdox",
|
||||
"minMsi": 80,
|
||||
"minCoveredMsi": 80
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Run baseline**
|
||||
```bash
|
||||
/mnt/c/xampp/php/php.exe vendor/bin/infection --configuration=infection.json5
|
||||
```
|
||||
|
||||
**Commit:** `test: add mutation testing with Infection`
|
||||
|
||||
---
|
||||
|
||||
### Task 4.3: Add browser tests (Laravel Dusk)
|
||||
|
||||
**Objective:** Test critical user flows (login, create project, map interaction, report generation)
|
||||
|
||||
**Files:**
|
||||
- Modify: `composer.json` — add `laravel/dusk` dev dep
|
||||
- Create: `tests/Browser/*.php`
|
||||
- Modify: `.env.dusk.local` (or CI config)
|
||||
|
||||
**Step 1: Install**
|
||||
```bash
|
||||
/mnt/c/xampp/php/php.exe composer require --dev laravel/dusk --no-interaction
|
||||
/mnt/c/xampp/php/php.exe artisan dusk:install
|
||||
```
|
||||
|
||||
**Step 2: Write critical flow tests**
|
||||
```php
|
||||
// tests/Browser/ProjectWorkflowTest.php
|
||||
public function test_user_can_create_project_and_add_feature()
|
||||
{
|
||||
$this->browse(function (Browser $browser) {
|
||||
$browser->loginAs(User::factory()->create())
|
||||
->visit('/projects/create')
|
||||
->type('name', 'Test Project')
|
||||
->press('Guardar')
|
||||
->assertPathIs('/projects/1')
|
||||
->visit('/projects/1/map')
|
||||
->click('@add-feature-button')
|
||||
->type('name', 'Test Feature')
|
||||
->press('Guardar')
|
||||
->assertSee('Test Feature');
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Commit:** `test: add Dusk browser tests for critical flows`
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Feature Gaps (Medium Priority)
|
||||
|
||||
### Task 5.1: Scheduled report email job
|
||||
|
||||
**Objective:** Daily/weekly email with project report to stakeholders
|
||||
|
||||
**Files:**
|
||||
- Create: `app/Jobs/SendScheduledReport.php`
|
||||
- Modify: `routes/console.php` — schedule job
|
||||
- Create: `resources/views/emails/scheduled-report.blade.php`
|
||||
- Modify: `app/Console/Commands/SendScheduledReports.php` (or use Schedule directly)
|
||||
|
||||
**Step 1: Create job**
|
||||
```php
|
||||
// app/Jobs/SendScheduledReport.php
|
||||
public function handle()
|
||||
{
|
||||
$projects = Project::whereHas('users', fn ($q) => $q->where('receive_reports', true))->get();
|
||||
foreach ($projects as $project) {
|
||||
$users = $project->users()->where('receive_reports', true)->get();
|
||||
foreach ($users as $user) {
|
||||
Mail::to($user)->send(new ScheduledReportMail($project, $user));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Schedule**
|
||||
```php
|
||||
// routes/console.php
|
||||
Schedule::job(new SendScheduledReport)->dailyAt('08:00');
|
||||
```
|
||||
|
||||
**Commit:** `feat: scheduled report emails`
|
||||
|
||||
---
|
||||
|
||||
### Task 5.2: Global search (Scout + Meilisearch/Typesense)
|
||||
|
||||
**Objective:** Cross-project search for features, issues, tasks, inspections
|
||||
|
||||
**Files:**
|
||||
- Modify: `composer.json` — add `laravel/scout`, `meilisearch/meilisearch-php`
|
||||
- Create: `config/scout.php` (vendor:publish)
|
||||
- Modify: Models (Feature, Issue, Task, Inspection) — `Searchable` trait
|
||||
- Create: `app/Livewire/Common/GlobalSearch.php`
|
||||
- Modify: Navigation layout — add search input
|
||||
|
||||
**Step 1: Install**
|
||||
```bash
|
||||
/mnt/c/xampp/php/php.exe composer require laravel/scout meilisearch/meilisearch-php --no-interaction
|
||||
/mnt/c/xampp/php/php.exe artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"
|
||||
```
|
||||
|
||||
**Step 2: Make models searchable**
|
||||
```php
|
||||
// Feature.php
|
||||
use Laravel\Scout\Searchable;
|
||||
|
||||
class Feature extends Model
|
||||
{
|
||||
use Searchable;
|
||||
|
||||
public function toSearchableArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'project_id' => $this->project_id,
|
||||
'status' => $this->status,
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Index & search**
|
||||
```bash
|
||||
/mnt/c/xampp/php/php.exe artisan scout:import "App\Models\Feature"
|
||||
```
|
||||
|
||||
**Commit:** `feat: global search with Scout + Meilisearch`
|
||||
|
||||
---
|
||||
|
||||
### Task 5.3: PWA offline support (Workbox)
|
||||
|
||||
**Objective:** Restore offline capability for field workers
|
||||
|
||||
**Files:**
|
||||
- Create: `public/sw.js` (Workbox-generated)
|
||||
- Modify: `vite.config.js` — add `vite-plugin-pwa`
|
||||
- Modify: `resources/js/app.js` — register SW
|
||||
- Modify: `OfflineSyncController` — ensure compatibility
|
||||
|
||||
**Step 1: Add PWA plugin**
|
||||
```bash
|
||||
npm install --save-dev vite-plugin-pwa workbox-window
|
||||
```
|
||||
|
||||
**Step 2: Configure**
|
||||
```javascript
|
||||
// vite.config.js
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
manifest: {
|
||||
name: 'ConstruProgress',
|
||||
short_name: 'CP',
|
||||
theme_color: '#2563eb',
|
||||
icons: [...]
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^https:\/\/.*\/api\/v1\/.*/,
|
||||
handler: 'NetworkFirst',
|
||||
options: { cacheName: 'api-cache', expiration: { maxEntries: 100, maxAgeSeconds: 86400 } }
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
**Commit:** `feat: PWA offline support with Workbox`
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Code Organization (Tech Debt)
|
||||
|
||||
### Task 6.1: Split ProjectMap into traits/components
|
||||
|
||||
**Objective:** Reduce 800-line component into manageable pieces
|
||||
|
||||
**Files:**
|
||||
- Create: `app/Livewire/Projects/Traits/MapLayers.php`
|
||||
- Create: `app/Livewire/Projects/Traits/MapInspections.php`
|
||||
- Create: `app/Livewire/Projects/Traits/MapIssues.php`
|
||||
- Create: `app/Livewire/Projects/Traits/MapFeatures.php`
|
||||
- Modify: `app/Livewire/Projects/ProjectMap.php` — use traits
|
||||
|
||||
**Step 1: Extract layers logic**
|
||||
```php
|
||||
// app/Livewire/Projects/Traits/MapLayers.php
|
||||
trait MapLayers
|
||||
{
|
||||
public $phases;
|
||||
public $activeLayers = [];
|
||||
public $showLayerModal = false;
|
||||
// ... layer methods
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Use in ProjectMap**
|
||||
```php
|
||||
class ProjectMap extends Component
|
||||
{
|
||||
use MapLayers, MapInspections, MapIssues, MapFeatures;
|
||||
// ... only core map logic remains
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Test**
|
||||
```bash
|
||||
/mnt/c/xampp/php/php.exe artisan test --filter="MapTablesTest"
|
||||
```
|
||||
|
||||
**Commit:** `refactor: split ProjectMap into traits`
|
||||
|
||||
---
|
||||
|
||||
### Task 6.2: Extract ReportGenerator into smaller services
|
||||
|
||||
**Objective:** Separate data aggregation from export formatting
|
||||
|
||||
**Files:**
|
||||
- Create: `app/Services/Report/DataAggregator.php`
|
||||
- Create: `app/Services/Report/HtmlExporter.php`
|
||||
- Create: `app/Services/Report/ExcelExporter.php`
|
||||
- Modify: `app/Services/ReportGenerator.php` — delegate to services
|
||||
- Modify: `app/Exports/ProjectReportExport.php` — use HtmlExporter/ExcelExporter
|
||||
|
||||
**Step 1: Create DataAggregator**
|
||||
```php
|
||||
// app/Services/Report/DataAggregator.php
|
||||
class DataAggregator
|
||||
{
|
||||
public function aggregate(Project $project, ReportFilters $filters): array
|
||||
{
|
||||
// ... all the data fetching logic from ReportGenerator::generate()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Create exporters**
|
||||
```php
|
||||
// app/Services/Report/HtmlExporter.php
|
||||
class HtmlExporter
|
||||
{
|
||||
public function export(array $data): string
|
||||
{
|
||||
return view('reports.complete', $data)->render();
|
||||
}
|
||||
}
|
||||
|
||||
// app/Services/Report/ExcelExporter.php
|
||||
class ExcelExporter
|
||||
{
|
||||
public function export(Project $project, ReportFilters $filters, array $data): BinaryFileResponse
|
||||
{
|
||||
return Excel::download(new ProjectReportExport($project, $filters, $data), $filename);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Simplify ReportGenerator**
|
||||
```php
|
||||
// app/Services/ReportGenerator.php
|
||||
public function generate(): array
|
||||
{
|
||||
return (new DataAggregator)->aggregate($this->project, $this->filters);
|
||||
}
|
||||
```
|
||||
|
||||
**Commit:** `refactor: split ReportGenerator into Aggregator + Exporters`
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
After all phases:
|
||||
- [ ] All 101 existing tests still pass
|
||||
- [ ] New tests added for each feature (target: +30 tests)
|
||||
- [ ] API routes unchanged (8 routes, 24 tests pass)
|
||||
- [ ] Pint passes on entire codebase
|
||||
- [ ] Mutation score ≥ 80%
|
||||
- [ ] Dusk tests pass for critical flows
|
||||
- [ ] Reverb + Echo working in local dev
|
||||
- [ ] Dashboard load time < 500ms (cached)
|
||||
- [ ] Report generation < 2s (cached)
|
||||
|
||||
---
|
||||
|
||||
## Risks & Tradeoffs
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Reverb adds infrastructure complexity | Start with single-server; scale later |
|
||||
| Cache invalidation bugs | Use model observers + cache tags; test thoroughly |
|
||||
| Breaking existing Livewire components | TDD each refactor; run full test suite after each |
|
||||
| Meilisearch adds external dependency | Can use Typesense or database driver as fallback |
|
||||
| Dusk requires Chrome in CI | Use `laravel/dusk` GitHub Action with Chrome |
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Redis hosting:** Self-hosted, Docker, or managed (Redis Cloud, Upstash)?
|
||||
2. **Reverb hosting:** Same server as app, or separate?
|
||||
3. **Search engine:** Meilisearch (simpler) vs Typesense (better filtering)?
|
||||
4. **PWA strategy:** Full offline-first or just cache-first for assets?
|
||||
5. **Report scheduling:** Per-user preferences or project-level only?
|
||||
|
||||
---
|
||||
|
||||
## Execution Order (Dependencies)
|
||||
|
||||
```
|
||||
Phase 1 (independent) → Phase 2 (needs Redis) → Phase 3 (independent)
|
||||
↓
|
||||
Phase 4 (needs Phase 1 cache) ← Phase 5 (needs Phase 2 Reverb)
|
||||
↓
|
||||
Phase 6 (refactor, anytime after tests stable)
|
||||
```
|
||||
|
||||
**Recommended start:** Task 1.1 (Redis) → 1.2 (Dashboard cache) → 2.1 (Reverb) → 3.1 (FormRequests)
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Issue;
|
||||
use App\Models\IssueTask;
|
||||
use App\Models\Project;
|
||||
use App\Models\Task;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$user = Auth::user();
|
||||
$cacheKey = "dashboard:{$user->id}:v1";
|
||||
|
||||
$data = Cache::remember($cacheKey, 60, function () use ($user) {
|
||||
$projects = Project::accessibleBy($user)
|
||||
->with(['phases' => fn ($q) => $q->select('id', 'project_id', 'progress_percent')])
|
||||
->orderBy('name')->take(8)->get();
|
||||
$projectsCount = Project::accessibleBy($user)->count();
|
||||
|
||||
$myTasks = IssueTask::where('assigned_to', $user->id)
|
||||
->where('is_done', false)
|
||||
->with('issue.project')
|
||||
->orderByRaw('due_date IS NULL, due_date ASC')
|
||||
->take(8)->get();
|
||||
|
||||
$myTasksFromTasks = Task::where('assigned_to', $user->id)
|
||||
->where('status', '!=', 'completed')
|
||||
->with('project')
|
||||
->orderByRaw('due_date IS NULL, due_date ASC')
|
||||
->take(8)->get();
|
||||
|
||||
$myTasksCount = IssueTask::where('assigned_to', $user->id)->where('is_done', false)->count()
|
||||
+ Task::where('assigned_to', $user->id)->where('status', '!=', 'completed')->count();
|
||||
|
||||
$myIssues = Issue::where('assigned_to', $user->id)
|
||||
->whereIn('status', ['open', 'in_review'])
|
||||
->with('project')
|
||||
->latest()->take(6)->get();
|
||||
$myIssuesCount = Issue::where('assigned_to', $user->id)->whereIn('status', ['open', 'in_review'])->count();
|
||||
|
||||
$notifications = $user->notifications()->latest()->take(6)->get();
|
||||
$unreadCount = $user->unreadNotifications()->count();
|
||||
|
||||
return compact(
|
||||
'user', 'projects', 'projectsCount', 'myTasks', 'myTasksFromTasks', 'myTasksCount',
|
||||
'myIssues', 'myIssuesCount', 'notifications', 'unreadCount'
|
||||
);
|
||||
});
|
||||
|
||||
return view('dashboard', $data);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Models\Project;
|
||||
use App\Services\ReportGenerator;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
|
||||
class ReportController extends Controller
|
||||
@@ -32,8 +33,12 @@ class ReportController extends Controller
|
||||
$this->authorizeProjectAccess($project);
|
||||
|
||||
$filters = ReportFilters::fromRequest($request->all());
|
||||
$generator = new ReportGenerator($project, $filters);
|
||||
$data = $generator->generate();
|
||||
$cacheKey = "report:{$project->id}:".md5(json_encode($filters->toArray()));
|
||||
|
||||
$data = Cache::remember($cacheKey, 300, function () use ($project, $filters) {
|
||||
$generator = new ReportGenerator($project, $filters);
|
||||
return $generator->generate();
|
||||
});
|
||||
|
||||
$format = $filters->format;
|
||||
|
||||
@@ -53,8 +58,12 @@ class ReportController extends Controller
|
||||
$this->authorizeProjectAccess($project);
|
||||
|
||||
$filters = ReportFilters::fromRequest($request->all());
|
||||
$generator = new ReportGenerator($project, $filters);
|
||||
$data = $generator->generate();
|
||||
$cacheKey = "report:{$project->id}:".md5(json_encode($filters->toArray()));
|
||||
|
||||
$data = Cache::remember($cacheKey, 300, function () use ($project, $filters) {
|
||||
$generator = new ReportGenerator($project, $filters);
|
||||
return $generator->generate();
|
||||
});
|
||||
|
||||
return view('reports.partials._preview', $data);
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ class ProjectMap extends Component
|
||||
$this->authorizeProjectAccess();
|
||||
|
||||
$this->phases = $project->phases()->with([
|
||||
'layers' => fn ($q) => $q->withCount('features'),
|
||||
'layers' => fn ($q) => $q->withCount('features')->with(['features.template', 'features.images']),
|
||||
'layers.features',
|
||||
'layers.features.images',
|
||||
])->get();
|
||||
@@ -124,7 +124,7 @@ class ProjectMap extends Component
|
||||
|
||||
$this->allFeatures = Feature::whereHas('layer.phase', function ($q) use ($project) {
|
||||
$q->where('project_id', $project->id);
|
||||
})->with(['layer.phase', 'template'])->get();
|
||||
})->with(['layer.phase', 'template', 'images'])->get();
|
||||
|
||||
$this->allInspections = Inspection::where('project_id', $project->id)
|
||||
->with(['feature.layer.phase', 'template', 'user'])
|
||||
|
||||
@@ -158,7 +158,7 @@ class TaskManager extends Component
|
||||
$query->orderBy('order')->orderBy('created_at', 'desc');
|
||||
}
|
||||
|
||||
$tasks = $query->paginate($this->perPage);
|
||||
$tasks = $query->with(['project', 'phase', 'assignee', 'creator', 'subtasks.parentTask'])->paginate($this->perPage);
|
||||
|
||||
// Filter options
|
||||
$assignees = User::whereHas('assignedTasks', function ($q) use ($user) {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Models\Feature;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class FeatureObserver
|
||||
{
|
||||
public function saved(Feature $feature): void
|
||||
{
|
||||
$this->clearReportCache($feature->project_id);
|
||||
}
|
||||
|
||||
public function deleted(Feature $feature): void
|
||||
{
|
||||
$this->clearReportCache($feature->project_id);
|
||||
}
|
||||
|
||||
private function clearReportCache(?int $projectId): void
|
||||
{
|
||||
if ($projectId) {
|
||||
Cache::tags(["report:project:{$projectId}"])->flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Models\Inspection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class InspectionObserver
|
||||
{
|
||||
public function saved(Inspection $inspection): void
|
||||
{
|
||||
$this->clearReportCache($inspection->project_id);
|
||||
}
|
||||
|
||||
public function deleted(Inspection $inspection): void
|
||||
{
|
||||
$this->clearReportCache($inspection->project_id);
|
||||
}
|
||||
|
||||
private function clearReportCache(?int $projectId): void
|
||||
{
|
||||
if ($projectId) {
|
||||
Cache::tags(["report:project:{$projectId}"])->flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Models\Issue;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class IssueObserver
|
||||
{
|
||||
public function saved(Issue $issue): void
|
||||
{
|
||||
$this->clearReportCache($issue->project_id);
|
||||
}
|
||||
|
||||
public function deleted(Issue $issue): void
|
||||
{
|
||||
$this->clearReportCache($issue->project_id);
|
||||
}
|
||||
|
||||
private function clearReportCache(?int $projectId): void
|
||||
{
|
||||
if ($projectId) {
|
||||
Cache::tags(["report:project:{$projectId}"])->flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,14 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\Feature;
|
||||
use App\Models\Inspection;
|
||||
use App\Models\Issue;
|
||||
use App\Models\Project;
|
||||
use App\Models\Task;
|
||||
use App\Observers\FeatureObserver;
|
||||
use App\Observers\InspectionObserver;
|
||||
use App\Observers\IssueObserver;
|
||||
use App\Policies\TaskPolicy;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
@@ -25,6 +31,11 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
// Register model observers for cache invalidation
|
||||
Feature::observe(FeatureObserver::class);
|
||||
Inspection::observe(InspectionObserver::class);
|
||||
Issue::observe(IssueObserver::class);
|
||||
|
||||
// Re-register model bindings so they persist in route cache
|
||||
Route::model('project', Project::class);
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"maatwebsite/excel": "*",
|
||||
"mansoor/blade-lets-icons": "^1.0",
|
||||
"phayes/geophp": "^1.2",
|
||||
"predis/predis": "^1.1",
|
||||
"rappasoft/laravel-livewire-tables": "^3.7",
|
||||
"spatie/laravel-permission": "^6.25"
|
||||
},
|
||||
|
||||
Generated
+67
-1
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "45553317b713050f78b4233c204790f9",
|
||||
"content-hash": "59f27ca027f68b2cd10cb3d30bae901b",
|
||||
"packages": [
|
||||
{
|
||||
"name": "blade-ui-kit/blade-heroicons",
|
||||
@@ -3870,6 +3870,72 @@
|
||||
],
|
||||
"time": "2025-12-27T19:41:33+00:00"
|
||||
},
|
||||
{
|
||||
"name": "predis/predis",
|
||||
"version": "v1.1.10",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/predis/predis.git",
|
||||
"reference": "a2fb02d738bedadcffdbb07efa3a5e7bd57f8d6e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/predis/predis/zipball/a2fb02d738bedadcffdbb07efa3a5e7bd57f8d6e",
|
||||
"reference": "a2fb02d738bedadcffdbb07efa3a5e7bd57f8d6e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.3.9"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "~4.8"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-curl": "Allows access to Webdis when paired with phpiredis",
|
||||
"ext-phpiredis": "Allows faster serialization and deserialization of the Redis protocol"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Predis\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Daniele Alessandri",
|
||||
"email": "suppakilla@gmail.com",
|
||||
"homepage": "http://clorophilla.net",
|
||||
"role": "Creator & Maintainer"
|
||||
},
|
||||
{
|
||||
"name": "Till Krüss",
|
||||
"homepage": "https://till.im",
|
||||
"role": "Maintainer"
|
||||
}
|
||||
],
|
||||
"description": "Flexible and feature-complete Redis client for PHP and HHVM",
|
||||
"homepage": "http://github.com/predis/predis",
|
||||
"keywords": [
|
||||
"nosql",
|
||||
"predis",
|
||||
"redis"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/predis/predis/issues",
|
||||
"source": "https://github.com/predis/predis/tree/v1.1.10"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/sponsors/tillkruss",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2022-01-05T17:46:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/cache",
|
||||
"version": "3.0.0",
|
||||
|
||||
Binary file not shown.
+1
-1
@@ -15,7 +15,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('CACHE_STORE', 'database'),
|
||||
'default' => env('CACHE_STORE', 'redis'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('QUEUE_CONNECTION', 'database'),
|
||||
'default' => env('QUEUE_CONNECTION', 'redis'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
+3
-37
@@ -52,47 +52,13 @@ Route::get('/', function () {
|
||||
return redirect()->route('dashboard');
|
||||
})->middleware(['auth']);
|
||||
|
||||
use App\Http\Controllers\DashboardController;
|
||||
|
||||
// Grupo de rutas protegidas por autenticación
|
||||
Route::middleware(['auth'])->group(function () {
|
||||
|
||||
// Home: vista ligera (proyectos, tareas e incidencias del usuario + notificaciones)
|
||||
Route::get('/dashboard', function () {
|
||||
$user = Auth::user();
|
||||
|
||||
$projects = Project::accessibleBy($user)
|
||||
->with(['phases' => fn ($q) => $q->select('id', 'project_id', 'progress_percent')])
|
||||
->orderBy('name')->take(8)->get();
|
||||
$projectsCount = Project::accessibleBy($user)->count();
|
||||
|
||||
$myTasks = IssueTask::where('assigned_to', $user->id)
|
||||
->where('is_done', false)
|
||||
->with('issue.project')
|
||||
->orderByRaw('due_date IS NULL, due_date ASC')
|
||||
->take(8)->get();
|
||||
|
||||
$myTasksFromTasks = Task::where('assigned_to', $user->id)
|
||||
->where('status', '!=', 'completed')
|
||||
->with('project')
|
||||
->orderByRaw('due_date IS NULL, due_date ASC')
|
||||
->take(8)->get();
|
||||
|
||||
$myTasksCount = IssueTask::where('assigned_to', $user->id)->where('is_done', false)->count()
|
||||
+ Task::where('assigned_to', $user->id)->where('status', '!=', 'completed')->count();
|
||||
|
||||
$myIssues = Issue::where('assigned_to', $user->id)
|
||||
->whereIn('status', ['open', 'in_review'])
|
||||
->with('project')
|
||||
->latest()->take(6)->get();
|
||||
$myIssuesCount = Issue::where('assigned_to', $user->id)->whereIn('status', ['open', 'in_review'])->count();
|
||||
|
||||
$notifications = $user->notifications()->latest()->take(6)->get();
|
||||
$unreadCount = $user->unreadNotifications()->count();
|
||||
|
||||
return view('dashboard', compact(
|
||||
'user', 'projects', 'projectsCount', 'myTasks', 'myTasksFromTasks', 'myTasksCount',
|
||||
'myIssues', 'myIssuesCount', 'notifications', 'unreadCount'
|
||||
));
|
||||
})->name('dashboard');
|
||||
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
|
||||
|
||||
Route::get('/reports/dashboard', ReportsDashboard::class)->name('reports.dashboard');
|
||||
Route::prefix('reports')->name('reports.')->group(function () {
|
||||
|
||||
Reference in New Issue
Block a user