Release 2.29 (#5325)

* bump composer packages
* fixes #5329 quotes for ANSI_MODE
* improve year selection
* improve year selection via dropdown
* added range selector in month-picker
* fix week number if week starts with sunday
* fix first day of month in URL
* predefined options for week chooser
* z-index issue with sticky table header
* replace duplicated translations
* add logout button to allow user switch without having to re-login in "remember me" login
* new flag to detect if invoice entry is a fixed rate
* improve export column lengths
This commit is contained in:
Kevin Papst
2025-02-09 00:16:03 +01:00
committed by GitHub
parent 8444928ae4
commit b42c77a2a1
106 changed files with 617 additions and 1068 deletions

View File

@@ -17,11 +17,11 @@ final class Constants
/**
* The current release version
*/
public const VERSION = '2.28.0';
public const VERSION = '2.29.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 22800;
public const VERSION_ID = 22900;
/**
* The software name
*/

View File

@@ -61,11 +61,9 @@ final class ReportUsersYearController extends AbstractController
$dateTimeFactory = $this->getDateTimeFactory();
$defaultDate = $dateTimeFactory->createStartOfYear();
$isFinancialYear = false;
if (null !== ($financialYear = $systemConfiguration->getFinancialYearStart())) {
$defaultDate = $this->getDateTimeFactory()->createStartOfFinancialYear($financialYear);
$isFinancialYear = true;
}
$values = new YearlyUserList();
@@ -74,7 +72,6 @@ final class ReportUsersYearController extends AbstractController
$form = $this->createFormForGetRequest(YearlyUserListForm::class, $values, [
'timezone' => $dateTimeFactory->getTimezone()->getName(),
'start_date' => $values->getDate(),
'show_range' => $isFinancialYear,
]);
$form->submit($request->query->all(), false);

View File

@@ -294,6 +294,8 @@ final class SystemConfigurationController extends AbstractController
return [
(new SystemConfigurationModel('timesheet'))
->setTranslation('time_tracking')
->setTranslationDomain('messages')
->setConfiguration([
(new Configuration('timesheet.mode'))
->setType(TrackingModeType::class)

View File

@@ -61,11 +61,11 @@ final class CustomerSubscriber extends AbstractActionsSubscriber
}
if ($this->isGranted('view_project') || $this->isGranted('view_teamlead_project') || $this->isGranted('view_team_project')) {
$event->addActionToSubmenu('filter', 'project', ['title' => 'project.filter', 'url' => $this->path('admin_project', ['customers[]' => $customer->getId()])]);
$event->addActionToSubmenu('filter', 'project', ['title' => 'projects', 'url' => $this->path('admin_project', ['customers[]' => $customer->getId()])]);
}
if ($this->isGranted('view_activity')) {
$event->addActionToSubmenu('filter', 'activity', ['title' => 'activity.filter', 'url' => $this->path('admin_activity', ['customers[]' => $customer->getId()])]);
$event->addActionToSubmenu('filter', 'activity', ['title' => 'activities', 'url' => $this->path('admin_activity', ['customers[]' => $customer->getId()])]);
}
if ($this->isGranted('view_other_timesheet')) {

View File

@@ -51,7 +51,7 @@ final class ProjectSubscriber extends AbstractActionsSubscriber
}
if ($this->isGranted('view_activity')) {
$event->addActionToSubmenu('filter', 'activity', ['title' => 'activity.filter', 'url' => $this->path('admin_activity', ['customers[]' => $customer->getId(), 'projects[]' => $project->getId()])]);
$event->addActionToSubmenu('filter', 'activity', ['title' => 'activities', 'url' => $this->path('admin_activity', ['customers[]' => $customer->getId(), 'projects[]' => $project->getId()])]);
}
if ($this->isGranted('view_other_timesheet')) {

View File

@@ -35,7 +35,13 @@ final class UserSubscriber extends AbstractActionsSubscriber
{
$payload = $event->getPayload();
if (($user = $payload['user']) === null || !$user instanceof User || $user->getId() === null) {
if (!\array_key_exists('user', $payload)) {
return;
}
$user = $payload['user'];
if (!$user instanceof User || $user->getId() === null) {
return;
}

View File

@@ -19,12 +19,16 @@ use App\Repository\Query\TimesheetQuery;
use OpenSpout\Writer\CSV\Options;
use OpenSpout\Writer\CSV\Writer;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\Translation\TranslatorInterface;
final class CsvRenderer implements RendererInterface, TimesheetExportInterface
{
use ExportTrait;
public function __construct(private readonly SpreadsheetRenderer $spreadsheetRenderer)
public function __construct(
private readonly SpreadsheetRenderer $spreadsheetRenderer,
private readonly TranslatorInterface $translator
)
{
}
@@ -63,7 +67,7 @@ final class CsvRenderer implements RendererInterface, TimesheetExportInterface
$options = new Options();
$options->SHOULD_ADD_BOM = false;
$spreadsheet = new SpoutSpreadsheet(new Writer($options));
$spreadsheet = new SpoutSpreadsheet(new Writer($options), $this->translator);
$spreadsheet->open($filename);
$this->spreadsheetRenderer->registerFormatter('date', new DateStringFormatter());

View File

@@ -27,6 +27,7 @@ use App\Export\Package\CellFormatter\RateFormatter;
use App\Export\Package\CellFormatter\TextFormatter;
use App\Export\Package\CellFormatter\TimeFormatter;
use App\Export\Package\Column;
use App\Export\Package\ColumnWidth;
use App\Export\Package\SpreadsheetPackage;
use App\Repository\Query\ActivityQuery;
use App\Repository\Query\CustomerQuery;
@@ -34,7 +35,6 @@ use App\Repository\Query\ProjectQuery;
use App\Repository\Query\TimesheetQuery;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @internal means no BC promise whatsoever!
@@ -47,7 +47,6 @@ final class SpreadsheetRenderer
private array $formatter = [];
public function __construct(
protected TranslatorInterface $translator,
protected EventDispatcherInterface $dispatcher,
protected Security $voter
) {
@@ -83,12 +82,7 @@ final class SpreadsheetRenderer
public function writeSpreadsheet(SpreadsheetPackage $spreadsheetPackage, array $exportItems, TimesheetQuery $query): void
{
$columns = $this->getColumns($query);
$headerRow = [];
foreach ($columns as $column) {
$headerRow[] = $this->translator->trans($column->getHeader());
}
$spreadsheetPackage->setHeader($headerRow);
$spreadsheetPackage->setColumns($columns);
$currentRow = 1;
foreach ($exportItems as $exportItem) {
@@ -150,26 +144,26 @@ final class SpreadsheetRenderer
$columns = [];
$columns[] = (new Column('date', $this->getFormatter('date')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getBegin());
$columns[] = (new Column('begin', $this->getFormatter('time')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getBegin());
$columns[] = (new Column('end', $this->getFormatter('time')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getEnd());
$columns[] = (new Column('duration', $this->getFormatter('duration')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration());
$columns[] = (new Column('begin', $this->getFormatter('time')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getBegin())->withColumnWidth(ColumnWidth::SMALL);
$columns[] = (new Column('end', $this->getFormatter('time')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getEnd())->withColumnWidth(ColumnWidth::SMALL);
$columns[] = (new Column('duration', $this->getFormatter('duration')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL);
if ($showRates) {
$columns[] = (new Column('currency', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getCurrency());
$columns[] = (new Column('currency', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getCurrency())->withColumnWidth(ColumnWidth::SMALL);
$columns[] = (new Column('rate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getRate());
$columns[] = (new Column('internalRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getInternalRate());
$columns[] = (new Column('hourlyRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getHourlyRate());
$columns[] = (new Column('fixedRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getFixedRate());
}
$columns[] = (new Column('username', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getDisplayName());
$columns[] = (new Column('username', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getDisplayName())->withColumnWidth(ColumnWidth::MEDIUM);
$columns[] = (new Column('account_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getAccountNumber());
$columns[] = (new Column('customer', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getName());
$columns[] = (new Column('project', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getName());
$columns[] = (new Column('activity', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getActivity()?->getName());
$columns[] = (new Column('description', new TextFormatter(true)))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDescription());
$columns[] = (new Column('customer', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);
$columns[] = (new Column('project', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);
$columns[] = (new Column('activity', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getActivity()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);
$columns[] = (new Column('description', new TextFormatter(true)))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDescription())->withColumnWidth(ColumnWidth::LARGE);
//$columns[] = (new Column('exported', new BooleanFormatter()))->withExtractor(fn(ExportableItem $exportableItem) => $exportableItem->isExported());
$columns[] = (new Column('billable', new BooleanFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->isBillable());
$columns[] = (new Column('billable', new BooleanFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->isBillable())->withColumnWidth(ColumnWidth::SMALL);
$columns[] = (new Column('tags', new ArrayFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getTagsAsArray());
$columns[] = (new Column('type', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getType());
$columns[] = (new Column('category', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getCategory());

View File

@@ -17,12 +17,16 @@ use App\Export\TimesheetExportInterface;
use App\Repository\Query\TimesheetQuery;
use OpenSpout\Writer\XLSX\Writer;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\Translation\TranslatorInterface;
final class XlsxRenderer implements RendererInterface, TimesheetExportInterface
{
use ExportTrait;
public function __construct(private readonly SpreadsheetRenderer $spreadsheetRenderer)
public function __construct(
private readonly SpreadsheetRenderer $spreadsheetRenderer,
private readonly TranslatorInterface $translator,
)
{
}
@@ -58,7 +62,7 @@ final class XlsxRenderer implements RendererInterface, TimesheetExportInterface
throw new \Exception('Could not open temporary file');
}
$spreadsheet = new SpoutSpreadsheet(new Writer());
$spreadsheet = new SpoutSpreadsheet(new Writer(), $this->translator);
$spreadsheet->open($filename);
$this->spreadsheetRenderer->writeSpreadsheet($spreadsheet, $exportItems, $query);

View File

@@ -16,6 +16,7 @@ class Column
{
private ?string $header = null;
private \Closure|null $extractor = null;
private ColumnWidth $columnWidth = ColumnWidth::DEFAULT;
public function __construct(private readonly string $name, private readonly CellFormatterInterface $formatter)
{
@@ -33,6 +34,18 @@ class Column
return $this;
}
public function withColumnWidth(ColumnWidth $columnWidth): Column
{
$this->columnWidth = $columnWidth;
return $this;
}
public function getColumnWidth(): ColumnWidth
{
return $this->columnWidth;
}
public function withExtractor(\Closure $extractor): Column
{
$this->extractor = $extractor;

View File

@@ -0,0 +1,19 @@
<?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\Export\Package;
enum ColumnWidth
{
case EXTRA_SMALL;
case SMALL;
case DEFAULT;
case MEDIUM;
case LARGE;
}

View File

@@ -15,6 +15,7 @@ use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use Symfony\Contracts\Translation\TranslatorInterface;
final class PhpOfficeSpreadsheet implements SpreadsheetPackage
{
@@ -23,7 +24,7 @@ final class PhpOfficeSpreadsheet implements SpreadsheetPackage
private int $currentRow = 1;
private ?string $filename = null;
public function __construct()
public function __construct(private readonly TranslatorInterface $translator)
{
$this->spreadsheet = new Spreadsheet();
$this->worksheet = $this->spreadsheet->getActiveSheet();
@@ -82,9 +83,9 @@ final class PhpOfficeSpreadsheet implements SpreadsheetPackage
}
/**
* @param array<string> $columns
* @param array<Column> $columns
*/
public function setHeader(array $columns): void
public function setColumns(array $columns): void
{
if ($this->worksheet === null) {
throw new \Exception('Cannot re-use spreadsheet after calling save()');
@@ -92,8 +93,9 @@ final class PhpOfficeSpreadsheet implements SpreadsheetPackage
$counter = 1;
foreach ($columns as $column) {
$title = $this->translator->trans($column->getHeader());
$pos = CellAddress::fromColumnAndRow($counter, 1);
$this->worksheet->setCellValue($pos, $column);
$this->worksheet->setCellValue($pos, $title);
$style = $this->worksheet->getStyle($pos);
$style->getBorders()->getBottom()->setBorderStyle(Border::BORDER_THIN);
$style->getFont()->setBold(true);

View File

@@ -17,28 +17,40 @@ use OpenSpout\Common\Entity\Style\BorderPart;
use OpenSpout\Common\Entity\Style\Color;
use OpenSpout\Common\Entity\Style\Style;
use OpenSpout\Writer\AbstractWriterMultiSheets;
use OpenSpout\Writer\CSV\Writer;
use OpenSpout\Writer\AutoFilter;
use OpenSpout\Writer\CSV\Writer as CSVWriter;
use OpenSpout\Writer\WriterInterface;
use OpenSpout\Writer\XLSX\Entity\SheetView;
use OpenSpout\Writer\XLSX\Writer as XLSXWriter;
use Symfony\Contracts\Translation\TranslatorInterface;
class SpoutSpreadsheet implements SpreadsheetPackage
{
private Style $dateStyle;
public function __construct(private readonly WriterInterface $writer)
public function __construct(
private readonly WriterInterface $writer,
private readonly TranslatorInterface $translator
)
{
$this->writer->setCreator(Constants::SOFTWARE);
$this->dateStyle = (new Style())->setFormat('yyyy-mm-dd');
}
/**
* @param array<string> $columns
* @param array<Column> $columns
*/
public function setHeader(array $columns): void
public function setColumns(array $columns): void
{
$columnCounter = \count($columns);
if ($columnCounter === 0) {
throw new \InvalidArgumentException('At least one column is required');
}
$tmp = [];
foreach ($columns as $column) {
$tmp[] = Cell::fromValue($column);
$title = $this->translator->trans($column->getHeader());
$tmp[] = Cell::fromValue($title);
}
$style = new Style();
@@ -48,6 +60,24 @@ class SpoutSpreadsheet implements SpreadsheetPackage
$style->setBorder(new Border(new BorderPart(Border::BOTTOM, Color::BLACK, Border::WIDTH_THIN, Border::STYLE_SOLID)));
$style->setFontBold();
if ($this->writer instanceof XLSXWriter) {
$autoFilter = new AutoFilter(0, 1, $columnCounter - 1, 1);
$this->writer->getCurrentSheet()->setAutoFilter($autoFilter);
$options = $this->writer->getOptions();
$i = 1;
foreach ($columns as $column) {
$width = match($column->getColumnWidth()) {
ColumnWidth::SMALL => 10,
ColumnWidth::MEDIUM => 30,
ColumnWidth::LARGE => 50,
default => 15,
};
$options->setColumnWidth($width, $i++);
}
}
$this->writer->addRow(new Row($tmp, $style));
}
@@ -62,7 +92,7 @@ class SpoutSpreadsheet implements SpreadsheetPackage
$style->setShouldShrinkToFit(true);
if (\array_key_exists('totals', $options) && $options['totals'] === true) {
if ($this->writer instanceof Writer) {
if ($this->writer instanceof CSVWriter) {
return;
}
$style->setBorder(new Border(new BorderPart(Border::TOP, Color::BLACK, Border::WIDTH_THIN, Border::STYLE_SOLID)));

View File

@@ -19,9 +19,9 @@ interface SpreadsheetPackage
public function save(): void;
/**
* @param array<string> $columns
* @param array<Column> $columns
*/
public function setHeader(array $columns): void;
public function setColumns(array $columns): void;
/**
* @param array<int, mixed> $columns

View File

@@ -28,7 +28,7 @@ final class Configuration
*/
private array $constraints = [];
public function __construct(private string $name)
public function __construct(private readonly string $name)
{
}

View File

@@ -28,22 +28,41 @@ final class MonthPickerType extends AbstractType
'widget' => 'single_text',
'html5' => false,
'format' => DateType::HTML5_FORMAT,
'start_date' => new \DateTime(),
'start_date' => new \DateTimeImmutable(),
]);
}
public function buildView(FormView $view, FormInterface $form, array $options): void
{
/** @var \DateTime|null $date */
if (!$options['start_date'] instanceof \DateTimeInterface) {
throw new \InvalidArgumentException('Start date needs to be a DateTime object');
}
/** @var \DateTimeInterface|null $date */
$date = $form->getData();
if (null === $date) {
/** @var \DateTimeImmutable $date */
$date = $options['start_date'];
}
$date = \DateTimeImmutable::createFromInterface($date);
$start = \DateTimeImmutable::createFromInterface($options['start_date']);
$start = $start->setDate((int) $start->format('Y'), (int) $start->format('m'), 1);
$range = [];
$end = $start->modify('-2 year');
$end = $end->setDate((int) $end->format('Y'), 1, 1);
$i = 1;
while ($i++ < 36 && $end <= $start) {
$range[] = $start;
$start = $start->modify('- 1 month');
}
$view->vars['month'] = $date;
$view->vars['previousMonth'] = (clone $date)->modify('-1 month');
$view->vars['nextMonth'] = (clone $date)->modify('+1 month');
$view->vars['range'] = $range;
$view->vars['previousMonth'] = $date->modify('-1 month');
$view->vars['nextMonth'] = $date->modify('+1 month');
}
public function getParent(): string

View File

@@ -34,6 +34,10 @@ final class WeekPickerType extends AbstractType
public function buildView(FormView $view, FormInterface $form, array $options): void
{
if (!$options['start_date'] instanceof \DateTimeInterface) {
throw new \InvalidArgumentException('Start date needs to be a DateTime object');
}
/** @var \DateTimeInterface|null $date */
$date = $form->getData();
@@ -42,11 +46,31 @@ final class WeekPickerType extends AbstractType
$date = $options['start_date'];
}
$date = \DateTime::createFromInterface($date);
$date = \DateTimeImmutable::createFromInterface($date);
$start = \DateTimeImmutable::createFromInterface($options['start_date']);
$view->vars['week'] = $date;
$view->vars['previousWeek'] = (clone $date)->modify('-1 week');
$view->vars['nextWeek'] = (clone $date)->modify('+1 week');
$week = $date;
if ($date->format('N') === '7') {
$week = $date->modify('+1 day');
}
if ($start->format('N') === '7') {
$start = $start->modify('+1 day');
}
$range = [];
$end = $start->modify('-1 year');
$end = $end->setDate((int) $end->format('Y'), 1, 1);
$i = 1;
while ($i++ < 106 && $end <= $start) {
$range[] = $start;
$start = $start->modify('- 1 week');
}
$view->vars['range'] = $range;
$view->vars['weekNumber'] = $week->format('W');
$view->vars['week'] = $week;
$view->vars['previousWeek'] = $week->modify('-1 week');
$view->vars['nextWeek'] = $week->modify('+1 week');
}
public function getParent(): string

View File

@@ -29,7 +29,6 @@ final class YearPickerType extends AbstractType
'html5' => false,
'format' => DateType::HTML5_FORMAT,
'start_date' => new \DateTimeImmutable(),
'show_range' => false,
]);
}
@@ -43,12 +42,19 @@ final class YearPickerType extends AbstractType
$date = $options['start_date'];
}
$date = \DateTime::createFromInterface($date);
$date = \DateTimeImmutable::createFromInterface($date);
$range = [];
$start = new \DateTimeImmutable();
$start = $start->setDate((int) $start->format('Y'), (int) $date->format('m'), (int) $date->format('d'));
for ($i = 0; $i < 6; $i++) {
$range[] = $start->modify('-' . $i . ' year');
}
$view->vars['year'] = $date;
$view->vars['show_range'] = $options['show_range'];
$view->vars['previousYear'] = (clone $date)->modify('-1 year');
$view->vars['nextYear'] = (clone $date)->modify('+1 year');
$view->vars['range'] = $range;
$view->vars['previousYear'] = $date->modify('-1 year');
$view->vars['nextYear'] = $date->modify('+1 year');
}
public function getParent(): string

View File

@@ -66,6 +66,7 @@ final class InvoiceItemDefaultHydrator implements InvoiceItemHydrator
'entry.rate_internal' => $formatter->getFormattedMoney($internalRate, $currency),
'entry.rate_internal_nc' => $formatter->getFormattedMoney($internalRate, $currency, false),
'entry.rate_internal_plain' => $internalRate,
'entry.rate_fixed' => ($item->isFixedRate() ? $item->getFixedRate() : null),
'entry.total' => $formatter->getFormattedMoney($rate, $currency),
'entry.total_nc' => $formatter->getFormattedMoney($rate, $currency, false),
'entry.total_plain' => $rate,

View File

@@ -28,7 +28,6 @@ final class YearlyUserListForm extends AbstractType
'model_timezone' => $options['timezone'],
'view_timezone' => $options['timezone'],
'start_date' => $options['start_date'],
'show_range' => $options['show_range'],
]);
$builder->add('team', TeamType::class, [
'multiple' => false,
@@ -51,7 +50,6 @@ final class YearlyUserListForm extends AbstractType
'start_date' => new \DateTime(),
'csrf_protection' => false,
'method' => 'GET',
'show_range' => false,
]);
}
}