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

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

View File

@@ -0,0 +1,76 @@
<?php
namespace Leantime\Domain\Clients\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Domain\Clients\Permissions\ClientsPermissions;
use Leantime\Domain\Clients\Services\Clients as ClientService;
use Symfony\Component\HttpFoundation\Response;
/**
* DelClient Controller - Deleting clients.
*/
class DelClient extends Controller
{
private ClientService $clientService;
/**
* Initializes dependencies.
*/
public function init(ClientService $clientService): void
{
$this->clientService = $clientService;
}
/**
* Displays the delete client confirmation page.
*
* @param array $params Request parameters
*/
#[RequiresPermission(ClientsPermissions::DELETE, global: true)]
public function get(array $params): Response
{
if (! isset($params['id'])) {
return $this->tpl->display('errors.error403', responseCode: 403);
}
$id = (int) $params['id'];
if ($this->clientService->hasTickets($id)) {
$this->tpl->setNotification($this->language->__('notification.client_has_todos'), 'error');
}
$this->tpl->assign('client', $this->clientService->get($id));
return $this->tpl->display('clients.delClient');
}
/**
* Handles client deletion.
*
* @param array $params Request parameters
*/
#[RequiresPermission(ClientsPermissions::DELETE, global: true)]
public function post(array $params): Response
{
if (! isset($params['id'])) {
return $this->tpl->display('errors.error403', responseCode: 403);
}
$id = (int) $params['id'];
if ($this->clientService->hasTickets($id)) {
$this->tpl->setNotification($this->language->__('notification.client_has_todos'), 'error');
$this->tpl->assign('client', $this->clientService->get($id));
return $this->tpl->display('clients.delClient');
}
$this->clientService->delete($id);
$this->tpl->setNotification($this->language->__('notification.client_deleted'), 'success');
return Frontcontroller::redirect(BASE_URL.'/clients/showAll');
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace Leantime\Domain\Clients\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Domain\Clients\Permissions\ClientsPermissions;
use Leantime\Domain\Clients\Services\Clients as ClientService;
use Symfony\Component\HttpFoundation\Response;
/**
* EditClient Controller - Editing clients.
*/
class EditClient extends Controller
{
private ClientService $clientService;
/**
* Initializes dependencies.
*/
public function init(ClientService $clientService): void
{
$this->clientService = $clientService;
}
/**
* Displays the edit client form.
*
* @param array $params Request parameters
*/
#[RequiresPermission(ClientsPermissions::EDIT, global: true)]
public function get(array $params): Response
{
if (! isset($params['id'])) {
return $this->tpl->display('errors.error403', responseCode: 403);
}
$id = (int) $params['id'];
$row = $this->clientService->get($id);
if ($row === false) {
return $this->tpl->display('errors.error404', responseCode: 404);
}
$this->tpl->assign('values', $row);
return $this->tpl->display('clients.editClient');
}
/**
* Handles client edit form submission.
*
* @param array $params Request parameters
*/
#[RequiresPermission(ClientsPermissions::EDIT, global: true)]
public function post(array $params): Response
{
if (! isset($params['id'])) {
return $this->tpl->display('errors.error403', responseCode: 403);
}
$id = (int) $params['id'];
$values = [
'id' => $id,
'name' => $_POST['name'] ?? '',
'street' => $_POST['street'] ?? '',
'zip' => $_POST['zip'] ?? '',
'city' => $_POST['city'] ?? '',
'state' => $_POST['state'] ?? '',
'country' => $_POST['country'] ?? '',
'phone' => $_POST['phone'] ?? '',
'internet' => $_POST['internet'] ?? '',
'email' => $_POST['email'] ?? '',
];
if ($values['name'] !== '') {
$this->clientService->editClient($values);
$this->tpl->setNotification('EDIT_CLIENT_SUCCESS', 'success', 'client_updated');
} else {
$this->tpl->setNotification('NO_NAME', 'error');
}
$this->tpl->assign('values', $values);
return $this->tpl->display('clients.editClient');
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace Leantime\Domain\Clients\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Core\Exceptions\EntityExistsException;
use Leantime\Core\Exceptions\MissingParameterException;
use Leantime\Domain\Clients\Permissions\ClientsPermissions;
use Leantime\Domain\Clients\Services\Clients as ClientService;
use Symfony\Component\HttpFoundation\Response;
/**
* NewClient Controller - Add a new client.
*/
class NewClient extends Controller
{
private ClientService $clientService;
/**
* Initializes dependencies.
*/
public function init(ClientService $clientService): void
{
$this->clientService = $clientService;
}
/**
* Displays the new client form.
*
* @param array $params Request parameters
*/
#[RequiresPermission(ClientsPermissions::CREATE, global: true)]
public function get(array $params): Response
{
$values = [
'name' => '',
'street' => '',
'zip' => '',
'city' => '',
'state' => '',
'country' => '',
'phone' => '',
'internet' => '',
'email' => '',
];
$this->tpl->assign('values', $values);
return $this->tpl->display('clients.newClient');
}
/**
* Handles new client form submission.
*
* @param array $params Request parameters
*/
#[RequiresPermission(ClientsPermissions::CREATE, global: true)]
public function post(array $params): Response
{
$values = [
'name' => $_POST['name'] ?? '',
'street' => $_POST['street'] ?? '',
'zip' => $_POST['zip'] ?? '',
'city' => $_POST['city'] ?? '',
'state' => $_POST['state'] ?? '',
'country' => $_POST['country'] ?? '',
'phone' => $_POST['phone'] ?? '',
'internet' => $_POST['internet'] ?? '',
'email' => $_POST['email'] ?? '',
];
try {
$id = $this->clientService->createClient($values);
$this->tpl->setNotification($this->language->__('notification.client_added_successfully'), 'success', 'new_client');
return Frontcontroller::redirect(BASE_URL.'/clients/showClient/'.$id);
} catch (EntityExistsException) {
$this->tpl->setNotification($this->language->__('notification.client_exists_already'), 'error');
} catch (MissingParameterException) {
$this->tpl->setNotification($this->language->__('notification.client_name_not_specified'), 'error');
}
$this->tpl->assign('values', $values);
return $this->tpl->display('clients.newClient');
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace Leantime\Domain\Clients\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Domain\Clients\Permissions\ClientsPermissions;
use Leantime\Domain\Clients\Services\Clients as ClientService;
use Symfony\Component\HttpFoundation\Response;
/**
* RemoveUser Controller - Remove user from client.
*/
class RemoveUser extends Controller
{
private ClientService $clientService;
/**
* Initializes dependencies.
*/
public function init(ClientService $clientService): void
{
$this->clientService = $clientService;
}
/**
* Displays the remove user confirmation (no state change on GET).
*
* @param array $params Request parameters
*/
#[RequiresPermission(ClientsPermissions::EDIT, global: true)]
public function get(array $params): Response
{
$clientId = (int) ($params['id'] ?? $_GET['id'] ?? 0);
return Frontcontroller::redirect(BASE_URL.'/clients/showClient/'.$clientId);
}
/**
* Handles user removal from client via POST (CSRF-protected).
*
* @param array $params Request parameters
*/
#[RequiresPermission(ClientsPermissions::EDIT, global: true)]
public function post(array $params): Response
{
$clientId = (int) ($params['id'] ?? $_POST['id'] ?? 0);
$userId = (int) ($params['userId'] ?? $_POST['userId'] ?? 0);
if ($clientId === 0 || $userId === 0) {
return $this->tpl->display('errors.error403', responseCode: 403);
}
if ($this->clientService->removeUser($clientId, $userId)) {
$this->tpl->setNotification(
$this->language->__('notification.user_removed_from_client'),
'success'
);
} else {
$this->tpl->setNotification(
$this->language->__('notification.error_removing_user'),
'error'
);
}
return Frontcontroller::redirect(BASE_URL.'/clients/showClient/'.$clientId);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace Leantime\Domain\Clients\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Domain\Clients\Permissions\ClientsPermissions;
use Leantime\Domain\Clients\Services\Clients as ClientService;
use Symfony\Component\HttpFoundation\Response;
/**
* ShowAll Controller - Show all clients.
*/
class ShowAll extends Controller
{
private ClientService $clientService;
/**
* Initializes dependencies.
*/
public function init(ClientService $clientService): void
{
$this->clientService = $clientService;
}
/**
* Displays the list of all clients.
*
* @param array $params Request parameters
*/
#[RequiresPermission(ClientsPermissions::VIEW, global: true)]
public function get(array $params): Response
{
if (session('userdata.role') == 'admin') {
$this->tpl->assign('admin', true);
}
$this->tpl->assign('allClients', $this->clientService->getAll());
return $this->tpl->display('clients.showAll');
}
}

View File

@@ -0,0 +1,161 @@
<?php
namespace Leantime\Domain\Clients\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Core\Exceptions\MissingParameterException;
use Leantime\Domain\Clients\Permissions\ClientsPermissions;
use Leantime\Domain\Clients\Services\Clients as ClientService;
use Leantime\Domain\Comments\Services\Comments as CommentService;
use Leantime\Domain\Files\Services\Files as FileService;
use Symfony\Component\HttpFoundation\Response;
/**
* ShowClient Controller - Show one client.
*/
class ShowClient extends Controller
{
private ClientService $clientService;
private CommentService $commentService;
private FileService $fileService;
/**
* Initializes dependencies.
*/
public function init(
ClientService $clientService,
CommentService $commentService,
FileService $fileService
): void {
$this->clientService = $clientService;
$this->commentService = $commentService;
$this->fileService = $fileService;
if (! session()->exists('lastPage')) {
session(['lastPage' => BASE_URL.'/clients/showAll']);
}
}
/**
* Displays the client detail page.
*
* @param array $params Request parameters
*/
#[RequiresPermission(ClientsPermissions::VIEW, global: true)]
public function get(array $params): Response
{
if (! isset($params['id'])) {
return $this->tpl->display('errors.error404', responseCode: 404);
}
$id = (int) $params['id'];
$client = $this->clientService->get($id);
if ($client === false) {
return $this->tpl->display('errors.error404', responseCode: 404);
}
// Handle file deletion via GET param
if (isset($_GET['delFile'])) {
$result = $this->fileService->deleteFile($_GET['delFile']);
if ($result === true) {
$this->tpl->setNotification($this->language->__('notifications.file_deleted'), 'success', 'clientfile_deleted');
return Frontcontroller::redirect(BASE_URL.'/clients/showClient/'.$id.'#files');
} else {
$this->tpl->setNotification($this->language->__('notifications.file_deleted_error'), 'error');
}
}
if (session('userdata.role') == 'admin') {
$this->tpl->assign('admin', true);
}
$this->tpl->assign('client', $client);
$pageData = $this->clientService->getClientPageData($id);
array_map([$this->tpl, 'assign'], array_keys($pageData), array_values($pageData));
return $this->tpl->display('clients.showClient');
}
/**
* Handles client detail form submissions (save, upload, comment).
*
* @param array $params Request parameters
*/
#[RequiresPermission(ClientsPermissions::EDIT, global: true)]
public function post(array $params): Response
{
if (! isset($params['id'])) {
return $this->tpl->display('errors.error404', responseCode: 404);
}
$id = (int) $params['id'];
$client = $this->clientService->get($id);
if ($client === false) {
return $this->tpl->display('errors.error404', responseCode: 404);
}
// Handle file upload
if (isset($_POST['upload'])) {
if (isset($_FILES['file']) && $_FILES['file']['tmp_name'] != '') {
$this->fileService->upload($_FILES, 'client', $id);
$this->tpl->setNotification($this->language->__('notifications.file_upload_success'), 'success', 'clientfile_uploaded');
} else {
$this->tpl->setNotification($this->language->__('notifications.file_upload_error'), 'error');
}
}
// Handle comment
if (isset($_POST['comment'])) {
if ($this->commentService->addComment($_POST, 'client', $id, $client)) {
$this->tpl->setNotification($this->language->__('notifications.comment_create_success'), 'success');
} else {
$this->tpl->setNotification($this->language->__('notifications.comment_create_error'), 'error');
}
}
// Handle client save
if (isset($_POST['save'])) {
$values = [
'id' => $id,
'name' => $_POST['name'] ?? '',
'street' => $_POST['street'] ?? '',
'zip' => $_POST['zip'] ?? '',
'city' => $_POST['city'] ?? '',
'state' => $_POST['state'] ?? '',
'country' => $_POST['country'] ?? '',
'phone' => $_POST['phone'] ?? '',
'internet' => $_POST['internet'] ?? '',
'email' => $_POST['email'] ?? '',
];
try {
$this->clientService->updateClient($values);
$this->tpl->setNotification($this->language->__('notification.client_saved_successfully'), 'success');
} catch (MissingParameterException) {
$this->tpl->setNotification($this->language->__('notification.client_name_not_specified'), 'error');
}
$client = $values;
}
if (session('userdata.role') == 'admin') {
$this->tpl->assign('admin', true);
}
$this->tpl->assign('client', $client);
$pageData = $this->clientService->getClientPageData($id);
array_map([$this->tpl, 'assign'], array_keys($pageData), array_values($pageData));
return $this->tpl->display('clients.showClient');
}
}

View File

@@ -0,0 +1,77 @@
leantime.clientsController = (function () {
//Functions
var initDates = function () {
jQuery(".projectDateFrom, .projectDateTo").datepicker(
{
dateFormat: leantime.i18n.__("language.dateformat"),
dayNames: leantime.i18n.__("language.dayNames").split(","),
dayNamesMin: leantime.i18n.__("language.dayNamesMin").split(","),
dayNamesShort: leantime.i18n.__("language.dayNamesShort").split(","),
monthNames: leantime.i18n.__("language.monthNames").split(","),
currentText: leantime.i18n.__("language.currentText"),
closeText: leantime.i18n.__("language.closeText"),
buttonText: leantime.i18n.__("language.buttonText"),
isRTL: leantime.i18n.__("language.isRTL") === "true" ? 1 : 0,
nextText: leantime.i18n.__("language.nextText"),
prevText: leantime.i18n.__("language.prevText"),
weekHeader: leantime.i18n.__("language.weekHeader"),
}
);
};
var initClientTabs = function () {
jQuery('.clientTabs').tabs();
};
var initClientTable = function () {
jQuery(document).ready(function () {
var size = 100;
var allProjects = jQuery("#allClientsTable").DataTable({
"language": {
"decimal": leantime.i18n.__("datatables.decimal"),
"emptyTable": leantime.i18n.__("datatables.emptyTable"),
"info": leantime.i18n.__("datatables.info"),
"infoEmpty": leantime.i18n.__("datatables.infoEmpty"),
"infoFiltered": leantime.i18n.__("datatables.infoFiltered"),
"infoPostFix": leantime.i18n.__("datatables.infoPostFix"),
"thousands": leantime.i18n.__("datatables.thousands"),
"lengthMenu": leantime.i18n.__("datatables.lengthMenu"),
"loadingRecords": leantime.i18n.__("datatables.loadingRecords"),
"processing": leantime.i18n.__("datatables.processing"),
"search": leantime.i18n.__("datatables.search"),
"zeroRecords": leantime.i18n.__("datatables.zeroRecords"),
"paginate": {
"first": leantime.i18n.__("datatables.first"),
"last": leantime.i18n.__("datatables.last"),
"next": leantime.i18n.__("datatables.next"),
"previous": leantime.i18n.__("datatables.previous"),
},
"aria": {
"sortAscending": leantime.i18n.__("datatables.sortAscending"),
"sortDescending":leantime.i18n.__("datatables.sortDescending"),
}
},
"dom": '<"top">rt<"bottom"ilp><"clear">',
"searching": false,
"displayLength":100
});
});
};
// Make public what you want to have public, everything else is private
return {
initDates:initDates,
initClientTabs:initClientTabs,
initClientTable:initClientTable
};
})();

View File

@@ -0,0 +1,50 @@
<?php
namespace Leantime\Domain\Clients\Permissions;
use Leantime\Core\Auth\Permissions\Permission;
use Leantime\Core\Auth\Permissions\ProvidesPermissions;
/**
* The Clients (company account management) permission vocabulary — the verbs only.
*
* Declares *what* can be done with clients; it says nothing about *which roles* may do it
* (role assignment is centrally owned — see {@see \Leantime\Core\Auth\Permissions\DefaultRolePermissions}).
*
* Every client capability is COMPANY-WIDE, not project-scoped: clients are a company resource,
* so authority is the user's GLOBAL role (see {@see \Leantime\Core\Auth\RoleResolver}). Each
* Permission below is constructed with `projectScoped = false`, and call sites gate with
* `#[RequiresPermission(..., global: true)]`.
*
* admin + owner hold all `clients.*` via the company-wide wildcard in DefaultRolePermissions;
* lower roles hold none — matching today's behavior, where all client management is admin+.
*/
final class ClientsPermissions implements ProvidesPermissions
{
/** View the client roster / read a client. */
public const VIEW = 'clients.view';
/** Create clients. */
public const CREATE = 'clients.create';
/** Edit a client (and manage its user assignments). */
public const EDIT = 'clients.edit';
/** Delete clients (cascades to their projects). */
public const DELETE = 'clients.delete';
public function domain(): string
{
return 'clients';
}
public function permissions(): array
{
return [
new Permission(self::VIEW, 'View clients', false),
new Permission(self::CREATE, 'Create clients', false),
new Permission(self::EDIT, 'Edit clients', false),
new Permission(self::DELETE, 'Delete clients', false),
];
}
}

View File

@@ -0,0 +1,212 @@
<?php
/**
* Client class - All data access for clients
*/
namespace Leantime\Domain\Clients\Repositories;
use Illuminate\Database\ConnectionInterface;
use Leantime\Core\Db\DatabaseHelper;
use Leantime\Core\Db\Db as DbCore;
use Leantime\Core\Db\Repository;
class Clients extends Repository
{
public string $name;
protected string $entity = 'clients';
public int $id;
private ConnectionInterface $db;
private DatabaseHelper $dbHelper;
/**
* __construct - get database connection
*/
public function __construct(DbCore $db, DatabaseHelper $dbHelper)
{
$this->db = $db->getConnection();
$this->dbHelper = $dbHelper;
}
/**
* getClient - get one client from db
*/
public function getClient(int|string $id): array|false
{
$result = $this->db->table('zp_clients')
->select(
'zp_clients.id',
'zp_clients.name',
'zp_clients.street',
'zp_clients.zip',
'zp_clients.city',
'zp_clients.state',
'zp_clients.country',
'zp_clients.phone',
'zp_clients.internet',
'zp_clients.email'
)
->selectRaw('COUNT('.$this->dbHelper->wrapColumn('zp_projects.clientId').') AS '.$this->dbHelper->wrapColumn('numberOfProjects'))
->leftJoin('zp_projects', 'zp_clients.id', '=', 'zp_projects.clientId')
->where('zp_clients.id', $id)
->groupBy(
'zp_clients.id',
'zp_clients.name',
'zp_clients.street',
'zp_clients.zip',
'zp_clients.city',
'zp_clients.state',
'zp_clients.country',
'zp_clients.phone',
'zp_clients.internet',
'zp_clients.email'
)
->orderBy('zp_clients.name')
->limit(1)
->first();
if ($result !== null) {
$row = (array) $result;
$this->name = $row['name'];
$this->id = $row['id'];
return $row;
}
return false;
}
/**
* getAll - get all clients
*/
public function getAll(): array
{
$results = $this->db->table('zp_clients')
->select(
'zp_clients.id',
'zp_clients.name',
'zp_clients.internet'
)
->selectRaw('COUNT('.$this->dbHelper->wrapColumn('zp_projects.clientId').') AS '.$this->dbHelper->wrapColumn('numberOfProjects'))
->leftJoin('zp_projects', 'zp_clients.id', '=', 'zp_projects.clientId')
->groupBy(
'zp_clients.id',
'zp_clients.name',
'zp_clients.internet'
)
->orderBy('zp_clients.name')
->get();
return array_map(fn ($item) => (array) $item, $results->toArray());
}
/**
* @return int|mixed
*/
public function getNumberOfClients(): mixed
{
return $this->db->table('zp_clients')->count();
}
public function isClient(array $values): bool
{
return $this->db->table('zp_clients')
->where('name', $values['name'])
->where('street', $values['street'])
->exists();
}
public function getClientsUsers(int|string $clientId): false|array
{
$results = $this->db->table('zp_user')
->select(
'id',
'firstname',
'lastname',
'username',
'notifications',
'profileId',
'phone',
'status'
)
->where('clientId', $clientId)
->where(function ($query) {
$query->whereNull('source')
->orWhere('source', '!=', 'api');
})
->get();
return array_map(fn ($item) => (array) $item, $results->toArray());
}
/**
* addClient - add a client and postback test
*/
public function addClient(array $values): false|string
{
$id = $this->db->table('zp_clients')->insertGetId([
'name' => $values['name'],
'street' => $values['street'] ?? '',
'zip' => $values['zip'] ?? '',
'city' => $values['city'] ?? '',
'state' => $values['state'] ?? '',
'country' => $values['country'] ?? '',
'phone' => $values['phone'] ?? '',
'internet' => $values['internet'] ?? '',
'email' => $values['email'] ?? '',
]);
return (string) $id;
}
/**
* editClient - edit a client
*/
public function editClient(array $values, int|string $id): bool
{
return $this->db->table('zp_clients')
->where('id', $id)
->update([
'name' => $values['name'],
'street' => $values['street'],
'zip' => $values['zip'],
'city' => $values['city'],
'state' => $values['state'],
'country' => $values['country'],
'phone' => $values['phone'],
'internet' => $values['internet'],
'email' => $values['email'],
]) >= 0;
}
/**
* deleteClient - delete a client and associated projects
*/
public function deleteClient(int|string $id): bool
{
// Delete projects associated with the client first
$this->db->table('zp_projects')
->where('clientId', $id)
->delete();
// Then delete the client
return $this->db->table('zp_clients')
->where('id', $id)
->delete() > 0;
}
/**
* hasTickets - check if a project has Tickets
*/
public function hasTickets(int|string $id): bool
{
return $this->db->table('zp_projects')
->join('zp_tickets', 'zp_projects.id', '=', 'zp_tickets.projectId')
->where('zp_projects.clientId', $id)
->exists();
}
}

View File

@@ -0,0 +1,304 @@
<?php
namespace Leantime\Domain\Clients\Services;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Exceptions\EntityExistsException;
use Leantime\Core\Exceptions\MissingParameterException;
use Leantime\Domain\Clients\Permissions\ClientsPermissions;
use Leantime\Domain\Clients\Repositories\Clients as ClientRepository;
use Leantime\Domain\Comments\Services\Comments as CommentService;
use Leantime\Domain\Files\Services\Files as FileService;
use Leantime\Domain\Projects\Repositories\Projects as ProjectRepository;
use Leantime\Domain\Users\Repositories\Users as UserRepository;
/**
* Client service - Business logic for client management.
*/
class Clients
{
private ProjectRepository $projectRepository;
private ClientRepository $clientRepository;
private CommentService $commentService;
private FileService $fileService;
private UserRepository $userRepository;
public function __construct(
ProjectRepository $projectRepository,
ClientRepository $clientRepository,
CommentService $commentService,
FileService $fileService,
UserRepository $userRepository,
) {
$this->projectRepository = $projectRepository;
$this->clientRepository = $clientRepository;
$this->commentService = $commentService;
$this->fileService = $fileService;
$this->userRepository = $userRepository;
}
/**
* Gets clients accessible by a specific user based on their project assignments.
*
* @param int $userId The user ID
* @return array List of clients the user has access to
*
* @internal Internal-only; deliberately excluded from the JSON-RPC surface. The $userId is
* caller-supplied, so exposing it would let any user enumerate another user's
* client list (IDOR). Only ever called internally with the session user's id
* (e.g. the roadmap/milestone client-filter dropdown).
*/
public function getUserClients(int $userId): array
{
$userProjects = $this->projectRepository->getUserProjects($userId);
$clients = [];
if (is_array($userProjects)) {
foreach ($userProjects as $project) {
if (! array_key_exists($project['clientId'], $clients)) {
$clients[$project['clientId']] = ['id' => $project['clientId'], 'name' => $project['clientName']];
}
}
}
return $clients;
}
/**
* Gets all clients.
*
* @param array|null $searchparams Optional search parameters
* @return array List of all clients
*
* @api
*/
#[RequiresPermission(ClientsPermissions::VIEW, global: true)]
public function getAll(?array $searchparams = null): array
{
return $this->clientRepository->getAll();
}
/**
* Patches the client by key.
*
* @param int $id Id of the object to be patched
* @param array $params Key=>value array where key represents the object field name and value the value
* @return bool Returns true on success, false on failure
*
* @api
*/
#[RequiresPermission(ClientsPermissions::EDIT, global: true)]
public function patch(int $id, array $params): bool
{
return $this->clientRepository->patch($id, $params);
}
/**
* Updates an existing client.
*
* @param array $values Client data including 'id' key
* @return bool Returns true on success, false on failure
*
* @api
*/
#[RequiresPermission(ClientsPermissions::EDIT, global: true)]
public function editClient(array $values): bool
{
return $this->clientRepository->editClient($values, $values['id']);
}
/**
* Creates a new client.
*
* @param array $values Client data to create
* @return int|false Returns id of new element or false
*
* @api
*/
#[RequiresPermission(ClientsPermissions::CREATE, global: true)]
public function create(array $values): int|false
{
return $this->clientRepository->addClient($values);
}
/**
* Deletes a client and its associated projects.
*
* @param int $id Id of the client to be deleted
* @return bool Returns true on success, false on failure
*
* @api
*/
#[RequiresPermission(ClientsPermissions::DELETE, global: true)]
public function delete(int $id): bool
{
return $this->clientRepository->deleteClient($id);
}
/**
* Gets 1 specific client by id.
*
* @param int $id Id of the client to be retrieved
* @return array|false Returns client data or false if not found
*
* @api
*/
#[RequiresPermission(ClientsPermissions::VIEW, global: true)]
public function get(int $id): array|false
{
return $this->clientRepository->getClient($id);
}
/**
* Checks if a client with the same name and street already exists.
*
* @param array $values Client data with 'name' and 'street' keys
* @return bool Returns true if client exists
*
* @api
*/
#[RequiresPermission(ClientsPermissions::VIEW, global: true)]
public function isClient(array $values): bool
{
return $this->clientRepository->isClient($values);
}
/**
* Checks if a client has any tickets via its projects.
*
* @param int $id Client id
* @return bool Returns true if client has tickets
*
* @api
*/
#[RequiresPermission(ClientsPermissions::VIEW, global: true)]
public function hasTickets(int $id): bool
{
return $this->clientRepository->hasTickets($id);
}
/**
* Gets all users assigned to a client.
*
* @param int $clientId Client id
* @return array|false Returns list of users or false
*
* @api
*/
#[RequiresPermission(ClientsPermissions::VIEW, global: true)]
public function getClientsUsers(int $clientId): array|false
{
return $this->clientRepository->getClientsUsers($clientId);
}
/**
* Gets projects belonging to a client.
*
* @param int $clientId Client id
* @return array List of projects for this client
*
* @api
*/
#[RequiresPermission(ClientsPermissions::VIEW, global: true)]
public function getClientProjects(int $clientId): array
{
return $this->projectRepository->getClientProjects($clientId);
}
/**
* Creates a new client after validating it and checking for duplicates.
*
* Encapsulates the name-required validation and the duplicate-name check
* that previously lived in the controller.
*
* @param array $values Client data to create (requires a non-empty 'name')
* @return int Id of the newly created client
*
* @throws MissingParameterException When the client name is empty
* @throws EntityExistsException When a client with the same name/street already exists
*
* @api
*/
#[RequiresPermission(ClientsPermissions::CREATE, global: true)]
public function createClient(array $values): int
{
if (($values['name'] ?? '') === '') {
throw new MissingParameterException('Client name not specified');
}
if ($this->isClient($values) === true) {
throw new EntityExistsException('Client exists already');
}
return (int) $this->clientRepository->addClient($values);
}
/**
* Updates an existing client after validating the name is present.
*
* @param array $values Client data including 'id' key (requires a non-empty 'name')
* @return bool Returns true on success, false on failure
*
* @throws MissingParameterException When the client name is empty
*
* @api
*/
#[RequiresPermission(ClientsPermissions::EDIT, global: true)]
public function updateClient(array $values): bool
{
if (($values['name'] ?? '') === '') {
throw new MissingParameterException('Client name not specified');
}
return $this->editClient($values);
}
/**
* Removes a user from a client by clearing the user's client assignment.
*
* Keeps the Clients controllers within the Clients service surface while the
* underlying mutation lives on the Users repository.
*
* @param int $clientId Client the user should be removed from
* @param int $userId User to remove from the client
* @return bool Returns true on success, false on failure
*
* @api
*/
#[RequiresPermission(ClientsPermissions::EDIT, global: true)]
public function removeUser(int $clientId, int $userId): bool
{
if ($clientId === 0 || $userId === 0) {
return false;
}
return $this->userRepository->removeFromClient($userId);
}
/**
* Assembles the template data needed to render the client detail page.
*
* Centralizes the shared assignment block previously duplicated across the
* GET and POST handlers of the ShowClient controller.
*
* @param int $id Client id
* @return array{userClients: array|false, comments: array|false, imgExtensions: array<int, string>, clientProjects: array, files: array|false}
*
* @api
*/
#[RequiresPermission(ClientsPermissions::VIEW, global: true)]
public function getClientPageData(int $id): array
{
return [
'userClients' => $this->getClientsUsers($id),
'comments' => $this->commentService->getComments('client', $id),
'imgExtensions' => ['jpg', 'jpeg', 'png', 'gif', 'psd', 'bmp', 'tif', 'thm', 'yuv'],
'clientProjects' => $this->getClientProjects($id),
'files' => $this->fileService->getFilesByModule('client', $id),
];
}
}

View File

@@ -0,0 +1,40 @@
@extends($layout)
@section('content')
@dispatchEvent('beforePageHeaderOpen')
<div class="pageheader">
@dispatchEvent('afterPageHeaderOpen')
<div class="pageicon"><span class="fa {{ $tpl->getModulePicture() }}"></span></div>
<div class="pagetitle">
<h5>{!! __('label.administration') !!}</h5>
<h1>{!! sprintf(__('headline.delete_client'), $client['name']) !!}</h1>
</div>
@dispatchEvent('beforePageHeaderClose')
</div><!--pageheader-->
@dispatchEvent('afterPageHeaderClose')
<div class="maincontent">
<div class="maincontentinner">
{!! $tpl->displayNotification() !!}
<h4 class="widget widgettitle">{!! __('subtitles.delete') !!}</h4>
<div class="widgetcontent">
<form method="post">
@dispatchEvent('afterFormOpen')
<p>{!! __('text.confirm_client_deletion') !!}<br /></p>
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.yes_delete')" name="del" />
<x-global::forms.button tag="a" link="/clients/showClient/{{ $client['id'] }}" contentRole="tertiary">{!! __('buttons.back') !!}</x-global::forms.button>
@dispatchEvent('beforeFormClose')
</form>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,69 @@
@extends($layout)
@section('content')
@dispatchEvent('beforePageHeaderOpen')
<div class="pageheader">
@dispatchEvent('afterPageHeaderOpen')
<div class="pageicon"><span class="fa {{ $tpl->getModulePicture() }}"></span></div>
<div class="pagetitle">
<h5>Administration</h5>
<h1>{!! __('EDIT_CLIENT') !!}</h1>
</div>
@dispatchEvent('beforePageHeaderClose')
</div><!--pageheader-->
@dispatchEvent('afterPageHeaderClose')
<div class="maincontent">
<div class="maincontentinner">
{!! $tpl->displayNotification() !!}
<form action="" method="post" class="stdform">
<div class="widget">
<h4 class="widgettitle">{!! __('OVERVIEW') !!}</h4>
<div class="widgetcontent">
<label for="name">{!! __('NAME') !!}</label>
<x-global::forms.text-input name="name" id="name" value="{{ $values['name'] }}" /><br />
<label for="email">{!! __('EMAIL') !!}</label>
<x-global::forms.text-input name="email" id="email" value="{{ $values['email'] }}" /><br />
<label for="internet">{!! __('URL') !!}</label> <x-global::forms.text-input
name="internet" id="internet"
value="{{ $values['internet'] }}" /><br />
<label for="street">{!! __('STREET') !!}</label> <x-global::forms.text-input
name="street" id="street"
value="{{ $values['street'] }}" /><br />
<label for="zip">{!! __('ZIP') !!}</label> <x-global::forms.text-input
name="zip" id="zip" value="{{ $values['zip'] }}" /><br />
<label for="city">{!! __('CITY') !!}</label> <x-global::forms.text-input
name="city" id="city" value="{{ $values['city'] }}" /><br />
<label for="state">{!! __('STATE') !!}</label> <x-global::forms.text-input
name="state" id="state"
value="{{ $values['state'] }}" /><br />
<label for="country">{!! __('COUNTRY') !!}</label> <x-global::forms.text-input
name="country" id="country"
value="{{ $values['country'] }}" /><br />
<label for="phone">{!! __('PHONE') !!}</label> <x-global::forms.text-input
name="phone" id="phone"
value="{{ $values['phone'] }}" /><br />
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('SAVE')" name="save" id="save" />
</div>
</div>
</form>
</div>
</div>
@endsection

View File

@@ -0,0 +1,128 @@
@extends($layout)
@section('content')
@dispatchEvent('beforePageHeaderOpen')
<div class="pageheader">
@dispatchEvent('afterPageHeaderOpen')
<div class="pageicon"><span class="fa fa-address-book"></span></div>
<div class="pagetitle">
<h5>{!! __('label.administration') !!}</h5>
<h1>{!! __('headline.new_client') !!}</h1>
</div>
@dispatchEvent('beforePageHeaderClose')
</div><!--pageheader-->
@dispatchEvent('afterPageHeaderClose')
<div class="maincontent">
<div class="maincontentinner">
{!! $tpl->displayNotification() !!}
<div class="widget">
<h4 class="widgettitle">{!! __('subtitle.details') !!}</h4>
<div class="widgetcontent">
<form action="" method="post" class="stdform">
@dispatchEvent('afterFormOpen')
<div class="row row-fluid">
<div class="col-md-6">
<div class="form-group">
<label class="span4 control-label">{!! __('label.name') !!}</label>
<div class="span6">
<x-global::forms.text-input name="name" id="name" value="{{ $values['name'] }}" />
</div>
</div>
<div class="form-group">
<label class="span4 control-label">{!! __('label.email') !!}</label>
<div class="span6">
<x-global::forms.text-input name="email" id="email" value="{{ $values['email'] }}" />
</div>
</div>
<div class="form-group">
<label class="span4 control-label">{!! __('label.url') !!}</label>
<div class="span6">
<x-global::forms.text-input
name="internet" id="internet"
value="{{ $values['internet'] }}" />
</div>
</div>
<div class="form-group">
<label class="span4 control-label">{!! __('label.street') !!}</label>
<div class="span6">
<x-global::forms.text-input
name="street" id="street"
value="{{ $values['street'] }}" />
</div>
</div>
<div class="form-group">
<label class="span4 control-label">{!! __('label.zip') !!}</label>
<div class="span6">
<x-global::forms.text-input
name="zip" id="zip" value="{{ $values['zip'] }}" />
</div>
</div>
<div class="form-group">
<label class="span4 control-label">{!! __('label.city') !!}</label>
<div class="span6">
<x-global::forms.text-input
name="city" id="city" value="{{ $values['city'] }}" />
</div>
</div>
<div class="form-group">
<label class="span4 control-label">{!! __('label.state') !!}</label>
<div class="span6">
<x-global::forms.text-input
name="state" id="state"
value="{{ $values['state'] }}" />
</div>
</div>
<div class="form-group">
<label class="span4 control-label">{!! __('label.country') !!}</label>
<div class="span6">
<x-global::forms.text-input
name="country" id="country"
value="{{ $values['country'] }}" />
</div>
</div>
<div class="form-group">
<label class="span4 control-label">{!! __('label.phone') !!}</label>
<div class="span6">
<x-global::forms.text-input
name="phone" id="phone"
value="{{ $values['phone'] }}" />
</div>
</div>
@dispatchEvent('beforeSubmitButton')
<div class="form-group">
<div class="span4 control-label">
<x-global::forms.button tag="input" inputType="submit" name="save" id="save"
:labelText="__('buttons.save')" contentRole="primary" />
</div>
<div class="span6">
</div>
</div>
</div>
</div>
@dispatchEvent('beforeFormClose')
</form>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,77 @@
@extends($layout)
@section('content')
@dispatchEvent('beforePageHeaderOpen')
<div class="pageheader">
@dispatchEvent('afterPageHeaderOpen')
<div class="pageicon"><span class="fa fa-address-book"></span></div>
<div class="pagetitle">
<h5>{!! __('label.administration') !!}</h5>
<h1>{!! __('headline.all_clients') !!}</h1>
</div>
@dispatchEvent('beforePageHeaderClose')
</div><!--pageheader-->
@dispatchEvent('afterPageHeaderClose')
<div class="maincontent">
<div class="maincontentinner">
{!! $tpl->displayNotification() !!}
@can('clients.create')
<x-global::forms.button tag="a" link="{{ BASE_URL }}/clients/newClient" contentRole="primary"><i class='fa fa-plus'></i> {!! __('link.new_client') !!}</x-global::forms.button>
@endcan
<table class="table table-bordered" cellpadding="0" cellspacing="0" border="0" id="allClientsTable">
<colgroup>
<col class='con0' />
<col class='con1' />
<col class='con0' />
</colgroup>
<thead>
<tr>
<th class='head0'>{!! __('label.client_id') !!}</th>
<th class='head1'>{!! __('label.client_name') !!}</th>
<th class='head0'>{!! __('label.url') !!}</th>
<th class='head1'>{!! __('label.number_of_projects') !!}</th>
</tr>
</thead>
<tbody>
@foreach ($allClients as $row)
<tr>
<td>{{ $row['id'] }}</td>
<td>
<a class="" href="{{ BASE_URL }}/clients/showClient/{{ $row['id'] }}"><i class='fa fa-plus'></i> {{ $row['name'] }}</a>
</td>
<td><a href="{{ $row['internet'] }}" target="_blank">{{ $row['internet'] }}</a></td>
<td>{{ $row['numberOfProjects'] }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@once
@push('scripts')
<script type="text/javascript">
@dispatchEvent('scripts.afterOpen')
jQuery(document).ready(function() {
leantime.clientsController.initClientTable();
});
@dispatchEvent('scripts.beforeClose')
</script>
@endpush
@endonce
@endsection

View File

@@ -0,0 +1,286 @@
@extends($layout)
@section('content')
@php
$values = $client;
@endphp
@dispatchEvent('beforePageHeaderOpen')
<div class="pageheader">
@dispatchEvent('afterPageHeaderOpen')
<div class="pageicon"><span class="fa fa-address-book"></span></div>
<div class="pagetitle">
<h5>{!! __('label.administration') !!}</h5>
<h1>{{ $values['name'] }}</h1>
</div>
@dispatchEvent('beforePageHeaderClose')
</div><!--pageheader-->
@dispatchEvent('afterPageHeaderClose')
<div class="maincontent">
<div class="maincontentinner">
{!! $tpl->displayNotification() !!}
<div class="tabbedwidget tab-primary clientTabs">
<ul>
<li><a href="#clientDetails">{!! __('label.client_details') !!}</a></li>
<li><a href="#comment">{!! sprintf(__('tabs.discussion_with_count'), count($comments)) !!}</a></li>
<li><a href="#files">{!! sprintf(__('tabs.files_with_count'), count($files)) !!}</a></li>
</ul>
<div id='clientDetails'>
<form action="" method="post">
<div class="row row-fluid">
<div class="col-md-6">
<h4 class="widgettitle title-light"><span class="fa fa-leaf"></span> {!! __('subtitle.details') !!}</h4>
<div class="form-group">
<label class=" control-label">{!! __('label.client_id') !!}</label>
<div class="">
<x-global::forms.text-input name="id" id="id" value="{{ $values['id'] }}" readonly />
</div>
</div>
<div class="form-group">
<label class=" control-label">{!! __('label.name') !!}</label>
<div class="">
<x-global::forms.text-input name="name" id="name" value="{{ $values['name'] }}" />
</div>
</div>
<div class="form-group">
<label class=" control-label">{!! __('label.email') !!}</label>
<div class="">
<x-global::forms.text-input name="email" id="email" value="{{ $values['email'] }}" />
</div>
</div>
<div class="form-group">
<label class=" control-label">{!! __('label.url') !!}</label>
<div class="">
<x-global::forms.text-input
name="internet" id="internet"
value="{{ $values['internet'] }}" />
</div>
</div>
<div class="form-group">
<label class=" control-label">{!! __('label.street') !!}</label>
<div class="">
<x-global::forms.text-input
name="street" id="street"
value="{{ $values['street'] }}" />
</div>
</div>
<div class="form-group">
<label class=" control-label">{!! __('label.zip') !!}</label>
<div class="">
<x-global::forms.text-input
name="zip" id="zip" value="{{ $values['zip'] }}" />
</div>
</div>
<div class="form-group">
<label class=" control-label">{!! __('label.city') !!}</label>
<div class="">
<x-global::forms.text-input
name="city" id="city" value="{{ $values['city'] }}" />
</div>
</div>
<div class="form-group">
<label class=" control-label">{!! __('label.state') !!}</label>
<div class="">
<x-global::forms.text-input
name="state" id="state"
value="{{ $values['state'] }}" />
</div>
</div>
<div class="form-group">
<label class=" control-label">{!! __('label.country') !!}</label>
<div class="">
<x-global::forms.text-input
name="country" id="country"
value="{{ $values['country'] }}" />
</div>
</div>
<div class="form-group">
<label class=" control-label">{!! __('label.phone') !!}</label>
<div class="">
<x-global::forms.text-input
name="phone" id="phone"
value="{{ $values['phone'] }}" />
</div>
</div>
</div>
<div class="col-md-6">
<h4 class="widgettitle title-light"><span class="fa fa-users"></span> {!! __('subtitles.users_assigned_to_this_client') !!}</h4>
<x-global::forms.button tag="a" link="#/users/newUser?preSelectedClient={{ $values['id'] }}" contentRole="primary"><i class='fa fa-plus'></i> {!! __('buttons.add_user') !!} </x-global::forms.button>
<table class='table table-bordered'>
<colgroup>
<col class="con1" />
<col class="con0"/>
<col class="con1" />
</colgroup>
<thead>
<tr>
<th>{!! __('label.name') !!}</th>
<th>{!! __('label.email') !!}</th>
<th>{!! __('label.phone') !!}</th>
<th>{!! __('label.actions') !!}</th>
</tr>
</thead>
<tbody>
@foreach ($userClients as $user)
<tr>
<td>
{!! sprintf(__('text.full_name'), e($user['firstname']), e($user['lastname'])) !!}
</td>
<td><a href='mailto:{{ $user['username'] }}'>{{ $user['username'] }}</a></td>
<td>{{ $user['phone'] }}</td>
<td>
<a href="{{ BASE_URL }}/users/editUser/{{ $user['id'] }}" title="{{ __('buttons.edit') }}">
<i class="fa fa-edit"></i>
</a>
<form method="post" action="{{ BASE_URL }}/clients/removeUser" style="display:inline;"
onsubmit="return confirm('{{ __('text.confirm_remove_user_from_client') }}')">
@csrf
<input type="hidden" name="id" value="{{ $values['id'] }}" />
<input type="hidden" name="userId" value="{{ $user['id'] }}" />
<x-global::forms.button inputType="submit" class="delete" contentRole="link" title="{{ __('buttons.remove') }}" style="padding:0; border:none; background:none;">
<i class="fa fa-trash"></i>
</x-global::forms.button>
</form>
</td>
</tr>
@endforeach
@if (count($userClients) == 0)
<tr><td colspan='4'>{!! __('text.no_users_assigned_to_this_client') !!}</td></tr>
@endif
</tbody>
</table>
</div>
</div>
<div class="row">
<div class="col-md-6">
<x-global::forms.button tag="input" inputType="submit" name="save" id="save"
:labelText="__('buttons.save')" contentRole="primary" />
</div>
<div class="col-md-6 align-right">
<x-global::forms.button tag="a" link="{{ BASE_URL }}/clients/delClient/{{ $_GET['id'] }}" class="delete" state="danger" variant="outline"><i class="fa fa-trash"></i> {!! __('links.delete') !!}</x-global::forms.button>
</div>
</div>
</form>
</div>
<div id='comment'>
<form method="post" action="{{ BASE_URL }}/clients/showClient/{{ $_GET['id'] }}#comment">
<input type="hidden" name="comment" value="1" />
@include('comments::submodules.generalComment', ['formUrl' => BASE_URL . '/clients/showClient/' . e(request()->query('id', ''))])
</form>
</div>
<div id='files'>
<div class="mediamgr_category">
<form action='#files' method='POST' enctype="multipart/form-data">
<div class="par f-left" style="margin-right: 15px;">
<div class='fileupload fileupload-new' data-provides='fileupload'>
<input type="hidden" />
<div class="input-append">
<div class="uneditable-input span3">
<i class="fa-file fileupload-exists"></i><span class="fileupload-preview"></span>
</div>
<span class="btn btn-file">
<span class="fileupload-new">{!! __('label.select_file') !!}</span>
<span class='fileupload-exists'>{!! __('label.change') !!}</span>
<input type='file' name='file' />
</span>
<a href='#' class='btn fileupload-exists' data-dismiss='fileupload'>{!! __('buttons.remove') !!}</a>
</div>
</div>
</div>
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.upload')" name="upload" />
</form>
</div>
<div class="mediamgr_content">
<ul id='medialist' class='listfile'>
@foreach ($files as $file)
<li class="{{ $file['moduleId'] }}">
<div class="inlineDropDownContainer" style="float:right;">
<a href="javascript:void(0);" class="dropdown-toggle ticketDropDown" data-toggle="dropdown">
<i class="fa fa-ellipsis-v" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu">
<li class="nav-header">{!! __('subtitles.file') !!}</li>
<li><a href="{{ BASE_URL }}/files/get?module={{ $file['module'] }}&encName={{ $file['encName'] }}&ext={{ $file['extension'] }}&realName={{ $file['realName'] }}">{!! __('links.download') !!}</a></li>
@can('clients.edit')
<li><a href="{{ BASE_URL }}/clients/showClient/{{ $_GET['id'] }}?delFile={{ $file['id'] }}" class="delete"><i class="fa fa-trash"></i> {!! __('links.delete') !!}</a></li>
@endcan
</ul>
</div>
<a class="cboxElement" href="{{ BASE_URL }}/files/get?module={{ $file['module'] }}&encName={{ $file['encName'] }}&ext={{ $file['extension'] }}&realName={{ $file['realName'] }}">
@if (in_array(strtolower($file['extension']), $imgExtensions))
<img style='max-height: 50px; max-width: 70px;' src="{{ BASE_URL }}/files/get?module={{ $file['module'] }}&encName={{ $file['encName'] }}&ext={{ $file['extension'] }}&realName={{ $file['realName'] }}" alt="" />
@else
<img style='max-height: 50px; max-width: 70px;' src='{{ BASE_URL }}/dist/images/thumbs/doc.png' />
@endif
<span class="filename">{{ $file['realName'] }}</span>
</a>
</li>
@endforeach
<br class="clearall" />
</ul>
</div><!--mediamgr_content-->
<div style='clear:both'>&nbsp;</div>
</div>
</div>
</div>
</div>
@once
@push('scripts')
<script type="text/javascript">
@dispatchEvent('scripts.afterOpen')
jQuery(document).ready(function($)
{
leantime.clientsController.initClientTabs();
}
);
@dispatchEvent('scripts.beforeClose')
</script>
@endpush
@endonce
@endsection