0
0
Fork 0
mirror of https://github.com/kevinpapst/kimai2.git synced 2025-03-17 06:22:38 +00:00

added search modal for timesheet export ()

This commit is contained in:
Kevin Papst 2021-08-24 19:01:48 +02:00 committed by GitHub
parent f6a82ba119
commit f743503705
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 526 additions and 186 deletions

View file

@ -105,7 +105,7 @@ export default class KimaiAjaxModalForm extends KimaiReducedClickHandler {
// load new form from given content
if (jQuery(html).find('#form_modal .modal-content').length > 0) {
// switch classes, in case the modal type changed
// Support changing modal importance/types
remoteModal.on('hidden.bs.modal', function () {
if (remoteModal.hasClass('modal-danger')) {
remoteModal.removeClass('modal-danger');
@ -116,6 +116,16 @@ export default class KimaiAjaxModalForm extends KimaiReducedClickHandler {
remoteModal.addClass('modal-danger');
}
// Support changing modal sizes
let modalDialog = remoteModal.find('.modal-dialog');
let largeModal = jQuery(html).find('.modal-dialog').hasClass('modal-lg');
if (largeModal && !modalDialog.hasClass('modal-lg')) {
modalDialog.addClass('modal-lg');
}
if (!largeModal && modalDialog.hasClass('modal-lg')) {
modalDialog.removeClass('modal-lg');
}
jQuery('#remote_form_modal .modal-content').replaceWith(
jQuery(html).find('#form_modal .modal-content')
);
@ -155,7 +165,13 @@ export default class KimaiAjaxModalForm extends KimaiReducedClickHandler {
form.on('change', this._isDirtyHandler);
// click handler for modal save button, to send forms via ajax
form.on('submit', function(event){
form.on('submit', function(event) {
// if the form has a target, we let the normal HTML flow happen
if (form.attr('target') !== undefined) {
return true;
}
// otherwise we do some AJAX magic to process the form in the background
const btn = jQuery(formIdentifier + ' button[type=submit]').button('loading');
const eventName = form.attr('data-form-event');
const events = self.getContainer().getPlugin('event');

View file

@ -31,3 +31,4 @@
@import 'progressbar';
@import 'avatar';
@import 'security';
@import 'tabs';

View file

@ -1,3 +1,8 @@
.modal-header,
.modal-footer {
padding-top: 10px;
padding-bottom: 10px;
}
.modal-content {
border-radius: 3px;
box-shadow: 0 10px 80px rgba(0, 0, 0, 0.6);

3
assets/sass/tabs.scss Normal file
View file

@ -0,0 +1,3 @@
.tab-content {
margin-top: 20px;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -3,10 +3,10 @@
"app": {
"js": [
"build/runtime.b8e7bb04.js",
"build/app.39dfa7e1.js"
"build/app.e22732af.js"
],
"css": [
"build/app.068472ae.css"
"build/app.96253ac9.css"
]
},
"invoice": {

View file

@ -1,6 +1,6 @@
{
"build/app.css": "build/app.068472ae.css",
"build/app.js": "build/app.39dfa7e1.js",
"build/app.css": "build/app.96253ac9.css",
"build/app.js": "build/app.e22732af.js",
"build/invoice.css": "build/invoice.ff32661a.css",
"build/invoice.js": "build/invoice.19f36eca.js",
"build/invoice-pdf.css": "build/invoice-pdf.9a7468ef.css",

View file

@ -166,6 +166,31 @@ abstract class AbstractController extends BaseAbstractController implements Serv
return new LocaleFormats($this->container->get(LanguageFormattings::class), $locale);
}
private function getLastSearch(BaseQuery $query): ?array
{
$name = 'search_' . $this->getSearchName($query);
if (!$this->get('session')->has($name)) {
return null;
}
return $this->get('session')->get($name);
}
private function getSearchName(BaseQuery $query): string
{
return substr($query->getName(), 0, 50);
}
/**
* @param Request $request
* @internal
*/
protected function ignorePersistedSearch(Request $request): void
{
$request->query->set('performSearch', true);
}
protected function handleSearch(FormInterface $form, Request $request): bool
{
$data = $form->getData();
@ -173,23 +198,35 @@ abstract class AbstractController extends BaseAbstractController implements Serv
throw new \InvalidArgumentException('handleSearchForm() requires an instanceof BaseQuery as form data');
}
/** @var BookmarkRepository $bookmarkRepo */
$bookmarkRepo = $this->getDoctrine()->getRepository(Bookmark::class);
$bookmark = $bookmarkRepo->getSearchDefaultOptions($this->getUser(), $data->getName());
$submitData = $request->query->all();
// remove bookmark
if ($bookmark !== null && $request->query->has('removeDefaultQuery')) {
$bookmarkRepo->deleteBookmark($bookmark);
return true;
// allow to use forms with block-prefix
if (!empty($formName = $form->getConfig()->getName()) && $request->request->has($formName)) {
$submitData = $request->request->get($formName);
}
// apply bookmark ONLY if search form was not submitted manually
if ($bookmark !== null && !$request->query->has('performSearch')) {
$data->setBookmark($bookmark);
if (!$request->query->has('setDefaultQuery')) {
$searchName = $this->getSearchName($data);
/** @var BookmarkRepository $bookmarkRepo */
$bookmarkRepo = $this->getDoctrine()->getRepository(Bookmark::class);
$bookmark = $bookmarkRepo->getSearchDefaultOptions($this->getUser(), $searchName);
if ($bookmark !== null) {
if ($request->query->has('removeDefaultQuery')) {
$bookmarkRepo->deleteBookmark($bookmark);
$bookmark = null;
return true;
} else {
$data->setBookmark($bookmark);
}
}
// apply persisted search data ONLY if search form was not submitted manually
if (!$request->query->has('performSearch')) {
$sessionSearch = $this->getLastSearch($data);
if ($sessionSearch !== null) {
$submitData = array_merge($sessionSearch, $submitData);
} elseif ($bookmark !== null && !$request->query->has('setDefaultQuery')) {
$submitData = array_merge($bookmark->getContent(), $submitData);
}
}
@ -205,24 +242,39 @@ abstract class AbstractController extends BaseAbstractController implements Serv
if (!$form->isValid()) {
$data->resetByFormError($form->getErrors(true));
} elseif ($request->query->has('setDefaultQuery')) {
$params = [];
foreach ($form->all() as $name => $child) {
$params[$name] = $child->getViewData();
}
$filter = ['page', 'setDefaultQuery', 'removeDefaultQuery', 'performSearch'];
foreach ($filter as $name) {
if (isset($params[$name])) {
unset($params[$name]);
}
}
return false;
}
$params = [];
foreach ($form->all() as $name => $child) {
$params[$name] = $child->getViewData();
}
// these should NEVER be saved
$filter = ['setDefaultQuery', 'removeDefaultQuery', 'performSearch'];
foreach ($filter as $name) {
if (isset($params[$name])) {
unset($params[$name]);
}
}
$this->get('session')->set('search_' . $searchName, $params);
// filter stuff, that does not belong in a bookmark
$filter = ['page'];
foreach ($filter as $name) {
if (isset($params[$name])) {
unset($params[$name]);
}
}
if ($request->query->has('setDefaultQuery')) {
if ($bookmark === null) {
$bookmark = new Bookmark();
$bookmark->setType(Bookmark::SEARCH_DEFAULT);
$bookmark->setUser($this->getUser());
$bookmark->setName(substr($data->getName(), 0, 50));
$bookmark->setName($searchName);
}
$bookmark->setContent($params);

View file

@ -23,6 +23,7 @@ use App\Form\MultiUpdate\MultiUpdateTableDTO;
use App\Form\MultiUpdate\TimesheetMultiUpdate;
use App\Form\MultiUpdate\TimesheetMultiUpdateDTO;
use App\Form\TimesheetEditForm;
use App\Form\Toolbar\TimesheetExportToolbarForm;
use App\Form\Toolbar\TimesheetToolbarForm;
use App\Repository\ActivityRepository;
use App\Repository\ProjectRepository;
@ -33,6 +34,7 @@ use App\Timesheet\TimesheetService;
use App\Timesheet\TrackingMode\TrackingModeInterface;
use Doctrine\Common\Collections\ArrayCollection;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@ -47,10 +49,6 @@ abstract class TimesheetAbstractController extends AbstractController
* @var EventDispatcherInterface
*/
protected $dispatcher;
/**
* @var ServiceExport
*/
protected $exportService;
/**
* @var TimesheetService
*/
@ -63,13 +61,11 @@ abstract class TimesheetAbstractController extends AbstractController
public function __construct(
TimesheetRepository $repository,
EventDispatcherInterface $dispatcher,
ServiceExport $exportService,
TimesheetService $timesheetService,
SystemConfiguration $configuration
) {
$this->repository = $repository;
$this->dispatcher = $dispatcher;
$this->exportService = $exportService;
$this->service = $timesheetService;
$this->configuration = $configuration;
}
@ -241,40 +237,53 @@ abstract class TimesheetAbstractController extends AbstractController
]);
}
protected function export(Request $request, string $exporterId): Response
protected function export(Request $request, ServiceExport $serviceExport): Response
{
$query = new TimesheetQuery();
$query = $this->createDefaultQuery();
$form = $this->getToolbarForm($query);
$form->setData($query);
$form->submit($request->query->all(), false);
$form = $this->getExportForm($query);
$factory = $this->getDateTimeFactory();
// by default the current month is exported, but it can be overwritten
// this should not be removed, otherwise we would export EVERY available record in the admin section
// as the default toolbar query does neither limit the user nor the date-range!
if (null === $query->getBegin()) {
$query->setBegin($factory->getStartOfMonth());
if ($request->isMethod(Request::METHOD_POST)) {
$this->ignorePersistedSearch($request);
}
$query->getBegin()->setTime(0, 0, 0);
if (null === $query->getEnd()) {
$query->setEnd($factory->getEndOfMonth());
if ($this->handleSearch($form, $request)) {
return $this->redirectToRoute($this->getExportRoute());
}
$query->getEnd()->setTime(23, 59, 59);
$this->prepareQuery($query);
$entries = $this->repository->getTimesheetsForQuery($query);
$exporter = $this->exportService->getTimesheetExporterById($exporterId);
if (null === $exporter) {
throw $this->createNotFoundException('Invalid timesheet exporter given');
// make sure that we use the "expected time range"
if (null !== $query->getBegin()) {
$query->getBegin()->setTime(0, 0, 0);
}
if (null !== $query->getEnd()) {
$query->getEnd()->setTime(23, 59, 59);
}
return $exporter->render($entries, $query);
$entries = $this->repository->getTimesheetResult($query);
$stats = $entries->getStatistic();
// perform the real export
if ($request->isMethod(Request::METHOD_POST)) {
$type = $request->request->get('exporter');
if (null !== $type) {
$exporter = $serviceExport->getTimesheetExporterById($type);
if (null === $exporter) {
$form->addError(new FormError('Invalid timesheet exporter given'));
} else {
return $exporter->render($entries->getResults(true), $query);
}
}
}
return $this->render('timesheet/layout-export.html.twig', [
'form' => $form->createView(),
'route_back' => $this->getTimesheetRoute(),
'exporter' => $serviceExport->getTimesheetExporter(),
'stats' => $stats,
]);
}
protected function multiUpdate(Request $request, string $renderTemplate)
@ -518,6 +527,16 @@ abstract class TimesheetAbstractController extends AbstractController
]);
}
protected function getExportForm(TimesheetQuery $query): FormInterface
{
return $this->createForm(TimesheetExportToolbarForm::class, $query, [
'action' => $this->generateUrl($this->getExportRoute()),
'timezone' => $this->getDateTimeFactory()->getTimezone()->getName(),
'method' => Request::METHOD_POST,
'include_user' => $this->includeUserInForms('toolbar'),
]);
}
protected function getPermissionEditExport(): string
{
return 'edit_export_own_timesheet';
@ -563,11 +582,29 @@ abstract class TimesheetAbstractController extends AbstractController
return 'timesheet_multi_delete';
}
protected function getExportRoute(): string
{
return 'timesheet_export';
}
protected function canSeeStartEndTime(): bool
{
return $this->getTrackingMode()->canSeeBeginAndEndTimes();
}
protected function getQueryNamePrefix(): string
{
return 'MyTimes';
}
protected function createDefaultQuery(string $suffix = 'Listing'): TimesheetQuery
{
$query = new TimesheetQuery();
$query->setName($this->getQueryNamePrefix() . $suffix);
return $query;
}
abstract protected function getDuplicateForm(Timesheet $entry): FormInterface;
abstract protected function getCreateForm(Timesheet $entry): FormInterface;

View file

@ -11,10 +11,10 @@ namespace App\Controller;
use App\Entity\Timesheet;
use App\Event\TimesheetMetaDisplayEvent;
use App\Export\ServiceExport;
use App\Form\TimesheetEditForm;
use App\Repository\ActivityRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\TimesheetQuery;
use App\Repository\TagRepository;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\Form\FormInterface;
@ -35,20 +35,19 @@ class TimesheetController extends TimesheetAbstractController
*/
public function indexAction(int $page, Request $request): Response
{
$query = new TimesheetQuery();
$query = $this->createDefaultQuery();
$query->setPage($page);
$query->setName('MyTimesListing');
return $this->index($query, $request, 'timesheet', 'timesheet/index.html.twig', TimesheetMetaDisplayEvent::TIMESHEET);
}
/**
* @Route(path="/export/{exporter}", name="timesheet_export", methods={"GET"})
* @Route(path="/export/", name="timesheet_export", methods={"GET", "POST"})
* @Security("is_granted('export_own_timesheet')")
*/
public function exportAction(Request $request, string $exporter): Response
public function exportAction(Request $request, ServiceExport $serviceExport): Response
{
return $this->export($request, $exporter);
return $this->export($request, $serviceExport);
}
/**

View file

@ -14,6 +14,7 @@ use App\Entity\Team;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Event\TimesheetMetaDisplayEvent;
use App\Export\ServiceExport;
use App\Form\Model\MultiUserTimesheet;
use App\Form\TimesheetAdminEditForm;
use App\Form\TimesheetMultiUserEditForm;
@ -45,19 +46,19 @@ class TimesheetTeamController extends TimesheetAbstractController
*/
public function indexAction(int $page, Request $request): Response
{
$query = new TimesheetQuery();
$query = $this->createDefaultQuery();
$query->setPage($page);
$query->setName('TeamTimesListing');
return $this->index($query, $request, 'admin_timesheet', 'timesheet-team/index.html.twig', TimesheetMetaDisplayEvent::TEAM_TIMESHEET);
}
/**
* @Route(path="/export/{exporter}", name="admin_timesheet_export", methods={"GET"})
* @Route(path="/export/", name="admin_timesheet_export", methods={"GET", "POST"})
* @Security("is_granted('export_other_timesheet')")
*/
public function exportAction(Request $request, string $exporter): Response
public function exportAction(Request $request, ServiceExport $serviceExport): Response
{
return $this->export($request, $exporter);
return $this->export($request, $serviceExport);
}
/**
@ -233,6 +234,11 @@ class TimesheetTeamController extends TimesheetAbstractController
return 'admin_timesheet_edit';
}
protected function getExportRoute(): string
{
return 'admin_timesheet_export';
}
protected function getMultiUpdateRoute(): string
{
return 'admin_timesheet_multi_update';
@ -247,4 +253,9 @@ class TimesheetTeamController extends TimesheetAbstractController
{
return true;
}
protected function getQueryNamePrefix(): string
{
return 'TeamTimes';
}
}

View file

@ -1,39 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\EventSubscriber\Actions;
use App\Event\PageActionsEvent;
use App\Export\ServiceExport;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
abstract class AbstractTimesheetsSubscriber extends AbstractActionsSubscriber
{
private $serviceExport;
public function __construct(AuthorizationCheckerInterface $security, UrlGeneratorInterface $urlGenerator, ServiceExport $serviceExport)
{
parent::__construct($security, $urlGenerator);
$this->serviceExport = $serviceExport;
}
protected function addExporter(PageActionsEvent $event, string $routeExport): void
{
$allExporter = $this->serviceExport->getTimesheetExporter();
if (\count($allExporter) === 1) {
$event->addAction('download', ['url' => $this->path($routeExport, ['exporter' => $allExporter[0]->getId()]), 'class' => 'toolbar-action']);
} else {
foreach ($allExporter as $exporter) {
$id = $exporter->getId();
$event->addActionToSubmenu('download', 'exporter.' . $id, ['title' => 'button.' . $id, 'url' => $this->path($routeExport, ['exporter' => $id]), 'class' => 'toolbar-action exporter-' . $id]);
}
}
}
}

View file

@ -39,7 +39,7 @@ class ReportingSubscriber extends AbstractActionsSubscriber
if ($report instanceof Report) {
$subMenu = $report->getReportIcon();
}
$event->addActionToSubmenu($subMenu, $report->getId(), ['title' => $report->getLabel(), 'translation_domain' => 'reporting', 'url' => $this->path($report->getRoute()), 'class' => 'toolbar-action report-' . $report->getId()]);
$event->addActionToSubmenu($subMenu, $report->getId(), ['title' => $report->getLabel(), 'translation_domain' => 'reporting', 'url' => $this->path($report->getRoute()), 'class' => 'report-' . $report->getId()]);
}
$event->addHelp($this->documentationLink('reporting.html'));

View file

@ -11,7 +11,7 @@ namespace App\EventSubscriber\Actions;
use App\Event\PageActionsEvent;
class TimesheetsSubscriber extends AbstractTimesheetsSubscriber
class TimesheetsSubscriber extends AbstractActionsSubscriber
{
public static function getActionName(): string
{
@ -24,7 +24,7 @@ class TimesheetsSubscriber extends AbstractTimesheetsSubscriber
$event->addColumnToggle('#modal_timesheet');
if ($this->isGranted('export_own_timesheet')) {
$this->addExporter($event, 'timesheet_export');
$event->addAction('download', ['url' => $this->path('timesheet_export'), 'class' => 'toolbar-action modal-ajax-form']);
}
if ($this->isGranted('create_own_timesheet')) {

View file

@ -11,7 +11,7 @@ namespace App\EventSubscriber\Actions;
use App\Event\PageActionsEvent;
class TimesheetsTeamSubscriber extends AbstractTimesheetsSubscriber
class TimesheetsTeamSubscriber extends AbstractActionsSubscriber
{
public static function getActionName(): string
{
@ -24,7 +24,7 @@ class TimesheetsTeamSubscriber extends AbstractTimesheetsSubscriber
$event->addColumnToggle('#modal_timesheet_admin');
if ($this->isGranted('export_other_timesheet')) {
$this->addExporter($event, 'admin_timesheet_export');
$event->addAction('download', ['url' => $this->path('admin_timesheet_export'), 'class' => 'toolbar-action modal-ajax-form']);
}
if ($this->isGranted('create_other_timesheet')) {

View file

@ -706,36 +706,43 @@ abstract class AbstractSpreadsheetRenderer
$entryHeaderRow++;
}
if (null !== $durationColumn) {
$startCoordinate = $sheet->getCellByColumnAndRow($durationColumn, 2)->getCoordinate();
$endCoordinate = $sheet->getCellByColumnAndRow($durationColumn, $entryHeaderRow - 1)->getCoordinate();
$this->setDurationTotal($sheet, $durationColumn, $entryHeaderRow, $startCoordinate, $endCoordinate);
$style = $sheet->getStyleByColumnAndRow($durationColumn, $entryHeaderRow);
$style->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN);
$style->getFont()->setBold(true);
}
if ($this->isTotalRowSupported()) {
if (null !== $durationColumn) {
$startCoordinate = $sheet->getCellByColumnAndRow($durationColumn, 2)->getCoordinate();
$endCoordinate = $sheet->getCellByColumnAndRow($durationColumn, $entryHeaderRow - 1)->getCoordinate();
$this->setDurationTotal($sheet, $durationColumn, $entryHeaderRow, $startCoordinate, $endCoordinate);
$style = $sheet->getStyleByColumnAndRow($durationColumn, $entryHeaderRow);
$style->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN);
$style->getFont()->setBold(true);
}
if (null !== $rateColumn) {
$startCoordinate = $sheet->getCellByColumnAndRow($rateColumn, 2)->getCoordinate();
$endCoordinate = $sheet->getCellByColumnAndRow($rateColumn, $entryHeaderRow - 1)->getCoordinate();
$this->setRateTotal($sheet, $rateColumn, $entryHeaderRow, $startCoordinate, $endCoordinate);
$style = $sheet->getStyleByColumnAndRow($rateColumn, $entryHeaderRow);
$style->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN);
$style->getFont()->setBold(true);
}
if (null !== $rateColumn) {
$startCoordinate = $sheet->getCellByColumnAndRow($rateColumn, 2)->getCoordinate();
$endCoordinate = $sheet->getCellByColumnAndRow($rateColumn, $entryHeaderRow - 1)->getCoordinate();
$this->setRateTotal($sheet, $rateColumn, $entryHeaderRow, $startCoordinate, $endCoordinate);
$style = $sheet->getStyleByColumnAndRow($rateColumn, $entryHeaderRow);
$style->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN);
$style->getFont()->setBold(true);
}
if (null !== $internalRateColumn) {
$startCoordinate = $sheet->getCellByColumnAndRow($internalRateColumn, 2)->getCoordinate();
$endCoordinate = $sheet->getCellByColumnAndRow($internalRateColumn, $entryHeaderRow - 1)->getCoordinate();
$this->setRateTotal($sheet, $internalRateColumn, $entryHeaderRow, $startCoordinate, $endCoordinate);
$style = $sheet->getStyleByColumnAndRow($internalRateColumn, $entryHeaderRow);
$style->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN);
$style->getFont()->setBold(true);
if (null !== $internalRateColumn) {
$startCoordinate = $sheet->getCellByColumnAndRow($internalRateColumn, 2)->getCoordinate();
$endCoordinate = $sheet->getCellByColumnAndRow($internalRateColumn, $entryHeaderRow - 1)->getCoordinate();
$this->setRateTotal($sheet, $internalRateColumn, $entryHeaderRow, $startCoordinate, $endCoordinate);
$style = $sheet->getStyleByColumnAndRow($internalRateColumn, $entryHeaderRow);
$style->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN);
$style->getFont()->setBold(true);
}
}
return $spreadsheet;
}
protected function isTotalRowSupported(): bool
{
return false;
}
/**
* @param ExportItemInterface[] $exportItems
* @param TimesheetQuery $query

View file

@ -167,7 +167,9 @@ trait RendererTrait
}
}
$allBudgets = $projectStatisticService->getBudgetStatisticModelForProjects($projects, $query->getEnd());
$today = $this->getToday($query);
$allBudgets = $projectStatisticService->getBudgetStatisticModelForProjects($projects, $today);
foreach ($allBudgets as $projectId => $statisticModel) {
$project = $statisticModel->getProject();
@ -183,6 +185,29 @@ trait RendererTrait
return $summary;
}
private function getToday(TimesheetQuery $query): \DateTime
{
$end = $query->getEnd();
if ($end !== null) {
return $end;
}
if ($query->getCurrentUser() !== null) {
$timezone = $query->getCurrentUser()->getTimezone();
return new \DateTime('now', new \DateTimeZone($timezone));
}
if ($query->getUser() !== null) {
$timezone = $query->getUser()->getTimezone();
return new \DateTime('now', new \DateTimeZone($timezone));
}
return new \DateTime();
}
/**
* @param ExportItemInterface[] $exportItems
* @param TimesheetQuery $query
@ -236,7 +261,9 @@ trait RendererTrait
}
}
$allBudgets = $activityStatisticService->getBudgetStatisticModelForActivities($activities, $query->getEnd());
$today = $this->getToday($query);
$allBudgets = $activityStatisticService->getBudgetStatisticModelForActivities($activities, $today);
foreach ($allBudgets as $activityId => $statisticModel) {
$project = $statisticModel->getActivity()->getProject();

View file

@ -15,6 +15,11 @@ use PhpOffice\PhpSpreadsheet\Style\Alignment;
class XlsxRenderer extends AbstractSpreadsheetRenderer
{
protected function isTotalRowSupported(): bool
{
return true;
}
public function getFileExtension(): string
{
return '.xlsx';

View file

@ -0,0 +1,63 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form\Toolbar;
use App\Repository\Query\TimesheetQuery;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Defines the form used for filtering the timesheet.
*/
class TimesheetExportToolbarForm extends AbstractToolbarForm
{
public function getBlockPrefix()
{
return 'export';
}
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$newOptions = [];
if ($options['ignore_date'] === true) {
$newOptions['ignore_date'] = true;
}
$this->addSearchTermInputField($builder);
$this->addDateRange($builder, ['timezone' => $options['timezone']]);
$this->addCustomerMultiChoice($builder, $newOptions, true);
$this->addProjectMultiChoice($builder, $newOptions, true, true);
$this->addActivityMultiChoice($builder, [], true);
$this->addTagInputField($builder);
if ($options['include_user']) {
$this->addUsersChoice($builder);
}
$this->addTimesheetStateChoice($builder);
$this->addBillableChoice($builder);
$this->addExportStateChoice($builder);
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => TimesheetQuery::class,
'csrf_protection' => false,
'include_user' => false,
'ignore_date' => true,
'timezone' => date_default_timezone_get(),
]);
}
}

View file

@ -0,0 +1,56 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository\Result;
use App\Repository\Loader\TimesheetLoader;
use Doctrine\ORM\QueryBuilder;
class TimesheetResult
{
private $queryBuilder;
public function __construct(QueryBuilder $queryBuilder)
{
$this->queryBuilder = $queryBuilder;
}
public function getStatistic(): TimesheetResultStatistic
{
$qb = clone $this->queryBuilder;
$qb
->resetDQLPart('select')
->resetDQLPart('orderBy')
->select('COUNT(t.id) as counter')
->addSelect('COALESCE(SUM(t.duration), 0) as duration')
;
$result = $qb->getQuery()->getArrayResult()[0];
return new TimesheetResultStatistic($result['counter'], $result['duration']);
}
public function toIterable(): iterable
{
$query = $this->queryBuilder->getQuery();
return $query->toIterable();
}
public function getResults(bool $fullyHydrated = false): array
{
$query = $this->queryBuilder->getQuery();
$results = $query->getResult();
$loader = new TimesheetLoader($this->queryBuilder->getEntityManager(), $fullyHydrated);
$loader->loadResults($results);
return $results;
}
}

View file

@ -0,0 +1,32 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository\Result;
class TimesheetResultStatistic
{
private $count = 0;
private $duration = 0;
public function __construct(int $count, int $duration)
{
$this->count = $count;
$this->duration = $duration;
}
public function getCount(): int
{
return $this->count;
}
public function getDuration(): int
{
return $this->duration;
}
}

View file

@ -25,6 +25,7 @@ use App\Repository\Loader\TimesheetLoader;
use App\Repository\Paginator\LoaderPaginator;
use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\TimesheetQuery;
use App\Repository\Result\TimesheetResult;
use DateInterval;
use DateTime;
use Doctrine\DBAL\Types\Types;
@ -749,7 +750,7 @@ class TimesheetRepository extends EntityRepository
* Especially the following question is still un-answered!
*
* Should a teamlead:
* 1 . see all records of his team-members, even if they recorded times for projects invisible to him
* 1. see all records of his team-members, even if they recorded times for projects invisible to him
* 2. only see records for projects which can be accessed by hom (current situation)
*/
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): bool
@ -835,6 +836,13 @@ class TimesheetRepository extends EntityRepository
return $this->getHydratedResultsByQuery($qb, $fullyHydrated);
}
public function getTimesheetResult(TimesheetQuery $query): TimesheetResult
{
$qb = $this->getQueryBuilderForQuery($query);
return new TimesheetResult($qb);
}
/**
* @param QueryBuilder $qb
* @param bool $fullyHydrated

View file

@ -4,6 +4,8 @@
data-title="{{- get_title()|e('html_attr') -}}"
{% endblock %}
{% block page_title %}{{- get_title() -}}{% endblock %}
{% block after_body_start %}
{% embed 'embeds/modal.html.twig' %}
{% block modal_id %}remote_form_modal{% endblock %}

View file

@ -5,7 +5,7 @@
{% set _reset = reset is defined and reset is same as (false) ? false : true %}
<div class="box box-primary">
{% block form_before %}{% endblock %}
{{ form_start(form) }}
{{ form_start(form, formStartOptions|default({})) }}
<div class="box-header with-border">
<h3 class="box-title">
{{ title }}
@ -20,7 +20,9 @@
{% endblock %}
</div>
<div class="box-footer">
<input type="submit" value="{{ 'action.save'|trans }}" class="btn btn-primary" />
{% block submit_button %}
<input type="submit" data-loading-text="{{ (submit_button|default('action.save'))|trans }}…" value="{{ (submit_button|default('action.save'))|trans }}" class="btn btn-primary" />
{% endblock %}
{% if _back is not same as (false) %}
<a href="{{ _back }}" class="btn btn-link">{{ 'action.back'|trans }}</a>
{% endif %}

View file

@ -4,10 +4,10 @@ REQUIRED CHANGES NEED TO BE DONE IN THESE FILES:
- default/_form_modal.html.twig
#}
<div class="modal {% block modal_class %}{% endblock %}" id="{% block modal_id %}form_modal{% endblock %}" tabindex="-1" role="dialog" aria-labelledby="{{ block('modal_id') }}_label">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-dialog {% block modal_size %}modal-lg{% endblock %}" role="document">
<div class="modal-content">
{% block form_before %}{% endblock %}
{% block modal_before %}{{ form_start(form) }}{% endblock %}
{% block modal_before %}{{ form_start(form, formStartOptions|default({})) }}{% endblock %}
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="{{ 'action.close'|trans }}"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title" id="{{ block('modal_id') }}_label">
@ -28,7 +28,9 @@ REQUIRED CHANGES NEED TO BE DONE IN THESE FILES:
</div>
<div class="modal-footer">
{% block modal_footer %}
<button type="submit" class="btn btn-primary pull-left modal-form-save" data-loading-text="{{ 'action.save'|trans }}..." id="{{ block('modal_id') }}_save">{{ 'action.save'|trans }}</button>
{% block submit_button %}
<button type="submit" class="btn btn-primary pull-left modal-form-save" data-loading-text="{{ (submit_button|default('action.save'))|trans }}…" id="{{ block('modal_id') }}_save">{{ (submit_button|default('action.save'))|trans }}</button>
{% endblock %}
<button type="button" class="btn btn-default btn-cancel" data-dismiss="modal">{{ 'action.close'|trans }}</button>
{% endblock %}
</div>

View file

@ -4,7 +4,7 @@ REQUIRED CHANGES NEED TO BE DONE IN THESE FILES:
- default/_form_modal.html.twig
#}
<div class="modal{% if block('modal_class') is defined %} {{ block('modal_class') }}{% endif %}" id="{{ block('modal_id') }}" tabindex="-1" role="dialog" aria-labelledby="{{ block('modal_id') }}_label">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-dialog {% block modal_size %}modal-lg{% endblock %}" role="document">
<div class="modal-content">
{% if block('modal_before') is defined %}{{ block('modal_before') }}{% endif %}
<div class="modal-header">

View file

@ -211,7 +211,7 @@
<h4><i class="icon {{ icon|icon(icon) }}"></i> {{ title|trans }}</h4>
{{ description|trans }}
{% elseif icon %}
<h4><i class="icon {{ icon|icon(icon) }}"></i> {{ description|trans }}</h4>
<i class="icon {{ icon|icon(icon) }}"></i> {{ description|trans }}
{% else %}
{{ description|trans }}
{% endif %}

View file

@ -0,0 +1,31 @@
{% extends app.request.xmlHttpRequest ? 'form.html.twig' : 'base.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% block page_title %}{{ 'menu.export'|trans }}{% endblock %}
{% block main %}
{% if stats.count > 1000 %}
{{ widgets.alert('warning', ('export.warn_result_amount'|trans({'%count%': stats.count})), null, 'warning') }}
{% endif %}
{% set formEditTemplate = app.request.xmlHttpRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig' %}
{% set formOptions = {
'title': 'menu.export'|trans,
'form': form,
'back': path(route_back),
'formStartOptions': {'attr': {'class': 'form-narrow searchform', 'target': '_blank'}},
} %}
{% embed formEditTemplate with formOptions %}
{% form_theme form 'form/search-form.html.twig' %}
{% block modal_size %}{% endblock %}
{% block form_body %}
{{ form_widget(form) }}
{% endblock %}
{% block submit_button %}
<div class="btn-group pull-left" role="group">
{% for exp in exporter %}
<button id="export-btn-{{ exp.id }}" formtarget="_blank" type="submit" name="exporter" value="{{ exp.id }}" class="btn btn-primary" data-loading-text="{{ ('button.' ~ exp.id)|trans }}…">{{ ('button.' ~ exp.id)|trans }}</button>
{% endfor %}
</div>
{% endblock %}
{% endembed %}
{% endblock %}

View file

@ -41,11 +41,8 @@ class TimesheetControllerTest extends ControllerBaseTest
$this->assertHasNoEntriesWithFilter($client);
$this->assertPageActions($client, [
'search' => '#',
'toolbar-action exporter-csv' => $this->createUrl('/timesheet/export/csv'),
'toolbar-action exporter-print' => $this->createUrl('/timesheet/export/print'),
'toolbar-action exporter-pdf' => $this->createUrl('/timesheet/export/pdf'),
'toolbar-action exporter-xlsx' => $this->createUrl('/timesheet/export/xlsx'),
'visibility' => '#',
'download toolbar-action modal-ajax-form' => $this->createUrl('/timesheet/export/'),
'create modal-ajax-form' => $this->createUrl('/timesheet/create'),
'help' => 'https://www.kimai.org/documentation/timesheet.html'
]);
@ -134,18 +131,17 @@ class TimesheetControllerTest extends ControllerBaseTest
$fixture->setStartDate(new \DateTime('-10 days'));
$this->importFixture($fixture);
$this->request($client, '/timesheet/');
$this->request($client, '/timesheet/export/');
$this->assertTrue($client->getResponse()->isSuccessful());
$dateRange = (new \DateTime('-10 days'))->format('Y-m-d') . DateRangeType::DATE_SPACER . (new \DateTime())->format('Y-m-d');
$form = $client->getCrawler()->filter('form.searchform')->form();
$form->getFormNode()->setAttribute('action', $this->createUrl('/timesheet/export/print'));
$client->submit($form, [
'state' => 1,
'pageSize' => 25,
'daterange' => $dateRange,
'customers' => [],
$client->submitForm('export-btn-print', [
'export' => [
'state' => 1,
'daterange' => $dateRange,
'customers' => [],
]
]);
$this->assertTrue($client->getResponse()->isSuccessful());

View file

@ -43,11 +43,8 @@ class TimesheetTeamControllerTest extends ControllerBaseTest
$this->assertPageActions($client, [
'search' => '#',
'toolbar-action exporter-csv' => $this->createUrl('/team/timesheet/export/csv'),
'toolbar-action exporter-print' => $this->createUrl('/team/timesheet/export/print'),
'toolbar-action exporter-pdf' => $this->createUrl('/team/timesheet/export/pdf'),
'toolbar-action exporter-xlsx' => $this->createUrl('/team/timesheet/export/xlsx'),
'visibility' => '#',
'download toolbar-action modal-ajax-form' => $this->createUrl('/team/timesheet/export/'),
'create-ts modal-ajax-form' => $this->createUrl('/team/timesheet/create'),
'create-ts-mu modal-ajax-form' => $this->createUrl('/team/timesheet/create_mu'),
'help' => 'https://www.kimai.org/documentation/timesheet.html'
@ -146,18 +143,17 @@ class TimesheetTeamControllerTest extends ControllerBaseTest
$fixture->setStartDate(new \DateTime('-10 days'));
$this->importFixture($fixture);
$this->request($client, '/team/timesheet/');
$this->request($client, '/team/timesheet/export/');
$this->assertTrue($client->getResponse()->isSuccessful());
$dateRange = (new \DateTime('-10 days'))->format('Y-m-d') . DateRangeType::DATE_SPACER . (new \DateTime())->format('Y-m-d');
$form = $client->getCrawler()->filter('form.searchform')->form();
$form->getFormNode()->setAttribute('action', $this->createUrl('/team/timesheet/export/print'));
$client->submit($form, [
'state' => 1,
'pageSize' => 25,
'daterange' => $dateRange,
'customers' => [],
$client->submitForm('export-btn-print', [
'export' => [
'state' => 1,
'daterange' => $dateRange,
'customers' => [],
]
]);
$this->assertTrue($client->getResponse()->isSuccessful());

View file

@ -13,7 +13,6 @@ use App\EventSubscriber\Actions\TimesheetsSubscriber;
/**
* @covers \App\EventSubscriber\Actions\AbstractActionsSubscriber
* @covers \App\EventSubscriber\Actions\AbstractTimesheetsSubscriber
* @covers \App\EventSubscriber\Actions\TimesheetsSubscriber
*/
class TimesheetsSubscriberTest extends AbstractActionsSubscriberTest

View file

@ -13,7 +13,6 @@ use App\EventSubscriber\Actions\TimesheetsTeamSubscriber;
/**
* @covers \App\EventSubscriber\Actions\AbstractActionsSubscriber
* @covers \App\EventSubscriber\Actions\AbstractTimesheetsSubscriber
* @covers \App\EventSubscriber\Actions\TimesheetsTeamSubscriber
*/
class TimesheetsTeamSubscriberTest extends AbstractActionsSubscriberTest

View file

@ -34,7 +34,7 @@ class CsvRendererTest extends AbstractRendererTest
public function getTestModel()
{
return [
['400', '2437.12', ' EUR 1,947.99 ', 7, 5, 1, 2, 2]
['400', '2437.12', ' EUR 1,947.99 ', 6, 5, 1, 2, 2]
];
}
@ -55,8 +55,6 @@ class CsvRendererTest extends AbstractRendererTest
$this->assertTrue(file_exists($file->getRealPath()));
$content = file_get_contents($file->getRealPath());
$this->assertStringContainsString('"' . $totalDuration . '"', $content);
$this->assertStringContainsString('"' . $totalRate . '"', $content);
$this->assertStringContainsString('"' . $expectedRate . '"', $content);
$this->assertEquals($expectedRows, substr_count($content, PHP_EOL));
$this->assertEquals($expectedDescriptions, substr_count($content, 'activity description'));
@ -108,7 +106,7 @@ class CsvRendererTest extends AbstractRendererTest
27 => 'ORDER-123',
];
self::assertEquals(7, \count($all));
self::assertEquals(6, \count($all));
self::assertEquals($expected, $all[5]);
self::assertEquals(\count($expected), \count($all[0]));
self::assertEquals('foo', $all[4][14]);

View file

@ -31,7 +31,7 @@ class CsvRendererTest extends AbstractRendererTest
public function getTestModel()
{
return [
['400', '2437.12', ' EUR 1,947.99 ', 7, 5, 1, 2, 2]
['400', '2437.12', ' EUR 1,947.99 ', 6, 5, 1, 2, 2]
];
}
@ -52,8 +52,6 @@ class CsvRendererTest extends AbstractRendererTest
$this->assertTrue(file_exists($file->getRealPath()));
$content = file_get_contents($file->getRealPath());
$this->assertStringContainsString('"' . $totalDuration . '"', $content);
$this->assertStringContainsString('"' . $totalRate . '"', $content);
$this->assertStringContainsString('"' . $expectedRate . '"', $content);
$this->assertEquals($expectedRows, substr_count($content, PHP_EOL));
$this->assertEquals($expectedDescriptions, substr_count($content, 'activity description'));
@ -105,7 +103,7 @@ class CsvRendererTest extends AbstractRendererTest
27 => 'ORDER-123',
];
self::assertEquals(7, \count($all));
self::assertEquals(6, \count($all));
self::assertEquals($expected, $all[5]);
self::assertEquals(\count($expected), \count($all[0]));
self::assertEquals('foo', $all[4][14]);

View file

@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Repository\Result;
use App\Repository\Result\TimesheetResultStatistic;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Repository\Result\TimesheetResultStatistic
*/
class TimesheetResultStatisticTest extends TestCase
{
public function testConstruct()
{
$sut = new TimesheetResultStatistic(13, 7705);
self::assertSame(13, $sut->getCount());
self::assertSame(7705, $sut->getDuration());
}
}

View file

@ -1122,6 +1122,10 @@
<source>export.date_copyright</source>
<target>Erstellt %date% mit %kimai%</target>
</trans-unit>
<trans-unit id="export.warn_result_amount">
<source>export.warn_result_amount</source>
<target>Ihre Suche führt zu %count% Ergebnissen. Sollte der Export fehlschlagen, müssen Sie die Suche weiter eingrenzen.</target>
</trans-unit>
<!--
Navbar - recent entries and activities
-->

View file

@ -1122,6 +1122,10 @@
<source>export.date_copyright</source>
<target>Created %date% with %kimai%</target>
</trans-unit>
<trans-unit id="export.warn_result_amount">
<source>export.warn_result_amount</source>
<target>Your search leads to %count% results. If the export fails, you have to narrow down your search further.</target>
</trans-unit>
<!--
Navbar - recent entries and activities
-->