diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php index 3e97444d7..178beca24 100755 --- a/app/Http/Controllers/Api/ProjectController.php +++ b/app/Http/Controllers/Api/ProjectController.php @@ -46,6 +46,7 @@ use App\Models\ProjectTaskTemplate; use App\Models\ProjectTag; use App\Models\ProjectTaskRelation; use App\Models\ProjectTaskAiEvent; +use App\Models\UserDepartment; use App\Module\AiTaskSuggestion; use App\Observers\ProjectTaskObserver; @@ -128,6 +129,7 @@ class ProjectController extends AbstractController public function lists() { $user = User::auth(); + $departmentView = UserDepartment::ownerViewContext($user); // $all = Request::input('all'); $type = Request::input('type', 'all'); @@ -141,6 +143,9 @@ class ProjectController extends AbstractController if ($all) { $user->identity('admin'); $builder = Project::allData(); + } elseif ($departmentView['enabled']) { + $projectIds = array_values(array_unique(array_merge($departmentView['own_project_ids'], $departmentView['project_ids']))); + $builder = Project::allData()->whereIn('projects.id', $projectIds); } else { $builder = Project::authData(); } @@ -180,8 +185,9 @@ class ProjectController extends AbstractController ->orderBy('project_users.sort') ->orderByDesc('projects.id') ->paginate(Base::getPaginate(100, 50)); - $list->transform(function (Project $project) use ($getstatistics, $getuserid, $user) { + $list->transform(function (Project $project) use ($getstatistics, $getuserid, $user, $departmentView) { $array = $project->toArray(); + $array = UserDepartment::appendDepartmentReadonlyProject($array, $departmentView); if ($getuserid == 'yes') { $array['userid_list'] = ProjectUser::whereProjectId($project->id)->pluck('userid')->toArray(); } @@ -250,13 +256,15 @@ class ProjectController extends AbstractController public function one() { $user = User::auth(); + $departmentView = UserDepartment::ownerViewContext($user, true); // $project_id = intval(Request::input('project_id')); // - $project = Project::userProject($project_id); + $project = Project::findForDepartmentView($project_id); $data = array_merge($project->toArray(), $project->getTaskStatistics($user->userid), [ 'project_user' => $project->projectUser, ]); + $data = UserDepartment::appendDepartmentReadonlyProject($data, $departmentView); // return Base::retSuccess('success', $data); } @@ -999,7 +1007,7 @@ class ProjectController extends AbstractController // $project_id = intval(Request::input('project_id')); // 项目 - $project = Project::userProject($project_id); + $project = Project::findForDepartmentView($project_id); // $list = ProjectColumn::whereProjectId($project->id) ->orderBy('sort') @@ -1230,6 +1238,7 @@ class ProjectController extends AbstractController { $user = User::auth(); $userid = $user->userid; + $departmentView = UserDepartment::ownerViewContext($user, true); // $parent_id = intval(Request::input('parent_id')); $project_id = intval(Request::input('project_id')); @@ -1294,7 +1303,7 @@ class ProjectController extends AbstractController if ($parent_id > 0) { $isArchived = str_replace(['all', 'yes', 'no'], [null, false, true], $archived); $isDeleted = str_replace(['all', 'yes', 'no'], [null, false, true], $deleted); - ProjectTask::userTask($parent_id, $isArchived, $isDeleted); + ProjectTask::findForDepartmentView($parent_id, $isArchived, $isDeleted); $scopeAll = true; $archived = 'all'; $builder->where('project_tasks.parent_id', $parent_id); @@ -1302,17 +1311,23 @@ class ProjectController extends AbstractController $builder->where('project_tasks.parent_id', 0); } if ($project_id > 0) { - Project::userProject($project_id); + if (!UserDepartment::isDepartmentReadonlyProject($departmentView, $project_id)) { + Project::userProject($project_id); + } $scopeAll = true; $builder->where('project_tasks.project_id', $project_id); } if (!$scopeAll && $scope === 'all_project') { $scopeAll = true; - $builder->whereIn('project_tasks.project_id', function ($query) use ($userid) { - $query->select('project_id') - ->from('project_users') - ->where('userid', $userid); - }); + if ($departmentView['enabled']) { + $builder->whereIn('project_tasks.project_id', array_values(array_unique(array_merge($departmentView['own_project_ids'], $departmentView['project_ids'])))); + } else { + $builder->whereIn('project_tasks.project_id', function ($query) use ($userid) { + $query->select('project_id') + ->from('project_users') + ->where('userid', $userid); + }); + } } if ($scopeAll) { $builder->allData(); @@ -1391,12 +1406,15 @@ class ProjectController extends AbstractController $query->on('project_sub_task_visibility_users.task_id', '=', 'project_tasks.parent_id'); $query->where('project_sub_task_visibility_users.userid', $userid); }); - $builder->where(function ($query) use ($userid) { + $builder->where(function ($query) use ($userid, $departmentView) { $query->where("project_tasks.visibility", 1); $query->orWhere("project_users.userid", $userid); $query->orWhere("project_task_users.userid", $userid); $query->orWhere("project_task_visibility_users.userid", $userid); $query->orWhere("project_sub_task_visibility_users.userid", $userid); + if ($departmentView['enabled']) { + $query->orWhereIn('project_tasks.project_id', $departmentView['project_ids']); + } }); // 优化子查询汇总 $builder->leftJoinSub(function ($query) { @@ -1439,6 +1457,7 @@ class ProjectController extends AbstractController $data = $list->toArray(); // 还原字段 foreach($data['data'] as &$item){ + $item['department_readonly'] = UserDepartment::isDepartmentReadonlyProject($departmentView, intval($item['project_id'])); $item['file_num'] = $item['_file_num'] ?: 0; $item['msg_num'] = $item['_msg_num'] ?: 0; $item['sub_num'] = $item['_sub_num'] ?: 0; @@ -2018,17 +2037,18 @@ class ProjectController extends AbstractController public function task__one() { $user = User::auth(); + $departmentView = UserDepartment::ownerViewContext($user, true); // $task_id = intval(Request::input('task_id')); $archived = Request::input('archived', 'no'); // $isArchived = str_replace(['all', 'yes', 'no'], [null, false, true], $archived); - $task = ProjectTask::userTask($task_id, $isArchived, true, ['taskUser', 'taskTag']); + $task = ProjectTask::findForDepartmentView($task_id, $isArchived, true, ['taskUser', 'taskTag']); // 项目可见性 $projectOwnerids = ProjectUser::whereProjectId($task->project_id) ->whereIn('owner', [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY]) ->pluck('userid')->map(fn($v) => (int)$v)->toArray(); // 项目负责人(含项目管理员) - if ($task->visibility != 1 && !in_array($user->userid, $projectOwnerids)) { + if (!UserDepartment::isDepartmentReadonlyProject($departmentView, intval($task->project_id)) && $task->visibility != 1 && !in_array($user->userid, $projectOwnerids)) { $taskUserids = ProjectTaskUser::whereTaskId($task_id)->pluck('userid')->toArray(); //任务负责人、协助人 $subTaskUserids = ProjectTaskUser::whereTaskPid($task_id)->pluck('userid')->toArray(); //子任务负责人、协助人 $visibleUserids = ProjectTaskVisibilityUser::whereTaskId($task_id)->pluck('userid')->toArray(); //可见人 @@ -2039,6 +2059,7 @@ class ProjectController extends AbstractController } // $data = $task->toArray(); + $data['department_readonly'] = UserDepartment::isDepartmentReadonlyProject($departmentView, intval($task->project_id)); $data['project_name'] = $task->project?->name; $data['column_name'] = $task->projectColumn?->name; $data['visibility_appointor'] = $task->visibility == 1 ? [0] : ProjectTaskVisibilityUser::whereTaskId($task_id)->pluck('userid'); @@ -2067,7 +2088,7 @@ class ProjectController extends AbstractController return Base::retError('参数错误', ['task_id' => $task_id]); } // - $task = ProjectTask::userTask($task_id); + $task = ProjectTask::findForDepartmentView($task_id); // return Base::retSuccess('success', [ 'id' => $task->id, @@ -2099,7 +2120,7 @@ class ProjectController extends AbstractController return Base::retError('参数错误', ['task_id' => $task_id]); } - $task = ProjectTask::userTask($task_id, null); + $task = ProjectTask::findForDepartmentView($task_id, null); $relations = ProjectTaskRelation::whereTaskId($task->id) ->orderByDesc('updated_at') @@ -2117,7 +2138,7 @@ class ProjectController extends AbstractController $relatedTasks = []; foreach ($relatedTaskIds as $relatedId) { try { - $relatedTask = ProjectTask::userTask($relatedId, null, true, ['project', 'projectColumn']); + $relatedTask = ProjectTask::findForDepartmentView($relatedId, null, true, ['project', 'projectColumn']); $flowItemParts = explode('|', $relatedTask->flow_item_name ?: ''); $flowItemStatus = $flowItemParts[0] ?? ''; @@ -2243,7 +2264,7 @@ class ProjectController extends AbstractController $task_id = intval(Request::input('task_id')); $history_id = intval(Request::input('history_id')); // - $task = ProjectTask::userTask($task_id, null); + $task = ProjectTask::findForDepartmentView($task_id, null); // if ($history_id > 0) { $taskContent = ProjectTaskContent::whereTaskId($task->id)->whereId($history_id)->first(); @@ -2283,7 +2304,7 @@ class ProjectController extends AbstractController // $task_id = intval(Request::input('task_id')); // - $task = ProjectTask::userTask($task_id, null); + $task = ProjectTask::findForDepartmentView($task_id, null); // $data = ProjectTaskContent::select(['id', 'task_id', 'desc', 'userid', 'created_at']) ->whereTaskId($task->id) @@ -2312,7 +2333,7 @@ class ProjectController extends AbstractController // $task_id = intval(Request::input('task_id')); // - $task = ProjectTask::userTask($task_id, null); + $task = ProjectTask::findForDepartmentView($task_id, null); // return Base::retSuccess('success', $task->taskFile); } @@ -2401,7 +2422,7 @@ class ProjectController extends AbstractController $data = $file->toArray(); $data['path'] = $file->getRawOriginal('path'); // - ProjectTask::userTask($file->task_id, null); + ProjectTask::findForDepartmentView($file->task_id, null); // UserRecentItem::record( $user->userid, @@ -2442,7 +2463,7 @@ class ProjectController extends AbstractController abort_if(empty($file), 403, "This file not exist."); // try { - ProjectTask::userTask($file->task_id, null); + ProjectTask::findForDepartmentView($file->task_id, null); } catch (\Throwable $e) { abort(403, $e->getMessage() ?: "This file not support download."); } @@ -3389,7 +3410,7 @@ class ProjectController extends AbstractController // $project_id = intval(Request::input('project_id')); // - $project = Project::userProject($project_id, true); + $project = Project::findForDepartmentView($project_id, true); // $list = ProjectFlow::with(['ProjectFlowItem'])->whereProjectId($project->id)->get(); return Base::retSuccess('success', $list); @@ -3488,10 +3509,10 @@ class ProjectController extends AbstractController // $builder = ProjectLog::select(["*"]); if ($task_id > 0) { - $task = ProjectTask::userTask($task_id, null); + $task = ProjectTask::findForDepartmentView($task_id, null); $builder->whereTaskId($task->id); } else { - $project = Project::userProject($project_id); + $project = Project::findForDepartmentView($project_id); $builder->with(['projectTask:id,parent_id,name'])->whereProjectId($project->id)->whereTaskOnly(0); } // diff --git a/app/Http/Controllers/Api/SystemController.php b/app/Http/Controllers/Api/SystemController.php index 53870bfff..586e1f006 100755 --- a/app/Http/Controllers/Api/SystemController.php +++ b/app/Http/Controllers/Api/SystemController.php @@ -94,6 +94,7 @@ class SystemController extends AbstractController 'unclaimed_task_reminder', 'unclaimed_task_reminder_time', 'task_ai_auto_analyze', + 'department_owner_project_view', ])) { unset($all[$key]); } @@ -148,6 +149,7 @@ class SystemController extends AbstractController $setting['unclaimed_task_reminder'] = $setting['unclaimed_task_reminder'] ?: 'close'; $setting['unclaimed_task_reminder_time'] = $setting['unclaimed_task_reminder_time'] ?: ''; $setting['task_ai_auto_analyze'] = $setting['task_ai_auto_analyze'] ?: 'open'; + $setting['department_owner_project_view'] = $setting['department_owner_project_view'] ?: 'close'; $setting['server_timezone'] = config('app.timezone'); $setting['server_version'] = Base::getVersion(); // diff --git a/app/Http/Controllers/Api/UsersController.php b/app/Http/Controllers/Api/UsersController.php index c7aaed184..8c670b3ed 100755 --- a/app/Http/Controllers/Api/UsersController.php +++ b/app/Http/Controllers/Api/UsersController.php @@ -404,9 +404,22 @@ class UsersController extends AbstractController $data['nickname_original'] = $user->getRawOriginal('nickname'); $data['department_name'] = $user->getDepartmentName(); $data['department_owner'] = UserDepartment::where('parent_id',0)->where('owner_userid', $user->userid)->exists(); // 适用默认部门下第1级负责人才能添加部门OKR + $data['managed_departments'] = UserDepartment::getManagedDepartments($user->userid)->toArray(); return Base::retSuccess('success', $data); } + /** + * @api {get} api/users/info/managed_departments 获取我可切换负责人视角的部门列表 + */ + public function info__managed_departments() + { + $user = User::auth(); + if (Base::settingFind('system', 'department_owner_project_view', 'close') !== 'open') { + return Base::retSuccess('success', []); + } + return Base::retSuccess('success', UserDepartment::getManagedDepartments($user->userid)); + } + /** * @api {get} api/users/info/departments 获取我的部门列表 * @@ -3272,7 +3285,7 @@ class UsersController extends AbstractController return Base::retError('参数错误'); } // - ProjectTask::userTask($task_id, null, null); + ProjectTask::findForDepartmentView($task_id, null, null); // UserTaskBrowse::recordBrowse($user->userid, $task_id); // diff --git a/app/Models/Project.php b/app/Models/Project.php index 8f4937b08..b1c6fb20b 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -723,4 +723,31 @@ class Project extends AbstractModel } return $project; } + + /** + * 获取项目(含部门负责人只读视角兜底) + * @param int $project_id + * @param null|bool $archived true:仅限未归档, false:仅限已归档, null:不限制 + * @param null|bool|string $mustOwner 仅限 null 时尝试部门只读视角 + * @return self + */ + public static function findForDepartmentView($project_id, $archived = true, $mustOwner = null) + { + $user = User::auth(); + $departmentView = UserDepartment::ownerViewContext($user, true); + if (UserDepartment::isDepartmentReadonlyProject($departmentView, intval($project_id)) && $mustOwner === null) { + $project = self::allData()->where('projects.id', intval($project_id))->first(); + if (empty($project)) { + throw new ApiException('项目不存在或已被删除', [ 'project_id' => $project_id ], -4001); + } + if ($archived === true && $project->archived_at != null) { + throw new ApiException('项目已归档', [ 'project_id' => $project_id ], -4001); + } + if ($archived === false && $project->archived_at == null) { + throw new ApiException('项目未归档', [ 'project_id' => $project_id ]); + } + return $project; + } + return self::userProject($project_id, $archived, $mustOwner); + } } diff --git a/app/Models/ProjectTask.php b/app/Models/ProjectTask.php index 3c99a9552..001274475 100644 --- a/app/Models/ProjectTask.php +++ b/app/Models/ProjectTask.php @@ -2258,6 +2258,39 @@ class ProjectTask extends AbstractModel return $task; } + /** + * 获取任务(含部门负责人只读视角兜底) + * @param int $task_id + * @param null|bool $archived true:仅限未归档, false:仅限已归档, null:不限制 + * @param null|bool $trashed true:仅限未删除, false:仅限已删除, null:不限制 + * @param array $with + * @return self + */ + public static function findForDepartmentView($task_id, $archived = true, $trashed = true, $with = []) + { + $user = User::auth(); + $departmentView = UserDepartment::ownerViewContext($user, true); + if ($departmentView['enabled']) { + $builder = self::with($with)->allData()->where('project_tasks.id', intval($task_id)); + if ($trashed === false) { + $builder->onlyTrashed(); + } elseif ($trashed === null) { + $builder->withTrashed(); + } + $task = $builder->first(); + if (!empty($task) && UserDepartment::isDepartmentReadonlyProject($departmentView, intval($task->project_id))) { + if ($archived === true && $task->archived_at != null) { + throw new ApiException('任务已归档', ['task_id' => $task_id]); + } + if ($archived === false && $task->archived_at == null) { + throw new ApiException('任务未归档', ['task_id' => $task_id]); + } + return $task; + } + } + return self::userTask($task_id, $archived, $trashed, $with); + } + /** * 构建指定周期内的未完成任务查询(用于周报/日报等) * @param int $userid diff --git a/app/Models/UserDepartment.php b/app/Models/UserDepartment.php index 944dc2f25..77f708633 100644 --- a/app/Models/UserDepartment.php +++ b/app/Models/UserDepartment.php @@ -3,7 +3,9 @@ namespace App\Models; use App\Exceptions\ApiException; +use App\Module\Base; use Cache; +use Request; /** * App\Models\UserDepartment @@ -411,6 +413,93 @@ class UserDepartment extends AbstractModel return array_unique($subIds); } + /** + * 获取用户可切换负责人视角的部门(正负责人 + 部门管理员) + * @param int $userid + * @return \Illuminate\Support\Collection + */ + public static function getManagedDepartments($userid) + { + $userid = intval($userid); + if ($userid <= 0) { + return collect(); + } + $deputyDepartmentIds = \DB::table('user_department_owners') + ->where('userid', $userid) + ->pluck('department_id') + ->map(fn($v) => intval($v)) + ->toArray(); + + return self::select(['id', 'name', 'parent_id', 'owner_userid']) + ->where(function ($query) use ($userid, $deputyDepartmentIds) { + $query->where('owner_userid', $userid); + if ($deputyDepartmentIds) { + $query->orWhereIn('id', $deputyDepartmentIds); + } + }) + ->orderBy('id') + ->get(); + } + + /** + * 获取用户选择的负责人视角部门范围(含所有下级部门) + * @param int $userid + * @param array|string|null $selectedIds all/空表示全部可管理部门 + * @return array + */ + public static function getManagedDepartmentScopeIds($userid, $selectedIds = null): array + { + $managedIds = self::getManagedDepartments($userid)->pluck('id')->map(fn($v) => intval($v))->toArray(); + if (empty($managedIds)) { + return []; + } + if ($selectedIds === 'all' || $selectedIds === null || $selectedIds === '' || $selectedIds === []) { + $selected = $managedIds; + } else { + if (!is_array($selectedIds)) { + $selectedIds = explode(',', (string)$selectedIds); + } + $selected = array_values(array_intersect( + array_map('intval', $selectedIds), + $managedIds + )); + } + if (empty($selected)) { + return []; + } + $scopeIds = []; + foreach ($selected as $departmentId) { + $scopeIds[] = $departmentId; + $scopeIds = array_merge($scopeIds, self::getAllSubDepartmentIds($departmentId)); + } + return array_values(array_unique(array_map('intval', $scopeIds))); + } + + /** + * 获取负责人视角可管理的成员 userid + * @param int $userid + * @param array|string|null $selectedIds + * @return array + */ + public static function getManagedMemberUserids($userid, $selectedIds = null): array + { + $departmentIds = self::getManagedDepartmentScopeIds($userid, $selectedIds); + if (empty($departmentIds)) { + return []; + } + return User::select(['userid']) + ->where(function ($query) use ($departmentIds) { + foreach ($departmentIds as $departmentId) { + $query->orWhere('department', 'like', "%,{$departmentId},%"); + } + }) + ->pluck('userid') + ->map(fn($v) => intval($v)) + ->unique() + ->values() + ->toArray(); + } + /** * 获取部门基本信息(缓存时间1小时) * @param int|array $ids @@ -453,4 +542,70 @@ class UserDepartment extends AbstractModel return is_array($ids) ? $result : $result->first(); } + /** + * 部门负责人视角上下文(只读)。 + * $defaultAll=true 用于项目内只读辅助接口兜底:前端漏传部门选择时按全部可管理部门判断。 + */ + public static function ownerViewContext(User $user, bool $defaultAll = false): array + { + $ids = Request::input('department_owner_ids', Request::input('department_ids')); + if (($ids === null || $ids === '') && $defaultAll) { + $ids = 'all'; + } + $empty = [ + 'enabled' => false, + 'member_userids' => [], + 'project_ids' => [], + 'project_id_map' => [], + 'own_project_ids' => [], + 'own_project_id_map' => [], + ]; + if ($ids === null || $ids === '' || Base::settingFind('system', 'department_owner_project_view', 'close') !== 'open') { + return $empty; + } + $memberUserids = self::getManagedMemberUserids($user->userid, $ids); + if (empty($memberUserids)) { + return $empty; + } + $projectIds = ProjectUser::whereIn('userid', $memberUserids) + ->pluck('project_id') + ->map(fn($v) => intval($v)) + ->unique() + ->values() + ->toArray(); + $ownProjectIds = ProjectUser::whereUserid($user->userid) + ->pluck('project_id') + ->map(fn($v) => intval($v)) + ->unique() + ->values() + ->toArray(); + return [ + 'enabled' => !empty($projectIds), + 'member_userids' => $memberUserids, + 'project_ids' => $projectIds, + 'project_id_map' => array_fill_keys($projectIds, true), + 'own_project_ids' => $ownProjectIds, + 'own_project_id_map' => array_fill_keys($ownProjectIds, true), + ]; + } + + /** + * 判断项目是否属于部门只读范围(非本人项目) + */ + public static function isDepartmentReadonlyProject(array $context, int $projectId): bool + { + return !empty($context['enabled']) + && isset($context['project_id_map'][$projectId]) + && !isset($context['own_project_id_map'][$projectId]); + } + + /** + * 为项目数据附加部门只读标记 + */ + public static function appendDepartmentReadonlyProject(array $project, array $context): array + { + $project['department_readonly'] = self::isDepartmentReadonlyProject($context, intval($project['id'])); + return $project; + } + } diff --git a/app/Models/WebSocketDialog.php b/app/Models/WebSocketDialog.php index 96413d368..1af0f0c22 100644 --- a/app/Models/WebSocketDialog.php +++ b/app/Models/WebSocketDialog.php @@ -912,6 +912,12 @@ class WebSocketDialog extends AbstractModel if ($projectId > 0 && ProjectUser::whereProjectId($projectId)->whereUserid($userid)->exists()) { return $dialog; } + if ($projectId > 0 && $checkOwner === false) { + $departmentView = UserDepartment::ownerViewContext(User::auth(), true); + if (UserDepartment::isDepartmentReadonlyProject($departmentView, $projectId)) { + return $dialog; + } + } break; case 'okr': diff --git a/resources/assets/js/components/GanttView.vue b/resources/assets/js/components/GanttView.vue index 4fcb64fdb..4191e3b1c 100644 --- a/resources/assets/js/components/GanttView.vue +++ b/resources/assets/js/components/GanttView.vue @@ -73,6 +73,10 @@ export default { itemWidth: { type: Number, default: 100 + }, + readonly: { + type: Boolean, + default: false } }, data() { @@ -328,6 +332,12 @@ export default { this.onDateMove(e.clientX); }, itemMouseDown(e, item) { + if (this.readonly) { + if (e.target.classList.contains('timeline-title')) { + this.clickItem(item); + } + return; + } e.preventDefault(); this.onItemMove(item, e.target, e.clientX); }, @@ -347,6 +357,9 @@ export default { }; }, onItemMove(item, target, clientX) { + if (this.readonly) { + return; + } let type = 'moveX'; if (target.classList.contains('timeline-resizer')) { type = 'moveW'; @@ -384,6 +397,11 @@ export default { } }, onMoveOver(target) { + if (this.readonly) { + this.mouseItem = null; + this.dateMove = null; + return; + } if (this.mouseItem != null) { const {start, end} = this.mouseItem.time; let isM = false; diff --git a/resources/assets/js/components/TEditorTask.vue b/resources/assets/js/components/TEditorTask.vue index 27985ddf8..42989d8bc 100755 --- a/resources/assets/js/components/TEditorTask.vue +++ b/resources/assets/js/components/TEditorTask.vue @@ -8,7 +8,7 @@ :option-full="optionFull" :placeholder="placeholder" :placeholderFull="placeholderFull" - :readOnly="windowTouch" + :readOnly="readonly || windowTouch" :readOnlyFull="false" :readOnlyImagePreview="false" @on-blur="onBlur" @@ -24,10 +24,10 @@ transfer>