diff --git a/src/Controller/Reporting/ProjectViewController.php b/src/Controller/Reporting/ProjectViewController.php index 4c3524a8..e1a05844 100644 --- a/src/Controller/Reporting/ProjectViewController.php +++ b/src/Controller/Reporting/ProjectViewController.php @@ -10,21 +10,42 @@ namespace App\Controller\Reporting; use App\Controller\AbstractController; +use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter; +use App\Export\Spreadsheet\Writer\XlsxWriter; use App\Project\ProjectStatisticService; use App\Reporting\ProjectView\ProjectViewForm; use App\Reporting\ProjectView\ProjectViewQuery; +use PhpOffice\PhpSpreadsheet\Reader\Html; use Symfony\Component\ExpressionLanguage\Expression; +use Symfony\Component\Form\FormView; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; +#[Route(path: '/reporting/project_view')] final class ProjectViewController extends AbstractController { - #[Route(path: '/reporting/project_view', name: 'report_project_view', methods: ['GET', 'POST'])] + #[Route(path: '', name: 'report_project_view', methods: ['GET', 'POST'])] #[IsGranted('report:project')] #[IsGranted(new Expression("is_granted('budget_any', 'project')"))] public function __invoke(Request $request, ProjectStatisticService $service): Response + { + $data = $this->getData($request, $service); + + return $this->render('reporting/project_view.html.twig', array_merge($data, [ + 'report_title' => 'report_project_view', + 'tableName' => 'project_view_reporting', + 'export_route' => 'report_project_view_export', + ])); + } + + /** + * @param Request $request + * @param ProjectStatisticService $service + * @return array{'entries': array, 'form': FormView, 'now': \DateTimeInterface} + */ + private function getData(Request $request, ProjectStatisticService $service): array { $dateFactory = $this->getDateTimeFactory(); $user = $this->getUser(); @@ -45,12 +66,30 @@ final class ProjectViewController extends AbstractController $byCustomer[$customer->getId()]['projects'][] = $entry; } - return $this->render('reporting/project_view.html.twig', [ + return [ 'entries' => $byCustomer, 'form' => $form->createView(), - 'report_title' => 'report_project_view', - 'tableName' => 'project_view_reporting', 'now' => $dateFactory->createDateTime(), - ]); + ]; + } + + #[Route(path: '/export', name: 'report_project_view_export', methods: ['GET', 'POST'])] + public function export(Request $request, ProjectStatisticService $service): Response + { + $data = $this->getData($request, $service); + + // Projektübersicht inkl. dem was Projektdetails anzeigen + // Budget / Zeitbudget + // Abrechenbar + // Interner Preis + + $content = $this->renderView('reporting/project_list_export.html.twig', $data); + + $reader = new Html(); + $spreadsheet = $reader->loadFromString($content); + + $writer = new BinaryFileResponseWriter(new XlsxWriter(), 'kimai-export-project-overview'); + + return $writer->getFileResponse($spreadsheet); } } diff --git a/templates/reporting/project_list_export.html.twig b/templates/reporting/project_list_export.html.twig new file mode 100644 index 00000000..5353809f --- /dev/null +++ b/templates/reporting/project_list_export.html.twig @@ -0,0 +1,134 @@ +{% set columns = { + 'customer': {'title': 'customer'|trans}, + 'name': {'title': 'name'|trans}, + 'budgetType': {'title': 'budgetType'|trans}, +} %} +{% if is_granted('budget_time', 'project') %} + {% set columns = columns|merge({ + 'timeBudget': {'title': 'timeBudget'|trans}, + 'durationTotal': {'title': 'stats.durationTotal'|trans}, + 'billableTime': {'title': 'billable'|trans}, + 'exported': {'title': 'not_exported'|trans}, + }) %} +{% endif %} +{% if is_granted('budget_money', 'project') %} + {% set columns = columns|merge({ + 'budget': {'title': 'budget'|trans}, + 'amountTotal': {'title': 'stats.amountTotal'|trans}, + 'billableMoney': {'title': 'billable'|trans}, + 'internalRate': {'title': 'internalRate'|trans}, + 'revenue': {'title': ('stats.revenue'|trans ~ ' (' ~ 'billable'|trans ~ ' - ' ~ 'internalRate'|trans ~ ')')}, + 'hourlyBillable': {'title': ('hourlyRate'|trans ~ ' (' ~ 'billable'|trans ~ ' / ' ~ 'stats.durationTotal'|trans ~ ')')}, + 'hourlyTotal': {'title': ('hourlyRate'|trans ~ ' (' ~ 'stats.revenue'|trans ~ ' / ' ~ 'stats.durationTotal'|trans ~ ')')}, + 'invoiced': {'title': 'not_invoiced'|trans}, + }) %} +{% endif %} + + + + {% for column, settings in columns %} + + {% endfor %} + + + + {% for id, mapping in entries|sort((a, b) => a.customer.name <=> b.customer.name) %} + {% for entry in mapping.projects|sort((a, b) => a.project.name <=> b.project.name) %} + + {% set project = entry.project %} + {% set budgetStatisticModel = entry.getBudgetStatisticModel() %} + {% set currency = project.customer.currency %} + {% set durationTotal = entry.durationTotal|chart_duration %} + {% set canSeeTimeBudget = is_granted('time', project) %} + {% set showTimeBudget = project.hasTimeBudget() and canSeeTimeBudget %} + {% set canSeeMoneyBudget = is_granted('budget', project) %} + {% set showMoneyBudget = project.hasBudget() and canSeeMoneyBudget %} + {% set revenue = budgetStatisticModel.getBudgetSpent() - budgetStatisticModel.getInternalRate() %} + {% for name, column_config in columns %} + + {% endfor %} + + {% endfor %} + {% endfor %} + +
{{ settings.title }}
+ {% if name == 'name' %} + {{ project.name }} + {% elseif name == 'budgetType' %} + {% if project.budgetType is not null %} + {{ ('budgetType_' ~ project.budgetType)|trans }} + {% endif %} + {% elseif name == 'customer' %} + {{ project.customer.name }} + {% elseif name == 'lastRecord' %} + {% if entry.lastRecord is not null %} + {{ entry.lastRecord|date_short }} + {% else %} + – + {% endif %} + {% elseif name == 'today' %} + {{ entry.durationDay|duration }} + {% elseif name == 'week' %} + {{ entry.durationWeek|duration }} + {% elseif name == 'month' %} + {{ entry.durationMonth|duration }} + {% elseif name == 'timeBudget' %} + {% if showTimeBudget %} + {{ budgetStatisticModel.getTimeBudget()|duration }} + {% else %} + – + {% endif %} + {% elseif name == 'durationTotal' %} + {{ entry.durationTotal|duration }} + {% elseif name == 'billableTime' %} + {% if canSeeTimeBudget %} + {{ budgetStatisticModel.getTimeBudgetSpent()|duration }} + {% endif %} + {% elseif name == 'exported' %} + {% if canSeeTimeBudget %} + {{ entry.notExportedDuration|duration }} + {% endif %} + {% elseif name == 'budget' %} + {% if showMoneyBudget %} + {{ budgetStatisticModel.getBudget()|money(currency) }} + {% else %} + – + {% endif %} + {% elseif name == 'amountTotal' %} + {% if canSeeMoneyBudget %} + {{ budgetStatisticModel.getStatisticTotal().getRate()|money(currency) }} + {% endif %} + {% elseif name == 'billableMoney' %} + {% if canSeeMoneyBudget %} + {{ budgetStatisticModel.getBudgetSpent()|money(currency) }} + {% endif %} + {% elseif name == 'internalRate' %} + {% if canSeeMoneyBudget %} + {{ budgetStatisticModel.getInternalRate()|money(currency) }} + {% endif %} + {% elseif name == 'revenue' %} + {% if canSeeMoneyBudget %} + {{ revenue|money(currency) }} + {% endif %} + {% elseif name == 'hourlyBillable' %} + {% if canSeeMoneyBudget and durationTotal > 0 %} + {{ (budgetStatisticModel.getBudgetSpent() / durationTotal)|money(currency) }} + {% endif %} + {% elseif name == 'hourlyTotal' %} + {% if canSeeMoneyBudget and durationTotal > 0 %} + {{ (revenue / durationTotal)|money(currency) }} + {% endif %} + {% elseif name == 'invoiced' %} + {% if canSeeMoneyBudget %} + {{ entry.notExportedRate|money(currency) }} + {% endif %} + {% elseif name == 'projectStart' %} + {% if project.start is not null %}{{ project.start|date_short }}{% endif %} + {% elseif name == 'projectEnd' %} + {% if project.end is not null %}{{ project.end|date_short }}{% endif %} + {% elseif name == 'comment' %} + {{ project.comment }} + {% elseif name == 'actions' %} + {{ projectActions.project(project, 'custom') }} + {% endif %} +
diff --git a/templates/reporting/project_view.html.twig b/templates/reporting/project_view.html.twig index cd138dd2..d056c438 100644 --- a/templates/reporting/project_view.html.twig +++ b/templates/reporting/project_view.html.twig @@ -18,4 +18,6 @@ + {% from '@theme/components/buttons.html.twig' import submit_button %} + {{ submit_button('download', {'attr': {'formaction': path(export_route)}, 'icon': 'download', 'combined': false}, 'primary') }} {% endblock %} diff --git a/tests/Controller/Reporting/ProjectViewControllerTest.php b/tests/Controller/Reporting/ProjectViewControllerTest.php index 786ca888..ea415bb6 100644 --- a/tests/Controller/Reporting/ProjectViewControllerTest.php +++ b/tests/Controller/Reporting/ProjectViewControllerTest.php @@ -15,6 +15,7 @@ use App\Tests\DataFixtures\ActivityFixtures; use App\Tests\DataFixtures\CustomerFixtures; use App\Tests\DataFixtures\ProjectFixtures; use App\Tests\DataFixtures\TimesheetFixtures; +use Symfony\Component\HttpFoundation\BinaryFileResponse; /** * @group integration @@ -57,4 +58,41 @@ class ProjectViewControllerTest extends ControllerBaseTest $rows = $client->getCrawler()->filterXPath("//table[contains(@class, 'dataTable')]/tbody/tr[not(@class='summary')]"); self::assertGreaterThan(0, $rows->count()); } + + public function testReportExport(): void + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + + $customers = new CustomerFixtures(); + $customers->setIsVisible(true); + $customers->setAmount(1); + $customers = $this->importFixture($customers); + + $projects = new ProjectFixtures(); + $projects->setCustomers($customers); + $projects->setAmount(2); + $projects->setIsVisible(true); + $this->importFixture($projects); + + $activities = new ActivityFixtures(); + $activities->setAmount(5); + $activities->setIsGlobal(true); + $activities = $this->importFixture($activities); + + $timesheets = new TimesheetFixtures(); + $timesheets->setAmount(50); + $timesheets->setActivities($activities); + $timesheets->setUser($this->getUserByRole(User::ROLE_TEAMLEAD)); + $this->importFixture($timesheets); + + $this->assertAccessIsGranted($client, '/reporting/project_view/export'); + $response = $client->getResponse(); + $this->assertTrue($response->isSuccessful()); + self::assertInstanceOf(BinaryFileResponse::class, $response); + self::assertNotNull($response->headers->get('Content-Type')); + self::assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('Content-Type')); + self::assertNotNull($response->headers->get('Content-Disposition')); + self::assertStringContainsString('attachment; filename=kimai-export-project-overview_', $response->headers->get('Content-Disposition')); + self::assertStringContainsString('.xlsx', $response->headers->get('Content-Disposition')); + } }