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

@@ -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