From a2ab6f365d5c08c18d4f22b4a016191a8c31414b Mon Sep 17 00:00:00 2001 From: Saad Ibn-Ezzoubayr Date: Fri, 20 Mar 2026 12:00:24 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20Story=202.1=20=E2=80=94=20O?= =?UTF-8?q?wner/Manager=20Command=20Center=20Dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ...-owner-manager-command-center-dashboard.md | 261 ++++++++ .../sprint-status.yaml | 8 +- app/Enums/DeclarationStatus.php | 6 +- app/Http/Controllers/DashboardController.php | 246 ++++--- database/factories/DeclarationFactory.php | 9 +- database/factories/WorkspaceFactory.php | 2 +- .../js/components/dashboard/StatCard.vue | 81 +++ resources/js/components/ui/table/Table.vue | 16 + .../js/components/ui/table/TableBody.vue | 17 + .../js/components/ui/table/TableCaption.vue | 17 + .../js/components/ui/table/TableCell.vue | 22 + .../js/components/ui/table/TableEmpty.vue | 34 + .../js/components/ui/table/TableFooter.vue | 17 + .../js/components/ui/table/TableHead.vue | 17 + .../js/components/ui/table/TableHeader.vue | 17 + resources/js/components/ui/table/TableRow.vue | 17 + resources/js/components/ui/table/index.ts | 9 + resources/js/components/ui/table/utils.ts | 10 + resources/js/pages/Dashboard.vue | 598 ++++++------------ resources/js/types/dashboard.ts | 36 ++ resources/js/types/index.ts | 1 + .../Feature/Dashboard/OwnerDashboardTest.php | 333 ++++++++++ .../Declaration/DeclarationStatusFlowTest.php | 32 + 23 files changed, 1283 insertions(+), 523 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/2-1-owner-manager-command-center-dashboard.md create mode 100644 resources/js/components/dashboard/StatCard.vue create mode 100644 resources/js/components/ui/table/Table.vue create mode 100644 resources/js/components/ui/table/TableBody.vue create mode 100644 resources/js/components/ui/table/TableCaption.vue create mode 100644 resources/js/components/ui/table/TableCell.vue create mode 100644 resources/js/components/ui/table/TableEmpty.vue create mode 100644 resources/js/components/ui/table/TableFooter.vue create mode 100644 resources/js/components/ui/table/TableHead.vue create mode 100644 resources/js/components/ui/table/TableHeader.vue create mode 100644 resources/js/components/ui/table/TableRow.vue create mode 100644 resources/js/components/ui/table/index.ts create mode 100644 resources/js/components/ui/table/utils.ts create mode 100644 resources/js/types/dashboard.ts create mode 100644 tests/Feature/Dashboard/OwnerDashboardTest.php diff --git a/_bmad-output/implementation-artifacts/2-1-owner-manager-command-center-dashboard.md b/_bmad-output/implementation-artifacts/2-1-owner-manager-command-center-dashboard.md new file mode 100644 index 0000000..86c78ea --- /dev/null +++ b/_bmad-output/implementation-artifacts/2-1-owner-manager-command-center-dashboard.md @@ -0,0 +1,261 @@ +# Story 2.1: Owner/Manager Command Center Dashboard + +Status: review + + + +## 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) diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index fbe23f7..bdf6204 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -50,18 +50,18 @@ development_status: epic-0-retrospective: done # Epic 1: Team Management & Permission System - epic-1: in-progress + epic-1: done 1-1-permission-configuration-and-controller-traits: done 1-2-team-management-page-view-and-invite-members: done 1-3-role-assignment-and-member-removal: done 1-4-manager-permission-toggle-matrix: done 1-5-role-based-access-enforcement-across-views: 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: backlog - 2-1-owner-manager-command-center-dashboard: backlog + epic-2: in-progress + 2-1-owner-manager-command-center-dashboard: review 2-2-priority-alerts-panel: backlog 2-3-worker-scoped-dashboard: backlog 2-4-dashboard-activity-feed: backlog diff --git a/app/Enums/DeclarationStatus.php b/app/Enums/DeclarationStatus.php index d4e6be3..d916364 100644 --- a/app/Enums/DeclarationStatus.php +++ b/app/Enums/DeclarationStatus.php @@ -14,6 +14,8 @@ final class DeclarationStatus extends Enum const Termine = 'termine'; + const MiseEnDemeure = 'mise_en_demeure'; + const Ferme = 'ferme'; /** @@ -28,6 +30,7 @@ final class DeclarationStatus extends Enum self::EnCours => 'En cours', self::EnAttenteClient => 'En attente client', self::Termine => 'Terminé', + self::MiseEnDemeure => 'Mise en demeure', self::Ferme => 'Fermé', ]; } @@ -42,8 +45,9 @@ final class DeclarationStatus extends Enum return [ self::Created => [self::EnCours], self::EnCours => [self::EnAttenteClient, self::Termine], - self::EnAttenteClient => [self::EnCours], + self::EnAttenteClient => [self::EnCours, self::MiseEnDemeure], self::Termine => [self::Ferme], + self::MiseEnDemeure => [self::EnCours, self::Ferme], self::Ferme => [], ]; } diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 4807ff2..1d87cc3 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -3,16 +3,19 @@ namespace App\Http\Controllers; use App\Enums\DeclarationStatus; +use App\Enums\WorkspaceUserRole; use App\Models\Declaration; use App\Models\Workspace; +use App\Models\WorkspaceUser; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; use Inertia\Inertia; use Inertia\Response; 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 { @@ -20,107 +23,152 @@ class DashboardController extends Controller $workspaceId = $request->session()->get('current_workspace_id'); $workspace = $workspaceId ? Workspace::query()->find($workspaceId) : null; - $assignedDeclarations = []; - $notifications = []; - - if ($workspace && $user) { - $assignedDeclarations = $workspace->declarations() - ->where('assigned_to', $user->id) - ->whereNotIn('status', [DeclarationStatus::Ferme]) - ->with('client:id,company_name') - ->orderByRaw('CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, due_date ASC') - ->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, - ]; + if (! $workspace || ! $user) { + return Inertia::render('Dashboard', [ + 'stats' => null, + 'statCards' => [], + 'declarations' => [], + 'workspaceName' => null, + 'roleLabel' => null, + 'declarationsUrl' => null, + 'clientsUrl' => null, + ]); } + /** @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', [ - 'assignedDeclarations' => $assignedDeclarations, - 'notifications' => $notifications, - 'workspaceName' => $workspace?->name ?? null, - 'declarationsUrl' => $workspace ? route('declarations.index') : null, - 'clientsUrl' => $workspace ? route('clients.index') : null, + 'stats' => $dashboardData, + 'statCards' => $statCards, + 'declarations' => $urgentDeclarations, + 'workspaceName' => $workspace->name, + 'roleLabel' => $roleLabel, + 'declarationsUrl' => route('declarations.index'), + 'clientsUrl' => route('clients.index'), ]); } + + /** + * Get declaration type labels. + * + * @return array + */ + 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 + */ + protected function roleLabels(): array + { + return [ + WorkspaceUserRole::Owner => 'Propriétaire', + WorkspaceUserRole::Manager => 'Manager', + WorkspaceUserRole::Worker => 'Collaborateur', + ]; + } } diff --git a/database/factories/DeclarationFactory.php b/database/factories/DeclarationFactory.php index 6397465..a9e739e 100644 --- a/database/factories/DeclarationFactory.php +++ b/database/factories/DeclarationFactory.php @@ -21,9 +21,6 @@ class DeclarationFactory extends Factory */ public function definition(): array { - $workspace = Workspace::factory()->create(); - $client = Client::factory()->create(['workspace_id' => $workspace->id]); - $year = fake()->numberBetween(2024, 2026); $excludeOldVat = array_filter(DeclarationType::getValues(), fn ($v) => $v !== 'vat'); $type = fake()->randomElement(array_values($excludeOldVat)); @@ -32,8 +29,10 @@ class DeclarationFactory extends Factory $isVatQuarterly = $type === 'vat_quarterly'; return [ - 'workspace_id' => $workspace->id, - 'client_id' => $client->id, + 'workspace_id' => Workspace::factory(), + 'client_id' => function (array $attributes) { + return Client::factory()->create(['workspace_id' => $attributes['workspace_id']])->id; + }, 'created_by' => null, 'title' => 'Déclaration '.$this->typeLabel($type).' - '.$year, 'type' => $type, diff --git a/database/factories/WorkspaceFactory.php b/database/factories/WorkspaceFactory.php index 13baf2e..367e1cb 100644 --- a/database/factories/WorkspaceFactory.php +++ b/database/factories/WorkspaceFactory.php @@ -17,7 +17,7 @@ class WorkspaceFactory extends Factory */ public function definition(): array { - $name = fake()->company(); + $name = fake()->unique()->company(); $slug = Str::slug($name); return [ diff --git a/resources/js/components/dashboard/StatCard.vue b/resources/js/components/dashboard/StatCard.vue new file mode 100644 index 0000000..85acf78 --- /dev/null +++ b/resources/js/components/dashboard/StatCard.vue @@ -0,0 +1,81 @@ + + + diff --git a/resources/js/components/ui/table/Table.vue b/resources/js/components/ui/table/Table.vue new file mode 100644 index 0000000..0d0cd9b --- /dev/null +++ b/resources/js/components/ui/table/Table.vue @@ -0,0 +1,16 @@ + + + diff --git a/resources/js/components/ui/table/TableBody.vue b/resources/js/components/ui/table/TableBody.vue new file mode 100644 index 0000000..d14a2d3 --- /dev/null +++ b/resources/js/components/ui/table/TableBody.vue @@ -0,0 +1,17 @@ + + + diff --git a/resources/js/components/ui/table/TableCaption.vue b/resources/js/components/ui/table/TableCaption.vue new file mode 100644 index 0000000..3630084 --- /dev/null +++ b/resources/js/components/ui/table/TableCaption.vue @@ -0,0 +1,17 @@ + + + diff --git a/resources/js/components/ui/table/TableCell.vue b/resources/js/components/ui/table/TableCell.vue new file mode 100644 index 0000000..d6e9ed2 --- /dev/null +++ b/resources/js/components/ui/table/TableCell.vue @@ -0,0 +1,22 @@ + + + diff --git a/resources/js/components/ui/table/TableEmpty.vue b/resources/js/components/ui/table/TableEmpty.vue new file mode 100644 index 0000000..9519328 --- /dev/null +++ b/resources/js/components/ui/table/TableEmpty.vue @@ -0,0 +1,34 @@ + + + diff --git a/resources/js/components/ui/table/TableFooter.vue b/resources/js/components/ui/table/TableFooter.vue new file mode 100644 index 0000000..29e0ce9 --- /dev/null +++ b/resources/js/components/ui/table/TableFooter.vue @@ -0,0 +1,17 @@ + + + diff --git a/resources/js/components/ui/table/TableHead.vue b/resources/js/components/ui/table/TableHead.vue new file mode 100644 index 0000000..f83efe5 --- /dev/null +++ b/resources/js/components/ui/table/TableHead.vue @@ -0,0 +1,17 @@ + + + diff --git a/resources/js/components/ui/table/TableHeader.vue b/resources/js/components/ui/table/TableHeader.vue new file mode 100644 index 0000000..b4ab5cf --- /dev/null +++ b/resources/js/components/ui/table/TableHeader.vue @@ -0,0 +1,17 @@ + + + diff --git a/resources/js/components/ui/table/TableRow.vue b/resources/js/components/ui/table/TableRow.vue new file mode 100644 index 0000000..8f1d172 --- /dev/null +++ b/resources/js/components/ui/table/TableRow.vue @@ -0,0 +1,17 @@ + + + diff --git a/resources/js/components/ui/table/index.ts b/resources/js/components/ui/table/index.ts new file mode 100644 index 0000000..3be308b --- /dev/null +++ b/resources/js/components/ui/table/index.ts @@ -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" diff --git a/resources/js/components/ui/table/utils.ts b/resources/js/components/ui/table/utils.ts new file mode 100644 index 0000000..3d4fd12 --- /dev/null +++ b/resources/js/components/ui/table/utils.ts @@ -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(updaterOrValue: Updater, ref: Ref) { + ref.value = isFunction(updaterOrValue) + ? updaterOrValue(ref.value) + : updaterOrValue +} diff --git a/resources/js/pages/Dashboard.vue b/resources/js/pages/Dashboard.vue index 58dafd2..8c42d69 100644 --- a/resources/js/pages/Dashboard.vue +++ b/resources/js/pages/Dashboard.vue @@ -1,57 +1,46 @@