302 lines
9.6 KiB
PHP
302 lines
9.6 KiB
PHP
<?php
|
||
|
||
namespace App\Twig;
|
||
|
||
use App\BusProNet\DataProvider\CountryDataProvider;
|
||
use App\BusProNet\DataProvider\PickupDataProvider;
|
||
use App\Entity\SeasonAssignment;
|
||
use App\Entity\Teamer;
|
||
use App\Entity\Upload;
|
||
use App\Service\Season\SeasonAssignmentLabeler;
|
||
use App\Service\Teamer\PickupResolver;
|
||
use Carbon\CarbonImmutable;
|
||
use Symfony\Component\HttpFoundation\RequestStack;
|
||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||
use Twig\Environment;
|
||
use Twig\Extension\RuntimeExtensionInterface;
|
||
use Twig\Extra\Intl\IntlExtension;
|
||
|
||
class AppRuntime implements RuntimeExtensionInterface
|
||
{
|
||
public function __construct(
|
||
private readonly RequestStack $requestStack,
|
||
private readonly IntlExtension $intlExtension,
|
||
private readonly TranslatorInterface $translator,
|
||
private readonly CountryDataProvider $countries,
|
||
private readonly PickupDataProvider $pickups,
|
||
private readonly PickupResolver $pickupResolver,
|
||
private readonly SeasonAssignmentLabeler $seasonAssignmentLabeler,
|
||
private readonly string $environment,
|
||
) {
|
||
}
|
||
|
||
/**
|
||
* Minutes as hours and minutes, the way a timesheet reads them: 510 becomes "8:30".
|
||
*
|
||
* Hours are not padded and not capped at 24 - a month total is a perfectly ordinary
|
||
* "195:45". Null is a day the SaaS never reported, which is not the same as zero.
|
||
*/
|
||
public function formatDurationHoursMinutes(?int $minutes, string $empty = '–'): string
|
||
{
|
||
if (null === $minutes) {
|
||
return $empty;
|
||
}
|
||
|
||
$sign = $minutes < 0 ? '-' : '';
|
||
$minutes = abs($minutes);
|
||
|
||
return sprintf('%s%d:%02d', $sign, intdiv($minutes, 60), $minutes % 60);
|
||
}
|
||
|
||
public function seasonAssignmentLabel(SeasonAssignment $seasonAssignment): string
|
||
{
|
||
return $this->seasonAssignmentLabeler->label($seasonAssignment);
|
||
}
|
||
|
||
/**
|
||
* The house name for a stored hotel code. Only the code is stored, so every template that
|
||
* shows a house resolves it here rather than freezing a name into the record.
|
||
*/
|
||
public function houseLabel(?string $hotelCode): string
|
||
{
|
||
return $this->seasonAssignmentLabeler->houseLabel($hotelCode);
|
||
}
|
||
|
||
/**
|
||
* All-season is a flag on top of the status, not a status of its own - a teamer can be a
|
||
* Bestandsteamer:in and an all-season teamer at once. The general Teamübersicht is the only
|
||
* place that overlap is not otherwise visible, so the marker is appended there; the
|
||
* dedicated Saisonteamer list passes false, where repeating it on every row says nothing.
|
||
*/
|
||
public function teamerStatusLabel(Teamer $teamer, bool $includeAllSeason = true): string
|
||
{
|
||
$label = $this->baseTeamerStatusLabel($teamer);
|
||
|
||
if (true === $includeAllSeason && true === $teamer->isAllSeason()) {
|
||
return sprintf('%s · Saison', $label);
|
||
}
|
||
|
||
return $label;
|
||
}
|
||
|
||
private function baseTeamerStatusLabel(Teamer $teamer): string
|
||
{
|
||
$labelItems = [];
|
||
$user = $teamer->getUser();
|
||
|
||
if (null !== $user) {
|
||
if ($user->hasRole('ROLE_TEAM_ADMIN')) {
|
||
$labelItems[] = 'Admin';
|
||
}
|
||
|
||
if ($user->hasRole('ROLE_MANAGER')) {
|
||
$labelItems[] = 'Reisemanagement';
|
||
}
|
||
|
||
if ($user->hasRole('ROLE_HOUSE_MANAGER')) {
|
||
$labelItems[] = 'Hausleitung';
|
||
}
|
||
}
|
||
|
||
if (0 < count($labelItems)) {
|
||
return implode(', ', $labelItems);
|
||
}
|
||
|
||
if (Teamer::STATUS_NEW === $teamer->getStatus()) {
|
||
return 'Neuteamer:in';
|
||
}
|
||
|
||
return 'Bestandsteamer:in';
|
||
}
|
||
|
||
public function licenseLabel(string $type): string
|
||
{
|
||
return sprintf('Offizielle %slehrer:innenlizenz', ucfirst(strtolower($type)));
|
||
}
|
||
|
||
public function bpnCountryLabel(?string $token, string $property = 'name'): string
|
||
{
|
||
$country = $this->countries->get($token);
|
||
|
||
if (null === $country) {
|
||
return 'unbekannt';
|
||
}
|
||
|
||
return 'nationality' === $property ? $country->getNationality() : $country->getName();
|
||
}
|
||
|
||
public function bpnPickupLabel($id): ?string
|
||
{
|
||
if (null === $id) {
|
||
return 'keine Busbegleitung';
|
||
}
|
||
|
||
$pickup = $this->pickups->get((int) $id);
|
||
|
||
if (null === $pickup) {
|
||
return null;
|
||
}
|
||
|
||
return 'ab '.$pickup->getCity();
|
||
}
|
||
|
||
public function hasPickup(?int $pickupId, Teamer $teamer): bool
|
||
{
|
||
return $this->pickupResolver->hasPickup($pickupId, $teamer);
|
||
}
|
||
|
||
public function fileIconFilter(Environment $environment, string $mimeType, ?string $classes = 'w-4 h-4'): string
|
||
{
|
||
$icon = match ($mimeType) {
|
||
'application/pdf' => 'pdf',
|
||
'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'excel',
|
||
'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'word',
|
||
default => 'download',
|
||
};
|
||
|
||
return $this->renderIcon($environment, $icon, $classes);
|
||
}
|
||
|
||
public function formatBytes(int $bytes, ?int $precision = 2): string
|
||
{
|
||
$size = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||
$factor = floor((strlen($bytes) - 1) / 3);
|
||
|
||
return sprintf("%.{$precision}f", $bytes / (1024 ** $factor)).@$size[$factor];
|
||
}
|
||
|
||
public function formatMoney(int $amount): string
|
||
{
|
||
// Amounts are stored as integers so divide by 100 first
|
||
$amount = $amount / 100;
|
||
|
||
return $this->intlExtension->formatCurrency($amount, 'EUR');
|
||
}
|
||
|
||
public function formatRating(?int $rating): string
|
||
{
|
||
if (null === $rating) {
|
||
return '-';
|
||
}
|
||
|
||
// Ratings are stored as integers so divide by 100 first
|
||
$rating = $rating / 100;
|
||
|
||
return 'Ø '.$this->intlExtension->formatNumber($rating, ['fraction_digit' => 2]);
|
||
}
|
||
|
||
public function renderIcon(Environment $environment, string $icon, string $classes = 'w-5 h-5'): string
|
||
{
|
||
return $environment->render('_partials/_icon.html.twig', [
|
||
'icon' => $icon,
|
||
'class' => $classes,
|
||
]);
|
||
}
|
||
|
||
public function renderUploadStatusBadge(string $status): string
|
||
{
|
||
$label = $this->translator->trans('label.document.status.'.$status);
|
||
$class = 'upload-status-badge ';
|
||
$class .= match ($status) {
|
||
Upload::STATUS_NEW => 'upload-status-badge--new',
|
||
Upload::STATUS_REJECTED => 'upload-status-badge--error',
|
||
Upload::STATUS_PAID => 'upload-status-badge--paid',
|
||
Upload::STATUS_CHECKED => 'upload-status-badge--checked',
|
||
default => 'upload-status-badge--default',
|
||
};
|
||
|
||
return sprintf('<span class="%s">%s</span>', $class, $label);
|
||
}
|
||
|
||
public function nl2List(?string $content, string $class = 'list-disc pl-4'): string
|
||
{
|
||
if (null === $content) {
|
||
return '';
|
||
}
|
||
|
||
$items = explode(PHP_EOL, $content);
|
||
|
||
$list = '<ul class="'.$class.'">';
|
||
foreach ($items as $item) {
|
||
$list .= '<li>'.htmlspecialchars($item, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').'</li>';
|
||
}
|
||
$list .= '</ul>';
|
||
|
||
return $list;
|
||
}
|
||
|
||
public function isCurrentRoute(string $route): bool
|
||
{
|
||
$requestedRoute = $this->requestStack->getMainRequest()->attributes->get('_route');
|
||
|
||
return $requestedRoute === $route;
|
||
}
|
||
|
||
/**
|
||
* The attribute is set by MaintenanceModeListener, on the ip whitelist branch only.
|
||
*/
|
||
public function isMaintenanceMode(): bool
|
||
{
|
||
return true === $this->requestStack->getMainRequest()?->attributes->get('_maintenance_mode_allowed');
|
||
}
|
||
|
||
public function getEncodedReturnUrl(): string
|
||
{
|
||
$masterRequest = $this->requestStack->getMainRequest();
|
||
|
||
if (null === $masterRequest) {
|
||
return '';
|
||
}
|
||
|
||
return rawurlencode($masterRequest->getRequestUri());
|
||
}
|
||
|
||
/**
|
||
* Passes the return url of the current page on to the next one.
|
||
*
|
||
* Used by links that lead deeper into a detail view or trigger an action on it:
|
||
* they have to keep pointing back at where the user originally came from instead
|
||
* of at the page they sit on. Null when there is nothing to pass on, so that the
|
||
* url generator drops the parameter rather than emitting an empty one.
|
||
*/
|
||
public function getForwardedReturnUrl(): ?string
|
||
{
|
||
$returnUrl = $this->requestStack->getMainRequest()?->query->get('r');
|
||
|
||
return is_string($returnUrl) && '' !== $returnUrl ? $returnUrl : null;
|
||
}
|
||
|
||
public function renderQaAttribute(string $label, ?string $value = null): string
|
||
{
|
||
if ('test' !== $this->environment) {
|
||
return '';
|
||
}
|
||
|
||
if (null === $value) {
|
||
return sprintf(' data-qa-%s', strtolower($label));
|
||
}
|
||
|
||
return sprintf(' data-qa-%s="%s"', strtolower($label), $value);
|
||
}
|
||
|
||
public function dateDiffForHumans(\DateTimeInterface $dateTime, ?\DateTimeInterface $other = null): string
|
||
{
|
||
$oldLocale = CarbonImmutable::getLocale();
|
||
$locale = $this->requestStack->getMainRequest()->getLocale();
|
||
CarbonImmutable::setLocale($locale);
|
||
|
||
$result = CarbonImmutable::instance($dateTime)->diffForHumans($other);
|
||
|
||
CarbonImmutable::setLocale($oldLocale);
|
||
|
||
return $result;
|
||
}
|
||
|
||
public function getFilenameWithFolder(Upload $upload): string
|
||
{
|
||
$folder = substr($upload->getFilename(), 0, 1);
|
||
|
||
return sprintf('%s/%s', $folder, $upload->getFilename());
|
||
}
|
||
}
|