feat: implement Story 2.1 — Owner/Manager Command Center Dashboard
- Rewrite DashboardController with cached role-scoped KPI aggregation (Cache::remember, 5-min TTL, Declaration::forUser scope) - Create StatCard.vue component with CVA status variants and a11y - Rewrite Dashboard.vue with 4-column KPI grid + urgent declarations table - Add mise_en_demeure status to DeclarationStatus enum with transitions - Exclude termine, mise_en_demeure, ferme from dashboard queries - Set deadline proximity red threshold to ≤5 days - Add abort(404) for non-member workspace access per architecture - Fix null-safe client access for soft-deleted clients - Fix hardcoded routes with Wayfinder type-safe imports - Fix DashboardProps.stats type to allow null - Add aria-pressed to StatCard for accessibility - Install shadcn-vue table component (11 files) - Add 11 Pest feature tests + 3 mise_en_demeure transition tests - Fix DeclarationFactory eager workspace creation causing slug collisions - 196 tests pass, 836 assertions, zero regressions
This commit is contained in:
@@ -0,0 +1,261 @@
|
|||||||
|
# Story 2.1: Owner/Manager Command Center Dashboard
|
||||||
|
|
||||||
|
Status: review
|
||||||
|
|
||||||
|
<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->
|
||||||
|
|
||||||
|
## Story
|
||||||
|
|
||||||
|
As a firm owner or manager,
|
||||||
|
I want to see my entire firm's operational status on one screen when I open the app,
|
||||||
|
So that I can instantly identify what needs my attention without drilling into individual clients or declarations.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
1. **Given** an Owner or Manager is logged in, **When** they navigate to `/dashboard`, **Then** the page displays a row of KPI summary cards (StatCard components) in a 4-column CSS Grid layout:
|
||||||
|
- "En retard" (overdue) -- red, count of declarations past deadline
|
||||||
|
- "Cette semaine" (due this week) -- amber, count of declarations due within 7 days
|
||||||
|
- "En attente client" (waiting for client) -- blue, count of declarations with `en_attente_client` status
|
||||||
|
- "En cours" (on track) -- green, count of declarations with `en_cours` status
|
||||||
|
|
||||||
|
2. **Given** the KPI cards are displayed, **When** a user clicks a StatCard, **Then** the browser navigates to the Declarations list page with the corresponding filter pre-applied via URL query params (e.g., `/declarations?status=en_attente_client`)
|
||||||
|
|
||||||
|
3. **Given** the dashboard is loaded, **Then** a declarations summary table appears below the KPI cards showing the most urgent declarations (sorted by deadline ascending, limited to 15 rows) with columns: Client, Type, Deadline (with proximity color), Assignee, Status badge
|
||||||
|
|
||||||
|
4. **Given** the summary table is displayed, **When** a user clicks a table row, **Then** the browser navigates to the declaration detail page
|
||||||
|
|
||||||
|
5. **Given** the summary table is displayed, **Then** inline row actions are available via a dropdown menu (View, Nudge placeholder, Reassign placeholder)
|
||||||
|
|
||||||
|
6. **Given** the dashboard data is requested, **Then** dashboard data is served from Redis cache (`Cache::remember()` with 5-minute TTL, key: `dashboard:{workspace_id}:{user_id}`)
|
||||||
|
|
||||||
|
7. **Given** the `DashboardController` is invoked, **Then** it aggregates declaration counts by status, overdue counts, and due-this-week counts using role-scoped queries via `Declaration::forUser()` scope
|
||||||
|
|
||||||
|
8. **Given** the dashboard page is loaded, **Then** the page renders within 3 seconds for workspaces with up to 200 active clients (NFR5)
|
||||||
|
|
||||||
|
9. **Given** the dashboard page is loaded, **Then** the page uses the existing `AppLayout` with role-driven sidebar navigation (already implemented in Epic 1)
|
||||||
|
|
||||||
|
## Tasks / Subtasks
|
||||||
|
|
||||||
|
- [x] Task 1: Rewrite `DashboardController` with cached, role-scoped aggregation (AC: #1, #6, #7)
|
||||||
|
- [x] 1.1 Build cached dashboard data method using `Cache::remember()` with key `dashboard:{workspace_id}:{user_id}` and 5-min TTL
|
||||||
|
- [x] 1.2 Aggregate KPI counts: overdue (`due_date < now()`), due this week (`due_date` between now and +7 days), `en_attente_client` status count, `en_cours` status count -- all using `Declaration::forUser()` scope
|
||||||
|
- [x] 1.3 Query top 15 urgent declarations sorted by `due_date ASC` with eager-loaded `client:id,company_name` and `assignee:id,name`
|
||||||
|
- [x] 1.4 Pass all URLs as Inertia props via `route()` helper (declarationsUrl with filter params for each KPI card)
|
||||||
|
- [x] 1.5 Return role label from `WorkspaceUser` for conditional rendering
|
||||||
|
- [x] Task 2: Create `StatCard.vue` component (AC: #1, #2)
|
||||||
|
- [x] 2.1 Build component with props: `label`, `count`, `status` (danger|warning|info|success), `href`
|
||||||
|
- [x] 2.2 Compose from shadcn-vue `Card` + CVA variants for status colors
|
||||||
|
- [x] 2.3 Make clickable with `router.get(href)` navigation
|
||||||
|
- [x] 2.4 Add `role="button"`, `aria-pressed` for active state, keyboard focusable
|
||||||
|
- [x] 2.5 Support loading skeleton state
|
||||||
|
- [x] Task 3: Rewrite `Dashboard.vue` page with command center layout (AC: #1, #3, #4, #5, #9)
|
||||||
|
- [x] 3.1 Replace current template with 4-column KPI grid using StatCard components
|
||||||
|
- [x] 3.2 Build declarations summary table below KPI cards with columns: Client, Type, Deadline (proximity color), Assignee, Status badge
|
||||||
|
- [x] 3.3 Add clickable rows navigating to declaration detail via `showUrl` prop
|
||||||
|
- [x] 3.4 Add inline row actions dropdown (View, Nudge placeholder, Reassign placeholder) using shadcn-vue `DropdownMenu`
|
||||||
|
- [x] 3.5 Add deadline proximity color: green (>7d), amber (3-7d), red (<3d), pulsing red (overdue)
|
||||||
|
- [x] 3.6 Preserve no-workspace admin view for users without workspace
|
||||||
|
- [x] Task 4: Add TypeScript types for dashboard data (AC: #1, #3)
|
||||||
|
- [x] 4.1 Create `resources/js/types/dashboard.ts` with `DashboardStats`, `DashboardDeclaration`, `DashboardProps` types
|
||||||
|
- [x] 4.2 Export from `resources/js/types/index.ts` barrel
|
||||||
|
- [x] Task 5: Write Pest feature tests for dashboard (AC: #1, #6, #7, #8)
|
||||||
|
- [x] 5.1 Test Owner sees all workspace declarations in KPI counts
|
||||||
|
- [x] 5.2 Test Manager sees all workspace declarations in KPI counts
|
||||||
|
- [x] 5.3 Test Worker sees only assigned declarations (Story 2.3 prep -- verify scoping works)
|
||||||
|
- [x] 5.4 Test Redis cache is used (verifies cache key and data structure)
|
||||||
|
- [x] 5.5 Test unauthenticated user redirected to login
|
||||||
|
- [x] 5.6 Test no-workspace user sees admin view
|
||||||
|
|
||||||
|
## Dev Notes
|
||||||
|
|
||||||
|
### Architecture Patterns & Constraints
|
||||||
|
|
||||||
|
- **Workspace resolution:** Always from session `current_workspace_id`, NEVER from URL params
|
||||||
|
- **Authorization:** Use `abort(404)` not `abort(403)` for workspace boundary violations
|
||||||
|
- **Role-scoped queries:** Use existing `Declaration::forUser($user, $workspaceUser)` scope -- Workers see only assigned, Owners/Managers see all
|
||||||
|
- **Data shaping:** Manually build arrays in controller, NO API Resources
|
||||||
|
- **URLs as props:** ALL frontend URLs must come from controller via `route()` helper -- never hardcode routes in Vue
|
||||||
|
- **Cache pattern from architecture (D4):**
|
||||||
|
```php
|
||||||
|
$dashboardData = Cache::remember(
|
||||||
|
"dashboard:{$workspace->id}:{$user->id}",
|
||||||
|
300, // 5 minutes
|
||||||
|
fn () => [
|
||||||
|
'total_active' => Declaration::workspace($workspace)->active()->forUser($user, $workspace)->count(),
|
||||||
|
'by_status' => Declaration::workspace($workspace)->active()->forUser($user, $workspace)
|
||||||
|
->selectRaw('status, count(*) as count')
|
||||||
|
->groupBy('status')
|
||||||
|
->pluck('count', 'status'),
|
||||||
|
'overdue' => Declaration::workspace($workspace)->active()->forUser($user, $workspace)
|
||||||
|
->where('deadline', '<', now())
|
||||||
|
->count(),
|
||||||
|
'due_this_week' => Declaration::workspace($workspace)->active()->forUser($user, $workspace)
|
||||||
|
->whereBetween('deadline', [now(), now()->endOfWeek()])
|
||||||
|
->count(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
**IMPORTANT:** The architecture uses `deadline` but the actual DB column is `due_date`. Use `due_date` in all queries. Also use `$workspace->declarations()` relationship (not `Declaration::workspace()` which doesn't exist) -- see existing DashboardController pattern.
|
||||||
|
|
||||||
|
- **Declaration statuses (DeclarationStatus enum):** `created`, `en_cours`, `en_attente_client`, `termine`, `ferme`
|
||||||
|
- **Exclude from dashboard:** `ferme` status declarations (already closed) and archived (`archived_at IS NOT NULL`)
|
||||||
|
- **Default sort:** `due_date ASC` (most urgent first), with NULLs last
|
||||||
|
|
||||||
|
### Existing Code to Rewrite (NOT Create Fresh)
|
||||||
|
|
||||||
|
**`app/Http/Controllers/DashboardController.php`** -- Current invokable controller:
|
||||||
|
- Currently filters ONLY by `assigned_to = $user->id` (worker-scoped for everyone)
|
||||||
|
- Must be rewritten to use `Declaration::forUser()` scope so Owners/Managers see ALL workspace declarations
|
||||||
|
- Currently runs 5 separate queries without caching -- consolidate into cached aggregation
|
||||||
|
- Currently maps manually -- keep this pattern (no API Resources per project convention)
|
||||||
|
- Keep the no-workspace branch for admin users
|
||||||
|
|
||||||
|
**`resources/js/pages/Dashboard.vue`** -- Current page:
|
||||||
|
- Currently uses inline notification cards and a flat table -- must be replaced with StatCard grid + summary table
|
||||||
|
- Currently defines status labels (`statusLabels`, `statusVariant`) inline -- these use OLD status values (`draft`, `waiting_documents`, etc.) that NO LONGER EXIST. The actual DeclarationStatus enum values are: `created`, `en_cours`, `en_attente_client`, `termine`, `ferme`
|
||||||
|
- Must update to use DeclarationStatus labels from the enum
|
||||||
|
- Preserve the `!hasWorkspace` admin view (quick links for Users, Workspaces, Clients)
|
||||||
|
- Replace `PlaceholderPattern` usage with actual dashboard content
|
||||||
|
|
||||||
|
### New Files to Create
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `resources/js/components/dashboard/StatCard.vue` | Clickable KPI card component with CVA status variants |
|
||||||
|
| `resources/js/types/dashboard.ts` | TypeScript types for dashboard props |
|
||||||
|
| `tests/Feature/Dashboard/OwnerDashboardTest.php` | Pest feature tests for Owner/Manager dashboard |
|
||||||
|
|
||||||
|
### Files to Modify
|
||||||
|
|
||||||
|
| File | Changes |
|
||||||
|
|------|---------|
|
||||||
|
| `app/Http/Controllers/DashboardController.php` | Full rewrite: cached aggregation, role-scoped queries, KPI counts, summary table data |
|
||||||
|
| `resources/js/pages/Dashboard.vue` | Full rewrite: StatCard grid, summary table, row actions, proximity colors |
|
||||||
|
| `resources/js/types/index.ts` | Add barrel export for `dashboard.ts` types |
|
||||||
|
|
||||||
|
### Component Specifications (from UX Design)
|
||||||
|
|
||||||
|
**StatCard:**
|
||||||
|
- Compose from shadcn-vue `Card` + CVA variants for status colors
|
||||||
|
- Props: `label: string`, `count: number`, `status: 'danger' | 'warning' | 'info' | 'success'`, `href: string`
|
||||||
|
- States: default, active (selected as filter), loading (skeleton)
|
||||||
|
- Accessibility: `role="button"`, `aria-pressed` for active, keyboard focusable
|
||||||
|
- Status-to-color mapping: danger=red (overdue), warning=amber (due this week), info=blue (waiting client), success=green (on track)
|
||||||
|
|
||||||
|
**Declarations summary table:**
|
||||||
|
- Use shadcn-vue `Table` components (Table, TableHeader, TableRow, TableHead, TableBody, TableCell) -- install `table` component if not present via `npx shadcn-vue@latest add table`
|
||||||
|
- Sortable by deadline (server-side sort, default `due_date ASC`)
|
||||||
|
- Clickable rows navigating to declaration detail
|
||||||
|
- Inline row actions via shadcn-vue `DropdownMenu` (already installed)
|
||||||
|
- Deadline proximity color classes:
|
||||||
|
- Green (`text-green-600`): > 7 days remaining
|
||||||
|
- Amber (`text-amber-600`): 3-7 days remaining
|
||||||
|
- Red (`text-red-600`): < 3 days remaining
|
||||||
|
- Pulsing red (`text-red-600 animate-pulse`): overdue (past deadline)
|
||||||
|
|
||||||
|
**Layout:**
|
||||||
|
- Dashboard grid: `grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4` for KPI cards
|
||||||
|
- Full-width table below cards
|
||||||
|
- Desktop: 4-column KPI cards + full table
|
||||||
|
- Tablet: 2-column KPI cards + table with core columns
|
||||||
|
- Mobile: stacked KPI cards + card-based list view
|
||||||
|
|
||||||
|
### Deadline Proximity Logic (Frontend)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function deadlineProximity(dueDate: string | null): 'safe' | 'approaching' | 'urgent' | 'overdue' | 'none' {
|
||||||
|
if (!dueDate) return 'none';
|
||||||
|
const now = new Date();
|
||||||
|
const deadline = new Date(dueDate);
|
||||||
|
const diffDays = Math.ceil((deadline.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
if (diffDays < 0) return 'overdue';
|
||||||
|
if (diffDays <= 3) return 'urgent';
|
||||||
|
if (diffDays <= 7) return 'approaching';
|
||||||
|
return 'safe';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project Structure Notes
|
||||||
|
|
||||||
|
- Alignment with architecture: new files go in `resources/js/components/dashboard/` directory (per architecture project structure)
|
||||||
|
- Tests go in `tests/Feature/Dashboard/` directory (per architecture)
|
||||||
|
- Types go in `resources/js/types/dashboard.ts` (per architecture)
|
||||||
|
- Controller stays at `app/Http/Controllers/DashboardController.php` (rewrite, not move)
|
||||||
|
- Route stays at `GET /dashboard` (no route changes needed)
|
||||||
|
|
||||||
|
### Testing Standards
|
||||||
|
|
||||||
|
- Use Pest syntax (`test()` closures), never PHPUnit class-based
|
||||||
|
- Test descriptions: lowercase, descriptive strings
|
||||||
|
- Use `route()` helper for URLs, never hardcoded paths
|
||||||
|
- Factory states: `User::factory()->create()` + attach to workspace with role via `WorkspaceUser`
|
||||||
|
- Use `RefreshDatabase` (auto-applied via Pest.php)
|
||||||
|
- Assertions: prefer Pest's `expect()` chaining
|
||||||
|
- Group tests in `tests/Feature/Dashboard/` subdirectory
|
||||||
|
- Run tests: `composer test`
|
||||||
|
|
||||||
|
### Previous Epic Intelligence (Epic 1 Learnings)
|
||||||
|
|
||||||
|
- **withPivot gotcha:** `WorkspaceUser` pivot needs explicit `withPivot('role', 'permissions')` on the workspace-user relationship. This was a recurring issue in 3/6 Epic 1 stories. Verify the relationship includes `withPivot` when querying role.
|
||||||
|
- **Wayfinder routes:** All URLs in Vue MUST use Wayfinder type-safe routes (e.g., `import { dashboard } from '@/routes'`). Hardcoded routes in Vue are rejection criteria. The existing Dashboard.vue already uses `dashboard().url` for breadcrumbs.
|
||||||
|
- **Flash messages:** Already implemented in AppSidebarLayout -- success/error toasts auto-dismiss after 4s.
|
||||||
|
- **Role-based sidebar:** Already working -- sidebar shows different nav items based on `auth.workspaceRole` shared prop. No sidebar changes needed for this story.
|
||||||
|
- **Shared Inertia props available:** `auth.user`, `auth.workspaces`, `auth.currentWorkspace`, `auth.workspaceRole` -- use `usePage()` to access workspace role on the frontend.
|
||||||
|
- **Test count at Epic 1 end:** 182 tests, 677 assertions. All passing. Do not break existing tests.
|
||||||
|
|
||||||
|
### References
|
||||||
|
|
||||||
|
- [Source: _bmad-output/planning-artifacts/epics.md#Epic 2 Story 2.1]
|
||||||
|
- [Source: _bmad-output/planning-artifacts/architecture.md#Dashboard Aggregation Patterns]
|
||||||
|
- [Source: _bmad-output/planning-artifacts/architecture.md#D4 Caching Strategy]
|
||||||
|
- [Source: _bmad-output/planning-artifacts/architecture.md#Role-Scoped Query Patterns]
|
||||||
|
- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Journey 1 Owner/Manager Morning Dashboard]
|
||||||
|
- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#StatCard Component]
|
||||||
|
- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Responsive Strategy]
|
||||||
|
- [Source: _bmad-output/planning-artifacts/prd.md#FR24 FR25 FR26]
|
||||||
|
- [Source: _bmad-output/implementation-artifacts/1-6-workspace-switching-for-multi-workspace-owners.md]
|
||||||
|
- [Source: _bmad-output/implementation-artifacts/epic-1-retro-2026-03-20.md]
|
||||||
|
- [Source: _bmad-output/project-context.md]
|
||||||
|
- [Source: app/Http/Controllers/DashboardController.php]
|
||||||
|
- [Source: resources/js/pages/Dashboard.vue]
|
||||||
|
- [Source: app/Models/Declaration.php#scopeForUser]
|
||||||
|
- [Source: app/Enums/DeclarationStatus.php]
|
||||||
|
|
||||||
|
## Dev Agent Record
|
||||||
|
|
||||||
|
### Agent Model Used
|
||||||
|
|
||||||
|
Claude Opus 4.6 (1M context)
|
||||||
|
|
||||||
|
### Debug Log References
|
||||||
|
|
||||||
|
- Fixed test: `enCours` count includes overdue declarations with `en_cours` status (counts are not mutually exclusive)
|
||||||
|
- Fixed test: replaced `Cache::shouldReceive` mock with real cache verification to avoid conflict with middleware Cache calls
|
||||||
|
|
||||||
|
### Completion Notes List
|
||||||
|
|
||||||
|
- Rewrote `DashboardController` from 5 separate uncached worker-scoped queries to a single cached role-scoped aggregation using `Cache::remember()` with 5-min TTL and `Declaration::forUser()` scope
|
||||||
|
- Created `StatCard.vue` component with CVA status color variants (danger/warning/info/success), skeleton loading support, keyboard accessibility (`role="button"`, `tabindex`, Enter/Space handlers)
|
||||||
|
- Rewrote `Dashboard.vue` with 4-column KPI grid + urgent declarations table using shadcn-vue `Table` and `DropdownMenu` components, deadline proximity colors, and preserved admin no-workspace view
|
||||||
|
- Created TypeScript types (`DashboardStats`, `DashboardDeclaration`, `DashboardProps`, `StatCardLink`) in `resources/js/types/dashboard.ts`
|
||||||
|
- Installed shadcn-vue `table` component (11 files)
|
||||||
|
- Added 11 new Pest feature tests covering: owner/manager/worker role scoping, cache verification, archived exclusion, sort order, 15-row limit, stat card structure, and assignee data
|
||||||
|
- All 193 tests pass (833 assertions), zero regressions
|
||||||
|
- PHP Pint and ESLint pass on all new/modified files
|
||||||
|
|
||||||
|
### Change Log
|
||||||
|
|
||||||
|
- 2026-03-20: Implemented Story 2.1 — Owner/Manager Command Center Dashboard (all 5 tasks complete)
|
||||||
|
|
||||||
|
### File List
|
||||||
|
|
||||||
|
**New files:**
|
||||||
|
- `resources/js/components/dashboard/StatCard.vue`
|
||||||
|
- `resources/js/types/dashboard.ts`
|
||||||
|
- `tests/Feature/Dashboard/OwnerDashboardTest.php`
|
||||||
|
- `resources/js/components/ui/table/` (11 files — shadcn-vue table component)
|
||||||
|
|
||||||
|
**Modified files:**
|
||||||
|
- `app/Http/Controllers/DashboardController.php` (full rewrite)
|
||||||
|
- `resources/js/pages/Dashboard.vue` (full rewrite)
|
||||||
|
- `resources/js/types/index.ts` (added dashboard barrel export)
|
||||||
@@ -50,18 +50,18 @@ development_status:
|
|||||||
epic-0-retrospective: done
|
epic-0-retrospective: done
|
||||||
|
|
||||||
# Epic 1: Team Management & Permission System
|
# Epic 1: Team Management & Permission System
|
||||||
epic-1: in-progress
|
epic-1: done
|
||||||
1-1-permission-configuration-and-controller-traits: done
|
1-1-permission-configuration-and-controller-traits: done
|
||||||
1-2-team-management-page-view-and-invite-members: done
|
1-2-team-management-page-view-and-invite-members: done
|
||||||
1-3-role-assignment-and-member-removal: done
|
1-3-role-assignment-and-member-removal: done
|
||||||
1-4-manager-permission-toggle-matrix: done
|
1-4-manager-permission-toggle-matrix: done
|
||||||
1-5-role-based-access-enforcement-across-views: done
|
1-5-role-based-access-enforcement-across-views: done
|
||||||
1-6-workspace-switching-for-multi-workspace-owners: done
|
1-6-workspace-switching-for-multi-workspace-owners: done
|
||||||
epic-1-retrospective: optional
|
epic-1-retrospective: done
|
||||||
|
|
||||||
# Epic 2: Role-Driven Dashboard & Command Center
|
# Epic 2: Role-Driven Dashboard & Command Center
|
||||||
epic-2: backlog
|
epic-2: in-progress
|
||||||
2-1-owner-manager-command-center-dashboard: backlog
|
2-1-owner-manager-command-center-dashboard: review
|
||||||
2-2-priority-alerts-panel: backlog
|
2-2-priority-alerts-panel: backlog
|
||||||
2-3-worker-scoped-dashboard: backlog
|
2-3-worker-scoped-dashboard: backlog
|
||||||
2-4-dashboard-activity-feed: backlog
|
2-4-dashboard-activity-feed: backlog
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ final class DeclarationStatus extends Enum
|
|||||||
|
|
||||||
const Termine = 'termine';
|
const Termine = 'termine';
|
||||||
|
|
||||||
|
const MiseEnDemeure = 'mise_en_demeure';
|
||||||
|
|
||||||
const Ferme = 'ferme';
|
const Ferme = 'ferme';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,6 +30,7 @@ final class DeclarationStatus extends Enum
|
|||||||
self::EnCours => 'En cours',
|
self::EnCours => 'En cours',
|
||||||
self::EnAttenteClient => 'En attente client',
|
self::EnAttenteClient => 'En attente client',
|
||||||
self::Termine => 'Terminé',
|
self::Termine => 'Terminé',
|
||||||
|
self::MiseEnDemeure => 'Mise en demeure',
|
||||||
self::Ferme => 'Fermé',
|
self::Ferme => 'Fermé',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -42,8 +45,9 @@ final class DeclarationStatus extends Enum
|
|||||||
return [
|
return [
|
||||||
self::Created => [self::EnCours],
|
self::Created => [self::EnCours],
|
||||||
self::EnCours => [self::EnAttenteClient, self::Termine],
|
self::EnCours => [self::EnAttenteClient, self::Termine],
|
||||||
self::EnAttenteClient => [self::EnCours],
|
self::EnAttenteClient => [self::EnCours, self::MiseEnDemeure],
|
||||||
self::Termine => [self::Ferme],
|
self::Termine => [self::Ferme],
|
||||||
|
self::MiseEnDemeure => [self::EnCours, self::Ferme],
|
||||||
self::Ferme => [],
|
self::Ferme => [],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,19 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Enums\DeclarationStatus;
|
use App\Enums\DeclarationStatus;
|
||||||
|
use App\Enums\WorkspaceUserRole;
|
||||||
use App\Models\Declaration;
|
use App\Models\Declaration;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceUser;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
class DashboardController extends Controller
|
class DashboardController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Display the dashboard with assigned declarations and notifications.
|
* Display the command center dashboard with KPI cards and urgent declarations.
|
||||||
*/
|
*/
|
||||||
public function __invoke(Request $request): Response
|
public function __invoke(Request $request): Response
|
||||||
{
|
{
|
||||||
@@ -20,107 +23,152 @@ class DashboardController extends Controller
|
|||||||
$workspaceId = $request->session()->get('current_workspace_id');
|
$workspaceId = $request->session()->get('current_workspace_id');
|
||||||
$workspace = $workspaceId ? Workspace::query()->find($workspaceId) : null;
|
$workspace = $workspaceId ? Workspace::query()->find($workspaceId) : null;
|
||||||
|
|
||||||
$assignedDeclarations = [];
|
if (! $workspace || ! $user) {
|
||||||
$notifications = [];
|
return Inertia::render('Dashboard', [
|
||||||
|
'stats' => null,
|
||||||
if ($workspace && $user) {
|
'statCards' => [],
|
||||||
$assignedDeclarations = $workspace->declarations()
|
'declarations' => [],
|
||||||
->where('assigned_to', $user->id)
|
'workspaceName' => null,
|
||||||
->whereNotIn('status', [DeclarationStatus::Ferme])
|
'roleLabel' => null,
|
||||||
->with('client:id,company_name')
|
'declarationsUrl' => null,
|
||||||
->orderByRaw('CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, due_date ASC')
|
'clientsUrl' => null,
|
||||||
->limit(50)
|
]);
|
||||||
->get()
|
|
||||||
->map(fn (Declaration $f) => [
|
|
||||||
'id' => $f->id,
|
|
||||||
'title' => $f->title,
|
|
||||||
'type' => $f->type->value,
|
|
||||||
'client_name' => $f->client->company_name,
|
|
||||||
'status' => $f->status->value,
|
|
||||||
'due_date' => $f->due_date?->format('Y-m-d'),
|
|
||||||
'priority' => $f->priority?->value,
|
|
||||||
'showUrl' => route('declarations.show', $f),
|
|
||||||
])
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$overdue = $workspace->declarations()
|
|
||||||
->where('assigned_to', $user->id)
|
|
||||||
->where('due_date', '<', now()->startOfDay())
|
|
||||||
->whereNotIn('status', [DeclarationStatus::Ferme])
|
|
||||||
->with('client:id,company_name')
|
|
||||||
->orderBy('due_date')
|
|
||||||
->limit(10)
|
|
||||||
->get()
|
|
||||||
->map(fn (Declaration $f) => [
|
|
||||||
'id' => $f->id,
|
|
||||||
'title' => $f->title,
|
|
||||||
'client_name' => $f->client->company_name,
|
|
||||||
'due_date' => $f->due_date?->format('Y-m-d'),
|
|
||||||
'showUrl' => route('declarations.show', $f),
|
|
||||||
])
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$dueSoon = $workspace->declarations()
|
|
||||||
->where('assigned_to', $user->id)
|
|
||||||
->whereBetween('due_date', [now()->startOfDay(), now()->addDays(7)->endOfDay()])
|
|
||||||
->whereNotIn('status', [DeclarationStatus::Ferme])
|
|
||||||
->with('client:id,company_name')
|
|
||||||
->orderBy('due_date')
|
|
||||||
->limit(10)
|
|
||||||
->get()
|
|
||||||
->map(fn (Declaration $f) => [
|
|
||||||
'id' => $f->id,
|
|
||||||
'title' => $f->title,
|
|
||||||
'client_name' => $f->client->company_name,
|
|
||||||
'due_date' => $f->due_date?->format('Y-m-d'),
|
|
||||||
'showUrl' => route('declarations.show', $f),
|
|
||||||
])
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$documentsReceived = $workspace->declarations()
|
|
||||||
->where('assigned_to', $user->id)
|
|
||||||
->where('status', DeclarationStatus::EnCours)
|
|
||||||
->with('client:id,company_name')
|
|
||||||
->orderBy('updated_at', 'desc')
|
|
||||||
->limit(10)
|
|
||||||
->get()
|
|
||||||
->map(fn (Declaration $f) => [
|
|
||||||
'id' => $f->id,
|
|
||||||
'title' => $f->title,
|
|
||||||
'client_name' => $f->client->company_name,
|
|
||||||
'showUrl' => route('declarations.show', $f),
|
|
||||||
])
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$awaitingValidation = $workspace->declarations()
|
|
||||||
->where('assigned_to', $user->id)
|
|
||||||
->where('status', DeclarationStatus::EnAttenteClient)
|
|
||||||
->with('client:id,company_name')
|
|
||||||
->orderBy('confirmation_requested_at', 'desc')
|
|
||||||
->limit(10)
|
|
||||||
->get()
|
|
||||||
->map(fn (Declaration $f) => [
|
|
||||||
'id' => $f->id,
|
|
||||||
'title' => $f->title,
|
|
||||||
'client_name' => $f->client->company_name,
|
|
||||||
'showUrl' => route('declarations.show', $f),
|
|
||||||
])
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$notifications = [
|
|
||||||
'overdue' => $overdue,
|
|
||||||
'due_soon' => $dueSoon,
|
|
||||||
'documents_received' => $documentsReceived,
|
|
||||||
'awaiting_validation' => $awaitingValidation,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @var WorkspaceUser|null $workspaceUser */
|
||||||
|
$workspaceUser = $workspace->users()
|
||||||
|
->where('users.id', $user->id)
|
||||||
|
->first()
|
||||||
|
?->pivot;
|
||||||
|
|
||||||
|
if (! $workspaceUser) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cacheKey = "dashboard:{$workspace->id}:{$user->id}";
|
||||||
|
|
||||||
|
$dashboardData = Cache::remember($cacheKey, 300, function () use ($workspace, $user, $workspaceUser) {
|
||||||
|
$baseQuery = fn () => $workspace->declarations()
|
||||||
|
->active()
|
||||||
|
->whereNotIn('status', [DeclarationStatus::Termine, DeclarationStatus::MiseEnDemeure, DeclarationStatus::Ferme])
|
||||||
|
->forUser($user, $workspaceUser);
|
||||||
|
|
||||||
|
$overdue = $baseQuery()
|
||||||
|
->where('due_date', '<', now()->startOfDay())
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$dueThisWeek = $baseQuery()
|
||||||
|
->whereBetween('due_date', [now()->startOfDay(), now()->addDays(7)->endOfDay()])
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$enAttenteClient = $baseQuery()
|
||||||
|
->where('status', DeclarationStatus::EnAttenteClient)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$enCours = $baseQuery()
|
||||||
|
->where('status', DeclarationStatus::EnCours)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'overdue' => $overdue,
|
||||||
|
'dueThisWeek' => $dueThisWeek,
|
||||||
|
'enAttenteClient' => $enAttenteClient,
|
||||||
|
'enCours' => $enCours,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
$urgentDeclarations = $workspace->declarations()
|
||||||
|
->active()
|
||||||
|
->whereNotIn('status', [DeclarationStatus::Termine, DeclarationStatus::MiseEnDemeure, DeclarationStatus::Ferme])
|
||||||
|
->forUser($user, $workspaceUser)
|
||||||
|
->with('client:id,company_name', 'assignee:id,name')
|
||||||
|
->orderByRaw('CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, due_date ASC')
|
||||||
|
->limit(15)
|
||||||
|
->get()
|
||||||
|
->map(fn (Declaration $d) => [
|
||||||
|
'id' => $d->id,
|
||||||
|
'title' => $d->title,
|
||||||
|
'type' => $d->type->value,
|
||||||
|
'typeLabel' => $this->typeLabels()[$d->type->value] ?? $d->type->value,
|
||||||
|
'clientName' => $d->client?->company_name ?? 'Client supprimé',
|
||||||
|
'assigneeName' => $d->assignee?->name,
|
||||||
|
'status' => $d->status->value,
|
||||||
|
'statusLabel' => DeclarationStatus::labels()[$d->status->value] ?? $d->status->value,
|
||||||
|
'dueDate' => $d->due_date?->format('Y-m-d'),
|
||||||
|
'showUrl' => route('declarations.show', $d),
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$roleLabel = $this->roleLabels()[$workspaceUser->role->value] ?? $workspaceUser->role->value;
|
||||||
|
|
||||||
|
$statCards = [
|
||||||
|
[
|
||||||
|
'label' => 'En retard',
|
||||||
|
'count' => $dashboardData['overdue'],
|
||||||
|
'status' => 'danger',
|
||||||
|
'href' => route('declarations.index', ['overdue' => 1]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Cette semaine',
|
||||||
|
'count' => $dashboardData['dueThisWeek'],
|
||||||
|
'status' => 'warning',
|
||||||
|
'href' => route('declarations.index', ['due_this_week' => 1]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'En attente client',
|
||||||
|
'count' => $dashboardData['enAttenteClient'],
|
||||||
|
'status' => 'info',
|
||||||
|
'href' => route('declarations.index', ['status' => DeclarationStatus::EnAttenteClient]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'En cours',
|
||||||
|
'count' => $dashboardData['enCours'],
|
||||||
|
'status' => 'success',
|
||||||
|
'href' => route('declarations.index', ['status' => DeclarationStatus::EnCours]),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
return Inertia::render('Dashboard', [
|
return Inertia::render('Dashboard', [
|
||||||
'assignedDeclarations' => $assignedDeclarations,
|
'stats' => $dashboardData,
|
||||||
'notifications' => $notifications,
|
'statCards' => $statCards,
|
||||||
'workspaceName' => $workspace?->name ?? null,
|
'declarations' => $urgentDeclarations,
|
||||||
'declarationsUrl' => $workspace ? route('declarations.index') : null,
|
'workspaceName' => $workspace->name,
|
||||||
'clientsUrl' => $workspace ? route('clients.index') : null,
|
'roleLabel' => $roleLabel,
|
||||||
|
'declarationsUrl' => route('declarations.index'),
|
||||||
|
'clientsUrl' => route('clients.index'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get declaration type labels.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function typeLabels(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'vat' => 'TVA',
|
||||||
|
'vat_monthly' => 'TVA mensuelle',
|
||||||
|
'vat_quarterly' => 'TVA trimestrielle',
|
||||||
|
'corporate_tax' => 'IS',
|
||||||
|
'income_tax' => 'IR',
|
||||||
|
'cnss' => 'CNSS',
|
||||||
|
'annual_balance' => 'Bilan',
|
||||||
|
'other' => 'Autre',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get workspace user role labels.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function roleLabels(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
WorkspaceUserRole::Owner => 'Propriétaire',
|
||||||
|
WorkspaceUserRole::Manager => 'Manager',
|
||||||
|
WorkspaceUserRole::Worker => 'Collaborateur',
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,9 +21,6 @@ class DeclarationFactory extends Factory
|
|||||||
*/
|
*/
|
||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
$workspace = Workspace::factory()->create();
|
|
||||||
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
|
|
||||||
|
|
||||||
$year = fake()->numberBetween(2024, 2026);
|
$year = fake()->numberBetween(2024, 2026);
|
||||||
$excludeOldVat = array_filter(DeclarationType::getValues(), fn ($v) => $v !== 'vat');
|
$excludeOldVat = array_filter(DeclarationType::getValues(), fn ($v) => $v !== 'vat');
|
||||||
$type = fake()->randomElement(array_values($excludeOldVat));
|
$type = fake()->randomElement(array_values($excludeOldVat));
|
||||||
@@ -32,8 +29,10 @@ class DeclarationFactory extends Factory
|
|||||||
$isVatQuarterly = $type === 'vat_quarterly';
|
$isVatQuarterly = $type === 'vat_quarterly';
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'workspace_id' => $workspace->id,
|
'workspace_id' => Workspace::factory(),
|
||||||
'client_id' => $client->id,
|
'client_id' => function (array $attributes) {
|
||||||
|
return Client::factory()->create(['workspace_id' => $attributes['workspace_id']])->id;
|
||||||
|
},
|
||||||
'created_by' => null,
|
'created_by' => null,
|
||||||
'title' => 'Déclaration '.$this->typeLabel($type).' - '.$year,
|
'title' => 'Déclaration '.$this->typeLabel($type).' - '.$year,
|
||||||
'type' => $type,
|
'type' => $type,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class WorkspaceFactory extends Factory
|
|||||||
*/
|
*/
|
||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
$name = fake()->company();
|
$name = fake()->unique()->company();
|
||||||
$slug = Str::slug($name);
|
$slug = Str::slug($name);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
81
resources/js/components/dashboard/StatCard.vue
Normal file
81
resources/js/components/dashboard/StatCard.vue
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { router } from '@inertiajs/vue3';
|
||||||
|
import { cva } from 'class-variance-authority';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
status: 'danger' | 'warning' | 'info' | 'success';
|
||||||
|
href: string;
|
||||||
|
loading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cardVariants = cva(
|
||||||
|
'cursor-pointer transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
status: {
|
||||||
|
danger: 'border-red-500/50 bg-red-500/5 hover:bg-red-500/10',
|
||||||
|
warning:
|
||||||
|
'border-amber-500/50 bg-amber-500/5 hover:bg-amber-500/10',
|
||||||
|
info: 'border-blue-500/50 bg-blue-500/5 hover:bg-blue-500/10',
|
||||||
|
success:
|
||||||
|
'border-green-500/50 bg-green-500/5 hover:bg-green-500/10',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const countColorClass = computed(() => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
danger: 'text-red-600',
|
||||||
|
warning: 'text-amber-600',
|
||||||
|
info: 'text-blue-600',
|
||||||
|
success: 'text-green-600',
|
||||||
|
};
|
||||||
|
return map[props.status] ?? '';
|
||||||
|
});
|
||||||
|
|
||||||
|
function navigate(): void {
|
||||||
|
router.get(props.href);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent): void {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
navigate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card
|
||||||
|
:class="cn(cardVariants({ status }))"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-pressed="false"
|
||||||
|
@click="navigate"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
>
|
||||||
|
<CardHeader class="pb-2">
|
||||||
|
<CardTitle class="text-sm font-medium text-muted-foreground">
|
||||||
|
<Skeleton v-if="loading" class="h-4 w-20" />
|
||||||
|
<template v-else>{{ label }}</template>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton v-if="loading" class="h-8 w-16" />
|
||||||
|
<div v-else :class="cn('text-3xl font-bold', countColorClass)">
|
||||||
|
{{ count }}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
16
resources/js/components/ui/table/Table.vue
Normal file
16
resources/js/components/ui/table/Table.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div data-slot="table-container" class="relative w-full overflow-auto">
|
||||||
|
<table data-slot="table" :class="cn('w-full caption-bottom text-sm', props.class)">
|
||||||
|
<slot />
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
resources/js/components/ui/table/TableBody.vue
Normal file
17
resources/js/components/ui/table/TableBody.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
:class="cn('[&_tr:last-child]:border-0', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</tbody>
|
||||||
|
</template>
|
||||||
17
resources/js/components/ui/table/TableCaption.vue
Normal file
17
resources/js/components/ui/table/TableCaption.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
:class="cn('text-muted-foreground mt-4 text-sm', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</caption>
|
||||||
|
</template>
|
||||||
22
resources/js/components/ui/table/TableCell.vue
Normal file
22
resources/js/components/ui/table/TableCell.vue
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
34
resources/js/components/ui/table/TableEmpty.vue
Normal file
34
resources/js/components/ui/table/TableEmpty.vue
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import TableCell from "./TableCell.vue"
|
||||||
|
import TableRow from "./TableRow.vue"
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
colspan?: number
|
||||||
|
}>(), {
|
||||||
|
colspan: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'p-4 whitespace-nowrap align-middle text-sm text-foreground',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-center py-10">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</template>
|
||||||
17
resources/js/components/ui/table/TableFooter.vue
Normal file
17
resources/js/components/ui/table/TableFooter.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
:class="cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</tfoot>
|
||||||
|
</template>
|
||||||
17
resources/js/components/ui/table/TableHead.vue
Normal file
17
resources/js/components/ui/table/TableHead.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
:class="cn('text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</th>
|
||||||
|
</template>
|
||||||
17
resources/js/components/ui/table/TableHeader.vue
Normal file
17
resources/js/components/ui/table/TableHeader.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
:class="cn('[&_tr]:border-b', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</thead>
|
||||||
|
</template>
|
||||||
17
resources/js/components/ui/table/TableRow.vue
Normal file
17
resources/js/components/ui/table/TableRow.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
:class="cn('hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
9
resources/js/components/ui/table/index.ts
Normal file
9
resources/js/components/ui/table/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export { default as Table } from "./Table.vue"
|
||||||
|
export { default as TableBody } from "./TableBody.vue"
|
||||||
|
export { default as TableCaption } from "./TableCaption.vue"
|
||||||
|
export { default as TableCell } from "./TableCell.vue"
|
||||||
|
export { default as TableEmpty } from "./TableEmpty.vue"
|
||||||
|
export { default as TableFooter } from "./TableFooter.vue"
|
||||||
|
export { default as TableHead } from "./TableHead.vue"
|
||||||
|
export { default as TableHeader } from "./TableHeader.vue"
|
||||||
|
export { default as TableRow } from "./TableRow.vue"
|
||||||
10
resources/js/components/ui/table/utils.ts
Normal file
10
resources/js/components/ui/table/utils.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Updater } from "@tanstack/vue-table"
|
||||||
|
|
||||||
|
import type { Ref } from "vue"
|
||||||
|
import { isFunction } from "@tanstack/vue-table"
|
||||||
|
|
||||||
|
export function valueUpdater<T>(updaterOrValue: Updater<T>, ref: Ref<T>) {
|
||||||
|
ref.value = isFunction(updaterOrValue)
|
||||||
|
? updaterOrValue(ref.value)
|
||||||
|
: updaterOrValue
|
||||||
|
}
|
||||||
@@ -1,57 +1,46 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, Link } from '@inertiajs/vue3';
|
import { Head, Link, router } from '@inertiajs/vue3';
|
||||||
import {
|
import {
|
||||||
Briefcase,
|
Briefcase,
|
||||||
Building2,
|
Building2,
|
||||||
Users,
|
EllipsisVertical,
|
||||||
|
Eye,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
AlertTriangle,
|
Send,
|
||||||
Clock,
|
UserRoundCog,
|
||||||
FileCheck,
|
Users,
|
||||||
MessageSquareWarning,
|
|
||||||
ArrowRight,
|
|
||||||
FileStack,
|
|
||||||
} from 'lucide-vue-next';
|
} from 'lucide-vue-next';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import PlaceholderPattern from '@/components/PlaceholderPattern.vue';
|
import StatCard from '@/components/dashboard/StatCard.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { dashboard } from '@/routes';
|
import { dashboard } from '@/routes';
|
||||||
import type { BreadcrumbItem } from '@/types';
|
import { index as usersIndex } from '@/routes/users';
|
||||||
|
import { index as workspacesIndex } from '@/routes/workspaces';
|
||||||
|
import type {
|
||||||
|
BreadcrumbItem,
|
||||||
|
DashboardDeclaration,
|
||||||
|
DashboardProps,
|
||||||
|
StatCardLink,
|
||||||
|
} from '@/types';
|
||||||
|
|
||||||
type AssignedDeclaration = {
|
type Props = DashboardProps;
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
client_name: string;
|
|
||||||
status: string;
|
|
||||||
due_date: string | null;
|
|
||||||
priority: string | null;
|
|
||||||
showUrl: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type NotificationItem = {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
client_name: string;
|
|
||||||
due_date?: string;
|
|
||||||
showUrl: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
assignedDeclarations: AssignedDeclaration[];
|
|
||||||
notifications: {
|
|
||||||
overdue: NotificationItem[];
|
|
||||||
due_soon: NotificationItem[];
|
|
||||||
documents_received: NotificationItem[];
|
|
||||||
awaiting_validation: NotificationItem[];
|
|
||||||
};
|
|
||||||
workspaceName: string | null;
|
|
||||||
declarationsUrl: string | null;
|
|
||||||
clientsUrl: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
@@ -64,74 +53,53 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||||||
|
|
||||||
const hasWorkspace = computed(() => !!props.workspaceName);
|
const hasWorkspace = computed(() => !!props.workspaceName);
|
||||||
|
|
||||||
const typeLabels: Record<string, string> = {
|
type DeadlineProximity = 'safe' | 'approaching' | 'urgent' | 'overdue' | 'none';
|
||||||
vat: 'TVA',
|
|
||||||
vat_monthly: 'TVA mensuelle',
|
|
||||||
vat_quarterly: 'TVA trimestrielle',
|
|
||||||
corporate_tax: 'IS',
|
|
||||||
income_tax: 'IR',
|
|
||||||
cnss: 'CNSS',
|
|
||||||
annual_balance: 'Bilan',
|
|
||||||
other: 'Autre',
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusLabels: Record<string, string> = {
|
function deadlineProximity(dueDate: string | null): DeadlineProximity {
|
||||||
draft: 'Brouillon',
|
if (!dueDate) return 'none';
|
||||||
waiting_documents: 'En attente documents',
|
const now = new Date();
|
||||||
documents_received: 'Documents reçus',
|
const deadline = new Date(dueDate);
|
||||||
processing: 'En cours',
|
const diffDays = Math.ceil(
|
||||||
additional_documents_requested: 'Pièces complémentaires',
|
(deadline.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
|
||||||
waiting_client_validation: 'En attente validation',
|
);
|
||||||
validated: 'Validé',
|
if (diffDays < 0) return 'overdue';
|
||||||
closed: 'Clôturé',
|
if (diffDays <= 5) return 'urgent';
|
||||||
cancelled: 'Annulé',
|
if (diffDays <= 7) return 'approaching';
|
||||||
};
|
return 'safe';
|
||||||
|
|
||||||
const statusVariant: Record<
|
|
||||||
string,
|
|
||||||
'default' | 'secondary' | 'destructive' | 'outline'
|
|
||||||
> = {
|
|
||||||
draft: 'secondary',
|
|
||||||
waiting_documents: 'outline',
|
|
||||||
documents_received: 'default',
|
|
||||||
processing: 'default',
|
|
||||||
additional_documents_requested: 'default',
|
|
||||||
waiting_client_validation: 'outline',
|
|
||||||
validated: 'secondary',
|
|
||||||
closed: 'secondary',
|
|
||||||
cancelled: 'secondary',
|
|
||||||
};
|
|
||||||
|
|
||||||
function statusLabel(s: string): string {
|
|
||||||
return statusLabels[s] ?? s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function typeLabel(t: string): string {
|
function deadlineClass(dueDate: string | null): string {
|
||||||
return typeLabels[t] ?? t;
|
const proximity = deadlineProximity(dueDate);
|
||||||
}
|
const map: Record<DeadlineProximity, string> = {
|
||||||
|
safe: 'text-green-600',
|
||||||
function progressPercent(status: string): number {
|
approaching: 'text-amber-600',
|
||||||
const steps: Record<string, number> = {
|
urgent: 'text-red-600',
|
||||||
draft: 0,
|
overdue: 'text-red-600 animate-pulse',
|
||||||
waiting_documents: 10,
|
none: 'text-muted-foreground',
|
||||||
documents_received: 30,
|
|
||||||
processing: 50,
|
|
||||||
additional_documents_requested: 45,
|
|
||||||
waiting_client_validation: 80,
|
|
||||||
validated: 100,
|
|
||||||
closed: 100,
|
|
||||||
cancelled: 0,
|
|
||||||
};
|
};
|
||||||
return steps[status] ?? 50;
|
return map[proximity];
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasAnyNotifications = computed(
|
function statusBadgeVariant(
|
||||||
() =>
|
status: string,
|
||||||
props.notifications.overdue.length > 0 ||
|
): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||||
props.notifications.due_soon.length > 0 ||
|
const map: Record<
|
||||||
props.notifications.documents_received.length > 0 ||
|
string,
|
||||||
props.notifications.awaiting_validation.length > 0,
|
'default' | 'secondary' | 'destructive' | 'outline'
|
||||||
);
|
> = {
|
||||||
|
created: 'secondary',
|
||||||
|
en_cours: 'default',
|
||||||
|
en_attente_client: 'outline',
|
||||||
|
termine: 'secondary',
|
||||||
|
mise_en_demeure: 'destructive',
|
||||||
|
ferme: 'secondary',
|
||||||
|
};
|
||||||
|
return map[status] ?? 'secondary';
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateToDeclaration(declaration: DashboardDeclaration): void {
|
||||||
|
router.get(declaration.showUrl);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -141,13 +109,13 @@ const hasAnyNotifications = computed(
|
|||||||
<div
|
<div
|
||||||
class="flex h-full flex-1 flex-col gap-6 overflow-x-auto rounded-xl p-4"
|
class="flex h-full flex-1 flex-col gap-6 overflow-x-auto rounded-xl p-4"
|
||||||
>
|
>
|
||||||
<!-- Quick links when no workspace -->
|
<!-- Quick links when no workspace (admin view) -->
|
||||||
<div
|
<div
|
||||||
v-if="!hasWorkspace"
|
v-if="!hasWorkspace"
|
||||||
class="grid auto-rows-min gap-4 md:grid-cols-3"
|
class="grid auto-rows-min gap-4 md:grid-cols-3"
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href="/users"
|
:href="usersIndex().url"
|
||||||
class="relative flex aspect-video flex-col items-center justify-center gap-2 overflow-hidden rounded-xl border border-sidebar-border/70 transition-colors hover:bg-muted/50 dark:border-sidebar-border"
|
class="relative flex aspect-video flex-col items-center justify-center gap-2 overflow-hidden rounded-xl border border-sidebar-border/70 transition-colors hover:bg-muted/50 dark:border-sidebar-border"
|
||||||
>
|
>
|
||||||
<Users class="h-8 w-8" />
|
<Users class="h-8 w-8" />
|
||||||
@@ -157,7 +125,7 @@ const hasAnyNotifications = computed(
|
|||||||
>
|
>
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/workspaces"
|
:href="workspacesIndex().url"
|
||||||
class="relative flex aspect-video flex-col items-center justify-center gap-2 overflow-hidden rounded-xl border border-sidebar-border/70 transition-colors hover:bg-muted/50 dark:border-sidebar-border"
|
class="relative flex aspect-video flex-col items-center justify-center gap-2 overflow-hidden rounded-xl border border-sidebar-border/70 transition-colors hover:bg-muted/50 dark:border-sidebar-border"
|
||||||
>
|
>
|
||||||
<Building2 class="h-8 w-8" />
|
<Building2 class="h-8 w-8" />
|
||||||
@@ -179,190 +147,27 @@ const hasAnyNotifications = computed(
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="hasWorkspace"
|
|
||||||
class="grid auto-rows-min gap-4 md:grid-cols-3"
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
v-if="declarationsUrl"
|
|
||||||
:href="declarationsUrl"
|
|
||||||
class="relative flex aspect-video flex-col items-center justify-center gap-2 overflow-hidden rounded-xl border border-sidebar-border/70 transition-colors hover:bg-muted/50 dark:border-sidebar-border"
|
|
||||||
>
|
|
||||||
<FileStack class="h-8 w-8" />
|
|
||||||
<span class="font-medium">Déclarations</span>
|
|
||||||
<span class="text-xs text-muted-foreground"
|
|
||||||
>Gérer les déclarations</span
|
|
||||||
>
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
v-if="clientsUrl"
|
|
||||||
:href="clientsUrl"
|
|
||||||
class="relative flex aspect-video flex-col items-center justify-center gap-2 overflow-hidden rounded-xl border border-sidebar-border/70 transition-colors hover:bg-muted/50 dark:border-sidebar-border"
|
|
||||||
>
|
|
||||||
<Briefcase class="h-8 w-8" />
|
|
||||||
<span class="font-medium">Clients</span>
|
|
||||||
<span class="text-xs text-muted-foreground"
|
|
||||||
>Manage clients</span
|
|
||||||
>
|
|
||||||
</Link>
|
|
||||||
<div
|
|
||||||
class="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"
|
|
||||||
>
|
|
||||||
<PlaceholderPattern />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Workspace dashboard -->
|
<!-- Workspace dashboard -->
|
||||||
<template v-if="hasWorkspace">
|
<template v-if="hasWorkspace">
|
||||||
<!-- Notifications -->
|
<!-- KPI StatCards -->
|
||||||
<div v-if="hasAnyNotifications" class="space-y-4">
|
<div
|
||||||
<h2 class="text-lg font-semibold">À traiter</h2>
|
class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4"
|
||||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
>
|
||||||
<Card
|
<StatCard
|
||||||
v-if="notifications.overdue.length > 0"
|
v-for="card in statCards"
|
||||||
class="border-destructive/50 bg-destructive/5"
|
:key="card.label"
|
||||||
>
|
:label="card.label"
|
||||||
<CardHeader class="pb-2">
|
:count="card.count"
|
||||||
<CardTitle
|
:status="card.status as StatCardLink['status']"
|
||||||
class="flex items-center gap-2 text-base"
|
:href="card.href"
|
||||||
>
|
/>
|
||||||
<AlertTriangle
|
|
||||||
class="h-4 w-4 text-destructive"
|
|
||||||
/>
|
|
||||||
En retard
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent class="space-y-2">
|
|
||||||
<Link
|
|
||||||
v-for="item in notifications.overdue"
|
|
||||||
:key="item.id"
|
|
||||||
:href="item.showUrl"
|
|
||||||
class="flex items-center justify-between rounded-md p-2 text-sm transition-colors hover:bg-muted/50"
|
|
||||||
>
|
|
||||||
<div class="truncate">
|
|
||||||
<span class="font-medium">{{
|
|
||||||
item.title
|
|
||||||
}}</span>
|
|
||||||
<span
|
|
||||||
class="ml-1 text-muted-foreground"
|
|
||||||
>
|
|
||||||
{{ item.client_name }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ArrowRight class="h-4 w-4 shrink-0" />
|
|
||||||
</Link>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card
|
|
||||||
v-if="notifications.due_soon.length > 0"
|
|
||||||
class="border-amber-500/50 bg-amber-500/5"
|
|
||||||
>
|
|
||||||
<CardHeader class="pb-2">
|
|
||||||
<CardTitle
|
|
||||||
class="flex items-center gap-2 text-base"
|
|
||||||
>
|
|
||||||
<Clock class="h-4 w-4 text-amber-600" />
|
|
||||||
Échéance proche
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent class="space-y-2">
|
|
||||||
<Link
|
|
||||||
v-for="item in notifications.due_soon"
|
|
||||||
:key="item.id"
|
|
||||||
:href="item.showUrl"
|
|
||||||
class="flex items-center justify-between rounded-md p-2 text-sm transition-colors hover:bg-muted/50"
|
|
||||||
>
|
|
||||||
<div class="truncate">
|
|
||||||
<span class="font-medium">{{
|
|
||||||
item.title
|
|
||||||
}}</span>
|
|
||||||
<span
|
|
||||||
class="ml-1 text-muted-foreground"
|
|
||||||
>
|
|
||||||
{{ item.client_name }} —
|
|
||||||
{{ item.due_date }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ArrowRight class="h-4 w-4 shrink-0" />
|
|
||||||
</Link>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card
|
|
||||||
v-if="notifications.documents_received.length > 0"
|
|
||||||
class="border-primary/50 bg-primary/5"
|
|
||||||
>
|
|
||||||
<CardHeader class="pb-2">
|
|
||||||
<CardTitle
|
|
||||||
class="flex items-center gap-2 text-base"
|
|
||||||
>
|
|
||||||
<FileCheck class="h-4 w-4 text-primary" />
|
|
||||||
Documents reçus
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent class="space-y-2">
|
|
||||||
<Link
|
|
||||||
v-for="item in notifications.documents_received"
|
|
||||||
:key="item.id"
|
|
||||||
:href="item.showUrl"
|
|
||||||
class="flex items-center justify-between rounded-md p-2 text-sm transition-colors hover:bg-muted/50"
|
|
||||||
>
|
|
||||||
<div class="truncate">
|
|
||||||
<span class="font-medium">{{
|
|
||||||
item.title
|
|
||||||
}}</span>
|
|
||||||
<span
|
|
||||||
class="ml-1 text-muted-foreground"
|
|
||||||
>
|
|
||||||
{{ item.client_name }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ArrowRight class="h-4 w-4 shrink-0" />
|
|
||||||
</Link>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card
|
|
||||||
v-if="notifications.awaiting_validation.length > 0"
|
|
||||||
class="border-blue-500/50 bg-blue-500/5"
|
|
||||||
>
|
|
||||||
<CardHeader class="pb-2">
|
|
||||||
<CardTitle
|
|
||||||
class="flex items-center gap-2 text-base"
|
|
||||||
>
|
|
||||||
<MessageSquareWarning
|
|
||||||
class="h-4 w-4 text-blue-600"
|
|
||||||
/>
|
|
||||||
En attente validation client
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent class="space-y-2">
|
|
||||||
<Link
|
|
||||||
v-for="item in notifications.awaiting_validation"
|
|
||||||
:key="item.id"
|
|
||||||
:href="item.showUrl"
|
|
||||||
class="flex items-center justify-between rounded-md p-2 text-sm transition-colors hover:bg-muted/50"
|
|
||||||
>
|
|
||||||
<div class="truncate">
|
|
||||||
<span class="font-medium">{{
|
|
||||||
item.title
|
|
||||||
}}</span>
|
|
||||||
<span
|
|
||||||
class="ml-1 text-muted-foreground"
|
|
||||||
>
|
|
||||||
{{ item.client_name }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ArrowRight class="h-4 w-4 shrink-0" />
|
|
||||||
</Link>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- My assigned declarations -->
|
<!-- Urgent Declarations Table -->
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="text-lg font-semibold">
|
<h2 class="text-lg font-semibold">
|
||||||
Mes déclarations — {{ workspaceName }}
|
Déclarations urgentes
|
||||||
</h2>
|
</h2>
|
||||||
<Button
|
<Button
|
||||||
v-if="declarationsUrl"
|
v-if="declarationsUrl"
|
||||||
@@ -371,144 +176,117 @@ const hasAnyNotifications = computed(
|
|||||||
>
|
>
|
||||||
<Link :href="declarationsUrl">
|
<Link :href="declarationsUrl">
|
||||||
Toutes les déclarations
|
Toutes les déclarations
|
||||||
<ArrowRight class="ml-1 h-4 w-4" />
|
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
v-if="assignedDeclarations.length > 0"
|
v-if="declarations.length > 0"
|
||||||
class="overflow-hidden"
|
class="overflow-hidden"
|
||||||
>
|
>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full text-sm">
|
<Table>
|
||||||
<thead
|
<TableHeader>
|
||||||
class="border-b border-sidebar-border/70 bg-muted/50"
|
<TableRow>
|
||||||
>
|
<TableHead>Client</TableHead>
|
||||||
<tr>
|
<TableHead>Type</TableHead>
|
||||||
<th
|
<TableHead>Date limite</TableHead>
|
||||||
class="h-10 px-4 text-left font-medium"
|
<TableHead>Assigné à</TableHead>
|
||||||
>
|
<TableHead>Statut</TableHead>
|
||||||
Déclaration / Client
|
<TableHead class="w-10" />
|
||||||
</th>
|
</TableRow>
|
||||||
<th
|
</TableHeader>
|
||||||
class="h-10 px-4 text-left font-medium"
|
<TableBody>
|
||||||
>
|
<TableRow
|
||||||
Type
|
v-for="declaration in declarations"
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="h-10 px-4 text-left font-medium"
|
|
||||||
>
|
|
||||||
Statut
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="h-10 px-4 text-left font-medium"
|
|
||||||
>
|
|
||||||
Progression
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="h-10 px-4 text-left font-medium"
|
|
||||||
>
|
|
||||||
Date limite
|
|
||||||
</th>
|
|
||||||
<th class="h-10 w-10 px-4"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr
|
|
||||||
v-for="declaration in assignedDeclarations"
|
|
||||||
:key="declaration.id"
|
:key="declaration.id"
|
||||||
class="border-b border-sidebar-border/50 transition-colors last:border-0 hover:bg-muted/30"
|
class="cursor-pointer"
|
||||||
|
@click="
|
||||||
|
navigateToDeclaration(declaration)
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<td class="px-4 py-3">
|
<TableCell class="font-medium">
|
||||||
<Link
|
{{ declaration.clientName }}
|
||||||
:href="declaration.showUrl"
|
</TableCell>
|
||||||
class="block font-medium hover:underline"
|
<TableCell>
|
||||||
>
|
{{ declaration.typeLabel }}
|
||||||
{{ declaration.title }}
|
</TableCell>
|
||||||
</Link>
|
<TableCell>
|
||||||
<span
|
<span
|
||||||
class="block text-xs text-muted-foreground"
|
:class="
|
||||||
>
|
deadlineClass(
|
||||||
{{ declaration.client_name }}
|
declaration.dueDate,
|
||||||
</span>
|
)
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
class="px-4 py-3 text-muted-foreground"
|
|
||||||
>
|
|
||||||
{{ typeLabel(declaration.type) }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<Badge
|
|
||||||
:variant="
|
|
||||||
statusVariant[
|
|
||||||
declaration.status
|
|
||||||
] ?? 'secondary'
|
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{{
|
{{ declaration.dueDate ?? '—' }}
|
||||||
statusLabel(
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{{
|
||||||
|
declaration.assigneeName ?? '—'
|
||||||
|
}}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
:variant="
|
||||||
|
statusBadgeVariant(
|
||||||
declaration.status,
|
declaration.status,
|
||||||
)
|
)
|
||||||
}}
|
"
|
||||||
|
>
|
||||||
|
{{ declaration.statusLabel }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</TableCell>
|
||||||
<td class="px-4 py-3">
|
<TableCell>
|
||||||
<div
|
<DropdownMenu>
|
||||||
class="flex h-2 w-24 overflow-hidden rounded-full bg-muted"
|
<DropdownMenuTrigger
|
||||||
>
|
as-child
|
||||||
<div
|
@click.stop
|
||||||
class="h-full bg-primary transition-all"
|
|
||||||
:style="{
|
|
||||||
width: `${progressPercent(declaration.status)}%`,
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
class="text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
progressPercent(
|
|
||||||
declaration.status,
|
|
||||||
)
|
|
||||||
}}%
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<span
|
|
||||||
:class="{
|
|
||||||
'font-medium text-destructive':
|
|
||||||
declaration.due_date &&
|
|
||||||
declaration.due_date <
|
|
||||||
new Date()
|
|
||||||
.toISOString()
|
|
||||||
.slice(0, 10),
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
declaration.due_date || '—'
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
as-child
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
:href="declaration.showUrl"
|
|
||||||
>
|
>
|
||||||
Voir
|
<Button
|
||||||
<ArrowRight
|
variant="ghost"
|
||||||
class="ml-1 h-3 w-3"
|
size="icon"
|
||||||
/>
|
class="h-8 w-8"
|
||||||
</Link>
|
>
|
||||||
</Button>
|
<EllipsisVertical
|
||||||
</td>
|
class="h-4 w-4"
|
||||||
</tr>
|
/>
|
||||||
</tbody>
|
</Button>
|
||||||
</table>
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
@click.stop="
|
||||||
|
navigateToDeclaration(
|
||||||
|
declaration,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Eye
|
||||||
|
class="mr-2 h-4 w-4"
|
||||||
|
/>
|
||||||
|
Voir
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem disabled>
|
||||||
|
<Send
|
||||||
|
class="mr-2 h-4 w-4"
|
||||||
|
/>
|
||||||
|
Relancer
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem disabled>
|
||||||
|
<UserRoundCog
|
||||||
|
class="mr-2 h-4 w-4"
|
||||||
|
/>
|
||||||
|
Réassigner
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -519,15 +297,9 @@ const hasAnyNotifications = computed(
|
|||||||
<FolderOpen
|
<FolderOpen
|
||||||
class="mb-3 h-12 w-12 text-muted-foreground"
|
class="mb-3 h-12 w-12 text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
<p class="mb-2 text-muted-foreground">
|
<p class="text-muted-foreground">
|
||||||
Aucune déclaration ne vous est assignée pour le
|
Aucune déclaration urgente pour le moment.
|
||||||
moment.
|
|
||||||
</p>
|
</p>
|
||||||
<Button v-if="declarationsUrl" as-child>
|
|
||||||
<Link :href="declarationsUrl"
|
|
||||||
>Voir toutes les déclarations</Link
|
|
||||||
>
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
36
resources/js/types/dashboard.ts
Normal file
36
resources/js/types/dashboard.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
export type DashboardStats = {
|
||||||
|
overdue: number;
|
||||||
|
dueThisWeek: number;
|
||||||
|
enAttenteClient: number;
|
||||||
|
enCours: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DashboardDeclaration = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
typeLabel: string;
|
||||||
|
clientName: string;
|
||||||
|
assigneeName: string | null;
|
||||||
|
status: string;
|
||||||
|
statusLabel: string;
|
||||||
|
dueDate: string | null;
|
||||||
|
showUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StatCardLink = {
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
status: 'danger' | 'warning' | 'info' | 'success';
|
||||||
|
href: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DashboardProps = {
|
||||||
|
stats: DashboardStats | null;
|
||||||
|
statCards: StatCardLink[];
|
||||||
|
declarations: DashboardDeclaration[];
|
||||||
|
workspaceName: string | null;
|
||||||
|
roleLabel: string | null;
|
||||||
|
declarationsUrl: string | null;
|
||||||
|
clientsUrl: string | null;
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './auth';
|
export * from './auth';
|
||||||
|
export * from './dashboard';
|
||||||
export * from './navigation';
|
export * from './navigation';
|
||||||
export * from './team';
|
export * from './team';
|
||||||
export * from './ui';
|
export * from './ui';
|
||||||
|
|||||||
333
tests/Feature/Dashboard/OwnerDashboardTest.php
Normal file
333
tests/Feature/Dashboard/OwnerDashboardTest.php
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\DeclarationStatus;
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Models\Declaration;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
function setupWorkspaceWithRole(string $role = 'owner'): array
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$workspace->users()->attach($user->id, ['role' => $role]);
|
||||||
|
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
|
||||||
|
session(['current_workspace_id' => $workspace->id]);
|
||||||
|
|
||||||
|
return [$user, $workspace, $client];
|
||||||
|
}
|
||||||
|
|
||||||
|
test('unauthenticated user is redirected to login', function () {
|
||||||
|
$response = $this->get(route('dashboard'));
|
||||||
|
|
||||||
|
$response->assertRedirect(route('login'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('user without workspace sees admin view', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$response = $this->get(route('dashboard'));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertInertia(fn ($page) => $page
|
||||||
|
->component('Dashboard')
|
||||||
|
->where('workspaceName', null)
|
||||||
|
->where('stats', null)
|
||||||
|
->where('statCards', [])
|
||||||
|
->where('declarations', [])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('owner sees all workspace declarations in kpi counts', function () {
|
||||||
|
[$user, $workspace, $client] = setupWorkspaceWithRole('owner');
|
||||||
|
|
||||||
|
$otherUser = User::factory()->create();
|
||||||
|
$workspace->users()->attach($otherUser->id, ['role' => 'worker']);
|
||||||
|
|
||||||
|
// Overdue declaration (assigned to other user)
|
||||||
|
Declaration::factory()->create([
|
||||||
|
'workspace_id' => $workspace->id,
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'assigned_to' => $otherUser->id,
|
||||||
|
'status' => DeclarationStatus::EnCours,
|
||||||
|
'due_date' => now()->subDays(5),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Due this week (assigned to current user)
|
||||||
|
Declaration::factory()->create([
|
||||||
|
'workspace_id' => $workspace->id,
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'assigned_to' => $user->id,
|
||||||
|
'status' => DeclarationStatus::EnCours,
|
||||||
|
'due_date' => now()->addDays(3),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// En attente client
|
||||||
|
Declaration::factory()->create([
|
||||||
|
'workspace_id' => $workspace->id,
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'assigned_to' => $otherUser->id,
|
||||||
|
'status' => DeclarationStatus::EnAttenteClient,
|
||||||
|
'due_date' => now()->addDays(10),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// En cours
|
||||||
|
Declaration::factory()->create([
|
||||||
|
'workspace_id' => $workspace->id,
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'assigned_to' => $user->id,
|
||||||
|
'status' => DeclarationStatus::EnCours,
|
||||||
|
'due_date' => now()->addDays(15),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Ferme (should be excluded)
|
||||||
|
Declaration::factory()->create([
|
||||||
|
'workspace_id' => $workspace->id,
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'status' => DeclarationStatus::Ferme,
|
||||||
|
'due_date' => now()->subDays(1),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Termine (should be excluded)
|
||||||
|
Declaration::factory()->create([
|
||||||
|
'workspace_id' => $workspace->id,
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'status' => DeclarationStatus::Termine,
|
||||||
|
'due_date' => now()->subDays(1),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Mise en demeure (should be excluded)
|
||||||
|
Declaration::factory()->create([
|
||||||
|
'workspace_id' => $workspace->id,
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'status' => DeclarationStatus::MiseEnDemeure,
|
||||||
|
'due_date' => now()->subDays(1),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$response = $this->get(route('dashboard'));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertInertia(fn ($page) => $page
|
||||||
|
->component('Dashboard')
|
||||||
|
->where('stats.overdue', 1)
|
||||||
|
->where('stats.dueThisWeek', 1)
|
||||||
|
->where('stats.enAttenteClient', 1)
|
||||||
|
->where('stats.enCours', 3)
|
||||||
|
->has('statCards', 4)
|
||||||
|
->where('workspaceName', $workspace->name)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manager sees all workspace declarations in kpi counts', function () {
|
||||||
|
[$user, $workspace, $client] = setupWorkspaceWithRole('manager');
|
||||||
|
|
||||||
|
$worker = User::factory()->create();
|
||||||
|
$workspace->users()->attach($worker->id, ['role' => 'worker']);
|
||||||
|
|
||||||
|
// Declaration assigned to the worker (manager should still see it)
|
||||||
|
Declaration::factory()->create([
|
||||||
|
'workspace_id' => $workspace->id,
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'assigned_to' => $worker->id,
|
||||||
|
'status' => DeclarationStatus::EnCours,
|
||||||
|
'due_date' => now()->subDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$response = $this->get(route('dashboard'));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertInertia(fn ($page) => $page
|
||||||
|
->component('Dashboard')
|
||||||
|
->where('stats.overdue', 1)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('worker sees only assigned declarations', function () {
|
||||||
|
[$user, $workspace, $client] = setupWorkspaceWithRole('worker');
|
||||||
|
|
||||||
|
$otherUser = User::factory()->create();
|
||||||
|
$workspace->users()->attach($otherUser->id, ['role' => 'worker']);
|
||||||
|
|
||||||
|
// Assigned to this worker
|
||||||
|
Declaration::factory()->create([
|
||||||
|
'workspace_id' => $workspace->id,
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'assigned_to' => $user->id,
|
||||||
|
'status' => DeclarationStatus::EnCours,
|
||||||
|
'due_date' => now()->subDays(1),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assigned to other worker (should NOT be visible)
|
||||||
|
Declaration::factory()->create([
|
||||||
|
'workspace_id' => $workspace->id,
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'assigned_to' => $otherUser->id,
|
||||||
|
'status' => DeclarationStatus::EnCours,
|
||||||
|
'due_date' => now()->subDays(1),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$response = $this->get(route('dashboard'));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertInertia(fn ($page) => $page
|
||||||
|
->component('Dashboard')
|
||||||
|
->where('stats.overdue', 1)
|
||||||
|
->where('stats.enCours', 1)
|
||||||
|
->has('declarations', 1)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dashboard data uses cache with correct key and ttl', function () {
|
||||||
|
[$user, $workspace, $client] = setupWorkspaceWithRole('owner');
|
||||||
|
|
||||||
|
Declaration::factory()->create([
|
||||||
|
'workspace_id' => $workspace->id,
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'status' => DeclarationStatus::EnCours,
|
||||||
|
'due_date' => now()->addDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
// First request populates cache
|
||||||
|
$this->get(route('dashboard'))->assertOk();
|
||||||
|
|
||||||
|
$cacheKey = "dashboard:{$workspace->id}:{$user->id}";
|
||||||
|
expect(Cache::has($cacheKey))->toBeTrue();
|
||||||
|
|
||||||
|
$cached = Cache::get($cacheKey);
|
||||||
|
expect($cached)->toBeArray()
|
||||||
|
->and($cached)->toHaveKeys(['overdue', 'dueThisWeek', 'enAttenteClient', 'enCours']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dashboard excludes archived declarations', function () {
|
||||||
|
[$user, $workspace, $client] = setupWorkspaceWithRole('owner');
|
||||||
|
|
||||||
|
// Active declaration
|
||||||
|
Declaration::factory()->create([
|
||||||
|
'workspace_id' => $workspace->id,
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'status' => DeclarationStatus::EnCours,
|
||||||
|
'due_date' => now()->addDays(2),
|
||||||
|
'archived_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Archived declaration (should NOT be counted)
|
||||||
|
Declaration::factory()->create([
|
||||||
|
'workspace_id' => $workspace->id,
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'status' => DeclarationStatus::EnCours,
|
||||||
|
'due_date' => now()->addDays(2),
|
||||||
|
'archived_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$response = $this->get(route('dashboard'));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertInertia(fn ($page) => $page
|
||||||
|
->component('Dashboard')
|
||||||
|
->where('stats.enCours', 1)
|
||||||
|
->has('declarations', 1)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dashboard returns declarations sorted by due date ascending', function () {
|
||||||
|
[$user, $workspace, $client] = setupWorkspaceWithRole('owner');
|
||||||
|
|
||||||
|
$later = Declaration::factory()->create([
|
||||||
|
'workspace_id' => $workspace->id,
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'status' => DeclarationStatus::EnCours,
|
||||||
|
'due_date' => now()->addDays(10),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$sooner = Declaration::factory()->create([
|
||||||
|
'workspace_id' => $workspace->id,
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'status' => DeclarationStatus::EnCours,
|
||||||
|
'due_date' => now()->addDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$response = $this->get(route('dashboard'));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertInertia(fn ($page) => $page
|
||||||
|
->component('Dashboard')
|
||||||
|
->has('declarations', 2)
|
||||||
|
->where('declarations.0.id', $sooner->id)
|
||||||
|
->where('declarations.1.id', $later->id)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dashboard limits declarations to 15 rows', function () {
|
||||||
|
[$user, $workspace, $client] = setupWorkspaceWithRole('owner');
|
||||||
|
|
||||||
|
// Create 20 declarations
|
||||||
|
for ($i = 0; $i < 20; $i++) {
|
||||||
|
Declaration::factory()->create([
|
||||||
|
'workspace_id' => $workspace->id,
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'status' => DeclarationStatus::EnCours,
|
||||||
|
'due_date' => now()->addDays($i),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$response = $this->get(route('dashboard'));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertInertia(fn ($page) => $page
|
||||||
|
->component('Dashboard')
|
||||||
|
->has('declarations', 15)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stat cards have correct filter hrefs', function () {
|
||||||
|
[$user, $workspace, $client] = setupWorkspaceWithRole('owner');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$response = $this->get(route('dashboard'));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertInertia(fn ($page) => $page
|
||||||
|
->component('Dashboard')
|
||||||
|
->has('statCards', 4)
|
||||||
|
->where('statCards.0.status', 'danger')
|
||||||
|
->where('statCards.1.status', 'warning')
|
||||||
|
->where('statCards.2.status', 'info')
|
||||||
|
->where('statCards.3.status', 'success')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('declaration rows include show url and assignee name', function () {
|
||||||
|
[$user, $workspace, $client] = setupWorkspaceWithRole('owner');
|
||||||
|
|
||||||
|
$assignee = User::factory()->create(['name' => 'Ahmed Test']);
|
||||||
|
|
||||||
|
Declaration::factory()->create([
|
||||||
|
'workspace_id' => $workspace->id,
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'assigned_to' => $assignee->id,
|
||||||
|
'status' => DeclarationStatus::EnCours,
|
||||||
|
'due_date' => now()->addDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$response = $this->get(route('dashboard'));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertInertia(fn ($page) => $page
|
||||||
|
->component('Dashboard')
|
||||||
|
->has('declarations', 1)
|
||||||
|
->where('declarations.0.assigneeName', 'Ahmed Test')
|
||||||
|
->where('declarations.0.clientName', $client->company_name)
|
||||||
|
->has('declarations.0.showUrl')
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -50,6 +50,38 @@ test('valid transition: termine to ferme', function () {
|
|||||||
expect($declaration->fresh()->status->value)->toBe('ferme');
|
expect($declaration->fresh()->status->value)->toBe('ferme');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('valid transition: en_attente_client to mise_en_demeure', function () {
|
||||||
|
$declaration = Declaration::factory()->create(['status' => DeclarationStatus::Created]);
|
||||||
|
$declaration->update(['status' => DeclarationStatus::EnCours]);
|
||||||
|
$declaration->update(['status' => DeclarationStatus::EnAttenteClient]);
|
||||||
|
|
||||||
|
$declaration->update(['status' => DeclarationStatus::MiseEnDemeure]);
|
||||||
|
|
||||||
|
expect($declaration->fresh()->status->value)->toBe('mise_en_demeure');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('valid transition: mise_en_demeure to en_cours', function () {
|
||||||
|
$declaration = Declaration::factory()->create(['status' => DeclarationStatus::Created]);
|
||||||
|
$declaration->update(['status' => DeclarationStatus::EnCours]);
|
||||||
|
$declaration->update(['status' => DeclarationStatus::EnAttenteClient]);
|
||||||
|
$declaration->update(['status' => DeclarationStatus::MiseEnDemeure]);
|
||||||
|
|
||||||
|
$declaration->update(['status' => DeclarationStatus::EnCours]);
|
||||||
|
|
||||||
|
expect($declaration->fresh()->status->value)->toBe('en_cours');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('valid transition: mise_en_demeure to ferme', function () {
|
||||||
|
$declaration = Declaration::factory()->create(['status' => DeclarationStatus::Created]);
|
||||||
|
$declaration->update(['status' => DeclarationStatus::EnCours]);
|
||||||
|
$declaration->update(['status' => DeclarationStatus::EnAttenteClient]);
|
||||||
|
$declaration->update(['status' => DeclarationStatus::MiseEnDemeure]);
|
||||||
|
|
||||||
|
$declaration->update(['status' => DeclarationStatus::Ferme]);
|
||||||
|
|
||||||
|
expect($declaration->fresh()->status->value)->toBe('ferme');
|
||||||
|
});
|
||||||
|
|
||||||
test('invalid transition: created to ferme throws validation exception', function () {
|
test('invalid transition: created to ferme throws validation exception', function () {
|
||||||
$declaration = Declaration::factory()->create(['status' => DeclarationStatus::Created]);
|
$declaration = Declaration::factory()->create(['status' => DeclarationStatus::Created]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user