From e6921096fa527dac2dbfe013cfa3e2cfecb0a755 Mon Sep 17 00:00:00 2001 From: Ariful Hoque Date: Tue, 23 Jun 2026 19:23:01 +0600 Subject: [PATCH 1/8] feat(dashboard): add specialized role-scoped dashboard as home page Add a new React dashboard (landing page) backed by a single role-scoped REST endpoint, plus a WP-admin "Dashboard" submenu link. Backend (pm/v2/dashboard, pm/v2/dashboard/heatmap): - Aggregates: KPIs, project status, task performance (7/30d), distribution, upcoming tasks, overdue/priority, milestones, recent activity, team, and a GitHub-style productivity heatmap with a year filter. - Three scope tiers: admin -> full org, manager -> their projects, member -> own assigned tasks. All figures read live from pm_* tables (no mock data). Frontend (views/assets/src/components/dashboard): - shadcn/ui + Recharts; brand-accent theme; Mona Sans font (matches WP ERP). - Local shadcn Calendar wrapper (react-day-picker v9) for the mini calendar. - Every card lazy-loaded behind its own Suspense boundary. - New /dashboard route is the landing page; sidebar + WP submenu link added. Co-Authored-By: Claude Opus 4.8 (1M context) --- core/WP/Menu.php | 8 +- package.json | 1 + pnpm-lock.yaml | 38 + routes/dashboard.php | 13 + .../Controllers/Dashboard_Controller.php | 692 ++++++++++++++++++ tailwind.config.js | 5 +- .../components/dashboard/DashboardPage.jsx | 151 ++++ .../dashboard/parts/ActiveProjectsCard.jsx | 69 ++ .../dashboard/parts/DashboardHeader.jsx | 61 ++ .../components/dashboard/parts/KpiCards.jsx | 48 ++ .../dashboard/parts/MilestonesCard.jsx | 42 ++ .../dashboard/parts/MiniCalendarCard.jsx | 137 ++++ .../dashboard/parts/OverduePriorityCard.jsx | 52 ++ .../dashboard/parts/ProInsightsRow.jsx | 90 +++ .../dashboard/parts/ProUpgradeCard.jsx | 45 ++ .../parts/ProductivityHeatmapCard.jsx | 153 ++++ .../dashboard/parts/ProjectStatusCard.jsx | 110 +++ .../dashboard/parts/RecentActivityCard.jsx | 44 ++ .../components/dashboard/parts/StatCard.jsx | 53 ++ .../dashboard/parts/TaskDistributionCard.jsx | 67 ++ .../dashboard/parts/TaskPerformanceCard.jsx | 67 ++ .../dashboard/parts/TeamStatusCard.jsx | 49 ++ .../dashboard/parts/UpcomingScheduleCard.jsx | 62 ++ .../src/components/layout/AppSidebar.jsx | 3 + views/assets/src/components/ui/calendar.jsx | 82 +++ views/assets/src/index.jsx | 6 +- views/assets/src/tailwind.css | 13 +- 27 files changed, 2150 insertions(+), 11 deletions(-) create mode 100644 routes/dashboard.php create mode 100644 src/Dashboard/Controllers/Dashboard_Controller.php create mode 100644 views/assets/src/components/dashboard/DashboardPage.jsx create mode 100644 views/assets/src/components/dashboard/parts/ActiveProjectsCard.jsx create mode 100644 views/assets/src/components/dashboard/parts/DashboardHeader.jsx create mode 100644 views/assets/src/components/dashboard/parts/KpiCards.jsx create mode 100644 views/assets/src/components/dashboard/parts/MilestonesCard.jsx create mode 100644 views/assets/src/components/dashboard/parts/MiniCalendarCard.jsx create mode 100644 views/assets/src/components/dashboard/parts/OverduePriorityCard.jsx create mode 100644 views/assets/src/components/dashboard/parts/ProInsightsRow.jsx create mode 100644 views/assets/src/components/dashboard/parts/ProUpgradeCard.jsx create mode 100644 views/assets/src/components/dashboard/parts/ProductivityHeatmapCard.jsx create mode 100644 views/assets/src/components/dashboard/parts/ProjectStatusCard.jsx create mode 100644 views/assets/src/components/dashboard/parts/RecentActivityCard.jsx create mode 100644 views/assets/src/components/dashboard/parts/StatCard.jsx create mode 100644 views/assets/src/components/dashboard/parts/TaskDistributionCard.jsx create mode 100644 views/assets/src/components/dashboard/parts/TaskPerformanceCard.jsx create mode 100644 views/assets/src/components/dashboard/parts/TeamStatusCard.jsx create mode 100644 views/assets/src/components/dashboard/parts/UpcomingScheduleCard.jsx create mode 100644 views/assets/src/components/ui/calendar.jsx diff --git a/core/WP/Menu.php b/core/WP/Menu.php index c82d0a9fe..dbce9eed2 100644 --- a/core/WP/Menu.php +++ b/core/WP/Menu.php @@ -19,8 +19,12 @@ public static function admin_menu() { $home = add_menu_page( __( 'Project Manager', 'wedevs-project-manager' ), __( 'Project Manager', 'wedevs-project-manager' ), self::$capability, $slug, array( new Output, 'home_page' ), self::pm_svg(), 3 ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Intentionally adding custom submenu items to WordPress admin menu - // 1. Projects - $submenu[$slug][] = [ __( 'Projects', 'wedevs-project-manager' ), self::$capability, "admin.php?page={$slug}#/" ]; + // 1. Dashboard (landing page) + $submenu[$slug][] = [ __( 'Dashboard', 'wedevs-project-manager' ), self::$capability, "admin.php?page={$slug}#/dashboard" ]; + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Intentionally adding custom submenu items to WordPress admin menu + // 2. Projects + $submenu[$slug][] = [ __( 'Projects', 'wedevs-project-manager' ), self::$capability, "admin.php?page={$slug}#/projects" ]; // 2. My Tasks $active_task = self::my_task_count(); diff --git a/package.json b/package.json index 1ddbeab7f..c0d15037c 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "lucide-react": "^0.400.0", "react": "^18.3.0", "react-chartjs-2": "^5.2.0", + "react-day-picker": "9", "react-dom": "^18.3.0", "react-redux": "^9.1.0", "react-router-dom": "^6.23.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ba1aa3f7..169b4391a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -167,6 +167,9 @@ importers: react-chartjs-2: specifier: ^5.2.0 version: 5.3.1(chart.js@4.5.1)(react@18.3.1) + react-day-picker: + specifier: '9' + version: 9.14.0(react@18.3.1) react-dom: specifier: ^18.3.0 version: 18.3.1(react@18.3.1) @@ -936,6 +939,9 @@ packages: peerDependencies: postcss-selector-parser: ^6.0.10 + '@date-fns/tz@1.5.0': + resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} + '@discoveryjs/json-ext@0.5.7': resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} engines: {node: '>=10.0.0'} @@ -2160,6 +2166,10 @@ packages: resolution: {integrity: sha512-LnhVjMWyMQV9ZmeEy26maJk+8HTIbd59cH4F2MJ439k9DqejRisfFNGAPvRYlKETuh9LrImlS8aKsBgKjMA8WA==} engines: {node: '>=14'} + '@tabby_ai/hijri-converter@1.0.5': + resolution: {integrity: sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==} + engines: {node: '>=16.0.0'} + '@tailwindcss/forms@0.5.11': resolution: {integrity: sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==} peerDependencies: @@ -3787,9 +3797,15 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + date-fns-jalali@4.1.0-0: + resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==} + date-fns@3.6.0: resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==} + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} @@ -6685,6 +6701,12 @@ packages: chart.js: ^4.1.1 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-day-picker@9.14.0: + resolution: {integrity: sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA==} + engines: {node: '>=18'} + peerDependencies: + react: '>=16.8.0' + react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: @@ -9567,6 +9589,8 @@ snapshots: dependencies: postcss-selector-parser: 6.1.2 + '@date-fns/tz@1.5.0': {} + '@discoveryjs/json-ext@0.5.7': {} '@dnd-kit/accessibility@3.1.1(react@18.3.1)': @@ -10875,6 +10899,8 @@ snapshots: - supports-color - typescript + '@tabby_ai/hijri-converter@1.0.5': {} + '@tailwindcss/forms@0.5.11(tailwindcss@3.4.19)': dependencies: mini-svg-data-uri: 1.4.4 @@ -12878,8 +12904,12 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + date-fns-jalali@4.1.0-0: {} + date-fns@3.6.0: {} + date-fns@4.4.0: {} + dateformat@4.6.3: {} debounce@1.2.1: {} @@ -16183,6 +16213,14 @@ snapshots: chart.js: 4.5.1 react: 18.3.1 + react-day-picker@9.14.0(react@18.3.1): + dependencies: + '@date-fns/tz': 1.5.0 + '@tabby_ai/hijri-converter': 1.0.5 + date-fns: 4.4.0 + date-fns-jalali: 4.1.0-0 + react: 18.3.1 + react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 diff --git a/routes/dashboard.php b/routes/dashboard.php new file mode 100644 index 000000000..c651af9aa --- /dev/null +++ b/routes/dashboard.php @@ -0,0 +1,13 @@ +get( 'dashboard', 'WeDevs/PM/Dashboard/Controllers/Dashboard_Controller@index' ) + ->permission( ['WeDevs\PM\Core\Permissions\Authentic'] ); + +// GET /wp-json/pm/v2/dashboard/heatmap?year=YYYY — productivity heatmap (self-fetched by the card). +$wedevs_pm_router->get( 'dashboard/heatmap', 'WeDevs/PM/Dashboard/Controllers/Dashboard_Controller@heatmap_data' ) + ->permission( ['WeDevs\PM\Core\Permissions\Authentic'] ); diff --git a/src/Dashboard/Controllers/Dashboard_Controller.php b/src/Dashboard/Controllers/Dashboard_Controller.php new file mode 100644 index 000000000..6421f6674 --- /dev/null +++ b/src/Dashboard/Controllers/Dashboard_Controller.php @@ -0,0 +1,692 @@ +user_id = get_current_user_id(); + $this->is_admin = wedevs_pm_has_admin_capability(); + $this->is_manager = wedevs_pm_has_manage_capability(); + + // Everyone except a full admin is limited to the projects they belong to. + if ( ! $this->is_admin ) { + $this->project_ids = $this->scoped_project_ids(); + } + } + + public function index( WP_REST_Request $request ) { + $this->boot(); + + // Performance chart window — 7 / 30 days (default 7). + $range = (int) $request->get_param( 'range' ); + $days = in_array( $range, [ 7, 30 ], true ) ? $range : 7; + + $data = [ + 'user' => $this->user_block(), + 'range' => $days, + 'kpis' => $this->kpis(), + 'projects_status' => $this->projects_status(), + 'performance' => $this->performance( $days ), + 'task_distribution' => $this->task_distribution(), + 'upcoming' => $this->upcoming_tasks(), + 'overdue_list' => $this->overdue_tasks(), + 'calendar' => $this->calendar_month(), + 'active_projects' => $this->active_projects(), + 'recent_activity' => $this->recent_activity(), + 'milestones' => $this->upcoming_milestones(), + 'team' => ( $this->is_admin || $this->is_manager ) ? $this->team_status() : [], + 'generated_at' => current_time( 'mysql' ), + ]; + + return rest_ensure_response( [ 'data' => $data ] ); + } + + // ────────────────────────────────────────────────────────────────── + // Scope helpers + // ────────────────────────────────────────────────────────────────── + + protected function scoped_project_ids() { + return User_Role::where( 'user_id', $this->user_id ) + ->distinct() + ->pluck( 'project_id' ) + ->map( 'absint' ) + ->all(); + } + + /** @return array project ids the caller is limited to (admin = no limit). */ + protected function scope_ids() { + return empty( $this->project_ids ) ? [ 0 ] : $this->project_ids; + } + + /** + * Base parent-task query honouring the caller's scope tier. + */ + protected function task_query() { + $query = Task::parent(); + + if ( $this->is_admin ) { + return $query; + } + + // Manager + member: limit to their projects. + $query->whereIn( 'project_id', $this->scope_ids() ); + + // Member only: further limit to tasks assigned to them. + if ( ! $this->is_manager ) { + $query->whereHas( 'assignees', function ( $a ) { + $a->where( 'assigned_to', $this->user_id ); + } ); + } + + return $query; + } + + protected function project_query() { + $query = Project::query(); + + if ( ! $this->is_admin ) { + $query->whereIn( 'id', $this->scope_ids() ); + } + + return $query; + } + + // ────────────────────────────────────────────────────────────────── + // Widgets + // ────────────────────────────────────────────────────────────────── + + protected function user_block() { + $user = wp_get_current_user(); + + if ( wedevs_pm_has_admin_capability() ) { + $role_label = __( 'Administrator', 'wedevs-project-manager' ); + } elseif ( $this->is_manager || wedevs_pm_current_user_is_manager_anywhere() ) { + $role_label = __( 'Project Manager', 'wedevs-project-manager' ); + } else { + $role_label = __( 'Team Member', 'wedevs-project-manager' ); + } + + return [ + 'id' => $this->user_id, + 'name' => $user->display_name, + 'role_label' => $role_label, + 'avatar_url' => Avatar::get_url( $this->user_id ), + ]; + } + + protected function kpis() { + $today = Carbon::today(); + + $total = (clone $this->task_query())->count(); + $completed = (clone $this->task_query())->where( 'status', Task::COMPLETE )->count(); + $in_progress = (clone $this->task_query())->where( 'status', Task::INCOMPLETE )->count(); + $pending = (clone $this->task_query())->where( 'status', Task::PENDING )->count(); + $overdue = (clone $this->task_query()) + ->where( 'status', '!=', Task::COMPLETE ) + ->whereNotNull( 'due_date' ) + ->whereDate( 'due_date', '<', $today ) + ->count(); + + // Trend: tasks completed in the last 7 days vs the prior 7 days. + $this_week = (clone $this->task_query()) + ->where( 'status', Task::COMPLETE ) + ->whereDate( 'completed_at', '>=', $today->copy()->subDays( 7 ) ) + ->count(); + $last_week = (clone $this->task_query()) + ->where( 'status', Task::COMPLETE ) + ->whereDate( 'completed_at', '>=', $today->copy()->subDays( 14 ) ) + ->whereDate( 'completed_at', '<', $today->copy()->subDays( 7 ) ) + ->count(); + + return [ + 'total_tasks' => $total, + 'completed' => $completed, + 'in_progress' => $in_progress, + 'pending' => $pending, + 'overdue' => $overdue, + 'completion_rate' => $total > 0 ? round( ( $completed / $total ) * 100 ) : 0, + 'completed_trend' => $this->trend( $this_week, $last_week ), + ]; + } + + protected function projects_status() { + $today = Carbon::today(); + $projects = (clone $this->project_query()) + ->withCount( [ + 'tasks as overdue_count' => function ( $q ) use ( $today ) { + $q->where( 'parent_id', 0 ) + ->where( 'status', '!=', Task::COMPLETE ) + ->whereNotNull( 'due_date' ) + ->whereDate( 'due_date', '<', $today ); + }, + ] ) + ->get( [ 'id', 'status' ] ); + + $total = $projects->count(); + $completed = $on_track = $at_risk = $archived = 0; + + foreach ( $projects as $p ) { + if ( (int) $p->status === Project::COMPLETE ) { + $completed++; + } elseif ( (int) $p->status === Project::ARCHIVED ) { + $archived++; + } elseif ( $p->overdue_count > 0 ) { + $at_risk++; + } else { + $on_track++; + } + } + + return [ + 'total' => $total, + 'on_track' => $on_track, + 'at_risk' => $at_risk, + 'completed' => $completed, + 'archived' => $archived, + ]; + } + + /** + * Created vs completed parent tasks per day over the requested window + * (7 or 30 days) for the bar chart. + */ + protected function performance( $days = 7 ) { + $rows = []; + $label = $days > 7 ? 'M j' : 'D'; + + for ( $i = $days - 1; $i >= 0; $i-- ) { + $day = Carbon::today()->subDays( $i ); + $start = $day->copy()->startOfDay(); + $end = $day->copy()->endOfDay(); + + $created = (clone $this->task_query()) + ->whereBetween( 'created_at', [ $start, $end ] ) + ->count(); + + $completed = (clone $this->task_query()) + ->where( 'status', Task::COMPLETE ) + ->whereBetween( 'completed_at', [ $start, $end ] ) + ->count(); + + $rows[] = [ + 'label' => $day->format( $label ), + 'date' => $day->format( 'Y-m-d' ), + 'created' => $created, + 'completed' => $completed, + ]; + } + + return $rows; + } + + /** + * Past-due, not-complete tasks (actionable priority list). + */ + protected function overdue_tasks() { + $today = Carbon::today(); + + $tasks = (clone $this->task_query()) + ->with( 'projects:id,title' ) + ->where( 'status', '!=', Task::COMPLETE ) + ->whereNotNull( 'due_date' ) + ->whereDate( 'due_date', '<', $today ) + ->orderBy( 'priority', 'DESC' ) + ->orderBy( 'due_date', 'ASC' ) + ->limit( 6 ) + ->get( [ 'id', 'title', 'due_date', 'priority', 'project_id' ] ); + + return $tasks->map( function ( $t ) use ( $today ) { + $due = Carbon::parse( $t->due_date )->startOfDay(); + $days_over = $due->diffInDays( $today ); + + return [ + 'id' => absint( $t->id ), + 'title' => $t->title, + 'priority' => $t->priority, + 'days_overdue' => $days_over, + 'project_id' => absint( $t->project_id ), + 'project_title' => $t->projects ? $t->projects->title : '', + ]; + } )->all(); + } + + /** + * REST endpoint: productivity heatmap, optionally for a specific calendar + * year. No `year` → rolling last 53 weeks. + */ + public function heatmap_data( WP_REST_Request $request ) { + $this->boot(); + + $year = $request->get_param( 'year' ); + $year = ( $year && intval( $year ) > 1970 ) ? intval( $year ) : null; + + return rest_ensure_response( [ 'data' => $this->heatmap( $year ) ] ); + } + + /** + * Activity counts per day (productivity heatmap). + * + * @param int|null $year Calendar year, or null for the rolling 53 weeks. + */ + protected function heatmap( $year = null ) { + if ( $year ) { + // Full calendar year — future days render as empty cells. + $start = Carbon::create( $year, 1, 1 )->startOfDay(); + $end = Carbon::create( $year, 12, 31 )->startOfDay(); + } else { + $end = Carbon::today(); + $start = $end->copy()->subDays( 7 * 53 - 1 )->startOfDay(); + } + + $query = \WeDevs\PM\Activity\Models\Activity::query() + ->where( 'created_at', '>=', $start->copy()->startOfDay() ) + ->where( 'created_at', '<=', $end->copy()->endOfDay() ); + + if ( ! $this->is_admin ) { + $query->whereIn( 'project_id', $this->scope_ids() ); + } + + $rows = $query->get( [ 'created_at' ] ); + + $counts = []; + foreach ( $rows as $r ) { + $d = Carbon::parse( $r->created_at )->format( 'Y-m-d' ); + $counts[ $d ] = isset( $counts[ $d ] ) ? $counts[ $d ] + 1 : 1; + } + + $days = []; + $total = 0; + $cursor = $start->copy(); + while ( $cursor->lte( $end ) ) { + $d = $cursor->format( 'Y-m-d' ); + $c = isset( $counts[ $d ] ) ? $counts[ $d ] : 0; + $days[] = [ 'date' => $d, 'count' => $c ]; + if ( $c > 0 ) { + $total++; + } + $cursor->addDay(); + } + + return [ + 'days' => $days, + 'active_days' => $total, + 'selected_year' => $year, + 'years' => $this->heatmap_years(), + ]; + } + + /** Distinct years that have activity in scope (desc), incl. current year. */ + protected function heatmap_years() { + $query = \WeDevs\PM\Activity\Models\Activity::query(); + + if ( ! $this->is_admin ) { + $query->whereIn( 'project_id', $this->scope_ids() ); + } + + $earliest = $query->min( 'created_at' ); + $current = (int) Carbon::today()->format( 'Y' ); + $first = $earliest ? (int) Carbon::parse( $earliest )->format( 'Y' ) : $current; + + $years = []; + for ( $y = $current; $y >= $first; $y-- ) { + $years[] = $y; + } + + return $years; + } + + protected function task_distribution() { + return [ + 'completed' => (clone $this->task_query())->where( 'status', Task::COMPLETE )->count(), + 'in_progress' => (clone $this->task_query())->where( 'status', Task::INCOMPLETE )->count(), + 'pending' => (clone $this->task_query())->where( 'status', Task::PENDING )->count(), + ]; + } + + protected function upcoming_tasks() { + $today = Carbon::today(); + + $tasks = (clone $this->task_query()) + ->with( 'projects:id,title' ) + ->where( 'status', '!=', Task::COMPLETE ) + ->whereNotNull( 'due_date' ) + ->whereDate( 'due_date', '>=', $today ) + ->orderBy( 'due_date', 'ASC' ) + ->limit( 6 ) + ->get( [ 'id', 'title', 'due_date', 'status', 'priority', 'project_id' ] ); + + return $tasks->map( function ( $t ) { + $due = $t->due_date ? wedevs_pm_format_date( $t->due_date ) : null; + + return [ + 'id' => absint( $t->id ), + 'title' => $t->title, + 'due_date' => is_array( $due ) ? ( $due['formatted_date'] ?? $due['date'] ?? null ) : $due, + 'priority' => $t->priority, + 'project_id' => absint( $t->project_id ), + 'project_title' => $t->projects ? $t->projects->title : '', + ]; + } )->all(); + } + + /** + * Due-task load per day for the current month (for the mini calendar). + * Returns each day that has incomplete tasks due, with totals and an + * overdue marker so the widget can colour past-due days. + */ + protected function calendar_month() { + $today = Carbon::today(); + $month_start = $today->copy()->startOfMonth(); + $month_end = $today->copy()->endOfMonth(); + + $tasks = (clone $this->task_query()) + ->where( 'status', '!=', Task::COMPLETE ) + ->whereNotNull( 'due_date' ) + ->whereBetween( 'due_date', [ $month_start, $month_end->copy()->endOfDay() ] ) + ->get( [ 'id', 'due_date' ] ); + + $days = []; + + foreach ( $tasks as $t ) { + $date = Carbon::parse( $t->due_date )->format( 'Y-m-d' ); + + if ( ! isset( $days[ $date ] ) ) { + $days[ $date ] = [ 'total' => 0, 'overdue' => false ]; + } + + $days[ $date ]['total']++; + + if ( Carbon::parse( $date )->lt( $today ) ) { + $days[ $date ]['overdue'] = true; + } + } + + $out = []; + foreach ( $days as $date => $info ) { + $out[] = [ + 'date' => $date, + 'total' => $info['total'], + 'overdue' => $info['overdue'], + ]; + } + + return [ + 'month' => $today->format( 'Y-m' ), + 'today' => $today->format( 'Y-m-d' ), + 'days' => $out, + ]; + } + + /** + * Scoped projects with completion progress + risk flag (Critical Projects). + */ + protected function active_projects() { + $today = Carbon::today(); + + $projects = (clone $this->project_query()) + ->where( 'status', '!=', Project::COMPLETE ) + ->where( 'status', '!=', Project::ARCHIVED ) + ->withCount( [ + 'tasks as total_tasks' => function ( $q ) { + $q->where( 'parent_id', 0 ); + }, + 'tasks as completed_tasks' => function ( $q ) { + $q->where( 'parent_id', 0 )->where( 'status', Task::COMPLETE ); + }, + 'tasks as overdue_tasks' => function ( $q ) use ( $today ) { + $q->where( 'parent_id', 0 ) + ->where( 'status', '!=', Task::COMPLETE ) + ->whereNotNull( 'due_date' ) + ->whereDate( 'due_date', '<', $today ); + }, + ] ) + ->get( [ 'id', 'title', 'status', 'color_code' ] ); + + $rows = $projects->map( function ( $p ) { + $total = (int) $p->total_tasks; + $done = (int) $p->completed_tasks; + $pct = $total > 0 ? (int) round( ( $done / $total ) * 100 ) : 0; + + return [ + 'id' => absint( $p->id ), + 'title' => $p->title, + 'color' => $p->color_code ?: '', + 'progress' => $pct, + 'total' => $total, + 'completed' => $done, + 'overdue' => (int) $p->overdue_tasks, + 'risk' => $p->overdue_tasks > 0 ? 'at_risk' : 'on_track', + ]; + } )->all(); + + // Busiest / riskiest first. + usort( $rows, function ( $a, $b ) { + return ( $b['overdue'] <=> $a['overdue'] ) ?: ( $b['total'] <=> $a['total'] ); + } ); + + return array_slice( $rows, 0, 6 ); + } + + /** + * Latest activity across scoped projects (timeline feed). + */ + protected function recent_activity() { + $query = \WeDevs\PM\Activity\Models\Activity::with( [ 'actor', 'project' ] ) + ->orderBy( 'created_at', 'DESC' ); + + if ( ! $this->is_admin ) { + $query->whereIn( 'project_id', $this->scope_ids() ); + } + + $items = $query->limit( 10 )->get(); + + return $items->map( function ( $a ) { + $actor = $a->actor ? $a->actor->display_name : __( 'Someone', 'wedevs-project-manager' ); + + return [ + 'id' => absint( $a->id ), + 'actor' => $actor, + 'avatar_url' => $a->actor_id ? Avatar::get_url( $a->actor_id ) : '', + 'action' => $this->humanize_action( (string) $a->action, (string) $a->action_type ), + 'project' => $a->project ? $a->project->title : '', + 'time' => $this->human_time( $a->created_at ), + ]; + } )->all(); + } + + /** + * Upcoming, not-yet-complete milestones with completion progress. + */ + protected function upcoming_milestones() { + $query = \WeDevs\PM\Milestone\Models\Milestone::with( [ 'achieve_date_field', 'project' ] ) + ->where( 'status', '!=', \WeDevs\PM\Milestone\Models\Milestone::COMPLETE ) + ->withCount( [ + 'tasks as total_tasks', + 'tasks as completed_tasks' => function ( $q ) { + $q->where( 'status', Task::COMPLETE ); + }, + ] ); + + if ( ! $this->is_admin ) { + $query->whereIn( 'project_id', $this->scope_ids() ); + } + + $milestones = $query->get(); + + $rows = []; + foreach ( $milestones as $m ) { + $achieve = $m->achieve_date; // Carbon|null via accessor + $total = (int) $m->total_tasks; + $done = (int) $m->completed_tasks; + + $rows[] = [ + 'id' => absint( $m->id ), + 'title' => $m->title, + 'project' => $m->project ? $m->project->title : '', + 'due_date' => $achieve ? $achieve->format( 'M j, Y' ) : null, + 'ts' => $achieve ? $achieve->timestamp : PHP_INT_MAX, + 'progress' => $total > 0 ? (int) round( ( $done / $total ) * 100 ) : 0, + ]; + } + + usort( $rows, function ( $a, $b ) { + return $a['ts'] <=> $b['ts']; + } ); + + $rows = array_slice( $rows, 0, 5 ); + foreach ( $rows as &$r ) { + unset( $r['ts'] ); + } + + return $rows; + } + + /** + * Turn raw activity action keys (e.g. "create_task" / "create") into a + * readable phrase like "created a task". + */ + protected function humanize_action( $action, $action_type ) { + $source = $action ?: $action_type; + $parts = explode( '_', $source ); + $verb = array_shift( $parts ); + $noun = trim( str_replace( '_', ' ', implode( ' ', $parts ) ) ); + + $past = [ + 'create' => __( 'created', 'wedevs-project-manager' ), + 'update' => __( 'updated', 'wedevs-project-manager' ), + 'delete' => __( 'deleted', 'wedevs-project-manager' ), + 'complete' => __( 'completed', 'wedevs-project-manager' ), + 'new' => __( 'added', 'wedevs-project-manager' ), + 'add' => __( 'added', 'wedevs-project-manager' ), + 'assign' => __( 'assigned', 'wedevs-project-manager' ), + ]; + + $verb = isset( $past[ $verb ] ) ? $past[ $verb ] : $verb; + + return trim( $verb . ( $noun ? ' ' . $noun : '' ) ); + } + + protected function human_time( $datetime ) { + if ( empty( $datetime ) ) { + return ''; + } + $ts = is_numeric( $datetime ) ? $datetime : strtotime( $datetime ); + return sprintf( + /* translators: %s: human-readable time difference */ + __( '%s ago', 'wedevs-project-manager' ), + human_time_diff( $ts, current_time( 'timestamp' ) ) + ); + } + + /** + * Per-member active/completed task counts (managers only). + */ + protected function team_status() { + // Admin → all projects; manager → their projects (project_query scopes it). + $project_ids = (clone $this->project_query())->pluck( 'id' )->all(); + + if ( empty( $project_ids ) ) { + return []; + } + + $members = User_Role::whereIn( 'project_id', $project_ids ) + ->distinct() + ->pluck( 'user_id' ) + ->map( 'absint' ) + ->take( 12 ) + ->all(); + + $team = []; + + foreach ( $members as $uid ) { + $base = Task::parent() + ->whereIn( 'project_id', $project_ids ) + ->whereHas( 'assignees', function ( $a ) use ( $uid ) { + $a->where( 'assigned_to', $uid ); + } ); + + $active = (clone $base)->where( 'status', '!=', Task::COMPLETE )->count(); + $completed = (clone $base)->where( 'status', Task::COMPLETE )->count(); + + if ( $active === 0 && $completed === 0 ) { + continue; + } + + $wp_user = get_userdata( $uid ); + if ( ! $wp_user ) { + continue; + } + + $team[] = [ + 'id' => $uid, + 'name' => $wp_user->display_name, + 'avatar_url' => Avatar::get_url( $uid ), + 'active' => $active, + 'completed' => $completed, + ]; + } + + // Busiest first. + usort( $team, function ( $a, $b ) { + return $b['active'] <=> $a['active']; + } ); + + return array_slice( $team, 0, 8 ); + } + + protected function trend( $current, $previous ) { + if ( $previous <= 0 ) { + return [ + 'direction' => $current > 0 ? 'up' : 'flat', + 'percent' => $current > 0 ? 100 : 0, + ]; + } + + $delta = ( ( $current - $previous ) / $previous ) * 100; + + return [ + 'direction' => $delta > 0 ? 'up' : ( $delta < 0 ? 'down' : 'flat' ), + 'percent' => abs( round( $delta ) ), + ]; + } +} diff --git a/tailwind.config.js b/tailwind.config.js index d88274605..1c05ad8e0 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -85,7 +85,8 @@ module.exports = { sm: 'calc(var(--radius) - 4px)', }, fontFamily: { - pm: ['Inter', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'], + pm: ['Mona Sans', 'Inter', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'], + sans: ['Mona Sans', 'Inter', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'], }, spacing: { 'pm-sidebar': '240px', @@ -116,5 +117,5 @@ module.exports = { require('@tailwindcss/typography'), require('tailwindcss-animate'), ], -}; +}; diff --git a/views/assets/src/components/dashboard/DashboardPage.jsx b/views/assets/src/components/dashboard/DashboardPage.jsx new file mode 100644 index 000000000..a1bb5cc3d --- /dev/null +++ b/views/assets/src/components/dashboard/DashboardPage.jsx @@ -0,0 +1,151 @@ +import { __ } from '@wordpress/i18n' +import React, { useEffect, useState, useCallback, Suspense } from 'react' +import { useApi } from '@hooks/useApi' +import { usePermissions } from '@hooks/usePermissions' +import { Skeleton } from '@components/ui/skeleton' + +// ── Header is eager (above the fold, tiny). Everything else is lazy-loaded +// into its own chunk and streamed in behind a Skeleton fallback. ── +import DashboardHeader from './parts/DashboardHeader' + +const KpiCards = React.lazy(() => import('./parts/KpiCards')) +const ProInsightsRow = React.lazy(() => import('./parts/ProInsightsRow')) +const TaskPerformanceCard = React.lazy(() => import('./parts/TaskPerformanceCard')) +const ProjectStatusCard = React.lazy(() => import('./parts/ProjectStatusCard')) +const ActiveProjectsCard = React.lazy(() => import('./parts/ActiveProjectsCard')) +const MiniCalendarCard = React.lazy(() => import('./parts/MiniCalendarCard')) +const ProductivityHeatmapCard = React.lazy(() => import('./parts/ProductivityHeatmapCard')) +const UpcomingScheduleCard = React.lazy(() => import('./parts/UpcomingScheduleCard')) +const MilestonesCard = React.lazy(() => import('./parts/MilestonesCard')) +const RecentActivityCard = React.lazy(() => import('./parts/RecentActivityCard')) +const OverduePriorityCard = React.lazy(() => import('./parts/OverduePriorityCard')) +const TaskDistributionCard = React.lazy(() => import('./parts/TaskDistributionCard')) +const TeamStatusCard = React.lazy(() => import('./parts/TeamStatusCard')) +const ProUpgradeCard = React.lazy(() => import('./parts/ProUpgradeCard')) + +// Lazy card wrapper — own Suspense boundary so one card streaming in never +// blocks the others. +function Lazy({ h = 'h-72', children }) { + return }>{children} +} + +function LoadingState() { + return ( +
+ +
+ {Array.from({ length: 5 }).map((_, i) => )} +
+
+ + +
+
+ ) +} + +export default function DashboardPage() { + const api = useApi() + const { isPro, canManage, canCreate, isManagerAnywhere } = usePermissions() + + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [refetching, setRefetching] = useState(false) + const [range, setRange] = useState(7) + const [error, setError] = useState(null) + + const load = useCallback(async (rangeArg = 7, isInitial = false) => { + if (isInitial) setLoading(true) + else setRefetching(true) + setError(null) + try { + const res = await api.get('dashboard', { range: rangeArg }) + setData(res?.data ?? res) + } catch (e) { + setError(e?.message || __('Failed to load dashboard.', 'wedevs-project-manager')) + } finally { + setLoading(false) + setRefetching(false) + } + }, [api]) + + useEffect(() => { load(range, true) }, []) // eslint-disable-line react-hooks/exhaustive-deps + + const onRangeChange = useCallback((r) => { + setRange(r) + load(r, false) + }, [load]) + + if (loading) { + return
+ } + + if (error) { + return ( +
+
+

{error}

+ +
+
+ ) + } + + const showTeam = canManage || isManagerAnywhere + const team = data?.team ?? [] + + return ( +
+ + + {/* Pro insights (module-gated, managers) */} + {showTeam && isPro && } + + + + {/* Performance (wide, range filter) + project status */} +
+
+ + + +
+ +
+ + {/* Active projects (wide) + calendar */} +
+
+ +
+ +
+ + {/* Productivity heatmap (full width, self-fetches by year) */} + + + {/* Upcoming + milestones + activity */} +
+ + + +
+ + {/* Overdue + distribution + team / upgrade */} +
+ + + {showTeam + ? + : (!isPro ? : null)} +
+
+ ) +} diff --git a/views/assets/src/components/dashboard/parts/ActiveProjectsCard.jsx b/views/assets/src/components/dashboard/parts/ActiveProjectsCard.jsx new file mode 100644 index 000000000..79c997ff2 --- /dev/null +++ b/views/assets/src/components/dashboard/parts/ActiveProjectsCard.jsx @@ -0,0 +1,69 @@ +import { __ } from '@wordpress/i18n' +import { useNavigate } from 'react-router-dom' +import { FolderKanban } from 'lucide-react' +import { Card } from '@components/ui/card' +import { Progress } from '@components/ui/progress' +import { Badge } from '@components/ui/badge' +import { cn } from '@lib/utils' + +export default function ActiveProjectsCard({ projects }) { + const navigate = useNavigate() + const list = projects || [] + + return ( + +
+

+ + {__('Active Projects', 'wedevs-project-manager')} +

+ +
+ + {list.length === 0 ? ( +
+ +

{__('No active projects.', 'wedevs-project-manager')}

+
+ ) : ( +
+ {list.map(p => ( + + ))} +
+ )} +
+ ) +} diff --git a/views/assets/src/components/dashboard/parts/DashboardHeader.jsx b/views/assets/src/components/dashboard/parts/DashboardHeader.jsx new file mode 100644 index 000000000..85917d953 --- /dev/null +++ b/views/assets/src/components/dashboard/parts/DashboardHeader.jsx @@ -0,0 +1,61 @@ +import { __ } from '@wordpress/i18n' +import { useNavigate } from 'react-router-dom' +import { Plus, CheckSquare, Flag } from 'lucide-react' +import { Button } from '@components/ui/button' +import { UserAvatar } from '@components/common/UserAvatar' + +function greeting() { + const h = new Date().getHours() + if (h < 12) return __('Good morning', 'wedevs-project-manager') + if (h < 18) return __('Good afternoon', 'wedevs-project-manager') + return __('Good evening', 'wedevs-project-manager') +} + +export default function DashboardHeader({ user, canCreate }) { + const navigate = useNavigate() + const name = user?.name || '' + const firstName = name.split(' ')[0] + + const today = new Date().toLocaleDateString(undefined, { + weekday: 'long', month: 'short', day: 'numeric', year: 'numeric', + }) + + return ( +
+
+ {user && ( + + )} +
+

+ {greeting()}{firstName ? `, ${firstName}` : ''} 👋 +

+

+ {user?.role_label ? `${user.role_label} · ` : ''}{today} +

+
+
+ + {canCreate && ( +
+ + + +
+ )} +
+ ) +} diff --git a/views/assets/src/components/dashboard/parts/KpiCards.jsx b/views/assets/src/components/dashboard/parts/KpiCards.jsx new file mode 100644 index 000000000..0f48bcd86 --- /dev/null +++ b/views/assets/src/components/dashboard/parts/KpiCards.jsx @@ -0,0 +1,48 @@ +import { __ } from '@wordpress/i18n' +import { ListChecks, Loader2, Clock3, AlertTriangle, CheckCircle2 } from 'lucide-react' +import { useNavigate } from 'react-router-dom' +import StatCard from './StatCard' + +export default function KpiCards({ kpis }) { + const navigate = useNavigate() + const k = kpis || {} + + return ( +
+ navigate('/my-tasks')} + /> + navigate('/my-tasks')} + /> + + + +
+ ) +} diff --git a/views/assets/src/components/dashboard/parts/MilestonesCard.jsx b/views/assets/src/components/dashboard/parts/MilestonesCard.jsx new file mode 100644 index 000000000..b0876f8ba --- /dev/null +++ b/views/assets/src/components/dashboard/parts/MilestonesCard.jsx @@ -0,0 +1,42 @@ +import { __ } from '@wordpress/i18n' +import { Flag } from 'lucide-react' +import { Card } from '@components/ui/card' +import { Progress } from '@components/ui/progress' + +export default function MilestonesCard({ milestones }) { + const list = milestones || [] + + return ( + +

+ + {__('Upcoming Milestones', 'wedevs-project-manager')} +

+ + {list.length === 0 ? ( +
+ +

{__('No upcoming milestones.', 'wedevs-project-manager')}

+
+ ) : ( +
+ {list.map(m => ( +
+
+ {m.title} + {m.due_date && ( + {m.due_date} + )} +
+
+ + {m.progress}% +
+ {m.project &&
{m.project}
} +
+ ))} +
+ )} +
+ ) +} diff --git a/views/assets/src/components/dashboard/parts/MiniCalendarCard.jsx b/views/assets/src/components/dashboard/parts/MiniCalendarCard.jsx new file mode 100644 index 000000000..3b6e81324 --- /dev/null +++ b/views/assets/src/components/dashboard/parts/MiniCalendarCard.jsx @@ -0,0 +1,137 @@ +import { __ } from '@wordpress/i18n' +import { useMemo, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { format, parseISO } from 'date-fns' +import { CalendarDays } from 'lucide-react' +import { Card } from '@components/ui/card' +import { Calendar } from '@components/ui/calendar' +import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@components/ui/tooltip' +import { cn } from '@lib/utils' + +export default function MiniCalendarCard({ calendar }) { + const navigate = useNavigate() + + const baseMonth = calendar?.month ? parseISO(calendar.month + '-01') : new Date() + const [month, setMonth] = useState(baseMonth) + + // Lookup: 'yyyy-MM-dd' -> { total, overdue } + const dayMap = useMemo(() => { + const m = new Map() + for (const d of calendar?.days || []) m.set(d.date, d) + return m + }, [calendar]) + + const { dueDates, overdueDates } = useMemo(() => { + const due = [], over = [] + for (const d of calendar?.days || []) { + const date = parseISO(d.date) + if (d.overdue) over.push(date) + else due.push(date) + } + return { dueDates: due, overdueDates: over } + }, [calendar]) + + const monthTotal = useMemo( + () => (calendar?.days || []).reduce((s, d) => s + d.total, 0), + [calendar], + ) + + // Flagged days, soonest first — fills the card below the grid. + const dueList = useMemo(() => { + return [...(calendar?.days || [])].sort((a, b) => a.date.localeCompare(b.date)) + }, [calendar]) + + // Custom day button: default number + a load dot (rose = overdue, accent = upcoming). + const DayButton = ({ day, modifiers, className, children, ...rest }) => { + const ds = format(day.date, 'yyyy-MM-dd') + const info = dayMap.get(ds) + + const btn = ( + + ) + + if (!info) return btn + + return ( + + {btn} + + {format(day.date, 'MMM d')} + {' · '} + {info.total} {__('task(s) due', 'wedevs-project-manager')} + {info.overdue && ({__('overdue', 'wedevs-project-manager')})} + + + ) + } + + return ( + +
+

+ + {__('Calendar', 'wedevs-project-manager')} +

+
+ {__('Due', 'wedevs-project-manager')} + {__('Overdue', 'wedevs-project-manager')} +
+
+ + + { if (d) navigate('/my-tasks') }} + components={{ DayButton }} + className="p-0 [&_.rdp-month_caption]:justify-start [&_.rdp-nav]:justify-end" + /> + + + {/* Due-days list — fills the space below the grid */} + {dueList.length > 0 && ( +
+
+ {__('Due this month', 'wedevs-project-manager')} +
+
+ {dueList.map(d => ( + + ))} +
+
+ )} + +
+ + {monthTotal} {__('tasks due this month', 'wedevs-project-manager')} + + +
+
+ ) +} diff --git a/views/assets/src/components/dashboard/parts/OverduePriorityCard.jsx b/views/assets/src/components/dashboard/parts/OverduePriorityCard.jsx new file mode 100644 index 000000000..327feb3df --- /dev/null +++ b/views/assets/src/components/dashboard/parts/OverduePriorityCard.jsx @@ -0,0 +1,52 @@ +import { __, sprintf, _n } from '@wordpress/i18n' +import { useNavigate } from 'react-router-dom' +import { AlertTriangle, CheckCircle2 } from 'lucide-react' +import { Card } from '@components/ui/card' +import { Badge } from '@components/ui/badge' +import { cn } from '@lib/utils' + +const PRIORITY_DOT = { + high: 'bg-rose-500', + medium: 'bg-amber-500', + low: 'bg-slate-400', +} + +export default function OverduePriorityCard({ items }) { + const navigate = useNavigate() + const list = items || [] + + return ( + +

+ + {__('Overdue & Priority', 'wedevs-project-manager')} +

+ + {list.length === 0 ? ( +
+ +

{__('Nothing overdue — great work!', 'wedevs-project-manager')}

+
+ ) : ( +
+ {list.map(t => ( + + ))} +
+ )} +
+ ) +} diff --git a/views/assets/src/components/dashboard/parts/ProInsightsRow.jsx b/views/assets/src/components/dashboard/parts/ProInsightsRow.jsx new file mode 100644 index 000000000..c40df2688 --- /dev/null +++ b/views/assets/src/components/dashboard/parts/ProInsightsRow.jsx @@ -0,0 +1,90 @@ +import { __ } from '@wordpress/i18n' +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { Timer, Zap, Receipt } from 'lucide-react' +import { useProApi } from '@hooks/useProApi' +import { Card } from '@components/ui/card' +import { Progress } from '@components/ui/progress' +import { cn } from '@lib/utils' + +function fmtDuration(seconds) { + const s = Math.max(0, parseInt(seconds, 10) || 0) + const h = Math.floor(s / 3600) + const m = Math.floor((s % 3600) / 60) + if (h === 0 && m === 0) return '0m' + return `${h > 0 ? h + 'h ' : ''}${m}m`.trim() +} + +function Tile({ icon: Icon, accent, children, onClick }) { + return ( + + + + +
{children}
+
+ ) +} + +export default function ProInsightsRow() { + const proApi = useProApi() + const navigate = useNavigate() + const [data, setData] = useState(null) + + useEffect(() => { + let cancelled = false + proApi.get('dashboard-pro') + .then(res => { if (!cancelled) setData(res?.data ?? res) }) + .catch(() => { /* pro insights are best-effort */ }) + return () => { cancelled = true } + }, []) + + if (!data) return null + + const { time, sprint, invoice } = data + const tiles = [] + + if (time?.enabled) { + tiles.push( + navigate('/reports')}> +
{__('Time tracked today', 'wedevs-project-manager')}
+
{fmtDuration(time.today_seconds)}
+
{fmtDuration(time.week_seconds)} {__('this week', 'wedevs-project-manager')}
+
+ ) + } + + if (sprint?.enabled && sprint?.active) { + tiles.push( + navigate('/sprints')}> +
{sprint.title || __('Active Sprint', 'wedevs-project-manager')}
+
{sprint.done}/{sprint.total}
+ +
+ ) + } + + if (invoice?.enabled) { + tiles.push( + +
{__('Invoices', 'wedevs-project-manager')}
+
{invoice.total}
+
+ {invoice.paid} {__('paid', 'wedevs-project-manager')} + {' · '} + {invoice.unpaid} {__('due', 'wedevs-project-manager')} +
+
+ ) + } + + if (tiles.length === 0) return null + + return
{tiles}
+} diff --git a/views/assets/src/components/dashboard/parts/ProUpgradeCard.jsx b/views/assets/src/components/dashboard/parts/ProUpgradeCard.jsx new file mode 100644 index 000000000..26e1d56c7 --- /dev/null +++ b/views/assets/src/components/dashboard/parts/ProUpgradeCard.jsx @@ -0,0 +1,45 @@ +import { __ } from '@wordpress/i18n' +import { useNavigate } from 'react-router-dom' +import { Crown, Check } from 'lucide-react' +import { Card } from '@components/ui/card' +import { Button } from '@components/ui/button' + +/** + * Non-pro upsell tile (managers only) — mirrors the "premium" promo pattern. + * When Pro IS active this card is not rendered (see DashboardPage). + */ +export default function ProUpgradeCard() { + const navigate = useNavigate() + + const perks = [ + __('Time tracking & reports', 'wedevs-project-manager'), + __('Kanban & Gantt views', 'wedevs-project-manager'), + __('Invoices & sprints', 'wedevs-project-manager'), + ] + + return ( + +
+ +

{__('Unlock Pro', 'wedevs-project-manager')}

+
+

+ {__('Get advanced analytics and team productivity tools.', 'wedevs-project-manager')} +

+
    + {perks.map(p => ( +
  • + {p} +
  • + ))} +
+ +
+ ) +} diff --git a/views/assets/src/components/dashboard/parts/ProductivityHeatmapCard.jsx b/views/assets/src/components/dashboard/parts/ProductivityHeatmapCard.jsx new file mode 100644 index 000000000..0e26f274e --- /dev/null +++ b/views/assets/src/components/dashboard/parts/ProductivityHeatmapCard.jsx @@ -0,0 +1,153 @@ +import { __, sprintf } from '@wordpress/i18n' +import { useMemo, useState, useEffect, useCallback } from 'react' +import { parseISO, format, getDay } from 'date-fns' +import { Flame } from 'lucide-react' +import { useApi } from '@hooks/useApi' +import { Card } from '@components/ui/card' +import { Skeleton } from '@components/ui/skeleton' +import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@components/ui/tooltip' +import { + Select, SelectTrigger, SelectValue, SelectContent, SelectItem, +} from '@components/ui/select' +import { cn } from '@lib/utils' + +const WEEKDAYS = ['', 'Mon', '', 'Wed', '', 'Fri', ''] +const ROLLING = 'rolling' + +// Distinct violet shades so all 4 levels separate clearly. Thresholds spread +// to the real distribution (counts often run 10–60+). +function levelClass(count) { + if (count <= 0) return 'bg-slate-100 border border-pm-border/70' + if (count >= 25) return 'bg-violet-800' + if (count >= 10) return 'bg-violet-600' + if (count >= 4) return 'bg-violet-400' + return 'bg-violet-300' // 1–3 +} + +export default function ProductivityHeatmapCard() { + const api = useApi() + const [data, setData] = useState(null) + const [year, setYear] = useState(ROLLING) + const [loading, setLoading] = useState(true) + + const load = useCallback(async (y) => { + setLoading(true) + try { + const params = y && y !== ROLLING ? { year: y } : {} + const res = await api.get('dashboard/heatmap', params) + setData(res?.data ?? res) + } catch { /* heatmap is best-effort */ } + finally { setLoading(false) } + }, [api]) + + useEffect(() => { load(ROLLING) }, [load]) + + const onYearChange = (v) => { setYear(v); load(v) } + + const days = data?.days || [] + const activeDays = data?.active_days ?? 0 + const years = data?.years || [] + + const weeks = useMemo(() => { + if (!days.length) return [] + const lead = getDay(parseISO(days[0].date)) // 0=Sun → leading pad + const cells = [...Array.from({ length: lead }, () => null), ...days] + while (cells.length % 7 !== 0) cells.push(null) // trailing pad → full rectangle + const out = [] + for (let i = 0; i < cells.length; i += 7) out.push(cells.slice(i, i + 7)) + return out + }, [days]) + + const monthLabels = useMemo(() => { + let prev = null + return weeks.map((week) => { + const first = week.find(Boolean) + if (!first) return '' + const m = format(parseISO(first.date), 'MMM') + if (m !== prev) { prev = m; return m } + return '' + }) + }, [weeks]) + + return ( + +
+

+ + {__('Productivity', 'wedevs-project-manager')} +

+
+ + {sprintf( __( '%d active days', 'wedevs-project-manager' ), activeDays )} + + +
+
+ + {loading ? ( + + ) : ( + +
+ {/* Month labels row — flex columns fill full width */} +
+ {monthLabels.map((m, wi) => ( + {m} + ))} +
+ +
+ {/* Weekday labels column */} +
+ {WEEKDAYS.map((w, i) => ( + {w} + ))} +
+ + {weeks.map((week, wi) => ( +
+ {Array.from({ length: 7 }).map((_, di) => { + const d = week[di] + if (!d) return
+ return ( + + +
+ + + {format(parseISO(d.date), 'EEE, MMM d')} + {' · '} + {sprintf( __( '%d activities', 'wedevs-project-manager' ), d.count )} + + + ) + })} +
+ ))} +
+
+ + )} + +
+ {__('Less', 'wedevs-project-manager')} + + + + + + {__('More', 'wedevs-project-manager')} +
+ + ) +} diff --git a/views/assets/src/components/dashboard/parts/ProjectStatusCard.jsx b/views/assets/src/components/dashboard/parts/ProjectStatusCard.jsx new file mode 100644 index 000000000..af4d08635 --- /dev/null +++ b/views/assets/src/components/dashboard/parts/ProjectStatusCard.jsx @@ -0,0 +1,110 @@ +import { __, _n, sprintf } from '@wordpress/i18n' +import { useMemo } from 'react' +import { useNavigate } from 'react-router-dom' +import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts' +import { ChevronRight, CheckCircle2 } from 'lucide-react' +import { Card } from '@components/ui/card' + +const COLORS = { + on_track: 'hsl(152 60% 45%)', + at_risk: 'hsl(0 72% 60%)', + completed: 'hsl(262 80% 57%)', + archived: 'hsl(220 9% 60%)', +} + +export default function ProjectStatusCard({ status }) { + const navigate = useNavigate() + const s = status || {} + + const segments = useMemo(() => ([ + { key: 'on_track', label: __('On Track', 'wedevs-project-manager'), value: s.on_track ?? 0 }, + { key: 'at_risk', label: __('At Risk', 'wedevs-project-manager'), value: s.at_risk ?? 0 }, + { key: 'completed', label: __('Completed', 'wedevs-project-manager'), value: s.completed ?? 0 }, + { key: 'archived', label: __('Archived', 'wedevs-project-manager'), value: s.archived ?? 0 }, + ]), [s]) + + const total = s.total ?? 0 + const data = segments.filter(seg => seg.value > 0) + const active = (s.on_track ?? 0) + (s.at_risk ?? 0) + const onTrackPct = active > 0 ? Math.round(((s.on_track ?? 0) / active) * 100) : 0 + + return ( + +
+

+ {__('Projects', 'wedevs-project-manager')} +

+ +
+ +
+
+ + + 1 ? 3 : 0} + strokeWidth={0} + startAngle={90} + endAngle={-270} + > + {(data.length ? data : [{ key: 'empty' }]).map(seg => ( + + ))} + + + +
+ {total} + {__('Total', 'wedevs-project-manager')} +
+
+ +
+ {segments.map(seg => ( +
+ + {seg.label} + {seg.value} +
+ ))} +
+
+ + {/* Health footer — fills the space + surfaces risk */} +
+
+ {__('On-track ratio', 'wedevs-project-manager')} + {onTrackPct}% +
+
+
+
+ {(s.at_risk ?? 0) > 0 ? ( + + ) : ( +
+ + {__('All projects on track', 'wedevs-project-manager')} +
+ )} +
+ + ) +} diff --git a/views/assets/src/components/dashboard/parts/RecentActivityCard.jsx b/views/assets/src/components/dashboard/parts/RecentActivityCard.jsx new file mode 100644 index 000000000..c53a4a1ee --- /dev/null +++ b/views/assets/src/components/dashboard/parts/RecentActivityCard.jsx @@ -0,0 +1,44 @@ +import { __ } from '@wordpress/i18n' +import { Activity } from 'lucide-react' +import { Card } from '@components/ui/card' +import { UserAvatar } from '@components/common/UserAvatar' + +export default function RecentActivityCard({ activity }) { + const list = activity || [] + + return ( + +

+ + {__('Recent Activity', 'wedevs-project-manager')} +

+ + {list.length === 0 ? ( +
+ +

{__('No recent activity.', 'wedevs-project-manager')}

+
+ ) : ( +
+ {list.map(a => ( +
+ +
+

+ {a.actor}{' '} + {a.action} + {a.project && <> · {a.project}} +

+ {a.time} +
+
+ ))} +
+ )} +
+ ) +} diff --git a/views/assets/src/components/dashboard/parts/StatCard.jsx b/views/assets/src/components/dashboard/parts/StatCard.jsx new file mode 100644 index 000000000..dadd3aad5 --- /dev/null +++ b/views/assets/src/components/dashboard/parts/StatCard.jsx @@ -0,0 +1,53 @@ +import { ArrowUpRight, ArrowDownRight, Minus } from 'lucide-react' +import { cn } from '@lib/utils' + +/** + * A single KPI stat tile — number, label, optional trend delta and sub-line. + * `accent` renders the filled (primary) variant used for the lead metric. + */ +export default function StatCard({ icon: Icon, label, value, sub, trend, accent = false, onClick }) { + const TrendIcon = trend?.direction === 'up' ? ArrowUpRight + : trend?.direction === 'down' ? ArrowDownRight : Minus + + return ( +
+
+ + {label} + + + {Icon && } + +
+ +
+ {value} +
+ +
+ {trend && ( + + + {trend.percent}% + + )} + {sub && {sub}} +
+
+ ) +} diff --git a/views/assets/src/components/dashboard/parts/TaskDistributionCard.jsx b/views/assets/src/components/dashboard/parts/TaskDistributionCard.jsx new file mode 100644 index 000000000..b575ece93 --- /dev/null +++ b/views/assets/src/components/dashboard/parts/TaskDistributionCard.jsx @@ -0,0 +1,67 @@ +import { __ } from '@wordpress/i18n' +import { useMemo } from 'react' +import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts' +import { Card } from '@components/ui/card' + +const COLORS = { + completed: 'hsl(262 80% 57%)', + in_progress: 'hsl(152 60% 45%)', + pending: 'hsl(38 92% 55%)', +} + +export default function TaskDistributionCard({ distribution }) { + const d = distribution || {} + + const segments = useMemo(() => ([ + { key: 'completed', label: __('Completed', 'wedevs-project-manager'), value: d.completed ?? 0 }, + { key: 'in_progress', label: __('In Progress', 'wedevs-project-manager'), value: d.in_progress ?? 0 }, + { key: 'pending', label: __('Pending', 'wedevs-project-manager'), value: d.pending ?? 0 }, + ]), [d]) + + const total = segments.reduce((sum, s) => sum + s.value, 0) + const pct = total > 0 ? Math.round(((d.completed ?? 0) / total) * 100) : 0 + const data = segments.filter(s => s.value > 0) + + return ( + +

+ {__('Task Distribution', 'wedevs-project-manager')} +

+ +
+ + + 1 ? 3 : 0} + strokeWidth={0} + startAngle={90} + endAngle={-270} + > + {(data.length ? data : [{ key: 'empty' }]).map(seg => ( + + ))} + + + +
+ {pct}% + {__('Done', 'wedevs-project-manager')} +
+
+ +
+ {segments.map(seg => ( +
+ + {seg.label} + {seg.value} +
+ ))} +
+
+ ) +} diff --git a/views/assets/src/components/dashboard/parts/TaskPerformanceCard.jsx b/views/assets/src/components/dashboard/parts/TaskPerformanceCard.jsx new file mode 100644 index 000000000..f1c83077e --- /dev/null +++ b/views/assets/src/components/dashboard/parts/TaskPerformanceCard.jsx @@ -0,0 +1,67 @@ +import { __ } from '@wordpress/i18n' +import { Bar, BarChart, XAxis, CartesianGrid } from 'recharts' +import { Card } from '@components/ui/card' +import { cn } from '@lib/utils' +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + ChartLegend, + ChartLegendContent, +} from '@components/ui/chart' + +export default function TaskPerformanceCard({ performance, range = 7, onRangeChange, loading = false }) { + const data = performance || [] + const ranges = [ + { v: 7, label: __('7d', 'wedevs-project-manager') }, + { v: 30, label: __('30d', 'wedevs-project-manager') }, + ] + + const chartConfig = { + completed: { label: __('Completed', 'wedevs-project-manager'), color: 'hsl(var(--primary))' }, + created: { label: __('Created', 'wedevs-project-manager'), color: 'hsl(152 60% 52%)' }, + } + + return ( + +
+
+

+ {__('Task Performance', 'wedevs-project-manager')} +

+

+ {__('Created vs completed', 'wedevs-project-manager')} +

+
+ {onRangeChange && ( +
+ {ranges.map(r => ( + + ))} +
+ )} +
+ + + + + + } /> + } /> + + + + +
+ ) +} diff --git a/views/assets/src/components/dashboard/parts/TeamStatusCard.jsx b/views/assets/src/components/dashboard/parts/TeamStatusCard.jsx new file mode 100644 index 000000000..b474c1d4d --- /dev/null +++ b/views/assets/src/components/dashboard/parts/TeamStatusCard.jsx @@ -0,0 +1,49 @@ +import { __, sprintf, _n } from '@wordpress/i18n' +import { Users } from 'lucide-react' +import { Card } from '@components/ui/card' +import { UserAvatar } from '@components/common/UserAvatar' + +export default function TeamStatusCard({ team }) { + const list = team || [] + + return ( + +

+ + {__('Team Workload', 'wedevs-project-manager')} +

+ + {list.length === 0 ? ( +
+ +

+ {__('No active team members yet.', 'wedevs-project-manager')} +

+
+ ) : ( +
+ {list.map(m => { + const totalAssigned = m.active + m.completed + const pct = totalAssigned > 0 ? Math.round((m.completed / totalAssigned) * 100) : 0 + return ( +
+ +
+
+ {m.name} + + {sprintf( _n( '%d active', '%d active', m.active, 'wedevs-project-manager' ), m.active )} + +
+
+
+
+
+
+ ) + })} +
+ )} + + ) +} diff --git a/views/assets/src/components/dashboard/parts/UpcomingScheduleCard.jsx b/views/assets/src/components/dashboard/parts/UpcomingScheduleCard.jsx new file mode 100644 index 000000000..e5ce13e05 --- /dev/null +++ b/views/assets/src/components/dashboard/parts/UpcomingScheduleCard.jsx @@ -0,0 +1,62 @@ +import { __ } from '@wordpress/i18n' +import { useNavigate } from 'react-router-dom' +import { CalendarDays, CircleDot } from 'lucide-react' +import { Card } from '@components/ui/card' + +const PRIORITY = { + 0: 'hsl(220 9% 60%)', // low + 1: 'hsl(38 92% 55%)', // medium + 2: 'hsl(0 72% 60%)', // high +} + +export default function UpcomingScheduleCard({ items }) { + const navigate = useNavigate() + const list = items || [] + + return ( + +
+

+ + {__('Upcoming Tasks', 'wedevs-project-manager')} +

+ +
+ + {list.length === 0 ? ( +
+ +

+ {__('No upcoming tasks with a due date.', 'wedevs-project-manager')} +

+
+ ) : ( +
+ {list.map(t => ( + + ))} +
+ )} +
+ ) +} diff --git a/views/assets/src/components/layout/AppSidebar.jsx b/views/assets/src/components/layout/AppSidebar.jsx index 21ffc2442..a0fd88058 100644 --- a/views/assets/src/components/layout/AppSidebar.jsx +++ b/views/assets/src/components/layout/AppSidebar.jsx @@ -11,6 +11,7 @@ import { ChevronDown, Star, LayoutList, Layout, MessageSquare, Milestone, FileText, Activity, Tag, Crown, Layers, Columns3, GitBranch, Receipt, Timer, Shield, Wrench, + LayoutDashboard, } from 'lucide-react' import { cn } from '@lib/utils' @@ -217,6 +218,7 @@ export function AppSidebar() { ) const topNavItems = useMemo(() => [ + { key: 'dashboard', label: __('Dashboard', 'wedevs-project-manager'), short: __('Home', 'wedevs-project-manager'), icon: LayoutDashboard, route: '/dashboard' }, { key: 'projects', label: __('Projects', 'wedevs-project-manager'), short: __('Proj', 'wedevs-project-manager'), icon: FolderKanban, route: '/projects' }, { key: 'my-tasks', label: __('My Tasks', 'wedevs-project-manager'), short: __('Tasks', 'wedevs-project-manager'), icon: CheckSquare, route: '/my-tasks' }, ], [__]) @@ -274,6 +276,7 @@ export function AppSidebar() { const activeKey = useMemo(() => { const path = location.pathname + if (path.startsWith('/dashboard')) return 'dashboard' if (path.startsWith('/modules')) return 'modules' if (path.startsWith('/premium')) return 'premium' if (path.startsWith('/categories')) return 'categories' diff --git a/views/assets/src/components/ui/calendar.jsx b/views/assets/src/components/ui/calendar.jsx new file mode 100644 index 000000000..5c7813dff --- /dev/null +++ b/views/assets/src/components/ui/calendar.jsx @@ -0,0 +1,82 @@ +import * as React from "react" +import { DayPicker } from "react-day-picker" +import { ChevronLeft, ChevronRight, ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "./button" + +/** + * shadcn/ui Calendar — built on react-day-picker v9. + * + * Local component (no @wedevs/plugin-ui dependency). All react-day-picker v9 + * props are forwarded, including `mode`, `selected`, `onSelect`, `modifiers` + * and `modifiersClassNames`. + * + * @example + * + * + */ +function Calendar({ + className, + classNames, + showOutsideDays = true, + mode, + components: consumerComponents, + ...props +}) { + return ( + { + if (orientation === "down") return + const Icon = orientation === "left" ? ChevronLeft : ChevronRight + return + }, + ...consumerComponents, + }} + /> + ) +} + +Calendar.displayName = "Calendar" + +export { Calendar } diff --git a/views/assets/src/index.jsx b/views/assets/src/index.jsx index fa0d882fa..710dbb55e 100644 --- a/views/assets/src/index.jsx +++ b/views/assets/src/index.jsx @@ -36,6 +36,7 @@ import { sanitizeHtml } from '@lib/sanitize' import './tailwind.css' // ── Free pages (always loaded) ────────────────────────── +const DashboardPage = React.lazy(() => import('@components/dashboard/DashboardPage')) const ProjectsPage = React.lazy(() => import('@components/projects/ProjectsPage')) const SettingsPage = React.lazy(() => import('@components/admin-settings/SettingsPage')) const TaskListsPage = React.lazy(() => import('@components/tasks/TaskListsPage')) @@ -99,7 +100,8 @@ function AppRoutes() { }> {/* ── Free routes ── */} - } /> + } /> + } /> } /> } /> } /> @@ -159,7 +161,7 @@ function AppRoutes() { + : } /> diff --git a/views/assets/src/tailwind.css b/views/assets/src/tailwind.css index bccdf4e57..cad076841 100644 --- a/views/assets/src/tailwind.css +++ b/views/assets/src/tailwind.css @@ -1,3 +1,6 @@ +/* Default UI font — Mona Sans (variable weight + italic), matching WP ERP. */ +@import url('https://fonts.googleapis.com/css2?family=Mona+Sans:ital,wght@0,200..900;1,200..900&display=swap'); + /* DO NOT use @tailwind base — Preflight is disabled in config */ @tailwind components; @tailwind utilities; @@ -170,7 +173,7 @@ body:not(.wp-admin) #wedevs-project-manager { } #wedevs-project-manager { - font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-family: 'Mona Sans', Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; line-height: 1.5; color: hsl(var(--foreground)); @@ -508,7 +511,7 @@ body.pm-fullscreen-settings.pm-mode-wordpress.folded #wedevs-pm { because a Select inside a Sheet still teleports here. ──── */ [data-radix-popper-content-wrapper] { z-index: 3000 !important; - font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important; + font-family: 'Mona Sans', Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important; font-size: 14px !important; line-height: 1.5 !important; color: hsl(var(--foreground)) !important; @@ -544,7 +547,7 @@ body.pm-fullscreen-settings.pm-mode-wordpress.folded #wedevs-pm { Specificity (0,2,0) beats Tailwind z-50 at (0,1,0). ───── */ [role="dialog"][data-state] { z-index: 2001 !important; - font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-family: 'Mona Sans', Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; line-height: 1.5; color: hsl(var(--foreground)); @@ -573,7 +576,7 @@ body.pm-fullscreen-settings.pm-mode-wordpress.folded #wedevs-pm { } [role="alertdialog"][data-state] { z-index: 2501 !important; - font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-family: 'Mona Sans', Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; line-height: 1.5; color: hsl(var(--foreground)); @@ -678,7 +681,7 @@ body.admin-bar [data-sonner-toaster] { min-height: 0 !important; min-width: 0 !important; padding: 0.5rem 1rem !important; - font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important; + font-family: 'Mona Sans', Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important; font-size: 0.875rem !important; font-weight: 500 !important; line-height: 1 !important; From f6274a2f131731776103f34dc4543340dfeeac07 Mon Sep 17 00:00:00 2001 From: Ariful Hoque Date: Tue, 23 Jun 2026 19:29:14 +0600 Subject: [PATCH 2/8] fix(dashboard): enlarge recent-activity avatar initials to medium Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/dashboard/parts/RecentActivityCard.jsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/views/assets/src/components/dashboard/parts/RecentActivityCard.jsx b/views/assets/src/components/dashboard/parts/RecentActivityCard.jsx index c53a4a1ee..6325afa30 100644 --- a/views/assets/src/components/dashboard/parts/RecentActivityCard.jsx +++ b/views/assets/src/components/dashboard/parts/RecentActivityCard.jsx @@ -24,8 +24,9 @@ export default function RecentActivityCard({ activity }) {

From 161b658236851ca6f7ebbdfca0bcddb0db43aa94 Mon Sep 17 00:00:00 2001 From: Ariful Hoque Date: Tue, 23 Jun 2026 19:30:03 +0600 Subject: [PATCH 3/8] fix(dashboard): bump team-status avatar initials to medium Co-Authored-By: Claude Opus 4.8 (1M context) --- views/assets/src/components/dashboard/parts/TeamStatusCard.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/views/assets/src/components/dashboard/parts/TeamStatusCard.jsx b/views/assets/src/components/dashboard/parts/TeamStatusCard.jsx index b474c1d4d..b8f2882c1 100644 --- a/views/assets/src/components/dashboard/parts/TeamStatusCard.jsx +++ b/views/assets/src/components/dashboard/parts/TeamStatusCard.jsx @@ -27,7 +27,7 @@ export default function TeamStatusCard({ team }) { const pct = totalAssigned > 0 ? Math.round((m.completed / totalAssigned) * 100) : 0 return (

- +
{m.name} From abdc0e7e62218d4bdf343d0d5e0d60abddfd6be2 Mon Sep 17 00:00:00 2001 From: Ariful Hoque Date: Tue, 23 Jun 2026 19:32:21 +0600 Subject: [PATCH 4/8] fix(dashboard): restore visible avatar fallback circle (violet disc) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/dashboard/parts/RecentActivityCard.jsx | 2 +- views/assets/src/components/dashboard/parts/TeamStatusCard.jsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/views/assets/src/components/dashboard/parts/RecentActivityCard.jsx b/views/assets/src/components/dashboard/parts/RecentActivityCard.jsx index 6325afa30..b486659c6 100644 --- a/views/assets/src/components/dashboard/parts/RecentActivityCard.jsx +++ b/views/assets/src/components/dashboard/parts/RecentActivityCard.jsx @@ -26,7 +26,7 @@ export default function RecentActivityCard({ activity }) { user={{ display_name: a.actor, avatar_url: a.avatar_url }} size="md" className="w-8 h-8 shrink-0 mt-0.5" - fallbackClassName="text-[12px]" + fallbackClassName="text-[12px] bg-violet-100 text-violet-700" />

diff --git a/views/assets/src/components/dashboard/parts/TeamStatusCard.jsx b/views/assets/src/components/dashboard/parts/TeamStatusCard.jsx index b8f2882c1..17c1438ce 100644 --- a/views/assets/src/components/dashboard/parts/TeamStatusCard.jsx +++ b/views/assets/src/components/dashboard/parts/TeamStatusCard.jsx @@ -27,7 +27,7 @@ export default function TeamStatusCard({ team }) { const pct = totalAssigned > 0 ? Math.round((m.completed / totalAssigned) * 100) : 0 return (

- +
{m.name} From e3a131785ddfa987a639a00aa4b3df6c9eb05144 Mon Sep 17 00:00:00 2001 From: Ariful Hoque Date: Tue, 23 Jun 2026 19:34:05 +0600 Subject: [PATCH 5/8] fix(dashboard): use violet avatar fallback in header for consistency Co-Authored-By: Claude Opus 4.8 (1M context) --- views/assets/src/components/dashboard/parts/DashboardHeader.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/views/assets/src/components/dashboard/parts/DashboardHeader.jsx b/views/assets/src/components/dashboard/parts/DashboardHeader.jsx index 85917d953..3ffeb6879 100644 --- a/views/assets/src/components/dashboard/parts/DashboardHeader.jsx +++ b/views/assets/src/components/dashboard/parts/DashboardHeader.jsx @@ -28,6 +28,7 @@ export default function DashboardHeader({ user, canCreate }) { user={{ id: user.id, display_name: user.name, avatar_url: user.avatar_url }} size="xl" className="w-12 h-12 shrink-0" + fallbackClassName="bg-violet-100 text-violet-700" /> )}
From 2e70cad19c3485f1c86b19629ef7a35b99c4f385 Mon Sep 17 00:00:00 2001 From: Ariful Hoque Date: Tue, 23 Jun 2026 19:40:41 +0600 Subject: [PATCH 6/8] fix(theme): make bg-pm-accent support opacity modifiers pm-accent was 'var(--pm-accent)' (a hex), so utilities like bg-pm-accent/10 emitted an invalid color and rendered nothing (e.g. avatar fallbacks in Activities/Progress). Add --pm-accent-hsl channels and define the token as hsl(var(--pm-accent-hsl) / ) so opacity modifiers work app-wide. Solid bg-pm-accent and direct var(--pm-accent) CSS uses are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- tailwind.config.js | 2 +- views/assets/src/tailwind.css | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/tailwind.config.js b/tailwind.config.js index 1c05ad8e0..99a9ea902 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -63,7 +63,7 @@ module.exports = { 'pm-surface-muted': 'var(--pm-surface-muted)', 'pm-border': 'var(--pm-border)', 'pm-hover': 'var(--pm-hover)', - 'pm-accent': 'var(--pm-accent)', + 'pm-accent': 'hsl(var(--pm-accent-hsl) / )', 'pm-accent-hover': 'var(--pm-accent-hover)', 'pm-accent-light': 'var(--pm-accent-light)', 'pm-text': 'var(--pm-text)', diff --git a/views/assets/src/tailwind.css b/views/assets/src/tailwind.css index cad076841..1c21daeff 100644 --- a/views/assets/src/tailwind.css +++ b/views/assets/src/tailwind.css @@ -78,6 +78,7 @@ --pm-border: #E5E7EB; --pm-hover: #F3F4F6; --pm-accent: #7C3AED; + --pm-accent-hsl: 262 80% 57%; /* channels so bg-pm-accent/ works */ --pm-accent-hover: #6D28D9; --pm-accent-light: #EDE9FE; --pm-text: #1F2937; @@ -128,6 +129,7 @@ --pm-border: #2a3a5c; --pm-hover: #1e2d48; --pm-accent: #9D6FFF; + --pm-accent-hsl: 258 100% 72%; /* channels so bg-pm-accent/ works */ --pm-accent-hover: #8B5CF6; --pm-accent-light: #2D1F5E; --pm-text: #CBD5E1; From 7f638ffdb3990081da8732808d6887d6ef5ef8a3 Mon Sep 17 00:00:00 2001 From: arifulhoque7 Date: Wed, 24 Jun 2026 10:37:01 +0600 Subject: [PATCH 7/8] feat(dashboard): wire header actions, role gating, and clickable links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New Task button opens NewTaskSheet inline (was navigating to /my-tasks); refetches dashboard on create - New Project button opens the shared ProjectCreateSheet via Redux and preloads categories/roles so dropdowns aren't empty when opened cold - Per-button role gating: New Task (any user), New Project (canCreate), Milestone (manager) — matches the rest of the app - Active Projects rows now open /task-lists to match the projects list - Recent Activity actor + project are clickable: emit project_id/task_id from the controller and deep-link to the task / project task list Refs weDevsOfficial/pm-pro#357 --- .../Controllers/Dashboard_Controller.php | 6 ++- .../components/dashboard/DashboardPage.jsx | 4 +- .../dashboard/parts/ActiveProjectsCard.jsx | 2 +- .../dashboard/parts/DashboardHeader.jsx | 53 +++++++++++++++---- .../dashboard/parts/RecentActivityCard.jsx | 26 ++++++++- 5 files changed, 74 insertions(+), 17 deletions(-) diff --git a/src/Dashboard/Controllers/Dashboard_Controller.php b/src/Dashboard/Controllers/Dashboard_Controller.php index 6421f6674..cd4aa24a3 100644 --- a/src/Dashboard/Controllers/Dashboard_Controller.php +++ b/src/Dashboard/Controllers/Dashboard_Controller.php @@ -521,14 +521,18 @@ protected function recent_activity() { $items = $query->limit( 10 )->get(); return $items->map( function ( $a ) { - $actor = $a->actor ? $a->actor->display_name : __( 'Someone', 'wedevs-project-manager' ); + $actor = $a->actor ? $a->actor->display_name : __( 'Someone', 'wedevs-project-manager' ); + $is_task = 'task' === (string) $a->resource_type; return [ 'id' => absint( $a->id ), 'actor' => $actor, + 'actor_id' => absint( $a->actor_id ), 'avatar_url' => $a->actor_id ? Avatar::get_url( $a->actor_id ) : '', 'action' => $this->humanize_action( (string) $a->action, (string) $a->action_type ), 'project' => $a->project ? $a->project->title : '', + 'project_id' => absint( $a->project_id ), + 'task_id' => ( $is_task && $a->resource_id ) ? absint( $a->resource_id ) : 0, 'time' => $this->human_time( $a->created_at ), ]; } )->all(); diff --git a/views/assets/src/components/dashboard/DashboardPage.jsx b/views/assets/src/components/dashboard/DashboardPage.jsx index a1bb5cc3d..5bae06e56 100644 --- a/views/assets/src/components/dashboard/DashboardPage.jsx +++ b/views/assets/src/components/dashboard/DashboardPage.jsx @@ -46,7 +46,7 @@ function LoadingState() { export default function DashboardPage() { const api = useApi() - const { isPro, canManage, canCreate, isManagerAnywhere } = usePermissions() + const { isPro, canManage, isManagerAnywhere } = usePermissions() const [data, setData] = useState(null) const [loading, setLoading] = useState(true) @@ -98,7 +98,7 @@ export default function DashboardPage() { return (
- + load(range, false)} /> {/* Pro insights (module-gated, managers) */} {showTeam && isPro && } diff --git a/views/assets/src/components/dashboard/parts/ActiveProjectsCard.jsx b/views/assets/src/components/dashboard/parts/ActiveProjectsCard.jsx index 79c997ff2..3141cc43c 100644 --- a/views/assets/src/components/dashboard/parts/ActiveProjectsCard.jsx +++ b/views/assets/src/components/dashboard/parts/ActiveProjectsCard.jsx @@ -32,7 +32,7 @@ export default function ActiveProjectsCard({ projects }) { {list.map(p => (
- {canCreate && ( -
- +
+ {/* New Task — any user, matches MyTasksPage (ungated) */} + + {/* Milestone — manager only; creation is project-scoped, so route to projects */} + {canManageMilestone && ( - -
- )} + )} +
+ + { setNewTaskOpen(false); onTaskCreated?.() }} + /> + {canCreate && }
) } diff --git a/views/assets/src/components/dashboard/parts/RecentActivityCard.jsx b/views/assets/src/components/dashboard/parts/RecentActivityCard.jsx index b486659c6..b934ee0c3 100644 --- a/views/assets/src/components/dashboard/parts/RecentActivityCard.jsx +++ b/views/assets/src/components/dashboard/parts/RecentActivityCard.jsx @@ -1,11 +1,24 @@ import { __ } from '@wordpress/i18n' +import { useNavigate } from 'react-router-dom' import { Activity } from 'lucide-react' import { Card } from '@components/ui/card' import { UserAvatar } from '@components/common/UserAvatar' export default function RecentActivityCard({ activity }) { + const navigate = useNavigate() const list = activity || [] + // Deep-link to the task the activity is about, else the project's task list — + // same destination as the Active Projects ("progress") card. + const goActivity = (a) => { + if (!a.project_id) return + if (a.task_id) navigate(`/projects/${a.project_id}/task-lists/tasks/${a.task_id}`) + else navigate(`/projects/${a.project_id}/task-lists`) + } + const goProject = (a) => { + if (a.project_id) navigate(`/projects/${a.project_id}/task-lists`) + } + return (

@@ -30,9 +43,18 @@ export default function RecentActivityCard({ activity }) { />

- {a.actor}{' '} + {a.project_id ? ( + + ) : ( + {a.actor} + )}{' '} {a.action} - {a.project && <> · {a.project}} + {a.project && <> ·{' '} + {a.project_id ? ( + + ) : ( + {a.project} + )}}

{a.time}
From 81c1cb02514b4b73ae0fe8130a196478257c2087 Mon Sep 17 00:00:00 2001 From: arifulhoque7 Date: Fri, 26 Jun 2026 09:23:45 +0600 Subject: [PATCH 8/8] =?UTF-8?q?fix(dashboard):=20address=20CodeRabbit=20re?= =?UTF-8?q?view=20=E2=80=94=20race=20guard,=20a11y,=20lint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #626 review fixes (safe subset): - DashboardPage: guard load() with requestSeqRef so stale range responses cannot overwrite newer data - StatCard: render clickable card as

+ ) } diff --git a/views/assets/src/tailwind.css b/views/assets/src/tailwind.css index 1c21daeff..4cf447108 100644 --- a/views/assets/src/tailwind.css +++ b/views/assets/src/tailwind.css @@ -1,5 +1,5 @@ /* Default UI font — Mona Sans (variable weight + italic), matching WP ERP. */ -@import url('https://fonts.googleapis.com/css2?family=Mona+Sans:ital,wght@0,200..900;1,200..900&display=swap'); +@import 'https://fonts.googleapis.com/css2?family=Mona+Sans:ital,wght@0,200..900;1,200..900&display=swap'; /* DO NOT use @tailwind base — Preflight is disabled in config */ @tailwind components;