diff --git a/app/Http/Controllers/Api/DashboardController.php b/app/Http/Controllers/Api/DashboardController.php new file mode 100644 index 000000000..b65632037 --- /dev/null +++ b/app/Http/Controllers/Api/DashboardController.php @@ -0,0 +1,86 @@ + 0) { + if (!in_array($memberId, $context['member_userids'], true)) { + return Base::retError('参数错误'); + } + } elseif ($level !== null) { + if ($level !== -1 && !in_array($level, DashboardTeam::priorityLevels(), true)) { + return Base::retError('参数错误'); + } + } elseif (!in_array($type, ['uncompleted', 'overdue', 'soon', 'hi', 'noowner'], true)) { + return Base::retError('参数错误'); + } + + return Base::retSuccess('success', DashboardTeam::tasks($context, [ + 'type' => $type, + 'member_id' => $memberId, + 'level' => $level, + ])); + } +} diff --git a/app/Module/DashboardTeam.php b/app/Module/DashboardTeam.php new file mode 100644 index 000000000..0f4db2c1e --- /dev/null +++ b/app/Module/DashboardTeam.php @@ -0,0 +1,538 @@ +userid) + ->pluck('id') + ->map(fn($id) => intval($id)) + ->values() + ->toArray(); + if (empty($managedIds)) { + throw new ApiException('没有可查看的部门数据'); + } + + $selectedIds = self::normalizeSelectedDepartmentIds($selectedDepartmentIds, $managedIds); + if (empty($selectedIds)) { + throw new ApiException('没有可查看的部门数据'); + } + + $departmentIds = self::expandDepartmentIds($selectedIds); + $members = self::loadMembers($departmentIds); + $memberUserids = $members->pluck('userid')->map(fn($id) => intval($id))->values()->toArray(); + $projectIds = self::loadProjectIds($memberUserids); + $ownProjectIds = empty($projectIds) ? [] : ProjectUser::whereUserid($user->userid) + ->whereIn('project_id', $projectIds) + ->pluck('project_id') + ->map(fn($id) => intval($id)) + ->unique() + ->values() + ->toArray(); + + return [ + 'viewer_userid' => intval($user->userid), + 'selected_department_ids' => $selectedIds, + 'department_ids' => $departmentIds, + 'member_userids' => $memberUserids, + 'member_map' => $members->keyBy('userid')->map(fn(User $member) => [ + 'userid' => intval($member->userid), + 'nickname' => $member->nickname, + 'userimg' => $member->userimg, + ])->toArray(), + 'project_ids' => $projectIds, + 'own_project_id_map' => array_fill_keys($ownProjectIds, true), + ]; + } + + /** + * 团队统计。 + */ + public static function stats(array $context, bool $refresh = false): array + { + $cacheKey = 'dashboard:team:stats:v3:' . $context['viewer_userid'] . ':' . sha1(json_encode([ + $context['selected_department_ids'], + $context['department_ids'], + $context['member_userids'], + $context['project_ids'], + ])); + if ($refresh) { + Cache::forget($cacheKey); + } + + return Cache::remember($cacheKey, now()->addSeconds(self::STATS_CACHE_SECONDS), function () use ($context) { + $now = Carbon::now(); + $taskAlias = DB::getTablePrefix() . 't'; + $soonEnd = $now->clone()->addDays(self::SOON_DAYS)->endOfDay(); + $weekStart = $now->clone()->startOfWeek(Carbon::MONDAY)->startOfDay(); + $nextWeekStart = $weekStart->clone()->addWeek(); + $lastWeekStart = $weekStart->clone()->subWeek(); + $base = self::baseTaskBuilder($context); + + $core = (clone $base)->selectRaw(" + SUM(CASE WHEN {$taskAlias}.complete_at IS NULL THEN 1 ELSE 0 END) AS stat_uncompleted, + SUM(CASE WHEN {$taskAlias}.complete_at IS NULL AND {$taskAlias}.end_at IS NOT NULL AND {$taskAlias}.end_at < ? THEN 1 ELSE 0 END) AS stat_overdue, + SUM(CASE WHEN {$taskAlias}.complete_at IS NULL AND {$taskAlias}.end_at IS NOT NULL AND {$taskAlias}.end_at >= ? AND {$taskAlias}.end_at <= ? THEN 1 ELSE 0 END) AS stat_due_soon, + SUM(CASE WHEN {$taskAlias}.complete_at >= ? AND {$taskAlias}.complete_at < ? THEN 1 ELSE 0 END) AS stat_week_completed, + SUM(CASE WHEN {$taskAlias}.complete_at >= ? AND {$taskAlias}.complete_at < ? THEN 1 ELSE 0 END) AS stat_last_week_completed + ", [ + $now->toDateTimeString(), + $now->toDateTimeString(), + $soonEnd->toDateTimeString(), + $weekStart->toDateTimeString(), + $nextWeekStart->toDateTimeString(), + $lastWeekStart->toDateTimeString(), + $weekStart->toDateTimeString(), + ])->first(); + + $noOwner = (clone $base) + ->whereNull('t.complete_at') + ->whereNotExists(self::ownerExistsQuery('t.id')) + ->count(); + $members = self::memberDistribution($context, $now); + + return [ + 'generated_at' => $now->toDateTimeString(), + 'member_count' => count($context['member_userids']), + 'blocks' => [ + 'uncompleted' => intval($core->stat_uncompleted ?? 0), + 'overdue' => intval($core->stat_overdue ?? 0), + 'overdue_owner_count' => count(array_filter($members, fn($member) => $member['overdue'] > 0)), + 'due_soon' => intval($core->stat_due_soon ?? 0), + 'week_completed' => intval($core->stat_week_completed ?? 0), + 'last_week_completed' => intval($core->stat_last_week_completed ?? 0), + 'no_owner' => intval($noOwner), + ], + 'priority' => self::priorityDistribution($context), + 'members' => $members, + 'high_levels' => self::highPriorityLevels(), + ]; + }); + } + + /** + * 分页任务列表。 + */ + public static function tasks(array $context, array $filters) + { + $now = Carbon::now(); + $soonEnd = $now->clone()->addDays(self::SOON_DAYS)->endOfDay(); + $memberId = intval($filters['member_id'] ?? 0); + $level = $filters['level'] ?? null; + $type = $filters['type'] ?? ''; + + $builder = self::baseTaskBuilder($context)->whereNull('t.complete_at'); + if ($memberId > 0) { + $builder->whereExists(self::ownerExistsQuery('t.id', [$memberId])); + } elseif ($level !== null) { + if (intval($level) === -1) { + $levels = self::priorityLevels(); + if (!empty($levels)) { + $builder->whereNotIn('t.p_level', $levels); + } + } else { + $builder->where('t.p_level', intval($level)); + } + } else { + switch ($type) { + case 'overdue': + $builder->whereNotNull('t.end_at')->where('t.end_at', '<', $now); + break; + + case 'soon': + $builder->whereNotNull('t.end_at')->whereBetween('t.end_at', [$now, $soonEnd]); + break; + + case 'hi': + $levels = self::highPriorityLevels(); + $builder->whereIn('t.p_level', $levels ?: [-1]); + break; + + case 'noowner': + $builder->whereNotExists(self::ownerExistsQuery('t.id')); + break; + } + } + + $taskAlias = DB::getTablePrefix() . 't'; + $builder->leftJoin('project_flow_items as fi', 'fi.id', '=', 't.flow_item_id') + ->select([ + 't.id', + 't.parent_id', + 't.project_id', + 't.column_id', + 't.name', + 't.end_at', + 't.p_level', + 't.p_name', + 't.p_color', + 't.flow_item_id', + 't.flow_item_name', + 'p.name as project_name', + 'fi.status as flow_item_status', + 'fi.color as flow_item_color', + ]) + ->orderByRaw("{$taskAlias}.end_at IS NULL") + ->orderBy('t.end_at') + ->orderByDesc('t.id'); + + $list = $builder->paginate(Base::getPaginate(50, 20)); + $taskIds = $list->getCollection()->pluck('id')->map(fn($id) => intval($id))->toArray(); + $owners = self::taskOwners($taskIds, $context['member_userids'], $memberId); + $ownProjectMap = $context['own_project_id_map']; + + $list->setCollection($list->getCollection()->map(function ($task) use ($owners, $ownProjectMap) { + $item = (array)$task; + $item['id'] = intval($item['id']); + $item['parent_id'] = intval($item['parent_id']); + $item['project_id'] = intval($item['project_id']); + $item['column_id'] = intval($item['column_id']); + $item['p_level'] = intval($item['p_level']); + $flowParts = explode('|', $item['flow_item_name'] ?: ''); + if (count($flowParts) >= 2) { + $item['flow_item_status'] = $item['flow_item_status'] ?: ($flowParts[0] ?? ''); + $item['flow_item_name'] = $flowParts[1] ?? $item['flow_item_name']; + $item['flow_item_color'] = $item['flow_item_color'] ?: ($flowParts[2] ?? ''); + } + $item['owners'] = $owners[$item['id']] ?? []; + $item['owner'] = $item['owners'][0] ?? null; + $item['department_readonly'] = !isset($ownProjectMap[$item['project_id']]); + return $item; + })); + return $list; + } + + /** + * 当前系统优先级ID列表(保持设置顺序)。 + * @return array + */ + public static function priorityLevels(): array + { + return array_map(fn($item) => intval($item['priority']), self::priorityList()); + } + + /** + * 基础任务范围:主任务、全员可见、未归档项目,且由团队成员负责或没有负责人。 + */ + protected static function baseTaskBuilder(array $context): Builder + { + $projectIds = $context['project_ids'] ?: [0]; + $memberUserids = $context['member_userids']; + + return DB::table('project_tasks as t') + ->join('projects as p', 'p.id', '=', 't.project_id') + ->whereIn('t.project_id', $projectIds) + ->where('t.parent_id', 0) + ->where('t.visibility', 1) + ->whereNull('t.archived_at') + ->whereNull('t.deleted_at') + ->whereNull('p.archived_at') + ->whereNull('p.deleted_at') + ->where(function (Builder $query) { + $query->where('p.department_owner_view', '<>', 'close') + ->orWhereNull('p.department_owner_view'); + }) + ->where(function (Builder $query) use ($memberUserids) { + if (empty($memberUserids)) { + $query->whereRaw('1 = 0'); + return; + } + $query->whereExists(self::ownerExistsQuery('t.id', $memberUserids)) + ->orWhereNotExists(self::ownerExistsQuery('t.id')); + }); + } + + /** + * 负责人存在子查询。 + */ + protected static function ownerExistsQuery(string $taskColumn, ?array $userids = null): callable + { + return function (Builder $query) use ($taskColumn, $userids) { + $query->selectRaw('1') + ->from('project_task_users as owner_scope') + ->whereColumn('owner_scope.task_id', $taskColumn) + ->where('owner_scope.owner', 1); + if ($userids !== null) { + $query->whereIn('owner_scope.userid', $userids ?: [0]); + } + }; + } + + /** + * 优先级分布(包含无负责人任务)。 + */ + protected static function priorityDistribution(array $context): array + { + $taskAlias = DB::getTablePrefix() . 't'; + $rows = self::baseTaskBuilder($context) + ->whereNull('t.complete_at') + ->selectRaw("{$taskAlias}.p_level, COUNT(*) AS stat_num") + ->groupBy('t.p_level') + ->pluck('stat_num', 't.p_level'); + + $result = []; + $matched = []; + foreach (self::priorityList() as $item) { + $level = intval($item['priority']); + $matched[] = $level; + $result[] = [ + 'level' => $level, + 'name' => $item['name'], + 'color' => $item['color'], + 'num' => intval($rows->get($level, 0)), + ]; + } + + $unset = 0; + foreach ($rows as $level => $num) { + if (!in_array(intval($level), $matched, true)) { + $unset += intval($num); + } + } + if ($unset > 0) { + $result[] = [ + 'level' => -1, + 'name' => '', + 'color' => '#c5c8ce', + 'num' => $unset, + ]; + } + return $result; + } + + /** + * 成员任务分配。流程阶段与超期风险独立统计,无工作流的普通未完成任务归入待处理; + * 多负责人任务会分别计入每个负责人的工作量,团队总数仍按任务去重。 + */ + protected static function memberDistribution(array $context, Carbon $now): array + { + $memberUserids = $context['member_userids']; + if (empty($memberUserids) || empty($context['project_ids'])) { + return array_values(array_map(fn($member) => array_merge($member, [ + 'total' => 0, + 'overdue' => 0, + 'segments' => ['progress' => 0, 'start' => 0, 'test' => 0], + ]), $context['member_map'])); + } + + $nowText = $now->toDateTimeString(); + $prefix = DB::getTablePrefix(); + $taskAlias = $prefix . 't'; + $taskUserAlias = $prefix . 'tu'; + $flowAlias = $prefix . 'fi'; + $rows = DB::table('project_task_users as tu') + ->join('project_tasks as t', 't.id', '=', 'tu.task_id') + ->join('projects as p', 'p.id', '=', 't.project_id') + ->leftJoin('project_flow_items as fi', 'fi.id', '=', 't.flow_item_id') + ->whereIn('tu.userid', $memberUserids) + ->where('tu.owner', 1) + ->whereIn('t.project_id', $context['project_ids']) + ->where('t.parent_id', 0) + ->where('t.visibility', 1) + ->whereNull('t.complete_at') + ->whereNull('t.archived_at') + ->whereNull('t.deleted_at') + ->whereNull('p.archived_at') + ->whereNull('p.deleted_at') + ->where(function (Builder $query) { + $query->where('p.department_owner_view', '<>', 'close') + ->orWhereNull('p.department_owner_view'); + }) + ->selectRaw(" + {$taskUserAlias}.userid, + COUNT(DISTINCT {$taskAlias}.id) AS stat_total, + COUNT(DISTINCT CASE WHEN {$taskAlias}.end_at IS NOT NULL AND {$taskAlias}.end_at < ? THEN {$taskAlias}.id END) AS stat_overdue, + COUNT(DISTINCT CASE WHEN {$flowAlias}.status = 'progress' THEN {$taskAlias}.id END) AS stat_progress, + COUNT(DISTINCT CASE WHEN {$flowAlias}.status = 'test' THEN {$taskAlias}.id END) AS stat_test, + COUNT(DISTINCT CASE WHEN {$flowAlias}.status IS NULL OR {$flowAlias}.status NOT IN ('progress', 'test') THEN {$taskAlias}.id END) AS stat_start + ", [$nowText]) + ->groupBy('tu.userid') + ->get() + ->keyBy(fn($row) => intval($row->userid)); + + $members = []; + foreach ($context['member_map'] as $userid => $member) { + $row = $rows->get(intval($userid)); + $members[] = array_merge($member, [ + 'total' => intval($row->stat_total ?? 0), + 'overdue' => intval($row->stat_overdue ?? 0), + 'segments' => [ + 'progress' => intval($row->stat_progress ?? 0), + 'start' => intval($row->stat_start ?? 0), + 'test' => intval($row->stat_test ?? 0), + ], + ]); + } + usort($members, function ($a, $b) { + return $b['overdue'] <=> $a['overdue'] + ?: $b['total'] <=> $a['total'] + ?: $a['userid'] <=> $b['userid']; + }); + return $members; + } + + /** + * 任务负责人数据,范围内成员优先展示;成员筛选时将该成员置首。 + */ + protected static function taskOwners(array $taskIds, array $memberUserids, int $preferredUserid = 0): array + { + if (empty($taskIds)) { + return []; + } + $rows = DB::table('project_task_users') + ->whereIn('task_id', $taskIds) + ->where('owner', 1) + ->orderBy('id') + ->get(['task_id', 'userid']); + $userids = $rows->pluck('userid')->map(fn($id) => intval($id))->unique()->values()->toArray(); + $users = empty($userids) ? collect() : User::select(User::$basicField) + ->whereIn('userid', $userids) + ->get() + ->keyBy('userid'); + $memberMap = array_fill_keys($memberUserids, true); + $owners = []; + foreach ($rows as $row) { + $userid = intval($row->userid); + $user = $users->get($userid); + $owners[intval($row->task_id)][] = [ + 'userid' => $userid, + 'nickname' => $user?->nickname ?? '', + ]; + } + foreach ($owners as &$list) { + usort($list, function ($a, $b) use ($memberMap, $preferredUserid) { + if ($preferredUserid > 0) { + $preferred = ($b['userid'] === $preferredUserid) <=> ($a['userid'] === $preferredUserid); + if ($preferred !== 0) { + return $preferred; + } + } + $managed = isset($memberMap[$b['userid']]) <=> isset($memberMap[$a['userid']]); + return $managed !== 0 ? $managed : $a['userid'] <=> $b['userid']; + }); + } + unset($list); + return $owners; + } + + /** + * 在全部部门中一次性展开下级部门,避免递归 N+1。 + */ + protected static function expandDepartmentIds(array $selectedIds): array + { + $children = UserDepartment::select(['id', 'parent_id']) + ->get() + ->groupBy(fn(UserDepartment $department) => intval($department->parent_id)); + $result = []; + $queue = array_values(array_unique(array_map('intval', $selectedIds))); + while (!empty($queue)) { + $departmentId = array_shift($queue); + if ($departmentId <= 0 || isset($result[$departmentId])) { + continue; + } + $result[$departmentId] = true; + foreach ($children->get($departmentId, collect()) as $child) { + $queue[] = intval($child->id); + } + } + return array_keys($result); + } + + /** + * 当前范围内的在职非机器人成员。users.department 为现有权限口径的权威数据源。 + */ + protected static function loadMembers(array $departmentIds): Collection + { + if (empty($departmentIds)) { + return collect(); + } + return User::select(User::$basicField) + ->whereNull('disable_at') + ->where('bot', 0) + ->where(function ($query) use ($departmentIds) { + foreach ($departmentIds as $departmentId) { + $query->orWhere('department', 'like', "%,{$departmentId},%"); + } + }) + ->orderBy('userid') + ->get(); + } + + /** + * 成员参与且允许负责人查看的活动项目。 + */ + protected static function loadProjectIds(array $memberUserids): array + { + if (empty($memberUserids)) { + return []; + } + return DB::table('project_users as pu') + ->join('projects as p', 'p.id', '=', 'pu.project_id') + ->whereIn('pu.userid', $memberUserids) + ->whereNull('p.archived_at') + ->whereNull('p.deleted_at') + ->where(function (Builder $query) { + $query->where('p.department_owner_view', '<>', 'close') + ->orWhereNull('p.department_owner_view'); + }) + ->distinct() + ->orderBy('p.id') + ->pluck('p.id') + ->map(fn($id) => intval($id)) + ->toArray(); + } + + protected static function normalizeSelectedDepartmentIds($selectedIds, array $managedIds): array + { + if ($selectedIds === null || $selectedIds === '' || $selectedIds === 'all' || $selectedIds === []) { + return $managedIds; + } + if (!is_array($selectedIds)) { + $selectedIds = explode(',', (string)$selectedIds); + } + return array_values(array_unique(array_intersect( + array_map('intval', $selectedIds), + $managedIds + ))); + } + + protected static function highPriorityLevels(): array + { + return array_slice(self::priorityLevels(), 0, self::HIGH_PRIORITY_COUNT); + } + + protected static function priorityList(): array + { + return Setting::normalizeTaskPriorityList(Base::setting('priority')); + } +} diff --git a/database/migrations/2026_07_18_000001_add_dashboard_team_indexes.php b/database/migrations/2026_07_18_000001_add_dashboard_team_indexes.php new file mode 100644 index 000000000..78978b757 --- /dev/null +++ b/database/migrations/2026_07_18_000001_add_dashboard_team_indexes.php @@ -0,0 +1,40 @@ +index( + ['project_id', 'parent_id', 'visibility', 'archived_at', 'deleted_at', 'complete_at', 'end_at'], + 'idx_pt_dashboard_due' + ); + $table->index( + ['project_id', 'parent_id', 'visibility', 'archived_at', 'deleted_at', 'complete_at', 'p_level'], + 'idx_pt_dashboard_priority' + ); + }); + + Schema::table('project_task_users', function (Blueprint $table) { + $table->index(['task_id', 'owner', 'userid'], 'idx_ptu_task_owner_user'); + $table->index(['userid', 'owner', 'task_id'], 'idx_ptu_user_owner_task'); + }); + } + + public function down() + { + Schema::table('project_tasks', function (Blueprint $table) { + $table->dropIndex('idx_pt_dashboard_due'); + $table->dropIndex('idx_pt_dashboard_priority'); + }); + + Schema::table('project_task_users', function (Blueprint $table) { + $table->dropIndex('idx_ptu_task_owner_user'); + $table->dropIndex('idx_ptu_user_owner_task'); + }); + } +}; diff --git a/language/original-api.txt b/language/original-api.txt index 1f46fc375..e85e0dc99 100644 --- a/language/original-api.txt +++ b/language/original-api.txt @@ -1041,3 +1041,5 @@ scene 暂未实现:(*) upload_id 不能为空 合并繁忙,请稍后再试 仅项目负责人或任务相关成员删除 +未开启部门负责人视角功能 +没有可查看的部门数据 diff --git a/routes/api-map.md b/routes/api-map.md index cab2088bb..b3377e173 100644 --- a/routes/api-map.md +++ b/routes/api-map.md @@ -2,7 +2,7 @@ > 此文件由 `php artisan doc:api-map` 生成,勿手改。 -接口总数:312 +接口总数:314 ## 路由规则 @@ -163,6 +163,13 @@ API 使用动态路由(见 `routes/web.php`),URL 段映射为控制器方 | api/project/task/ai_apply | task__ai_apply() | post | 采纳AI建议 | | api/project/task/ai_dismiss | task__ai_dismiss() | post | 忽略AI建议 | +## dashboard(DashboardController) + +| URL | 方法名 | HTTP | 说明 | +| --- | --- | --- | --- | +| api/dashboard/team/stats | team__stats() | get | 负责人视角统计 | +| api/dashboard/team/tasks | team__tasks() | get | 负责人视角任务列表 | + ## system(SystemController) | URL | 方法名 | HTTP | 说明 | diff --git a/routes/web.php b/routes/web.php index 801d74ff9..f3deb495c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -16,6 +16,7 @@ use App\Http\Controllers\Api\ComplaintController; use App\Http\Controllers\Api\SearchController; use App\Http\Controllers\Api\AppsController; use App\Http\Controllers\Api\UploadController; +use App\Http\Controllers\Api\DashboardController; /* |-------------------------------------------------------------------------- @@ -39,6 +40,9 @@ Route::prefix('api')->middleware(['webapi'])->group(function () { // 项目 Route::any('project/{method}', ProjectController::class); Route::any('project/{method}/{action}', ProjectController::class); + // 仪表盘 + Route::any('dashboard/{method}', DashboardController::class); + Route::any('dashboard/{method}/{action}', DashboardController::class); // 系统 Route::any('system/{method}', SystemController::class); Route::any('system/{method}/{action}', SystemController::class); diff --git a/tests/Feature/DashboardTeamTest.php b/tests/Feature/DashboardTeamTest.php new file mode 100644 index 000000000..9f4be33cc --- /dev/null +++ b/tests/Feature/DashboardTeamTest.php @@ -0,0 +1,170 @@ +createUser('dashboard_member_a'); + $memberB = $this->createUser('dashboard_member_b'); + $outside = $this->createUser('dashboard_outside'); + $projectId = DB::table('projects')->insertGetId([ + 'name' => 'Dashboard Team Project', + 'userid' => $memberA, + 'department_owner_view' => 'open', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $overdue = $this->createTask($projectId, [ + 'name' => 'Managed overdue', + 'end_at' => Carbon::now()->subDay(), + 'p_level' => 1, + ]); + $this->addOwner($projectId, $overdue, $memberA); + + $noOwnerSoon = $this->createTask($projectId, [ + 'name' => 'No owner soon', + 'end_at' => Carbon::now()->addDay(), + 'p_level' => 2, + ]); + + $outsideTask = $this->createTask($projectId, ['name' => 'Outside owner']); + $this->addOwner($projectId, $outsideTask, $outside); + + $subtask = $this->createTask($projectId, [ + 'name' => 'Subtask', + 'parent_id' => $overdue, + ]); + $this->addOwner($projectId, $subtask, $memberA); + + $privateTask = $this->createTask($projectId, [ + 'name' => 'Private task', + 'visibility' => 2, + ]); + $this->addOwner($projectId, $privateTask, $memberA); + + $weekCompleted = $this->createTask($projectId, [ + 'name' => 'Completed this week', + 'complete_at' => Carbon::now()->subDay(), + ]); + $this->addOwner($projectId, $weekCompleted, $memberA); + + $lastWeekCompleted = $this->createTask($projectId, [ + 'name' => 'Completed last week', + 'complete_at' => Carbon::parse('2026-07-10 10:00:00'), + ]); + $this->addOwner($projectId, $lastWeekCompleted, $memberA); + + $context = [ + 'viewer_userid' => $memberA, + 'selected_department_ids' => [1], + 'department_ids' => [1], + 'member_userids' => [$memberA, $memberB], + 'member_map' => [ + $memberA => ['userid' => $memberA, 'nickname' => 'Member A', 'userimg' => ''], + $memberB => ['userid' => $memberB, 'nickname' => 'Member B', 'userimg' => ''], + ], + 'project_ids' => [$projectId], + 'own_project_id_map' => [$projectId => true], + ]; + + $stats = DashboardTeam::stats($context); + $this->assertSame(2, $stats['blocks']['uncompleted']); + $this->assertSame(1, $stats['blocks']['overdue']); + $this->assertSame(1, $stats['blocks']['due_soon']); + $this->assertSame(1, $stats['blocks']['no_owner']); + $this->assertSame(1, $stats['blocks']['week_completed']); + $this->assertSame(1, $stats['blocks']['last_week_completed']); + $this->assertSame(2, array_sum(array_column($stats['priority'], 'num'))); + $memberStats = collect($stats['members'])->firstWhere('userid', $memberA); + $this->assertSame(1, $memberStats['total']); + $this->assertSame(1, $memberStats['overdue']); + $this->assertSame(['progress' => 0, 'start' => 1, 'test' => 0], $memberStats['segments']); + $this->assertSame($memberStats['total'], array_sum($memberStats['segments'])); + + $uncompleted = DashboardTeam::tasks($context, ['type' => 'uncompleted', 'member_id' => 0, 'level' => null]); + $overdueList = DashboardTeam::tasks($context, ['type' => 'overdue', 'member_id' => 0, 'level' => null]); + $soonList = DashboardTeam::tasks($context, ['type' => 'soon', 'member_id' => 0, 'level' => null]); + $noOwnerList = DashboardTeam::tasks($context, ['type' => 'noowner', 'member_id' => 0, 'level' => null]); + $memberList = DashboardTeam::tasks($context, ['type' => '', 'member_id' => $memberA, 'level' => null]); + + $this->assertSame(2, $uncompleted->total()); + $this->assertSame(1, $overdueList->total()); + $this->assertSame(1, $soonList->total()); + $this->assertSame(1, $noOwnerList->total()); + $this->assertSame(1, $memberList->total()); + $this->assertSame($noOwnerSoon, $noOwnerList->items()[0]['id']); + + $newTask = $this->createTask($projectId, ['name' => 'Refresh cache task']); + $this->addOwner($projectId, $newTask, $memberA); + $this->assertSame(2, DashboardTeam::stats($context)['blocks']['uncompleted']); + $this->assertSame(3, DashboardTeam::stats($context, true)['blocks']['uncompleted']); + + $nextTask = $this->createTask($projectId, ['name' => 'Next refresh cache task']); + $this->addOwner($projectId, $nextTask, $memberA); + $this->assertSame(4, DashboardTeam::stats($context, true)['blocks']['uncompleted']); + } + + private function createUser(string $name): int + { + return DB::table('users')->insertGetId([ + 'email' => $name . '_' . uniqid() . '@test.local', + 'nickname' => $name, + 'password' => md5('123456'), + 'department' => '', + 'bot' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ], 'userid'); + } + + private function createTask(int $projectId, array $data): int + { + return DB::table('project_tasks')->insertGetId(array_merge([ + 'project_id' => $projectId, + 'parent_id' => 0, + 'name' => 'Dashboard task', + 'visibility' => 1, + 'p_level' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ], $data)); + } + + private function addOwner(int $projectId, int $taskId, int $userid): void + { + DB::table('project_task_users')->insert([ + 'project_id' => $projectId, + 'task_id' => $taskId, + 'task_pid' => $taskId, + 'userid' => $userid, + 'owner' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +}