OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
52
app/Domain/Projects/Controllers/ChangeCurrentProject.php
Normal file
52
app/Domain/Projects/Controllers/ChangeCurrentProject.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Projects\Controllers;
|
||||
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Core\Controller\Frontcontroller;
|
||||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||||
|
||||
class ChangeCurrentProject extends Controller
|
||||
{
|
||||
private ProjectService $projectService;
|
||||
|
||||
public function init(ProjectService $projectService): void
|
||||
{
|
||||
$this->projectService = $projectService;
|
||||
}
|
||||
|
||||
/**
|
||||
* get - handle get requests
|
||||
*/
|
||||
public function get($params)
|
||||
{
|
||||
$id = (int) ($params['id'] ?? 0);
|
||||
|
||||
if (
|
||||
! isset($params['id']) ||
|
||||
! $this->projectService->isUserAssignedToProject(session('userdata.id'), $id) ||
|
||||
! $project = $this->projectService->getProject($id)
|
||||
) {
|
||||
return Frontcontroller::redirect(BASE_URL.'/errors/error404');
|
||||
}
|
||||
|
||||
$this->projectService->changeCurrentSessionProject($id);
|
||||
|
||||
$defaultURL = '/dashboard/show';
|
||||
$redirectFilter = self::dispatch_filter('defaultProjectUrl', $defaultURL, $project);
|
||||
|
||||
return Frontcontroller::redirect(BASE_URL.$redirectFilter);
|
||||
}
|
||||
|
||||
/**
|
||||
* post - handle post requests (via login for example) and redirects to get
|
||||
*/
|
||||
public function post($params)
|
||||
{
|
||||
if (isset($_GET['id'])) {
|
||||
$id = filter_var($_GET['id'], FILTER_SANITIZE_NUMBER_INT);
|
||||
|
||||
return Frontcontroller::redirect(BASE_URL.'/projects/changeCurrentProject/'.$id);
|
||||
}
|
||||
}
|
||||
}
|
||||
64
app/Domain/Projects/Controllers/Createnew.php
Normal file
64
app/Domain/Projects/Controllers/Createnew.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Projects\Controllers;
|
||||
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Domain\Auth\Models\Roles;
|
||||
use Leantime\Domain\Auth\Services\Auth;
|
||||
use Leantime\Domain\Modulemanager\Services\Modulemanager;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class Createnew extends Controller
|
||||
{
|
||||
private Modulemanager $modulemanager;
|
||||
|
||||
/**
|
||||
* Initializes dependencies.
|
||||
*/
|
||||
public function init(
|
||||
Modulemanager $modulemanager
|
||||
): void {
|
||||
$this->modulemanager = $modulemanager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the create new project type selection.
|
||||
*
|
||||
* @param array $params Request parameters
|
||||
*/
|
||||
public function get(array $params): Response
|
||||
{
|
||||
Auth::authOrRedirect([Roles::$owner, Roles::$admin, Roles::$manager], true);
|
||||
|
||||
$projectTypes = [
|
||||
'strategy' => [
|
||||
'label' => 'label.set_direction',
|
||||
'btnLabel' => 'label.create_strategy',
|
||||
'description' => 'description.strategy',
|
||||
'url' => 'strategyPro/newStrategy',
|
||||
'image' => 'undraw_thought_process_re_om58.svg',
|
||||
'active' => $this->modulemanager->isModuleAvailable('strategyPro'),
|
||||
],
|
||||
'plan' => [
|
||||
'label' => 'label.map_steps',
|
||||
'btnLabel' => 'label.create_plan',
|
||||
'description' => 'description.plan',
|
||||
'url' => 'pgmPro/newProgram',
|
||||
'image' => 'undraw_join_re_w1lh.svg',
|
||||
'active' => $this->modulemanager->isModuleAvailable('pgmPro'),
|
||||
],
|
||||
'project' => [
|
||||
'label' => 'label.launch_endeavour',
|
||||
'btnLabel' => 'label.create_project',
|
||||
'description' => 'description.project',
|
||||
'url' => 'projects/newProject',
|
||||
'image' => 'undraw_complete_task_u2c3.svg',
|
||||
'active' => true,
|
||||
],
|
||||
];
|
||||
|
||||
$this->tpl->assign('projectTypes', $projectTypes);
|
||||
|
||||
return $this->tpl->displayPartial('projects.createnew');
|
||||
}
|
||||
}
|
||||
91
app/Domain/Projects/Controllers/DelProject.php
Normal file
91
app/Domain/Projects/Controllers/DelProject.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Projects\Controllers;
|
||||
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Core\Controller\Frontcontroller;
|
||||
use Leantime\Domain\Auth\Models\Roles;
|
||||
use Leantime\Domain\Auth\Services\Auth;
|
||||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class DelProject extends Controller
|
||||
{
|
||||
private ProjectService $projectService;
|
||||
|
||||
/**
|
||||
* Initializes dependencies.
|
||||
*/
|
||||
public function init(ProjectService $projectService): void
|
||||
{
|
||||
$this->projectService = $projectService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the delete project confirmation page.
|
||||
*
|
||||
* @param array $params Request parameters
|
||||
*/
|
||||
public function get(array $params): Response
|
||||
{
|
||||
Auth::authOrRedirect([Roles::$owner, Roles::$admin, Roles::$manager], true);
|
||||
|
||||
if (! Auth::userIsAtLeast(Roles::$manager)) {
|
||||
return $this->tpl->display('errors.error403', responseCode: 403);
|
||||
}
|
||||
|
||||
if (! isset($params['id'])) {
|
||||
return $this->tpl->display('errors.error403', responseCode: 403);
|
||||
}
|
||||
|
||||
$id = (int) $params['id'];
|
||||
|
||||
$project = $this->projectService->getProject($id);
|
||||
if ($project === false) {
|
||||
return Frontcontroller::redirect(BASE_URL.'/errors/error404');
|
||||
}
|
||||
|
||||
if ($this->projectService->hasTickets($id)) {
|
||||
$this->tpl->setNotification($this->language->__('notification.project_has_tasks'), 'info');
|
||||
}
|
||||
|
||||
$this->tpl->assign('project', $project);
|
||||
|
||||
return $this->tpl->display('projects.delProject');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles project deletion.
|
||||
*
|
||||
* @param array $params Request parameters
|
||||
*/
|
||||
public function post(array $params): Response
|
||||
{
|
||||
Auth::authOrRedirect([Roles::$owner, Roles::$admin, Roles::$manager], true);
|
||||
|
||||
if (! Auth::userIsAtLeast(Roles::$manager)) {
|
||||
return $this->tpl->display('errors.error403', responseCode: 403);
|
||||
}
|
||||
|
||||
if (! isset($params['id'])) {
|
||||
return $this->tpl->display('errors.error403', responseCode: 403);
|
||||
}
|
||||
|
||||
$id = (int) $params['id'];
|
||||
|
||||
$result = $this->projectService->deleteProject($id);
|
||||
|
||||
if ($result === false) {
|
||||
$this->tpl->setNotification($this->language->__('notification.no_permission'), 'error');
|
||||
|
||||
return Frontcontroller::redirect(BASE_URL.'/projects/showAll');
|
||||
}
|
||||
|
||||
$this->projectService->resetCurrentProject();
|
||||
$this->projectService->setCurrentProject();
|
||||
|
||||
$this->tpl->setNotification($this->language->__('notification.project_deleted'), 'success');
|
||||
|
||||
return Frontcontroller::redirect(BASE_URL.'/projects/showAll');
|
||||
}
|
||||
}
|
||||
87
app/Domain/Projects/Controllers/DuplicateProject.php
Normal file
87
app/Domain/Projects/Controllers/DuplicateProject.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Projects\Controllers;
|
||||
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Core\Controller\Frontcontroller;
|
||||
use Leantime\Domain\Auth\Models\Roles;
|
||||
use Leantime\Domain\Auth\Services\Auth;
|
||||
use Leantime\Domain\Clients\Services\Clients as ClientService;
|
||||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class DuplicateProject extends Controller
|
||||
{
|
||||
private ProjectService $projectService;
|
||||
|
||||
private ClientService $clientService;
|
||||
|
||||
/**
|
||||
* Initializes dependencies.
|
||||
*/
|
||||
public function init(
|
||||
ProjectService $projectService,
|
||||
ClientService $clientService
|
||||
): void {
|
||||
$this->projectService = $projectService;
|
||||
$this->clientService = $clientService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the duplicate-project form.
|
||||
*
|
||||
* @param array $params Request parameters
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function get(array $params): Response
|
||||
{
|
||||
Auth::authOrRedirect([Roles::$owner, Roles::$admin, Roles::$manager], true);
|
||||
|
||||
$id = (int) ($params['id'] ?? $_GET['id'] ?? 0);
|
||||
|
||||
if (! Auth::userIsAtLeast(Roles::$manager) || $id <= 0) {
|
||||
return $this->tpl->displayPartial('errors.error403', responseCode: 403);
|
||||
}
|
||||
|
||||
$this->tpl->assign('allClients', $this->clientService->getAll());
|
||||
$this->tpl->assign('project', $this->projectService->getProject($id));
|
||||
|
||||
return $this->tpl->displayPartial('projects.duplicateProject');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the project duplication request.
|
||||
*
|
||||
* @param array $params Request parameters
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function post(array $params): Response
|
||||
{
|
||||
Auth::authOrRedirect([Roles::$owner, Roles::$admin, Roles::$manager], true);
|
||||
|
||||
if (! Auth::userIsAtLeast(Roles::$manager)) {
|
||||
return $this->tpl->displayPartial('errors.error403', responseCode: 403);
|
||||
}
|
||||
|
||||
$id = (int) ($params['id'] ?? $_GET['id'] ?? 0);
|
||||
$assignSameUsers = isset($params['assignSameUsers']);
|
||||
|
||||
$result = $this->projectService->duplicateProject(
|
||||
$id,
|
||||
(int) $params['clientId'],
|
||||
$params['projectName'],
|
||||
$params['startDate'] ?? '',
|
||||
$assignSameUsers
|
||||
);
|
||||
|
||||
$this->tpl->setNotification(
|
||||
sprintf($this->language->__('notifications.project_copied_successfully'), BASE_URL.'/projects/changeCurrentProject/'.$result),
|
||||
'success'
|
||||
);
|
||||
|
||||
return Frontcontroller::redirect(BASE_URL.'/projects/duplicateProject/'.$id);
|
||||
}
|
||||
}
|
||||
0
app/Domain/Projects/Controllers/EditProject.php
Normal file
0
app/Domain/Projects/Controllers/EditProject.php
Normal file
156
app/Domain/Projects/Controllers/NewProject.php
Normal file
156
app/Domain/Projects/Controllers/NewProject.php
Normal file
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Projects\Controllers;
|
||||
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Core\Controller\Frontcontroller;
|
||||
use Leantime\Core\Support\FromFormat;
|
||||
use Leantime\Domain\Auth\Models\Roles;
|
||||
use Leantime\Domain\Auth\Services\Auth;
|
||||
use Leantime\Domain\Clients\Services\Clients as ClientService;
|
||||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class NewProject extends Controller
|
||||
{
|
||||
private ClientService $clientService;
|
||||
|
||||
private ProjectService $projectService;
|
||||
|
||||
/**
|
||||
* Initializes dependencies.
|
||||
*/
|
||||
public function init(
|
||||
ClientService $clientService,
|
||||
ProjectService $projectService
|
||||
): void {
|
||||
$this->clientService = $clientService;
|
||||
$this->projectService = $projectService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the new project form.
|
||||
*
|
||||
* @param array $params Request parameters
|
||||
*/
|
||||
public function get(array $params): Response
|
||||
{
|
||||
Auth::authOrRedirect([Roles::$owner, Roles::$admin, Roles::$manager], true);
|
||||
|
||||
if (! session()->exists('lastPage')) {
|
||||
session(['lastPage' => BASE_URL.'/projects/showAll']);
|
||||
}
|
||||
|
||||
$defaultParent = $_GET['parent'] ?? '';
|
||||
if ($defaultParent === '') {
|
||||
// When creating a project from within a container project (e.g. a program or a
|
||||
// strategy), nest the new project under it by default so it's immediately
|
||||
// associated. The parent picker still lets the user change or clear it.
|
||||
$contextProject = $this->projectService->getProject(session('currentProject'));
|
||||
if (is_array($contextProject) && in_array($contextProject['type'] ?? '', ['program', 'strategy'], true)) {
|
||||
$defaultParent = (string) session('currentProject');
|
||||
}
|
||||
}
|
||||
|
||||
$values = $this->projectService->getNewProjectDefaults($defaultParent);
|
||||
|
||||
$this->tpl->assign('menuTypes', $this->projectService->getMenuTypes());
|
||||
$this->tpl->assign('project', $values);
|
||||
$this->tpl->assign('availableUsers', $this->projectService->getAllUsers());
|
||||
$this->tpl->assign('clients', $this->clientService->getAll());
|
||||
$this->tpl->assign('projectTypes', $this->projectService->getProjectTypes());
|
||||
$this->tpl->assign('info', '');
|
||||
|
||||
return $this->tpl->display('projects.newProject');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles new project form submission.
|
||||
*
|
||||
* @param array $params Request parameters
|
||||
*/
|
||||
public function post(array $params): Response
|
||||
{
|
||||
Auth::authOrRedirect([Roles::$owner, Roles::$admin, Roles::$manager], true);
|
||||
|
||||
if (! session()->exists('lastPage')) {
|
||||
session(['lastPage' => BASE_URL.'/projects/showAll']);
|
||||
}
|
||||
|
||||
$hourBudget = (! isset($_POST['hourBudget']) || $_POST['hourBudget'] == '' || $_POST['hourBudget'] == null)
|
||||
? '0'
|
||||
: $_POST['hourBudget'];
|
||||
|
||||
$assignedUsers = (isset($_POST['editorId']) && count($_POST['editorId']))
|
||||
? $_POST['editorId']
|
||||
: [];
|
||||
|
||||
// A project may only be nested under a CONTAINER (a program or a strategy) — never under
|
||||
// another regular project. Only keep the parent if the candidate is actually a
|
||||
// program/strategy; anything else (incl. a regular project) leaves it un-nested. (The
|
||||
// service validates this too, since addProject is JSON-RPC reachable.)
|
||||
//
|
||||
// If the parent field was on the form, respect the user's explicit choice — including an
|
||||
// intentional "none" (empty) selection, so they can clear the parent. Only when the field
|
||||
// was NOT submitted do we infer the container from the URL ("Add Project" button) or the
|
||||
// current project.
|
||||
if (isset($_POST['parent'])) {
|
||||
$parentCandidate = $_POST['parent'];
|
||||
} else {
|
||||
$parentCandidate = $_GET['parent'] ?? (string) session('currentProject');
|
||||
}
|
||||
|
||||
$parent = '';
|
||||
if ($parentCandidate !== '' && $parentCandidate !== '0') {
|
||||
$parentProject = $this->projectService->getProject((int) $parentCandidate);
|
||||
if (is_array($parentProject) && in_array($parentProject['type'] ?? '', ['program', 'strategy'], true)) {
|
||||
$parent = (string) $parentCandidate;
|
||||
}
|
||||
}
|
||||
|
||||
$values = [
|
||||
'name' => $_POST['name'] ?? '',
|
||||
'details' => $_POST['details'] ?? '',
|
||||
'clientId' => $_POST['clientId'] ?? 0,
|
||||
'hourBudget' => $hourBudget,
|
||||
'assignedUsers' => $assignedUsers,
|
||||
'dollarBudget' => $_POST['dollarBudget'] ?? 0,
|
||||
'state' => $_POST['projectState'],
|
||||
'psettings' => $_POST['globalProjectUserAccess'],
|
||||
'menuType' => $_POST['menuType'] ?? 'default',
|
||||
'type' => $_POST['type'] ?? 'project',
|
||||
'parent' => $parent,
|
||||
'start' => format(value: $_POST['start'], fromFormat: FromFormat::UserDateStartOfDay)->isoDateTime(),
|
||||
'end' => $_POST['end'] ? format(value: $_POST['end'], fromFormat: FromFormat::UserDateEndOfDay)->isoDateTime() : '',
|
||||
];
|
||||
|
||||
if ($values['name'] === '') {
|
||||
$this->tpl->setNotification($this->language->__('notification.no_project_name'), 'error');
|
||||
} elseif ($values['clientId'] === '') {
|
||||
$this->tpl->setNotification($this->language->__('notification.no_client'), 'error');
|
||||
} else {
|
||||
$id = $this->projectService->addProject($values);
|
||||
$this->projectService->changeCurrentSessionProject($id);
|
||||
|
||||
$this->projectService->notifyProjectCreated($id, $values['name'], session('userdata.name'));
|
||||
|
||||
$this->tpl->sendConfetti();
|
||||
$this->tpl->setNotification(
|
||||
sprintf($this->language->__('notifications.project_created_successfully'), BASE_URL.'/leancanvas/simpleCanvas/'),
|
||||
'success',
|
||||
'project_created'
|
||||
);
|
||||
|
||||
return Frontcontroller::redirect(BASE_URL.'/projects/showProject/'.$id);
|
||||
}
|
||||
|
||||
$this->tpl->assign('menuTypes', $this->projectService->getMenuTypes());
|
||||
$this->tpl->assign('project', $values);
|
||||
$this->tpl->assign('availableUsers', $this->projectService->getAllUsers());
|
||||
$this->tpl->assign('clients', $this->clientService->getAll());
|
||||
$this->tpl->assign('projectTypes', $this->projectService->getProjectTypes());
|
||||
$this->tpl->assign('info', '');
|
||||
|
||||
return $this->tpl->display('projects.newProject');
|
||||
}
|
||||
}
|
||||
68
app/Domain/Projects/Controllers/ProjectImage.php
Normal file
68
app/Domain/Projects/Controllers/ProjectImage.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Projects\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Leantime\Core\Http\Responses\ImageResponse;
|
||||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Serves and updates project avatars.
|
||||
*
|
||||
* A native Laravel controller (constructor DI, route-bound actions). Relocated from the
|
||||
* retired Api\Controllers\Projects. Bound in Projects/routes.php at the canonical
|
||||
* /projects/projectImage/{id} plus the backward-compatible /api/projects alias used by
|
||||
* core templates and the plugin submodule. The JSON sort/status operations the old
|
||||
* controller also multiplexed onto /api/projects now go through JSON-RPC.
|
||||
*/
|
||||
class ProjectImage
|
||||
{
|
||||
public function __construct(private ProjectService $projectService) {}
|
||||
|
||||
/**
|
||||
* GET — returns the project avatar as a binary/SVG response.
|
||||
*
|
||||
* The id comes from the canonical path segment ({id}) or the legacy ?projectAvatar=
|
||||
* query param. Avatars are low-sensitivity images rendered across the app (including
|
||||
* arbitrary project rows on admin screens), so the read stays open to any authenticated user.
|
||||
*/
|
||||
public function show(Request $request, ?string $id = null): ImageResponse|Response
|
||||
{
|
||||
$id = $request->query('projectAvatar', $id);
|
||||
|
||||
if (empty($id)) {
|
||||
return response()->json(['status' => 'failure'], 400);
|
||||
}
|
||||
|
||||
return new ImageResponse($this->projectService->getProjectAvatar($id));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST — uploads a new avatar for the active project.
|
||||
*
|
||||
* The avatar always targets session('currentProject'); the upload requires manage
|
||||
* rights on that project (the legacy endpoint had no role check at all).
|
||||
*/
|
||||
public function upload(): Response
|
||||
{
|
||||
if (! isset($_FILES['file'])) {
|
||||
return response()->json(['error' => 'File not included'], 400);
|
||||
}
|
||||
|
||||
$projectId = (int) session('currentProject');
|
||||
|
||||
if (! $this->projectService->userCanManageProject($projectId)) {
|
||||
return response()->json(['status' => 'unauthorized'], 403);
|
||||
}
|
||||
|
||||
$_FILES['file']['name'] = 'profileImage-'.$projectId.'.png';
|
||||
|
||||
$this->projectService->setProjectAvatar($_FILES, $projectId);
|
||||
|
||||
session(['msg' => 'PICTURE_CHANGED']);
|
||||
session(['msgT' => 'success']);
|
||||
|
||||
return response()->json(['status' => 'ok']);
|
||||
}
|
||||
}
|
||||
113
app/Domain/Projects/Controllers/ShowAll.php
Normal file
113
app/Domain/Projects/Controllers/ShowAll.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Projects\Controllers;
|
||||
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Domain\Auth\Models\Roles;
|
||||
use Leantime\Domain\Auth\Services\Auth;
|
||||
use Leantime\Domain\Bom\Services\Bom as BomService;
|
||||
use Leantime\Domain\Bom\Services\MasterRef as MasterRefService;
|
||||
use Leantime\Domain\Menu\Repositories\Menu as MenuRepository;
|
||||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ShowAll extends Controller
|
||||
{
|
||||
private ProjectService $projectService;
|
||||
|
||||
private MenuRepository $menuRepo;
|
||||
|
||||
private BomService $bomService;
|
||||
|
||||
private MasterRefService $masterRefService;
|
||||
|
||||
/**
|
||||
* Initializes dependencies.
|
||||
*/
|
||||
public function init(
|
||||
ProjectService $projectService,
|
||||
MenuRepository $menuRepo,
|
||||
BomService $bomService,
|
||||
MasterRefService $masterRefService
|
||||
): void {
|
||||
$this->projectService = $projectService;
|
||||
$this->menuRepo = $menuRepo;
|
||||
$this->bomService = $bomService;
|
||||
$this->masterRefService = $masterRefService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the list of all projects.
|
||||
*
|
||||
* @param array $params Request parameters
|
||||
*/
|
||||
public function get(array $params): Response
|
||||
{
|
||||
Auth::authOrRedirect([Roles::$owner, Roles::$admin, Roles::$manager], true);
|
||||
|
||||
if (! Auth::userIsAtLeast(Roles::$manager)) {
|
||||
return $this->tpl->display('errors.error403', responseCode: 403);
|
||||
}
|
||||
|
||||
if (! session()->exists('showClosedProjects')) {
|
||||
session(['showClosedProjects' => false]);
|
||||
}
|
||||
|
||||
$this->tpl->assign('role', session('userdata.role'));
|
||||
|
||||
if (Auth::userIsAtLeast(Roles::$admin)) {
|
||||
$allProjects = $this->projectService->getAll(session('showClosedProjects'));
|
||||
} else {
|
||||
$allProjects = $this->projectService->getClientManagerProjects(session('userdata.id'), session('userdata.clientId'));
|
||||
}
|
||||
|
||||
// 为每个项目附加其关联的全局主数据(BOM/工艺文件/工具清单),供概览页展开显示
|
||||
$typeNames = ['bom' => 'BOM', 'process' => '工艺文件', 'tooling' => '工具清单'];
|
||||
foreach ($allProjects as &$project) {
|
||||
$projectId = (int) $project['id'];
|
||||
$linkedMasters = [];
|
||||
foreach ($this->masterRefService->getRefs($projectId) as $ref) {
|
||||
$master = $this->bomService->getBom((int) $ref['masterId']);
|
||||
if ($master === false) {
|
||||
continue;
|
||||
}
|
||||
$master['refId'] = (int) $ref['id'];
|
||||
$master['typeName'] = $typeNames[$master['type'] ?? 'bom'] ?? 'BOM';
|
||||
$linkedMasters[] = $master;
|
||||
}
|
||||
$project['linkedMasters'] = $linkedMasters;
|
||||
}
|
||||
unset($project);
|
||||
|
||||
$this->tpl->assign('allProjects', $allProjects);
|
||||
|
||||
// 项目概览甘特图数据(frappe-gantt 兼容 tasks)
|
||||
$gantt = $this->projectService->getProjectGanttData($allProjects);
|
||||
$this->tpl->assign('ganttTasks', $gantt['tasks'] ?? []);
|
||||
|
||||
$this->tpl->assign('menuTypes', $this->menuRepo->getMenuTypes());
|
||||
$this->tpl->assign('showClosedProjects', session('showClosedProjects'));
|
||||
|
||||
return $this->tpl->display('projects.showAll');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles filter changes (show/hide closed projects).
|
||||
*
|
||||
* @param array $params Request parameters
|
||||
*/
|
||||
public function post(array $params): Response
|
||||
{
|
||||
Auth::authOrRedirect([Roles::$owner, Roles::$admin, Roles::$manager], true);
|
||||
|
||||
if (isset($_POST['hideClosedProjects'])) {
|
||||
session(['showClosedProjects' => false]);
|
||||
}
|
||||
|
||||
if (isset($_POST['showClosedProjects'])) {
|
||||
session(['showClosedProjects' => true]);
|
||||
}
|
||||
|
||||
return $this->get($params);
|
||||
}
|
||||
}
|
||||
41
app/Domain/Projects/Controllers/ShowMy.php
Normal file
41
app/Domain/Projects/Controllers/ShowMy.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Projects\Controllers;
|
||||
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Domain\Menu\Services\Menu;
|
||||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ShowMy extends Controller
|
||||
{
|
||||
private ProjectService $projectService;
|
||||
|
||||
private Menu $menuService;
|
||||
|
||||
public function init(
|
||||
ProjectService $projectService,
|
||||
Menu $menuService
|
||||
): void {
|
||||
$this->projectService = $projectService;
|
||||
$this->menuService = $menuService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the project hub for the current user.
|
||||
*/
|
||||
public function get(): Response
|
||||
{
|
||||
$clientId = (isset($_GET['client']) === true && $_GET['client'] != '') ? (int) $_GET['client'] : null;
|
||||
|
||||
$hubData = $this->projectService->getProjectHubData(session('userdata.id'), $clientId);
|
||||
|
||||
$this->tpl->assign('projectTypeAvatars', $this->menuService->getProjectTypeAvatars());
|
||||
$this->tpl->assign('currentClientName', $hubData['currentClientName']);
|
||||
$this->tpl->assign('currentClient', $hubData['currentClient']);
|
||||
$this->tpl->assign('clients', $hubData['clients']);
|
||||
$this->tpl->assign('allProjects', $hubData['allProjects']);
|
||||
|
||||
return $this->tpl->display('projects.projectHub');
|
||||
}
|
||||
}
|
||||
310
app/Domain/Projects/Controllers/ShowProject.php
Normal file
310
app/Domain/Projects/Controllers/ShowProject.php
Normal file
@@ -0,0 +1,310 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Projects\Controllers;
|
||||
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Core\Controller\Frontcontroller;
|
||||
use Leantime\Domain\Auth\Models\Roles;
|
||||
use Leantime\Domain\Auth\Services\Auth;
|
||||
use Leantime\Domain\Bom\Services\Bom as BomService;
|
||||
use Leantime\Domain\Bom\Services\MasterRef as MasterRefService;
|
||||
use Leantime\Domain\Clients\Services\Clients as ClientService;
|
||||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||||
use Leantime\Domain\Tickets\Services\Tickets as TicketService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ShowProject extends Controller
|
||||
{
|
||||
private ProjectService $projectService;
|
||||
|
||||
private TicketService $ticketService;
|
||||
|
||||
private ClientService $clientService;
|
||||
|
||||
private BomService $bomService;
|
||||
|
||||
private MasterRefService $masterRefService;
|
||||
|
||||
/**
|
||||
* Initializes dependencies.
|
||||
*/
|
||||
public function init(
|
||||
ProjectService $projectService,
|
||||
TicketService $ticketService,
|
||||
ClientService $clientService,
|
||||
BomService $bomService,
|
||||
MasterRefService $masterRefService
|
||||
): void {
|
||||
Auth::authOrRedirect([Roles::$owner, Roles::$admin, Roles::$manager]);
|
||||
|
||||
$this->projectService = $projectService;
|
||||
$this->ticketService = $ticketService;
|
||||
$this->clientService = $clientService;
|
||||
$this->bomService = $bomService;
|
||||
$this->masterRefService = $masterRefService;
|
||||
|
||||
if (! session()->exists('lastPage')) {
|
||||
session(['lastPage' => CURRENT_URL]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the project settings page.
|
||||
*
|
||||
* @param array $params Request parameters
|
||||
*/
|
||||
public function get(array $params): Response
|
||||
{
|
||||
if (! isset($params['id'])) {
|
||||
return $this->tpl->display('errors.error404', responseCode: 404);
|
||||
}
|
||||
|
||||
$id = (int) $params['id'];
|
||||
|
||||
if (Auth::userHasRole(Roles::$manager)) {
|
||||
if ($this->projectService->isUserAssignedToProject(session('userdata.id'), $id) === false) {
|
||||
return Frontcontroller::redirect(BASE_URL.'/errors/error403');
|
||||
}
|
||||
}
|
||||
|
||||
$project = $this->projectService->getProject($id);
|
||||
|
||||
if (! isset($project['id'])) {
|
||||
return Frontcontroller::redirect(BASE_URL.'/errors/error404');
|
||||
}
|
||||
|
||||
if (session('currentProject') != $project['id']) {
|
||||
$this->projectService->changeCurrentSessionProject($project['id']);
|
||||
}
|
||||
|
||||
session(['lastPage' => BASE_URL.'/projects/showProject/'.$id]);
|
||||
|
||||
$project['assignedUsers'] = $this->projectService->getUsersAssignedToProject($id, true);
|
||||
|
||||
$this->assignIntegrationSettings($id);
|
||||
$this->assignTemplateVars($id, $project);
|
||||
$this->assignMasterData($id);
|
||||
|
||||
return $this->tpl->display('projects.showProject');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles project settings form submissions.
|
||||
*
|
||||
* @param array $params Request parameters
|
||||
*/
|
||||
public function post(array $params): Response
|
||||
{
|
||||
if (! isset($params['id'])) {
|
||||
return $this->tpl->display('errors.error404', responseCode: 404);
|
||||
}
|
||||
|
||||
$id = (int) $params['id'];
|
||||
|
||||
if (Auth::userHasRole(Roles::$manager)) {
|
||||
if ($this->projectService->isUserAssignedToProject(session('userdata.id'), $id) === false) {
|
||||
return Frontcontroller::redirect(BASE_URL.'/errors/error403');
|
||||
}
|
||||
}
|
||||
|
||||
$project = $this->projectService->getProject($id);
|
||||
|
||||
if (! isset($project['id'])) {
|
||||
return Frontcontroller::redirect(BASE_URL.'/errors/error404');
|
||||
}
|
||||
|
||||
if (session('currentProject') != $project['id']) {
|
||||
$this->projectService->changeCurrentSessionProject($project['id']);
|
||||
}
|
||||
|
||||
// Handle Mattermost integration
|
||||
if (isset($_POST['mattermostSave'])) {
|
||||
$this->projectService->saveMattermostWebhook($id, $_POST['mattermostWebhookURL']);
|
||||
$this->tpl->setNotification($this->language->__('notification.saved_mattermost_webhook'), 'success');
|
||||
}
|
||||
|
||||
// Handle Slack integration
|
||||
if (isset($_POST['slackSave'])) {
|
||||
$this->projectService->saveSlackWebhook($id, $_POST['slackWebhookURL']);
|
||||
$this->tpl->setNotification($this->language->__('notification.saved_slack_webhook'), 'success');
|
||||
}
|
||||
|
||||
// Handle Zulip integration
|
||||
if (isset($_POST['zulipSave'])) {
|
||||
$zulipResult = $this->projectService->saveZulipWebhook($id, $_POST);
|
||||
|
||||
if ($zulipResult['saved']) {
|
||||
$this->tpl->setNotification($this->language->__('notification.saved_zulip_webhook'), 'success');
|
||||
} else {
|
||||
$this->tpl->setNotification($this->language->__('notification.error_zulip_webhook_fill_out_fields'), 'error');
|
||||
}
|
||||
|
||||
$this->tpl->assign('zulipHook', $zulipResult['hook']);
|
||||
}
|
||||
|
||||
// Handle Telegram integration
|
||||
if (isset($_POST['telegramSave'])) {
|
||||
$telegramResult = $this->projectService->saveTelegramWebhook($id, $_POST);
|
||||
|
||||
if ($telegramResult['saved']) {
|
||||
$this->tpl->setNotification($this->language->__('notification.saved_telegram_webhook'), 'success');
|
||||
} else {
|
||||
$errorKey = $telegramResult['error'] === 'missing_token'
|
||||
? 'notification.error_telegram_missing_token'
|
||||
: 'notification.error_telegram_chat_not_found';
|
||||
$this->tpl->setNotification($this->language->__($errorKey), 'error');
|
||||
}
|
||||
|
||||
$this->tpl->assign('telegramHook', $telegramResult['hook']);
|
||||
}
|
||||
|
||||
// Handle Discord integration
|
||||
if (isset($_POST['discordSave'])) {
|
||||
$this->projectService->saveDiscordWebhooks($id, $_POST);
|
||||
$this->tpl->setNotification($this->language->__('notification.saved_discord_webhook'), 'success');
|
||||
}
|
||||
|
||||
// Handle status label settings
|
||||
if (isset($_POST['submitSettings'])) {
|
||||
if (isset($_POST['labelKeys']) && is_array($_POST['labelKeys']) && count($_POST['labelKeys']) > 0) {
|
||||
if ($this->ticketService->saveStatusLabels($_POST)) {
|
||||
$this->tpl->setNotification($this->language->__('notification.new_status_saved'), 'success');
|
||||
} else {
|
||||
$this->tpl->setNotification($this->language->__('notification.error_saving_status'), 'error');
|
||||
}
|
||||
} else {
|
||||
$this->tpl->setNotification($this->language->__('notification.at_least_one_status'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle user assignment
|
||||
if (isset($_POST['saveUsers'])) {
|
||||
$assignedUsers = (isset($_POST['editorId']) && count($_POST['editorId']))
|
||||
? $_POST['editorId']
|
||||
: [];
|
||||
|
||||
$this->projectService->updateProjectUsers($id, $assignedUsers, $_POST);
|
||||
$this->tpl->setNotification($this->language->__('notifications.user_was_added_to_project'), 'success');
|
||||
}
|
||||
|
||||
// Handle project save
|
||||
if (isset($_POST['save'])) {
|
||||
$values = [
|
||||
'name' => $_POST['name'],
|
||||
'details' => $_POST['details'],
|
||||
'clientId' => $_POST['clientId'],
|
||||
'state' => $_POST['projectState'],
|
||||
'hourBudget' => $_POST['hourBudget'],
|
||||
'dollarBudget' => $_POST['dollarBudget'],
|
||||
'psettings' => $_POST['globalProjectUserAccess'],
|
||||
'menuType' => $_POST['menuType'],
|
||||
'type' => $_POST['type'] ?? $project['type'],
|
||||
'parent' => $_POST['parent'] ?? '',
|
||||
'start' => isset($_POST['start']) && dtHelper()->isValidDateString($_POST['start']) ? dtHelper()->parseUserDateTime($_POST['start'])->formatDateTimeForDb() : '',
|
||||
'end' => isset($_POST['end']) && dtHelper()->isValidDateString($_POST['end']) ? dtHelper()->parseUserDateTime($_POST['end'])->formatDateTimeForDb() : '',
|
||||
];
|
||||
|
||||
if ($values['name'] !== '') {
|
||||
if ($this->projectService->hasTickets($id) && $values['state'] == 1) {
|
||||
$this->tpl->setNotification($this->language->__('notification.project_has_tickets'), 'error');
|
||||
} else {
|
||||
$this->projectService->editProjectAndNotify(
|
||||
$values,
|
||||
$id,
|
||||
$project,
|
||||
CURRENT_URL,
|
||||
session('userdata.id'),
|
||||
session('userdata.name')
|
||||
);
|
||||
$this->tpl->setNotification($this->language->__('notification.project_saved'), 'success');
|
||||
|
||||
return Frontcontroller::redirect(BASE_URL.'/projects/showProject/'.$id);
|
||||
}
|
||||
} else {
|
||||
$this->tpl->setNotification($this->language->__('notification.no_project_name'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle master data linking (关联全局主数据到项目)
|
||||
if (isset($_POST['linkMaster']) && ! empty($_POST['masterId'])) {
|
||||
$masterId = (int) $_POST['masterId'];
|
||||
if ($this->masterRefService->link($id, $masterId) > 0) {
|
||||
$this->tpl->setNotification($this->language->__('notification.master_linked', '主数据已关联到项目'), 'success');
|
||||
} else {
|
||||
$this->tpl->setNotification($this->language->__('notification.master_link_failed', '关联失败'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
session(['lastPage' => BASE_URL.'/projects/showProject/'.$id]);
|
||||
|
||||
$project['assignedUsers'] = $this->projectService->getUsersAssignedToProject($id, true);
|
||||
|
||||
$this->assignIntegrationSettings($id);
|
||||
$this->assignTemplateVars($id, $project);
|
||||
$this->assignMasterData($id);
|
||||
|
||||
return $this->tpl->display('projects.showProject');
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and assigns integration settings to the template.
|
||||
*/
|
||||
private function assignIntegrationSettings(int $projectId): void
|
||||
{
|
||||
$settings = $this->projectService->getProjectIntegrationSettings($projectId);
|
||||
|
||||
array_map([$this->tpl, 'assign'], array_keys($settings), array_values($settings));
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns common template variables.
|
||||
*/
|
||||
private function assignTemplateVars(int $projectId, array $project): void
|
||||
{
|
||||
$this->tpl->assign('availableUsers', $this->projectService->getAllUsers());
|
||||
$this->tpl->assign('clients', $this->clientService->getAll());
|
||||
$this->tpl->assign('todoStatus', $this->ticketService->getStatusLabels());
|
||||
$this->tpl->assign('employees', $this->projectService->getEmployees());
|
||||
$this->tpl->assign('project', $project);
|
||||
$this->tpl->assign('menuTypes', $this->projectService->getMenuTypes());
|
||||
$this->tpl->assign('projectTypes', $this->projectService->getProjectTypes());
|
||||
$this->tpl->assign('state', ['open', 'closed']);
|
||||
$this->tpl->assign('role', session('userdata.role'));
|
||||
$this->tpl->assign('projectMuteCount', $this->projectService->getMuteCountForProject($projectId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载项目已引用的主数据 + 可关联的全局主数据列表,供「主数据」tab 使用。
|
||||
*/
|
||||
private function assignMasterData(int $projectId): void
|
||||
{
|
||||
$typeNames = ['bom' => 'BOM', 'process' => '工艺文件', 'tooling' => '工具清单'];
|
||||
|
||||
// 已引用
|
||||
$refs = $this->masterRefService->getRefs($projectId);
|
||||
$linked = [];
|
||||
foreach ($refs as $ref) {
|
||||
$master = $this->bomService->getBom((int) $ref['masterId']);
|
||||
if ($master !== false) {
|
||||
$ref['master'] = $master;
|
||||
$linked[] = $ref;
|
||||
}
|
||||
}
|
||||
|
||||
// 可关联的全局主数据(排除已关联)
|
||||
$linkedMasterIds = array_map(fn ($r) => (int) $r['masterId'], $refs);
|
||||
$available = [];
|
||||
foreach (['bom', 'process', 'tooling'] as $type) {
|
||||
foreach ($this->bomService->getMasters($type, 0) as $m) {
|
||||
if (in_array((int) $m['id'], $linkedMasterIds, true)) {
|
||||
continue;
|
||||
}
|
||||
$m['typeName'] = $typeNames[$m['type'] ?? 'bom'] ?? 'BOM';
|
||||
$available[] = $m;
|
||||
}
|
||||
}
|
||||
|
||||
$this->tpl->assign('linkedMasters', $linked);
|
||||
$this->tpl->assign('availableMasters', $available);
|
||||
}
|
||||
}
|
||||
48
app/Domain/Projects/Hxcontrollers/Checklist.php
Normal file
48
app/Domain/Projects/Hxcontrollers/Checklist.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Projects\Hxcontrollers;
|
||||
|
||||
use Error;
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Leantime\Core\Controller\HtmxController;
|
||||
use Leantime\Domain\Projects\Services\Projects;
|
||||
|
||||
class Checklist extends HtmxController
|
||||
{
|
||||
protected static string $view = 'projects::partials.checklist';
|
||||
|
||||
private Projects $projectService;
|
||||
|
||||
/**
|
||||
* Controller constructor
|
||||
*
|
||||
* @param Projects $projectService The projects domain service.
|
||||
*/
|
||||
public function init(Projects $projectService): void
|
||||
{
|
||||
$this->projectService = $projectService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates subtask status
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function updateSubtask(): void
|
||||
{
|
||||
if (! $this->incomingRequest->getMethod() == 'PATCH') {
|
||||
throw new Error('This endpoint only supports PATCH requests');
|
||||
}
|
||||
|
||||
// update project progress
|
||||
$projectProgress = $this->incomingRequest->request->all();
|
||||
|
||||
$this->projectService->updateProjectProgress($projectProgress, session('currentProject'));
|
||||
|
||||
// return view with new data
|
||||
[$progressSteps, $percentDone] = $this->projectService->getProjectSetupChecklist(session('currentProject'));
|
||||
$this->tpl->assign('progressSteps', $progressSteps);
|
||||
$this->tpl->assign('percentDone', $percentDone);
|
||||
$this->tpl->assign('includeTitle', false);
|
||||
}
|
||||
}
|
||||
87
app/Domain/Projects/Hxcontrollers/ProjectCard.php
Normal file
87
app/Domain/Projects/Hxcontrollers/ProjectCard.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Projects\Hxcontrollers;
|
||||
|
||||
use Leantime\Core\Controller\Frontcontroller;
|
||||
use Leantime\Core\Controller\HtmxController;
|
||||
use Leantime\Domain\Menu\Services\Menu;
|
||||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||||
use Leantime\Domain\Reactions\Services\Reactions;
|
||||
|
||||
class ProjectCard extends HtmxController
|
||||
{
|
||||
protected static string $view = 'projects::partials.projectCard';
|
||||
|
||||
private ProjectService $projectsService;
|
||||
|
||||
private Menu $menuService;
|
||||
|
||||
private Reactions $reactionService;
|
||||
|
||||
/**
|
||||
* Controller constructor
|
||||
*
|
||||
* @param \Leantime\Domain\Projects\Services\Projects $projectsService The projects domain service.
|
||||
* @param \Leantime\Domain\Menu\Services\Menu $menuService The menu domain service.
|
||||
* @param \Leantime\Domain\Reactions\Services\Reactions $reactionService The reactions domain service.
|
||||
*/
|
||||
public function init(
|
||||
ProjectService $projectsService,
|
||||
Menu $menuService,
|
||||
Reactions $reactionService
|
||||
): void {
|
||||
$this->projectsService = $projectsService;
|
||||
$this->menuService = $menuService;
|
||||
$this->reactionService = $reactionService;
|
||||
}
|
||||
|
||||
public function get() {}
|
||||
|
||||
/**
|
||||
* Toggles the current user's favorite reaction for a project and re-renders the card.
|
||||
*/
|
||||
public function toggleFavorite(): void
|
||||
{
|
||||
$projectData = $this->incomingRequest->request->all();
|
||||
|
||||
$projectId = $projectData['projectId'];
|
||||
$isFavorite = $projectData['isFavorite'];
|
||||
|
||||
if ($isFavorite) {
|
||||
$this->reactionService->removeReaction(
|
||||
userId: session('userdata.id'),
|
||||
module: 'project',
|
||||
moduleId: $projectId,
|
||||
reaction: 'favorite'
|
||||
);
|
||||
} else {
|
||||
$this->reactionService->addReaction(
|
||||
userId: session('userdata.id'),
|
||||
module: 'project',
|
||||
moduleId: $projectId,
|
||||
reaction: 'favorite'
|
||||
);
|
||||
}
|
||||
|
||||
$this->tpl->setHTMXEvent('HTMX.updateProjectList');
|
||||
|
||||
$this->tpl->assign('project', $this->projectsService->getProject($projectId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the project card for a project.
|
||||
*/
|
||||
public function getProgress(): void
|
||||
{
|
||||
$projectId = $_GET['projectId'];
|
||||
|
||||
$projectTypeAvatars = $this->menuService->getProjectTypeAvatars();
|
||||
|
||||
$currentUrlPath = BASE_URL.'/'.str_replace('.', '/', Frontcontroller::getCurrentRoute());
|
||||
|
||||
$this->tpl->assign('projectTypeAvatars', $projectTypeAvatars);
|
||||
$this->tpl->assign('currentUrlPath', $currentUrlPath);
|
||||
$this->tpl->assign('project', $this->projectsService->getProject($projectId));
|
||||
$this->tpl->assign('type', 'full');
|
||||
}
|
||||
}
|
||||
50
app/Domain/Projects/Hxcontrollers/ProjectCardProgress.php
Normal file
50
app/Domain/Projects/Hxcontrollers/ProjectCardProgress.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Projects\Hxcontrollers;
|
||||
|
||||
use Leantime\Core\Controller\Frontcontroller;
|
||||
use Leantime\Core\Controller\HtmxController;
|
||||
use Leantime\Domain\Menu\Services\Menu;
|
||||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||||
|
||||
class ProjectCardProgress extends HtmxController
|
||||
{
|
||||
protected static string $view = 'projects::partials.projectCardProgressBar';
|
||||
|
||||
private ProjectService $projectsService;
|
||||
|
||||
private Menu $menuService;
|
||||
|
||||
/**
|
||||
* Controller constructor
|
||||
*
|
||||
* @param \Leantime\Domain\Projects\Services\Projects $projectsService The projects domain service.
|
||||
* @param \Leantime\Domain\Menu\Services\Menu $menuService The menu domain service.
|
||||
*/
|
||||
public function init(
|
||||
ProjectService $projectsService,
|
||||
Menu $menuService
|
||||
): void {
|
||||
$this->projectsService = $projectsService;
|
||||
$this->menuService = $menuService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the project card progress bar partial.
|
||||
*/
|
||||
public function getProgress(): void
|
||||
{
|
||||
$projectId = $_GET['pId'];
|
||||
|
||||
$project = $this->projectsService->getProjectCardData($projectId);
|
||||
|
||||
$projectTypeAvatars = $this->menuService->getProjectTypeAvatars();
|
||||
|
||||
$currentUrlPath = BASE_URL.'/'.str_replace('.', '/', Frontcontroller::getCurrentRoute());
|
||||
|
||||
$this->tpl->assign('projectTypeAvatars', $projectTypeAvatars);
|
||||
$this->tpl->assign('currentUrlPath', $currentUrlPath);
|
||||
$this->tpl->assign('project', $project);
|
||||
$this->tpl->assign('type', 'full');
|
||||
}
|
||||
}
|
||||
50
app/Domain/Projects/Hxcontrollers/ProjectHubProjects.php
Normal file
50
app/Domain/Projects/Hxcontrollers/ProjectHubProjects.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Projects\Hxcontrollers;
|
||||
|
||||
use Leantime\Core\Controller\Frontcontroller;
|
||||
use Leantime\Core\Controller\HtmxController;
|
||||
use Leantime\Domain\Menu\Services\Menu;
|
||||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||||
|
||||
class ProjectHubProjects extends HtmxController
|
||||
{
|
||||
protected static string $view = 'projects::partials.projectHubProjects';
|
||||
|
||||
private ProjectService $projectsService;
|
||||
|
||||
private Menu $menuService;
|
||||
|
||||
/**
|
||||
* Controller constructor
|
||||
*
|
||||
* @param \Leantime\Domain\Projects\Services\Projects $projectsService The projects domain service.
|
||||
* @param \Leantime\Domain\Menu\Services\Menu $menuService The menu domain service.
|
||||
*/
|
||||
public function init(
|
||||
ProjectService $projectsService,
|
||||
Menu $menuService
|
||||
): void {
|
||||
$this->projectsService = $projectsService;
|
||||
$this->menuService = $menuService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the project hub project list partial.
|
||||
*/
|
||||
public function get(): void
|
||||
{
|
||||
$clientId = (isset($_GET['client']) === true && $_GET['client'] != '') ? (int) $_GET['client'] : null;
|
||||
|
||||
$hubData = $this->projectsService->getProjectHubData(session('userdata.id'), $clientId);
|
||||
|
||||
$currentUrlPath = BASE_URL.'/'.str_replace('.', '/', Frontcontroller::getCurrentRoute());
|
||||
|
||||
$this->tpl->assign('projectTypeAvatars', $this->menuService->getProjectTypeAvatars());
|
||||
$this->tpl->assign('currentUrlPath', $currentUrlPath);
|
||||
$this->tpl->assign('currentClientName', $hubData['currentClientName']);
|
||||
$this->tpl->assign('currentClient', $hubData['currentClient']);
|
||||
$this->tpl->assign('clients', $hubData['clients']);
|
||||
$this->tpl->assign('allProjects', $hubData['allProjects']);
|
||||
}
|
||||
}
|
||||
625
app/Domain/Projects/Js/projectsController.js
Normal file
625
app/Domain/Projects/Js/projectsController.js
Normal file
@@ -0,0 +1,625 @@
|
||||
leantime.projectsController = (function () {
|
||||
|
||||
|
||||
function countTickets()
|
||||
{
|
||||
|
||||
jQuery("#sortableTicketKanban .column").each(function () {
|
||||
var counting = jQuery(this).find('.moveable').length;
|
||||
jQuery(this).find(' .count').text(counting);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
//Functions
|
||||
|
||||
var initDates = function () {
|
||||
|
||||
jQuery(".projectDateFrom, .projectDateTo").datepicker(
|
||||
{
|
||||
dateFormat: leantime.dateHelper.getFormatFromSettings("dateformat", "jquery"),
|
||||
dayNames: leantime.i18n.__("language.dayNames").split(","),
|
||||
dayNamesMin: leantime.i18n.__("language.dayNamesMin").split(","),
|
||||
dayNamesShort: leantime.i18n.__("language.dayNamesShort").split(","),
|
||||
monthNames: leantime.i18n.__("language.monthNames").split(","),
|
||||
currentText: leantime.i18n.__("language.currentText"),
|
||||
closeText: leantime.i18n.__("language.closeText"),
|
||||
buttonText: leantime.i18n.__("language.buttonText"),
|
||||
isRTL: leantime.i18n.__("language.isRTL") === "true" ? 1 : 0,
|
||||
nextText: leantime.i18n.__("language.nextText"),
|
||||
prevText: leantime.i18n.__("language.prevText"),
|
||||
weekHeader: leantime.i18n.__("language.weekHeader"),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
var initProjectTabs = function () {
|
||||
jQuery('.projectTabs').tabs();
|
||||
};
|
||||
|
||||
var initDuplicateProjectModal = function () {
|
||||
|
||||
var regularModelConfig = {
|
||||
sizes: {
|
||||
minW: 450,
|
||||
minH: 350
|
||||
},
|
||||
resizable: true,
|
||||
autoSizable: true,
|
||||
callbacks: {
|
||||
afterShowCont: function () {
|
||||
jQuery(".showDialogOnLoad").show();
|
||||
initDates();
|
||||
jQuery(".duplicateProjectModal, .formModal").nyroModal(regularModelConfig);
|
||||
},
|
||||
beforeClose: function () {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
jQuery(".duplicateProjectModal").nyroModal(regularModelConfig);
|
||||
|
||||
};
|
||||
|
||||
var initProgressBar = function (percentage) {
|
||||
|
||||
jQuery("#progressbar").progressbar({
|
||||
value: percentage
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
|
||||
var initProjectTable = function () {
|
||||
|
||||
jQuery(document).ready(function () {
|
||||
|
||||
var size = 100;
|
||||
|
||||
var allProjects = jQuery("#allProjectsTable").DataTable({
|
||||
"language": {
|
||||
"decimal": leantime.i18n.__("datatables.decimal"),
|
||||
"emptyTable": leantime.i18n.__("datatables.emptyTable"),
|
||||
"info": leantime.i18n.__("datatables.info"),
|
||||
"infoEmpty": leantime.i18n.__("datatables.infoEmpty"),
|
||||
"infoFiltered": leantime.i18n.__("datatables.infoFiltered"),
|
||||
"infoPostFix": leantime.i18n.__("datatables.infoPostFix"),
|
||||
"thousands": leantime.i18n.__("datatables.thousands"),
|
||||
"lengthMenu": leantime.i18n.__("datatables.lengthMenu"),
|
||||
"loadingRecords": leantime.i18n.__("datatables.loadingRecords"),
|
||||
"processing": leantime.i18n.__("datatables.processing"),
|
||||
"search": leantime.i18n.__("datatables.search"),
|
||||
"zeroRecords": leantime.i18n.__("datatables.zeroRecords"),
|
||||
"paginate": {
|
||||
"first": leantime.i18n.__("datatables.first"),
|
||||
"last": leantime.i18n.__("datatables.last"),
|
||||
"next": leantime.i18n.__("datatables.next"),
|
||||
"previous": leantime.i18n.__("datatables.previous"),
|
||||
},
|
||||
"aria": {
|
||||
"sortAscending": leantime.i18n.__("datatables.sortAscending"),
|
||||
"sortDescending":leantime.i18n.__("datatables.sortDescending"),
|
||||
}
|
||||
|
||||
},
|
||||
"dom": '<"top">rt<"bottom"ilp><"clear">',
|
||||
"searching": false,
|
||||
"displayLength":100
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
var initTodoStatusSortable = function (element) {
|
||||
var sortCounter = 1;
|
||||
|
||||
jQuery(element).find("input.sorter").each(function (index) {
|
||||
|
||||
jQuery(this).val(sortCounter);
|
||||
sortCounter++;
|
||||
});
|
||||
|
||||
jQuery(element).sortable({
|
||||
stop: function ( event, ui ) {
|
||||
|
||||
sortCounter = 1;
|
||||
jQuery(element).find("input.sorter").each(function (index) {
|
||||
jQuery(this).val(sortCounter);
|
||||
sortCounter++;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
var initSelectFields = function () {
|
||||
|
||||
jQuery(document).ready(function () {
|
||||
|
||||
jQuery("#todosettings select.colorChosen").on('chosen:ready', function (e, params) {
|
||||
|
||||
var id = jQuery(this).attr('id').replace("-", "_");
|
||||
|
||||
jQuery("#" + id + "_chosen a span").removeClass();
|
||||
jQuery("#" + id + "_chosen a span").addClass(params.selected);
|
||||
|
||||
}).chosen({
|
||||
disable_search_threshold: 10
|
||||
});
|
||||
|
||||
jQuery("#todosettings select.colorChosen").on('change', function (evt, params) {
|
||||
|
||||
var id = jQuery(this).attr('id').replace("-", "_");
|
||||
|
||||
jQuery("#" + id + "_chosen a span").removeClass();
|
||||
jQuery("#" + id + "_chosen a span").addClass(params.selected);
|
||||
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
var removeStatus = function (id) {
|
||||
|
||||
jQuery("#todostatus-" + id).parent().remove();
|
||||
|
||||
};
|
||||
|
||||
var addToDoStatus = function (id) {
|
||||
|
||||
var highestKey = -1;
|
||||
|
||||
jQuery("#todosettings ul .statusList").each(function () {
|
||||
|
||||
var keyInt = jQuery(this).find('.labelKey').val();
|
||||
|
||||
if (parseInt(keyInt) >= parseInt(highestKey)) {
|
||||
highestKey = keyInt;
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
var newKey = parseInt(highestKey) + 1;
|
||||
|
||||
var statusCopy = jQuery(".newStatusTpl").clone();
|
||||
|
||||
statusCopy.html(function (i, oldHTML) {
|
||||
return updatedContent = oldHTML.replaceAll('XXNEWKEYXX', newKey);
|
||||
});
|
||||
|
||||
jQuery('#todoStatusList').append("<li>" + statusCopy.html() + "</li>");
|
||||
|
||||
jQuery("#todosettings select.colorChosen").chosen("destroy");
|
||||
leantime.projectsController.initSelectFields();
|
||||
jQuery("#todoStatusList").sortable("destroy");
|
||||
leantime.projectsController.initTodoStatusSortable("#todoStatusList");
|
||||
|
||||
};
|
||||
|
||||
var readURL = function (input) {
|
||||
|
||||
clearCroppie();
|
||||
|
||||
if (input.files && input.files[0]) {
|
||||
var reader = new FileReader();
|
||||
|
||||
var profileImg = jQuery('#projectAvatar');
|
||||
reader.onload = function (e) {
|
||||
//profileImg.attr('src', e.currentTarget.result);
|
||||
|
||||
_uploadResult = profileImg
|
||||
.croppie(
|
||||
{
|
||||
enableExif: true,
|
||||
viewport: {
|
||||
width: 200,
|
||||
height: 200,
|
||||
type: 'rectangle'
|
||||
},
|
||||
boundary: {
|
||||
width: 250,
|
||||
height: 250
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
_uploadResult.croppie(
|
||||
'bind',
|
||||
{
|
||||
url: e.currentTarget.result
|
||||
}
|
||||
);
|
||||
|
||||
jQuery("#previousImage").hide();
|
||||
};
|
||||
|
||||
reader.readAsDataURL(input.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
var clearCroppie = function () {
|
||||
jQuery('#profileImg').croppie('destroy');
|
||||
jQuery("#previousImage").show();
|
||||
};
|
||||
|
||||
var saveCroppie = function () {
|
||||
|
||||
jQuery('#save-picture').addClass('running');
|
||||
|
||||
jQuery('#profileImg').attr('src', leantime.appUrl + '/images/loaders/loader28.gif');
|
||||
_uploadResult.croppie(
|
||||
'result',
|
||||
{
|
||||
type: "blob",
|
||||
circle: false
|
||||
}
|
||||
).then(
|
||||
function (result) {
|
||||
var formData = new FormData();
|
||||
formData.append('file', result);
|
||||
jQuery.ajax(
|
||||
{
|
||||
type: 'POST',
|
||||
url: leantime.appUrl + '/projects/projectImage',
|
||||
data: formData,
|
||||
processData: false,
|
||||
contentType: false,
|
||||
success: function (resp) {
|
||||
|
||||
jQuery('#save-picture').removeClass('running');
|
||||
|
||||
location.reload();
|
||||
},
|
||||
error: function (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
var initGanttChart = function (projects, viewMode, readonly) {
|
||||
|
||||
function htmlEntities(str)
|
||||
{
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
jQuery(document).ready(
|
||||
function () {
|
||||
|
||||
// The Gantt constructor fires on_view_change once while building. Persisting that
|
||||
// event would overwrite the user's saved scale with the default, so ignore any
|
||||
// view change until construction is finished; only user-driven changes save.
|
||||
var ganttInitializing = true;
|
||||
|
||||
if (readonly === false) {
|
||||
var gantt_chart = new Gantt(
|
||||
"#gantt",
|
||||
projects,
|
||||
{
|
||||
header_height: 55,
|
||||
column_width: 20,
|
||||
step: 24,
|
||||
view_modes: ['Day', 'Week', 'Month'],
|
||||
bar_height: 40,
|
||||
static_progress_indicator: true,
|
||||
bar_corner_radius: 10,
|
||||
arrow_curve: 10,
|
||||
padding:20,
|
||||
view_mode: viewMode,
|
||||
date_format: leantime.i18n.__("language.momentJSDate"),
|
||||
language: 'en', // or 'es', 'it', 'ru', 'ptBr', 'fr', 'tr', 'zh'
|
||||
additional_rows: 5,
|
||||
custom_popup_html: function (project) {
|
||||
|
||||
// the task object will contain the updated
|
||||
// dates and progress value
|
||||
var end_date = project._end;
|
||||
|
||||
var popUpHTML = '<div class="details-container" style="min-width:600px;"> ';
|
||||
|
||||
if (project.projectName !== undefined) {
|
||||
popUpHTML += '<h3><b>' + project.name + '</b></h3>';
|
||||
}
|
||||
|
||||
popUpHTML += '<h4>' + htmlEntities(project.name) + '</a></h4><br /> ';
|
||||
|
||||
popUpHTML += '</div>';
|
||||
|
||||
return popUpHTML;
|
||||
},
|
||||
on_click: function (project) {
|
||||
//_initModals();
|
||||
},
|
||||
on_date_change: function (project, start, end) {
|
||||
|
||||
var idParts = project.id.split("-");
|
||||
|
||||
let entityId = 0;
|
||||
let entityType = "";
|
||||
|
||||
if (idParts.length > 1) {
|
||||
if (idParts[0] == "ticket") {
|
||||
entityId = idParts[1];
|
||||
entityType = "ticket"
|
||||
} else if (idParts[0] == "pgm") {
|
||||
entityId = idParts[1];
|
||||
entityType = "project"
|
||||
}
|
||||
} else {
|
||||
entityId = idParts;
|
||||
}
|
||||
|
||||
|
||||
if (entityType == "ticket") {
|
||||
leantime.ticketsRepository.updateMilestoneDates(entityId, start, end, project._index+1);
|
||||
} else {
|
||||
leantime.rpc('Projects.Projects.patchProject', {
|
||||
id: entityId,
|
||||
values: {
|
||||
start: start,
|
||||
end: end,
|
||||
sortIndex: project._index + 1,
|
||||
}
|
||||
}).catch(function (e) {
|
||||
jQuery.growl({ message: (e && e.message) ? e.message : leantime.i18n.__("short_notifications.not_saved"), style: "error" });
|
||||
console.error('Could not update project dates', e);
|
||||
});
|
||||
}
|
||||
|
||||
//leantime.ticketsRepository.updateMilestoneDates(task.id, start, end, task._index);
|
||||
//_initModals();
|
||||
|
||||
},
|
||||
on_sort_change: function (projects) {
|
||||
|
||||
var sortPayload = {};
|
||||
|
||||
for (var i = 0; i < projects.length; i++) {
|
||||
sortPayload[projects[i].id] = projects[i]._index+1;
|
||||
}
|
||||
|
||||
leantime.rpc('Projects.Projects.sortProjects', { params: sortPayload })
|
||||
.catch(function (e) {
|
||||
jQuery.growl({ message: (e && e.message) ? e.message : leantime.i18n.__("short_notifications.not_saved"), style: "error" });
|
||||
console.error('Could not sort projects', e);
|
||||
});
|
||||
|
||||
},
|
||||
on_progress_change: function (project, progress) {
|
||||
|
||||
//_initModals();
|
||||
},
|
||||
on_view_change: function (mode) {
|
||||
|
||||
if (ganttInitializing) { return; }
|
||||
leantime.usersRepository.updateUserViewSettings("projectGantt", mode);
|
||||
|
||||
},
|
||||
on_popup_show: function (project) {
|
||||
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
var gantt_chart = new Gantt(
|
||||
"#gantt",
|
||||
projects,
|
||||
{
|
||||
readonlyGantt: true,
|
||||
resizing: false,
|
||||
progress: false,
|
||||
is_draggable: false,
|
||||
view_modes: ['Day', 'Week', 'Month'],
|
||||
view_mode: viewMode,
|
||||
custom_popup_html: function (project) {
|
||||
|
||||
var popUpHTML = '<div class="details-container" style="min-width:600px;"> ';
|
||||
|
||||
if (project.projectName !== undefined) {
|
||||
popUpHTML += '<h3><b>' + project.name + '</b></h3>';
|
||||
}
|
||||
|
||||
popUpHTML += '<h4>' + htmlEntities(project.name) + '</a></h4><br /> ';
|
||||
|
||||
popUpHTML += '</div>';
|
||||
|
||||
return popUpHTML;
|
||||
},
|
||||
on_click: function (project) {
|
||||
|
||||
},
|
||||
on_date_change: function (project, start, end) {
|
||||
|
||||
|
||||
},
|
||||
on_progress_change: function (project, progress) {
|
||||
|
||||
//_initModals();
|
||||
},
|
||||
on_view_change: function (mode) {
|
||||
|
||||
if (ganttInitializing) { return; }
|
||||
leantime.usersRepository.updateUserViewSettings("projectGantt", mode);
|
||||
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
jQuery("#ganttTimeControl").on(
|
||||
"click",
|
||||
"a",
|
||||
function () {
|
||||
|
||||
var $btn = jQuery(this);
|
||||
var mode = $btn.attr("data-value");
|
||||
gantt_chart.change_view_mode(mode);
|
||||
$btn.parent().parent().find('a').removeClass('active');
|
||||
$btn.addClass('active');
|
||||
var label = $btn.text();
|
||||
jQuery(".viewText").text(label);
|
||||
}
|
||||
);
|
||||
|
||||
// Constructor already built the chart at the saved viewMode; construction is done,
|
||||
// so from here on real user-driven view changes are allowed to persist.
|
||||
ganttInitializing = false;
|
||||
|
||||
}
|
||||
);
|
||||
|
||||
};
|
||||
|
||||
var setUpKanbanColumns = function () {
|
||||
|
||||
jQuery(document).ready(function () {
|
||||
|
||||
countTickets();
|
||||
jQuery(".filterBar .row-fluid").css("opacity", "1");
|
||||
|
||||
var height = jQuery("html").height() - 250;
|
||||
|
||||
jQuery("#sortableProjectKanban .column .contentInner").each(function () {
|
||||
if (jQuery(this).height() > height) {
|
||||
height = jQuery(this).height();
|
||||
}
|
||||
});
|
||||
height = height + 50;
|
||||
jQuery("#sortableProjectKanban .column .contentInner").css("min-height", height);
|
||||
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
var initProjectsKanban = function (statusList) {
|
||||
|
||||
jQuery("#sortableProjectKanban .projectBox").hover(function () {
|
||||
jQuery(this).css("background", "var(--kanban-card-hover)");
|
||||
},function () {
|
||||
jQuery(this).css("background", "var(--kanban-card-bg)");
|
||||
});
|
||||
|
||||
var position_updated = false;
|
||||
|
||||
jQuery("#sortableProjectKanban .contentInner").sortable({
|
||||
connectWith: ".contentInner",
|
||||
items: "> .moveable",
|
||||
tolerance: 'intersect',
|
||||
placeholder: "ui-state-highlight",
|
||||
forcePlaceholderSize: true,
|
||||
cancel: ".portlet-toggle,:input,a,input",
|
||||
distance: 25,
|
||||
|
||||
start: function (event, ui) {
|
||||
ui.item.addClass('tilt');
|
||||
tilt_direction(ui.item);
|
||||
},
|
||||
stop: function (event, ui) {
|
||||
ui.item.removeClass("tilt");
|
||||
jQuery("html").unbind('mousemove', ui.item.data("move_handler"));
|
||||
ui.item.removeData("move_handler");
|
||||
|
||||
countTickets();
|
||||
|
||||
var sortPayload = {};
|
||||
var handler = ui.item[0].id;
|
||||
|
||||
for (var i = 0; i < statusList.length; i++) {
|
||||
if (jQuery(".contentInner.status_" + statusList[i]).length) {
|
||||
sortPayload[statusList[i]] = jQuery(".contentInner.status_" + statusList[i]).sortable('serialize');
|
||||
}
|
||||
}
|
||||
|
||||
leantime.rpc('Projects.Projects.patchProjectStatusAndSorting', { params: sortPayload, handler: handler })
|
||||
.catch(function (e) {
|
||||
jQuery.growl({ message: (e && e.message) ? e.message : leantime.i18n.__("short_notifications.not_saved"), style: "error" });
|
||||
console.error('Could not update project sorting', e);
|
||||
});
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
function tilt_direction(item)
|
||||
{
|
||||
var left_pos = item.position().left,
|
||||
move_handler = function (e) {
|
||||
if (e.pageX >= left_pos) {
|
||||
item.addClass("right");
|
||||
item.removeClass("left");
|
||||
} else {
|
||||
item.addClass("left");
|
||||
item.removeClass("right");
|
||||
}
|
||||
left_pos = e.pageX;
|
||||
};
|
||||
jQuery("html").bind("mousemove", move_handler);
|
||||
item.data("move_handler", move_handler);
|
||||
}
|
||||
|
||||
jQuery(".portlet")
|
||||
.addClass("ui-widget ui-widget-content ui-helper-clearfix ui-corner-all")
|
||||
.find(".portlet-header")
|
||||
.addClass("ui-widget-header ui-corner-all")
|
||||
.prepend("<span class='ui-icon ui-icon-minusthick portlet-toggle'></span>");
|
||||
|
||||
jQuery(".portlet-toggle").click(function () {
|
||||
var icon = jQuery(this);
|
||||
icon.toggleClass("ui-icon-minusthick ui-icon-plusthick");
|
||||
icon.closest(".portlet").find(".portlet-content").toggle();
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
var favoriteProject = function(id, element) {
|
||||
|
||||
jQuery(element).addClass("go");
|
||||
if (jQuery(element).hasClass("isFavorite")) {
|
||||
leantime.reactionsController.removeReaction(
|
||||
'project',
|
||||
id,
|
||||
'favorite',
|
||||
function() {
|
||||
jQuery(element).find("i").removeClass("fa-solid").addClass("fa-regular");
|
||||
jQuery(element).removeClass("isFavorite");
|
||||
}
|
||||
);
|
||||
} else {
|
||||
leantime.reactionsController.addReactions(
|
||||
'project',
|
||||
id,
|
||||
'favorite',
|
||||
function() {
|
||||
jQuery(element).find("i").removeClass("fa-regular").addClass("fa-solid");
|
||||
jQuery(element).addClass("isFavorite");
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Make public what you want to have public, everything else is private
|
||||
return {
|
||||
initDates:initDates,
|
||||
initProjectTabs:initProjectTabs,
|
||||
initProgressBar:initProgressBar,
|
||||
initProjectTable:initProjectTable,
|
||||
initDuplicateProjectModal:initDuplicateProjectModal,
|
||||
initTodoStatusSortable:initTodoStatusSortable,
|
||||
initSelectFields:initSelectFields,
|
||||
removeStatus:removeStatus,
|
||||
addToDoStatus:addToDoStatus,
|
||||
saveCroppie:saveCroppie,
|
||||
clearCroppie:clearCroppie,
|
||||
readURL:readURL,
|
||||
initGanttChart:initGanttChart,
|
||||
setUpKanbanColumns:setUpKanbanColumns,
|
||||
initProjectsKanban:initProjectsKanban,
|
||||
favoriteProject:favoriteProject
|
||||
|
||||
};
|
||||
})();
|
||||
43
app/Domain/Projects/Middleware/CurrentProject.php
Normal file
43
app/Domain/Projects/Middleware/CurrentProject.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Projects\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Leantime\Core\Http\HtmxRequest;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use Leantime\Domain\Auth\Services\Auth as AuthService;
|
||||
use Leantime\Domain\Help\Services\Helper;
|
||||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class CurrentProject
|
||||
{
|
||||
/**
|
||||
* Set the current project
|
||||
*
|
||||
* @param \Closure(IncomingRequest): Response $next
|
||||
**/
|
||||
public function handle(IncomingRequest $request, Closure $next): Response
|
||||
{
|
||||
|
||||
if (app()->make(AuthService::class)->loggedIn()) {
|
||||
|
||||
$actionPath = $request->getModuleName();
|
||||
|
||||
// Only change/set project if the request is not htmx, api or cron
|
||||
if (! ($request instanceof HtmxRequest) && $actionPath != 'api' && $actionPath != 'cron') {
|
||||
app()->make(ProjectService::class)->setCurrentProject();
|
||||
|
||||
// Ensure the user has a default project on first login.
|
||||
// This was previously triggered inside a view composer (Helpermodal)
|
||||
// which is unsafe since view composers should never perform writes.
|
||||
app()->make(Helper::class)->ensureDefaultProject(
|
||||
session('userdata.id'),
|
||||
session('userdata.role') ?? 'editor'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
95
app/Domain/Projects/Models/Project.php
Normal file
95
app/Domain/Projects/Models/Project.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Projects\Models;
|
||||
|
||||
use DateTime;
|
||||
|
||||
/**
|
||||
* Project Model
|
||||
*
|
||||
* @property int|string $id
|
||||
* @property string $name
|
||||
* @property int|string|null $clientId
|
||||
* @property DateTime|string|null $start
|
||||
* @property DateTime|string|null $end
|
||||
*/
|
||||
class Project
|
||||
{
|
||||
public int|string $id;
|
||||
|
||||
public $name;
|
||||
|
||||
public null|int|string $clientId;
|
||||
|
||||
public $start;
|
||||
|
||||
public $end;
|
||||
|
||||
public int|string $projectId;
|
||||
|
||||
public $type;
|
||||
|
||||
public $state;
|
||||
|
||||
public $menuType;
|
||||
|
||||
public $numberOfTickets;
|
||||
|
||||
public $sortIndex;
|
||||
|
||||
public $progress;
|
||||
|
||||
public $milestones;
|
||||
|
||||
public $lastUpdate;
|
||||
|
||||
public $report;
|
||||
|
||||
public $status;
|
||||
|
||||
public $clientName;
|
||||
|
||||
public $isFavorite;
|
||||
|
||||
/**
|
||||
* Create a new Project instance from array data
|
||||
*
|
||||
* @param array|null $data Array of project data
|
||||
*/
|
||||
public function __construct(?array $data = null)
|
||||
{
|
||||
if ($data === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Map array data to object properties
|
||||
$this->id = $data['id'] ?? 0;
|
||||
$this->name = $data['name'] ?? '';
|
||||
$this->clientId = $data['clientId'] ?? null;
|
||||
$this->projectId = $data['projectId'] ?? $this->id;
|
||||
|
||||
// Handle dates
|
||||
$this->start = ! empty($data['start']) ? $data['start'] : null;
|
||||
$this->end = ! empty($data['end']) ? $data['end'] : null;
|
||||
|
||||
// Project metadata
|
||||
$this->type = $data['type'] ?? '';
|
||||
$this->state = $data['state'] ?? '';
|
||||
$this->menuType = $data['menuType'] ?? '';
|
||||
$this->status = $data['status'] ?? '';
|
||||
|
||||
// Project metrics
|
||||
$this->numberOfTickets = $data['numberOfTickets'] ?? 0;
|
||||
$this->progress = $data['progress'] ?? 0;
|
||||
$this->sortIndex = $data['sortIndex'] ?? 0;
|
||||
|
||||
// Related data
|
||||
$this->milestones = $data['milestones'] ?? null;
|
||||
$this->report = $data['report'] ?? null;
|
||||
$this->clientName = $data['clientName'] ?? '';
|
||||
|
||||
// Additional properties
|
||||
$this->lastUpdate = $data['lastUpdate'] ?? null;
|
||||
$this->isFavorite = $data['isFavorite'] ?? false;
|
||||
}
|
||||
}
|
||||
60
app/Domain/Projects/Permissions/ProjectsPermissions.php
Normal file
60
app/Domain/Projects/Permissions/ProjectsPermissions.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Projects\Permissions;
|
||||
|
||||
use Leantime\Core\Auth\Permissions\Permission;
|
||||
use Leantime\Core\Auth\Permissions\ProvidesPermissions;
|
||||
|
||||
/**
|
||||
* The Projects permission vocabulary.
|
||||
*
|
||||
* Two scopes, because Projects mixes a company-level management capability with per-project data
|
||||
* reads:
|
||||
*
|
||||
* - VIEW is PROJECT-scoped (readonly+): reading a project's data (name, progress, settings,
|
||||
* avatar, integration config) by id. It auto-grants through the matrix and the project-scoped
|
||||
* check AND-ins data access (isUserAssignedToProject), so a caller can only read projects they
|
||||
* already have access to. This closes the cross-project read IDOR on the @api reads without
|
||||
* changing who can see their own projects.
|
||||
*
|
||||
* - CREATE / EDIT / DELETE are GLOBAL-scoped, MANAGER+ (admin/owner via scope:any; an explicit
|
||||
* manager rule in {@see \Leantime\Core\Auth\Permissions\DefaultRolePermissions} grants them —
|
||||
* editors do NOT get them). This is grant-equivalent to the legacy controllers, which gate
|
||||
* project management on the GLOBAL manager role (authOrRedirect([owner,admin,manager],
|
||||
* forceGlobalRoleCheck: true)). A manager manages any project company-wide; being global-scoped
|
||||
* they sidestep the project-membership AND-in (and the ChecksProjectAccess recursion path).
|
||||
* Project-membership management (assign users, roles) folds into EDIT.
|
||||
*
|
||||
* NOT represented here (deliberately): the access-resolution methods getProjectRole() and
|
||||
* isUserAssignedToProject() — the permission engine itself calls those during every project-scoped
|
||||
* authorization, so they must remain ungated (an in-body authorize would recurse infinitely).
|
||||
*/
|
||||
final class ProjectsPermissions implements ProvidesPermissions
|
||||
{
|
||||
/** Read a project's data by id (project-scoped, readonly+; AND-ins data access). */
|
||||
public const VIEW = 'projects.view';
|
||||
|
||||
/** Create a project. Manager+ (global, company-wide). */
|
||||
public const CREATE = 'projects.create';
|
||||
|
||||
/** Edit a project's settings / membership / integrations. Manager+ (global, company-wide). */
|
||||
public const EDIT = 'projects.edit';
|
||||
|
||||
/** Delete a project. Manager+ (global, company-wide). */
|
||||
public const DELETE = 'projects.delete';
|
||||
|
||||
public function domain(): string
|
||||
{
|
||||
return 'projects';
|
||||
}
|
||||
|
||||
public function permissions(): array
|
||||
{
|
||||
return [
|
||||
new Permission(self::VIEW, 'View a project', true),
|
||||
new Permission(self::CREATE, 'Create projects', false),
|
||||
new Permission(self::EDIT, 'Edit projects', false),
|
||||
new Permission(self::DELETE, 'Delete projects', false),
|
||||
];
|
||||
}
|
||||
}
|
||||
1163
app/Domain/Projects/Repositories/Projects.php
Normal file
1163
app/Domain/Projects/Repositories/Projects.php
Normal file
File diff suppressed because it is too large
Load Diff
3643
app/Domain/Projects/Services/Projects.php
Normal file
3643
app/Domain/Projects/Services/Projects.php
Normal file
File diff suppressed because it is too large
Load Diff
40
app/Domain/Projects/Templates/createnew.blade.php
Normal file
40
app/Domain/Projects/Templates/createnew.blade.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<div class="center padding-lg" style="max-width:1200px;">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h1 style="font-size:var(--font-size-xxxl);">Create something new</h1><br />
|
||||
{!! __("text.creation_hub") !!}
|
||||
|
||||
<br />
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="row">
|
||||
|
||||
@foreach($projectTypes as $projectType)
|
||||
<div class="col-md-4 {{ $projectType["active"] !== true ? "disabled" : "" }}" >
|
||||
<div class="profileBox">
|
||||
|
||||
<x-global::undrawSvg image="{{ $projectType['image'] }}" headline="{{ __($projectType['label']) }}" maxWidth="50%" height="150px"></x-global::undrawSvg>
|
||||
|
||||
<br />
|
||||
{!! __($projectType["description"]) !!}
|
||||
<br /><br />
|
||||
@if($projectType["active"] == true )
|
||||
<x-global::forms.button tag="a" link="{{ BASE_URL }}/{{ $projectType['url'] }}" contentRole="primary">{{ __($projectType['btnLabel']) }}</x-global::forms.button>
|
||||
@else
|
||||
<x-global::forms.button tag="a" link="#" contentRole="primary" class="disabled">Not Available in this plan</x-global::forms.button>
|
||||
@endif
|
||||
<div class="clearall"></div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
34
app/Domain/Projects/Templates/delProject.blade.php
Normal file
34
app/Domain/Projects/Templates/delProject.blade.php
Normal file
@@ -0,0 +1,34 @@
|
||||
@extends($layout)
|
||||
|
||||
@section('content')
|
||||
|
||||
<div class="pageheader">
|
||||
|
||||
<div class="pageicon"><span class="fa {{ $tpl->getModulePicture() }}"></span></div>
|
||||
<div class="pagetitle">
|
||||
<h5>{!! __('label.administration') !!}</h5>
|
||||
<h1>{!! sprintf(__('headlines.delete_project_x'), $project['name']) !!}</h1>
|
||||
</div>
|
||||
</div><!--pageheader-->
|
||||
|
||||
<div class="maincontent">
|
||||
<div class="maincontentinner">
|
||||
|
||||
{!! $tpl->displayNotification() !!}
|
||||
|
||||
<h4 class="widget widgettitle">{!! __('subtitles.delete') !!}</h4>
|
||||
<div class="widgetcontent">
|
||||
|
||||
<form method="post">
|
||||
<p>{!! __('text.confirm_project_deletion') !!}</p><br />
|
||||
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.yes_delete')" name="del" />
|
||||
<x-global::forms.button tag="a" link="{{ BASE_URL }}/projects/showProject/{{ $project['id'] }}" contentRole="tertiary">{!! __('buttons.back') !!}</x-global::forms.button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
37
app/Domain/Projects/Templates/duplicateProject.blade.php
Normal file
37
app/Domain/Projects/Templates/duplicateProject.blade.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<h4 class="widgettitle title-light">{!! sprintf(__('headlines.duplicate_project_x'), $project['name']) !!}</h4>
|
||||
|
||||
{!! $tpl->displayNotification() !!}
|
||||
|
||||
<form class="formModal" method="post" action="{{ BASE_URL }}/projects/duplicateProject/{{ $project['id'] }}">
|
||||
|
||||
<label>{!! __('label.newProjectName') !!}</label>
|
||||
<x-global::forms.text-input name="projectName" value="{!! __('label.copy_of') !!} {{ $project['name'] }}" /><br />
|
||||
|
||||
<label>{!! __('label.planned_start_date') !!}</label>
|
||||
<input type="text" name="startDate" class="projectDateFrom" value="{{ format(date('Y-m-d'))->date() }}" placeholder="{{ __('language.dateformat') }}" id="sprintStart" /><br />
|
||||
|
||||
<label>{!! __('label.client_product') !!}</label>
|
||||
<select name="clientId" id="clientId">
|
||||
@foreach ($allClients as $row)
|
||||
<option value="{{ $row['id'] }}"
|
||||
@if ($project['clientId'] == $row['id']) selected=selected @endif
|
||||
>{{ $row['name'] }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<br />
|
||||
<input style="float:left; margin-right:5px;"
|
||||
type="checkbox" name="assignSameUsers" id="assignSameUsers"/>
|
||||
<label for="assignSameUsers">{!! __('label.assignSameUsers') !!}</label>
|
||||
|
||||
<br />
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.duplicate')" />
|
||||
</div>
|
||||
<div class="col-md-6 align-right padding-top-sm">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
1
app/Domain/Projects/Templates/editAccount.blade.php
Normal file
1
app/Domain/Projects/Templates/editAccount.blade.php
Normal file
@@ -0,0 +1 @@
|
||||
{{-- Intentionally empty stub — EditAccount controller and template are not yet implemented. --}}
|
||||
1
app/Domain/Projects/Templates/editProject.blade.php
Normal file
1
app/Domain/Projects/Templates/editProject.blade.php
Normal file
@@ -0,0 +1 @@
|
||||
{{-- Intentionally empty stub — EditProject controller and template are not yet implemented. --}}
|
||||
168
app/Domain/Projects/Templates/newProject.blade.php
Normal file
168
app/Domain/Projects/Templates/newProject.blade.php
Normal file
@@ -0,0 +1,168 @@
|
||||
@extends($layout)
|
||||
|
||||
@section('content')
|
||||
|
||||
<div class="pageheader">
|
||||
|
||||
<div class="pageicon"><span class="fa fa-suitcase"></span></div>
|
||||
<div class="pagetitle">
|
||||
<h5>{!! __('label.administration') !!}</h5>
|
||||
<h1>{!! __('headline.new_project') !!}</h1>
|
||||
</div>
|
||||
|
||||
</div><!--pageheader-->
|
||||
|
||||
<div class="maincontent">
|
||||
<div class="maincontentinner">
|
||||
|
||||
{!! $tpl->displayNotification() !!}
|
||||
|
||||
<div class="tabbedwidget tab-primary projectTabs">
|
||||
|
||||
<ul>
|
||||
<li><a href="#projectdetails">{!! __('tabs.projectdetails') !!}</a></li>
|
||||
</ul>
|
||||
|
||||
<div id="projectdetails">
|
||||
<form action="" method="post" class="">
|
||||
|
||||
<div class="row">
|
||||
|
||||
<div class="col-md-8">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
|
||||
<div class="form-group">
|
||||
<x-global::forms.text-input variant="headline" name="name" id="name" style="width:99%" value="{{ $project['name'] }}" placeholder="{{ __('input.placeholders.enter_title_of_project') }}" />
|
||||
</div>
|
||||
<input type="hidden" name="projectState" id="projectState" value="0" />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<br />
|
||||
<p>
|
||||
{!! __('label.accomplish') !!}
|
||||
{!! __('label.describe_outcome') !!}
|
||||
<br /><br />
|
||||
</p>
|
||||
<textarea name="details" id="details" class="tiptapComplex" rows="5" cols="50">{{ $project['details'] }}</textarea>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="padding-top">
|
||||
@if (isset($project['id']) && $project['id'] != '')
|
||||
<div class="pull-right padding-top">
|
||||
<x-global::forms.button tag="a" link="{{ BASE_URL }}/projects/delProject/{{ $project['id'] }}" class="delete" state="danger" variant="outline"><i class="fa fa-trash"></i> {!! __('buttons.delete') !!}</x-global::forms.button>
|
||||
</div>
|
||||
@endif
|
||||
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')" name="save" id="save" />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
|
||||
@if ($projectTypes && count($projectTypes) > 1)
|
||||
<h4 class="widgettitle title-light"><i class="fa-regular fa-rectangle-list"></i> Project Type</h4>
|
||||
<p>The type of the project. This will determine which features are available.</p>
|
||||
<select name="type">
|
||||
@foreach ($projectTypes as $key => $type)
|
||||
<option value="{{ $key }}"
|
||||
@if ($project['type'] == $key) selected='selected' @endif
|
||||
>{!! __($key) !!}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<br /><br />
|
||||
@endif
|
||||
|
||||
@dispatchEvent('beforeClientPicker', $project)
|
||||
|
||||
<div style="margin-bottom: 30px;">
|
||||
<h4 class="widgettitle title-light tw-block"><span
|
||||
class="fa fa-calendar"></span>{!! __('label.project_dates') !!}</h4>
|
||||
<div>
|
||||
<label>{!! __('label.project_start') !!}</label>
|
||||
<div class="">
|
||||
<input type="text" class="dates dateFrom" style="width:100px;" name="start" autocomplete="off"
|
||||
value="{{ $project['start'] }}" placeholder="{{ __('language.dateformat') }}"/>
|
||||
|
||||
</div>
|
||||
<label>{!! __('label.project_end') !!}</label>
|
||||
<div class="">
|
||||
<input type="text" class="dates dateTo" style="width:100px;" name="end" autocomplete="off"
|
||||
value="{{ $project['end'] }}" placeholder="{{ __('language.dateformat') }}"/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 30px;">
|
||||
|
||||
<div class="">
|
||||
<h4 class="widgettitle title-light"><span
|
||||
class="fa fa-building"></span>{!! __('label.client_product') !!}</h4>
|
||||
<select name="clientId" id="clientId">
|
||||
|
||||
@foreach ($clients as $row)
|
||||
<option value="{{ $row['id'] }}"
|
||||
@if ($project['clientId'] == $row['id']) selected=selected @endif
|
||||
>{{ $row['name'] }}</option>
|
||||
@endforeach
|
||||
|
||||
</select>
|
||||
@if ($login::userIsAtLeast('manager'))
|
||||
<br /><a href="{{ BASE_URL }}/clients/newClient" target="_blank">{!! __('label.client_not_listed') !!}</a>
|
||||
@endif
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 30px;">
|
||||
<div class="">
|
||||
<h4 class="widgettitle title-light"><span
|
||||
class="fa fa-lock-open"></span>{!! __('labels.defaultaccess') !!}</h4>
|
||||
{!! __('text.who_can_access') !!}
|
||||
<br /><br />
|
||||
|
||||
<select name="globalProjectUserAccess" style="max-width:300px;">
|
||||
<option value="restricted" {{ $project['psettings'] == 'restricted' ? "selected='selected'" : '' }}>{!! __('labels.only_chose') !!}</option>
|
||||
<option value="clients" {{ $project['psettings'] == 'clients' ? "selected='selected'" : '' }}>{!! __('labels.everyone_in_client') !!}</option>
|
||||
<option value="all" {{ $project['psettings'] == 'all' ? "selected='selected'" : '' }}>{!! __('labels.everyone_in_org') !!}</option>
|
||||
</select>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@once @push('scripts')
|
||||
<script type="text/javascript">
|
||||
jQuery(document).ready(function() {
|
||||
|
||||
jQuery("#projectdetails select").chosen();
|
||||
leantime.dateController.initDateRangePicker(".dateFrom", ".dateTo", 2);
|
||||
|
||||
leantime.projectsController.initProjectTabs();
|
||||
if (window.leantime && window.leantime.tiptapController) {
|
||||
leantime.tiptapController.initComplexEditor();
|
||||
}
|
||||
|
||||
}
|
||||
);
|
||||
|
||||
</script>
|
||||
@endpush @endonce
|
||||
|
||||
@endsection
|
||||
77
app/Domain/Projects/Templates/partials/checklist.blade.php
Normal file
77
app/Domain/Projects/Templates/partials/checklist.blade.php
Normal file
@@ -0,0 +1,77 @@
|
||||
@props([
|
||||
'includeTitle' => true
|
||||
])
|
||||
|
||||
@if ($includeTitle)
|
||||
<h5 class="subtitle">Project Checklist <i class="fa fa-question-circle-o helperTooltip" data-tippy-content="The project checklist is list of activities you should do to ensure your projects are well defined, planned and executed."></i> </h5><br/>
|
||||
|
||||
@endif
|
||||
|
||||
<form name="progressForm" id="progressForm">
|
||||
<div class="projectSteps">
|
||||
<div class="progressWrapper">
|
||||
<div class="progress">
|
||||
<div
|
||||
id="progressChecklistBar"
|
||||
class="progress-bar progress-bar-success tx-transition"
|
||||
role="progressbar"
|
||||
aria-valuenow="0"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
style="width: {{ $percentDone }}%"
|
||||
><span class="sr-only">{{ $percentDone }}%</span></div>
|
||||
</div>
|
||||
|
||||
@foreach ($progressSteps as $step)
|
||||
<div class="step {{ $step['stepType'] }}" style="left: {{ $step['positionLeft'] }}%;">
|
||||
<a href="javascript:void(0)" data-toggle="dropdown" class="dropdown-toggle" data-tippy-content="{{ __($step['description']) }}">
|
||||
<span class="innerCircle"></span>
|
||||
<span class="title">
|
||||
@if ($step['status'] == 'done')
|
||||
<i class="fa fa-circle-check"></i>
|
||||
@else
|
||||
<i class="fa-regular fa-circle"></i>
|
||||
@endif
|
||||
{{ __("text.step_".$loop->index + 1) }}: {{ __($step['title']) }}
|
||||
<i class="fa fa-caret-down" aria-hidden="true"></i>
|
||||
</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
|
||||
@foreach ($step['tasks'] as $key => $task)
|
||||
<li @if ($task['status'] == 'done') class="done" @endif>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="{{ $key }}"
|
||||
id="progress_{{ $key }}"
|
||||
hx-patch="{{ BASE_URL }}/hx/projects/checklist/update-subtask/"
|
||||
hx-target="#progressForm"
|
||||
hx-swap="outerHTML"
|
||||
@if ($task['status'] == 'done') checked @endif
|
||||
@if (! in_array($step['stepType'], ['complete', 'current']))
|
||||
disabled
|
||||
|
||||
@endif
|
||||
/>
|
||||
<label for="progress_{{ $key }}"
|
||||
@if (! in_array($step['stepType'], ['complete', 'current']))
|
||||
data-tippy-content="Finish the previous steps first"
|
||||
|
||||
@endif
|
||||
|
||||
>{{ __($task['title'] ?? '') }}</label>
|
||||
<span class="clearall"></span>
|
||||
<span class="taskDescription">
|
||||
{{ __($task['description'] ?? '') }}<br />
|
||||
<a href="{{ $task['link'] ?? '#' }}"><i class="fa fa-external-link"></i> Take me there</a>
|
||||
</span>
|
||||
|
||||
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
57
app/Domain/Projects/Templates/partials/projectCard.blade.php
Normal file
57
app/Domain/Projects/Templates/partials/projectCard.blade.php
Normal file
@@ -0,0 +1,57 @@
|
||||
@props([
|
||||
'project' => [],
|
||||
'type' => 'simple'
|
||||
])
|
||||
|
||||
<div class="projectBox" id="projectBox-{{ $project['id'] }}">
|
||||
<div class="row" >
|
||||
<div class="col-md-12 fixed">
|
||||
<div class="row tw-pb-sm">
|
||||
<div class="col-md-10">
|
||||
<a href="{{ BASE_URL }}/dashboard/show?projectId={{ $project['id'] }}">
|
||||
<span class="projectAvatar">
|
||||
@if(isset($projectTypeAvatars[$project["type"]]) && $projectTypeAvatars[$project["type"]] != "avatar")
|
||||
<span class="{{ $projectTypeAvatars[$project["type"]] }}"></span>
|
||||
@else
|
||||
<img src='{{ BASE_URL }}/api/projects?projectAvatar={{ $project["id"] }}&v={{ format($project['modified'])->timestamp() }}' />
|
||||
@endif
|
||||
</span>
|
||||
@if($project["clientName"] != '')
|
||||
<small>{{ $project["clientName"] }}</small><br />
|
||||
@else
|
||||
<small>{{ __('projectType.'.$project["type"] ?? 'project') }}</small><br />
|
||||
@endif
|
||||
<strong>{{ $project['name'] }} <i class="fa-solid fa-up-right-from-square"></i></strong>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-2 tw-text-right">
|
||||
<a href="javascript:void(0);"
|
||||
hx-patch="{{ BASE_URL }}/hx/projects/projectCard/toggleFavorite"
|
||||
hx-vals='{"isFavorite": {{ $project['isFavorite'] }}, "projectId": {{ $project['id'] }}}'
|
||||
hx-target="#projectBox-{{ $project['id'] }}"
|
||||
onclick="jQuery(this).addClass('go')"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="jQuery(this).removeClass('go')"
|
||||
class="favoriteClick favoriteStar pull-right margin-right {{ $project['isFavorite'] ? 'isFavorite' : ''}} tw-mr-[5px]"
|
||||
data-tippy-content="{{ __('label.favorite_tooltip') }}">
|
||||
<i class="{{ $project['isFavorite'] ? 'fa-solid' : 'fa-regular' }} fa-star"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($type != "simple")
|
||||
<div id="projectProgressBox-{{ $project['id'] }}"
|
||||
hx-get="{{ BASE_URL }}/hx/projects/projectCardProgress/getProgress?pId={{ $project['id'] }}"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML"
|
||||
hx-target="#projectProgressBox-{{ $project['id'] }}"
|
||||
hx-indicator=".htmx-indicator">
|
||||
<div class="htmx-indicator">
|
||||
<x-global::loadingText type="card" count="1" />
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,52 @@
|
||||
@php( $percentDone = format($project['progress']['percent'])->decimal())
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-7">
|
||||
{{ __("subtitles.project_progress") }}
|
||||
</div>
|
||||
<div class="col-md-5" style="text-align:right">
|
||||
{{ sprintf(__("text.percent_complete"), $percentDone) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress">
|
||||
<div class="progress-bar progress-bar-success"
|
||||
role="progressbar"
|
||||
aria-valuenow="{{ $percentDone }}"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
style="width: {{ $percentDone }}%">
|
||||
<span class="sr-only">{{ sprintf(__("text.percent_complete"), $percentDone) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
@if ($project['status'] !== null && $project['status'] != '')
|
||||
<span class="label label-{{ $project['status'] }}">
|
||||
{{ __("label.project_status_" . $project['status']) }}
|
||||
</span><br />
|
||||
@else
|
||||
<span class="label label-grey">{{ __("label.no_status") }}</span><br />
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="team">
|
||||
@foreach ($project['team'] as $member)
|
||||
<div class="commentImage" style="margin-right:-10px;" data-tippy-content="{{ $member['firstname'] }} {{ $member['lastname'] }}">
|
||||
<img
|
||||
style=""
|
||||
src="{{ BASE_URL }}/api/users?profileImage={{ $member['id'] }}&v={{ format($member['modified'])->timestamp() }}" data-tippy-content="{{ $member['firstname'] . ' ' . $member['lastname'] }}" />
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<div class="clearall"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Idempotent init (see milestoneCard) — avoids re-instancing all tooltips
|
||||
// every time a project progress bar renders.
|
||||
window.leantime?.initTooltips?.();
|
||||
</script>
|
||||
@@ -0,0 +1,99 @@
|
||||
<div id="myProjectsHub"
|
||||
hx-get="{{BASE_URL}}/projects/projectHubProjects/get"
|
||||
hx-trigger="HTMX.updateProjectList from:body"
|
||||
hx-target="#myProjectsHub"
|
||||
hx-swap="outerHTML">
|
||||
|
||||
@if (count($clients) > 0)
|
||||
<div class="dropdown dropdownWrapper pull-right">
|
||||
<a href="javascript:void(0)" class="btn btn-default dropdown-toggle header-title-dropdown" data-toggle="dropdown">
|
||||
@if ($currentClientName != '')
|
||||
{{ $currentClientName }}
|
||||
@else
|
||||
{{ __("headline.all_clients") }}
|
||||
@endif
|
||||
|
||||
<i class="fa fa-caret-down"></i>
|
||||
</a>
|
||||
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="{{ BASE_URL }}/projects/showMy">{{ __("headline.all_clients") }}</a></li>
|
||||
@foreach ($clients as $key => $value)
|
||||
@if(! empty($key))
|
||||
<li>
|
||||
<a href="javascript:void(0);"
|
||||
hx-get="{{BASE_URL}}/projects/projectHubProjects/get?client={{ $key }}"
|
||||
hx-target="#myProjectsHub"
|
||||
hx-swap="outerHTML">{{ $value['name'] }}</a>
|
||||
</li>
|
||||
@endif
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (count($allProjects) == 0)
|
||||
<br /><br />
|
||||
<div class='center'>
|
||||
<div style='width:70%; color:var(--main-action-color)' class='svgContainer'>
|
||||
{{ __('notifications.not_assigned_to_any_project') }}
|
||||
@if($login::userIsAtLeast($roles::$manager))
|
||||
<br />
|
||||
<x-global::forms.button tag="a" link="{{ BASE_URL }}/projects/newProject" contentRole="primary">{{ __('link.new_project') }}</x-global::forms.button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<x-global::accordion id="myProjectsHub-favorites" class="noBackground">
|
||||
<x-slot name="title">
|
||||
⭐ My Favorites
|
||||
</x-slot>
|
||||
<x-slot name="content">
|
||||
<div class="row">
|
||||
@php
|
||||
$hasFavorites = false;
|
||||
@endphp
|
||||
@foreach ($allProjects as $project)
|
||||
@if($project['isFavorite'] == true)
|
||||
<div class="col-md-4">
|
||||
@include("projects::partials.projectCard", ["project" => $project, "type" => "detailed"])
|
||||
</div>
|
||||
@php
|
||||
$hasFavorites = true;
|
||||
@endphp
|
||||
@endif
|
||||
@endforeach
|
||||
@if($hasFavorites === false)
|
||||
<div style="color:var(--main-action-color)">
|
||||
You don't have any favorites. 😿
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-slot>
|
||||
</x-global::accordion>
|
||||
|
||||
|
||||
<x-global::accordion id="myProjectsHub-otherProjects" class="noBackground">
|
||||
<x-slot name="title">
|
||||
🗂️ All Assigned Projects
|
||||
</x-slot>
|
||||
<x-slot name="content">
|
||||
|
||||
<div class="row">
|
||||
@foreach ($allProjects as $project)
|
||||
@if($project['isFavorite'] == false)
|
||||
|
||||
<div class="col-md-3">
|
||||
@include("projects::partials.projectCard", ["project" => $project, "type" => "detailed"])
|
||||
</div>
|
||||
|
||||
@endif
|
||||
|
||||
@endforeach
|
||||
</div>
|
||||
</x-slot>
|
||||
</x-global::accordion>
|
||||
</div>
|
||||
|
||||
|
||||
129
app/Domain/Projects/Templates/projectHub.blade.php
Normal file
129
app/Domain/Projects/Templates/projectHub.blade.php
Normal file
@@ -0,0 +1,129 @@
|
||||
@extends($layout)
|
||||
|
||||
@section('content')
|
||||
|
||||
@props([
|
||||
'includeTitle' => true,
|
||||
'allProjects' => []
|
||||
])
|
||||
|
||||
<div class="maincontent" style="margin-top:0px">
|
||||
<div style="padding:10px 0px">
|
||||
<div class="center">
|
||||
<span style="font-size:38px; color:var(--main-titles-color););">
|
||||
{{ __("headline.project_hub") }}
|
||||
</span><br />
|
||||
<span style="font-size:18px; color:var(--main-titles-color););">
|
||||
{{ __("text.project_hub_intro") }}
|
||||
@if ($login::userIsAtLeast("manager"))
|
||||
<br /><br /><x-global::forms.button tag="a" link="#/projects/createnew" contentRole="default">{!! __("menu.create_something_new") !!}</x-global::forms.button>
|
||||
@endif
|
||||
</span>
|
||||
<br />
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(is_array($allProjects) && count($allProjects) == 0)
|
||||
<x-global::undrawSvg image="undraw_a_moment_to_relax_bbpa.svg" style="color:var(--main-titles-color);" maxWidth="30%">
|
||||
</x-global::undrawSvg>
|
||||
|
||||
@endif
|
||||
|
||||
<div id="myProjectsHub"
|
||||
hx-get="{{BASE_URL}}/projects/projectHubProjects/get"
|
||||
hx-trigger="HTMX.updateProjectList from:body"
|
||||
hx-target="#myProjectsHub"
|
||||
hx-swap="outerHTML">
|
||||
|
||||
@if (count($clients) > 0)
|
||||
<div class="dropdown dropdownWrapper pull-right">
|
||||
<a href="javascript:void(0)" class="btn btn-default dropdown-toggle header-title-dropdown" data-toggle="dropdown">
|
||||
@if ($currentClientName != '')
|
||||
{{ $currentClientName }}
|
||||
@else
|
||||
{{ __("headline.all_clients") }}
|
||||
@endif
|
||||
|
||||
<i class="fa fa-caret-down"></i>
|
||||
</a>
|
||||
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="{{ CURRENT_URL }}">{{ __("headline.all_clients") }}</a></li>
|
||||
@foreach ($clients as $key => $value)
|
||||
<li>
|
||||
<a href="javascript:void(0);"
|
||||
hx-get="{{BASE_URL}}/projects/projectHubProjects/get?client={{ $key }}"
|
||||
hx-target="#myProjectsHub"
|
||||
hx-swap="outerHTML">{{ $value['name'] }}</a>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (count($allProjects) == 0)
|
||||
<br /><br />
|
||||
<div class='center'>
|
||||
<div style='width:70%; color:var(--main-titles-color)' class='svgContainer'>
|
||||
{{ __('notifications.not_assigned_to_any_project') }}
|
||||
@if($login::userIsAtLeast($roles::$manager))
|
||||
<br /><br />
|
||||
<x-global::forms.button tag="a" link="{{ BASE_URL }}/projects/newProject" contentRole="primary">{{ __('link.new_project') }}</x-global::forms.button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<x-global::accordion id="myProjectsHub-favorites" class="noBackground">
|
||||
<x-slot name="title">
|
||||
⭐ My Favorites
|
||||
</x-slot>
|
||||
<x-slot name="content">
|
||||
<div class="row">
|
||||
@php
|
||||
$hasFavorites = false;
|
||||
@endphp
|
||||
@foreach ($allProjects as $project)
|
||||
@if($project['isFavorite'] == true)
|
||||
<div class="col-md-4">
|
||||
@include("projects::partials.projectCard", ["project" => $project, "type" => "detailed"])
|
||||
</div>
|
||||
@php
|
||||
$hasFavorites = true;
|
||||
@endphp
|
||||
@endif
|
||||
@endforeach
|
||||
@if($hasFavorites === false)
|
||||
<div style="color:var(--main-titles-color)">
|
||||
{{ __("text.no_favorites") }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-slot>
|
||||
</x-global::accordion>
|
||||
|
||||
|
||||
<x-global::accordion id="myProjectsHub-otherProjects" class="noBackground">
|
||||
<x-slot name="title">
|
||||
{{ __("text.all_assigned_projects") }}
|
||||
</x-slot>
|
||||
<x-slot name="content">
|
||||
|
||||
<div class="row">
|
||||
@foreach ($allProjects as $project)
|
||||
@if($project['isFavorite'] == false)
|
||||
|
||||
<div class="col-md-3">
|
||||
@include("projects::partials.projectCard", ["project" => $project, "type" => "detailed"])
|
||||
</div>
|
||||
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
</x-slot>
|
||||
</x-global::accordion>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
222
app/Domain/Projects/Templates/showAll.blade.php
Normal file
222
app/Domain/Projects/Templates/showAll.blade.php
Normal file
@@ -0,0 +1,222 @@
|
||||
@extends($layout)
|
||||
|
||||
@section('content')
|
||||
|
||||
@php
|
||||
$showClosedProjects = $showClosedProjects ?? false;
|
||||
@endphp
|
||||
|
||||
<div class="pageheader">
|
||||
|
||||
<div class="pageicon"><span class="fa fa-suitcase"></span></div>
|
||||
<div class="pagetitle">
|
||||
<h5>{!! __('label.administration') !!}</h5>
|
||||
<h1>{!! __('headline.all_projects') !!}</h1>
|
||||
</div>
|
||||
|
||||
</div><!--pageheader-->
|
||||
|
||||
<div class="maincontent">
|
||||
<div class="maincontentinner">
|
||||
|
||||
{!! $tpl->displayNotification() !!}
|
||||
|
||||
<div class="pull-right">
|
||||
<form action="" method="post">
|
||||
<input type="hidden" name="hideClosedProjects" value="1" />
|
||||
<input type="checkbox" name="showClosedProjects" onclick="form.submit();" id="showClosed" @if ($showClosedProjects) checked='checked' @endif /> <label for="showClosed" class="pull-right">Show Closed Projects</label>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<x-global::forms.button tag="a" link="{{ BASE_URL }}/projects/newProject" contentRole="primary"><i class='fa fa-plus'></i> {!! __('link.new_project') !!}</x-global::forms.button>
|
||||
|
||||
{{-- 视图切换:甘特图 / 列表 --}}
|
||||
<div class="btn-group pull-right" style="margin-left:8px;">
|
||||
<button type="button" class="btn btn-default active" id="btnViewGantt"><i class="fa fa-chart-gantt"></i> 甘特图</button>
|
||||
<button type="button" class="btn btn-default" id="btnViewList"><i class="fa fa-list"></i> 列表</button>
|
||||
</div>
|
||||
<div class="clearall"></div>
|
||||
|
||||
{{-- 甘特图视图(默认显示,页面加载即初始化) --}}
|
||||
<div id="ganttView" style="margin-top:16px;">
|
||||
<div class="gantt-wrapper" style="position:relative;min-height:420px;">
|
||||
<svg id="gantt" style="min-height:420px;min-width:700px;width:100%;display:block;"></svg>
|
||||
</div>
|
||||
<div id="ganttError" class="alert alert-danger" style="display:none;margin-top:8px;"></div>
|
||||
</div>
|
||||
|
||||
{{-- 列表视图(默认隐藏) --}}
|
||||
<div id="listView" style="display:none;">
|
||||
<table class="table table-bordered" cellpadding="0" cellspacing="0" border="0" id="allProjectsTable">
|
||||
|
||||
<colgroup>
|
||||
<col class="con1"/>
|
||||
<col class="con0" />
|
||||
<col class="con1"/>
|
||||
<col class="con0" />
|
||||
<col class="con1"/>
|
||||
<col class="con0"/>
|
||||
<col class="con0"/>
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="head0">{!! __('label.project_name') !!}</th>
|
||||
<th class="head1">{!! __('label.client_product') !!}</th>
|
||||
<th class="head1">{!! __('label.project_type') !!}</th>
|
||||
<th class="head0">{!! __('label.project_state') !!}</th>
|
||||
<th class="head0">{!! __('label.hourly_budget') !!}</th>
|
||||
<th class="head1">{!! __('label.budget_cost') !!}</th>
|
||||
<th class="head1" style="width:40px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
|
||||
@foreach ($allProjects as $row)
|
||||
<tr class='gradeA'>
|
||||
<td style="padding:6px;">
|
||||
<a class="" href="{{ BASE_URL }}/projects/showProject/{{ $row['id'] }}">{{ $row['name'] }}</a>
|
||||
</td>
|
||||
<td>
|
||||
<a class="" href="{{ BASE_URL }}/clients/showClient/{{ $row['clientId'] }}">{{ $row['clientName'] }}</a>
|
||||
</td>
|
||||
|
||||
<td> {{ $row['type'] }} </td>
|
||||
|
||||
<td>
|
||||
@if ($row['state'] == -1)
|
||||
{!! __('label.closed') !!}
|
||||
@else
|
||||
{!! __('label.open') !!}
|
||||
@endif
|
||||
</td>
|
||||
<td class="center">{{ $row['hourBudget'] }}</td>
|
||||
<td class="center">{{ $row['dollarBudget'] }}</td>
|
||||
<td class="center">
|
||||
<button type="button" class="btn btn-xs btn-default btnToggleProject" data-project-id="{{ $row['id'] }}">
|
||||
<i class="fa fa-chevron-down"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="projectDetailRow" id="projectDetail-{{ $row['id'] }}" style="display:none;">
|
||||
<td colspan="7" style="padding:12px;background:#f9f9f9;">
|
||||
<div style="display:flex;gap:16px;flex-wrap:wrap;">
|
||||
<div style="flex:1;min-width:220px;">
|
||||
<h5 style="margin-top:0;"><i class="fa fa-link"></i> 关联主数据</h5>
|
||||
@if (empty($row['linkedMasters']))
|
||||
<em class="text-muted">暂无关联主数据</em>
|
||||
@else
|
||||
<table class="table table-condensed table-bordered" style="margin-bottom:0;background:#fff;">
|
||||
<thead>
|
||||
<tr><th>类型</th><th>编号</th><th>名称</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($row['linkedMasters'] as $m)
|
||||
<tr>
|
||||
<td><span class="label label-info">{{ $m['typeName'] }}</span></td>
|
||||
<td>{{ $m['bomNo'] ?? '' }}</td>
|
||||
<td>{{ $m['productName'] ?? '' }}</td>
|
||||
<td><a class="btn btn-xs btn-default" href="{{ BASE_URL }}/bom/refs/{{ $m['refId'] }}">查看</a></td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
@endif
|
||||
</div>
|
||||
<div style="min-width:180px;">
|
||||
<h5 style="margin-top:0;"><i class="fa fa-cog"></i> 快捷操作</h5>
|
||||
<a class="btn btn-xs btn-primary" href="{{ BASE_URL }}/projects/showProject/{{ $row['id'] }}#masterdata">
|
||||
<i class="fa fa-link"></i> 关联主数据
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>{{-- /#listView --}}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@once @push('scripts')
|
||||
<script type="text/javascript">
|
||||
jQuery(document).ready(function() {
|
||||
|
||||
leantime.projectsController.initProjectTable();
|
||||
|
||||
// 点击展开按钮,切换项目关联主数据详情行
|
||||
jQuery('.btnToggleProject').on('click', function () {
|
||||
var projectId = jQuery(this).data('project-id');
|
||||
var row = jQuery('#projectDetail-' + projectId);
|
||||
var icon = jQuery(this).find('i');
|
||||
if (row.is(':visible')) {
|
||||
row.hide();
|
||||
icon.removeClass('fa-chevron-up').addClass('fa-chevron-down');
|
||||
} else {
|
||||
row.show();
|
||||
icon.removeClass('fa-chevron-down').addClass('fa-chevron-up');
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 甘特图 / 列表 视图切换(甘特图默认显示,加载即初始化) ----
|
||||
var ganttTasks = {!! json_encode($ganttTasks ?? [], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) !!};
|
||||
|
||||
function renderGantt() {
|
||||
if (!ganttTasks || !ganttTasks.length) {
|
||||
jQuery('#gantt').html('<div class="alert alert-info" style="margin:20px;">暂无项目数据</div>');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (typeof Gantt === 'undefined') {
|
||||
jQuery('#ganttError').text('甘特图组件未加载(Gantt undefined),请强制刷新 Ctrl+Shift+R').show();
|
||||
return;
|
||||
}
|
||||
new Gantt('#gantt', ganttTasks, {
|
||||
readonlyGantt: true,
|
||||
resizing: false,
|
||||
progress: false,
|
||||
is_draggable: false,
|
||||
view_modes: ['Day', 'Week', 'Month'],
|
||||
view_mode: 'Month',
|
||||
bar_height: 40,
|
||||
header_height: 55,
|
||||
column_width: 30,
|
||||
step: 24,
|
||||
padding: 20,
|
||||
bar_corner_radius: 10,
|
||||
arrow_curve: 10,
|
||||
language: 'en'
|
||||
});
|
||||
} catch (e) {
|
||||
jQuery('#ganttError').text('甘特图渲染失败:' + e.message).show();
|
||||
}
|
||||
}
|
||||
|
||||
// 甘特图组件是 defer 加载,用 load 事件确保已就绪后再渲染
|
||||
if (document.readyState === 'complete') {
|
||||
renderGantt();
|
||||
} else {
|
||||
window.addEventListener('load', renderGantt);
|
||||
}
|
||||
|
||||
jQuery('#btnViewGantt').on('click', function () {
|
||||
jQuery('#ganttView').show();
|
||||
jQuery('#listView').hide();
|
||||
jQuery('#btnViewGantt').addClass('active');
|
||||
jQuery('#btnViewList').removeClass('active');
|
||||
});
|
||||
jQuery('#btnViewList').on('click', function () {
|
||||
jQuery('#ganttView').hide();
|
||||
jQuery('#listView').show();
|
||||
jQuery('#btnViewList').addClass('active');
|
||||
jQuery('#btnViewGantt').removeClass('active');
|
||||
});
|
||||
|
||||
});
|
||||
</script>
|
||||
@endpush @endonce
|
||||
|
||||
@endsection
|
||||
584
app/Domain/Projects/Templates/showProject.blade.php
Normal file
584
app/Domain/Projects/Templates/showProject.blade.php
Normal file
@@ -0,0 +1,584 @@
|
||||
@extends($layout)
|
||||
|
||||
@section('content')
|
||||
|
||||
@php
|
||||
$state = $state ?? null;
|
||||
@endphp
|
||||
|
||||
<div class="pageheader">
|
||||
<div class="pageicon"><span class="fa fa-suitcase"></span></div>
|
||||
<div class="pagetitle">
|
||||
<h5>{!! __('label.administration') !!}</h5>
|
||||
<h1>{!! sprintf(__('headline.project'), $tpl->escape($project['name'])) !!}
|
||||
</h1>
|
||||
</div>
|
||||
</div><!--pageheader-->
|
||||
|
||||
<div class="maincontent">
|
||||
<div class="maincontentinner">
|
||||
|
||||
{!! $tpl->displayNotification() !!}
|
||||
|
||||
<div class="inlineDropDownContainer" style="float:right; z-index:9; padding-top:2px;">
|
||||
|
||||
<x-global::forms.button tag="a" link="{{ BASE_URL }}/projects/duplicateProject/{{ $project['id'] }}" contentRole="default" class="duplicateProjectModal" data-tippy-content="{{ __('link.duplicate_project') }}"><i class="fa-regular fa-copy"></i> Copy</x-global::forms.button>
|
||||
<x-global::forms.button tag="a" state="danger" variant="outline" class="delete" link="{{ BASE_URL }}/projects/delProject/{{ $project['id'] }}" data-tippy-content="{{ __('link.delete_project') }}"><i class="fa fa-trash"></i> Delete</x-global::forms.button>
|
||||
|
||||
|
||||
</div>
|
||||
<div class="tabbedwidget tab-primary projectTabs">
|
||||
|
||||
<ul>
|
||||
<li><a href="#projectdetails"><span class="fa fa-leaf"></span> {!! __('tabs.projectdetails') !!}</a></li>
|
||||
<li><a href="#team"><span class="fa fa-group"></span> {!! __('tabs.team') !!}</a></li>
|
||||
|
||||
<li><a href="#integrations"> <span class="fa fa-asterisk"></span> {!! __('tabs.Integrations') !!}</a></li>
|
||||
<li><a href="#todosettings"><span class="fa fa-list-ul"></span> {!! __('tabs.todosettings') !!}</a></li>
|
||||
<li><a href="#masterdata"><span class="fa fa-link"></span> 主数据</a></li>
|
||||
@dispatchEvent('projectTabsList')
|
||||
</ul>
|
||||
|
||||
<div id="projectdetails">
|
||||
@include('projects::submodules.projectDetails')
|
||||
</div>
|
||||
|
||||
<div id="team">
|
||||
<form method="post" action="{{ BASE_URL }}/projects/showProject/{{ $project['id'] }}#team">
|
||||
<input type="hidden" name="saveUsers" value="1" />
|
||||
|
||||
|
||||
<div class="row-fluid">
|
||||
<div class="span12">
|
||||
|
||||
<div class="form-group">
|
||||
<br />{!! __('text.choose_access_for_users') !!}<br />
|
||||
<br />
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h4 class="widgettitle title-light">
|
||||
<span class="fa fa-users"></span>{!! __('headlines.team_member') !!}
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
@foreach ($project['assignedUsers'] as $userId => $assignedUser)
|
||||
<div class="col-md-4">
|
||||
<div class="userBox">
|
||||
<input type='checkbox' name='editorId[]' id="user-{{ $assignedUser['id'] }}" value='{{ $assignedUser['id'] }}'
|
||||
checked="checked"
|
||||
/>
|
||||
<div class="commentImage">
|
||||
<img src="{{ BASE_URL }}/api/users?profileImage={{ $assignedUser['id'] }}&v={{ format($assignedUser['modified'])->timestamp() }}"/>
|
||||
</div>
|
||||
<label for="user-{{ $assignedUser['id'] }}" >{!! sprintf(__('text.full_name'), $tpl->escape($assignedUser['firstname']), $tpl->escape($assignedUser['lastname'])) !!}
|
||||
@if ($assignedUser['jobTitle'] != '')
|
||||
<small>
|
||||
{{ $assignedUser['jobTitle'] }}
|
||||
</small>
|
||||
<br/>
|
||||
@endif
|
||||
@if ($assignedUser['source'] == 'api')
|
||||
<small>
|
||||
API Access
|
||||
</small>
|
||||
<br/>
|
||||
@endif
|
||||
@if ($assignedUser['status'] == 'i')
|
||||
<small>{!! __('label.invited') !!}</small>
|
||||
@endif
|
||||
</label>
|
||||
@php
|
||||
if (($roles::getRoles()[$assignedUser['role']] == $roles::$admin || $roles::getRoles()[$assignedUser['role']] == $roles::$owner)) {
|
||||
@endphp
|
||||
<x-global::forms.text-input readonly disabled value="{{ __('label.roles.'.$roles::getRoles()[$assignedUser['role']]) }}" />
|
||||
@php
|
||||
} else {
|
||||
@endphp
|
||||
<select name="userProjectRole-{{ $assignedUser['id'] }}">
|
||||
<option value="inherit">Inherit</option>
|
||||
<option value="{{ array_search($roles::$readonly, $roles::getRoles()) }}"
|
||||
@if ($assignedUser['projectRole'] == array_search($roles::$readonly, $roles::getRoles())) selected='selected' @endif
|
||||
>{!! __('label.roles.'.$roles::$readonly) !!}</option>
|
||||
|
||||
<option value="{{ array_search($roles::$commenter, $roles::getRoles()) }}"
|
||||
@if ($assignedUser['projectRole'] == array_search($roles::$commenter, $roles::getRoles())) selected='selected' @endif
|
||||
>{!! __('label.roles.'.$roles::$commenter) !!}</option>
|
||||
<option value="{{ array_search($roles::$editor, $roles::getRoles()) }}"
|
||||
@if ($assignedUser['projectRole'] == array_search($roles::$editor, $roles::getRoles())) selected='selected' @endif
|
||||
>{!! __('label.roles.'.$roles::$editor) !!}</option>
|
||||
<option value="{{ array_search($roles::$manager, $roles::getRoles()) }}"
|
||||
@if ($assignedUser['projectRole'] == array_search($roles::$manager, $roles::getRoles())) selected='selected' @endif
|
||||
>{!! __('label.roles.'.$roles::$manager) !!}</option>
|
||||
</select>
|
||||
@php } @endphp
|
||||
<div class="clearall"></div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
` <div class="row">
|
||||
<div class="col-md-12">
|
||||
<h4 class="widgettitle title-light">
|
||||
<span class="fa fa-user-friends "></span>{!! __('headlines.assign_users_to_project') !!}
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
@foreach ($availableUsers as $row)
|
||||
@if (collect($project['assignedUsers'])->where('id', $row['id'])->isEmpty())
|
||||
<div class="col-md-4">
|
||||
<div class="userBox">
|
||||
<input type='checkbox' name='editorId[]' id="user-{{ $row['id'] }}" value='{{ $row['id'] }}' />
|
||||
|
||||
<div class="commentImage">
|
||||
<img src="{{ BASE_URL }}/api/users?profileImage={{ $row['id'] }}&v={{ format($row['modified'])->timestamp() }}"/>
|
||||
</div>
|
||||
<label for="user-{{ $row['id'] }}" >{!! sprintf(__('text.full_name'), $tpl->escape($row['firstname']), $tpl->escape($row['lastname'])) !!}</label>
|
||||
@if ($roles::getRoles()[$row['role']] == $roles::$admin || $roles::getRoles()[$row['role']] == $roles::$owner)
|
||||
<x-global::forms.text-input readonly disabled value="{{ __('label.roles.'.$roles::getRoles()[$row['role']]) }}" />
|
||||
@else
|
||||
@php $assignedUserMatch = collect($project['assignedUsers'])->where('id', $row['id'])->first(); @endphp
|
||||
<select name="userProjectRole-{{ $row['id'] }}">
|
||||
<option value="inherit">Inherit</option>
|
||||
<option value="{{ array_search($roles::$readonly, $roles::getRoles()) }}"
|
||||
@if ($assignedUserMatch && $assignedUserMatch['projectRole'] == array_search($roles::$readonly, $roles::getRoles())) selected='selected' @endif
|
||||
>{!! __('label.roles.'.$roles::$readonly) !!}</option>
|
||||
|
||||
<option value="{{ array_search($roles::$commenter, $roles::getRoles()) }}"
|
||||
@if ($assignedUserMatch && $assignedUserMatch['projectRole'] == array_search($roles::$commenter, $roles::getRoles())) selected='selected' @endif
|
||||
>{!! __('label.roles.'.$roles::$commenter) !!}</option>
|
||||
<option value="{{ array_search($roles::$editor, $roles::getRoles()) }}"
|
||||
@if ($assignedUserMatch && $assignedUserMatch['projectRole'] == array_search($roles::$editor, $roles::getRoles())) selected='selected' @endif
|
||||
>{!! __('label.roles.'.$roles::$editor) !!}</option>
|
||||
<option value="{{ array_search($roles::$manager, $roles::getRoles()) }}"
|
||||
@if ($assignedUserMatch && $assignedUserMatch['projectRole'] == array_search($roles::$manager, $roles::getRoles())) selected='selected' @endif
|
||||
>{!! __('label.roles.'.$roles::$manager) !!}</option>
|
||||
</select>
|
||||
@endif
|
||||
<div class="clearall"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
@endif
|
||||
@endforeach
|
||||
@if ($login::userIsAtLeast($roles::$manager))
|
||||
<div class="col-md-4">
|
||||
|
||||
<div class="userBox">
|
||||
<a class="userEditModal" href="{{ BASE_URL }}/users/newUser?preSelectProjectId={{ $project['id'] }}" style="font-size:var(--font-size-l); line-height:61px"><span class="fa fa-user-plus"></span> {!! __('links.create_user') !!}</a>
|
||||
<div class="clearall"></div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<br/>
|
||||
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')" name="saveUsers" id="save" />
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
<div id="integrations">
|
||||
|
||||
@if ($projectMuteCount > 0)
|
||||
<div class="alert alert-info" style="margin-bottom: 20px;">
|
||||
<i class="fa fa-bell-slash"></i>
|
||||
{!! sprintf(__('label.project_mute_count'), $projectMuteCount) !!}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<h4 class="widgettitle title-light"><span class="fa fa-leaf"></span>Mattermost</h4>
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<img src="{{ BASE_URL }}/dist/images/mattermost-logoHorizontal.png" width="200" />
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
{!! __('text.mattermost_instructions') !!}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<strong>{!! __('label.webhook_url') !!}</strong><br />
|
||||
<form action="{{ BASE_URL }}/projects/showProject/{{ $project['id'] }}#integrations" method="post">
|
||||
<x-global::forms.text-input name="mattermostWebhookURL" id="mattermostWebhookURL" value="{{ e($mattermostWebhookURL) }}" />
|
||||
<br />
|
||||
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')" name="mattermostSave" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<h4 class="widgettitle title-light"><span class="fa fa-leaf"></span>Slack</h4>
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<img src="https://cdn.cdnlogo.com/logos/s/52/slack.svg" width="200"/>
|
||||
</div>
|
||||
|
||||
<div class="col-md-5">
|
||||
{!! __('text.slack_instructions') !!}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<strong>{!! __('label.webhook_url') !!}</strong><br />
|
||||
<form action="{{ BASE_URL }}/projects/showProject/{{ $project['id'] }}#integrations" method="post">
|
||||
<x-global::forms.text-input name="slackWebhookURL" id="slackWebhookURL" value="{{ e($slackWebhookURL) }}" />
|
||||
<br />
|
||||
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')" name="slackSave" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="widgettitle title-light"><span class="fa fa-leaf"></span>Zulip</h4>
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<img src="{{ BASE_URL }}/dist/images/zulip-org-logo.png" width="200"/>
|
||||
</div>
|
||||
|
||||
<div class="col-md-5">
|
||||
{!! __('text.zulip_instructions') !!}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
|
||||
<form action="{{ BASE_URL }}/projects/showProject/{{ $project['id'] }}#integrations" method="post">
|
||||
<strong>{!! __('label.base_url') !!}</strong><br />
|
||||
<x-global::forms.text-input name="zulipURL" id="zulipURL" placeholder="{{ __('input.placeholders.zulip_url') }}" value="{{ $zulipHook['zulipURL'] }}" />
|
||||
<br />
|
||||
<strong>{!! __('label.bot_email') !!}</strong><br />
|
||||
<x-global::forms.text-input name="zulipEmail" id="zulipEmail" placeholder="" value="{{ $tpl->escape($zulipHook['zulipEmail']) }}" />
|
||||
<br />
|
||||
<strong>{!! __('label.botkey') !!}</strong><br />
|
||||
<x-global::forms.text-input name="zulipBotKey" id="zulipBotKey" placeholder="" value="{{ $tpl->escape($zulipHook['zulipBotKey']) }}" />
|
||||
<br />
|
||||
<strong>{!! __('label.stream') !!}</strong><br />
|
||||
<x-global::forms.text-input name="zulipStream" id="zulipStream" placeholder="" value="{{ $tpl->escape($zulipHook['zulipStream']) }}" />
|
||||
<br />
|
||||
<strong>{!! __('label.topic') !!}</strong><br />
|
||||
<x-global::forms.text-input name="zulipTopic" id="zulipTopic" placeholder="" value="{{ $tpl->escape($zulipHook['zulipTopic']) }}" />
|
||||
<br />
|
||||
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')" name="zulipSave" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="widgettitle title-light"><span class="fa fa-leaf"></span>Telegram</h4>
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<img src="{{ BASE_URL }}/dist/images/telegram-logo.png" width="130" alt="Telegram logo" />
|
||||
</div>
|
||||
|
||||
<div class="col-md-5">
|
||||
{!! __('text.telegram_instructions') !!}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<form action="{{ BASE_URL }}/projects/showProject/{{ $project['id'] }}#integrations" method="post">
|
||||
<strong>{!! __('label.botkey') !!}</strong><br />
|
||||
<x-global::forms.text-input name="telegramBotToken" id="telegramBotToken" placeholder="" value="{{ $tpl->escape($telegramHook['telegramBotToken']) }}" />
|
||||
<br />
|
||||
<strong>{!! __('label.telegram_chat_id') !!}</strong><br />
|
||||
<x-global::forms.text-input name="telegramChatId" id="telegramChatId" placeholder="{{ __('input.placeholders.telegram_chat_id') }}" value="{{ $tpl->escape($telegramHook['telegramChatId']) }}" />
|
||||
<br />
|
||||
<strong>{!! __('label.telegram_topic_id') !!}</strong><br />
|
||||
<x-global::forms.text-input name="telegramTopicId" id="telegramTopicId" placeholder="{{ __('input.placeholders.telegram_topic_id') }}" value="{{ $tpl->escape($telegramHook['telegramTopicId']) }}" />
|
||||
<br />
|
||||
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')" name="telegramSave" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Slack webhook --}}
|
||||
<h4 class='widgettitle title-light'><span class='fa fa-leaf'></span>Discord</h4>
|
||||
<div class='row'>
|
||||
<div class='col-md-3'>
|
||||
<img src='{{ BASE_URL }}/dist/images/discord-logo.png' width='200'/>
|
||||
</div>
|
||||
|
||||
<div class='col-md-5'>
|
||||
{!! __('text.discord_instructions') !!}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<strong>{!! __('label.webhook_url') !!}</strong><br/>
|
||||
<form action="{{ BASE_URL }}/projects/showProject/{{ $project['id'] }}#integrations" method="post">
|
||||
@for ($i = 1; $i <= 3; $i++)
|
||||
@php $discordVarName = 'discordWebhookURL'.$i; $discordVarVal = $$discordVarName ?? ''; @endphp
|
||||
<input type="text" name="discordWebhookURL{{ $i }}" id="discordWebhookURL{{ $i }}" placeholder="{{ __('input.placeholders.discord_url') }}" value="{{ e($discordVarVal) }}"/><br/>
|
||||
@endfor
|
||||
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')" name="discordSave" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div id="todosettings">
|
||||
<form action="{{ BASE_URL }}/projects/showProject/{{ $project['id'] }}#todosettings" method="post">
|
||||
<ul class="sortableTicketList" id="todoStatusList">
|
||||
@foreach ($todoStatus as $key => $ticketStatus)
|
||||
<li>
|
||||
<div class="ticketBox">
|
||||
|
||||
<div class="row statusList" id="todostatus-{{ $key }}">
|
||||
|
||||
<input type="hidden" name="labelKeys[]" id="labelKey-{{ $key }}" class='labelKey' value="{{ $key }}"/>
|
||||
<div class="sortHandle">
|
||||
<br />
|
||||
<span class="fa fa-sort"></span>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label>{!! __('label.sortindex') !!}</label>
|
||||
<input type="text" name="labelSort-{{ $key }}" class="sorter" id="labelSort-{{ $key }}" value="{{ $ticketStatus['sortKey'] }}" style="width:50px;"/>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
|
||||
<label>{!! __('label.label') !!}</label>
|
||||
<input type="text" name="label-{{ $key }}" {{ $key == -1 ? 'readonly' : '' }} id="label-{{ $key }}" value="{{ $ticketStatus['name'] }}" />
|
||||
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label>{!! __('label.color') !!}</label>
|
||||
<select name="labelClass-{{ $key }}" id="labelClass-{{ $key }}" class="colorChosen">
|
||||
<option value="label-purple" class="label-purple" {{ $ticketStatus['class'] == 'label-purple' ? 'selected="selected"' : '' }}><span class="label-purple">{!! __('label.purple') !!}</span></option>
|
||||
<option value="label-pink" class="label-pink" {{ $ticketStatus['class'] == 'label-pink' ? 'selected="selected"' : '' }}><span class="label-pink">{!! __('label.pink') !!}</span></option>
|
||||
<option value="label-darker-blue" class="label-darker-blue" {{ $ticketStatus['class'] == 'label-darker-blue' ? 'selected="selected"' : '' }}><span class="label-darker-blue">{!! __('label.darker-blue') !!}</span></option>
|
||||
<option value="label-info" class="label-info" {{ $ticketStatus['class'] == 'label-info' ? 'selected="selected"' : '' }}><span class="label-info">{!! __('label.dark-blue') !!}</span></option>
|
||||
<option value="label-blue" class="label-blue" {{ $ticketStatus['class'] == 'label-blue' ? 'selected="selected"' : '' }}><span class="label-blue">{!! __('label.blue') !!}</span></option>
|
||||
<option value="label-dark-green" class="label-dark-green" {{ $ticketStatus['class'] == 'label-dark-green' ? 'selected="selected"' : '' }}><span class="label-dark-green">{!! __('label.dark-green') !!}</span></option>
|
||||
<option value="label-success" class="label-success" {{ $ticketStatus['class'] == 'label-success' ? 'selected="selected"' : '' }}><span class="label-success">{!! __('label.green') !!}</span></option>
|
||||
<option value="label-warning" class="label-warning" {{ $ticketStatus['class'] == 'label-warning' ? 'selected="selected"' : '' }}><span class="label-warning">{!! __('label.yellow') !!}</span></option>
|
||||
<option value="label-brown" class="label-brown" {{ $ticketStatus['class'] == 'label-brown' ? 'selected="selected"' : '' }}><span class="label-brown">{!! __('label.brown') !!}</span></option>
|
||||
<option value="label-danger" class="label-danger" {{ $ticketStatus['class'] == 'label-danger' ? 'selected="selected"' : '' }}><span class="label-danger">{!! __('label.dark-red') !!}</span></option>
|
||||
<option value="label-important" class="label-important" {{ $ticketStatus['class'] == 'label-important' ? 'selected="selected"' : '' }}><span class="label-important">{!! __('label.red') !!}</span></option>
|
||||
<option value="label-default" class="label-default" {{ $ticketStatus['class'] == 'label-default' ? 'selected="selected"' : '' }}><span class="label-default">{!! __('label.grey') !!}</span></option>
|
||||
|
||||
|
||||
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label>{!! __('label.reportType') !!}</label>
|
||||
<select name="labelType-{{ $key }}" id="labelType-{{ $key }}">
|
||||
<option value="NEW" {{ ($ticketStatus['statusType'] == 'NEW') ? 'selected="selected"' : '' }}>{!! __('status.new') !!}</option>
|
||||
<option value="INPROGRESS" {{ ($ticketStatus['statusType'] == 'INPROGRESS') ? 'selected="selected"' : '' }}>{!! __('status.in_progress') !!}</option>
|
||||
<option value="DONE" {{ ($ticketStatus['statusType'] == 'DONE') ? 'selected="selected"' : '' }}>{!! __('status.done') !!}</option>
|
||||
<option value="NONE" {{ ($ticketStatus['statusType'] == 'NONE') ? 'selected="selected"' : '' }}>{!! __('status.dont_report') !!}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label for="">{!! __('label.showInKanban') !!}</label>
|
||||
<input type="checkbox" name="labelKanbanCol-{{ $key }}" id="labelKanbanCol-{{ $key }}" {{ $ticketStatus['kanbanCol'] ? 'checked="checked"' : '' }}/>
|
||||
</div>
|
||||
<div class="remove">
|
||||
<br />
|
||||
@if ($key != -1)
|
||||
<a href="javascript:void(0);" onclick="leantime.projectsController.removeStatus({{ $key }})" class="delete"><span class="fa fa-trash"></span></a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($key == -1)
|
||||
<em>* the archive status is protected cannot be renamed or removed.</em>
|
||||
@endif
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@endforeach
|
||||
</ul>
|
||||
|
||||
<a href="javascript:void(0);" onclick="leantime.projectsController.addToDoStatus();" class="quickAddLink" style="text-align:left;">{!! __('links.add_status') !!}</a>
|
||||
<br />
|
||||
<x-global::forms.button tag="input" inputType="submit" :labelText="__('buttons.save')" name="submitSettings" contentRole="primary"/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="masterdata">
|
||||
<h4 class="widgettitle title-light"><span class="fa fa-link"></span> 关联全局主数据</h4>
|
||||
<p class="text-muted">关联 BOM / 工艺文件 / 工具清单后,可在项目里追加项目级列(日期、库存、采购数量等),不影响全局主数据。</p>
|
||||
|
||||
@if (empty($linkedMasters))
|
||||
<div class="alert alert-info">本项目尚未关联任何主数据。</div>
|
||||
@else
|
||||
<div class="table-responsive" style="margin-bottom:20px;">
|
||||
<table class="table table-striped table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>类型</th>
|
||||
<th>编号</th>
|
||||
<th>名称</th>
|
||||
<th>版本</th>
|
||||
<th style="width:180px;">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($linkedMasters as $ref)
|
||||
@php $m = $ref['master'] ?? []; $typeNames = ['bom'=>'BOM','process'=>'工艺文件','tooling'=>'工具清单']; @endphp
|
||||
<tr>
|
||||
<td><span class="label label-info">{{ $typeNames[$m['type'] ?? 'bom'] ?? 'BOM' }}</span></td>
|
||||
<td>{{ $m['bomNo'] ?? '' }}</td>
|
||||
<td>{{ $m['productName'] ?? '' }}</td>
|
||||
<td>{{ $m['version'] ?? '' }}</td>
|
||||
<td>
|
||||
<a class="btn btn-xs btn-default" href="{{ BASE_URL }}/bom/refs/{{ $ref['id'] }}">打开</a>
|
||||
<button class="btn btn-xs btn-danger btnUnlinkMaster" data-ref-id="{{ $ref['id'] }}">取消关联</button>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (! empty($availableMasters))
|
||||
<form method="post" action="{{ BASE_URL }}/projects/showProject/{{ $project['id'] }}#masterdata">
|
||||
<input type="hidden" name="linkMaster" value="1" />
|
||||
<div class="row">
|
||||
<div class="col-md-5">
|
||||
<select name="masterId" class="form-control">
|
||||
@foreach ($availableMasters as $m)
|
||||
<option value="{{ $m['id'] }}">{{ $m['typeName'] }} — {{ $m['bomNo'] ?? '' }} {{ $m['productName'] ?? '' }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" labelText="关联主数据" name="linkMasterSubmit" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@else
|
||||
<div class="alert alert-warning">没有可关联的全局主数据。请先到「BOM / 工艺文件 / 工具清单」创建主数据。</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@dispatchEvent('projectTabsContent')
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- New Status Template -->
|
||||
<div class="newStatusTpl" style="display:none;">
|
||||
<div class="ticketBox">
|
||||
<div class="row statusList" id="todostatus-XXNEWKEYXX">
|
||||
<input type="hidden" name="labelKeys[]" id="labelKey-XXNEWKEYXX" class='labelKey' value="XXNEWKEYXX"/>
|
||||
<div class="sortHandle">
|
||||
<br />
|
||||
<span class="fa fa-sort"></span>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label>{!! __('label.sortindex') !!}</label>
|
||||
<input type="text" name="labelSort-XXNEWKEYXX" class="sorter" id="labelSort-XXNEWKEYXX" value="" style="width:50px;"/>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label>{!! __('label.label') !!}</label>
|
||||
<input type="text" name="label-XXNEWKEYXX" id="label-XXNEWKEYXX" value="" />
|
||||
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label>{!! __('label.color') !!}</label>
|
||||
<select name="labelClass-XXNEWKEYXX" id="labelClass-XXNEWKEYXX" class="colorChosen">
|
||||
<option value="label-blue" class="label-blue"><span class="label-blue">{!! __('label.blue') !!}</span></option>
|
||||
<option value="label-info" class="label-info"><span class="label-info">{!! __('label.dark-blue') !!}</span></option>
|
||||
<option value="label-darker-blue" class="label-darker-blue"><span class="label-darker-blue">{!! __('label.darker-blue') !!}</span></option>
|
||||
<option value="label-warning" class="label-warning"><span class="label-warning">{!! __('label.yellow') !!}</span></option>
|
||||
<option value="label-success" class="label-success"><span class="label-success">{!! __('label.green') !!}</span></option>
|
||||
<option value="label-dark-green" class="label-dark-green"><span class="label-dark-green">{!! __('label.dark-green') !!}</span></option>
|
||||
<option value="label-important" class="label-important"><span class="label-important">{!! __('label.red') !!}</span></option>
|
||||
<option value="label-danger" class="label-danger"><span class="label-danger">{!! __('label.dark-red') !!}</span></option>
|
||||
<option value="label-pink" class="label-pink"><span class="label-pink">{!! __('label.pink') !!}</span></option>
|
||||
<option value="label-purple" class="label-purple"><span class="label-purple">{!! __('label.purple') !!}</span></option>
|
||||
<option value="label-brown" class="label-brown"><span class="label-brown">{!! __('label.brown') !!}</span></option>
|
||||
<option value="label-default" class="label-default"><span class="label-default">{!! __('label.grey') !!}</span></option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label>{!! __('label.reportType') !!}</label>
|
||||
<select name="labelType-XXNEWKEYXX" id="labelType-XXNEWKEYXX">
|
||||
<option value="NEW">{!! __('status.new') !!}</option>
|
||||
<option value="INPROGRESS">{!! __('status.in_progress') !!}</option>
|
||||
<option value="DONE">{!! __('status.done') !!}</option>
|
||||
<option value="NONE">{!! __('status.dont_report') !!}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label for="">{!! __('label.showInKanban') !!}</label>
|
||||
<input type="checkbox" name="labelKanbanCol-XXNEWKEYXX" id="labelKanbanCol-XXNEWKEYXX"/>
|
||||
</div>
|
||||
<div class="remove">
|
||||
<br />
|
||||
<a href="javascript:void(0);" onclick="leantime.projectsController.removeStatus('XXNEWKEYXX')" class="delete"><span class="fa fa-trash"></span></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@once @push('scripts')
|
||||
<script type='text/javascript'>
|
||||
|
||||
jQuery(document).ready(function() {
|
||||
jQuery("#projectdetails select").chosen();
|
||||
|
||||
@if (isset($_GET['integrationSuccess']))
|
||||
window.history.pushState({},document.title, '{{ BASE_URL }}/projects/showProject/{{ (int) $project['id'] }}');
|
||||
@endif
|
||||
|
||||
jQuery(".dates").datepicker(
|
||||
{
|
||||
dateFormat: leantime.dateHelper.getFormatFromSettings("dateformat", "jquery"),
|
||||
dayNames: leantime.i18n.__("language.dayNames").split(","),
|
||||
dayNamesMin: leantime.i18n.__("language.dayNamesMin").split(","),
|
||||
dayNamesShort: leantime.i18n.__("language.dayNamesShort").split(","),
|
||||
monthNames: leantime.i18n.__("language.monthNames").split(","),
|
||||
currentText: leantime.i18n.__("language.currentText"),
|
||||
closeText: leantime.i18n.__("language.closeText"),
|
||||
buttonText: leantime.i18n.__("language.buttonText"),
|
||||
isRTL: leantime.i18n.__("language.isRTL") === "true" ? 1 : 0,
|
||||
nextText: leantime.i18n.__("language.nextText"),
|
||||
prevText: leantime.i18n.__("language.prevText"),
|
||||
weekHeader: leantime.i18n.__("language.weekHeader"),
|
||||
firstDay: leantime.i18n.__("language.firstDayOfWeek"),
|
||||
}
|
||||
);
|
||||
|
||||
leantime.projectsController.initProjectTabs();
|
||||
leantime.projectsController.initDuplicateProjectModal();
|
||||
leantime.projectsController.initTodoStatusSortable("#todoStatusList");
|
||||
leantime.projectsController.initSelectFields();
|
||||
leantime.usersController.initUserEditModal();
|
||||
|
||||
// 取消关联主数据
|
||||
jQuery('.btnUnlinkMaster').on('click', function () {
|
||||
var refId = jQuery(this).data('ref-id');
|
||||
if (!confirm('确定取消关联该主数据?项目级追加的列和数据将被删除,不影响全局主数据。')) { return; }
|
||||
jQuery.ajax({
|
||||
url: '{{ BASE_URL }}/bom/api/ref/' + refId,
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-TOKEN': jQuery('meta[name="csrf-token"]').attr('content') || '' },
|
||||
success: function () { location.reload(); },
|
||||
error: function () { alert('取消失败'); }
|
||||
});
|
||||
});
|
||||
|
||||
if (window.leantime && window.leantime.tiptapController) {
|
||||
leantime.tiptapController.initComplexEditor();
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
</script>
|
||||
@endpush @endonce
|
||||
|
||||
@endsection
|
||||
@@ -0,0 +1,221 @@
|
||||
@php
|
||||
use Leantime\Domain\Menu\Repositories\Menu;
|
||||
@endphp
|
||||
|
||||
<form action="" method="post" class="stdform">
|
||||
|
||||
<div class="row">
|
||||
|
||||
<div class="col-md-8">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="form-group">
|
||||
<x-global::forms.text-input variant="headline" name="name" id="name" style="width:99%" value="{{ $project['name'] }}" placeholder="{{ __('input.placeholders.enter_title_of_project') }}" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<p>
|
||||
{!! __('label.accomplish') !!}
|
||||
<br /><br />
|
||||
</p>
|
||||
<textarea name="details" id="details" class="tiptapComplex" rows="5" cols="50">{{ $project['details'] }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row padding-top">
|
||||
<div class="col-md-12">
|
||||
|
||||
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')" name="save" id="save" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
|
||||
<div class="row marginBottom">
|
||||
|
||||
|
||||
|
||||
@if ($projectTypes && count($projectTypes) > 1)
|
||||
<div class="col-md-12 center">
|
||||
<h4 class="widgettitle title-light"><i class="fa-regular fa-rectangle-list"></i> Project Type</h4>
|
||||
<p>The type of the project. This will determine which features are available.</p>
|
||||
<select name="type">
|
||||
@foreach ($projectTypes as $key => $type)
|
||||
<option value="{{ $tpl->escape($key) }}"
|
||||
@if ($project['type'] == $key)
|
||||
selected='selected'
|
||||
@endif
|
||||
>{!! __($tpl->escape($type)) !!}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<br /><br />
|
||||
</div>
|
||||
@endif
|
||||
|
||||
|
||||
</div>
|
||||
<div class="row marginBottom">
|
||||
|
||||
<div class="col-md-12 center">
|
||||
|
||||
<h4 class="widgettitle title-light"><span
|
||||
class="fa fa-picture-o"></span>{!! __('label.project_avatar') !!}</h4>
|
||||
|
||||
<img src='{{ BASE_URL }}/api/projects?projectAvatar={{ $project['id'] }}&v={{ format($project['modified'])->timestamp() }}' class='profileImg' alt='Profile Picture' id="previousImage"/>
|
||||
<div id="projectAvatar">
|
||||
</div>
|
||||
|
||||
<div class="par">
|
||||
|
||||
<div class='fileupload fileupload-new' data-provides='fileupload'>
|
||||
<input type="hidden"/>
|
||||
<div class="input-append">
|
||||
<div class="uneditable-input span3">
|
||||
<i class="fa-file fileupload-exists"></i>
|
||||
<span class="fileupload-preview"></span>
|
||||
</div>
|
||||
<span class="btn btn-file">
|
||||
<span class="fileupload-new">{!! __('buttons.select_file') !!}</span>
|
||||
<span class='fileupload-exists'>{!! __('buttons.change') !!}</span>
|
||||
<input type='file' name='file' onchange="leantime.projectsController.readURL(this)" accept=".jpg,.png,.gif,.webp"/>
|
||||
</span>
|
||||
|
||||
<a href='#' class='btn fileupload-exists' data-dismiss='fileupload' onclick="leantime.projectsController.clearCroppie()">{!! __('buttons.remove') !!}</a>
|
||||
</div>
|
||||
|
||||
<span id="save-picture" class="btn btn-primary fileupload-exists ld-ext-right">
|
||||
<span onclick="leantime.projectsController.saveCroppie()">{!! __('buttons.save') !!}</span>
|
||||
<span class="ld ld-ring ld-spin"></span>
|
||||
</span>
|
||||
<input type="hidden" name="profileImage" value="1" />
|
||||
<input id="picSubmit" type="submit" name="savePic" class="hidden"
|
||||
value="{{ __('buttons.upload') }}"/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@dispatchEvent('afterProjectAvatar', $project)
|
||||
|
||||
<div class="row marginBottom" style="margin-bottom: 30px;">
|
||||
<div class="col-md-12">
|
||||
<h4 class="widgettitle title-light"><span
|
||||
class="fa fa-calendar"></span>{!! __('label.project_dates') !!}</h4>
|
||||
|
||||
|
||||
<label class="control-label">{!! __('label.project_start') !!}</label>
|
||||
<div class="">
|
||||
<input type="text" class="dates" style="width:100px;" name="start" autocomplete="off"
|
||||
value="{{ format($project['start'])->date() }}" placeholder="{{ __('language.dateformat') }}"/>
|
||||
|
||||
</div>
|
||||
<label class="control-label">{!! __('label.project_end') !!}</label>
|
||||
<div class="">
|
||||
<input type="text" class="dates" style="width:100px;" name="end" autocomplete="off"
|
||||
value="{{ format($project['end'])->date() }}" placeholder="{{ __('language.dateformat') }}"/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="row" style="margin-bottom: 30px;">
|
||||
|
||||
<div class="col-md-12 " style="margin-bottom: 30px;">
|
||||
<h4 class="widgettitle title-light"><span
|
||||
class="fa fa-building"></span>{!! __('label.client_product') !!}</h4>
|
||||
<select name="clientId" id="clientId">
|
||||
|
||||
@foreach ($clients as $row)
|
||||
<option value="{{ $row['id'] }}"
|
||||
@if ($project['clientId'] == $row['id'])
|
||||
selected=selected
|
||||
@endif
|
||||
>{{ $row['name'] }}</option>
|
||||
@endforeach
|
||||
|
||||
</select>
|
||||
@if ($login::userIsAtLeast('manager'))
|
||||
<br /><a href="{{ BASE_URL }}/clients/newClient" target="_blank">{!! __('label.client_not_listed') !!}</a>
|
||||
@endif
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="row marginBottom" style="margin-bottom: 30px;">
|
||||
<div class="col-md-12">
|
||||
<h4 class="widgettitle title-light"><span
|
||||
class="fa fa-wrench"></span>{!! __('label.settings') !!}</h4>
|
||||
|
||||
<input type="hidden" name="menuType" id="menuType"
|
||||
value="{{ Menu::DEFAULT_MENU }}">
|
||||
|
||||
<div class="form-group">
|
||||
|
||||
<label class="col-md-4 control-label" for="projectState">{!! __('label.project_state') !!}</label>
|
||||
<div class="col-md-6">
|
||||
<select name="projectState" id="projectState">
|
||||
<option value="0" @if ($project['state'] == 0) selected=selected @endif>{!! __('label.open') !!}</option>
|
||||
|
||||
<option value="-1" @if ($project['state'] == -1) selected=selected @endif>{!! __('label.closed') !!}</option>
|
||||
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row marginBottom" style="margin-bottom: 30px;">
|
||||
<div class="col-md-12 ">
|
||||
<h4 class="widgettitle title-light"><span
|
||||
class="fa fa-lock-open"></span>{!! __('labels.defaultaccess') !!}</h4>
|
||||
{!! __('text.who_can_access') !!}
|
||||
<br /><br />
|
||||
|
||||
<select name="globalProjectUserAccess" style="max-width:300px;">
|
||||
<option value="restricted" {{ $project['psettings'] == 'restricted' ? "selected='selected'" : '' }}>{!! __('labels.only_chose') !!}</option>
|
||||
<option value="clients" {{ $project['psettings'] == 'clients' ? "selected='selected'" : '' }}>{!! __('labels.everyone_in_client') !!}</option>
|
||||
<option value="all" {{ $project['psettings'] == 'all' ? "selected='selected'" : '' }}>{!! __('labels.everyone_in_org') !!}</option>
|
||||
</select>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" style="margin-bottom: 30px;">
|
||||
<div class="col-md-12 ">
|
||||
<h4 class="widgettitle title-light"><span
|
||||
class="fa fa-money-bill-alt"></span>{!! __('label.budgets') !!}</h4>
|
||||
<div class="form-group">
|
||||
<label class="col-md-4 control-label"for="hourBudget">{!! __('label.hourly_budget') !!}</label>
|
||||
<div class="col-md-6">
|
||||
<x-global::forms.text-input variant="large" name="hourBudget" id="hourBudget" value="{{ $project['hourBudget'] }}" />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="col-md-4 control-label" for="dollarBudget">{!! __('label.budget_cost') !!}</label>
|
||||
<div class="col-md-6">
|
||||
<x-global::forms.text-input variant="large" name="dollarBudget" id="dollarBudget" value="{{ $project['dollarBudget'] }}" />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</form>
|
||||
83
app/Domain/Projects/Templates/submodules/tickets.blade.php
Normal file
83
app/Domain/Projects/Templates/submodules/tickets.blade.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<script type="text/javascript">
|
||||
var ganttData = [
|
||||
|
||||
@php
|
||||
$jsContent = [];
|
||||
foreach ($projectTickets as $ticket) {
|
||||
if ($ticket['editFrom'] != '0000-00-00 00:00:00' && $ticket['editFrom'] != '1969-12-31 00:00:00') {
|
||||
$plannedFromDate = new DateTime($ticket['editFrom']);
|
||||
$plannedToDate = new DateTime($ticket['editTo']);
|
||||
} else {
|
||||
$plannedFromDate = new DateTime;
|
||||
$plannedToDate = new DateTime;
|
||||
$plannedToDate->add(new DateInterval('P1D'));
|
||||
}
|
||||
|
||||
$author = str_replace("'", '', str_replace('"', '', json_encode($ticket['firstname'] . ' ' . $ticket['lastname'])));
|
||||
|
||||
$jsContent[] = '{
|
||||
id: ' . $ticket['id'] . ", name: '<a href=\"/tickets/showTicket/" . $ticket['id'] . "'>" . str_replace("'", '', str_replace('"', '', json_encode($ticket['headline']))) . "</a>', series: [
|
||||
{name: '" . $author . "', start: new Date(" . $plannedFromDate->format('Y') . ', ' . ($plannedFromDate->format('m') - 1) . ', ' . $plannedFromDate->format('d') . '), end: new Date(' . $plannedToDate->format('Y') . ', ' . ($plannedToDate->format('m') - 1) . ', ' . $plannedToDate->format('d') . ') }
|
||||
]
|
||||
}';
|
||||
}
|
||||
|
||||
echo implode(',', $jsContent);
|
||||
@endphp
|
||||
|
||||
];
|
||||
|
||||
|
||||
jQuery(function () {
|
||||
|
||||
var width = jQuery(".maincontentinner").width() - 500;
|
||||
|
||||
jQuery("#ganttChart").ganttView({
|
||||
data: ganttData,
|
||||
slideWidth: width,
|
||||
behavior: {
|
||||
onClick: function (data) {
|
||||
|
||||
},
|
||||
onResize: function (data) {
|
||||
var msg = "You edited the To-Do to start: " + data.start.toString("M/d/yyyy") + ", end: " + data.end.toString("M/d/yyyy") + " }";
|
||||
jQuery("#eventMessage").text(msg);
|
||||
|
||||
jQuery.ajax({
|
||||
type: 'POST',
|
||||
url: leantime.appUrl+'/tickets/editTicket?raw=true&changeDate=true',
|
||||
data:
|
||||
{
|
||||
id : data.id,
|
||||
dateFrom:data.start.toString("yyyy-M-d"),
|
||||
dateTo:data.end.toString("yyyy-M-d")
|
||||
}
|
||||
});
|
||||
jQuery("#eventMessage").show();
|
||||
},
|
||||
onDrag: function (data) {
|
||||
var msg = "You dragged the To-Do to start: " + data.start.toString("M/d/yyyy") + ", end: " + data.end.toString("M/d/yyyy") + " ";
|
||||
jQuery("#eventMessage").text(msg);
|
||||
|
||||
jQuery.ajax({
|
||||
type: 'POST',
|
||||
url: leantime.appUrl+'/tickets/editTicket?raw=true&changeDate=true',
|
||||
data:
|
||||
{
|
||||
id : data.id,
|
||||
dateFrom:data.start.toString("yyyy-M-d"),
|
||||
dateTo:data.end.toString("yyyy-M-d")
|
||||
}
|
||||
});
|
||||
jQuery("#eventMessage").show();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// $("#ganttChart").ganttView("setSlideWidth", 600);
|
||||
});
|
||||
</script>
|
||||
|
||||
{!! $tpl->displayLink('tickets.newTicket', "<i class='fa fa-plus'></i> " . __('NEW_TICKET'), null, ['class' => 'btn btn-primary btn-rounded']) !!}
|
||||
<div id="eventMessage" class="alert alert-success" style="display:none;"></div>
|
||||
<div id="ganttChart"></div>
|
||||
73
app/Domain/Projects/Tools/AddProjectTool.php
Normal file
73
app/Domain/Projects/Tools/AddProjectTool.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Projects\Tools;
|
||||
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\ToolInputSchema;
|
||||
use Laravel\Mcp\Server\Tools\ToolResult;
|
||||
use Leantime\Domain\Modulemanager\Services\Modulemanager;
|
||||
use Leantime\Domain\Projects\Services\Projects;
|
||||
|
||||
/**
|
||||
* Creates a new project with the specified details.
|
||||
*/
|
||||
class AddProjectTool extends Tool
|
||||
{
|
||||
public function __construct(
|
||||
private Projects $projectService,
|
||||
private Modulemanager $moduleManager,
|
||||
) {}
|
||||
|
||||
public function schema(ToolInputSchema $schema): ToolInputSchema
|
||||
{
|
||||
return $schema
|
||||
->string('name')->description('Name of the project.')
|
||||
->required()
|
||||
->integer('clientId')->description('ID of the client for this project.')
|
||||
->required()
|
||||
->string('details')->description('Project description.')
|
||||
->string('start')->description('Start date in ISO 8601 format.')
|
||||
->string('end')->description('End date in ISO 8601 format.')
|
||||
->integer('hourBudget')->description('Hour budget for the project.')
|
||||
->integer('parent')->description('ID of the parent program or plan (only works if PgmPro plugin is active).');
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'addProject';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Creates a new project with the specified details.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the tool request.
|
||||
*/
|
||||
public function handle(array $arguments): ToolResult
|
||||
{
|
||||
$values = [
|
||||
'name' => $arguments['name'],
|
||||
'details' => ($arguments['details'] ?? ''),
|
||||
'clientId' => (int) ($arguments['clientId'] ?? 0),
|
||||
'hourBudget' => ($arguments['hourBudget'] ?? null),
|
||||
'start' => ($arguments['start'] ?? null),
|
||||
'end' => ($arguments['end'] ?? null),
|
||||
];
|
||||
|
||||
// Check if PgmPro plugin is active and parent is specified
|
||||
$parent = ($arguments['parent'] ?? null);
|
||||
if ($parent && $this->moduleManager->isModuleAvailable('pgmPro')) {
|
||||
$values['parent'] = $parent;
|
||||
}
|
||||
|
||||
$projectId = $this->projectService->addProject($values);
|
||||
|
||||
if ($projectId) {
|
||||
return ToolResult::text("Project created successfully with ID: $projectId");
|
||||
}
|
||||
|
||||
return ToolResult::error('Failed to create project. Please check the provided information.');
|
||||
}
|
||||
}
|
||||
61
app/Domain/Projects/Tools/DeleteProjectTool.php
Normal file
61
app/Domain/Projects/Tools/DeleteProjectTool.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Projects\Tools;
|
||||
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\ToolInputSchema;
|
||||
use Laravel\Mcp\Server\Tools\ToolResult;
|
||||
use Leantime\Domain\Projects\Services\Projects;
|
||||
|
||||
/**
|
||||
* 删除项目(高风险写操作)。
|
||||
*
|
||||
* 会删除项目本身 + 所有用户关系。调用方(AI)应先 getProject 列出项目信息
|
||||
* 并征得用户明确确认。
|
||||
*/
|
||||
class DeleteProjectTool extends Tool
|
||||
{
|
||||
public function __construct(
|
||||
private Projects $projectService,
|
||||
) {}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'deleteProject';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return '删除一个项目(高风险操作,不可恢复)。会删除项目及其用户关联。调用前必须先 getProject 列出项目信息并征得用户明确确认。';
|
||||
}
|
||||
|
||||
public function schema(ToolInputSchema $schema): ToolInputSchema
|
||||
{
|
||||
return $schema
|
||||
->integer('id')->description('要删除的项目 ID。')->required()
|
||||
->boolean('confirmed')->description('用户是否已明确确认删除。必须为 true 才执行。')->required();
|
||||
}
|
||||
|
||||
public function handle(array $arguments): ToolResult
|
||||
{
|
||||
$id = (int) ($arguments['id'] ?? 0);
|
||||
$confirmed = (bool) ($arguments['confirmed'] ?? false);
|
||||
|
||||
if (! $confirmed) {
|
||||
return ToolResult::error('未确认删除。请先 getProject 列出项目信息,征得用户确认后再传 confirmed=true。');
|
||||
}
|
||||
|
||||
$project = $this->projectService->getProject($id);
|
||||
if (! isset($project['id'])) {
|
||||
return ToolResult::error("项目不存在或无权访问:{$id}");
|
||||
}
|
||||
|
||||
$name = $project['name'] ?? ('项目 #'.$id);
|
||||
|
||||
if ($this->projectService->deleteProject($id)) {
|
||||
return ToolResult::text("项目已删除:{$name}(ID {$id})。");
|
||||
}
|
||||
|
||||
return ToolResult::error('删除失败(可能无权限或项目不存在)。');
|
||||
}
|
||||
}
|
||||
112
app/Domain/Projects/Tools/EditProjectTool.php
Normal file
112
app/Domain/Projects/Tools/EditProjectTool.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Projects\Tools;
|
||||
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\ToolInputSchema;
|
||||
use Laravel\Mcp\Server\Tools\ToolResult;
|
||||
use Leantime\Domain\Modulemanager\Services\Modulemanager;
|
||||
use Leantime\Domain\Projects\Services\Projects;
|
||||
|
||||
/**
|
||||
* Updates an existing project with the specified details.
|
||||
*/
|
||||
class EditProjectTool extends Tool
|
||||
{
|
||||
public function __construct(
|
||||
private Projects $projectService,
|
||||
private Modulemanager $moduleManager,
|
||||
) {}
|
||||
|
||||
public function schema(ToolInputSchema $schema): ToolInputSchema
|
||||
{
|
||||
return $schema
|
||||
->integer('id')->description('ID of the project to update.')
|
||||
->required()
|
||||
->string('name')->description('Name of the project.')
|
||||
->string('details')->description('Project description.')
|
||||
->integer('clientId')->description('ID of the client for this project.')
|
||||
->string('start')->description('Start date in ISO8601 format.')
|
||||
->string('end')->description('End date in ISO8601 format.')
|
||||
->integer('hourBudget')->description('Hour budget for the project.')
|
||||
->integer('state')->description('Project state (0=open, 1=closed).')
|
||||
->integer('parent')->description('ID of the parent program or plan (only works if PgmPro plugin is active).');
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'editProject';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Updates an existing project with the specified details.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the tool request.
|
||||
*/
|
||||
public function handle(array $arguments): ToolResult
|
||||
{
|
||||
$id = (int) ($arguments['id'] ?? 0);
|
||||
|
||||
// Get current project to ensure it exists
|
||||
$currentProject = $this->projectService->getProject($id);
|
||||
if (! $currentProject) {
|
||||
return ToolResult::error('Project not found.');
|
||||
}
|
||||
|
||||
$values = [];
|
||||
|
||||
// Only include parameters that were actually provided
|
||||
$name = ($arguments['name'] ?? null);
|
||||
if ($name !== null) {
|
||||
$values['name'] = $name;
|
||||
}
|
||||
|
||||
$details = ($arguments['details'] ?? null);
|
||||
if ($details !== null) {
|
||||
$values['details'] = $details;
|
||||
}
|
||||
|
||||
$clientId = ($arguments['clientId'] ?? null);
|
||||
if ($clientId !== null) {
|
||||
$values['clientId'] = $clientId;
|
||||
}
|
||||
|
||||
$start = ($arguments['start'] ?? null);
|
||||
if ($start !== null) {
|
||||
$values['start'] = $start;
|
||||
}
|
||||
|
||||
$end = ($arguments['end'] ?? null);
|
||||
if ($end !== null) {
|
||||
$values['end'] = $end;
|
||||
}
|
||||
|
||||
$hourBudget = ($arguments['hourBudget'] ?? null);
|
||||
if ($hourBudget !== null) {
|
||||
$values['hourBudget'] = $hourBudget;
|
||||
}
|
||||
|
||||
$state = ($arguments['state'] ?? null);
|
||||
if ($state !== null) {
|
||||
$values['state'] = $state;
|
||||
}
|
||||
|
||||
// Check if PgmPro plugin is active and parent is specified
|
||||
$parent = ($arguments['parent'] ?? null);
|
||||
if ($parent !== null && $this->moduleManager->isModuleAvailable('pgmPro')) {
|
||||
$values['parent'] = $parent;
|
||||
}
|
||||
|
||||
// If no values were provided, return early
|
||||
if (empty($values)) {
|
||||
return ToolResult::text('No changes provided for the project.');
|
||||
}
|
||||
|
||||
$this->projectService->editProject($values, $id);
|
||||
|
||||
return ToolResult::text('Project updated successfully.');
|
||||
}
|
||||
}
|
||||
65
app/Domain/Projects/Tools/FindProjectTool.php
Normal file
65
app/Domain/Projects/Tools/FindProjectTool.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Projects\Tools;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
use Laravel\Mcp\Server\Tools\ToolInputSchema;
|
||||
use Laravel\Mcp\Server\Tools\ToolResult;
|
||||
use Leantime\Domain\Projects\Services\Projects;
|
||||
|
||||
/**
|
||||
* Searches for projects by name.
|
||||
*/
|
||||
#[IsReadOnly]
|
||||
class FindProjectTool extends Tool
|
||||
{
|
||||
public function __construct(
|
||||
private Projects $projectService,
|
||||
) {}
|
||||
|
||||
public function schema(ToolInputSchema $schema): ToolInputSchema
|
||||
{
|
||||
return $schema
|
||||
->string('term')->description('Search term to find in project names.')
|
||||
->required();
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'findProject';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Searches for projects by name.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the tool request.
|
||||
*/
|
||||
public function handle(array $arguments): ToolResult
|
||||
{
|
||||
$term = $arguments['term'];
|
||||
$projects = $this->projectService->findProject($term);
|
||||
|
||||
if (empty($projects)) {
|
||||
return ToolResult::text("No projects found matching: '$term'.");
|
||||
}
|
||||
|
||||
$response = "## Projects Matching: '$term'\n";
|
||||
foreach ($projects as $project) {
|
||||
$result = [
|
||||
'id' => $project['id'],
|
||||
'name' => Str::sanitizeForLLM($project['name']),
|
||||
'clientName' => Str::sanitizeForLLM($project['clientName']),
|
||||
'type' => $project['type'],
|
||||
'state' => $project['state'],
|
||||
];
|
||||
$response .= Str::toMarkdown($result)."\n";
|
||||
}
|
||||
|
||||
return ToolResult::text($response);
|
||||
}
|
||||
}
|
||||
118
app/Domain/Projects/Tools/GetAllProjectsTool.php
Normal file
118
app/Domain/Projects/Tools/GetAllProjectsTool.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Projects\Tools;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
use Laravel\Mcp\Server\Tools\ToolInputSchema;
|
||||
use Laravel\Mcp\Server\Tools\ToolResult;
|
||||
use Leantime\Domain\Comments\Services\Comments;
|
||||
use Leantime\Domain\Projects\Services\Projects;
|
||||
|
||||
/**
|
||||
* Gets all projects the current user has access to with comprehensive progress information.
|
||||
*/
|
||||
#[IsReadOnly]
|
||||
class GetAllProjectsTool extends Tool
|
||||
{
|
||||
public function __construct(
|
||||
private Projects $projectService,
|
||||
private Comments $commentsService,
|
||||
) {}
|
||||
|
||||
public function schema(ToolInputSchema $schema): ToolInputSchema
|
||||
{
|
||||
return $schema
|
||||
->boolean('showClosedProjects')->description('Whether to include closed projects in the results.')
|
||||
->boolean('includeProgressDetails')->description('Whether to include detailed progress information (RAG status, completion dates, recent comments).');
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'getAllProjects';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Gets all projects the current user has access to with comprehensive progress information.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the tool request.
|
||||
*/
|
||||
public function handle(array $arguments): ToolResult
|
||||
{
|
||||
$showClosedProjects = $arguments['showClosedProjects'] ?? false;
|
||||
$includeProgressDetails = $arguments['includeProgressDetails'] ?? true;
|
||||
|
||||
$projects = $this->projectService->getAll($showClosedProjects);
|
||||
|
||||
$response = "## All Projects Overview\n";
|
||||
|
||||
if (empty($projects)) {
|
||||
return ToolResult::text('No projects found.');
|
||||
}
|
||||
|
||||
foreach ($projects as $project) {
|
||||
$result = [
|
||||
'id' => $project['id'],
|
||||
'name' => Str::sanitizeForLLM($project['name']),
|
||||
'clientName' => Str::sanitizeForLLM($project['clientName']),
|
||||
'type' => $project['type'],
|
||||
'state' => $project['state'],
|
||||
'start' => $project['start'] ?? 'Not set',
|
||||
'end' => $project['end'] ?? 'Not set',
|
||||
'progress' => isset($project['progress']['percent']) ? round($project['progress']['percent']).'%' : 'Not calculated',
|
||||
];
|
||||
|
||||
// Add detailed progress information if requested
|
||||
if ($includeProgressDetails) {
|
||||
try {
|
||||
$progress = $this->projectService->getProjectProgress($project['id']);
|
||||
$projectComments = $this->commentsService->getComments('project', $project['id'], 1);
|
||||
|
||||
$result['progressPercent'] = isset($progress['percent']) ? round($progress['percent']).'%' : 'Not calculated';
|
||||
$result['estimatedCompletion'] = isset($progress['estimatedCompletionDate']) ? strip_tags($progress['estimatedCompletionDate']) : 'Not set';
|
||||
$result['plannedCompletion'] = $progress['plannedCompletionDate'] ?? 'Not set';
|
||||
|
||||
// Add RAG status and latest update
|
||||
if (! empty($projectComments)) {
|
||||
$latestComment = $projectComments[0];
|
||||
$result['ragStatus'] = $this->formatRagStatus($latestComment['status'] ?? '');
|
||||
$result['lastUpdate'] = [
|
||||
'date' => $latestComment['date'],
|
||||
'status' => $this->formatRagStatus($latestComment['status'] ?? ''),
|
||||
'message' => Str::sanitizeForLLM($latestComment['comment'] ?? ''),
|
||||
'author' => $latestComment['firstname'].' '.$latestComment['lastname'],
|
||||
];
|
||||
} else {
|
||||
$result['ragStatus'] = 'Not set';
|
||||
$result['lastUpdate'] = 'No updates available';
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Fallback to basic progress info if detailed fetch fails
|
||||
$result['ragStatus'] = 'Unable to fetch';
|
||||
$result['lastUpdate'] = 'Unable to fetch';
|
||||
}
|
||||
}
|
||||
|
||||
$response .= Str::toMarkdown($result)."\n\n";
|
||||
}
|
||||
|
||||
return ToolResult::text($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format RAG status with appropriate emoji.
|
||||
*/
|
||||
private function formatRagStatus(string $status): string
|
||||
{
|
||||
return match (strtolower($status)) {
|
||||
'green' => 'Green (On Track)',
|
||||
'yellow' => 'Yellow (At Risk)',
|
||||
'red' => 'Red (Critical)',
|
||||
default => $status ?: 'Not Set'
|
||||
};
|
||||
}
|
||||
}
|
||||
226
app/Domain/Projects/Tools/GetFullProjectOverviewTool.php
Normal file
226
app/Domain/Projects/Tools/GetFullProjectOverviewTool.php
Normal file
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Projects\Tools;
|
||||
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
use Laravel\Mcp\Server\Tools\ToolInputSchema;
|
||||
use Laravel\Mcp\Server\Tools\ToolResult;
|
||||
use Leantime\Domain\Comments\Services\Comments;
|
||||
use Leantime\Domain\Projects\Services\Projects;
|
||||
use Leantime\Domain\Tickets\Services\Tickets;
|
||||
use Leantime\Domain\Timesheets\Services\Timesheets;
|
||||
|
||||
/**
|
||||
* Gets comprehensive project information in a single call.
|
||||
*/
|
||||
#[IsReadOnly]
|
||||
class GetFullProjectOverviewTool extends Tool
|
||||
{
|
||||
public function __construct(
|
||||
private Projects $projectService,
|
||||
private Comments $commentsService,
|
||||
private Tickets $ticketsService,
|
||||
) {}
|
||||
|
||||
public function schema(ToolInputSchema $schema): ToolInputSchema
|
||||
{
|
||||
return $schema
|
||||
->integer('projectId')->description('Project ID to get full overview for.')
|
||||
->required()
|
||||
->boolean('includeTimesheets')->description('Whether to include timesheet data in the overview. Default false.')
|
||||
->string('dateFrom')->description('Start date for timesheet data if included. ISO8601 format (example: 2024-04-30T15:00:00-04:00).')
|
||||
->string('dateTo')->description('End date for timesheet data if included. ISO8601 format (example: 2024-04-30T15:00:00-04:00).');
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'getFullProjectOverview';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Gets comprehensive project information in a single call, combining project details, progress, comments, and optionally timesheets.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the tool request.
|
||||
*/
|
||||
public function handle(array $arguments): ToolResult
|
||||
{
|
||||
$projectId = (int) ($arguments['projectId'] ?? 0);
|
||||
$includeTimesheets = $arguments['includeTimesheets'] ?? false;
|
||||
$dateFrom = ($arguments['dateFrom'] ?? '');
|
||||
$dateTo = ($arguments['dateTo'] ?? '');
|
||||
|
||||
// This method consolidates what would normally be 3-4 separate tool calls
|
||||
$response = "# Complete Project Overview\n\n";
|
||||
|
||||
try {
|
||||
// Get project details (equivalent to getProject tool)
|
||||
$project = $this->projectService->getProject($projectId);
|
||||
if (! $project) {
|
||||
return ToolResult::error("Project with ID {$projectId} not found.");
|
||||
}
|
||||
|
||||
$response .= "## Project Details\n";
|
||||
$response .= "**Name:** {$project['name']}\n";
|
||||
$response .= '**Client:** '.($project['clientName'] ?? 'No client')."\n";
|
||||
$response .= "**Type:** {$project['type']}\n";
|
||||
$response .= '**Status:** '.($project['state'] ?? 'Not set')."\n";
|
||||
$response .= '**Start Date:** '.($project['start'] ?? 'Not set')."\n";
|
||||
$response .= '**End Date:** '.($project['end'] ?? 'Not set')."\n";
|
||||
$response .= "**Description:** {$project['details']}\n\n";
|
||||
|
||||
// Get project progress (equivalent to getProjectProgress tool)
|
||||
$progress = $this->projectService->getProjectProgress($projectId);
|
||||
|
||||
$response .= "## Progress Overview\n";
|
||||
$response .= '**Overall Progress:** '.($progress['percent'] ?? '0')."%\n";
|
||||
$response .= '**RAG Status:** '.$this->formatRagStatus($progress['ragStatus'] ?? '')."\n";
|
||||
$response .= '**Estimated Completion:** '.($progress['estimatedCompletionDate'] ?? 'Not calculated')."\n";
|
||||
$response .= '**Planned Completion:** '.($progress['plannedCompletionDate'] ?? 'Not set')."\n\n";
|
||||
|
||||
// Get recent status updates/comments (equivalent to getAllProjectComments tool)
|
||||
$comments = $this->commentsService->getComments('project', $projectId);
|
||||
|
||||
$response .= "## Recent Status Updates\n";
|
||||
if (empty($comments)) {
|
||||
$response .= "*No status updates found.*\n\n";
|
||||
} else {
|
||||
foreach ($comments as $comment) {
|
||||
$status = $this->formatRagStatus($comment['status'] ?? '');
|
||||
$response .= "**{$comment['date']}** - {$status}\n";
|
||||
$response .= "*{$comment['firstname']} {$comment['lastname']}:* {$comment['text']}\n\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Optionally include timesheet data
|
||||
if ($includeTimesheets) {
|
||||
$response .= "## Time Tracking Summary\n";
|
||||
|
||||
// Set default date range if not provided
|
||||
if (empty($dateFrom)) {
|
||||
$dateFrom = date('Y-m-01'); // First day of current month
|
||||
}
|
||||
if (empty($dateTo)) {
|
||||
$dateTo = date('Y-m-d'); // Today
|
||||
}
|
||||
|
||||
try {
|
||||
$timesheets = app(Timesheets::class)->getAll(
|
||||
dateFrom: dtHelper()->parseUserDateTime($dateFrom)->startOfDay(),
|
||||
dateTo: dtHelper()->parseUserDateTime($dateTo)->endOfDay(),
|
||||
projectId: $projectId
|
||||
);
|
||||
|
||||
if (empty($timesheets)) {
|
||||
$response .= "*No timesheet entries found for the specified period.*\n\n";
|
||||
} else {
|
||||
$totalHours = 0;
|
||||
$userHours = [];
|
||||
|
||||
foreach ($timesheets as $entry) {
|
||||
$hours = floatval($entry['hours'] ?? 0);
|
||||
$totalHours += $hours;
|
||||
$user = $entry['firstname'].' '.$entry['lastname'];
|
||||
$userHours[$user] = ($userHours[$user] ?? 0) + $hours;
|
||||
}
|
||||
|
||||
$response .= "**Total Hours Logged:** {$totalHours}h\n";
|
||||
$response .= "**Period:** {$dateFrom} to {$dateTo}\n";
|
||||
$response .= "**Team Breakdown:**\n";
|
||||
foreach ($userHours as $user => $hours) {
|
||||
$response .= "- {$user}: {$hours}h\n";
|
||||
}
|
||||
$response .= "\n";
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$response .= "*Could not retrieve timesheet data.*\n\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Add quick task summary
|
||||
$response .= "## Task Summary\n";
|
||||
try {
|
||||
$allTasks = $this->ticketsService->getAll(['currentProject' => $projectId], 100);
|
||||
$taskStats = $this->calculateTaskStats($allTasks);
|
||||
|
||||
$response .= "**Total Tasks:** {$taskStats['total']}\n";
|
||||
$response .= "**Completed:** {$taskStats['completed']} ({$taskStats['completedPercent']}%)\n";
|
||||
$response .= "**In Progress:** {$taskStats['inProgress']}\n";
|
||||
$response .= "**Not Started:** {$taskStats['notStarted']}\n";
|
||||
$response .= "**Overdue:** {$taskStats['overdue']}\n\n";
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$response .= "*Could not retrieve task statistics.*\n\n";
|
||||
}
|
||||
|
||||
return ToolResult::text($response);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return ToolResult::error('Error retrieving project overview: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate task statistics from a list of tasks.
|
||||
*
|
||||
* @param array $tasks Array of task data.
|
||||
* @return array<string, int|float> Computed statistics.
|
||||
*/
|
||||
private function calculateTaskStats(array $tasks): array
|
||||
{
|
||||
$stats = [
|
||||
'total' => count($tasks),
|
||||
'completed' => 0,
|
||||
'inProgress' => 0,
|
||||
'notStarted' => 0,
|
||||
'overdue' => 0,
|
||||
'completedPercent' => 0,
|
||||
];
|
||||
|
||||
$now = new \DateTime;
|
||||
|
||||
foreach ($tasks as $task) {
|
||||
$status = $task['status'] ?? '';
|
||||
$dueDate = $task['dateToFinish'] ?? '';
|
||||
|
||||
// Count by status type
|
||||
if (in_array($status, ['done', 'closed', 'completed'])) {
|
||||
$stats['completed']++;
|
||||
} elseif (in_array($status, ['inprogress', 'working', 'development'])) {
|
||||
$stats['inProgress']++;
|
||||
} else {
|
||||
$stats['notStarted']++;
|
||||
}
|
||||
|
||||
// Check for overdue tasks
|
||||
if (! empty($dueDate) && ! in_array($status, ['done', 'closed', 'completed'])) {
|
||||
$due = new \DateTime($dueDate);
|
||||
if ($due < $now) {
|
||||
$stats['overdue']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($stats['total'] > 0) {
|
||||
$stats['completedPercent'] = round(($stats['completed'] / $stats['total']) * 100, 1);
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format RAG status with appropriate label.
|
||||
*/
|
||||
private function formatRagStatus(string $status): string
|
||||
{
|
||||
return match (strtolower($status)) {
|
||||
'green' => 'Green (On Track)',
|
||||
'yellow' => 'Yellow (At Risk)',
|
||||
'red' => 'Red (Critical)',
|
||||
default => $status ?: 'Not Set'
|
||||
};
|
||||
}
|
||||
}
|
||||
86
app/Domain/Projects/Tools/GetProjectTool.php
Normal file
86
app/Domain/Projects/Tools/GetProjectTool.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Projects\Tools;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
use Laravel\Mcp\Server\Tools\ToolInputSchema;
|
||||
use Laravel\Mcp\Server\Tools\ToolResult;
|
||||
use Leantime\Domain\Comments\Services\Comments;
|
||||
use Leantime\Domain\Projects\Services\Projects;
|
||||
|
||||
/**
|
||||
* Gets detailed information about a specific project by its ID.
|
||||
*/
|
||||
#[IsReadOnly]
|
||||
class GetProjectTool extends Tool
|
||||
{
|
||||
public function __construct(
|
||||
private Projects $projectService,
|
||||
private Comments $commentsService,
|
||||
) {}
|
||||
|
||||
public function schema(ToolInputSchema $schema): ToolInputSchema
|
||||
{
|
||||
return $schema
|
||||
->integer('projectId')->description('ID of the project to retrieve.')
|
||||
->required();
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'getProject';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Gets detailed information about a specific project by its ID and project progress.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the tool request.
|
||||
*/
|
||||
public function handle(array $arguments): ToolResult
|
||||
{
|
||||
$projectId = (int) ($arguments['projectId'] ?? 0);
|
||||
$project = $this->projectService->getProject($projectId);
|
||||
|
||||
if (! $project) {
|
||||
return ToolResult::error('Project not found.');
|
||||
}
|
||||
|
||||
$progress = $this->projectService->getProjectProgress($projectId);
|
||||
$projectComment = $this->commentsService->getComments('project', $project['id']);
|
||||
$project['team'] = $this->projectService->getUsersAssignedToProject($project['id']);
|
||||
|
||||
if (is_array($projectComment) && count($projectComment) > 0) {
|
||||
$project['lastUpdate'] = $projectComment[0];
|
||||
$project['status'] = $projectComment[0]['status'];
|
||||
} else {
|
||||
$project['lastUpdate'] = false;
|
||||
$project['status'] = '';
|
||||
}
|
||||
|
||||
$project['progress'] = $progress;
|
||||
|
||||
$response = "## Project Details\n";
|
||||
$result = [
|
||||
'id' => $project['id'],
|
||||
'name' => Str::sanitizeForLLM($project['name']),
|
||||
'details' => Str::sanitizeForLLM($project['details']),
|
||||
'clientName' => Str::sanitizeForLLM($project['clientName'] ?? ''),
|
||||
'type' => $project['type'],
|
||||
'state' => $project['state'],
|
||||
'ragStatus' => $project['status'],
|
||||
'lastUpdateMessage' => $project['lastUpdate'],
|
||||
'start' => $project['start'] ?? 'Not set',
|
||||
'end' => $project['end'] ?? 'Not set',
|
||||
'progress' => isset($progress['percent']) ? round($progress['percent']).'%' : 'Not calculated',
|
||||
'estimatedCompletionDate' => isset($progress['estimatedCompletionDate']) ? strip_tags($progress['estimatedCompletionDate']) : 'Unknown',
|
||||
];
|
||||
$response .= Str::toMarkdown($result)."\n";
|
||||
|
||||
return ToolResult::text($response);
|
||||
}
|
||||
}
|
||||
78
app/Domain/Projects/Tools/GetProjectsAssignedToUserTool.php
Normal file
78
app/Domain/Projects/Tools/GetProjectsAssignedToUserTool.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Projects\Tools;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
use Laravel\Mcp\Server\Tools\ToolInputSchema;
|
||||
use Laravel\Mcp\Server\Tools\ToolResult;
|
||||
use Leantime\Domain\Projects\Services\Projects;
|
||||
|
||||
/**
|
||||
* Gets all projects assigned to a specific user.
|
||||
*/
|
||||
#[IsReadOnly]
|
||||
class GetProjectsAssignedToUserTool extends Tool
|
||||
{
|
||||
public function __construct(
|
||||
private Projects $projectService,
|
||||
) {}
|
||||
|
||||
public function schema(ToolInputSchema $schema): ToolInputSchema
|
||||
{
|
||||
return $schema
|
||||
->integer('userId')->description('User ID to get projects for.')
|
||||
->required()
|
||||
->string('projectStatus')->description('Filter by project status (open, closed, all).')
|
||||
->integer('clientId')->description('Filter by client ID.')
|
||||
->string('projectTypes')->description('Filter by project types (comma-separated: project, program, etc.). Use "all" for all project types.');
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'getProjectsAssignedToUser';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Gets all projects assigned to a specific user.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the tool request.
|
||||
*/
|
||||
public function handle(array $arguments): ToolResult
|
||||
{
|
||||
$userId = (int) ($arguments['userId'] ?? 0);
|
||||
$projectStatus = ($arguments['projectStatus'] ?? 'open');
|
||||
$clientId = ($arguments['clientId'] ?? null);
|
||||
$projectTypes = ($arguments['projectTypes'] ?? 'all');
|
||||
|
||||
if ($userId === 0) {
|
||||
$userId = session('userdata.id') ?? '';
|
||||
}
|
||||
|
||||
$projects = $this->projectService->getProjectsAssignedToUser($userId, $projectStatus, $clientId, $projectTypes);
|
||||
|
||||
$response = "## Projects Assigned to User\n";
|
||||
foreach ($projects as $project) {
|
||||
$result = [
|
||||
'id' => $project['id'],
|
||||
'name' => Str::sanitizeForLLM($project['name']),
|
||||
'clientName' => Str::sanitizeForLLM($project['clientName']),
|
||||
'type' => $project['type'],
|
||||
'state' => $project['state'],
|
||||
'start' => $project['start'] ?? 'Not set',
|
||||
'end' => $project['end'] ?? 'Not set',
|
||||
];
|
||||
$response .= Str::toMarkdown($result)."\n";
|
||||
}
|
||||
|
||||
if (empty($projects)) {
|
||||
return ToolResult::text('No projects found.');
|
||||
}
|
||||
|
||||
return ToolResult::text($response);
|
||||
}
|
||||
}
|
||||
68
app/Domain/Projects/Tools/GetUsersAssignedToProjectTool.php
Normal file
68
app/Domain/Projects/Tools/GetUsersAssignedToProjectTool.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Projects\Tools;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
use Laravel\Mcp\Server\Tools\ToolInputSchema;
|
||||
use Laravel\Mcp\Server\Tools\ToolResult;
|
||||
use Leantime\Domain\Projects\Services\Projects;
|
||||
|
||||
/**
|
||||
* Gets all users assigned to a specific project.
|
||||
*/
|
||||
#[IsReadOnly]
|
||||
class GetUsersAssignedToProjectTool extends Tool
|
||||
{
|
||||
public function __construct(
|
||||
private Projects $projectService,
|
||||
) {}
|
||||
|
||||
public function schema(ToolInputSchema $schema): ToolInputSchema
|
||||
{
|
||||
return $schema
|
||||
->integer('projectId')->description('ID of the project to get users for.')
|
||||
->required()
|
||||
->boolean('teamOnly')->description('Whether to only include direct team members.');
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'getUsersAssignedToProject';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Gets all users assigned to a specific project.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the tool request.
|
||||
*/
|
||||
public function handle(array $arguments): ToolResult
|
||||
{
|
||||
$projectId = (int) ($arguments['projectId'] ?? 0);
|
||||
$teamOnly = $arguments['teamOnly'] ?? false;
|
||||
|
||||
$users = $this->projectService->getUsersAssignedToProject($projectId, $teamOnly);
|
||||
|
||||
if (empty($users)) {
|
||||
return ToolResult::text('No users assigned to this project.');
|
||||
}
|
||||
|
||||
$response = "## Users Assigned to Project\n";
|
||||
foreach ($users as $user) {
|
||||
$result = [
|
||||
'id' => $user['id'],
|
||||
'name' => Str::sanitizeForLLM($user['firstname'].' '.$user['lastname']),
|
||||
'email' => $user['username'],
|
||||
'role' => $user['role'] ?? 'Not specified',
|
||||
'projectRole' => $user['projectRole'] ?? 'Not specified',
|
||||
];
|
||||
$response .= Str::toMarkdown($result)."\n";
|
||||
}
|
||||
|
||||
return ToolResult::text($response);
|
||||
}
|
||||
}
|
||||
75
app/Domain/Projects/Tools/PatchProjectTool.php
Normal file
75
app/Domain/Projects/Tools/PatchProjectTool.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Projects\Tools;
|
||||
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\ToolInputSchema;
|
||||
use Laravel\Mcp\Server\Tools\ToolResult;
|
||||
use Leantime\Domain\Modulemanager\Services\Modulemanager;
|
||||
use Leantime\Domain\Projects\Services\Projects;
|
||||
|
||||
/**
|
||||
* Updates specific fields of an existing project.
|
||||
*/
|
||||
class PatchProjectTool extends Tool
|
||||
{
|
||||
public function __construct(
|
||||
private Projects $projectService,
|
||||
private Modulemanager $moduleManager,
|
||||
) {}
|
||||
|
||||
public function schema(ToolInputSchema $schema): ToolInputSchema
|
||||
{
|
||||
return $schema
|
||||
->integer('id')->description('ID of the project to update.')
|
||||
->required()
|
||||
->raw('params', ['type' => 'object', 'description' => 'Key-value pairs of fields to update. Example: {"name": "New name", "state": 0}'])->required();
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'patchProject';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Updates specific fields of an existing project.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the tool request.
|
||||
*/
|
||||
public function handle(array $arguments): ToolResult
|
||||
{
|
||||
$id = (int) ($arguments['id'] ?? 0);
|
||||
$params = ($arguments['params'] ?? null);
|
||||
|
||||
// Get current project to ensure it exists
|
||||
$currentProject = $this->projectService->getProject($id);
|
||||
if (! $currentProject) {
|
||||
return ToolResult::error('Project not found.');
|
||||
}
|
||||
|
||||
// Check if $params is array of arrays (AI sometimes does this)
|
||||
if (is_array($params) && ! empty($params) && isset($params[0]) && is_array($params[0])) {
|
||||
$params = $params[0];
|
||||
}
|
||||
|
||||
if (! is_array($params)) {
|
||||
return ToolResult::error('The params parameter is not a valid object. Please provide an object of key-value pairs.');
|
||||
}
|
||||
|
||||
// Handle parent field if PgmPro plugin is active
|
||||
if (isset($params['parent']) && ! $this->moduleManager->isModuleAvailable('pgmPro')) {
|
||||
unset($params['parent']);
|
||||
}
|
||||
|
||||
$result = $this->projectService->patch($id, $params);
|
||||
|
||||
if ($result) {
|
||||
return ToolResult::text('Project updated successfully.');
|
||||
}
|
||||
|
||||
return ToolResult::error('Failed to update project. Please check the provided information.');
|
||||
}
|
||||
}
|
||||
29
app/Domain/Projects/routes.php
Normal file
29
app/Domain/Projects/routes.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Leantime\Domain\Projects\Controllers\ProjectImage;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Projects Domain Routes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Project avatars were relocated here from the retired Api\Controllers\Projects.
|
||||
| The canonical route is /projects/projectImage/{id}. The /api/projects alias is
|
||||
| kept because core templates and the plugin submodule (StrategyPro, PgmPro) hardcode
|
||||
| /api/projects?projectAvatar= and cannot be rewritten from this repo. Both paths are
|
||||
| served directly (no redirect) to avoid an avatar redirect-storm on project lists.
|
||||
|
|
||||
| The JSON sort/status/patch operations the old /api/projects controller also handled
|
||||
| now go through JSON-RPC (Projects.Projects.patchProjectStatusAndSorting / sortProjects
|
||||
| / patchProject), so this alias only covers the avatar GET and the avatar upload POST.
|
||||
|
|
||||
*/
|
||||
|
||||
// Canonical
|
||||
Route::get('/projects/projectImage/{id?}', [ProjectImage::class, 'show'])->name('projects.projectImage');
|
||||
Route::post('/projects/projectImage', [ProjectImage::class, 'upload']);
|
||||
|
||||
// Backward-compat alias for the retired /api/projects avatar endpoint
|
||||
Route::get('/api/projects', [ProjectImage::class, 'show'])->name('projects.projectImage.legacy');
|
||||
Route::post('/api/projects', [ProjectImage::class, 'upload']);
|
||||
Reference in New Issue
Block a user