new export of project overview list (#5009)
This commit is contained in:
@@ -10,21 +10,42 @@
|
|||||||
namespace App\Controller\Reporting;
|
namespace App\Controller\Reporting;
|
||||||
|
|
||||||
use App\Controller\AbstractController;
|
use App\Controller\AbstractController;
|
||||||
|
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
|
||||||
|
use App\Export\Spreadsheet\Writer\XlsxWriter;
|
||||||
use App\Project\ProjectStatisticService;
|
use App\Project\ProjectStatisticService;
|
||||||
use App\Reporting\ProjectView\ProjectViewForm;
|
use App\Reporting\ProjectView\ProjectViewForm;
|
||||||
use App\Reporting\ProjectView\ProjectViewQuery;
|
use App\Reporting\ProjectView\ProjectViewQuery;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Reader\Html;
|
||||||
use Symfony\Component\ExpressionLanguage\Expression;
|
use Symfony\Component\ExpressionLanguage\Expression;
|
||||||
|
use Symfony\Component\Form\FormView;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
#[Route(path: '/reporting/project_view')]
|
||||||
final class ProjectViewController extends AbstractController
|
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('report:project')]
|
||||||
#[IsGranted(new Expression("is_granted('budget_any', 'project')"))]
|
#[IsGranted(new Expression("is_granted('budget_any', 'project')"))]
|
||||||
public function __invoke(Request $request, ProjectStatisticService $service): Response
|
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<mixed>, 'form': FormView, 'now': \DateTimeInterface}
|
||||||
|
*/
|
||||||
|
private function getData(Request $request, ProjectStatisticService $service): array
|
||||||
{
|
{
|
||||||
$dateFactory = $this->getDateTimeFactory();
|
$dateFactory = $this->getDateTimeFactory();
|
||||||
$user = $this->getUser();
|
$user = $this->getUser();
|
||||||
@@ -45,12 +66,30 @@ final class ProjectViewController extends AbstractController
|
|||||||
$byCustomer[$customer->getId()]['projects'][] = $entry;
|
$byCustomer[$customer->getId()]['projects'][] = $entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->render('reporting/project_view.html.twig', [
|
return [
|
||||||
'entries' => $byCustomer,
|
'entries' => $byCustomer,
|
||||||
'form' => $form->createView(),
|
'form' => $form->createView(),
|
||||||
'report_title' => 'report_project_view',
|
|
||||||
'tableName' => 'project_view_reporting',
|
|
||||||
'now' => $dateFactory->createDateTime(),
|
'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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
134
templates/reporting/project_list_export.html.twig
Normal file
134
templates/reporting/project_list_export.html.twig
Normal file
@@ -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 %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{% for column, settings in columns %}
|
||||||
|
<th>{{ settings.title }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% 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) %}
|
||||||
|
<tr>
|
||||||
|
{% 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 %}
|
||||||
|
<td>
|
||||||
|
{% 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 %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
@@ -18,4 +18,6 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
{% from '@theme/components/buttons.html.twig' import submit_button %}
|
||||||
|
{{ submit_button('download', {'attr': {'formaction': path(export_route)}, 'icon': 'download', 'combined': false}, 'primary') }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use App\Tests\DataFixtures\ActivityFixtures;
|
|||||||
use App\Tests\DataFixtures\CustomerFixtures;
|
use App\Tests\DataFixtures\CustomerFixtures;
|
||||||
use App\Tests\DataFixtures\ProjectFixtures;
|
use App\Tests\DataFixtures\ProjectFixtures;
|
||||||
use App\Tests\DataFixtures\TimesheetFixtures;
|
use App\Tests\DataFixtures\TimesheetFixtures;
|
||||||
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @group integration
|
* @group integration
|
||||||
@@ -57,4 +58,41 @@ class ProjectViewControllerTest extends ControllerBaseTest
|
|||||||
$rows = $client->getCrawler()->filterXPath("//table[contains(@class, 'dataTable')]/tbody/tr[not(@class='summary')]");
|
$rows = $client->getCrawler()->filterXPath("//table[contains(@class, 'dataTable')]/tbody/tr[not(@class='summary')]");
|
||||||
self::assertGreaterThan(0, $rows->count());
|
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'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user