OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)

This commit is contained in:
wangruiguo
2026-09-03 18:49:20 +08:00
commit d647428529
3501 changed files with 1988906 additions and 0 deletions

View File

@@ -0,0 +1,68 @@
<?php
namespace Leantime\Domain\Dashboard\Controllers;
use Illuminate\Contracts\Container\BindingResolutionException;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Domain\Setting\Services\Setting;
use Leantime\Domain\Widgets\Services\Widgets;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
class Home extends Controller
{
private Setting $settingsSvc;
private Widgets $widgetService;
public function init(
Setting $settingsSvc,
Widgets $widgetService
): void {
$this->settingsSvc = $settingsSvc;
$this->widgetService = $widgetService;
session(['lastPage' => BASE_URL.'/dashboard/home']);
}
/**
* @throws BindingResolutionException
*/
public function get(array $params): Response
{
// Debug param to reset dashboard
if (isset($params['resetDashboard']) === true) {
$this->widgetService->resetDashboard(session('userdata.id'));
}
$dashboardGrid = $this->widgetService->getActiveWidgets(session('userdata.id'));
$this->tpl->assign('dashboardGrid', $dashboardGrid);
$completedOnboarding = $this->settingsSvc->onboardingHandler();
if ($completedOnboarding instanceof RedirectResponse) {
return $completedOnboarding;
}
$this->tpl->assign('completedOnboarding', $completedOnboarding);
return $this->tpl->display('dashboard.home');
}
/**
* @throws BindingResolutionException
*/
public function post($params): Response
{
// Handle saving dashboard grid layout
if (isset($params['action']) && $params['action'] === 'saveGrid' &&
isset($params['data']) && $params['data'] !== '') {
$this->widgetService->saveGrid($params['data'], session('userdata.id'));
return new Response;
}
return Frontcontroller::redirect(BASE_URL.'/dashboard/home');
}
}

View File

@@ -0,0 +1,159 @@
<?php
namespace Leantime\Domain\Dashboard\Controllers;
use Illuminate\Contracts\Container\BindingResolutionException;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Core\Controller\Frontcontroller as FrontcontrollerCore;
use Leantime\Domain\Auth\Models\Roles;
use Leantime\Domain\Auth\Services\Auth as AuthService;
use Leantime\Domain\Dashboard\Services\Dashboard as DashboardService;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
use Leantime\Domain\Setting\Services\Setting;
use Leantime\Domain\Tickets\Services\Tickets as TicketService;
use Leantime\Domain\Timesheets\Services\Timesheets as TimesheetService;
use Leantime\Domain\Users\Services\Users as UserService;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
class Show extends Controller
{
private ProjectService $projectService;
private TicketService $ticketService;
private UserService $userService;
private TimesheetService $timesheetService;
private DashboardService $dashboardService;
private Setting $settingsSvc;
/**
* @throws BindingResolutionException
*/
public function init(
ProjectService $projectService,
TicketService $ticketService,
UserService $userService,
TimesheetService $timesheetService,
DashboardService $dashboardService,
Setting $settingsSvc
): void {
$this->projectService = $projectService;
$this->ticketService = $ticketService;
$this->userService = $userService;
$this->timesheetService = $timesheetService;
$this->dashboardService = $dashboardService;
$this->settingsSvc = $settingsSvc;
session(['lastPage' => BASE_URL.'/dashboard/show']);
}
/**
* @throws BindingResolutionException
*/
public function get(): Response
{
$currentProjectId = $this->projectService->getCurrentProjectId();
if ($currentProjectId === 0) {
return FrontcontrollerCore::redirect(BASE_URL.'/dashboard/home');
}
$project = $this->projectService->getProject($currentProjectId);
if (isset($project['id']) === false) {
return FrontcontrollerCore::redirect(BASE_URL.'/dashboard/home');
}
$projectRedirectFilter = self::dispatch_filter('dashboardRedirect', '/dashboard/show', ['type' => $project['type']]);
if ($projectRedirectFilter != '/dashboard/show') {
return FrontcontrollerCore::redirect(BASE_URL.$projectRedirectFilter);
}
[$progressSteps, $percentDone] = $this->projectService->getProjectSetupChecklist($currentProjectId);
$this->tpl->assign('progressSteps', $progressSteps);
$this->tpl->assign('percentDone', $percentDone);
$project['assignedUsers'] = $this->projectService->getUsersAssignedToProject($currentProjectId);
$this->tpl->assign('project', $project);
$this->tpl->assign('isFavorite', $this->dashboardService->userHasFavoritedProject(session('userdata.id'), $currentProjectId));
$this->tpl->assign('allUsers', $this->userService->getAll());
// Project Progress
$progress = $this->projectService->getProjectProgress($currentProjectId);
$this->tpl->assign('projectProgress', $progress);
$this->tpl->assign('currentProjectName', $this->projectService->getProjectName($currentProjectId));
// Milestones
$allProjectMilestones = $this->ticketService->getAllMilestones(['sprint' => '', 'type' => 'milestone', 'currentProject' => session('currentProject')]);
$this->tpl->assign('milestones', $allProjectMilestones);
// Delete comment (only confirm success when the auth-checked delete actually ran)
if (isset($_GET['delComment']) === true) {
if ($this->dashboardService->deleteProjectComment((int) $_GET['delComment'])) {
$this->tpl->setNotification($this->language->__('notifications.comment_deleted'), 'success', 'projectcomment_deleted');
}
}
$this->tpl->assign('delUrlBase', $this->dashboardService->buildDeleteCommentUrlBase());
$this->tpl->assign('comments', $this->dashboardService->getProjectCommentsWithReplies($currentProjectId));
$this->tpl->assign('numComments', $this->dashboardService->countProjectComments($currentProjectId));
$completedOnboarding = $this->settingsSvc->onboardingHandler();
if ($completedOnboarding instanceof RedirectResponse) {
return $completedOnboarding;
}
$this->tpl->assign('completedOnboarding', $completedOnboarding);
// TICKETS
$this->tpl->assign('tickets', $this->ticketService->getLastTickets($currentProjectId));
$this->tpl->assign('onTheClock', $this->timesheetService->isClocked(session('userdata.id')));
$this->tpl->assign('efforts', $this->ticketService->getEffortLabels());
$this->tpl->assign('priorities', $this->ticketService->getPriorityLabels());
$this->tpl->assign('types', $this->ticketService->getTicketTypes());
$this->tpl->assign('statusLabels', $this->ticketService->getStatusLabels());
return $this->tpl->display('dashboard.show');
}
/**
* @throws BindingResolutionException
*/
public function post($params): Response
{
if (AuthService::userHasRole([Roles::$owner, Roles::$manager, Roles::$editor, Roles::$commenter])) {
if (isset($params['quickadd'])) {
$result = $this->ticketService->quickAddTicket($params);
if (isset($result['status'])) {
$this->tpl->setNotification($result['message'], $result['status']);
} else {
$this->tpl->setNotification($this->language->__('notifications.ticket_saved'), 'success', 'quickticket_created');
}
return Frontcontroller::redirect(BASE_URL.'/dashboard/show');
}
}
// Manage Post comment
if (isset($_POST['comment']) === true) {
$currentProjectId = $this->projectService->getCurrentProjectId();
$project = $this->projectService->getProject($currentProjectId);
if ($project && $this->dashboardService->addProjectComment($_POST, $currentProjectId, $project)) {
$this->tpl->setNotification($this->language->__('notifications.comment_create_success'), 'success', 'dashboardcomment_created');
} else {
$this->tpl->setNotification($this->language->__('notifications.comment_create_error'), 'error');
}
}
return Frontcontroller::redirect(BASE_URL.'/dashboard/show');
}
}

View File

@@ -0,0 +1,367 @@
leantime.dashboardController = (function () {
// Variables (underscore for private variables)
var chartColors = {};
if (leantime.theme == "dark") {
chartColors = {
red: 'rgb(201,48,44)',
orange: 'rgb(255, 159, 64)',
yellow: 'rgb(255, 205, 86)',
green: 'rgb(90,182,90)',
blue: 'rgb(54, 162, 235)',
purple: 'rgb(153, 102, 255)',
grey: 'rgb(56, 56, 56)'
};
} else {
chartColors = {
red: 'rgb(201,48,44)',
orange: 'rgb(255, 159, 64)',
yellow: 'rgb(255, 205, 86)',
green: 'rgb(90,182,90)',
blue: 'rgb(54, 162, 235)',
purple: 'rgb(153, 102, 255)',
grey: 'rgb(201, 203, 207)'
};
}
var _burndownConfig = '';
var _progressChart = '';
//Functions
var prepareHiddenDueDate = function () {
var thisFriday = moment().startOf('week').add(5, 'days');
jQuery("#dateToFinish").val(thisFriday.format("YYYY-MM-DD"));
};
var initProgressChart = function (chartId, complete, incomplete ) {
var config = {
type: 'doughnut',
data: {
datasets: [{
data: [
complete,
incomplete
],
backgroundColor: [
leantime.dashboardController.chartColors.green,
leantime.dashboardController.chartColors.grey
],
label: leantime.i18n.__("label.project_done")
}],
labels: [
complete + '% Done',
incomplete + '% Open'
]
},
options: {
maintainAspectRatio : false,
responsive: true,
plugins: {
legend: {
position: 'bottom',
},
title: {
display: false,
text: ''
}
},
animation: {
animateScale: true,
animateRotate: true
}
}
};
var ctx = document.getElementById(chartId).getContext('2d');
_progressChart = new Chart(ctx, config);
};
var initBurndown = function (labels, plannedData, actualData) {
moment.locale(leantime.i18n.__("language.code"));
var MONTHS = labels;
var config = {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: leantime.i18n.__("label.ideal"),
backgroundColor: leantime.dashboardController.chartColors.blue,
borderColor: leantime.dashboardController.chartColors.blue,
data: plannedData,
fill: false,
lineTension: 0,
},
{
label: 'Actual',
backgroundColor: leantime.dashboardController.chartColors.red,
borderColor: leantime.dashboardController.chartColors.red,
data: actualData,
fill: false,
lineTension: 0,
}
]
},
options: {
responsive: true,
maintainAspectRatio : false,
hover: {
mode: 'nearest',
intersect: true
},
plugins: {
legend: {
position: 'bottom',
},
title: {
display: false,
text: 'Line Chart'
},
tooltips: {
mode: 'index',
intersect: false,
}
},
scales: {
x: {
display: true,
title: {
display: true,
text: leantime.i18n.__("label.date"),
},
type: 'time',
time: {
unit: 'day'
}
},
y: {
display: true,
title: {
display: true,
text: leantime.i18n.__("label.num_tickets")
},
ticks: {
beginAtZero:true
}
}
}
}
};
var ctx2 = document.getElementById('sprintBurndown').getContext('2d');
_burndownChart = new Chart(ctx2, config);
return _burndownChart;
};
var initChartButtonClick = function (id, label, plannedData, actualData, chart) {
jQuery("#" + id).click(
function (event) {
chart.data.datasets[0].data = plannedData;
chart.data.datasets[1].data = actualData;
chart.options.scales.y.title.text = label;
//chart.options.scales.yAxes[0].scaleLabel.labelString = label;
jQuery(".chartButtons").removeClass('active');
jQuery(this).addClass('active');
chart.update();
}
);
};
var initBacklogBurndown = function (labels, actualData) {
moment.locale(leantime.i18n.__("language.code"));
var MONTHS = labels;
var config = {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: leantime.i18n.__("label.done_todos"),
backgroundColor: leantime.dashboardController.chartColors.green,
borderColor: leantime.dashboardController.chartColors.green,
data: actualData.done.data,
fill: true,
lineTension: 0,
pointRadius:0,
},
{
label: leantime.i18n.__("label.progress_todos"),
backgroundColor: leantime.dashboardController.chartColors.yellow,
borderColor: leantime.dashboardController.chartColors.yellow,
data: actualData.progress.data,
fill: true,
lineTension: 0,
pointRadius:0,
},
{
label: leantime.i18n.__("label.new_todos"),
backgroundColor: leantime.dashboardController.chartColors.red,
borderColor: leantime.dashboardController.chartColors.red,
data: actualData.open.data,
fill: true,
lineTension: 0,
pointRadius:0,
},
]
},
options: {
responsive: true,
maintainAspectRatio : false,
hover: {
mode: 'nearest',
intersect: true
},
elements: {
point: {
pointStyle: "line",
radius:"0"
}
},
plugins: {
tooltips: {
mode: 'index',
intersect: false,
},
legend: {
position: 'bottom',
},
title: {
display: false,
text: 'Line Chart'
},
},
scales: {
x: {
display: true,
title: {
display: true,
text: leantime.i18n.__("label.date"),
},
type: 'time',
time: {
unit: 'day'
},
},
y: {
display: true,
title: {
display: true,
text: leantime.i18n.__("label.num_tickets")
},
ticks: {
beginAtZero:true
},
stacked: true
}
}
}
};
var ctx2 = document.getElementById('backlogBurndown').getContext('2d');
_burndownChart = new Chart(ctx2, config);
return _burndownChart;
};
var initBacklogChartButtonClick = function (id, actualData, label, chart) {
jQuery("#" + id).click(
function (event) {
chart.data.datasets[0].data = actualData.done.data;
chart.data.datasets[1].data = actualData.progress.data;
chart.data.datasets[2].data = actualData.open.data;
chart.options.scales.y.title.text = label;
jQuery(".backlogChartButtons").removeClass('active');
jQuery(this).addClass('active');
chart.update();
}
);
};
var initDueDateTimePickers = function () {
jQuery(document).ready(function () {
jQuery(".duedates").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"),
onClose: function (date) {
var newDate = "";
if (date == "") {
jQuery(this).val(leantime.i18n.__("text.anytime"));
}
var dateTime = moment(date, leantime.i18n.__("language.momentJSDate")).format("YYYY-MM-DD HH:mm:ss");
var id = jQuery(this).attr("data-id");
newDate = dateTime;
leantime.ticketsRepository.updateDueDates(id, newDate, function () {
jQuery.growl({message: leantime.i18n.__("short_notifications.duedate_updated")});
});
}
}
);
});
};
// Make public what you want to have public, everything else is private
return {
chartColors: chartColors,
initBurndown: initBurndown,
initChartButtonClick: initChartButtonClick,
initBacklogBurndown:initBacklogBurndown,
initBacklogChartButtonClick:initBacklogChartButtonClick,
initProgressChart:initProgressChart,
prepareHiddenDueDate:prepareHiddenDueDate,
initDueDateTimePickers:initDueDateTimePickers
};
})();

View File

@@ -0,0 +1,18 @@
<?php
namespace Leantime\Domain\Dashboard\Repositories;
use Leantime\Core\Db\Db as DbCore;
class Dashboard
{
public ?DbCore $db;
/**
* __construct - neu db connection
*/
public function __construct(DbCore $db)
{
$this->db = $db;
}
}

View File

@@ -0,0 +1,140 @@
<?php
namespace Leantime\Domain\Dashboard\Services;
use Leantime\Domain\Comments\Repositories\Comments as CommentRepository;
use Leantime\Domain\Comments\Services\Comments as CommentService;
use Leantime\Domain\Reactions\Models\Reactions;
use Leantime\Domain\Reactions\Services\Reactions as ReactionService;
/**
* Dashboard service - business logic backing the project dashboard view.
*
* Wraps the comment and reaction data assembly the dashboard needs so the
* controllers stay thin (param-read -> service call -> assign -> Response).
*
* @api
*/
class Dashboard
{
/**
* @param CommentService $commentService Comment business logic (authorization-aware deletes)
* @param CommentRepository $commentRepository Comment data access (replies, counts)
* @param ReactionService $reactionsService Reaction business logic
*/
public function __construct(
private CommentService $commentService,
private CommentRepository $commentRepository,
private ReactionService $reactionsService,
) {}
/**
* Gets the top-level comments for a project with their replies attached.
*
* Mirrors the legacy dashboard behavior: top-level comments (parent 0)
* each enriched with a 'replies' key holding their direct replies.
*
* @param int $projectId The project to load comments for
* @return array<int, array<string, mixed>> Top-level comments, each with a 'replies' array
*
* @api
*/
public function getProjectCommentsWithReplies(int $projectId): array
{
if ($projectId <= 0) {
throw new \InvalidArgumentException('A valid project id is required to load comments.');
}
$comments = $this->commentService->getComments('project', $projectId, 0);
if (! is_array($comments)) {
return [];
}
return array_map(function ($comment) {
$comment['replies'] = $this->commentRepository->getReplies($comment['id']);
return $comment;
}, $comments);
}
/**
* Counts all comments attached to a project.
*
* @param int $projectId The project to count comments for
* @return int The number of comments
*
* @api
*/
public function countProjectComments(int $projectId): int
{
if ($projectId <= 0) {
throw new \InvalidArgumentException('A valid project id is required to count comments.');
}
return (int) $this->commentRepository->countComments('project', $projectId);
}
/**
* Deletes a project comment.
*
* Delegates to the comment service so its author/manager authorization
* check is enforced (the legacy dashboard deleted directly with no check).
*
* @param int $commentId The comment to delete
* @return bool True if the comment was deleted
*
* @api
*/
public function deleteProjectComment(int $commentId): bool
{
return $this->commentService->deleteComment($commentId);
}
/**
* Adds a comment to a project.
*
* @param array<string, mixed> $values The submitted comment values
* @param int $projectId The project the comment belongs to
* @param array<string, mixed> $project The loaded project entity
* @return bool True if the comment was created
*
* @api
*/
public function addProjectComment(array $values, int $projectId, array $project): bool
{
return $this->commentService->addComment($values, 'project', $projectId, $project);
}
/**
* Determines whether a user has favorited a project.
*
* @param int $userId The user to check
* @param int $projectId The project to check
* @return bool True if the user has a favorite reaction on the project
*
* @api
*/
public function userHasFavoritedProject(int $userId, int $projectId): bool
{
$userReaction = $this->reactionsService->getUserReactions($userId, 'project', $projectId, Reactions::$favorite);
return $userReaction && is_array($userReaction);
}
/**
* Builds the base URL used to delete a dashboard comment.
*
* Derives scheme/host/path from the current request URL and appends the
* delComment query parameter, leaving the caller to suffix the comment id.
*
* @return string The delete-comment URL base (ends with 'delComment=')
*
* @api
*/
public function buildDeleteCommentUrlBase(): string
{
// Current URL up to (but excluding) any existing query string, plus the delComment flag.
return strtok(CURRENT_URL, '?').'?delComment=';
}
}

View File

@@ -0,0 +1,54 @@
@extends($layout)
@section('content')
<div class="maincontent" id="gridBoard" style="margin-top:0px; opacity:0;">
{!! $tpl->displayNotification() !!}
<div class="grid-stack">
@foreach($dashboardGrid as $widget)
<x-widgets::moveableWidget
gs-x="{{ $widget->gridX }}"
gs-y="{{ $widget->gridY }}"
gs-h="{{ $widget->gridHeight }}"
gs-w="{{ $widget->gridWidth }}"
gs-min-w="{{ $widget->gridMinWidth }}"
gs-min-h="{{ $widget->gridMinHeight }}"
isNew="{{ isset($widget->isNew) ? 'true' : 'false' }}"
background="{{ $widget->widgetBackground }}"
noTitle="{{ $widget->noTitle }}"
name="{{ $widget->name }}"
:fixed="(empty($widget->fixed) ? false : true )"
alwaysVisible="{{ $widget->alwaysVisible }}"
id="widget_wrapper_{{ $widget->id }}"
>
<div hx-get="{{$widget->widgetUrl }}"
hx-trigger="revealed"
id="{{ $widget->id }}"
class="tw-h-full"
hx-swap="innerHTML">
<x-global::loadingText type="{{ $widget->widgetLoadingIndicator }}" count="1" includeHeadline="true" />
</div>
</x-widgets::moveableWidget>
@endforeach
</div>
</div>
<script>
@dispatchEvent('scripts.afterOpen')
jQuery(document).ready(function() {
leantime.widgetController.initGrid();
@php(session(["usersettings.modals.homeDashboardTour" => 1]))
});
</script>
@endsection

View File

@@ -0,0 +1,11 @@
@if ($login::userIsAtLeast($roles::$editor))
<div class="btn-group pull-left" style="margin-right:5px;">
<button class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown"><?=$tpl->__("links.new_with_icon") ?> <span class="caret"></span></button>
<ul class="dropdown-menu">
<li><a href="#/tickets/newTicket">Add Todo</a></li>
<li><a href="#/tickets/editMilestone">Add Milestone</a></li>
</ul>
</div>
@endif

View File

@@ -0,0 +1,594 @@
@extends($layout)
@section('content')
<x-global::pageheader :icon="'fa fa-gauge-high'">
@if (count($allUsers) == 1)
<a href="#/users/newUser" class="headerCTA">
<i class="fa fa-users"></i>
<span class="tw-text-[14px] tw-leading-[25px]">
{{ __('links.dont_do_it_alone') }}
</span>
</a>
@endif
<h5>{{ session("currentProjectClient") }}</h5>
<h1>{!! __('headlines.project_dashboard') !!}</h1>
</x-global::pageheader>
<div class="maincontent">
{!! $tpl->displayNotification() !!}
<div class="row">
<div class="col-md-8">
<div class="maincontentinner tw-z-20">
@if ($login::userIsAtLeast($roles::$admin))
<div class="pull-right dropdownWrapper">
<a
class="dropdown-toggle btn round-button"
data-toggle="dropdown"
data-tippy-content="{{ __('label.edit_project') }}"
href="{{ BASE_URL }}/projects/showProject/{{ $project['id'] }}"
><i class="fa fa-ellipsis"></i></a>
<ul class="dropdown-menu">
<li class="dropdown-item">
<a
href="{{ BASE_URL }}/projects/showProject/{{ $project['id'] }}"
><i class="fa fa-edit"></i> Edit Project</a>
</li>
<li class="dropdown-item">
<a
href="{{ BASE_URL }}/projects/delProject/{{ $project['id'] }}"
class="delete"
><i class="fa fa-trash"></i> Delete Project</a>
</li>
</ul>
</div>
@endif
<div class="pull-right dropdownWrapper tw-mr-[5px]">
<a
class="dropdown-toggle btn round-button"
data-toggle="dropdown"
data-tippy-content="{{ __('label.copy_url_tooltip') }}"
href="{{ BASE_URL }}/projects/changeCurrentProject/{{ $project['id'] }}"
><i class="fa fa-link"></i></a>
<div class="dropdown-menu padding-md">
<x-global::forms.text-input id="projectUrl" value="{{ BASE_URL }}/projects/changeCurrentProject/{{ $project['id'] }}" />
<x-global::forms.button contentRole="primary" onclick="leantime.snippets.copyUrl('projectUrl')">{{ __('links.copy_url') }}</x-global::forms.button>
</div>
</div>
<a
href="javascript:void(0);"
id="favoriteProject"
class="btn pull-right margin-right {{ $isFavorite ? 'isFavorite' : ''}} tw-mr-[5px] round-button"
data-tippy-content="{{ __('label.favorite_tooltip') }}"
><i class="{{ $isFavorite ? 'fa-solid' : 'fa-regular' }} fa-star"></i></a>
<h3>{{ session("currentProjectClient") }}</h3>
<h1 class="articleHeadline">{{ $currentProjectName }}</h1>
<br/>
@include('projects::partials.checklist', [
'progressSteps' => $progressSteps,
'percentDone' => $percentDone
])
<br/><br/>
<strong>{{ __('label.background') }}</strong><br/>
<div class="readMoreBox">
<div class="tiptap-content kanbanContent closed tw-max-h-[200px] readMoreContent tw-pb-[30px]" id="projectDescription">
{!! $tpl->escapeMinimal($project['details']) !!}
</div>
<div class="center readMoreToggle" style="display:none;">
<a href="javascript:void(0)" id="descriptionReadMoreToggle">{{ __('label.read_more') }}</a>
</div>
</div>
<br/>
</div>
<div class="maincontentinner tw-z-10 latest-todos">
<x-global::forms.button tag="a" link="#/tickets/newTicket" contentRole="link" class="action-link pull-right" style="margin-top:-7px;"><i class="fa fa-plus"></i> Create To-Do</x-global::forms.button>
<h5 class="subtitle">{{ __('headlines.latest_todos') }}</h5>
<br/>
<ul class="sortableTicketList">
@if (count($tickets) == 0)
<em>Nothing to see here. Move on.</em><br/><br/>
@endif
@foreach($tickets as $row)
<li class="ui-state-default" id="ticket_{!! $row['id'] !!}">
<div class="ticketBox fixed priority-border-{!! $row['priority'] !!}" data-val="{!! $row['id'] !!}">
<div class="row">
<div class="col-md-12 timerContainer tw-py-[5px] tw-px-[15px]" id="timerContainer-{!! $row['id'] !!}">
@if($row['dependingTicketId'] > 0)
<a href="#/tickets/showTicket/{{ $row['dependingTicketId'] }}">
{{ $row['parentHeadline'] }}
</a>
//
@endif
<a href="#/tickets/showTicket/{{ $row['id'] }}">
<strong>{{ $row['headline'] }}</strong>
</a>
@include("tickets::partials.ticketsubmenu", ["ticket" => $row,"onTheClock" => $onTheClock])
</div>
</div>
<div class="row">
<div class="col-md-4 tw-px-[15px] tw-py-0">
<i class="fa-solid fa-business-time infoIcon" data-tippy-content=" {{ __("label.due") }}"></i>
<input
type="text"
title="{{ __('label.due') }}"
value="{{ format($row['dateToFinish'])->date(__('text.anytime')) }}"
class="duedates secretInput"
data-id="{{ $row['id'] }}"
name="date"
/>
</div>
<div class="col-md-8 tw-mt-[3px]">
<div class="right">
<div class="dropdown ticketDropdown effortDropdown show">
<a
class="dropdown-toggle f-left label-default effort"
href="javascript:void(0);"
role="button"
id="effortDropdownMenuLink{{ $row['id'] }}"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
><span class="text">
{{ $row['storypoints'] != '' && $row['storypoints'] > 0
? ($efforts[''.$row['storypoints'].''] ?? $row['storypoints'])
: __('label.story_points_unkown')
}}
</span>&nbsp;<i class="fa fa-caret-down" aria-hidden="true"></i></a>
<ul class="dropdown-menu" aria-labelledby="effortDropdownMenuLink{{ $row['id'] }}">
<li class="nav-header border">{{ __('dropdown.how_big_todo') }}</li>
@foreach ($efforts as $effortKey => $effortValue)
<li class="dropdown-item">
<a
href="javascript:void(0)"
data-value="{{ $row['id'] }}_{{ $effortKey}}"
id="ticketEffortChange_{{ $row['id'] . $effortKey }}"
>{{ $effortValue }}</a>
</li>
@endforeach
</ul>
</div>
<div class="dropdown ticketDropdown milestoneDropdown colorized show">
<a
style="background-color:{{ __($row['milestoneColor']) }}"
class="dropdown-toggle f-left label-default milestone"
href="javascript:void(0);"
role="button"
id="milestoneDropdownMenuLink{{ $row['id'] }}"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
><span class="text">
{{ $row['milestoneid'] != '' && $row['milestoneid'] != 0
? $row['milestoneHeadline']
: __('label.no_milestone')
}}
</span>&nbsp;<i class="fa fa-caret-down" aria-hidden="true"></i></a>
<ul class="dropdown-menu" aria-labeledby="milestoneDropdownMenuLink{{ $row['id'] }}">
<li class="nav-header border">{{ __('dropdown.choose_milestone') }}</li>
<li class="dropdown-item">
<a
href="javascript:void(0);"
data-label="{{ __('label.no_milestone') }}"
data-value="{{ $row['id'] }}_0_#b0b0b0"
class="tw-bg-[#b0b0b0]"
>{{ __('label.no_milestone') }}</a>
</li>
@foreach ($milestones as $milestone)
<li class="dropdown-item">
<a
href="javascript:void(0);"
data-label="{{ $milestone->headline }}"
data-value="{{ $row['id'] }}_{!! $milestone->id !!}_{{ $milestone->tags }}"
id="ticketMilestoneChange_{{ $row['id'] . $milestone->id }}"
style="background-color:{{ $milestone->tags }}"
>{{ $milestone->headline }}</a>
</li>
@endforeach
</ul>
</div>
<div class="dropdown ticketDropdown statusDropdown colorized show">
<a
class="dropdown-toggle f-left status {!! $statusLabels[$row['status']]['class'] !!}"
href="javascript:void(0);"
role="button"
id="statusDropdownMenuLink{{ $row['id'] }}"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
><span class="text">{!! $statusLabels[$row['status']]['name'] !!}</span>&nbsp;<i class="fa fa-caret-down" aria-hidden="true"></i></a>
<ul class="dropdown-menu" aria-labelledby="statusDropdownMenuLink{!! $row['id'] !!}">
<li class="nav-header border">{{ __('dropdown.choose_status') }}</li>
@foreach ($statusLabels as $key => $label)
<li class="dropdown-item">
<a
href="javascript:void(0);"
class="{!! $label['class'] !!}"
data-label="{{ $label['name'] }}"
data-value="{{ $row['id'] }}_{{ $key }}_{!! $label['class'] !!}"
id="ticketStatusChange{{ $row['id'] . $key }}"
>{{ $label['name'] }}</a>
</li>
@endforeach
</ul>
</div>
</div>
</div>
</div>
</div>
</li>
@endforeach
</ul>
</div>
<div class="maincontentinner team-container">
@dispatchEvent('teamBoxBeginning', ['project' => $project])
<h5 class="subtitle">{{ __('tabs.team') }}</h5>
<div class="row teamBox">
@foreach ($project['assignedUsers'] as $userId => $assignedUser)
<div class="col-md-3">
<x-users::profile-box :user="$assignedUser">
@spaceless
@php $hasName = $assignedUser['firstname'] != '' || $assignedUser['lastname'] != ''; @endphp
@if ($hasName)
{{ sprintf(
__('text.full_name'),
$assignedUser['firstname'],
$assignedUser['lastname'],
) }}
@else
{{ $assignedUser['username'] }}
@endif
<br />
<small>{{ $hasName ? $assignedUser['jobTitle'] : __('label.invited') }}</small>
@if ($hasName)
@dispatchEvent('usercardBottom', ['user' => $assignedUser, 'project' => $project])
@endif
@endspaceless
</x-users::profile-box>
</div>
@endforeach
@if ($login::userIsAtLeast($roles::$manager))
<div class="col-md-3">
<x-users::profile-box>
<a href="#/users/newUser?preSelectProjectId={{ $project['id'] }}">
{{ __('links.invite_user') }}
</a><br/>&nbsp;
</x-users::profile-box>
</div>
@endif
</div>
</div>
</div>
<div class="col-md-4">
<div class="maincontentinner project-updates">
<div class="pull-right">
@if ($login::userIsAtLeast($roles::$editor))
<x-global::forms.button
tag="a"
link="javascript:void(0);"
onclick="leantime.commentsController.toggleCommentBoxes(0);jQuery('.noCommentsMessage').toggle();"
id="mainToggler"
contentRole="link"
class="action-link"
style="margin-top:-7px;"
><span class="fa fa-plus"></span> {{ __('links.add_new_report') }}</x-global::forms.button>
@endif
</div>
<h5 class="subtitle">{{ __('subtitles.project_updates') }}</h5>
<form method="post" action="{{ BASE_URL }}/dashboard/show">
<input type="hidden" name="comment" value="1" />
@if ($login::userIsAtLeast($roles::$editor))
<div id="comment0" class="commentBox tw-hidden">
<label for="projectStatus tw-inline">{{ __('label.project_status_is') }}</label>
<select name="status" id="projectStatus" class="tw-ml-0 tw-mb-[10px]">
<option value="green">{{ __('label.project_status_green') }}</option>
<option value="yellow">{{ __('label.project_status_yellow') }}</option>
<option value="red">{{ __('label.project_status_red') }}</option>
</select>
<div class="commentReply">
<textarea rows="5" cols="50" class="tiptapSimple tw-w-full" name="text"></textarea>
<input
type="submit"
value="{{ __('buttons.save') }}"
name="comment"
class="btn btn-primary btn-success tw-ml-0"
/>
<x-global::forms.button
tag="a"
link="javascript:void(0);"
onclick="leantime.commentsController.toggleCommentBoxes(-1);jQuery('.noCommentsMessage').toggle();"
class="tw-leading-[50px]"
contentRole="secondary"
>{{ __('links.cancel') }}</x-global::forms.button>
<input type="hidden" name="comment" value="1"/>
<input type="hidden" name="father" id="father" value="0"/>
</div>
</div>
@endif
<div id="comments">
@foreach ($comments as $row)
@if ($loop->iteration == 3)
<a href="javascript:void(0);" onclick="jQuery('.readMore').toggle('fast')">
{{ __('links.read_more') }}
</a>
<div class="readMore tw-hidden tw-mt-[20px]">
@endif
<div class="clearall">
<div>
<div class="commentContent statusUpdate commentStatus-{{ $row['status'] }}">
<strong class="fancyLink">
{{ sprintf(
__('text.report_written_on'),
format($row['date'])->date(),
format($row['date'])->time()
) }}
</strong>
@if ($login::userIsAtLeast($roles::$editor))
<div class="inlineDropDownContainer tw-float-right tw-ml-[10px]">
<a href="javascript:void(0)" class="dropdown-toggle" data-toggle="dropdown">
<i class="fa fa-ellipsis-v"></i>
</a>
<ul class="dropdown-menu">
@if ($row['userId'] == session("userdata.id"))
<li>
<a href="{!! $delUrlBase . $row['id'] !!}" class="deleteComment">
<span class="fa fa-trash"></span> {{ __('links.delete') }}
</a>
</li>
@endif
@isset($ticket->id)
<li>
<a
href="javascript:void(0);"
onclick="leantime.ticketsController.addCommentTimesheetContent({!! $row['id'] !!}, {!! $ticket->id !!})"
>{{ __('links.add_to_timesheets') }}</a>
</li>
@endif
</ul>
</div>
@endif
<div class="text" id="commentText-{{ $row['id'] }}">{!! $tpl->escapeMinimal($row['text']) !!}</div>
</div>
<div class="commentLinks">
<small class="right">
{!! sprintf(
__('text.written_on_by'),
format($row['date'])->date(),
format($row['date'])->time(),
$tpl->escape($row['firstname']),
$tpl->escape($row['lastname'])
) !!}
</small>
@if ($login::userIsAtLeast($roles::$commenter))
<a
href="javascript:void(0);"
onclick="leantime.commentsController.toggleCommentBoxes({!! $row['id'] !!});"
><span class="fa fa-reply"></span> {{ __('links.reply') }}
</a>
@endif
</div>
<div class="replies">
@if ($row['replies'])
@foreach ($row['replies'] as $comment)
<x-comments::reply :comment="$comment" :iteration="$loop->iteration" />
@endforeach
@endif
<x-comments::input :commentId="$row['id']" :user="session('userdata')" />
</div>
</div>
</div>
@endforeach
@if (count($comments) >= 3)
</div>
@endif
</div>
@if (count($comments) == 0)
<div style="padding-left:0px; clear:both;" class="noCommentsMessage">
{{ __('text.no_updates') }}
</div>
@endif
<div class="clearall"></div>
</form>
<div class="clearall"></div>
</div>
<div class="maincontentinner project-progress">
<div class="row" id="projectProgressContainer">
<div class="col-md-12">
<h5 class="subtitle">{{ __('subtitles.project_progress') }}</h5>
<div id="canvas-holder" class="tw-w-full tw-h-[250px]">
<canvas id="chart-area"></canvas>
</div>
<br/><br/>
</div>
</div>
<div class="row" id="milestoneProgressContainer">
<div class="col-md-12">
<h5 class="subtitle">{{ __('headline.milestones') }}</h5>
<ul class="sortableTicketList">
@if (count($milestones) == 0)
<div class="center">
<br/>
<h4>{{ __('headlines.no_milestones') }}</h4>
{{ __('text.milestones_help_organize_projects') }}
<br/><br/>
<a href="{{ BASE_URL }}/tickets/roadmap">{!! __('links.goto_milestones') !!}</a>
</div>
@endif
@foreach($milestones as $row)
@if ($row->percentDone >= 100 && (new \DateTime($row->editTo) < new \DateTime()))
@break
@endif
<li class="ui-state-default" id="milestone_{!! $row->id !!}">
<div hx-trigger="load"
hx-indicator=".htmx-indicator"
hx-get="<?= BASE_URL ?>/hx/tickets/milestones/showCard?milestoneId=<?= $row->id ?>">
<div class="htmx-indicator">
<?= $tpl->__('label.loading_milestone') ?>
</div>
</div>
</li>
@endforeach
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
@once @push('scripts')
<script type='text/javascript'>
if (window.leantime && window.leantime.tiptapController) {
leantime.tiptapController.initSimpleEditor();
}
</script>
@endpush @endonce
@once @push('scripts')
<script>
@dispatchEvent('scripts.afterOpen')
jQuery(document).ready(function () {
jQuery('#descriptionReadMoreToggle').click(function() {
if (jQuery("#projectDescription").hasClass("closed")) {
jQuery("#projectDescription").css("max-height", "100%");
jQuery("#projectDescription").removeClass("closed");
jQuery("#projectDescription").removeClass("kanbanContent");
jQuery('#descriptionReadMoreToggle').text("{{ __('label.read_less') }}");
} else {
jQuery("#projectDescription").css("max-height", "200px");
jQuery("#projectDescription").addClass("closed");
jQuery("#projectDescription").addClass("kanbanContent");
jQuery('#descriptionReadMoreToggle').text("{{ __('label.read_more') }}");
}
});
jQuery(".readMoreBox").each(function() {
if (jQuery(this).find(".readMoreContent").height() >= 169) {
jQuery(this).find(".readMoreToggle").show();
}
});
jQuery(document).on('click', '.progressWrapper .dropdown-menu', function (e) {
e.stopPropagation();
});
@if ($login::userIsAtLeast($roles::$editor))
leantime.dashboardController.prepareHiddenDueDate();
leantime.ticketsController.initEffortDropdown();
leantime.ticketsController.initMilestoneDropdown();
leantime.ticketsController.initStatusDropdown();
leantime.usersController.initUserEditModal();
@else
leantime.authController.makeInputReadonly(".maincontentinner");
@endif
leantime.dashboardController.initProgressChart(
"chart-area",
{!! round($projectProgress['percent']) !!},
{!! round(100 - $projectProgress['percent']) !!}
);
jQuery("#favoriteProject").click(function() {
if (jQuery("#favoriteProject").hasClass("isFavorite")) {
leantime.reactionsController.removeReaction(
'project',
{!! $project['id'] !!},
'favorite',
function() {
jQuery("#favoriteProject").find("i").removeClass("fa-solid").addClass("fa-regular");
jQuery("#favoriteProject").removeClass("isFavorite");
}
);
} else {
leantime.reactionsController.addReactions(
'project',
{!! $project['id'] !!},
'favorite',
function() {
jQuery("#favoriteProject").find("i").removeClass("fa-regular").addClass("fa-solid");
jQuery("#favoriteProject").addClass("isFavorite");
}
);
}
});
leantime.ticketsController.initDueDateTimePickers();
leantime.ticketsController.initDueDateTimePickers();
@php(session(["usersettings.modals.projectDashboardTour" => 1]));
});
@dispatchEvent('scripts.beforeClose')
</script>
@endpush @endonce
@endsection