re-written exporter for simpler extendability (#1349)

This commit is contained in:
Kevin Papst
2020-01-07 21:20:45 +01:00
committed by GitHub
parent d6798eed1e
commit e57b69973f
20 changed files with 497 additions and 260 deletions

View File

@@ -12,9 +12,14 @@ Perform EACH version specific task between your version and the new one, otherwi
**New database tables and fields were created, don't forget to [run the updater](https://www.kimai.org/documentation/updates.html).** **New database tables and fields were created, don't forget to [run the updater](https://www.kimai.org/documentation/updates.html).**
### Plugins ### ExpenseBundle
- Expenses plugin needs to be updated, due to incompatibilities in the underlying frameworks. **ATTENTION** due to incompatibilities in the underlying frameworks users of the ExpenseBundle need to do one more step:
If you use the ExpensePlugin, you should delete it before updating: `rm -r var/plugins/ExpenseBundle`.
You will run into an error otherwise during the update.
After the Kimai update was successful, you have to re-install the latest version, which is compatible with Kimai 1.7 only.
### Hosting ### Hosting
@@ -24,6 +29,7 @@ Perform EACH version specific task between your version and the new one, otherwi
- Projects now have a start and end date and the API will only return those, which are either unconfigured or currently active, you might want to reload the list of projects once the user entered begin and end datetime OR use the new `ignoreDates` parameter. - Projects now have a start and end date and the API will only return those, which are either unconfigured or currently active, you might want to reload the list of projects once the user entered begin and end datetime OR use the new `ignoreDates` parameter.
- Doctrine bundle was updated to v2, check your code for [the usage of RegistryInterface and ObjectManager](https://github.com/doctrine/DoctrineBundle/blob/master/UPGRADE-2.0.md) - Doctrine bundle was updated to v2, check your code for [the usage of RegistryInterface and ObjectManager](https://github.com/doctrine/DoctrineBundle/blob/master/UPGRADE-2.0.md)
- Removed the webserver bundle and the command `server:run` - see [docs](https://www.kimai.org/documentation/developers.html)
## [1.6](https://github.com/kevinpapst/kimai2/releases/tag/1.6), [1.6.1](https://github.com/kevinpapst/kimai2/releases/tag/1.6.1), [1.6.2](https://github.com/kevinpapst/kimai2/releases/tag/1.6.2) ## [1.6](https://github.com/kevinpapst/kimai2/releases/tag/1.6), [1.6.1](https://github.com/kevinpapst/kimai2/releases/tag/1.6.1), [1.6.2](https://github.com/kevinpapst/kimai2/releases/tag/1.6.2)

View File

@@ -9,7 +9,7 @@
namespace App\Entity; namespace App\Entity;
use App\Invoice\InvoiceItemInterface; use App\Export\ExportItemInterface;
use DateTime; use DateTime;
use DateTimeZone; use DateTimeZone;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
@@ -39,8 +39,11 @@ use Symfony\Component\Validator\Constraints as Assert;
* columns={"start_time","end_time"} => IDX_4F60C6B1502DF58741561401 => ??? * columns={"start_time","end_time"} => IDX_4F60C6B1502DF58741561401 => ???
* columns={"start_time","end_time","user"} => IDX_4F60C6B1502DF587415614018D93D649 => ??? * columns={"start_time","end_time","user"} => IDX_4F60C6B1502DF587415614018D93D649 => ???
*/ */
class Timesheet implements EntityWithMetaFields, InvoiceItemInterface class Timesheet implements EntityWithMetaFields, ExportItemInterface
{ {
public const TYPE_TIMESHEET = 'timesheet';
public const CATEGORY_WORK = 'work';
/** /**
* @var int * @var int
* *
@@ -385,7 +388,7 @@ class Timesheet implements EntityWithMetaFields, InvoiceItemInterface
/** /**
* @return string[] * @return string[]
*/ */
public function getTagsAsArray() public function getTagsAsArray(): array
{ {
return array_map( return array_map(
function (Tag $element) { function (Tag $element) {
@@ -437,6 +440,18 @@ class Timesheet implements EntityWithMetaFields, InvoiceItemInterface
return $this; return $this;
} }
public function getType(): string
{
// this will be improved in a future version
return self::TYPE_TIMESHEET;
}
public function getCategory(): string
{
// this will be improved in a future version
return self::CATEGORY_WORK;
}
/** /**
* @internal only here for symfony forms * @internal only here for symfony forms
* @return Collection|MetaTableTypeInterface[] * @return Collection|MetaTableTypeInterface[]

View File

@@ -10,13 +10,13 @@
namespace App\Export\Base; namespace App\Export\Base;
use App\Entity\MetaTableTypeInterface; use App\Entity\MetaTableTypeInterface;
use App\Entity\Timesheet;
use App\Event\ActivityMetaDisplayEvent; use App\Event\ActivityMetaDisplayEvent;
use App\Event\CustomerMetaDisplayEvent; use App\Event\CustomerMetaDisplayEvent;
use App\Event\MetaDisplayEventInterface; use App\Event\MetaDisplayEventInterface;
use App\Event\ProjectMetaDisplayEvent; use App\Event\ProjectMetaDisplayEvent;
use App\Event\TimesheetMetaDisplayEvent; use App\Event\TimesheetMetaDisplayEvent;
use App\Event\UserPreferenceDisplayEvent; use App\Event\UserPreferenceDisplayEvent;
use App\Export\ExportItemInterface;
use App\Repository\Query\CustomerQuery; use App\Repository\Query\CustomerQuery;
use App\Repository\Query\TimesheetQuery; use App\Repository\Query\TimesheetQuery;
use App\Twig\DateExtensions; use App\Twig\DateExtensions;
@@ -61,6 +61,30 @@ abstract class AbstractSpreadsheetRenderer
* @var AuthorizationCheckerInterface * @var AuthorizationCheckerInterface
*/ */
protected $voter; protected $voter;
/**
* @var array
*/
protected $columns = [
'date' => [],
'begin' => [],
'end' => [],
'duration' => [],
'rate' => [],
'user' => [],
'customer' => [],
'project' => [],
'activity' => [],
'description' => [],
'exported' => [],
'tags' => [],
'hourlyRate' => [],
'fixedRate' => [],
'timesheet-meta' => [],
'customer-meta' => [],
'project-meta' => [],
'activity-meta' => [],
'user-meta' => [],
];
public function __construct(TranslatorInterface $translator, DateExtensions $dateExtension, EventDispatcherInterface $dispatcher, AuthorizationCheckerInterface $voter) public function __construct(TranslatorInterface $translator, DateExtensions $dateExtension, EventDispatcherInterface $dispatcher, AuthorizationCheckerInterface $voter)
{ {
@@ -72,7 +96,11 @@ abstract class AbstractSpreadsheetRenderer
protected function isRenderRate(TimesheetQuery $query): bool protected function isRenderRate(TimesheetQuery $query): bool
{ {
return true; if (null !== $query->getUser()) {
return $this->voter->isGranted('view_rate_own_timesheet');
}
return $this->voter->isGranted('view_rate_other_timesheet');
} }
protected function setFormattedDateTime(Worksheet $sheet, $column, $row, ?DateTime $date) protected function setFormattedDateTime(Worksheet $sheet, $column, $row, ?DateTime $date)
@@ -152,75 +180,302 @@ abstract class AbstractSpreadsheetRenderer
} }
/** /**
* @param Timesheet[] $timesheets * @param ExportItemInterface[] $exportItems
* @param TimesheetQuery $query
* @param array $columns
* @return array
*/
protected function getColumns(array $exportItems, TimesheetQuery $query, array $columns): array
{
$showRates = $this->isRenderRate($query);
if (isset($columns['date']) && !isset($columns['date']['render'])) {
$columns['date']['render'] = function (Worksheet $sheet, int $row, int $column, ExportItemInterface $entity) {
$this->setFormattedDate($sheet, $column, $row, $entity->getBegin());
};
}
if (isset($columns['begin']) && !isset($columns['begin']['render'])) {
$columns['begin']['render'] = function (Worksheet $sheet, int $row, int $column, ExportItemInterface $entity) {
$this->setFormattedTime($sheet, $column, $row, $entity->getBegin());
};
}
if (isset($columns['end']) && !isset($columns['end']['render'])) {
$columns['end']['render'] = function (Worksheet $sheet, int $row, int $column, ExportItemInterface $entity) {
$this->setFormattedTime($sheet, $column, $row, $entity->getEnd());
};
}
if (isset($columns['duration']) && !isset($columns['duration']['render'])) {
$columns['duration']['render'] = function (Worksheet $sheet, int $row, int $column, ExportItemInterface $entity) {
$this->setDuration($sheet, $column, $row, $entity->getDuration());
};
}
if ($showRates && isset($columns['rate']) && !isset($columns['rate']['render'])) {
$columns['rate']['render'] = function (Worksheet $sheet, int $row, int $column, ExportItemInterface $entity) {
$currency = '';
if (null !== $entity->getProject()) {
$currency = $entity->getProject()->getCustomer()->getCurrency();
}
$this->setRate($sheet, $column, $row, $entity->getRate(), $currency);
};
}
if (isset($columns['user']) && !isset($columns['user']['render'])) {
$columns['user']['render'] = function (Worksheet $sheet, int $row, int $column, ExportItemInterface $entity) {
$user = '';
if (null !== $entity->getUser()) {
$user = $entity->getUser()->getDisplayName();
}
$sheet->setCellValueByColumnAndRow($column, $row, $user);
};
}
if (isset($columns['customer']) && !isset($columns['customer']['render'])) {
$columns['customer']['render'] = function (Worksheet $sheet, int $row, int $column, ExportItemInterface $entity) {
$customer = '';
if (null !== $entity->getProject()) {
$customer = $entity->getProject()->getCustomer()->getName();
}
$sheet->setCellValueByColumnAndRow($column, $row, $customer);
};
}
if (isset($columns['project']) && !isset($columns['project']['render'])) {
$columns['project']['render'] = function (Worksheet $sheet, int $row, int $column, ExportItemInterface $entity) {
$project = '';
if (null !== $entity->getProject()) {
$project = $entity->getProject()->getName();
}
$sheet->setCellValueByColumnAndRow($column, $row, $project);
};
}
if (isset($columns['activity']) && !isset($columns['activity']['render'])) {
$columns['activity']['render'] = function (Worksheet $sheet, int $row, int $column, ExportItemInterface $entity) {
$activity = '';
if (null !== $entity->getActivity()) {
$activity = $entity->getActivity()->getName();
}
$sheet->setCellValueByColumnAndRow($column, $row, $activity);
};
}
if (isset($columns['description']) && !isset($columns['description']['render'])) {
$columns['description']['render'] = function (Worksheet $sheet, int $row, int $column, ExportItemInterface $entity) {
$sheet->setCellValueByColumnAndRow($column, $row, $entity->getDescription());
};
}
if (isset($columns['exported']) && !isset($columns['exported']['render'])) {
$columns['exported']['render'] = function (Worksheet $sheet, int $row, int $column, ExportItemInterface $entity) {
$exported = $entity->isExported() ? 'entryState.exported' : 'entryState.not_exported';
$sheet->setCellValueByColumnAndRow($column, $row, $this->translator->trans($exported));
};
}
if (isset($columns['tags']) && !isset($columns['tags']['render'])) {
$columns['tags']['render'] = function (Worksheet $sheet, int $row, int $column, ExportItemInterface $entity) {
$sheet->setCellValueByColumnAndRow($column, $row, implode(',', $entity->getTagsAsArray()));
};
}
if ($showRates && isset($columns['hourlyRate']) && !isset($columns['hourlyRate']['render'])) {
$columns['hourlyRate']['render'] = function (Worksheet $sheet, int $row, int $column, ExportItemInterface $entity) {
$currency = '';
if (null !== $entity->getProject()) {
$currency = $entity->getProject()->getCustomer()->getCurrency();
}
$this->setRate($sheet, $column, $row, $entity->getHourlyRate(), $currency);
};
}
if ($showRates && isset($columns['fixedRate']) && !isset($columns['fixedRate']['render'])) {
$columns['fixedRate']['render'] = function (Worksheet $sheet, int $row, int $column, ExportItemInterface $entity) {
$currency = '';
if (null !== $entity->getProject()) {
$currency = $entity->getProject()->getCustomer()->getCurrency();
}
$this->setRate($sheet, $column, $row, $entity->getFixedRate(), $currency);
};
}
if (isset($columns['timesheet-meta'])) {
$timesheetMetaFields = $this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT));
$columns['timesheet-meta'] = [
'header' => function (Worksheet $sheet, $row, $column) use ($timesheetMetaFields) {
foreach ($timesheetMetaFields as $metaField) {
$sheet->setCellValueByColumnAndRow($column++, $row, $this->translator->trans($metaField->getLabel()));
}
return count($timesheetMetaFields);
},
'render' => function (Worksheet $sheet, int $row, int $column, ExportItemInterface $entity) use ($timesheetMetaFields) {
foreach ($timesheetMetaFields as $metaField) {
$metaFieldValue = '';
$metaField = $entity->getMetaField($metaField->getName());
if (null !== $metaField) {
$metaFieldValue = $metaField->getValue();
}
$sheet->setCellValueByColumnAndRow($column++, $row, $metaFieldValue);
}
return count($timesheetMetaFields);
}
];
}
if (isset($columns['customer-meta'])) {
/** @var CustomerQuery $customerQuery */
$customerQuery = $query->copyTo(new CustomerQuery());
$customerMetaFields = $this->findMetaColumns(new CustomerMetaDisplayEvent($customerQuery, CustomerMetaDisplayEvent::EXPORT));
$columns['customer-meta'] = [
'header' => function (Worksheet $sheet, $row, $column) use ($customerMetaFields) {
foreach ($customerMetaFields as $metaField) {
$sheet->setCellValueByColumnAndRow($column++, $row, $this->translator->trans($metaField->getLabel()));
}
return count($customerMetaFields);
},
'render' => function (Worksheet $sheet, int $row, int $column, ExportItemInterface $entity) use ($customerMetaFields) {
foreach ($customerMetaFields as $metaField) {
$metaFieldValue = '';
if (null !== $entity->getProject()) {
$metaField = $entity->getProject()->getCustomer()->getMetaField($metaField->getName());
if (null !== $metaField) {
$metaFieldValue = $metaField->getValue();
}
}
$sheet->setCellValueByColumnAndRow($column++, $row, $metaFieldValue);
}
return count($customerMetaFields);
}
];
}
if (isset($columns['project-meta'])) {
$projectMetaFields = $this->findMetaColumns(new ProjectMetaDisplayEvent($query, ProjectMetaDisplayEvent::EXPORT));
$columns['project-meta'] = [
'header' => function (Worksheet $sheet, $row, $column) use ($projectMetaFields) {
foreach ($projectMetaFields as $metaField) {
$sheet->setCellValueByColumnAndRow($column++, $row, $this->translator->trans($metaField->getLabel()));
}
return count($projectMetaFields);
},
'render' => function (Worksheet $sheet, int $row, int $column, ExportItemInterface $entity) use ($projectMetaFields) {
foreach ($projectMetaFields as $metaField) {
$metaFieldValue = '';
if (null !== $entity->getProject()) {
$metaField = $entity->getProject()->getMetaField($metaField->getName());
if (null !== $metaField) {
$metaFieldValue = $metaField->getValue();
}
}
$sheet->setCellValueByColumnAndRow($column++, $row, $metaFieldValue);
}
return count($projectMetaFields);
}
];
}
if (isset($columns['activity-meta'])) {
$activityMetaFields = $this->findMetaColumns(new ActivityMetaDisplayEvent($query, ActivityMetaDisplayEvent::EXPORT));
$columns['activity-meta'] = [
'header' => function (Worksheet $sheet, $row, $column) use ($activityMetaFields) {
foreach ($activityMetaFields as $metaField) {
$sheet->setCellValueByColumnAndRow($column++, $row, $this->translator->trans($metaField->getLabel()));
}
return count($activityMetaFields);
},
'render' => function (Worksheet $sheet, int $row, int $column, ExportItemInterface $entity) use ($activityMetaFields) {
foreach ($activityMetaFields as $metaField) {
$metaFieldValue = '';
if (null !== $entity->getActivity()) {
$metaField = $entity->getActivity()->getMetaField($metaField->getName());
if (null !== $metaField) {
$metaFieldValue = $metaField->getValue();
}
}
$sheet->setCellValueByColumnAndRow($column++, $row, $metaFieldValue);
}
return count($activityMetaFields);
}
];
}
if (isset($columns['user-meta'])) {
$event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT);
$this->dispatcher->dispatch($event);
$userPreferences = $event->getPreferences();
$columns['user-meta'] = [
'header' => function (Worksheet $sheet, $row, $column) use ($userPreferences) {
foreach ($userPreferences as $metaField) {
$sheet->setCellValueByColumnAndRow($column++, $row, $this->translator->trans($metaField->getLabel()));
}
return count($userPreferences);
},
'render' => function (Worksheet $sheet, int $row, int $column, ExportItemInterface $entity) use ($userPreferences) {
foreach ($userPreferences as $preference) {
$metaFieldValue = '';
if (null !== $entity->getUser()) {
$metaField = $entity->getUser()->getPreference($preference->getName());
if (null !== $metaField) {
$metaFieldValue = $metaField->getValue();
}
}
$sheet->setCellValueByColumnAndRow($column++, $row, $metaFieldValue);
}
return count($userPreferences);
}
];
}
if (!$showRates) {
$removes = ['rate', 'fixedRate', 'hourlyRate'];
foreach ($removes as $removeMe) {
if (array_key_exists($removeMe, $columns)) {
unset($columns[$removeMe]);
}
}
}
return $columns;
}
/**
* @param ExportItemInterface[] $exportItems
* @param TimesheetQuery $query * @param TimesheetQuery $query
* @return Spreadsheet * @return Spreadsheet
* @throws \PhpOffice\PhpSpreadsheet\Exception * @throws \PhpOffice\PhpSpreadsheet\Exception
*/ */
protected function fromArrayToSpreadsheet(array $timesheets, TimesheetQuery $query): Spreadsheet protected function fromArrayToSpreadsheet(array $exportItems, TimesheetQuery $query): Spreadsheet
{ {
$customerQuery = $query->copyTo(new CustomerQuery());
$timesheetMetaFields = $this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT));
$customerMetaFields = $this->findMetaColumns(new CustomerMetaDisplayEvent($customerQuery, CustomerMetaDisplayEvent::EXPORT));
$projectMetaFields = $this->findMetaColumns(new ProjectMetaDisplayEvent($query, ProjectMetaDisplayEvent::EXPORT));
$activityMetaFields = $this->findMetaColumns(new ActivityMetaDisplayEvent($query, ActivityMetaDisplayEvent::EXPORT));
$event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT);
$this->dispatcher->dispatch($event);
$userPreferences = $event->getPreferences();
$spreadsheet = new Spreadsheet(); $spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet(); $sheet = $spreadsheet->getActiveSheet();
$showRates = $this->isRenderRate($query);
$recordsHeaderColumn = 1; $recordsHeaderColumn = 1;
$recordsHeaderRow = 1; $recordsHeaderRow = 1;
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.date')); $columns = $this->getColumns($exportItems, $query, $this->columns);
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.begin'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.end')); foreach ($columns as $label => $settings) {
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.duration')); if (isset($settings['header'])) {
if ($showRates) { $amount = $settings['header']($sheet, $recordsHeaderRow, $recordsHeaderColumn);
$sheet->setCellValueByColumnAndRow( $recordsHeaderColumn += $amount;
$recordsHeaderColumn++, } else {
$recordsHeaderRow, $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.' . $label));
$this->translator->trans('label.rate') }
);
}
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.user'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.customer'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.project'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.activity'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.description'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.exported'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.tags'));
if ($showRates) {
$sheet->setCellValueByColumnAndRow(
$recordsHeaderColumn++,
$recordsHeaderRow,
$this->translator->trans('label.hourlyRate')
);
$sheet->setCellValueByColumnAndRow(
$recordsHeaderColumn++,
$recordsHeaderRow,
$this->translator->trans('label.fixedRate')
);
}
foreach ($timesheetMetaFields as $metaField) {
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans($metaField->getLabel()));
}
foreach ($customerMetaFields as $metaField) {
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans($metaField->getLabel()));
}
foreach ($projectMetaFields as $metaField) {
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans($metaField->getLabel()));
}
foreach ($activityMetaFields as $metaField) {
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans($metaField->getLabel()));
}
foreach ($userPreferences as $preference) {
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans($preference->getLabel()));
} }
$entryHeaderRow = $recordsHeaderRow + 1; $entryHeaderRow = $recordsHeaderRow + 1;
@@ -228,111 +483,22 @@ abstract class AbstractSpreadsheetRenderer
$durationColumn = null; $durationColumn = null;
$rateColumn = null; $rateColumn = null;
foreach ($timesheets as $timesheet) { foreach ($exportItems as $exportItem) {
$entryHeaderColumn = 1; $entryHeaderColumn = 1;
$customerCurrency = $timesheet->getProject()->getCustomer()->getCurrency(); foreach ($columns as $label => $settings) {
$exported = $timesheet->isExported() ? 'entryState.exported' : 'entryState.not_exported'; if ($label === 'duration') {
$durationColumn = $entryHeaderColumn;
$this->setFormattedDate($sheet, $entryHeaderColumn, $entryHeaderRow, $timesheet->getBegin()); } elseif ($label === 'rate') {
$entryHeaderColumn++; $rateColumn = $entryHeaderColumn;
$this->setFormattedTime($sheet, $entryHeaderColumn, $entryHeaderRow, $timesheet->getBegin());
$entryHeaderColumn++;
$this->setFormattedTime($sheet, $entryHeaderColumn, $entryHeaderRow, $timesheet->getEnd());
$entryHeaderColumn++;
$this->setDuration($sheet, $entryHeaderColumn, $entryHeaderRow, $timesheet->getDuration());
$durationColumn = $entryHeaderColumn;
$entryHeaderColumn++;
if ($showRates) {
$this->setRate($sheet, $entryHeaderColumn, $entryHeaderRow, $timesheet->getRate(), $customerCurrency);
$rateColumn = $entryHeaderColumn;
$entryHeaderColumn++;
}
$sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $timesheet->getUser()->getDisplayName());
$entryHeaderColumn++;
$sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $timesheet->getProject()->getCustomer()->getName());
$entryHeaderColumn++;
$sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $timesheet->getProject()->getName());
$entryHeaderColumn++;
$sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $timesheet->getActivity()->getName());
$entryHeaderColumn++;
$sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $timesheet->getDescription());
$entryHeaderColumn++;
$sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $this->translator->trans($exported));
$entryHeaderColumn++;
$sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, implode(',', $timesheet->getTagsAsArray()));
$entryHeaderColumn++;
if ($showRates) {
$this->setRate(
$sheet,
$entryHeaderColumn,
$entryHeaderRow,
$timesheet->getHourlyRate(),
$customerCurrency
);
$entryHeaderColumn++;
$this->setRate(
$sheet,
$entryHeaderColumn,
$entryHeaderRow,
$timesheet->getFixedRate(),
$customerCurrency
);
$entryHeaderColumn++;
}
foreach ($timesheetMetaFields as $metaField) {
$metaField = $timesheet->getMetaField($metaField->getName());
$metaFieldValue = '';
if (null !== $metaField) {
$metaFieldValue = $metaField->getValue();
} }
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $metaFieldValue);
} if (!array_key_exists('render', $settings) || !is_callable($settings['render'])) {
foreach ($customerMetaFields as $metaField) { throw new \RuntimeException(sprintf('Missing renderer for export column %s', $label));
$metaField = $timesheet->getProject()->getCustomer()->getMetaField($metaField->getName());
$metaFieldValue = '';
if (null !== $metaField) {
$metaFieldValue = $metaField->getValue();
} }
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $metaFieldValue);
} $amount = $settings['render']($sheet, $entryHeaderRow, $entryHeaderColumn, $exportItem);
foreach ($projectMetaFields as $metaField) { $entryHeaderColumn += (null === $amount) ? 1 : $amount;
$metaField = $timesheet->getProject()->getMetaField($metaField->getName());
$metaFieldValue = '';
if (null !== $metaField) {
$metaFieldValue = $metaField->getValue();
}
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $metaFieldValue);
}
foreach ($activityMetaFields as $metaField) {
$metaField = $timesheet->getActivity()->getMetaField($metaField->getName());
$metaFieldValue = '';
if (null !== $metaField) {
$metaFieldValue = $metaField->getValue();
}
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $metaFieldValue);
}
foreach ($userPreferences as $preference) {
$metaField = $timesheet->getUser()->getPreference($preference->getName());
$metaFieldValue = '';
if (null !== $metaField) {
$metaFieldValue = $metaField->getValue();
}
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $metaFieldValue);
} }
$entryHeaderRow++; $entryHeaderRow++;
@@ -360,15 +526,15 @@ abstract class AbstractSpreadsheetRenderer
} }
/** /**
* @param Timesheet[] $timesheets * @param ExportItemInterface[] $exportItems
* @param TimesheetQuery $query * @param TimesheetQuery $query
* @return Response * @return Response
* @throws \PhpOffice\PhpSpreadsheet\Exception * @throws \PhpOffice\PhpSpreadsheet\Exception
* @throws \PhpOffice\PhpSpreadsheet\Writer\Exception * @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
*/ */
public function render(array $timesheets, TimesheetQuery $query): Response public function render(array $exportItems, TimesheetQuery $query): Response
{ {
$spreadsheet = $this->fromArrayToSpreadsheet($timesheets, $query); $spreadsheet = $this->fromArrayToSpreadsheet($exportItems, $query);
$filename = $this->saveSpreadsheet($spreadsheet); $filename = $this->saveSpreadsheet($spreadsheet);
return $this->getFileResponse($filename, 'kimai-export' . $this->getFileExtension()); return $this->getFileResponse($filename, 'kimai-export' . $this->getFileExtension());

View File

@@ -10,13 +10,13 @@
namespace App\Export\Base; namespace App\Export\Base;
use App\Entity\MetaTableTypeInterface; use App\Entity\MetaTableTypeInterface;
use App\Entity\Timesheet;
use App\Event\ActivityMetaDisplayEvent; use App\Event\ActivityMetaDisplayEvent;
use App\Event\CustomerMetaDisplayEvent; use App\Event\CustomerMetaDisplayEvent;
use App\Event\MetaDisplayEventInterface; use App\Event\MetaDisplayEventInterface;
use App\Event\ProjectMetaDisplayEvent; use App\Event\ProjectMetaDisplayEvent;
use App\Event\TimesheetMetaDisplayEvent; use App\Event\TimesheetMetaDisplayEvent;
use App\Event\UserPreferenceDisplayEvent; use App\Event\UserPreferenceDisplayEvent;
use App\Export\ExportItemInterface;
use App\Repository\Query\CustomerQuery; use App\Repository\Query\CustomerQuery;
use App\Repository\Query\TimesheetQuery; use App\Repository\Query\TimesheetQuery;
use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface;
@@ -54,7 +54,7 @@ class HtmlRenderer
} }
/** /**
* @param Timesheet[] $timesheets * @param ExportItemInterface[] $timesheets
* @param TimesheetQuery $query * @param TimesheetQuery $query
* @return Response * @return Response
* @throws \Twig\Error\LoaderError * @throws \Twig\Error\LoaderError
@@ -63,6 +63,7 @@ class HtmlRenderer
*/ */
public function render(array $timesheets, TimesheetQuery $query): Response public function render(array $timesheets, TimesheetQuery $query): Response
{ {
/** @var CustomerQuery $customerQuery */
$customerQuery = $query->copyTo(new CustomerQuery()); $customerQuery = $query->copyTo(new CustomerQuery());
$timesheetMetaFields = $this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT)); $timesheetMetaFields = $this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT));

View File

@@ -9,7 +9,7 @@
namespace App\Export\Base; namespace App\Export\Base;
use App\Entity\Timesheet; use App\Export\ExportItemInterface;
use App\Repository\Query\TimesheetQuery; use App\Repository\Query\TimesheetQuery;
use App\Timesheet\UserDateTimeFactory; use App\Timesheet\UserDateTimeFactory;
use App\Utils\HtmlToPdfConverter; use App\Utils\HtmlToPdfConverter;
@@ -47,7 +47,7 @@ class PDFRenderer
} }
/** /**
* @param Timesheet[] $timesheets * @param ExportItemInterface[] $timesheets
* @param TimesheetQuery $query * @param TimesheetQuery $query
* @return Response * @return Response
* @throws \Twig\Error\LoaderError * @throws \Twig\Error\LoaderError

View File

@@ -9,28 +9,48 @@
namespace App\Export\Base; namespace App\Export\Base;
use App\Entity\Timesheet; use App\Export\ExportItemInterface;
trait RendererTrait trait RendererTrait
{ {
/** /**
* @param Timesheet[] $timesheets * @param ExportItemInterface[] $exportItems
* @return array * @return array
*/ */
protected function calculateSummary(array $timesheets) protected function calculateSummary(array $exportItems)
{ {
$summary = []; $summary = [];
foreach ($timesheets as $timesheet) { foreach ($exportItems as $exportItem) {
$id = $timesheet->getProject()->getCustomer()->getId() . '_' . $timesheet->getProject()->getId(); $customerId = 'none';
$activityId = $timesheet->getActivity()->getId(); $customerName = '';
$currency = null;
$projectId = 'none';
$projectName = '';
$activityId = 'none';
$activityName = '';
if (null !== $exportItem->getProject()) {
$customerId = $exportItem->getProject()->getCustomer()->getId();
$customerName = $exportItem->getProject()->getCustomer()->getName();
$projectId = $exportItem->getProject()->getId();
$projectName = $exportItem->getProject()->getName();
$currency = $exportItem->getProject()->getCustomer()->getCurrency();
}
if (null !== $exportItem->getActivity()) {
$activityId = $exportItem->getActivity()->getId();
$activityName = $exportItem->getActivity()->getName();
}
$id = $customerId . '_' . $projectId;
if (!isset($summary[$id])) { if (!isset($summary[$id])) {
$summary[$id] = [ $summary[$id] = [
'customer' => $timesheet->getProject()->getCustomer()->getName(), 'customer' => $customerName,
'project' => $timesheet->getProject()->getName(), 'project' => $projectName,
'activities' => [], 'activities' => [],
'currency' => $timesheet->getProject()->getCustomer()->getCurrency(), 'currency' => $currency,
'rate' => 0, 'rate' => 0,
'duration' => 0, 'duration' => 0,
]; ];
@@ -38,21 +58,21 @@ trait RendererTrait
if (!isset($summary[$id]['activities'][$activityId])) { if (!isset($summary[$id]['activities'][$activityId])) {
$summary[$id]['activities'][$activityId] = [ $summary[$id]['activities'][$activityId] = [
'activity' => $timesheet->getActivity()->getName(), 'activity' => $activityName,
'currency' => $timesheet->getProject()->getCustomer()->getCurrency(), 'currency' => $currency,
'rate' => 0, 'rate' => 0,
'duration' => 0, 'duration' => 0,
]; ];
} }
$duration = $timesheet->getDuration(); $duration = $exportItem->getDuration();
if (null === $duration) { if (null === $duration) {
$duration = 0; $duration = 0;
} }
$summary[$id]['rate'] += $timesheet->getRate(); $summary[$id]['rate'] += $exportItem->getRate();
$summary[$id]['duration'] += $duration; $summary[$id]['duration'] += $duration;
$summary[$id]['activities'][$activityId]['rate'] += $timesheet->getRate(); $summary[$id]['activities'][$activityId]['rate'] += $exportItem->getRate();
$summary[$id]['activities'][$activityId]['duration'] += $duration; $summary[$id]['activities'][$activityId]['duration'] += $duration;
} }

View File

@@ -0,0 +1,52 @@
<?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;
use App\Entity\MetaTableTypeInterface;
use App\Invoice\InvoiceItemInterface;
interface ExportItemInterface extends InvoiceItemInterface
{
/**
* A name representation for this type of export.
*
* @return string
*/
public function getType(): string;
/**
* A name representation for the category of this type.
*
* @return string
*/
public function getCategory(): string;
/**
* Whether this item was already exported.
*
* @return bool
*/
public function isExported(): bool;
/**
* Returns the named meta field or null.
*
* @param string $name
* @return MetaTableTypeInterface|null
*/
public function getMetaField(string $name): ?MetaTableTypeInterface;
/**
* Returns all assigned tag names.
*
* @return string[]
*/
public function getTagsAsArray(): array;
}

View File

@@ -9,18 +9,17 @@
namespace App\Export; namespace App\Export;
use App\Entity\Timesheet;
use App\Repository\Query\TimesheetQuery; use App\Repository\Query\TimesheetQuery;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
interface RendererInterface interface RendererInterface
{ {
/** /**
* @param Timesheet[] $timesheets * @param ExportItemInterface[] $exportItems
* @param TimesheetQuery $query * @param TimesheetQuery $query
* @return Response * @return Response
*/ */
public function render(array $timesheets, TimesheetQuery $query): Response; public function render(array $exportItems, TimesheetQuery $query): Response;
/** /**
* @return string * @return string

View File

@@ -11,16 +11,7 @@ namespace App\Export\Timesheet;
use App\Export\Base\CsvRenderer as BaseCsvRenderer; use App\Export\Base\CsvRenderer as BaseCsvRenderer;
use App\Export\TimesheetExportInterface; use App\Export\TimesheetExportInterface;
use App\Repository\Query\TimesheetQuery;
final class CsvRenderer extends BaseCsvRenderer implements TimesheetExportInterface final class CsvRenderer extends BaseCsvRenderer implements TimesheetExportInterface
{ {
protected function isRenderRate(TimesheetQuery $query): bool
{
if (null !== $query->getUser()) {
return $this->voter->isGranted('view_rate_own_timesheet');
}
return true;
}
} }

View File

@@ -11,12 +11,7 @@ namespace App\Export\Timesheet;
use App\Export\Base\XlsxRenderer as BaseXlsxRenderer; use App\Export\Base\XlsxRenderer as BaseXlsxRenderer;
use App\Export\TimesheetExportInterface; use App\Export\TimesheetExportInterface;
use App\Repository\Query\TimesheetQuery;
final class XlsxRenderer extends BaseXlsxRenderer implements TimesheetExportInterface final class XlsxRenderer extends BaseXlsxRenderer implements TimesheetExportInterface
{ {
protected function isRenderRate(TimesheetQuery $query): bool
{
return false;
}
} }

View File

@@ -10,13 +10,16 @@
namespace App\Invoice\Calculator; namespace App\Invoice\Calculator;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Export\ExportItemInterface;
use App\Invoice\InvoiceItem; use App\Invoice\InvoiceItem;
use App\Invoice\InvoiceItemInterface; use App\Invoice\InvoiceItemInterface;
use App\Invoice\InvoiceItemWithAmountInterface; use App\Invoice\InvoiceItemWithAmountInterface;
use App\Invoice\InvoiceItemWithTypeInterface;
abstract class AbstractMergedCalculator extends AbstractCalculator abstract class AbstractMergedCalculator extends AbstractCalculator
{ {
public const TYPE_MIXED = 'mixed';
public const CATEGORY_MIXED = 'mixed';
/** /**
* @deprecated since 1.3 - will be removed with 2.0 * @deprecated since 1.3 - will be removed with 2.0
*/ */
@@ -39,19 +42,19 @@ abstract class AbstractMergedCalculator extends AbstractCalculator
$amount = $entry->getAmount(); $amount = $entry->getAmount();
} }
if ($entry instanceof InvoiceItemWithTypeInterface) { $type = Timesheet::TYPE_TIMESHEET;
$type = $entry->getInvoiceType(); $category = Timesheet::CATEGORY_WORK;
$category = $entry->getInvoiceCategory();
} else { if ($entry instanceof ExportItemInterface) {
$type = InvoiceItem::TYPE_TIMESHEET; $type = $entry->getType();
$category = InvoiceItem::CATEGORY_TIMESHEET_WORK; $category = $entry->getCategory();
} }
if (null !== $invoiceItem->getType() && $type !== $invoiceItem->getType()) { if (null !== $invoiceItem->getType() && $type !== $invoiceItem->getType()) {
$type = InvoiceItem::TYPE_MIXED; $type = self::TYPE_MIXED;
} }
if (null !== $invoiceItem->getCategory() && $category !== $invoiceItem->getCategory()) { if (null !== $invoiceItem->getCategory() && $category !== $invoiceItem->getCategory()) {
$category = InvoiceItem::CATEGORY_MIXED; $category = self::CATEGORY_MIXED;
} }
$invoiceItem->setType($type); $invoiceItem->setType($type);

View File

@@ -18,11 +18,6 @@ use App\Entity\User;
*/ */
final class InvoiceItem final class InvoiceItem
{ {
public const TYPE_TIMESHEET = 'timesheet';
public const CATEGORY_TIMESHEET_WORK = 'work';
public const TYPE_MIXED = 'mixed';
public const CATEGORY_MIXED = 'mixed';
/** /**
* @var float * @var float
*/ */

View File

@@ -14,6 +14,9 @@ use App\Entity\MetaTableTypeInterface;
use App\Entity\Project; use App\Entity\Project;
use App\Entity\User; use App\Entity\User;
/**
* @deprecated will be removed with 2.0 - use ExportItemInterface instead
*/
interface InvoiceItemInterface interface InvoiceItemInterface
{ {
public function getActivity(): ?Activity; public function getActivity(): ?Activity;

View File

@@ -1,17 +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\Invoice;
interface InvoiceItemWithTypeInterface
{
public function getInvoiceType(): string;
public function getInvoiceCategory(): string;
}

View File

@@ -91,6 +91,8 @@ class TimesheetQuery extends ActivityQuery
} }
/** /**
* Limit the data exclusively to the user (eg. users own timesheets).
*
* @return User|null * @return User|null
*/ */
public function getUser() public function getUser()
@@ -99,6 +101,8 @@ class TimesheetQuery extends ActivityQuery
} }
/** /**
* Limit the data exclusively to the user (eg. users own timesheets).
*
* @param User|int|null $user * @param User|int|null $user
* @return TimesheetQuery * @return TimesheetQuery
*/ */

View File

@@ -291,7 +291,9 @@
<td class="column-project" {% if not columns.project %}style="display: none"{% endif %}>{{ entry.project.name }} <td class="column-project" {% if not columns.project %}style="display: none"{% endif %}>{{ entry.project.name }}
</td> </td>
<td class="column-activity" {% if not columns.activity %}style="display: none"{% endif %}> <td class="column-activity" {% if not columns.activity %}style="display: none"{% endif %}>
{{ entry.activity.name }} {% if entry.activity is not null %}
{{ entry.activity.name }}
{% endif %}
</td> </td>
<td class="column-description" {% if not columns.description %}style="display: none"{% endif %}> <td class="column-description" {% if not columns.description %}style="display: none"{% endif %}>
{% if entry.description is not empty %} {% if entry.description is not empty %}
@@ -327,7 +329,9 @@
{% endfor %} {% endfor %}
{% for id, field in activityMetaFields %} {% for id, field in activityMetaFields %}
<td class="column-{{ 'a_' ~ field.name }} text-nowrap" {% if not columns['a_' ~ field.name] %}style="display: none"{% endif %}> <td class="column-{{ 'a_' ~ field.name }} text-nowrap" {% if not columns['a_' ~ field.name] %}style="display: none"{% endif %}>
{{ tables.datatable_meta_column(entry.activity, field) }} {% if entry.activity is not null %}
{{ tables.datatable_meta_column(entry.activity, field) }}
{% endif %}
</td> </td>
{% endfor %} {% endfor %}
{% for id, field in userPreferences %} {% for id, field in userPreferences %}

View File

@@ -182,7 +182,7 @@ mpdf-->
<td>{{ entry.user.displayName }}</td> <td>{{ entry.user.displayName }}</td>
{% endif %} {% endif %}
<td> <td>
{{ entry.project.customer.name }} - {{ entry.project.name }} - {{ entry.activity.name }} {{ entry.project.customer.name }} - {{ entry.project.name }}{% if entry.activity is not null %} - {{ entry.activity.name }}{% endif %}
{% if entry.description is not empty %} {% if entry.description is not empty %}
<br> <br>
<i>{{ entry.description|escape|desc2html }}</i> <i>{{ entry.description|escape|desc2html }}</i>
@@ -209,7 +209,7 @@ mpdf-->
{% endif %} {% endif %}
<td class="totals duration">{{ duration|duration }}</td> <td class="totals duration">{{ duration|duration }}</td>
{% if showRateColumn %} {% if showRateColumn %}
<td class="totals cost">{{ rate|money(currency) }}</td> <td class="totals cost">{% if currency is not null %}{{ rate|money(currency) }}{% endif %}</td>
{% endif %} {% endif %}
</tr> </tr>
</tbody> </tbody>

View File

@@ -4,7 +4,7 @@
{% set tableName = 'timesheet_admin' %} {% set tableName = 'timesheet_admin' %}
{% set paginationRoute = 'admin_timesheet_paginated' %} {% set paginationRoute = 'admin_timesheet_paginated' %}
{% set editRoute = 'admin_timesheet_edit' %} {% set editRoute = 'admin_timesheet_edit' %}
{% set canSeeRate = true %} {% set canSeeRate = is_granted('view_rate_other_timesheet') %}
{% set canSeeUsername = true %} {% set canSeeUsername = true %}
{% set allowMarkdown = false %} {% set allowMarkdown = false %}

View File

@@ -12,23 +12,23 @@
<div class="row"> <div class="row">
<div class="col-xs-12"> <div class="col-xs-12">
<h2 class="page-header"> <h2 class="page-header">
<span contenteditable="true"> {% if not showUserColumn %}
{% if query.begin is not empty and query.end is not empty %} {{ widgets.username(query.user) }}:
{% if query.begin|date('m') != query.end|date('m') or query.begin|date('Y') != query.end|date('Y') %} {% endif %}
{{ query.begin|date_short }} - {{ query.end|date_short }} {% if query.begin is not empty %}
{% elseif query.end is not empty %} {{ query.begin|date_short }}
{{ query.end|month_name|trans }} {{ query.end|date('Y') }} {% else %}
{% elseif query.begin is not empty %} {% set last = entries|last %}
{{ query.begin|month_name|trans }} {{ query.begin|date('Y') }} {{ last.begin|date_short }}
{% endif %} {% endif %}
{% endif %} &ndash;
</span> {% if query.end is not empty %}
{{ query.end|date_short }}
{% else %}
{% set first = entries|first %}
{{ first.end|date_short }}
{% endif %}
</h2> </h2>
{% if not showUserColumn %}
<p>
{{ 'label.user'|trans }}: {{ widgets.username(query.user) }}
</p>
{% endif %}
</div> </div>
</div> </div>
@@ -43,7 +43,7 @@
{% endif %} {% endif %}
<th>{{ 'label.description'|trans }}</th> <th>{{ 'label.description'|trans }}</th>
{% for field in metaColumns %} {% for field in metaColumns %}
<th>{{ field.label|trans }}</th> <th class="text-nowrap">{{ field.label|trans }}</th>
{% endfor %} {% endfor %}
<th>{{ 'label.hours'|trans }}</th> <th>{{ 'label.hours'|trans }}</th>
</tr> </tr>
@@ -64,7 +64,7 @@
</div> </div>
{% endif %} {% endif %}
<span class="small"> <span class="small">
{{ 'label.activity'|trans }}: {{ entry.activity.name }} | {% if entry.activity is not null %}{{ 'label.activity'|trans }}: {{ entry.activity.name }} |{% endif %}
{{ 'label.project'|trans }}: {{ entry.project.name }} | {{ 'label.project'|trans }}: {{ entry.project.name }} |
{{ 'label.customer'|trans }}: {{ entry.project.customer.name }} {{ 'label.customer'|trans }}: {{ entry.project.customer.name }}
</span> </span>
@@ -86,7 +86,7 @@
<th></th> <th></th>
{% endfor %} {% endfor %}
<th>{{ 'invoice.total_working_time'|trans }}</th> <th>{{ 'invoice.total_working_time'|trans }}</th>
<th>{{ timeWorked|duration }}</th> <th class="text-nowrap">{{ timeWorked|duration }}</th>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>

View File

@@ -46,6 +46,6 @@ class HtmlRendererTest extends AbstractRendererTest
$content = $response->getContent(); $content = $response->getContent();
$this->assertStringContainsString('<th>01:50 h</th>', $content); $this->assertStringContainsString('>01:50 h<', $content);
} }
} }