OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
76
app/Domain/Widgets/Controllers/WidgetManager.php
Normal file
76
app/Domain/Widgets/Controllers/WidgetManager.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Widgets\Controllers;
|
||||
|
||||
use Leantime\Core\Controller\Controller;
|
||||
use Leantime\Domain\Auth\Models\Roles;
|
||||
use Leantime\Domain\Auth\Services\Auth;
|
||||
use Leantime\Domain\Widgets\Services\Widgets;
|
||||
use Symfony\Component\HttpFoundation;
|
||||
|
||||
/**
|
||||
* Class WidgetManager
|
||||
*
|
||||
* This class represents a widget manager.
|
||||
*/
|
||||
class WidgetManager extends Controller
|
||||
{
|
||||
private Widgets $widgetService;
|
||||
|
||||
/**
|
||||
* Initializes the object.
|
||||
*
|
||||
* @param Widgets $widgetService The widget service object.
|
||||
* @return void
|
||||
*/
|
||||
public function init(Widgets $widgetService)
|
||||
{
|
||||
$this->widgetService = $widgetService;
|
||||
|
||||
Auth::authOrRedirect([Roles::$owner, Roles::$admin, Roles::$manager, Roles::$editor]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an HTTP response.
|
||||
*
|
||||
* @param array $params An array of parameters.
|
||||
* @return HttpFoundation\Response The HTTP response.
|
||||
*/
|
||||
public function get(array $params): HttpFoundation\Response
|
||||
{
|
||||
$availableWidgets = $this->widgetService->getAll();
|
||||
$activeWidgets = $this->widgetService->getActiveWidgets(session('userdata.id'));
|
||||
$newWidgets = $this->widgetService->getNewWidgets(session('userdata.id'));
|
||||
|
||||
$this->tpl->assign('availableWidgets', $availableWidgets);
|
||||
$this->tpl->assign('activeWidgets', $activeWidgets);
|
||||
$this->tpl->assign('newWidgets', $newWidgets);
|
||||
|
||||
return $this->tpl->displayPartial('widgets.widgetManager');
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts data and returns an HTTP response.
|
||||
*
|
||||
* @param array $params An array of parameters.
|
||||
* @return HttpFoundation\Response The HTTP response.
|
||||
*/
|
||||
public function post(array $params): HttpFoundation\Response
|
||||
{
|
||||
if (isset($params['action'])) {
|
||||
switch ($params['action']) {
|
||||
case 'saveGrid':
|
||||
if (isset($params['data']) && $params['data'] != '') {
|
||||
$this->widgetService->saveGridForUser(
|
||||
$params['data'],
|
||||
session('userdata.id'),
|
||||
$params['visibilityData'] ?? null
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new \Symfony\Component\HttpFoundation\Response;
|
||||
}
|
||||
}
|
||||
29
app/Domain/Widgets/Hxcontrollers/Calendar.php
Normal file
29
app/Domain/Widgets/Hxcontrollers/Calendar.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Widgets\Hxcontrollers;
|
||||
|
||||
use Leantime\Core\Controller\HtmxController;
|
||||
use Leantime\Domain\Calendar\Services\Calendar as CalendarService;
|
||||
|
||||
class Calendar extends HtmxController
|
||||
{
|
||||
protected static string $view = 'widgets::partials.calendar';
|
||||
|
||||
private CalendarService $calendarService;
|
||||
|
||||
/**
|
||||
* Initializes dependencies.
|
||||
*/
|
||||
public function init(CalendarService $calendarService): void
|
||||
{
|
||||
$this->calendarService = $calendarService;
|
||||
}
|
||||
|
||||
public function get(): void
|
||||
{
|
||||
$userId = (int) session('userdata.id');
|
||||
|
||||
$this->tpl->assign('externalCalendars', $this->calendarService->getMyExternalCalendars($userId));
|
||||
$this->tpl->assign('calendar', $this->calendarService->getCalendar($userId));
|
||||
}
|
||||
}
|
||||
37
app/Domain/Widgets/Hxcontrollers/MyProjects.php
Normal file
37
app/Domain/Widgets/Hxcontrollers/MyProjects.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Widgets\Hxcontrollers;
|
||||
|
||||
use Leantime\Core\Controller\HtmxController;
|
||||
use Leantime\Domain\Menu\Services\Menu;
|
||||
use Leantime\Domain\Widgets\Services\Widgets as WidgetService;
|
||||
|
||||
class MyProjects extends HtmxController
|
||||
{
|
||||
protected static string $view = 'widgets::partials.myProjects';
|
||||
|
||||
private WidgetService $widgetService;
|
||||
|
||||
private Menu $menuService;
|
||||
|
||||
/**
|
||||
* Initializes dependencies.
|
||||
*/
|
||||
public function init(
|
||||
WidgetService $widgetService,
|
||||
Menu $menuService
|
||||
): void {
|
||||
$this->widgetService = $widgetService;
|
||||
$this->menuService = $menuService;
|
||||
}
|
||||
|
||||
public function get(): void
|
||||
{
|
||||
$widgetData = $this->widgetService->getMyProjectsWidgetData((int) session('userdata.id'));
|
||||
|
||||
$this->tpl->assign('background', $_GET['noBackground'] ?? '');
|
||||
$this->tpl->assign('type', $_GET['type'] ?? 'simple');
|
||||
$this->tpl->assign('projectTypeAvatars', $this->menuService->getProjectTypeAvatars());
|
||||
$this->tpl->assign('allProjects', $widgetData['projects']);
|
||||
}
|
||||
}
|
||||
240
app/Domain/Widgets/Hxcontrollers/MyToDos.php
Normal file
240
app/Domain/Widgets/Hxcontrollers/MyToDos.php
Normal file
@@ -0,0 +1,240 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Widgets\Hxcontrollers;
|
||||
|
||||
use Leantime\Core\Controller\HtmxController;
|
||||
use Leantime\Domain\Auth\Models\Roles;
|
||||
use Leantime\Domain\Auth\Services\Auth as AuthService;
|
||||
use Leantime\Domain\Tickets\Services\Tickets as TicketService;
|
||||
use Leantime\Domain\Widgets\Services\Dashboard as DashboardService;
|
||||
|
||||
/**
|
||||
* Class MyToDos
|
||||
*
|
||||
* This class extends the HtmxController class and represents a controller for managing to-do items.
|
||||
*/
|
||||
class MyToDos extends HtmxController
|
||||
{
|
||||
protected static string $view = 'widgets::partials.myToDos';
|
||||
|
||||
private TicketService $ticketsService;
|
||||
|
||||
private DashboardService $dashboardService;
|
||||
|
||||
private int $limit = 50;
|
||||
|
||||
/**
|
||||
* Initializes dependencies.
|
||||
*
|
||||
* @param TicketService $ticketsService The tickets service.
|
||||
* @param DashboardService $dashboardService The dashboard orchestration service.
|
||||
*/
|
||||
public function init(
|
||||
TicketService $ticketsService,
|
||||
DashboardService $dashboardService,
|
||||
): void {
|
||||
$this->ticketsService = $ticketsService;
|
||||
$this->dashboardService = $dashboardService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the todo widget assignments.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function get()
|
||||
{
|
||||
$params = $this->incomingRequest->query->all();
|
||||
|
||||
// Set initial pagination - only load first page of tasks per group
|
||||
if (! isset($params['limit'])) {
|
||||
$params['limit'] = $this->limit;
|
||||
}
|
||||
|
||||
$tplVars = $this->dashboardService->getToDoWidgetData((int) session('userdata.id'), $params);
|
||||
|
||||
$this->tpl->assign('limit', $tplVars['limit']);
|
||||
|
||||
array_map([$this->tpl, 'assign'], array_keys($tplVars), array_values($tplVars));
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the user's personal task sorting preferences.
|
||||
*
|
||||
* @param mixed $params The sort items posted by the request.
|
||||
*/
|
||||
public function saveSorting($params)
|
||||
{
|
||||
$post = $this->incomingRequest->request->all();
|
||||
|
||||
if (is_array($params)) {
|
||||
unset($params['act']);
|
||||
}
|
||||
|
||||
$result = $this->dashboardService->saveTodoSorting(
|
||||
(int) session('userdata.id'),
|
||||
$params,
|
||||
$post['groupChanges'] ?? [],
|
||||
$post['groupBy'] ?? ''
|
||||
);
|
||||
|
||||
if ($result['successCount'] > 0 && $result['errorCount'] === 0) {
|
||||
$this->tpl->setNotification($this->language->__('short_notifications.group_changes_applied'), 'success');
|
||||
} elseif ($result['successCount'] > 0 && $result['errorCount'] > 0) {
|
||||
$this->tpl->setNotification($this->language->__('short_notifications.group_changes_partial'), 'warning');
|
||||
} elseif ($result['errorCount'] > 0) {
|
||||
$this->tpl->setNotification($this->language->__('short_notifications.group_changes_failed'), 'error');
|
||||
}
|
||||
|
||||
if (! $result['sorted']) {
|
||||
$this->tpl->setNotification($this->language->__('short_notifications.sorting_error'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the collapse state of a task.
|
||||
*
|
||||
* @param array $params Request parameters containing the taskId.
|
||||
* @return string|void The new collapse state when a taskId is provided.
|
||||
*/
|
||||
public function toggleTaskCollapse($params)
|
||||
{
|
||||
if (isset($params['taskId'])) {
|
||||
return $this->dashboardService->toggleTaskCollapse((int) session('userdata.id'), $params['taskId']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update task status via HTMX.
|
||||
*/
|
||||
public function updateStatus()
|
||||
{
|
||||
$params = $this->incomingRequest->request->all();
|
||||
|
||||
if (isset($params['id']) && isset($params['status'])) {
|
||||
$result = $this->ticketsService->patch($params['id'], ['status' => $params['status']]);
|
||||
|
||||
if ($result) {
|
||||
$this->tpl->setNotification($this->language->__('short_notifications.status_updated'), 'success');
|
||||
} else {
|
||||
$this->tpl->setNotification($this->language->__('short_notifications.status_update_error'), 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update task milestone via HTMX.
|
||||
*/
|
||||
public function updateMilestone()
|
||||
{
|
||||
$params = $this->incomingRequest->request->all();
|
||||
|
||||
if (isset($params['id']) && isset($params['milestoneId'])) {
|
||||
$result = $this->ticketsService->patch($params['id'], ['milestoneid' => $params['milestoneId']]);
|
||||
|
||||
if ($result) {
|
||||
$this->tpl->setNotification($this->language->__('short_notifications.milestone_updated'), 'success');
|
||||
} else {
|
||||
$this->tpl->setNotification($this->language->__('short_notifications.milestone_update_error'), 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update task due date via HTMX.
|
||||
*/
|
||||
public function updateDueDate()
|
||||
{
|
||||
$params = $this->incomingRequest->request->all();
|
||||
|
||||
if (isset($params['id']) && isset($params['date'])) {
|
||||
$result = $this->ticketsService->patch($params['id'], ['dateToFinish' => $params['date']]);
|
||||
|
||||
if ($result) {
|
||||
$this->tpl->setNotification($this->language->__('short_notifications.date_updated'), 'success');
|
||||
} else {
|
||||
$this->tpl->setNotification($this->language->__('short_notifications.date_update_error'), 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update task title via HTMX.
|
||||
*
|
||||
* @param array $params Request parameters containing id and headline.
|
||||
* @return mixed The raw rendered headline when an id and headline are provided.
|
||||
*/
|
||||
public function updateTitle($params)
|
||||
{
|
||||
if (isset($params['id']) && isset($params['headline'])) {
|
||||
$headline = $params['headline'];
|
||||
|
||||
$result = $this->ticketsService->patch($params['id'], ['headline' => $headline]);
|
||||
|
||||
if ($result) {
|
||||
$this->tpl->setNotification($this->language->__('short_notifications.title_updated'), 'success');
|
||||
} else {
|
||||
$this->tpl->setNotification($this->language->__('short_notifications.title_update_error'), 'error');
|
||||
}
|
||||
|
||||
return $this->tpl->displayRaw("{$headline}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle subtask creation.
|
||||
*/
|
||||
public function addSubtask()
|
||||
{
|
||||
$params = $this->incomingRequest->request->all();
|
||||
$getParams = $this->incomingRequest->query->all();
|
||||
|
||||
if ($this->dashboardService->addSubtask($params, (int) $getParams['ticketId'])) {
|
||||
$this->tpl->setNotification($this->language->__('notifications.subtask_saved'), 'success');
|
||||
} else {
|
||||
$this->tpl->setNotification($this->language->__('notifications.subtask_save_error'), 'error');
|
||||
}
|
||||
|
||||
// Refresh the todo widget
|
||||
$tplVars = $this->ticketsService->getToDoWidgetHierarchicalAssignments($params);
|
||||
array_map([$this->tpl, 'assign'], array_keys($tplVars), array_values($tplVars));
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick-add a to-do and refresh the widget.
|
||||
*/
|
||||
public function addTodo()
|
||||
{
|
||||
$params = $this->incomingRequest->request->all();
|
||||
|
||||
if (AuthService::userHasRole([Roles::$owner, Roles::$manager, Roles::$editor])) {
|
||||
if (isset($params['quickadd']) == true) {
|
||||
$result = $this->dashboardService->addTodo($params);
|
||||
|
||||
if (isset($result['status'])) {
|
||||
$this->tpl->setNotification($result['message'], $result['status']);
|
||||
} else {
|
||||
$this->tpl->setNotification($this->language->__('notifications.ticket_saved'), 'success');
|
||||
}
|
||||
|
||||
$this->tpl->setHTMXEvent('HTMX.ShowNotification');
|
||||
}
|
||||
}
|
||||
|
||||
$tplVars = $this->ticketsService->getToDoWidgetHierarchicalAssignments($params);
|
||||
array_map([$this->tpl, 'assign'], array_keys($tplVars), array_values($tplVars));
|
||||
}
|
||||
|
||||
/**
|
||||
* Load more todos for infinite scroll.
|
||||
*/
|
||||
public function loadMore()
|
||||
{
|
||||
$params = $this->incomingRequest->query->all();
|
||||
|
||||
$tplVars = $this->dashboardService->getToDoWidgetLoadMoreData((int) session('userdata.id'), $params, $this->limit);
|
||||
|
||||
$this->tpl->assign('limit', $tplVars['limit']);
|
||||
array_map([$this->tpl, 'assign'], array_keys($tplVars), array_values($tplVars));
|
||||
}
|
||||
}
|
||||
37
app/Domain/Widgets/Hxcontrollers/Welcome.php
Normal file
37
app/Domain/Widgets/Hxcontrollers/Welcome.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Widgets\Hxcontrollers;
|
||||
|
||||
use Leantime\Core\Controller\HtmxController;
|
||||
use Leantime\Domain\Widgets\Services\Dashboard as DashboardService;
|
||||
|
||||
class Welcome extends HtmxController
|
||||
{
|
||||
protected static string $view = 'widgets::partials.welcome';
|
||||
|
||||
private DashboardService $dashboardService;
|
||||
|
||||
/**
|
||||
* Initializes the class by assigning the dashboard service and setting the last page session variable.
|
||||
*
|
||||
* @param DashboardService $dashboardService The dashboard aggregation service.
|
||||
*/
|
||||
public function init(DashboardService $dashboardService): void
|
||||
{
|
||||
$this->dashboardService = $dashboardService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the Welcome widget data and assigns it to the template for display.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function get()
|
||||
{
|
||||
$this->dashboardService->sendAnonymousTelemetry();
|
||||
|
||||
$welcomeData = $this->dashboardService->getWelcomeWidgetData((int) session('userdata.id'));
|
||||
|
||||
array_map([$this->tpl, 'assign'], array_keys($welcomeData), array_values($welcomeData));
|
||||
}
|
||||
}
|
||||
265
app/Domain/Widgets/Js/Widgetcontroller.js
Normal file
265
app/Domain/Widgets/Js/Widgetcontroller.js
Normal file
@@ -0,0 +1,265 @@
|
||||
leantime.widgetController = (function () {
|
||||
var grid = [];
|
||||
|
||||
// Helper function to find next available position
|
||||
var findAvailablePosition = function(widget, grid) {
|
||||
let x = widget.gridX || 0;
|
||||
let y = widget.gridY || 0;
|
||||
let width = widget.gridWidth || 2;
|
||||
let height = widget.gridHeight || 2;
|
||||
|
||||
// Try the preferred position first
|
||||
if (grid.willItFit({x, y, width, height})) {
|
||||
return { x: x, y: y };
|
||||
}
|
||||
|
||||
// If preferred position is occupied, find next available spot
|
||||
let maxY = Math.max(...grid.engine.nodes.map(n => n.y + n.h), 0);
|
||||
|
||||
// Try positions from top to bottom
|
||||
for (let newY = 0; newY <= maxY + 1; newY++) {
|
||||
for (let newX = 0; newX <= 12 - width; newX++) {
|
||||
if (grid.willItFit({newX, newY, width, height})) {
|
||||
return { x: newX, y: newY };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { x: 0, y: maxY + 1 }; // Fallback to bottom
|
||||
};
|
||||
|
||||
// Implement safe HTML rendering callback
|
||||
GridStack.renderCB = function(el, w) {
|
||||
if (w.content) {
|
||||
// Using DOMPurify to sanitize content if available
|
||||
if (typeof DOMPurify !== 'undefined') {
|
||||
el.innerHTML = DOMPurify.sanitize(w.content);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
var initGrid = function () {
|
||||
grid = GridStack.init({
|
||||
margin: '0px 15px 15px 0px',
|
||||
handle: ".grid-handler-top",
|
||||
minRow: 2,
|
||||
cellHeight: '30px',
|
||||
float: true,
|
||||
draggable: {
|
||||
handle: '.grid-handler-top',
|
||||
appendTo: 'body',
|
||||
// scroll: true,
|
||||
// scrollSensitivity: 20,
|
||||
// scrollSpeed: 10
|
||||
},
|
||||
lazyLoad: false,
|
||||
columnOpts: {
|
||||
breakpointForWindow: true, // test window vs grid size
|
||||
breakpoints: [{w:1199, c:1}]
|
||||
},
|
||||
});
|
||||
|
||||
grid.on('dragstop', function(event, item) {
|
||||
saveGrid();
|
||||
});
|
||||
|
||||
grid.on('resizestop', function(Event, item) {
|
||||
saveGrid();
|
||||
});
|
||||
|
||||
// Mobile/tablet (<1200px): force a static single-column grid so that
|
||||
// scrolling or tapping a widget doesn't drag/reshuffle it (#3350). The
|
||||
// 1-column layout comes from columnOpts.breakpoints above; setStatic
|
||||
// disables drag/resize (so saveGrid never fires here and the desktop
|
||||
// layout is never overwritten). Restore the draggable grid on resize up.
|
||||
var welcomeRemoved = false;
|
||||
var applyResponsiveGridMode = function () {
|
||||
if (!grid) return;
|
||||
var isMobile = window.innerWidth < 1200;
|
||||
grid.setStatic(isMobile);
|
||||
grid.margin(isMobile ? '0px 5px 5px 0px' : '0px 15px 15px 0px');
|
||||
|
||||
// Hide the low-value welcome/stats widget on mobile. Removing it from
|
||||
// the grid engine (keeping the DOM, which CSS hides) and compacting
|
||||
// closes the gap its grid rows would otherwise leave behind.
|
||||
if (isMobile && !welcomeRemoved) {
|
||||
var welcome = document.getElementById('widget_wrapper_welcome');
|
||||
if (welcome) {
|
||||
try {
|
||||
grid.removeWidget(welcome, false);
|
||||
grid.compact();
|
||||
} catch (e) { /* grid not ready; CSS still hides it */ }
|
||||
welcomeRemoved = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
applyResponsiveGridMode();
|
||||
var gridResizeTimer;
|
||||
window.addEventListener('resize', function () {
|
||||
clearTimeout(gridResizeTimer);
|
||||
gridResizeTimer = setTimeout(applyResponsiveGridMode, 200);
|
||||
});
|
||||
|
||||
jQuery(".grid-stack-item").each(function(){
|
||||
jQuery(this).find(".removeWidget").click(function(){
|
||||
removeWidget(jQuery(this).closest(".grid-stack-item")[0]);
|
||||
});
|
||||
jQuery(this).find(".fitContent").click(function(){
|
||||
resizeWidget(jQuery(this).closest(".grid-stack-item")[0]);
|
||||
});
|
||||
});
|
||||
|
||||
jQuery(document).ready(function(){
|
||||
jQuery("#gridBoard").css("opacity", 1);
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
var saveGrid = function() {
|
||||
let items = grid.save();
|
||||
|
||||
// Sort items by Y position first, then X position
|
||||
items.sort((a, b) => {
|
||||
return a.y === b.y ? a.x - b.x : a.y - b.y;
|
||||
});
|
||||
|
||||
let visibilityData = null;
|
||||
|
||||
if(arguments.length > 0 && arguments[0].action === "toggleWidget") {
|
||||
visibilityData = {
|
||||
widgetId: arguments[0].widgetId,
|
||||
visible: arguments[0].visible
|
||||
};
|
||||
}
|
||||
|
||||
items.forEach(function(item) {
|
||||
//get hx links
|
||||
let htmxElement = jQuery(item.content).find("[hx-get]").first();
|
||||
|
||||
item.id = htmxElement.attr("id");
|
||||
item.widgetUrl = htmxElement.attr("hx-get");
|
||||
item.widgetTrigger = htmxElement.attr("hx-trigger");
|
||||
|
||||
if(item.x == undefined) {
|
||||
item.x = 0;
|
||||
}
|
||||
item.gridX = item.x;
|
||||
|
||||
if(item.y == undefined) {
|
||||
item.y = 0;
|
||||
}
|
||||
item.gridY = item.y;
|
||||
|
||||
if(item.w == undefined) {
|
||||
item.w = 1;
|
||||
}
|
||||
item.gridWidth = item.w;
|
||||
|
||||
if(item.h == undefined) {
|
||||
item.h = 1;
|
||||
}
|
||||
item.gridHeight = item.h;
|
||||
|
||||
item.content = '';
|
||||
});
|
||||
|
||||
|
||||
jQuery.post(leantime.appUrl+"/widgets/widgetManager",
|
||||
{
|
||||
action: "saveGrid",
|
||||
data: items,
|
||||
visibilityData: visibilityData
|
||||
},
|
||||
function(data, status){
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
var removeWidget = function (el) {
|
||||
el.remove();
|
||||
grid.removeWidget(el, true);
|
||||
saveGrid();
|
||||
}
|
||||
|
||||
var resizeWidget = function (el) {
|
||||
let grid = document.querySelector('.grid-stack').gridstack;
|
||||
grid.resizeToContent(el, false);
|
||||
saveGrid();
|
||||
}
|
||||
|
||||
var toggleWidgetVisibility = function(id, element, widget) {
|
||||
let grid = document.querySelector('.grid-stack').gridstack;
|
||||
let visible = jQuery(element).is(":checked");
|
||||
|
||||
// Find the next available position
|
||||
let position = findAvailablePosition(widget, grid);
|
||||
|
||||
if (!visible) {
|
||||
removeWidget(jQuery("#" + id).closest(".grid-stack-item")[0]);
|
||||
} else {
|
||||
// Create the widget structure using DOM methods
|
||||
const widgetNode = document.createElement('div');
|
||||
widgetNode.className = 'grid-stack-item';
|
||||
|
||||
// Create the content container
|
||||
const contentDiv = document.createElement('div');
|
||||
contentDiv.className = `grid-stack-item-content tw-p-none ${
|
||||
widget.widgetBackground == "default" ? "maincontentinner" : widget.background
|
||||
}`;
|
||||
|
||||
// Set the inner structure
|
||||
contentDiv.innerHTML = buildWidget(widget);
|
||||
widgetNode.appendChild(contentDiv);
|
||||
|
||||
// Add to grid and make it a widget
|
||||
grid.el.appendChild(widgetNode);
|
||||
grid.makeWidget(widgetNode, {
|
||||
x: widget.gridX || 0,
|
||||
y: widget.gridY || 50,
|
||||
w: widget.gridWidth || 2,
|
||||
h: widget.gridHeight || 2
|
||||
});
|
||||
|
||||
// Initialize HTMX
|
||||
htmx.process(widgetNode);
|
||||
|
||||
saveGrid({action: "toggleWidget", widgetId: id, visible: visible});
|
||||
}
|
||||
}
|
||||
|
||||
var buildWidget = function(widget) {
|
||||
return '<div class="widgetInner">' +
|
||||
' <div class="' + (widget.widgetBackground == "default" ? "tw-pb-l" : "") + '">\n' +
|
||||
' <div class="stickyHeader" style="padding:15px; height:50px; width:100%;">\n' +
|
||||
' <div class="grid-handler-top tw-h-[40px] tw-cursor-grab tw-float-left tw-mr-sm">\n' +
|
||||
' <i class="fa-solid fa-grip-vertical"></i>\n' +
|
||||
' </div>\n' +
|
||||
' ' + (widget.name != '' ? '<h5 class="subtitle tw-pb-m tw-float-left tw-mr-sm">' + widget.name + '</h5>' : '') + '\n' +
|
||||
' <div class="inlineDropDownContainer tw-float-right">\n' +
|
||||
' <a href="javascript:void(0);" class="dropdown-toggle ticketDropDown editHeadline" data-toggle="dropdown">\n' +
|
||||
' <i class="fa fa-ellipsis-v" aria-hidden="true"></i>\n' +
|
||||
' </a>\n' +
|
||||
' <ul class="dropdown-menu">\n' +
|
||||
' <li><a href="javascript:void(0)" class="fitContent"><i class="fa-solid fa-up-right-and-down-left-from-center"></i> Resize to fit content</a></li>\n' +
|
||||
' <li><a href="javascript:void(0)" class="removeWidget"><i class="fa fa-eye-slash"></i> Hide</a></li>\n' +
|
||||
' </ul>\n' +
|
||||
' </div>\n' +
|
||||
'\n' +
|
||||
' </div>\n' +
|
||||
' <div class="widgetContent tw-px-l">\n' +
|
||||
' <div hx-get="'+widget.widgetUrl+'" hx-trigger="'+widget.widgetTrigger+'" id="'+widget.id+'"></div>\n' +
|
||||
' </div>\n' +
|
||||
' </div>\n' +
|
||||
' <div class="clear"></div>\n' +
|
||||
' </div>\n';
|
||||
}
|
||||
|
||||
// Make public what you want to have public, everything else is private
|
||||
return {
|
||||
resizeWidget: resizeWidget,
|
||||
removeWidget: removeWidget,
|
||||
saveGrid: saveGrid,
|
||||
initGrid:initGrid,
|
||||
toggleWidgetVisibility:toggleWidgetVisibility
|
||||
};
|
||||
})();
|
||||
46
app/Domain/Widgets/Models/Widget.php
Normal file
46
app/Domain/Widgets/Models/Widget.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Widgets\Models;
|
||||
|
||||
class Widget
|
||||
{
|
||||
/** Set transiently by the Widgets service to flag freshly-available widgets in the UI. */
|
||||
public bool $isNew = false;
|
||||
|
||||
/**
|
||||
* Constructor for creating a new instance of the class.
|
||||
*
|
||||
* @param string $id The unique identifier for the widget.
|
||||
* @param string $name The name of the widget.
|
||||
* @param string $widgetUrl The URL of the widget.
|
||||
* @param string $widgetTrigger The trigger for loading the widget (default: "load").
|
||||
* @param int $gridMinWidth The minimum width of the widget in the grid (default: 1).
|
||||
* @param int $gridMinHeight The minimum height of the widget in the grid (default: 1).
|
||||
* @param int $gridX The X position of the widget in the grid (default: 0).
|
||||
* @param int $gridY The Y position of the widget in the grid (default: 0).
|
||||
* @param int $gridHeight The height of the widget in the grid (default: 1).
|
||||
* @param int $gridWidth The width of the widget in the grid (default: 1).
|
||||
* @param string $widgetLoadingIndicator The loading indicator type for the widget (default: "text").
|
||||
* @param string $widgetBackground The background type for the widget (default: "default").
|
||||
* @param bool $alwaysVisible Indicates if the widget is always visible (default: false).
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $name,
|
||||
public string $widgetUrl,
|
||||
public string $description = '',
|
||||
public string $widgetTrigger = 'load',
|
||||
public int $gridMinWidth = 1,
|
||||
public int $gridMinHeight = 1,
|
||||
public int $gridX = 0,
|
||||
public int $gridY = 0,
|
||||
public int $gridHeight = 1,
|
||||
public int $gridWidth = 1,
|
||||
public bool $noTitle = false,
|
||||
public bool $fixed = false,
|
||||
public string $widgetLoadingIndicator = 'text',
|
||||
public string $widgetBackground = 'default',
|
||||
public bool $alwaysVisible = false
|
||||
) {}
|
||||
}
|
||||
603
app/Domain/Widgets/Services/Dashboard.php
Normal file
603
app/Domain/Widgets/Services/Dashboard.php
Normal file
@@ -0,0 +1,603 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Widgets\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||||
use Leantime\Domain\Reports\Services\Reports as ReportService;
|
||||
use Leantime\Domain\Setting\Services\Setting as SettingService;
|
||||
use Leantime\Domain\Tickets\Services\Tickets as TicketService;
|
||||
use Leantime\Domain\Users\Services\Users as UserService;
|
||||
|
||||
/**
|
||||
* Class Dashboard
|
||||
*
|
||||
* Aggregates and orchestrates the business logic behind the dashboard widgets
|
||||
* (the Welcome widget and the "My To-Dos" widget). The HxControllers in this
|
||||
* domain delegate all data access, grouping, ordering and orchestration to this
|
||||
* service so they can stay thin.
|
||||
*/
|
||||
class Dashboard
|
||||
{
|
||||
/**
|
||||
* Constructs the dashboard service.
|
||||
*
|
||||
* @param TicketService $ticketsService The tickets service.
|
||||
* @param SettingService $settingsService The setting service.
|
||||
* @param ProjectService $projectsService The projects service.
|
||||
* @param UserService $usersService The users service.
|
||||
* @param ReportService $reportService The reports service (anonymous telemetry).
|
||||
* @param Widgets $widgetService The widgets service.
|
||||
*/
|
||||
public function __construct(
|
||||
protected TicketService $ticketsService,
|
||||
protected SettingService $settingsService,
|
||||
protected ProjectService $projectsService,
|
||||
protected UserService $usersService,
|
||||
protected ReportService $reportService,
|
||||
protected Widgets $widgetService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Fires the anonymous telemetry collection and waits for it to complete.
|
||||
*
|
||||
* Failures are logged and swallowed so the dashboard render path is never
|
||||
* blocked or broken by telemetry issues.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function sendAnonymousTelemetry(): void
|
||||
{
|
||||
try {
|
||||
$promise = $this->reportService->sendAnonymousTelemetry();
|
||||
if ($promise !== false) {
|
||||
$promise->wait();
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates all data the Welcome widget needs into a single array.
|
||||
*
|
||||
* Moves the count()/is_array guards and the today start/end computation out
|
||||
* of the controller and into the service.
|
||||
*
|
||||
* @param int $userId The user the dashboard is rendered for.
|
||||
* @return array{
|
||||
* currentUser: mixed,
|
||||
* showSettingsIndicator: bool,
|
||||
* totalTickets: int,
|
||||
* closedTicketsCount: int,
|
||||
* ticketsInGoals: int,
|
||||
* totalTodayCount: int,
|
||||
* doneTodayCount: int,
|
||||
* allProjects: array<int, mixed>,
|
||||
* projectCount: int
|
||||
* }
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function getWelcomeWidgetData(int $userId): array
|
||||
{
|
||||
$currentUser = $this->usersService->getUser($userId);
|
||||
|
||||
// Check for new widgets to show settings indicator
|
||||
$showSettingsIndicator = false;
|
||||
if (session()->exists('userdata')) {
|
||||
$newWidgets = $this->widgetService->getNewWidgets($userId);
|
||||
$showSettingsIndicator = ! empty($newWidgets);
|
||||
}
|
||||
|
||||
$totalTickets = $this->ticketsService->simpleTicketCounter(
|
||||
userId: $userId,
|
||||
status: 'not_done',
|
||||
types: ['task', 'story', 'bug']
|
||||
);
|
||||
|
||||
$closedTicketsCount = 0;
|
||||
$closedTickets = $this->ticketsService->getRecentlyCompletedTicketsByUser($userId, null);
|
||||
if (is_array($closedTickets)) {
|
||||
$closedTicketsCount = count($closedTickets);
|
||||
}
|
||||
|
||||
$ticketsInGoals = 0;
|
||||
$goalTickets = $this->ticketsService->goalsRelatedToWork($userId, null);
|
||||
if (is_array($goalTickets)) {
|
||||
$ticketsInGoals = count($goalTickets);
|
||||
}
|
||||
|
||||
$todayStart = dtHelper()->userNow()->startOfDay();
|
||||
$todayEnd = dtHelper()->userNow()->endOfDay();
|
||||
$todaysTasks = $this->ticketsService->getScheduledTasks($todayStart, $todayEnd, $userId);
|
||||
$totalToday = count($todaysTasks['totalTasks'] ?? []);
|
||||
$doneToday = count($todaysTasks['doneTasks'] ?? []);
|
||||
|
||||
$allAssignedProjects = $this->projectsService->getProjectsAssignedToUser($userId, 'open');
|
||||
if (! is_array($allAssignedProjects)) {
|
||||
$allAssignedProjects = [];
|
||||
}
|
||||
|
||||
return [
|
||||
'currentUser' => $currentUser,
|
||||
'showSettingsIndicator' => $showSettingsIndicator,
|
||||
'totalTickets' => $totalTickets,
|
||||
'closedTicketsCount' => $closedTicketsCount,
|
||||
'ticketsInGoals' => $ticketsInGoals,
|
||||
'totalTodayCount' => $totalToday,
|
||||
'doneTodayCount' => $doneToday,
|
||||
'allProjects' => $allAssignedProjects,
|
||||
'projectCount' => count($allAssignedProjects),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the template variables for the "My To-Dos" widget, including the
|
||||
* hierarchical task assignments, the user's stored sorting preferences and
|
||||
* the pagination "has more" computation.
|
||||
*
|
||||
* @param int $userId The user the widget is rendered for.
|
||||
* @param array $params The request parameters (limit, offset, filters...).
|
||||
* @return array<string, mixed> Template variables including hasMoreTickets, sorting and limit.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function getToDoWidgetData(int $userId, array $params): array
|
||||
{
|
||||
$tplVars = $this->ticketsService->getToDoWidgetHierarchicalAssignments($params);
|
||||
|
||||
$tplVars['hasMoreTickets'] = $this->hasMoreTickets($tplVars['tickets'] ?? [], (int) $params['limit']);
|
||||
|
||||
$tplVars['sorting'] = collect($this->getUserSorting($userId));
|
||||
$tplVars['limit'] = $params['limit'];
|
||||
|
||||
return $tplVars;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the template variables for a "load more" (infinite scroll) request
|
||||
* of the "My To-Dos" widget.
|
||||
*
|
||||
* @param int $userId The user the widget is rendered for.
|
||||
* @param array $params The request parameters; limit is incremented by the page size.
|
||||
* @param int $pageSize The number of tasks loaded per page.
|
||||
* @return array<string, mixed> Template variables including hasMoreTickets, nextOffset, isLoadMore and limit.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function getToDoWidgetLoadMoreData(int $userId, array $params, int $pageSize): array
|
||||
{
|
||||
$params['limit'] = $params['limit'] + $pageSize;
|
||||
$params['offset'] = 0;
|
||||
|
||||
$tplVars = $this->ticketsService->getToDoWidgetHierarchicalAssignments($params);
|
||||
|
||||
$tplVars['hasMoreTickets'] = $this->hasMoreTickets($tplVars['tickets'] ?? [], (int) $params['limit']);
|
||||
$tplVars['nextOffset'] = $params['offset'] + $params['limit'];
|
||||
$tplVars['isLoadMore'] = true;
|
||||
$tplVars['limit'] = $params['limit'];
|
||||
|
||||
return $tplVars;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes whether more tickets are available to load by checking whether a
|
||||
* full page was returned.
|
||||
*
|
||||
* @param array $ticketGroups The grouped tickets returned by the tickets service.
|
||||
* @param int $limit The current page limit.
|
||||
* @return bool True when at least a full page worth of tickets was loaded.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function hasMoreTickets(array $ticketGroups, int $limit): bool
|
||||
{
|
||||
$totalLoadedTickets = 0;
|
||||
|
||||
foreach ($ticketGroups as $ticketGroup) {
|
||||
$totalLoadedTickets += collect($ticketGroups)->countNested('tickets');
|
||||
}
|
||||
|
||||
return $totalLoadedTickets >= $limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the user's stored personal task sorting preferences.
|
||||
*
|
||||
* @param int $userId The user whose sorting to read.
|
||||
* @return array The decoded sorting array, or an empty array when none is stored.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function getUserSorting(int $userId): array
|
||||
{
|
||||
$sorting = $this->settingsService->getSetting($this->sortingKey($userId));
|
||||
|
||||
if ($sorting) {
|
||||
return json_decode($sorting, true) ?? [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the user's personal task sorting preferences and applies any group
|
||||
* changes (time/project/priority moves) and dependency (parent) updates.
|
||||
*
|
||||
* Owns the json_decode / normalize / +10 ordering, persistence of the sorting
|
||||
* setting, the dependency updates and the group-change application. Returns the
|
||||
* success/error counts of the group changes so the caller can map them to
|
||||
* notifications.
|
||||
*
|
||||
* @param int $userId The user whose sorting to persist.
|
||||
* @param mixed $rawItems The raw, JSON-encoded sort items from the request (array expected).
|
||||
* @param array $groupChanges The raw, JSON-encoded group-change items from the request.
|
||||
* @param string $groupBy The grouping type the changes apply to (time, project, priority).
|
||||
* @return array{sorted: bool, successCount: int, errorCount: int} Result of the operation.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function saveTodoSorting(int $userId, mixed $rawItems, array $groupChanges, string $groupBy): array
|
||||
{
|
||||
$successCount = 0;
|
||||
$errorCount = 0;
|
||||
|
||||
$decodedGroupChanges = [];
|
||||
foreach ($groupChanges as $value) {
|
||||
$decodedGroupChanges[] = json_decode($value, true);
|
||||
}
|
||||
|
||||
if (! empty($decodedGroupChanges)) {
|
||||
[$successCount, $errorCount] = $this->processGroupChanges($decodedGroupChanges, $groupBy);
|
||||
}
|
||||
|
||||
// The sorting payload must be an array to be persisted; group changes are
|
||||
// applied above regardless so they are never lost on a malformed payload.
|
||||
if (! is_array($rawItems)) {
|
||||
return [
|
||||
'sorted' => false,
|
||||
'successCount' => $successCount,
|
||||
'errorCount' => $errorCount,
|
||||
];
|
||||
}
|
||||
|
||||
$taskList = array_map(function ($item) {
|
||||
if (is_string($item)) {
|
||||
$task = json_decode($item, true);
|
||||
if (is_array($task) && isset($task['id'])) {
|
||||
// start sorting at 10 so we have room for new tasks at the top
|
||||
$task['order'] = $task['order'] ?? 0;
|
||||
$task['order'] += 10;
|
||||
|
||||
return $task;
|
||||
}
|
||||
}
|
||||
}, $rawItems);
|
||||
|
||||
$this->settingsService->saveSetting($this->sortingKey($userId), json_encode($taskList));
|
||||
$this->updateTicketDependencies($taskList);
|
||||
|
||||
return [
|
||||
'sorted' => true,
|
||||
'successCount' => $successCount,
|
||||
'errorCount' => $errorCount,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the collapse state of a task for a user and returns the new state.
|
||||
*
|
||||
* @param int $userId The user whose collapse state to toggle.
|
||||
* @param string $taskId The task whose collapse state to toggle.
|
||||
* @return string The new collapse state ('open' or 'closed').
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function toggleTaskCollapse(int $userId, string $taskId): string
|
||||
{
|
||||
$toggleKey = "user.{$userId}.taskCollapsed.{$taskId}";
|
||||
|
||||
$currentState = $this->settingsService->getSetting($toggleKey, 'open');
|
||||
$newState = ($currentState === 'open') ? 'closed' : 'open';
|
||||
$this->settingsService->saveSetting($toggleKey, $newState);
|
||||
|
||||
return $newState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick-adds a to-do, defaulting its due date based on the group context
|
||||
* (this week / overdue / later) when no explicit date was provided.
|
||||
*
|
||||
* @param array $params The quick-add parameters (must contain 'quickadd').
|
||||
* @return array|bool|int The result of the underlying quick-add call.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function addTodo(array $params): array|bool|int
|
||||
{
|
||||
$params['dateToFinish'] = $this->resolveQuickAddDueDate($params);
|
||||
|
||||
return $this->ticketsService->quickAddTicket($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a subtask under the given parent ticket.
|
||||
*
|
||||
* @param array $values The subtask form values.
|
||||
* @param int $parentTicketId The id of the parent ticket.
|
||||
* @return bool True on success, false otherwise.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function addSubtask(array $values, int $parentTicketId): bool
|
||||
{
|
||||
$parentTicket = $this->ticketsService->getTicket($parentTicketId);
|
||||
|
||||
return $this->ticketsService->upsertSubtask($values, $parentTicket);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the due date for a quick-added to-do based on the group context.
|
||||
*
|
||||
* If a non-empty date was already supplied it is returned unchanged. Otherwise
|
||||
* "thisWeek" maps to next Friday, "overdue" maps to today, and "later" leaves
|
||||
* the date empty.
|
||||
*
|
||||
* @param array $params The quick-add parameters.
|
||||
* @return string The resolved due date (Y-m-d) or an empty string.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function resolveQuickAddDueDate(array $params): string
|
||||
{
|
||||
$dateToFinish = $params['dateToFinish'] ?? '';
|
||||
|
||||
if ($dateToFinish !== '') {
|
||||
return $dateToFinish;
|
||||
}
|
||||
|
||||
$group = $params['group'] ?? null;
|
||||
|
||||
if ($group === 'thisWeek') {
|
||||
// Due this week - set to end of week (Friday)
|
||||
return date('Y-m-d', strtotime('next friday'));
|
||||
}
|
||||
|
||||
if ($group === 'overdue') {
|
||||
// Overdue - set to today
|
||||
return date('Y-m-d');
|
||||
}
|
||||
|
||||
// For 'later' group (or no group), leave date empty
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes group changes and updates the corresponding task fields.
|
||||
*
|
||||
* @param array $groupChanges Array of decoded group change data.
|
||||
* @param string $groupBy The grouping type (time, project, priority).
|
||||
* @return array{0: int, 1: int} The [successCount, errorCount] tuple.
|
||||
*/
|
||||
private function processGroupChanges(array $groupChanges, string $groupBy): array
|
||||
{
|
||||
$successCount = 0;
|
||||
$errorCount = 0;
|
||||
|
||||
foreach ($groupChanges as $change) {
|
||||
$taskId = $change['id'] ?? null;
|
||||
$toGroup = $change['toGroup'] ?? null;
|
||||
$fromGroup = $change['fromGroup'] ?? null;
|
||||
|
||||
// Skip invalid changes
|
||||
if (empty($taskId) || empty($toGroup)) {
|
||||
$errorCount++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate that user has permission to update this task
|
||||
if (! $this->canUserUpdateTask($taskId)) {
|
||||
Log::warning("User does not have permission to update task {$taskId}");
|
||||
$errorCount++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$fieldsToUpdate = $this->mapGroupToFields($groupBy, $toGroup);
|
||||
|
||||
if (! empty($fieldsToUpdate)) {
|
||||
try {
|
||||
$result = $this->ticketsService->patch($taskId, $fieldsToUpdate);
|
||||
|
||||
if ($result) {
|
||||
$successCount++;
|
||||
|
||||
// Log successful group change for debugging
|
||||
Log::info("Successfully moved task {$taskId} from group {$fromGroup} to {$toGroup} ({$groupBy})");
|
||||
} else {
|
||||
$errorCount++;
|
||||
Log::error("Failed to update task {$taskId} with group change to {$toGroup}");
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$errorCount++;
|
||||
Log::error("Error updating task {$taskId}: ".$e->getMessage());
|
||||
}
|
||||
} else {
|
||||
// No valid field mapping found
|
||||
Log::warning("No valid field mapping found for group {$toGroup} in groupBy {$groupBy}");
|
||||
}
|
||||
}
|
||||
|
||||
return [$successCount, $errorCount];
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a group key to field updates based on the grouping type.
|
||||
*
|
||||
* @param string $groupBy The grouping type.
|
||||
* @param string $groupKey The target group key.
|
||||
* @return array Fields to update.
|
||||
*/
|
||||
public function mapGroupToFields(string $groupBy, string $groupKey): array
|
||||
{
|
||||
switch ($groupBy) {
|
||||
case 'time':
|
||||
return $this->mapTimeGroupToFields($groupKey);
|
||||
|
||||
case 'project':
|
||||
return $this->mapProjectGroupToFields($groupKey);
|
||||
|
||||
case 'priority':
|
||||
return $this->mapPriorityGroupToFields($groupKey);
|
||||
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a time group to date fields.
|
||||
*
|
||||
* @param string $groupKey Time group key (overdue, thisWeek, later).
|
||||
* @return array Fields to update.
|
||||
*/
|
||||
private function mapTimeGroupToFields(string $groupKey): array
|
||||
{
|
||||
switch ($groupKey) {
|
||||
case 'overdue':
|
||||
// Set due date to yesterday to make it overdue
|
||||
return ['dateToFinish' => date('Y-m-d', strtotime('yesterday'))];
|
||||
|
||||
case 'thisWeek':
|
||||
// Set due date to end of current week (Friday)
|
||||
return ['dateToFinish' => date('Y-m-d', strtotime('next friday'))];
|
||||
|
||||
case 'later':
|
||||
// Clear due date for "later" group
|
||||
return ['dateToFinish' => ''];
|
||||
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a project group to the project field.
|
||||
*
|
||||
* @param string $groupKey Project ID.
|
||||
* @return array Fields to update.
|
||||
*/
|
||||
private function mapProjectGroupToFields(string $groupKey): array
|
||||
{
|
||||
// Validate that the group key is a valid project ID
|
||||
if (is_numeric($groupKey) && $groupKey > 0) {
|
||||
return ['projectId' => (int) $groupKey];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a priority group to the priority field.
|
||||
*
|
||||
* @param string $groupKey Priority value.
|
||||
* @return array Fields to update.
|
||||
*/
|
||||
private function mapPriorityGroupToFields(string $groupKey): array
|
||||
{
|
||||
// Handle priority mapping
|
||||
if ($groupKey === '999') {
|
||||
// 999 represents "undefined priority" - clear the priority
|
||||
return ['priority' => ''];
|
||||
}
|
||||
|
||||
// Validate priority is within valid range (1-4)
|
||||
if (is_numeric($groupKey) && $groupKey >= 1 && $groupKey <= 4) {
|
||||
return ['priority' => (int) $groupKey];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the current user can update a specific task.
|
||||
*
|
||||
* @param int $taskId The task ID to check.
|
||||
* @return bool True if the user can update, false otherwise.
|
||||
*/
|
||||
private function canUserUpdateTask(int $taskId): bool
|
||||
{
|
||||
try {
|
||||
// Attempt to get the ticket - this will return false if user doesn't have access
|
||||
$ticket = $this->ticketsService->getTicket($taskId);
|
||||
|
||||
if (! $ticket) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If user can view the ticket, they can update it
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Permission check failed for task {$taskId}: ".$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates ticket dependencies based on the sorting hierarchy.
|
||||
*
|
||||
* @param array $sorting The sorting data with parent-child relationships.
|
||||
*/
|
||||
public function updateTicketDependencies(array $sorting): void
|
||||
{
|
||||
// Create a map of ticket IDs to their parent IDs
|
||||
$parentMap = [];
|
||||
foreach ($sorting as $item) {
|
||||
if (isset($item['id']) && isset($item['parentId']) && $item['parentId'] !== null) {
|
||||
$parentMap[$item['id']]['parentId'] = $item['parentId'];
|
||||
$parentMap[$item['id']]['parentType'] = $item['parentType'];
|
||||
} elseif (isset($item['id'])) {
|
||||
// If no parent, ensure we clear any existing dependency
|
||||
$parentMap[$item['id']]['parentId'] = 0;
|
||||
$parentMap[$item['id']]['parentType'] = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Update each ticket's dependencies
|
||||
foreach ($parentMap as $ticketId => $parent) {
|
||||
// Skip if the parent is the same as the ticket (prevent self-reference)
|
||||
if ($ticketId == $parent['parentId']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parentId = $parent['parentId'];
|
||||
|
||||
// For tickets with parents, set the dependingTicketId
|
||||
if ($parentId > 0) {
|
||||
$this->ticketsService->patch($ticketId, [
|
||||
'dependingTicketId' => $parentId,
|
||||
]);
|
||||
} else {
|
||||
// For tickets without parents, clear the dependingTicketId
|
||||
$this->ticketsService->patch($ticketId, [
|
||||
'dependingTicketId' => '',
|
||||
'milestoneid' => '',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the per-user settings key for the stored to-do sorting.
|
||||
*
|
||||
* @param int $userId The user the sorting belongs to.
|
||||
* @return string The settings key.
|
||||
*/
|
||||
private function sortingKey(int $userId): string
|
||||
{
|
||||
return "user.{$userId}.myTodosSorting";
|
||||
}
|
||||
}
|
||||
358
app/Domain/Widgets/Services/Widgets.php
Normal file
358
app/Domain/Widgets/Services/Widgets.php
Normal file
@@ -0,0 +1,358 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Domain\Widgets\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use Leantime\Domain\Projects\Services\Projects as ProjectService;
|
||||
use Leantime\Domain\Reports\Services\Reports as ReportService;
|
||||
use Leantime\Domain\Setting\Repositories\Setting;
|
||||
use Leantime\Domain\Users\Services\Users;
|
||||
use Leantime\Domain\Widgets\Models\Widget;
|
||||
|
||||
class Widgets
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
/**
|
||||
* @api
|
||||
*/
|
||||
public array $availableWidgets = [];
|
||||
|
||||
/**
|
||||
* @api
|
||||
*/
|
||||
public array $defaultWidgets = [];
|
||||
|
||||
/**
|
||||
* @api
|
||||
*/
|
||||
public Setting $settingRepo;
|
||||
|
||||
private const WIDGET_HISTORY_KEY = 'usersettings.%d.widgetHistory';
|
||||
|
||||
private const ACTIVE_WIDGETS_KEY = 'usersettings.%d.dashboardGrid';
|
||||
|
||||
/**
|
||||
* __construct method.
|
||||
*
|
||||
* Initializes the object and sets the available widgets and default widgets.
|
||||
*
|
||||
* @param Setting $settingRepo The Setting repository object
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
Setting $settingRepo,
|
||||
protected ProjectService $projectService,
|
||||
protected ReportService $reportService
|
||||
) {
|
||||
|
||||
$this->settingRepo = $settingRepo;
|
||||
|
||||
$this->availableWidgets['welcome'] = app()->make("Leantime\Domain\Widgets\Models\Widget", [
|
||||
'id' => 'welcome',
|
||||
'name' => 'widgets.title.welcome',
|
||||
'description' => 'widgets.descriptions.welcome',
|
||||
'widgetUrl' => BASE_URL.'/widgets/welcome/get',
|
||||
'gridHeight' => 7,
|
||||
'gridWidth' => 12,
|
||||
'gridMinHeight' => 7,
|
||||
'gridMinWidth' => 6,
|
||||
'gridX' => 0,
|
||||
'gridY' => 0,
|
||||
'widgetBackground' => '',
|
||||
'widgetTrigger' => 'load, every 5m',
|
||||
'alwaysVisible' => true,
|
||||
'noTitle' => true,
|
||||
'fixed' => true,
|
||||
]);
|
||||
|
||||
$this->availableWidgets['todos'] = app()->make("Leantime\Domain\Widgets\Models\Widget", [
|
||||
'id' => 'todos',
|
||||
'name' => 'widgets.title.my_todos',
|
||||
'description' => 'widgets.descriptions.my_todos',
|
||||
'widgetUrl' => BASE_URL.'/widgets/myToDos/get',
|
||||
'gridHeight' => 30,
|
||||
'gridWidth' => 8,
|
||||
'gridMinHeight' => 16,
|
||||
'gridMinWidth' => 3,
|
||||
'gridX' => 0,
|
||||
'gridY' => 7,
|
||||
'alwaysVisible' => false,
|
||||
'noTitle' => false,
|
||||
'fixed' => false,
|
||||
|
||||
]);
|
||||
|
||||
$this->availableWidgets['calendar'] = app()->make("Leantime\Domain\Widgets\Models\Widget", [
|
||||
'id' => 'calendar',
|
||||
'name' => 'widgets.title.calendar',
|
||||
'description' => 'widgets.descriptions.calendar',
|
||||
'gridHeight' => 30,
|
||||
'gridWidth' => 4,
|
||||
'gridMinHeight' => 12,
|
||||
'gridMinWidth' => 3,
|
||||
'gridX' => 8,
|
||||
'gridY' => 7,
|
||||
'alwaysVisible' => false,
|
||||
'noTitle' => false,
|
||||
'widgetUrl' => BASE_URL.'/widgets/calendar/get',
|
||||
'fixed' => false,
|
||||
]);
|
||||
|
||||
$this->availableWidgets['myprojects'] = app()->make("Leantime\Domain\Widgets\Models\Widget", [
|
||||
'id' => 'myprojects',
|
||||
'name' => 'widgets.title.my_projects',
|
||||
'description' => 'widgets.descriptions.my_projects',
|
||||
'gridHeight' => 22,
|
||||
'gridWidth' => 8,
|
||||
'gridMinHeight' => 10,
|
||||
'gridMinWidth' => 2,
|
||||
'gridX' => 0,
|
||||
'gridY' => 43,
|
||||
'alwaysVisible' => false,
|
||||
'noTitle' => false,
|
||||
'widgetUrl' => BASE_URL.'/widgets/myProjects/get',
|
||||
'fixed' => false,
|
||||
]);
|
||||
|
||||
$this->defaultWidgets = [
|
||||
'welcome' => $this->availableWidgets['welcome'],
|
||||
'calendar' => $this->availableWidgets['calendar'],
|
||||
'todos' => $this->availableWidgets['todos'],
|
||||
];
|
||||
|
||||
$this->availableWidgets = self::dispatch_filter('availableWidgets', $this->availableWidgets);
|
||||
$this->defaultWidgets = self::dispatch_filter('defaultWidgets', $this->defaultWidgets, ['availableWidgets' => $this->availableWidgets]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new widget.
|
||||
*
|
||||
* @param Widget $widget The widget to register.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function registerWidget(Widget $widget): void
|
||||
{
|
||||
$this->availableWidgets[$widget->id] = $widget;
|
||||
|
||||
// Update widget history for all users
|
||||
$users = app()->make(Users::class)->getAll(true);
|
||||
foreach ($users as $user) {
|
||||
$widgetHistory = $this->getWidgetHistory($user['id']);
|
||||
// Don't add to history during registration - let users discover it
|
||||
if (! isset($widgetHistory[$widget->id])) {
|
||||
$widget->isNew = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all available widgets.
|
||||
*
|
||||
* @return array The array of available widgets.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function getAll(): array
|
||||
{
|
||||
return DispatchesEvents::dispatch_filter('availableWidgets', $this->availableWidgets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the active widgets for a specific user.
|
||||
*
|
||||
* @param int $userId The ID of the user.
|
||||
* @return array The array of active widgets.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function getActiveWidgets(int $userId): array
|
||||
{
|
||||
|
||||
$activeWidgetKey = sprintf(self::ACTIVE_WIDGETS_KEY, $userId);
|
||||
|
||||
if (Cache::has($activeWidgetKey) && is_array(Cache::get($activeWidgetKey)) && count(Cache::get($activeWidgetKey)) > 0) {
|
||||
return Cache::get($activeWidgetKey);
|
||||
}
|
||||
|
||||
$activeWidgets = $this->settingRepo->getSetting($activeWidgetKey);
|
||||
$widgetHistory = $this->getWidgetHistory($userId);
|
||||
$widgets = $this->defaultWidgets;
|
||||
|
||||
if ($activeWidgets && $activeWidgets != '') {
|
||||
$unserializedData = safe_unserialize($activeWidgets, []);
|
||||
|
||||
$widgets = [];
|
||||
foreach ($unserializedData as $key => $widget) {
|
||||
|
||||
if (isset($widget['id']) === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this widget exists in available widgets but not in user's stored widgets
|
||||
if (isset($this->availableWidgets[$widget['id']]) && ! isset($widgetHistory[$widget['id']])) {
|
||||
$widget['isNew'] = true;
|
||||
}
|
||||
|
||||
if (isset($this->availableWidgets[$widget['id']])) {
|
||||
$widget['name'] = $this->availableWidgets[$widget['id']]->name;
|
||||
$widget['widgetUrl'] = $this->availableWidgets[$widget['id']]->widgetUrl;
|
||||
$widget['widgetBackground'] = $this->availableWidgets[$widget['id']]->widgetBackground;
|
||||
$widget['description'] = $this->availableWidgets[$widget['id']]->description;
|
||||
$widget['widgetTrigger'] = $this->availableWidgets[$widget['id']]->widgetTrigger;
|
||||
$widget['alwaysVisible'] = $this->availableWidgets[$widget['id']]->alwaysVisible;
|
||||
$widget['gridMinWidth'] = $this->availableWidgets[$widget['id']]->gridMinWidth;
|
||||
$widget['gridMinHeight'] = $this->availableWidgets[$widget['id']]->gridMinHeight;
|
||||
$widget['noTitle'] = $this->availableWidgets[$widget['id']]->noTitle;
|
||||
$widget['fixed'] = $this->availableWidgets[$widget['id']]->fixed;
|
||||
$widgets[$widget['id']] = app()->make(Widget::class, $widget);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort Widgets
|
||||
$widgets = array_sort($widgets, [['gridY', 'asc'], ['gridX', 'asc']]);
|
||||
|
||||
Cache::set($activeWidgetKey, $widgets, new \DateInterval('P30D'));
|
||||
|
||||
return $widgets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the dashboard grid for a specific user.
|
||||
*
|
||||
* @param int $userId The ID of the user for whom the dashboard grid needs to be reset.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function resetDashboard(int $userId): void
|
||||
{
|
||||
|
||||
$activeWidgetKey = sprintf(self::ACTIVE_WIDGETS_KEY, $userId);
|
||||
|
||||
Cache::forget($activeWidgetKey);
|
||||
$this->settingRepo->deleteSetting($activeWidgetKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get new widgets for the user.
|
||||
*
|
||||
* @param int $userId The ID of the user.
|
||||
* @return array An array of new widgets.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function getNewWidgets(int $userId): array
|
||||
{
|
||||
$availableWidgets = $this->getAll();
|
||||
$widgetHistory = $this->getWidgetHistory($userId);
|
||||
$activeWidgets = $this->getActiveWidgets($userId);
|
||||
|
||||
$newWidgets = [];
|
||||
|
||||
foreach ($availableWidgets as $widgetId => $widget) {
|
||||
if (! isset($widgetHistory[$widgetId]) && ! isset($activeWidgets[$widgetId])) {
|
||||
$widget->isNew = true;
|
||||
$newWidgets[$widgetId] = $widget;
|
||||
}
|
||||
}
|
||||
|
||||
return $newWidgets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get widget history for a user
|
||||
*/
|
||||
private function getWidgetHistory(int $userId): array
|
||||
{
|
||||
$historyKey = sprintf(self::WIDGET_HISTORY_KEY, $userId);
|
||||
$history = $this->settingRepo->getSetting($historyKey);
|
||||
|
||||
return $history ? safe_unserialize($history, []) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a widget as seen by a user
|
||||
*/
|
||||
public function markWidgetAsSeen(int $userId, string $widgetId): void
|
||||
{
|
||||
$historyKey = sprintf(self::WIDGET_HISTORY_KEY, $userId);
|
||||
$history = $this->getWidgetHistory($userId);
|
||||
$history[$widgetId] = time();
|
||||
$this->settingRepo->saveSetting($historyKey, serialize($history));
|
||||
}
|
||||
|
||||
public function saveGrid($data, $userId)
|
||||
{
|
||||
$activeWidgetKey = sprintf(self::ACTIVE_WIDGETS_KEY, $userId);
|
||||
Cache::forget($activeWidgetKey);
|
||||
$this->settingRepo->saveSetting($activeWidgetKey,
|
||||
serialize($data)
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the dashboard grid for a user and, when visibility data marks the
|
||||
* widget as visible, records it in the user's widget history.
|
||||
*
|
||||
* @param mixed $data The grid layout data to persist.
|
||||
* @param int $userId The user whose grid is being saved.
|
||||
* @param array|null $visibilityData Optional ['visible' => bool, 'widgetId' => string] payload.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function saveGridForUser($data, int $userId, ?array $visibilityData = null): void
|
||||
{
|
||||
$this->saveGrid($data, $userId);
|
||||
|
||||
if ($visibilityData !== null && ! empty($visibilityData['visible'])) {
|
||||
$this->markWidgetAsSeen($userId, $visibilityData['widgetId']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the data for the "My Projects" dashboard widget: the open projects
|
||||
* assigned to the user, each enriched with its progress and realtime report,
|
||||
* plus a deduplicated client map.
|
||||
*
|
||||
* @param int $userId The user whose assigned projects to load
|
||||
* @param string $clientFilter Optional client id to filter projects by ('' = all clients)
|
||||
* @return array{projects: array<int, array<string, mixed>>, clients: array<int|string, string>}
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function getMyProjectsWidgetData(int $userId, string $clientFilter = ''): array
|
||||
{
|
||||
$assignedProjects = $this->projectService->getProjectsAssignedToUser($userId, 'open');
|
||||
|
||||
$clients = [];
|
||||
$projects = [];
|
||||
|
||||
if (! is_array($assignedProjects)) {
|
||||
return ['projects' => $projects, 'clients' => $clients];
|
||||
}
|
||||
|
||||
foreach ($assignedProjects as $project) {
|
||||
// Build the client map from every assigned project, regardless of filter.
|
||||
if (! array_key_exists($project['clientId'], $clients)) {
|
||||
$clients[$project['clientId']] = $project['clientName'];
|
||||
}
|
||||
|
||||
if ($clientFilter !== '' && $project['clientId'] != $clientFilter) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$project['progress'] = $this->projectService->getProjectProgress($project['id']);
|
||||
$project['report'] = $this->reportService->getRealtimeReport($project['id'], '');
|
||||
|
||||
$projects[] = $project;
|
||||
}
|
||||
|
||||
return ['projects' => $projects, 'clients' => $clients];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<div class="grid-stack-item" {{ $attributes }}>
|
||||
<div class="grid-stack-item-content {{ ($background == "default") ? "maincontentinner" : $background }} tw-p-none">
|
||||
<div class="tw-flex tw-flex-col tw-h-full {{ ($background == "default") ? "tw-pb-l" : "" }}">
|
||||
@if(empty($fixed))
|
||||
<div class="stickyHeader" style="padding:15px; height:50px; width:100%;">
|
||||
|
||||
<div class="grid-handler-top tw-h-[30px] tw-cursor-grab tw-float-left tw-mr-sm">
|
||||
<i class="fa-solid fa-grip-vertical"></i>
|
||||
</div>
|
||||
|
||||
@if($name != '' && $noTitle == false)
|
||||
<h5 class="subtitle tw-pb-m tw-float-left tw-mr-sm" style="margin-top:-5px;">{{ __($name) }}</h5>
|
||||
@endif
|
||||
<div class="inlineDropDownContainer tw-float-right">
|
||||
<a href="javascript:void(0);" class="dropdown-toggle ticketDropDown editHeadline" data-toggle="dropdown">
|
||||
<i class="fa fa-ellipsis-v" aria-hidden="true"></i>
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="javascript:void(0)" class="fitContent"><i class="fa-solid fa-up-right-and-down-left-from-center"></i> Resize to fit content</a></li>
|
||||
@if(empty($alwaysVisible))
|
||||
<li><a href="javascript:void(0)" class="removeWidget"><i class="fa fa-eye-slash"></i> Hide</a></li>
|
||||
@endif
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
<span class="clearall"></span>
|
||||
<div class="widgetContent {{ ($background == "default") ? 'tw-px-m' : '' }}">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="clear"></div>
|
||||
</div>
|
||||
</div>
|
||||
124
app/Domain/Widgets/Templates/partials/calendar.blade.php
Normal file
124
app/Domain/Widgets/Templates/partials/calendar.blade.php
Normal file
@@ -0,0 +1,124 @@
|
||||
@props([
|
||||
'includeTitle' => true,
|
||||
'calendar' => [],
|
||||
])
|
||||
|
||||
@dispatchEvent('beforeCalendar')
|
||||
|
||||
|
||||
<div class="clear minCalendar" style="position:absolute; top:10px; right:35px;">
|
||||
|
||||
<button class="btn btn-link btn-round-icon dropdown-toggle f-right" type="button" data-tippy-content="{{ __('text.calendar_view') }}"
|
||||
data-toggle="dropdown"> <i class="fa-solid fa-calendar-week"></i></button>
|
||||
<ul class="dropdown-menu pull-right">
|
||||
<li>
|
||||
<a class="fc-agendaDay-button fc-button fc-state-default fc-corner-right calendarViewSelect" href="javascript:void(0);"
|
||||
data-value="multiMonthOneMonth"
|
||||
@if($tpl->getToggleState("dashboardCalendarView") == 'multiMonthOneMonth') selected='selected' @endif>Month</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="fc-timeGridWeek-button fc-button fc-state-default fc-corner-right calendarViewSelect" href="javascript:void(0);"
|
||||
data-value="timeGridWeek" @if($tpl->getToggleState("dashboardCalendarView") == 'timeGridWeek') selected='selected' @endif>Week</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="fc-agendaWeek-button fc-button fc-state-default calendarViewSelect" href="javascript:void(0);"
|
||||
data-value="timeGridDay" @if($tpl->getToggleState("dashboardCalendarView") == 'timeGridDay' || empty($tpl->getToggleState("dashboardCalendarView")) ) selected='selected' @endif>Day</a>
|
||||
</li>
|
||||
<li><a class="fc-agendaWeek-button fc-button fc-state-default calendarViewSelect" href="javascript:void(0);"
|
||||
data-value="listWeek" @if($tpl->getToggleState("dashboardCalendarView") == 'listWeek') selected='selected' @endif>List</a></li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="tw-h-full minCalendar">
|
||||
<div class="clear"></div>
|
||||
<div class="fc-toolbar tw-z-10">
|
||||
<div class="fc-left tw-flex">
|
||||
<div class="day-selector tw-w-full tw-flex tw-gap-2 tw-mb-4 tw-justify-between"
|
||||
@php
|
||||
$currentView = $tpl->getToggleState("dashboardCalendarView") ?: 'timeGridDay';
|
||||
@endphp
|
||||
@if ($currentView !== 'timeGridDay') style="display:none" @endif>
|
||||
@php
|
||||
$today = dtHelper()->userNow();
|
||||
$startOfWeek = dtHelper()->userNow()->startOf("week");
|
||||
$week = [];
|
||||
for($i = 0; $i < 7; $i++) {
|
||||
$date = $startOfWeek->modify("+$i days");
|
||||
$week[] = $date;
|
||||
}
|
||||
@endphp
|
||||
@foreach($week as $day)
|
||||
<button class="day-button tw-rounded-md tw-w-12 tw-h-12 tw-flex tw-flex-col tw-items-center tw-justify-center tw-text-sm {{ $day->format('Y-m-d') === $today->format('Y-m-d') ? 'today active' : '' }}" data-date="{{ $day->format('Y-m-d') }}">
|
||||
<span class="tw-text-xs">{{ $day->format('D') }}</span>
|
||||
<span class="tw-font-medium">{{ $day->format('d') }}</span>
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
<div class="clear"></div>
|
||||
</div>
|
||||
<div class="clear"></div>
|
||||
<div class="minCalendarWrapper">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
|
||||
var eventSources = [];
|
||||
|
||||
var events = {events: [
|
||||
@foreach ($calendar as $event)
|
||||
{
|
||||
|
||||
title: {!! json_encode($event['title']) !!},
|
||||
|
||||
start: new Date({{ format($event['dateFrom'])->jsTimestamp() }}),
|
||||
@if (isset($event['dateTo']))
|
||||
end: new Date({{ format($event['dateTo'])->jsTimestamp() }}),
|
||||
@endif
|
||||
@if ((isset($event['allDay']) && $event['allDay'] === true))
|
||||
allDay: true,
|
||||
@else
|
||||
allDay: false,
|
||||
@endif
|
||||
enitityId: {{ $event['id'] }},
|
||||
@if (isset($event['eventType']) && $event['eventType'] == 'calendar')
|
||||
url: '#/calendar/editEvent/{{ $event['id'] }}',
|
||||
backgroundColor: '{{ $event['backgroundColor'] ?? "var(--accent2)" }}',
|
||||
borderColor: '{{ $event['borderColor'] ?? "var(--accent2)" }}',
|
||||
enitityType: "event",
|
||||
@else
|
||||
url: '#/tickets/showTicket/{{ $event['id'] }}?projectId={{ $event['projectId'] }}',
|
||||
backgroundColor: '{{ $event['backgroundColor'] ?? "var(--accent2)" }}',
|
||||
borderColor: '{{ $event['borderColor'] ?? "var(--accent2)" }}',
|
||||
enitityType: "ticket",
|
||||
@endif
|
||||
},
|
||||
@endforeach
|
||||
]};
|
||||
|
||||
eventSources.push(events);
|
||||
|
||||
<?php
|
||||
$externalCalendars = $externalCalendars ?? [];
|
||||
|
||||
foreach ($externalCalendars as $externalCalendar) { ?>
|
||||
eventSources.push(
|
||||
{
|
||||
url: '<?= BASE_URL ?>/calendar/externalCal/<?= $externalCalendar['id'] ?>',
|
||||
format: 'ics',
|
||||
color: '<?= $externalCalendar['colorClass'] ?>',
|
||||
editable: false,
|
||||
}
|
||||
);
|
||||
<?php } ?>
|
||||
|
||||
var initialView = '{{ $tpl->getToggleState("dashboardCalendarView") ? $tpl->getToggleState("dashboardCalendarView") : "timeGridDay" }}';
|
||||
leantime.calendarController.initWidgetCalendar(".minCalendarWrapper", initialView)
|
||||
|
||||
|
||||
|
||||
@dispatchEvent('scripts.beforeClose')
|
||||
|
||||
</script>
|
||||
77
app/Domain/Widgets/Templates/partials/myProjects.blade.php
Normal file
77
app/Domain/Widgets/Templates/partials/myProjects.blade.php
Normal file
@@ -0,0 +1,77 @@
|
||||
@props([
|
||||
'includeTitle' => true,
|
||||
'allProjects' => [],
|
||||
'background' => ''
|
||||
])
|
||||
|
||||
<div id="myProjectsWidget"
|
||||
hx-get="{{BASE_URL}}/widgets/myProjects/get"
|
||||
hx-trigger="HTMX.updateProjectList from:body"
|
||||
hx-target="#myProjectsWidget"
|
||||
hx-swap="outerHTML">
|
||||
@if (count($allProjects) == 0)
|
||||
<br /><br />
|
||||
<div class='center'>
|
||||
<div style='width:70%' class='svgContainer'>
|
||||
{{ __('notifications.not_assigned_to_any_project') }}
|
||||
@if($login::userIsAtLeast($roles::$manager))
|
||||
<br /><br />
|
||||
<a href='{{ BASE_URL }}/projects/newProject' class='btn btn-primary'>{{ __('link.new_project') }}</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
<div class="clearall"></div>
|
||||
|
||||
<x-global::accordion id="myProjectWidget-favorites" class="{{ $background }}">
|
||||
<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" => $type])
|
||||
</div>
|
||||
@php
|
||||
$hasFavorites = true;
|
||||
@endphp
|
||||
@endif
|
||||
@endforeach
|
||||
@if($hasFavorites === false)
|
||||
You don't have any favorites. 😿
|
||||
@endif
|
||||
</div>
|
||||
</x-slot>
|
||||
</x-global::accordion>
|
||||
|
||||
|
||||
<x-global::accordion id="myProjectWidget-otherProjects" class="{{ $background }}">
|
||||
<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-4">
|
||||
@include("projects::partials.projectCard", ["project" => $project, "type" => $type])
|
||||
</div>
|
||||
|
||||
@endif
|
||||
|
||||
@endforeach
|
||||
</div>
|
||||
</x-slot>
|
||||
</x-global::accordion>
|
||||
|
||||
</div>
|
||||
|
||||
@dispatchEvent('afterMyProjectBox')
|
||||
441
app/Domain/Widgets/Templates/partials/myToDos.blade.php
Normal file
441
app/Domain/Widgets/Templates/partials/myToDos.blade.php
Normal file
@@ -0,0 +1,441 @@
|
||||
@props([
|
||||
'includeTitle' => true,
|
||||
'tickets' => [],
|
||||
'onTheClock' => false,
|
||||
'groupBy' => '',
|
||||
'allProjects' => [],
|
||||
'allAssignedprojects' => [],
|
||||
'projectFilter' => '',
|
||||
])
|
||||
|
||||
@php
|
||||
// Helper function to count tickets recursively
|
||||
if (!function_exists('countTicketsRecursive')) {
|
||||
function countTicketsRecursive($tickets) {
|
||||
$count = count($tickets);
|
||||
|
||||
foreach ($tickets as $ticket) {
|
||||
if (!empty($ticket['children'])) {
|
||||
$count += countTicketsRecursive($ticket['children']);
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
@endphp
|
||||
|
||||
<div id="yourToDoContainer"
|
||||
hx-get="{{BASE_URL}}/widgets/myToDos/get"
|
||||
hx-trigger="{{ \Leantime\Domain\Tickets\Htmx\HtmxTicketEvents::UPDATE }} from:body, {{ \Leantime\Domain\Tickets\Htmx\HtmxTicketEvents::SUBTASK_UPDATE }} from:body"
|
||||
class="clear"
|
||||
hx-swap="outerHTML"
|
||||
hx-ext="json-enc"
|
||||
hx-indicator="#todoWidgetLoader"
|
||||
data-group-by="{{ $groupBy }}"
|
||||
>
|
||||
|
||||
{{-- The loader lives INSIDE the swapped container: #yourToDoContainer swaps with outerHTML and
|
||||
the response re-includes this partial, so a sibling loader would accumulate a duplicate
|
||||
#todoWidgetLoader on every refresh. Inside the target, the swap replaces it cleanly. --}}
|
||||
<div id="todoWidgetLoader" class="htmx-indicator full-width-loader">
|
||||
<div class="indeterminate"></div>
|
||||
</div>
|
||||
|
||||
<div class="clear" style="position:absolute; top:10px; right:35px;">
|
||||
|
||||
@dispatchEvent("beforeTodoWidgetGroupByDropdown")
|
||||
|
||||
<div class="btn-group left">
|
||||
<button class="btn btn-link btn-round-icon dropdown-toggle f-right" type="button" data-tippy-content="{{ __('text.group_by') }}"
|
||||
data-toggle="dropdown"><span class="fa-solid fa-diagram-project"></span></button>
|
||||
<ul class="dropdown-menu pull-right">
|
||||
<li class="nav-header">{!! __("text.group_by") !!}</li>
|
||||
<li>
|
||||
<span class="radio">
|
||||
<input type="radio" name="groupBy"
|
||||
@if($groupBy == "time") checked='checked' @endif
|
||||
value="time" id="groupByDate"
|
||||
hx-get="{{BASE_URL}}/widgets/myToDos/get"
|
||||
hx-trigger="click"
|
||||
hx-target="#yourToDoContainer"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#todoWidgetLoader"
|
||||
style="margin-top:4px;"
|
||||
hx-vals='{"projectFilter": "{{ $projectFilter }}", "groupBy": "time" }'
|
||||
/>
|
||||
<label for="groupByDate">{!! __("label.dates") !!}</label>
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="radio">
|
||||
<input type="radio"
|
||||
name="groupBy"
|
||||
@if($groupBy == "project") checked='checked' @endif
|
||||
value="project" id="groupByProject"
|
||||
hx-get="{{BASE_URL}}/widgets/myToDos/get"
|
||||
hx-trigger="click"
|
||||
hx-target="#yourToDoContainer"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#todoWidgetLoader"
|
||||
style="margin-top:4px;"
|
||||
hx-vals='{"projectFilter": "{{ $projectFilter }}", "groupBy": "project" }'
|
||||
/>
|
||||
<label for="groupByProject">{!! __("label.project") !!}</label>
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="radio">
|
||||
<input type="radio"
|
||||
name="groupBy"
|
||||
@if($groupBy == "priority") checked='checked' @endif
|
||||
value="priority" id="groupByPriority"
|
||||
hx-get="{{BASE_URL}}/widgets/myToDos/get"
|
||||
hx-trigger="click"
|
||||
hx-target="#yourToDoContainer"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#todoWidgetLoader"
|
||||
style="margin-top:4px;"
|
||||
hx-vals='{"projectFilter": "{{ $projectFilter }}", "groupBy": "priority" }'
|
||||
/>
|
||||
<label for="groupByPriority">{!! __("label.priority") !!}</label>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="btn-group left ">
|
||||
<button class="btn btn-link btn-round-icon dropdown-toggle f-right" type="button" data-toggle="dropdown">
|
||||
<i class="fas fa-filter"></i>
|
||||
@if($projectFilter != '')
|
||||
<span class='badge badge-primary'>1</span>
|
||||
@endif
|
||||
</button>
|
||||
<ul class="dropdown-menu pull-right">
|
||||
<li class="nav-header">{!! __("text.filter") !!}</li>
|
||||
<li
|
||||
@if($projectFilter == '')
|
||||
class='active'
|
||||
@endif
|
||||
><a href=""
|
||||
hx-get="{{BASE_URL}}/widgets/myToDos/get"
|
||||
hx-trigger="click"
|
||||
hx-target="#yourToDoContainer"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#todoWidgetLoader"
|
||||
hx-vals='{"projectFilter": "all", "groupBy": "{{ $groupBy }}" }'
|
||||
|
||||
>{{ __('labels.all_projects') }}
|
||||
|
||||
</a></li>
|
||||
|
||||
@if($allAssignedprojects)
|
||||
@foreach($allAssignedprojects as $project)
|
||||
<li
|
||||
@if($projectFilter == $project['id'])
|
||||
class='active'
|
||||
@endif
|
||||
><a href=""
|
||||
hx-get="{{BASE_URL}}/widgets/myToDos/get"
|
||||
hx-trigger="click"
|
||||
hx-target="#yourToDoContainer"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#todoWidgetLoader"
|
||||
hx-vals='{"projectFilter": "{{ $project['id'] }}", "groupBy": "{{ $groupBy }}" }'
|
||||
>{{ $project['name'] }}</a></li>
|
||||
@endforeach
|
||||
@endif
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@dispatchEvent("afterTodoWidgetGroupByDropdown")
|
||||
|
||||
</div>
|
||||
|
||||
<div class="tw-flex tw-flex-col">
|
||||
|
||||
<div class="">
|
||||
@if($tickets !== null && count($tickets) == 0)
|
||||
|
||||
<div class='center'>
|
||||
<div style='width:30%' class='svgContainer'>
|
||||
{!! file_get_contents(ROOT . "/dist/images/svg/undraw_a_moment_to_relax_bbpa.svg") !!}
|
||||
</div>
|
||||
<br/>
|
||||
<h4>{{ __("text.no_tasks_assigned") }}</h4>
|
||||
<x-global::forms.button tag="a" link="javascript:void(0);" contentRole="link" class="add-task-button" style="margin-left:0px;" data-group="emptyGroup"><i class="fa-solid fa-circle-plus"></i> {{ __('links.add_task') }}</x-global::forms.button>
|
||||
|
||||
<div class="quickAddForm" id="quickAddForm-emptyGroup"
|
||||
style="display:none; margin-bottom:15px; padding-bottom:5px; padding-left:5px;">
|
||||
<form method="post"
|
||||
hx-post="{{ BASE_URL }}/widgets/myToDos/addTodo"
|
||||
hx-target="#yourToDoContainer"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#todoWidgetLoader">
|
||||
<div class="tw-flex tw-flex-row tw-gap-2">
|
||||
<div class="tw-flex-grow">
|
||||
<x-global::forms.text-input variant="headline" name="headline"
|
||||
style="font-size:var(--base-font-size)"
|
||||
placeholder="{{ __('input.placeholders.what_are_you_working_on') }}" />
|
||||
<input type="hidden" name="quickadd" value="true"/>
|
||||
</div>
|
||||
<div>
|
||||
<select name="projectId">
|
||||
@foreach($allAssignedprojects as $project)
|
||||
<option value="{{ $project['id'] }}"
|
||||
|
||||
{{ (session('currentProject') == $project['id'] ) ? 'selected' : '' }}
|
||||
>{{ $project["name"] }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<input type="hidden" name="milestone" value=""/>
|
||||
<input type="hidden" name="status" value="3"/>
|
||||
<input type="hidden" name="priority"
|
||||
value=""/>
|
||||
<input type="hidden" name="dateToFinish"
|
||||
value="{{ date('Y-m-d', strtotime('next friday'))}}"/>
|
||||
<x-global::forms.textarea name="description" class="description-input" style="display:none;"
|
||||
placeholder="{{ __('input.placeholders.description') }}"></x-global::forms.textarea>
|
||||
</div>
|
||||
<div>
|
||||
<x-global::forms.button tag="input" inputType="submit" :labelText="__('buttons.save')" name="create"
|
||||
contentRole="primary"/>
|
||||
<x-global::forms.button tag="a" link="javascript:void(0);" class="cancel-add-task"
|
||||
data-group="emptyGroup">{{ __('buttons.cancel') }}</x-global::forms.button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
@endif
|
||||
|
||||
@foreach ($tickets as $groupKey => $ticketGroup)
|
||||
|
||||
@php
|
||||
//Get first duedate if exist
|
||||
$firstDueDate = null;
|
||||
foreach($ticketGroup['tickets'] as $ticket) {
|
||||
if($ticket['dateToFinish'] != '0000-00-00' && $ticket['dateToFinish'] != '1969-12-31 00:00:00') {
|
||||
if($firstDueDate == null || $ticket['dateToFinish'] < $firstDueDate) {
|
||||
$firstDueDate = $ticket['dateToFinish'];
|
||||
}
|
||||
}
|
||||
}
|
||||
@endphp
|
||||
|
||||
<x-global::accordion id="ticketBox1-{{ $groupKey }}-{{ $loop->index }}">
|
||||
<x-slot name="title">
|
||||
{!! __($ticketGroup["labelName"]) !!}
|
||||
<span class="task-count" id="task-count-{{ $groupKey }}">
|
||||
({{ count($ticketGroup["tickets"]) }})
|
||||
</span>
|
||||
</x-slot>
|
||||
<x-slot name="actionlink">
|
||||
<x-global::forms.button tag="a" link="javascript:void(0);" contentRole="link" class="add-task-button" style="padding:0px; padding-left:1px; width:31px; line-height:31px; height:31px; font-weight:bold; text-align: center; font-size:var(--font-size-l);" data-group="{{ $groupKey }}">
|
||||
<i class="fa-solid fa-circle-plus"></i></x-global::forms.button>
|
||||
</x-slot>
|
||||
<x-slot name="content">
|
||||
<!-- Quick Add Form for this group -->
|
||||
<div class="quickAddForm" id="quickAddForm-{{ $groupKey }}"
|
||||
style="display:none; margin-bottom:15px; padding-bottom:5px; padding-left:5px;">
|
||||
<form method="post"
|
||||
hx-post="{{ BASE_URL }}/widgets/myToDos/addTodo"
|
||||
hx-target="#yourToDoContainer"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#todoWidgetLoader">
|
||||
<div class="tw-flex tw-flex-row tw-gap-2">
|
||||
<div class="tw-flex-grow">
|
||||
<x-global::forms.text-input variant="headline" name="headline"
|
||||
style="font-size:var(--base-font-size)"
|
||||
placeholder="{{ __('input.placeholders.what_are_you_working_on') }}" />
|
||||
<input type="hidden" name="quickadd" value="true"/>
|
||||
</div>
|
||||
<div>
|
||||
<select name="projectId">
|
||||
@foreach($allAssignedprojects as $project)
|
||||
<option value="{{ $project['id'] }}"
|
||||
|
||||
{{ (($groupBy === "project" && $project['id'] == $groupKey) || ($groupBy !== "project" && session('currentProject') == $groupKey)) ? 'selected' : '' }}
|
||||
>{{ $project["name"] }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<input type="hidden" name="milestone" value=""/>
|
||||
<input type="hidden" name="status" value="3"/>
|
||||
<input type="hidden" name="priority"
|
||||
value="{{ $groupBy === "priority" ? $groupKey : '' }}"/>
|
||||
|
||||
@php
|
||||
$dueDate = '';
|
||||
if($groupKey === 'thisWeek'){
|
||||
$dueDate = dtHelper()->userNow()->next('Friday')->formatDateForUser();
|
||||
}else if($groupKey === 'overdue'){
|
||||
$dueDate = dtHelper()->userNow()->subtract("3 days")->formatDateForUser();
|
||||
}
|
||||
@endphp
|
||||
<input type="hidden" name="dateToFinish"
|
||||
value="{{ $dueDate }}"/>
|
||||
<x-global::forms.textarea name="description" class="description-input" style="display:none;"
|
||||
placeholder="{{ __('input.placeholders.description') }}"></x-global::forms.textarea>
|
||||
</div>
|
||||
<div>
|
||||
<x-global::forms.button tag="input" inputType="submit" :labelText="__('buttons.save')" name="create"
|
||||
contentRole="primary"/>
|
||||
<x-global::forms.button tag="a" link="javascript:void(0);" class="cancel-add-task"
|
||||
data-group="{{ $groupKey }}">{{ __('buttons.cancel') }}</x-global::forms.button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="sortable-list" data-container-type="section" data-group-key="{{ $groupKey }}" style="padding-left:5px;">
|
||||
@foreach ($ticketGroup['tickets'] as $row)
|
||||
@include('widgets::partials.todoItem', ['ticket' => $row, 'statusLabels' => $statusLabels, 'onTheClock' => $onTheClock, 'tpl' => $tpl, 'level' => 0, 'groupKey' => $groupKey])
|
||||
@endforeach
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
</x-global::accordion>
|
||||
|
||||
@endforeach
|
||||
|
||||
</div>
|
||||
|
||||
@if(isset($hasMoreTickets) && $hasMoreTickets === true)
|
||||
<!-- Global Load more trigger for infinite scroll -->
|
||||
<div id="global-load-more"
|
||||
class="load-more-trigger"
|
||||
hx-get="{{ BASE_URL }}/widgets/myToDos/loadMore"
|
||||
hx-trigger="intersect once"
|
||||
hx-target="#yourToDoContainer"
|
||||
hx-swap="outerHTML"
|
||||
hx-vals='{"limit": {{ $limit }}, "groupBy": "{{ $groupBy }}", "projectFilter": "{{ $projectFilter }}"}'>
|
||||
<div class="tw-text-center tw-py-4">
|
||||
<div class="htmx-indicator">
|
||||
<div class="indeterminate"></div>
|
||||
</div>
|
||||
<div class="tw-text-sm tw-text-gray-500">
|
||||
{{ __('text.loading_more_tasks') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
|
||||
@dispatchEvent('afterTodoListWidgetBox')
|
||||
|
||||
|
||||
<script type="text/javascript">
|
||||
|
||||
@dispatchEvent('scripts.afterOpen')
|
||||
|
||||
|
||||
jQuery(document).ready(function () {
|
||||
|
||||
console.debugging = true;
|
||||
console.debug = function () {
|
||||
if (!console.debugging) return;
|
||||
console.log.apply(this, arguments);
|
||||
};
|
||||
|
||||
var sortableEnabled = {{ $tpl->dispatchFilter('todoWidgetSortableEnabled', 'true') ? 'true' : 'false' }};
|
||||
|
||||
@if(session('userdata.id') != null)
|
||||
leantime.ticketsController.initMilestoneDropdown();
|
||||
leantime.ticketsController.initStatusDropdown();
|
||||
leantime.ticketsController.initDueDateTimePickers();
|
||||
|
||||
|
||||
if(sortableEnabled) {
|
||||
|
||||
// Initialize the sortable lists for hierarchical tasks
|
||||
jQuery('.sortable-list').nestedSortable();
|
||||
|
||||
}
|
||||
|
||||
@else
|
||||
if(sortableEnabled) {
|
||||
leantime.authController.makeInputReadonly(".maincontentinner");
|
||||
}
|
||||
@endif
|
||||
});
|
||||
|
||||
// Register once: this script lives inside #yourToDoContainer, which re-swaps on
|
||||
// every ticket event, so an unguarded htmx.onLoad stacked a new handler per refresh.
|
||||
if (!window.leantime._todoSortableOnLoadRegistered) {
|
||||
window.leantime._todoSortableOnLoadRegistered = true;
|
||||
htmx.onLoad(function () {
|
||||
jQuery('.sortable-list').nestedSortable();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<script>
|
||||
|
||||
// Quick Add Task functionality
|
||||
jQuery(document).ready(function () {
|
||||
initAddTaskBtns();
|
||||
});
|
||||
|
||||
// Register once (see note above — same re-swap handler-leak applies here).
|
||||
if (!window.leantime._todoAddBtnsOnLoadRegistered) {
|
||||
window.leantime._todoAddBtnsOnLoadRegistered = true;
|
||||
htmx.onLoad(function () {
|
||||
initAddTaskBtns();
|
||||
});
|
||||
}
|
||||
|
||||
function initAddTaskBtns() {
|
||||
// Show the quick add form when the + button is clicked
|
||||
jQuery('.add-task-button').on('click', function () {
|
||||
var groupKey = jQuery(this).data('group');
|
||||
jQuery('#quickAddForm-' + groupKey).show();
|
||||
jQuery('#quickAddForm-' + groupKey + ' .main-title-input').focus();
|
||||
});
|
||||
|
||||
// Hide the quick add form when cancel is clicked
|
||||
jQuery('.cancel-add-task').on('click', function () {
|
||||
var groupKey = jQuery(this).data('group');
|
||||
jQuery('#quickAddForm-' + groupKey).hide();
|
||||
jQuery('#quickAddForm-' + groupKey + ' .main-title-input').val('');
|
||||
jQuery('#quickAddForm-' + groupKey + ' .description-input').val('');
|
||||
});
|
||||
|
||||
jQuery('.ticket-title').each(function(){
|
||||
|
||||
let currentTitle = jQuery(this);
|
||||
jQuery(this).hover(function () {
|
||||
jQuery(this).find(".edit-button").show();
|
||||
},
|
||||
function(){
|
||||
jQuery(this).find(".edit-button").hide();
|
||||
|
||||
});
|
||||
|
||||
jQuery(this).find(".edit-button").click(function() {
|
||||
currentTitle.find(".edit-button").hide();
|
||||
currentTitle.find('.title-text').hide();
|
||||
currentTitle.find('.edit-form').show();
|
||||
});
|
||||
|
||||
jQuery(this).find(".edit-form .cancel-edit-task").click(function() {
|
||||
currentTitle.find('.title-text').show();
|
||||
currentTitle.find('.edit-form').hide();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
</div>
|
||||
105
app/Domain/Widgets/Templates/partials/myToDosLoadMore.blade.php
Normal file
105
app/Domain/Widgets/Templates/partials/myToDosLoadMore.blade.php
Normal file
@@ -0,0 +1,105 @@
|
||||
@props([
|
||||
'includeTitle' => true,
|
||||
'tickets' => [],
|
||||
'onTheClock' => false,
|
||||
'groupBy' => '',
|
||||
'allProjects' => [],
|
||||
'allAssignedprojects' => [],
|
||||
'projectFilter' => '',
|
||||
'hasMoreTickets' => false,
|
||||
'nextOffset' => 0,
|
||||
'isLoadMore' => true,
|
||||
])
|
||||
|
||||
@php
|
||||
// Helper function to count tickets recursively
|
||||
if (!function_exists('countTicketsRecursive')) {
|
||||
function countTicketsRecursive($tickets) {
|
||||
$count = count($tickets);
|
||||
|
||||
foreach ($tickets as $ticket) {
|
||||
if (!empty($ticket['children'])) {
|
||||
$count += countTicketsRecursive($ticket['children']);
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
@endphp
|
||||
|
||||
<!-- Just append individual tasks to existing groups -->
|
||||
@foreach ($tickets as $groupKey => $ticketGroup)
|
||||
@if (isset($ticketGroup['tickets']) && count($ticketGroup['tickets']) > 0)
|
||||
@foreach ($ticketGroup['tickets'] as $row)
|
||||
<div class="additional-task" data-group-key="{{ $groupKey }}" style="display: none;">
|
||||
@include('widgets::partials.todoItem', ['ticket' => $row, 'statusLabels' => $statusLabels, 'onTheClock' => $onTheClock, 'tpl' => $tpl, 'level' => 0, 'groupKey' => $groupKey])
|
||||
</div>
|
||||
@endforeach
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
@if(isset($hasMoreTickets) && $hasMoreTickets === true)
|
||||
<!-- Global Load more trigger for infinite scroll -->
|
||||
<div id="global-load-more"
|
||||
class="load-more-trigger"
|
||||
hx-get="{{ BASE_URL }}/widgets/myToDos/loadMore"
|
||||
hx-trigger="intersect once"
|
||||
hx-target="#global-load-more"
|
||||
hx-swap="outerHTML"
|
||||
hx-vals='{"offset": {{ $nextOffset }}, "limit": 20, "groupBy": "{{ $groupBy }}", "projectFilter": "{{ $projectFilter }}"}'>
|
||||
<div class="tw-text-center tw-py-4">
|
||||
<div class="htmx-indicator">
|
||||
<div class="indeterminate"></div>
|
||||
</div>
|
||||
<div class="tw-text-sm tw-text-gray-500">
|
||||
{{ __('text.loading_more_tasks') }}...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<script>
|
||||
// Process additional tasks and append to existing groups
|
||||
htmx.onLoad(function(content) {
|
||||
const additionalTasks = content.querySelectorAll('.additional-task');
|
||||
|
||||
additionalTasks.forEach(function(taskDiv) {
|
||||
const groupKey = taskDiv.dataset.groupKey;
|
||||
const taskContent = taskDiv.innerHTML;
|
||||
|
||||
// Find existing group's sortable container
|
||||
const existingGroup = document.querySelector(`[id*="ticketBox1-${groupKey}-"] .sortable-list`);
|
||||
|
||||
if (existingGroup) {
|
||||
// Append task to existing group
|
||||
existingGroup.insertAdjacentHTML('beforeend', taskContent);
|
||||
|
||||
// Update counter
|
||||
const counter = document.querySelector(`#task-count-${groupKey}`);
|
||||
if (counter) {
|
||||
const currentText = counter.textContent;
|
||||
const currentCount = parseInt(currentText.match(/\d+/)[0]) || 0;
|
||||
counter.textContent = `(${currentCount + 1})`;
|
||||
}
|
||||
} else {
|
||||
// Group doesn't exist yet - this shouldn't happen with global pagination,
|
||||
// but if it does, we could create a new group here
|
||||
console.warn('Group not found for key:', groupKey);
|
||||
}
|
||||
});
|
||||
|
||||
if (additionalTasks.length > 0) {
|
||||
// Re-initialize nested sortable for new content
|
||||
jQuery('.sortable-list').nestedSortable();
|
||||
|
||||
// Re-initialize interactive elements
|
||||
leantime.ticketsController.initMilestoneDropdown();
|
||||
leantime.ticketsController.initStatusDropdown();
|
||||
leantime.ticketsController.initDueDateTimePickers();
|
||||
|
||||
// Re-initialize add task buttons
|
||||
initAddTaskBtns();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,17 @@
|
||||
<div class="ticket-submenu">
|
||||
<ul class="nav nav-pills">
|
||||
<li><a href="#/tickets/{{ $ticket["id"] }}" class=""><i class="fa-solid fa-eye"></i> {{ __("links.view_todo") }}</a></li>
|
||||
<li><a href="#/tickets/edit/{{ $ticket["id"] }}" class=""><i class="fa-solid fa-pencil"></i> {{ __("links.edit_todo") }}</a></li>
|
||||
<li><a href="#/tickets/delete/{{ $ticket["id"] }}" class=""><i class="fa-solid fa-trash"></i> {{ __("links.delete_todo") }}</a></li>
|
||||
@dispatchEvent("beforeMoveTicket", ["ticket"=>$ticket])
|
||||
<li><a href="#/tickets/moveTicket/{{ $ticket["id"] }}" class=""><i class="fa-solid fa-arrow-right-arrow-left"></i> {{ __("links.move_todo") }}</a></li>
|
||||
@if($allowSubtaskCreation)
|
||||
<li><a href="javascript:void(0);"
|
||||
onclick="jQuery('#subtask-form-{{$ticket['id']}}').slideToggle();"
|
||||
class="add-subtask-link">
|
||||
<i class="fa-solid fa-diagram-predecessor"></i>
|
||||
Add Subtask
|
||||
</a></li>
|
||||
@endif
|
||||
</ul>
|
||||
</div>
|
||||
311
app/Domain/Widgets/Templates/partials/todoItem.blade.php
Normal file
311
app/Domain/Widgets/Templates/partials/todoItem.blade.php
Normal file
@@ -0,0 +1,311 @@
|
||||
@props([
|
||||
'ticket',
|
||||
'statusLabels',
|
||||
'onTheClock',
|
||||
'tpl',
|
||||
'level' => 0
|
||||
])
|
||||
|
||||
@php
|
||||
$ticketDataJson = json_encode([
|
||||
'id' => $ticket['id'] ,
|
||||
'title' => $ticket['headline'],
|
||||
'color' => 'var(--accent2)',
|
||||
'enitityType' => 'ticket',
|
||||
'url' => BASE_URL.'#/tickets/showTicket/'.$ticket['id'],
|
||||
], JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_TAG | JSON_HEX_AMP);
|
||||
|
||||
$hasChildren = !empty($ticket['children']);
|
||||
@endphp
|
||||
|
||||
<div class="sortable-item draggable-todo"
|
||||
id="ticket_{{ $groupKey.$ticket['id'] }}"
|
||||
data-item-type="{{ $ticket['type'] === 'milestone' ? 'milestone' : ($ticket['type'] === 'subtask' ? 'subtask' : 'task') }}"
|
||||
data-id="{{ $ticket['id'] }}"
|
||||
data-project="{{ $ticket['projectId'] }}"
|
||||
data-draggable="true"
|
||||
data-sort-index="{{ $ticket['sortIndex'] ?? 10 }}"
|
||||
data-event='{!! $ticketDataJson !!}'>
|
||||
|
||||
@php
|
||||
$accordionId = 'task-children-'.$groupKey.$ticket['id'];
|
||||
$accordionState = $tpl->getToggleState($tpl->getToggleState("accordion_content-".$accordionId) === 'closed' ? 'closed' : 'open');
|
||||
@endphp
|
||||
|
||||
<div
|
||||
class="tw-relative ticketBox {{ $ticket['type'] === 'milestone' ? 'milestone-box priority-border- ' : 'priority-border-'.$ticket['priority'] }} {{ $hasChildren ? 'has-children' : '' }}"
|
||||
data-val="{{ $ticket['id'] }}"
|
||||
data-event='{!! $ticketDataJson !!}'
|
||||
@if($ticket['type'] === 'milestone')
|
||||
style="background: var(--secondary-background) linear-gradient(135deg, {{ $ticket['tags'] }} 0%, var(--accent1) 100%); background-repeat: no-repeat; background-size: 100% 5px; background-position: bottom;"
|
||||
@endif
|
||||
>
|
||||
<div class="tw-absolute full-width-loader htmx-indicator-ticket-{{$ticket['id']}}">
|
||||
<div class="indeterminate"></div>
|
||||
</div>
|
||||
|
||||
@if($hasChildren)
|
||||
<div id="accordion_toggle_{{$accordionId }}"
|
||||
class="task-collapse-toggle accordion-toggle {{ $accordionState }}"
|
||||
onclick="leantime.snippets.accordionToggle('{{ $accordionId}}');"
|
||||
>
|
||||
<i class="fa fa-angle-{{ $accordionState == 'closed' ? 'right' : 'down' }}"></i>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($ticket['type'] == 'milestone')
|
||||
<div class="tw-flex tw-flex-row tw-items-center tw-gap-4">
|
||||
<div class="tw-flex-grow">
|
||||
<small style="display:inline-block; ">{{ $ticket['projectName'] }}</small>
|
||||
<h4><a href="#/tickets/editMilestone/{{ $ticket['id'] }}"
|
||||
style="font-size:var(--font-size-l);">{{ $ticket['headline'] }}</a></h4>
|
||||
|
||||
</div>
|
||||
<div class="tw-flex-grow">
|
||||
<div hx-trigger="load"
|
||||
hx-indicator=".htmx-indicator-ticket-{{ $ticket['id'] }}"
|
||||
hx-get="<?= BASE_URL ?>/hx/tickets/milestones/progress?milestoneId=<?= $ticket['id'] ?>&progressColor={{ trim($ticket['tags'], "#") }}">
|
||||
<div class="htmx-indicator">
|
||||
<?= $tpl->__('label.loading_milestone') ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="tw-flex tw-flex-row">
|
||||
<div class="tw-content-center">
|
||||
<div class="tw-content-center tw-mr-[10px]">
|
||||
@include('tickets::partials.timerButton', ['parentTicketId' => $ticket['id'], 'onTheClock' => $onTheClock])
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tw-flex-1 ticket-title ticket-title-wrapper">
|
||||
<div class="title-text">
|
||||
<small style="display:inline-block; ">{{ $ticket['projectName'] }}</small> <br/>
|
||||
<strong><a href="#/tickets/showTicket/{{ $ticket['id'] }}" preload="mouseover"
|
||||
class="ticket-headline-{{ $ticket['id'] }}">{{ $ticket['headline'] }}</a></strong>
|
||||
<a href="javascript:void(0);" class="tw-hidden edit-button"
|
||||
data-tippy-content="{{ __('text.edit_task_headline') }}"><i class="fa fa-edit"></i></a>
|
||||
|
||||
</div>
|
||||
<div class="tw-hidden edit-form">
|
||||
<form class="tw-flex tw-flex-row tw-items-center tw-gap-2"
|
||||
hx-post="{{ BASE_URL }}/hx/widgets/myToDos/updateTitle"
|
||||
hx-target=".ticket-headline-{{ $ticket['id'] }}"
|
||||
onsubmit="jQuery(this).closest('.edit-form').find('.cancel-edit-task').click();"
|
||||
>
|
||||
<input type="hidden" name="id" value="{{ $ticket['id'] }}"/>
|
||||
<div>
|
||||
<x-global::forms.text-input variant="headline"
|
||||
style="font-size:var(--base-font-size); margin-bottom:0px"
|
||||
value="{{ $ticket['headline'] }}" name="headline" />
|
||||
</div>
|
||||
<div>
|
||||
<x-global::forms.button inputType="submit" name="edit" contentRole="primary">
|
||||
<i class="fa fa-check"></i>
|
||||
</x-global::forms.button>
|
||||
</div>
|
||||
<div>
|
||||
<x-global::forms.button tag="a" link="javascript:void(0);" class="cancel-edit-task" data-group="{{ $groupKey }}"><i
|
||||
class="fa fa-x"></i></x-global::forms.button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@dispatchEvent('beforePlaceholder', ['ticket' => (object)$ticket])
|
||||
<div class="placeholder-container tw-flex-1 tw-flex tw-flex-row tw-content-center">
|
||||
@dispatchEvent('placeholderContainer', ['ticket' => (object)$ticket])
|
||||
</div>
|
||||
|
||||
@dispatchEvent('beforeDueDate', ['ticket' => (object)$ticket])
|
||||
<div
|
||||
class="due-date-container tw-flex-1 tw-justify-right tw-flex tw-flex-row tw-justify-end tw-content-center due-date-wrapper">
|
||||
<div class="tw-content-center">
|
||||
<div class="date-picker-form-control">
|
||||
<i class="fa-solid fa-business-time infoIcon"
|
||||
data-tippy-content="{{ __("label.due") }}"></i>
|
||||
|
||||
<input id="due-date-picker-{{ $ticket['id'] }}"
|
||||
type="text"
|
||||
title="{{ __("label.due") }}"
|
||||
value="{{ format($ticket['dateToFinish'])->date(__("text.anytime")) }}"
|
||||
class="duedates secretInput"
|
||||
style="margin-left:0px; width:100px;"
|
||||
data-id="{{ $ticket['id'] }}"
|
||||
onchange="jQuery('#due-date-picker-trigger-{{ $ticket['id'] }}').text(this.value);"
|
||||
name="date"
|
||||
hx-post="{{ BASE_URL }}/widgets/myToDos/updateDueDate"
|
||||
hx-trigger="change"
|
||||
hx-vals='{"id": "{{ $ticket['id'] }}"}'
|
||||
hx-indicator=".htmx-indicator-ticket-{{ $ticket['id'] }}"/>
|
||||
<button class="reset-button"
|
||||
data-id="{{ $ticket['id'] }}"
|
||||
id="reset-date-{{ $ticket['id'] }}"
|
||||
hx-post="{{ BASE_URL }}/widgets/myToDos/updateDueDate"
|
||||
hx-vals='{"id": "{{ $ticket['id'] }}", "date": ""}'
|
||||
hx-indicator=".htmx-indicator-ticket-{{ $ticket['id'] }}">
|
||||
<span class="sr-only">{{ __("language.resetDate") }}</span>
|
||||
<i class="fa fa-close"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tw-content-center">
|
||||
@dispatchEvent('afterDueDate', ['ticket' => (object)$ticket])
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@dispatchEvent('beforeStatusUpdate')
|
||||
<div
|
||||
class="status-container tw-flex-1 tw-justify-items-end tw-flex tw-flex-row tw-justify-end tw-gap-2 tw-content-center">
|
||||
<div class="tw-content-center tw-mr-[10px] dropdown ticketDropdown statusDropdown colorized show">
|
||||
<a class="dropdown-toggle f-left status {{ $statusLabels[$ticket['projectId']][$ticket['status']]["class"] ?? 'label-default' }}"
|
||||
href="javascript:void(0);"
|
||||
role="button"
|
||||
id="statusDropdownMenuLink{{ $ticket['id'] }}"
|
||||
data-toggle="dropdown"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false">
|
||||
<span class="text">
|
||||
@if(isset($statusLabels[$ticket['projectId']][$ticket['status']]))
|
||||
{{ $statusLabels[$ticket['projectId']][$ticket['status']]["name"] }}
|
||||
@else
|
||||
unknown
|
||||
@endif
|
||||
</span>
|
||||
<i class="fa fa-caret-down" aria-hidden="true"></i>
|
||||
</a>
|
||||
<ul class="dropdown-menu pull-right"
|
||||
aria-labelledby="statusDropdownMenuLink{{ $ticket['id'] }}">
|
||||
<li class="nav-header border">{{ __("dropdown.choose_status") }}</li>
|
||||
@foreach ($statusLabels[$ticket['projectId']] as $key => $label)
|
||||
<li class='dropdown-item'>
|
||||
<a href='javascript:void(0);'
|
||||
class='{{ $label["class"] }}'
|
||||
data-label='{{ $label["name"] }}'
|
||||
data-value='{{ $ticket['id'] }}_{{ $key }}_{{ $label["class"] }}'
|
||||
id='ticketStatusChange{{$ticket['id'] . $key }}'
|
||||
hx-post="{{ BASE_URL }}/widgets/myToDos/updateStatus"
|
||||
hx-swap="none"
|
||||
hx-vals='{"id": "{{ $ticket['id'] }}", "status": "{{ $key }}"}'>
|
||||
{{ $label["name"] }}
|
||||
</a>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="tw-content-center">
|
||||
<div class="scheduler">
|
||||
@if( $ticket['editFrom'] != "0000-00-00 00:00:00" && $ticket['editFrom'] != "1969-12-31 00:00:00")
|
||||
<i class="fa-solid fa-calendar-check infoIcon" style="color:var(--accent2)"
|
||||
data-tippy-content="{{ __('text.schedule_to_start_on') }} {{ format($ticket['editFrom'])->date() }}"></i>
|
||||
@else
|
||||
<i class="fa-regular fa-calendar-xmark infoIcon"
|
||||
data-tippy-content="{{ __('text.not_scheduled_drag_ai') }}"></i>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="tw-content-center">
|
||||
@include("tickets::partials.ticketsubmenu", ["ticket" => $ticket, "onTheClock" => $onTheClock, "allowSubtaskCreation" => true])
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Subtask Form -->
|
||||
<div id="subtask-form-{{$ticket['id']}}" class="subtask-form ticketBox"
|
||||
style="display:none; margin:10px; margin-left:40px;">
|
||||
<form class="form-group"
|
||||
hx-post="{{ BASE_URL }}/widgets/myToDos/addSubtask?ticketId={{$ticket['id']}}"
|
||||
hx-target="#yourToDoContainer"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator=".htmx-indicator-ticket-{{ $ticket['id'] }}">
|
||||
<input type="hidden" value="new" name="subtaskId"/>
|
||||
<input type="hidden" value="1" name="subtaskSave"/>
|
||||
<input type="hidden" value="{{ format($ticket['dateToFinish'])->date() }}" name="dateToFinish"/>
|
||||
<div class="tw-flex tw-flex-row tw-gap-2">
|
||||
<div class="tw-flex-grow">
|
||||
<x-global::forms.text-input name="headline" variant="headline"
|
||||
style="font-size:var(--base-font-size)"
|
||||
placeholder="{{ __('input.placeholders.what_are_you_working_on') }}" />
|
||||
</div>
|
||||
<div>
|
||||
<input type="hidden" name="status" value="3"/>
|
||||
<x-global::forms.button inputType="submit" contentRole="primary">{{ __('buttons.save') }}</x-global::forms.button>
|
||||
<x-global::forms.button tag="a" link="javascript:void(0);"
|
||||
onclick="jQuery('#subtask-form-{{$ticket['id']}}').toggle();">{{ __('buttons.cancel') }}</x-global::forms.button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- End Subtask Form -->
|
||||
|
||||
<div id="accordion_content-{{ $accordionId }}"
|
||||
style="{{ $accordionState =='closed' ? 'display:none;' : '' }}"
|
||||
class="sortable-list task-children {{ $tpl->getToggleState("user.".session('userdata.id').".taskCollapsed.".$ticket['id'], 'open') }}"
|
||||
data-container-type="{{ $ticket['type'] == 'milestone' ? 'milestone' : ($ticket['type'] == 'subtask' ? 'subtask' : 'task') }}">
|
||||
|
||||
@foreach(($ticket['children'] ?? []) as $childTicket)
|
||||
@include('widgets::partials.todoItem', ['ticket' => $childTicket, 'statusLabels' => $statusLabels, 'onTheClock' => $onTheClock, 'tpl' => $tpl, 'level' => $level + 1, 'groupKey' => $groupKey])
|
||||
@endforeach
|
||||
|
||||
@if($level == 0 && $ticket['type'] === "milestone")
|
||||
|
||||
<!-- Subtask Form -->
|
||||
<div id="task-add-form-{{ $groupKey }}-{{$ticket['id']}}" class="subtask-form ticketBox"
|
||||
style="display:none; margin:5px 0px;">
|
||||
<form class="form-group"
|
||||
id="task-add-form-{{ $groupKey }}-{{$ticket['id']}}-form"
|
||||
hx-post="{{ BASE_URL }}/widgets/myToDos/addTodo"
|
||||
hx-target="#yourToDoContainer"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator=".htmx-indicator-ticket-{{ $ticket['id'] }}"
|
||||
onsubmit="jQuery(this).find('.main-title-input').attr('readonly', true);"
|
||||
>
|
||||
<input type="hidden" name="milestone"
|
||||
value="{{ $ticket['type'] == "milestone" ? $ticket['id'] : '' }}"/>
|
||||
<input type="hidden" name="status" value="3"/>
|
||||
<input type="hidden" name="quickadd" value="true"/>
|
||||
<input type="hidden" name="sortIndex"
|
||||
value="{{ isset($ticket['children']) ? ((collect($ticket['children'])->last()['sortIndex'] ?? 10)+5) : 10 }}"/>
|
||||
<input type="hidden" name="projectId" value="{{ $ticket['projectId'] }}"/>
|
||||
<input type="hidden" name="priority"
|
||||
value="{{ $groupBy === "priority" ? $groupKey : '' }}"/>
|
||||
<input type="hidden" name="dateToFinish"
|
||||
@if($groupKey === 'thisWeek')
|
||||
value="{{ dtHelper()->userNow()->next('Friday')->formatDateForUser() }}"
|
||||
@elseif($groupKey === 'overdue')
|
||||
value="{{ dtHelper()->userNow()->yesterday()->formatDateForUser() }}"
|
||||
@else
|
||||
value=""
|
||||
@endif
|
||||
/>
|
||||
<div class="tw-flex tw-flex-row tw-gap-2">
|
||||
<div class="tw-flex-grow">
|
||||
<x-global::forms.text-input name="headline" variant="headline"
|
||||
style="font-size:var(--base-font-size)"
|
||||
placeholder="{{ __('input.placeholders.what_are_you_working_on') }}" />
|
||||
</div>
|
||||
<div>
|
||||
<input type="hidden" name="status" value="3"/>
|
||||
<x-global::forms.button inputType="submit" contentRole="primary">{{ __('buttons.save') }}</x-global::forms.button>
|
||||
<x-global::forms.button tag="a" link="javascript:void(0);"
|
||||
onclick="jQuery('#task-add-form-{{ $groupKey }}-{{$ticket['id']}}').toggle(); jQuery('#task-add-form-{{ $groupKey }}-{{$ticket['id']}}-handler').toggle();">{{ __('buttons.cancel') }}</x-global::forms.button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- End Subtask Form -->
|
||||
|
||||
<a href="javascript:void(0);" id="task-add-form-{{ $groupKey }}-{{$ticket['id']}}-handler"
|
||||
onclick="jQuery(this).toggle(); jQuery('#task-add-form-{{ $groupKey }}-{{$ticket['id']}}').toggle(); "><i
|
||||
class="fa fa-plus-circle"></i> {{ __('links.add_task') }}</a>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
</div>
|
||||
75
app/Domain/Widgets/Templates/partials/welcome.blade.php
Normal file
75
app/Domain/Widgets/Templates/partials/welcome.blade.php
Normal file
@@ -0,0 +1,75 @@
|
||||
@props([
|
||||
'includeTitle' => true,
|
||||
'randomImage' => '',
|
||||
'totalTickets' => 0,
|
||||
'projectCount' => 0,
|
||||
'closedTicketsCount' => 0,
|
||||
'ticketsInGoals' => 0,
|
||||
'doneTodayCount' => 0,
|
||||
'totalTodayCount' => 0,
|
||||
])
|
||||
|
||||
<div class="welcome-widget">
|
||||
|
||||
<div style="padding:0px 0px">
|
||||
<div style="font-size:18px; color:var(--main-titles-color); padding-bottom:15px; padding-top:8px">
|
||||
👋 {{ __('text.hi') }} {{ session()->get("userdata.name") }}
|
||||
|
||||
<div class="tw-float-right">
|
||||
<x-global::forms.button tag="a" link="{{ BASE_URL }}/users/editOwn#theme" contentRole="link" style="color:var(--main-titles-color); padding:0px; width:31px; line-height:31px; text-align: center;" data-tippy-content="{{ __('text.update_theme') }}">
|
||||
<i class="fa-solid fa-palette"></i>
|
||||
</x-global::forms.button>
|
||||
|
||||
<x-global::forms.button tag="a" link="#/widgets/widgetManager" contentRole="link" style="color:var(--main-titles-color); padding:0px; width:31px; line-height:31px; text-align: center;" data-tippy-content="{{ __('text.update_dashboard') }}">
|
||||
<span class="fa fa-fw fa-cogs"></span>
|
||||
@if($showSettingsIndicator)
|
||||
<span class='new-indicator'></span>
|
||||
@endif
|
||||
</x-global::forms.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tw-flex tw-gap-x-[10px]">
|
||||
|
||||
<div class="bigNumberBox tw-flex-1 tw-flex-grow">
|
||||
<div class="bigNumberBoxInner">
|
||||
<div class="bigNumberBoxNumber">⏱️ {{ $doneTodayCount }}/{{ $totalTodayCount }} </div>
|
||||
<div class="bigNumberBoxText">{{ __("welcome_widget.timeboxed_completed") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bigNumberBox tw-flex-1 tw-flex-grow">
|
||||
<div class="bigNumberBoxInner">
|
||||
<div class="bigNumberBoxNumber">🥳 {{ $closedTicketsCount }} </div>
|
||||
<div class="bigNumberBoxText">{{ __("welcome_widget.tasks_completed") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bigNumberBox tw-flex-1 tw-flex-grow ">
|
||||
|
||||
<div class="bigNumberBoxInner">
|
||||
<div class="bigNumberBoxNumber">📥 {{ $totalTickets }} </div>
|
||||
<div class="bigNumberBoxText">{{ __("welcome_widget.tasks_left") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bigNumberBox tw-flex-1 tw-flex-grow">
|
||||
|
||||
<div class="bigNumberBoxInner">
|
||||
<div class="bigNumberBoxNumber">🎯 {{ $ticketsInGoals }} </div>
|
||||
<div class="bigNumberBoxText">{{ __("welcome_widget.goals_contributing_to") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="clear"></div>
|
||||
|
||||
@dispatchEvent('afterWelcomeMessage')
|
||||
|
||||
<div class="clear"></div>
|
||||
|
||||
</div>
|
||||
|
||||
@dispatchEvent('afterWelcomeMessageBox')
|
||||
39
app/Domain/Widgets/Templates/widgetManager.blade.php
Normal file
39
app/Domain/Widgets/Templates/widgetManager.blade.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<div class="" style="min-width:50%;">
|
||||
<h1>{{ __("headlines.widget_manager") }}</h1>
|
||||
<x-global::forms.button tag="a" contentRole="secondary" class="pull-right" link="{{ BASE_URL }}/dashboard/home?resetDashboard=true" style="margin-bottom:10px;"><i class="fa-solid fa-arrow-rotate-left"></i> Reset Dashboard</x-global::forms.button>
|
||||
<p>{{ __("text.choose_widgets") }}</p>
|
||||
<br />
|
||||
<div class="row">
|
||||
@foreach($availableWidgets as $widgetId => $widget)
|
||||
@if($widget->alwaysVisible !== true)
|
||||
@php( $widget->name = __($widget->name))
|
||||
@php( $widget->description = __($widget->description))
|
||||
<div class="col-md-4">
|
||||
<div class="projectBox tw-p-m tw-min-w-[250px] @if(in_array($widgetId, array_keys($newWidgets)) && !isset($activeWidgets[$widgetId])) newWidget @endif">
|
||||
<h5>{{ $widget->name }}</h5>
|
||||
<p>{!! $widget->description !!} </p>
|
||||
<div class="right">
|
||||
@if($widget->alwaysVisible == false)
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle"
|
||||
id="widget-toggle-{{ $widget->id }}"
|
||||
onclick="leantime.widgetController.toggleWidgetVisibility('{{ $widget->id }}', this, {{ json_encode($widget) }})"
|
||||
@if(isset($activeWidgets[$widget->id]))
|
||||
checked='checked'
|
||||
@if(isset($activeWidgets[$widget->id]->isNew) && $activeWidgets[$widget->id]->isNew)
|
||||
data-is-new="true"
|
||||
@endif
|
||||
@endif
|
||||
/>
|
||||
<label for="widget-toggle-{{ $widget->id }}"></label>
|
||||
@endif
|
||||
</div>
|
||||
<div class="clearall"></div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
<div class="clear"></div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user