added search modal for timesheet export (#2728)

This commit is contained in:
Kevin Papst
2021-08-24 19:01:48 +02:00
committed by GitHub
parent f6a82ba119
commit f743503705
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 // load new form from given content
if (jQuery(html).find('#form_modal .modal-content').length > 0) { 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 () { remoteModal.on('hidden.bs.modal', function () {
if (remoteModal.hasClass('modal-danger')) { if (remoteModal.hasClass('modal-danger')) {
remoteModal.removeClass('modal-danger'); remoteModal.removeClass('modal-danger');
@@ -116,6 +116,16 @@ export default class KimaiAjaxModalForm extends KimaiReducedClickHandler {
remoteModal.addClass('modal-danger'); 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('#remote_form_modal .modal-content').replaceWith(
jQuery(html).find('#form_modal .modal-content') jQuery(html).find('#form_modal .modal-content')
); );
@@ -155,7 +165,13 @@ export default class KimaiAjaxModalForm extends KimaiReducedClickHandler {
form.on('change', this._isDirtyHandler); form.on('change', this._isDirtyHandler);
// click handler for modal save button, to send forms via ajax // 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 btn = jQuery(formIdentifier + ' button[type=submit]').button('loading');
const eventName = form.attr('data-form-event'); const eventName = form.attr('data-form-event');
const events = self.getContainer().getPlugin('event'); const events = self.getContainer().getPlugin('event');

View File

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

View File

@@ -1,3 +1,8 @@
.modal-header,
.modal-footer {
padding-top: 10px;
padding-bottom: 10px;
}
.modal-content { .modal-content {
border-radius: 3px; border-radius: 3px;
box-shadow: 0 10px 80px rgba(0, 0, 0, 0.6); 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": { "app": {
"js": [ "js": [
"build/runtime.b8e7bb04.js", "build/runtime.b8e7bb04.js",
"build/app.39dfa7e1.js" "build/app.e22732af.js"
], ],
"css": [ "css": [
"build/app.068472ae.css" "build/app.96253ac9.css"
] ]
}, },
"invoice": { "invoice": {

View File

@@ -1,6 +1,6 @@
{ {
"build/app.css": "build/app.068472ae.css", "build/app.css": "build/app.96253ac9.css",
"build/app.js": "build/app.39dfa7e1.js", "build/app.js": "build/app.e22732af.js",
"build/invoice.css": "build/invoice.ff32661a.css", "build/invoice.css": "build/invoice.ff32661a.css",
"build/invoice.js": "build/invoice.19f36eca.js", "build/invoice.js": "build/invoice.19f36eca.js",
"build/invoice-pdf.css": "build/invoice-pdf.9a7468ef.css", "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); 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 protected function handleSearch(FormInterface $form, Request $request): bool
{ {
$data = $form->getData(); $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'); 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(); $submitData = $request->query->all();
// allow to use forms with block-prefix
// remove bookmark if (!empty($formName = $form->getConfig()->getName()) && $request->request->has($formName)) {
if ($bookmark !== null && $request->query->has('removeDefaultQuery')) { $submitData = $request->request->get($formName);
$bookmarkRepo->deleteBookmark($bookmark);
return true;
} }
// apply bookmark ONLY if search form was not submitted manually $searchName = $this->getSearchName($data);
if ($bookmark !== null && !$request->query->has('performSearch')) {
$data->setBookmark($bookmark); /** @var BookmarkRepository $bookmarkRepo */
if (!$request->query->has('setDefaultQuery')) { $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); $submitData = array_merge($bookmark->getContent(), $submitData);
} }
} }
@@ -205,24 +242,39 @@ abstract class AbstractController extends BaseAbstractController implements Serv
if (!$form->isValid()) { if (!$form->isValid()) {
$data->resetByFormError($form->getErrors(true)); $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']; return false;
foreach ($filter as $name) { }
if (isset($params[$name])) {
unset($params[$name]);
}
}
$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) { if ($bookmark === null) {
$bookmark = new Bookmark(); $bookmark = new Bookmark();
$bookmark->setType(Bookmark::SEARCH_DEFAULT); $bookmark->setType(Bookmark::SEARCH_DEFAULT);
$bookmark->setUser($this->getUser()); $bookmark->setUser($this->getUser());
$bookmark->setName(substr($data->getName(), 0, 50)); $bookmark->setName($searchName);
} }
$bookmark->setContent($params); $bookmark->setContent($params);

View File

@@ -23,6 +23,7 @@ use App\Form\MultiUpdate\MultiUpdateTableDTO;
use App\Form\MultiUpdate\TimesheetMultiUpdate; use App\Form\MultiUpdate\TimesheetMultiUpdate;
use App\Form\MultiUpdate\TimesheetMultiUpdateDTO; use App\Form\MultiUpdate\TimesheetMultiUpdateDTO;
use App\Form\TimesheetEditForm; use App\Form\TimesheetEditForm;
use App\Form\Toolbar\TimesheetExportToolbarForm;
use App\Form\Toolbar\TimesheetToolbarForm; use App\Form\Toolbar\TimesheetToolbarForm;
use App\Repository\ActivityRepository; use App\Repository\ActivityRepository;
use App\Repository\ProjectRepository; use App\Repository\ProjectRepository;
@@ -33,6 +34,7 @@ use App\Timesheet\TimesheetService;
use App\Timesheet\TrackingMode\TrackingModeInterface; use App\Timesheet\TrackingMode\TrackingModeInterface;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -47,10 +49,6 @@ abstract class TimesheetAbstractController extends AbstractController
* @var EventDispatcherInterface * @var EventDispatcherInterface
*/ */
protected $dispatcher; protected $dispatcher;
/**
* @var ServiceExport
*/
protected $exportService;
/** /**
* @var TimesheetService * @var TimesheetService
*/ */
@@ -63,13 +61,11 @@ abstract class TimesheetAbstractController extends AbstractController
public function __construct( public function __construct(
TimesheetRepository $repository, TimesheetRepository $repository,
EventDispatcherInterface $dispatcher, EventDispatcherInterface $dispatcher,
ServiceExport $exportService,
TimesheetService $timesheetService, TimesheetService $timesheetService,
SystemConfiguration $configuration SystemConfiguration $configuration
) { ) {
$this->repository = $repository; $this->repository = $repository;
$this->dispatcher = $dispatcher; $this->dispatcher = $dispatcher;
$this->exportService = $exportService;
$this->service = $timesheetService; $this->service = $timesheetService;
$this->configuration = $configuration; $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 = $this->getExportForm($query);
$form->setData($query);
$form->submit($request->query->all(), false);
$factory = $this->getDateTimeFactory(); if ($request->isMethod(Request::METHOD_POST)) {
$this->ignorePersistedSearch($request);
// 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());
} }
$query->getBegin()->setTime(0, 0, 0);
if (null === $query->getEnd()) { if ($this->handleSearch($form, $request)) {
$query->setEnd($factory->getEndOfMonth()); return $this->redirectToRoute($this->getExportRoute());
} }
$query->getEnd()->setTime(23, 59, 59);
$this->prepareQuery($query); $this->prepareQuery($query);
$entries = $this->repository->getTimesheetsForQuery($query); // make sure that we use the "expected time range"
if (null !== $query->getBegin()) {
$exporter = $this->exportService->getTimesheetExporterById($exporterId); $query->getBegin()->setTime(0, 0, 0);
}
if (null === $exporter) { if (null !== $query->getEnd()) {
throw $this->createNotFoundException('Invalid timesheet exporter given'); $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) 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 protected function getPermissionEditExport(): string
{ {
return 'edit_export_own_timesheet'; return 'edit_export_own_timesheet';
@@ -563,11 +582,29 @@ abstract class TimesheetAbstractController extends AbstractController
return 'timesheet_multi_delete'; return 'timesheet_multi_delete';
} }
protected function getExportRoute(): string
{
return 'timesheet_export';
}
protected function canSeeStartEndTime(): bool protected function canSeeStartEndTime(): bool
{ {
return $this->getTrackingMode()->canSeeBeginAndEndTimes(); 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 getDuplicateForm(Timesheet $entry): FormInterface;
abstract protected function getCreateForm(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\Entity\Timesheet;
use App\Event\TimesheetMetaDisplayEvent; use App\Event\TimesheetMetaDisplayEvent;
use App\Export\ServiceExport;
use App\Form\TimesheetEditForm; use App\Form\TimesheetEditForm;
use App\Repository\ActivityRepository; use App\Repository\ActivityRepository;
use App\Repository\ProjectRepository; use App\Repository\ProjectRepository;
use App\Repository\Query\TimesheetQuery;
use App\Repository\TagRepository; use App\Repository\TagRepository;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormInterface;
@@ -35,20 +35,19 @@ class TimesheetController extends TimesheetAbstractController
*/ */
public function indexAction(int $page, Request $request): Response public function indexAction(int $page, Request $request): Response
{ {
$query = new TimesheetQuery(); $query = $this->createDefaultQuery();
$query->setPage($page); $query->setPage($page);
$query->setName('MyTimesListing');
return $this->index($query, $request, 'timesheet', 'timesheet/index.html.twig', TimesheetMetaDisplayEvent::TIMESHEET); 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')") * @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\Timesheet;
use App\Entity\User; use App\Entity\User;
use App\Event\TimesheetMetaDisplayEvent; use App\Event\TimesheetMetaDisplayEvent;
use App\Export\ServiceExport;
use App\Form\Model\MultiUserTimesheet; use App\Form\Model\MultiUserTimesheet;
use App\Form\TimesheetAdminEditForm; use App\Form\TimesheetAdminEditForm;
use App\Form\TimesheetMultiUserEditForm; use App\Form\TimesheetMultiUserEditForm;
@@ -45,19 +46,19 @@ class TimesheetTeamController extends TimesheetAbstractController
*/ */
public function indexAction(int $page, Request $request): Response public function indexAction(int $page, Request $request): Response
{ {
$query = new TimesheetQuery(); $query = $this->createDefaultQuery();
$query->setPage($page); $query->setPage($page);
$query->setName('TeamTimesListing');
return $this->index($query, $request, 'admin_timesheet', 'timesheet-team/index.html.twig', TimesheetMetaDisplayEvent::TEAM_TIMESHEET); 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'; return 'admin_timesheet_edit';
} }
protected function getExportRoute(): string
{
return 'admin_timesheet_export';
}
protected function getMultiUpdateRoute(): string protected function getMultiUpdateRoute(): string
{ {
return 'admin_timesheet_multi_update'; return 'admin_timesheet_multi_update';
@@ -247,4 +253,9 @@ class TimesheetTeamController extends TimesheetAbstractController
{ {
return true; 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) { if ($report instanceof Report) {
$subMenu = $report->getReportIcon(); $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')); $event->addHelp($this->documentationLink('reporting.html'));

View File

@@ -11,7 +11,7 @@ namespace App\EventSubscriber\Actions;
use App\Event\PageActionsEvent; use App\Event\PageActionsEvent;
class TimesheetsSubscriber extends AbstractTimesheetsSubscriber class TimesheetsSubscriber extends AbstractActionsSubscriber
{ {
public static function getActionName(): string public static function getActionName(): string
{ {
@@ -24,7 +24,7 @@ class TimesheetsSubscriber extends AbstractTimesheetsSubscriber
$event->addColumnToggle('#modal_timesheet'); $event->addColumnToggle('#modal_timesheet');
if ($this->isGranted('export_own_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')) { if ($this->isGranted('create_own_timesheet')) {

View File

@@ -11,7 +11,7 @@ namespace App\EventSubscriber\Actions;
use App\Event\PageActionsEvent; use App\Event\PageActionsEvent;
class TimesheetsTeamSubscriber extends AbstractTimesheetsSubscriber class TimesheetsTeamSubscriber extends AbstractActionsSubscriber
{ {
public static function getActionName(): string public static function getActionName(): string
{ {
@@ -24,7 +24,7 @@ class TimesheetsTeamSubscriber extends AbstractTimesheetsSubscriber
$event->addColumnToggle('#modal_timesheet_admin'); $event->addColumnToggle('#modal_timesheet_admin');
if ($this->isGranted('export_other_timesheet')) { 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')) { if ($this->isGranted('create_other_timesheet')) {

View File

@@ -706,36 +706,43 @@ abstract class AbstractSpreadsheetRenderer
$entryHeaderRow++; $entryHeaderRow++;
} }
if (null !== $durationColumn) { if ($this->isTotalRowSupported()) {
$startCoordinate = $sheet->getCellByColumnAndRow($durationColumn, 2)->getCoordinate(); if (null !== $durationColumn) {
$endCoordinate = $sheet->getCellByColumnAndRow($durationColumn, $entryHeaderRow - 1)->getCoordinate(); $startCoordinate = $sheet->getCellByColumnAndRow($durationColumn, 2)->getCoordinate();
$this->setDurationTotal($sheet, $durationColumn, $entryHeaderRow, $startCoordinate, $endCoordinate); $endCoordinate = $sheet->getCellByColumnAndRow($durationColumn, $entryHeaderRow - 1)->getCoordinate();
$style = $sheet->getStyleByColumnAndRow($durationColumn, $entryHeaderRow); $this->setDurationTotal($sheet, $durationColumn, $entryHeaderRow, $startCoordinate, $endCoordinate);
$style->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN); $style = $sheet->getStyleByColumnAndRow($durationColumn, $entryHeaderRow);
$style->getFont()->setBold(true); $style->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN);
} $style->getFont()->setBold(true);
}
if (null !== $rateColumn) { if (null !== $rateColumn) {
$startCoordinate = $sheet->getCellByColumnAndRow($rateColumn, 2)->getCoordinate(); $startCoordinate = $sheet->getCellByColumnAndRow($rateColumn, 2)->getCoordinate();
$endCoordinate = $sheet->getCellByColumnAndRow($rateColumn, $entryHeaderRow - 1)->getCoordinate(); $endCoordinate = $sheet->getCellByColumnAndRow($rateColumn, $entryHeaderRow - 1)->getCoordinate();
$this->setRateTotal($sheet, $rateColumn, $entryHeaderRow, $startCoordinate, $endCoordinate); $this->setRateTotal($sheet, $rateColumn, $entryHeaderRow, $startCoordinate, $endCoordinate);
$style = $sheet->getStyleByColumnAndRow($rateColumn, $entryHeaderRow); $style = $sheet->getStyleByColumnAndRow($rateColumn, $entryHeaderRow);
$style->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN); $style->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN);
$style->getFont()->setBold(true); $style->getFont()->setBold(true);
} }
if (null !== $internalRateColumn) { if (null !== $internalRateColumn) {
$startCoordinate = $sheet->getCellByColumnAndRow($internalRateColumn, 2)->getCoordinate(); $startCoordinate = $sheet->getCellByColumnAndRow($internalRateColumn, 2)->getCoordinate();
$endCoordinate = $sheet->getCellByColumnAndRow($internalRateColumn, $entryHeaderRow - 1)->getCoordinate(); $endCoordinate = $sheet->getCellByColumnAndRow($internalRateColumn, $entryHeaderRow - 1)->getCoordinate();
$this->setRateTotal($sheet, $internalRateColumn, $entryHeaderRow, $startCoordinate, $endCoordinate); $this->setRateTotal($sheet, $internalRateColumn, $entryHeaderRow, $startCoordinate, $endCoordinate);
$style = $sheet->getStyleByColumnAndRow($internalRateColumn, $entryHeaderRow); $style = $sheet->getStyleByColumnAndRow($internalRateColumn, $entryHeaderRow);
$style->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN); $style->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN);
$style->getFont()->setBold(true); $style->getFont()->setBold(true);
}
} }
return $spreadsheet; return $spreadsheet;
} }
protected function isTotalRowSupported(): bool
{
return false;
}
/** /**
* @param ExportItemInterface[] $exportItems * @param ExportItemInterface[] $exportItems
* @param TimesheetQuery $query * @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) { foreach ($allBudgets as $projectId => $statisticModel) {
$project = $statisticModel->getProject(); $project = $statisticModel->getProject();
@@ -183,6 +185,29 @@ trait RendererTrait
return $summary; 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 ExportItemInterface[] $exportItems
* @param TimesheetQuery $query * @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) { foreach ($allBudgets as $activityId => $statisticModel) {
$project = $statisticModel->getActivity()->getProject(); $project = $statisticModel->getActivity()->getProject();

View File

@@ -15,6 +15,11 @@ use PhpOffice\PhpSpreadsheet\Style\Alignment;
class XlsxRenderer extends AbstractSpreadsheetRenderer class XlsxRenderer extends AbstractSpreadsheetRenderer
{ {
protected function isTotalRowSupported(): bool
{
return true;
}
public function getFileExtension(): string public function getFileExtension(): string
{ {
return '.xlsx'; 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\LoaderPaginator;
use App\Repository\Paginator\PaginatorInterface; use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\TimesheetQuery; use App\Repository\Query\TimesheetQuery;
use App\Repository\Result\TimesheetResult;
use DateInterval; use DateInterval;
use DateTime; use DateTime;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
@@ -749,7 +750,7 @@ class TimesheetRepository extends EntityRepository
* Especially the following question is still un-answered! * Especially the following question is still un-answered!
* *
* Should a teamlead: * 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) * 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 private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): bool
@@ -835,6 +836,13 @@ class TimesheetRepository extends EntityRepository
return $this->getHydratedResultsByQuery($qb, $fullyHydrated); return $this->getHydratedResultsByQuery($qb, $fullyHydrated);
} }
public function getTimesheetResult(TimesheetQuery $query): TimesheetResult
{
$qb = $this->getQueryBuilderForQuery($query);
return new TimesheetResult($qb);
}
/** /**
* @param QueryBuilder $qb * @param QueryBuilder $qb
* @param bool $fullyHydrated * @param bool $fullyHydrated

View File

@@ -4,6 +4,8 @@
data-title="{{- get_title()|e('html_attr') -}}" data-title="{{- get_title()|e('html_attr') -}}"
{% endblock %} {% endblock %}
{% block page_title %}{{- get_title() -}}{% endblock %}
{% block after_body_start %} {% block after_body_start %}
{% embed 'embeds/modal.html.twig' %} {% embed 'embeds/modal.html.twig' %}
{% block modal_id %}remote_form_modal{% endblock %} {% 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 %} {% set _reset = reset is defined and reset is same as (false) ? false : true %}
<div class="box box-primary"> <div class="box box-primary">
{% block form_before %}{% endblock %} {% block form_before %}{% endblock %}
{{ form_start(form) }} {{ form_start(form, formStartOptions|default({})) }}
<div class="box-header with-border"> <div class="box-header with-border">
<h3 class="box-title"> <h3 class="box-title">
{{ title }} {{ title }}
@@ -20,7 +20,9 @@
{% endblock %} {% endblock %}
</div> </div>
<div class="box-footer"> <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) %} {% if _back is not same as (false) %}
<a href="{{ _back }}" class="btn btn-link">{{ 'action.back'|trans }}</a> <a href="{{ _back }}" class="btn btn-link">{{ 'action.back'|trans }}</a>
{% endif %} {% endif %}

View File

@@ -4,10 +4,10 @@ REQUIRED CHANGES NEED TO BE DONE IN THESE FILES:
- default/_form_modal.html.twig - 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 {% 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"> <div class="modal-content">
{% block form_before %}{% endblock %} {% block form_before %}{% endblock %}
{% block modal_before %}{{ form_start(form) }}{% endblock %} {% block modal_before %}{{ form_start(form, formStartOptions|default({})) }}{% endblock %}
<div class="modal-header"> <div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="{{ 'action.close'|trans }}"><span aria-hidden="true">&times;</span></button> <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"> <h4 class="modal-title" id="{{ block('modal_id') }}_label">
@@ -28,7 +28,9 @@ REQUIRED CHANGES NEED TO BE DONE IN THESE FILES:
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
{% block 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> <button type="button" class="btn btn-default btn-cancel" data-dismiss="modal">{{ 'action.close'|trans }}</button>
{% endblock %} {% endblock %}
</div> </div>

View File

@@ -4,7 +4,7 @@ REQUIRED CHANGES NEED TO BE DONE IN THESE FILES:
- default/_form_modal.html.twig - 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{% 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"> <div class="modal-content">
{% if block('modal_before') is defined %}{{ block('modal_before') }}{% endif %} {% if block('modal_before') is defined %}{{ block('modal_before') }}{% endif %}
<div class="modal-header"> <div class="modal-header">

View File

@@ -211,7 +211,7 @@
<h4><i class="icon {{ icon|icon(icon) }}"></i> {{ title|trans }}</h4> <h4><i class="icon {{ icon|icon(icon) }}"></i> {{ title|trans }}</h4>
{{ description|trans }} {{ description|trans }}
{% elseif icon %} {% elseif icon %}
<h4><i class="icon {{ icon|icon(icon) }}"></i> {{ description|trans }}</h4> <i class="icon {{ icon|icon(icon) }}"></i> {{ description|trans }}
{% else %} {% else %}
{{ description|trans }} {{ description|trans }}
{% endif %} {% 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->assertHasNoEntriesWithFilter($client);
$this->assertPageActions($client, [ $this->assertPageActions($client, [
'search' => '#', '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' => '#', 'visibility' => '#',
'download toolbar-action modal-ajax-form' => $this->createUrl('/timesheet/export/'),
'create modal-ajax-form' => $this->createUrl('/timesheet/create'), 'create modal-ajax-form' => $this->createUrl('/timesheet/create'),
'help' => 'https://www.kimai.org/documentation/timesheet.html' 'help' => 'https://www.kimai.org/documentation/timesheet.html'
]); ]);
@@ -134,18 +131,17 @@ class TimesheetControllerTest extends ControllerBaseTest
$fixture->setStartDate(new \DateTime('-10 days')); $fixture->setStartDate(new \DateTime('-10 days'));
$this->importFixture($fixture); $this->importFixture($fixture);
$this->request($client, '/timesheet/'); $this->request($client, '/timesheet/export/');
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
$dateRange = (new \DateTime('-10 days'))->format('Y-m-d') . DateRangeType::DATE_SPACER . (new \DateTime())->format('Y-m-d'); $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(); $client->submitForm('export-btn-print', [
$form->getFormNode()->setAttribute('action', $this->createUrl('/timesheet/export/print')); 'export' => [
$client->submit($form, [ 'state' => 1,
'state' => 1, 'daterange' => $dateRange,
'pageSize' => 25, 'customers' => [],
'daterange' => $dateRange, ]
'customers' => [],
]); ]);
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());

View File

@@ -43,11 +43,8 @@ class TimesheetTeamControllerTest extends ControllerBaseTest
$this->assertPageActions($client, [ $this->assertPageActions($client, [
'search' => '#', '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' => '#', 'visibility' => '#',
'download toolbar-action modal-ajax-form' => $this->createUrl('/team/timesheet/export/'),
'create-ts modal-ajax-form' => $this->createUrl('/team/timesheet/create'), 'create-ts modal-ajax-form' => $this->createUrl('/team/timesheet/create'),
'create-ts-mu modal-ajax-form' => $this->createUrl('/team/timesheet/create_mu'), 'create-ts-mu modal-ajax-form' => $this->createUrl('/team/timesheet/create_mu'),
'help' => 'https://www.kimai.org/documentation/timesheet.html' 'help' => 'https://www.kimai.org/documentation/timesheet.html'
@@ -146,18 +143,17 @@ class TimesheetTeamControllerTest extends ControllerBaseTest
$fixture->setStartDate(new \DateTime('-10 days')); $fixture->setStartDate(new \DateTime('-10 days'));
$this->importFixture($fixture); $this->importFixture($fixture);
$this->request($client, '/team/timesheet/'); $this->request($client, '/team/timesheet/export/');
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
$dateRange = (new \DateTime('-10 days'))->format('Y-m-d') . DateRangeType::DATE_SPACER . (new \DateTime())->format('Y-m-d'); $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(); $client->submitForm('export-btn-print', [
$form->getFormNode()->setAttribute('action', $this->createUrl('/team/timesheet/export/print')); 'export' => [
$client->submit($form, [ 'state' => 1,
'state' => 1, 'daterange' => $dateRange,
'pageSize' => 25, 'customers' => [],
'daterange' => $dateRange, ]
'customers' => [],
]); ]);
$this->assertTrue($client->getResponse()->isSuccessful()); $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\AbstractActionsSubscriber
* @covers \App\EventSubscriber\Actions\AbstractTimesheetsSubscriber
* @covers \App\EventSubscriber\Actions\TimesheetsSubscriber * @covers \App\EventSubscriber\Actions\TimesheetsSubscriber
*/ */
class TimesheetsSubscriberTest extends AbstractActionsSubscriberTest class TimesheetsSubscriberTest extends AbstractActionsSubscriberTest

View File

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

View File

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

View File

@@ -31,7 +31,7 @@ class CsvRendererTest extends AbstractRendererTest
public function getTestModel() public function getTestModel()
{ {
return [ 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())); $this->assertTrue(file_exists($file->getRealPath()));
$content = file_get_contents($file->getRealPath()); $content = file_get_contents($file->getRealPath());
$this->assertStringContainsString('"' . $totalDuration . '"', $content);
$this->assertStringContainsString('"' . $totalRate . '"', $content);
$this->assertStringContainsString('"' . $expectedRate . '"', $content); $this->assertStringContainsString('"' . $expectedRate . '"', $content);
$this->assertEquals($expectedRows, substr_count($content, PHP_EOL)); $this->assertEquals($expectedRows, substr_count($content, PHP_EOL));
$this->assertEquals($expectedDescriptions, substr_count($content, 'activity description')); $this->assertEquals($expectedDescriptions, substr_count($content, 'activity description'));
@@ -105,7 +103,7 @@ class CsvRendererTest extends AbstractRendererTest
27 => 'ORDER-123', 27 => 'ORDER-123',
]; ];
self::assertEquals(7, \count($all)); self::assertEquals(6, \count($all));
self::assertEquals($expected, $all[5]); self::assertEquals($expected, $all[5]);
self::assertEquals(\count($expected), \count($all[0])); self::assertEquals(\count($expected), \count($all[0]));
self::assertEquals('foo', $all[4][14]); 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> <source>export.date_copyright</source>
<target>Erstellt %date% mit %kimai%</target> <target>Erstellt %date% mit %kimai%</target>
</trans-unit> </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 Navbar - recent entries and activities
--> -->

View File

@@ -1122,6 +1122,10 @@
<source>export.date_copyright</source> <source>export.date_copyright</source>
<target>Created %date% with %kimai%</target> <target>Created %date% with %kimai%</target>
</trans-unit> </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 Navbar - recent entries and activities
--> -->