Compare commits

...

16 Commits
dev ... 2.51.0

Author SHA1 Message Date
Kevin Papst
a0601c8cb2 check customer permissions on invoice api access (#5849) 2026-03-01 16:53:49 +01:00
Kevin Papst
5b320bf2ea Release 2.51 (#5847)
* bump packages
2026-03-01 16:21:07 +01:00
Kevin Papst
15110f78d8 fix sticky calendar popup (#5846) 2026-02-28 19:25:06 +01:00
Kevin Papst
4154de6bd1 Release 2.50 (#5835)
* replace p-0 class with fullsize embed option
* bump parsedown package
* remove support for file:// urls
* fix missing macro in export print template
* fix weekly hours with breaks
2026-02-25 21:07:40 +01:00
Weblate (bot)
8094fcf5b5 Translated using Weblate (#5831)
Co-authored-by: Francisco Serrador <fserrador@gmail.com>
Co-authored-by: Marat Ismailov <klippygames@gmail.com>
Co-authored-by: Sean Young <assanges@users.noreply.hosted.weblate.org>
2026-02-25 21:06:48 +01:00
Kevin Papst
77afa207e0 Allow to customize statistic queries (#5827)
* use TimesheetStatisticsQUery for all repository calls
* send event to customize statistics query
2026-02-24 13:27:48 +01:00
Kevin Papst
d9ef6dfcad Update database requirements in README (#5825) 2026-02-15 21:42:25 +01:00
Kevin Papst
ff6918fcab Release 2.49 (#5820)
* bump packages
* add column summarization per customer (#5808)
* clarify database requirements
* added helper methods to fetch original expected time

Co-authored-by: GregorB54321 <34287148+GregorB54321@users.noreply.github.com>
2026-02-15 21:31:06 +01:00
Weblate (bot)
f376b5c8a1 Translated using Weblate (#5804)
Co-authored-by: AlaxLima <thanhkhoidangngoc@gmail.com>
Co-authored-by: Arif Budiman <arifpedia@gmail.com>
Co-authored-by: Artem <artemkozhin80@gmail.com>
Co-authored-by: Eleni Diamantopoulou <elenidiama00@gmail.com>
Co-authored-by: Florent Berthelot <florentius.b@gmail.com>
Co-authored-by: Kehribar <103407696+dpentx@users.noreply.github.com>
Co-authored-by: Kevin Papst <kevin@kevinpapst.de>
Co-authored-by: Lasse Edsvik <lasse@lasseedsvik.se>
Co-authored-by: Levente Déri <derilevi@gmail.com>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Paul <snide-slum-partake@duck.com>
Co-authored-by: Posemartonis <weblate.drainage895@passmail.net>
Co-authored-by: Romhányi-Kakucska Viktor <viktor@romhanyi.dev>
Co-authored-by: irooniline <mart.styff@protonmail.com>
Co-authored-by: leonidovmob <leonidovmob@gmail.com>
2026-02-15 21:28:53 +01:00
Kevin Papst
b00fbd2516 Delete .opensourcefinder-verify 2026-02-14 18:56:38 +01:00
Kevin Papst
d97abc3f57 Add .opensourcefinder-verify file with claims (#5824) 2026-02-14 08:19:51 +01:00
Kevin Papst
3f184e42c8 missing translations 2026-01-31 10:28:30 +01:00
Kevin Papst
4a31411d69 Release 2.48 (#5789) 2026-01-30 16:44:26 +01:00
Weblate (bot)
3925eacf9f Translated using Weblate (#5748)
Co-authored-by: AlaxLima <thanhkhoidangngoc@gmail.com>
Co-authored-by: Amir <amearb@duck.com>
Co-authored-by: Fabio Gomes de lima <fabiogomesdelima598@gmail.com>
Co-authored-by: Heeheon Ryu <heeheon.ryu001@gmail.com>
Co-authored-by: Kevin Papst <kevin@kevinpapst.de>
Co-authored-by: Kolappan N <kolappan@users.noreply.hosted.weblate.org>
Co-authored-by: Lasse Edsvik <lasse@lasseedsvik.se>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Mauro F. T. <maurofroeltani@gmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Co-authored-by: Pose marto <weblate.drainage895@passmail.net>
Co-authored-by: Serhii Horichenko <serhii@horichenko.com>
Co-authored-by: Yaron Shahrabani <sh.yaron@gmail.com>
Co-authored-by: avv-dev <vildan.abdullin@gmail.com>
Co-authored-by: m45ked <m45ked@users.noreply.hosted.weblate.org>
2026-01-30 13:14:21 +01:00
Kevin Papst
394f377890 Update header (#5799) 2026-01-28 20:10:33 +01:00
Milo Ivir
e8b0dc4969 Add translation links to README (#5798)
Co-authored-by: Kevin Papst <kevinpapst@users.noreply.github.com>
2026-01-28 19:58:12 +01:00
207 changed files with 4052 additions and 1886 deletions

View File

@@ -73,6 +73,7 @@ jobs:
target: prod
platforms: linux/amd64,linux/arm64
tags: |
kimai/kimai2:stable
kimai/kimai2:apache
kimai/kimai2:apache-${{ env.kimai_version }}
push: true

View File

@@ -6,11 +6,9 @@
<a href="https://github.com/kimai/kimai/actions"><img alt="CI Status" src="https://github.com/kimai/kimai/actions/workflows/testing.yaml/badge.svg"></a>
<a href="https://codecov.io/gh/kimai/kimai"><img alt="Code Coverage" src="https://codecov.io/gh/kimai/kimai/branch/main/graph/badge.svg"></a>
<a href="https://packagist.org/packages/kimai/kimai"><img alt="Latest stable version" src="https://poser.pugx.org/kimai/kimai/v/stable"></a>
<a href="https://www.gnu.org/licenses/agpl-3.0.en.html"><img alt="License" src="https://poser.pugx.org/kimai/kimai/license"></a>
<a href="https://phpc.social/@kimai" rel="me"><img alt="Mastodon" src="https://img.shields.io/badge/toot-%40kimai-8c8dff"></a>
</p>
<h1 align="center">Kimai - time-tracker</h1>
<h1 align="center">Kimai<br>#1 Open-Source Time-Tracker</h1>
Kimai is a professional grade time-tracking application, free and open-source.
It handles use-cases of freelancers as well as companies with dozens or hundreds of users.
@@ -30,7 +28,7 @@ and so much more.
### Requirements
- PHP 8.1.3 minimum with support for 8.2, 8.3, 8.4, 8.5
- MariaDB or MySQL
- MariaDB / MySQL: oldest maintained LTS release (MariaDB >= [10.6](https://endoflife.date/mariadb) or MySQL >= [8.4](https://endoflife.date/mysql)) or newer
- A webserver and subdomain (subdirectory is not supported)
- PHP extensions: `gd`, `intl`, `json`, `mbstring`, `pdo`, `tokenizer`, `xml`, `xsl`, `zip`
@@ -71,6 +69,7 @@ The best way to start is to [open a new issue](https://github.com/kimai/kimai/is
In case you want to contribute, but you wouldn't know how, here are some suggestions:
- Spread the word: Please [write a testimonial for our Wall of love](https://love.kimai.org), vote for Kimai on any software platform, you can toot or tweet about it, share it on LinkedIn, Reddit and any other social media platform!
- [Translate Kimai into your language](https://hosted.weblate.org/engage/kimai/), or help to improve the existing translations, many languages look for a contributor
- Answer questions: You know the answer to another user's problem? Share your knowledge.
- Something can be done better? An essential feature is missing? Create a feature request.
- Report bugs makes Kimai better for everyone.
@@ -79,6 +78,12 @@ In case you want to contribute, but you wouldn't know how, here are some suggest
There is one simple rule in our "Code of conduct": Don't be an ass!
## Follow Kimai
- Mastodon: [@kimai](https://phpc.social/@kimai)
- Youtube: [@kimai_org](https://www.youtube.com/@kimai_org)
- LinkedIn: [@kimai-org](https://www.linkedin.com/company/kimai-org/)
### Credits
Kimai is based on modern technologies and frameworks such as [PHP](https://www.php.net/),

View File

@@ -195,7 +195,7 @@ export default class KimaiCalendar {
if (!this.isKimaiSource(unmountInfo.event)) {
return;
}
const popover = Popover.getInstance(unmountInfo.element);
const popover = Popover.getInstance(unmountInfo.el);
if (popover !== null) {
popover.dispose();
}

View File

@@ -1,3 +1,4 @@
@import "variables";
@import "layout";
@import "error-page";
@import "print";

View File

@@ -0,0 +1,33 @@
:root {
--kimai-public-holiday: var(--tblr-lime);
--kimai-holiday: var(--tblr-green);
--kimai-sickness: var(--tblr-yellow);
--kimai-time-off: var(--tblr-blue);
--kimai-other: var(--tblr-purple);
--kimai-public-holiday-bg: var(--tblr-lime-lt);
--kimai-holiday-bg: var(--tblr-green-lt);
--kimai-sickness-bg: var(--tblr-yellow-lt);
--kimai-time-off-bg: var(--tblr-blue-lt);
--kimai-other-bg: var(--tblr-purple-lt);
--kimai-unexpected-bg: var(--tblr-pink-lt);
--kimai-missing-bg: var(--tblr-pink-lt);
--kimai-weekend-bg: var(--tblr-bg-surface-tertiary);
}
.public-holiday { color: var(--kimai-public-holiday); }
.holiday { color: var(--kimai-holiday); }
.sickness { color: var(--kimai-sickness); }
.time-off { color: var(--kimai-time-off); }
.other { color: var(--kimai-other); }
.bg-public-holiday{ background-color: var(--kimai-public-holiday-bg); --tblr-table-bg: var(--kimai-public-holiday-bg); i.fas{ color: var(--kimai-public-holiday); } };
.bg-holiday { background-color: var(--kimai-holiday-bg); --tblr-table-bg: var(--kimai-holiday-bg); i.fas{ color: var(--kimai-holiday); } };
.bg-sickness { background-color: var(--kimai-sickness-bg); --tblr-table-bg: var(--kimai-sickness-bg); i.fas{ color: var(--kimai-sickness); } };
.bg-time-off { background-color: var(--kimai-time-off-bg); --tblr-table-bg: var(--kimai-time-off-bg); i.fas{ color: var(--kimai-time-off); } };
.bg-other { background-color: var(--kimai-other-bg); --tblr-table-bg: var(--kimai-other-bg); i.fas{ color: var(--kimai-other); } };
.bg-unexpected { background-color: var(--kimai-unexpected-bg); --tblr-table-bg: var(--kimai-unexpected-bg); };
.bg-missing { background-color: var(--kimai-missing-bg); --tblr-table-bg: var(--kimai-missing-bg); };
.bg-weekend { background-color: var(--kimai-weekend-bg); --tblr-table-bg: var(--kimai-weekend-bg); };

897
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -75,6 +75,7 @@ tabler:
fax: fas fa-fax
filter: fas fa-filter
help: far fa-question-circle
holiday: fas fa-umbrella-beach
home: fas fa-home
info: fas fa-info-circle
import: fas fa-file-import
@@ -95,6 +96,7 @@ tabler:
ods: fas fa-table
off: fas fa-toggle-off
on: fas fa-toggle-on
other: fas fa-file-alt
password: fas fa-key
pause: fas fa-pause
pause-small: far fa-pause-circle
@@ -108,6 +110,7 @@ tabler:
profile: fas fa-user-edit
profile-stats: far fa-chart-bar
project: fas fa-briefcase
public-holiday: fas fa-calendar-day
repeat: fas fa-repeat
reporting: far fa-chart-bar
report: far fa-chart-bar
@@ -119,6 +122,7 @@ tabler:
save: far fa-save
search: fas fa-search
settings: fas fa-cog
sickness: fas fa-prescription-bottle-medical
shop: fas fa-shopping-cart
spinner: fas fa-spinner
start: fas fa-play
@@ -130,6 +134,7 @@ tabler:
team: fas fa-users
timesheet: fas fa-clock
timesheet-team: fas fa-user-clock
time-off: fas fa-couch
trash: far fa-trash-alt
unlocked: fas fa-unlock-alt
upload: fas fa-upload

View File

@@ -27,6 +27,7 @@ parameters:
numericOperandsInArithmeticOperators: true
switchConditionsMatchingType: true
noVariableVariables: false
reportNonIntStringArrayKey: false
paths:
- src
tmpDir: %rootDir%/../../../var/cache/phpstan
@@ -3799,11 +3800,6 @@ parameters:
count: 1
path: src/Utils/ParsedownExtension.php
-
message: "#^Property App\\\\Utils\\\\ParsedownExtension\\:\\:\\$safeLinksWhitelist has no type specified\\.$#"
count: 1
path: src/Utils/ParsedownExtension.php
-
message: "#^Parameter \\#1 \\$profile of method App\\\\Utils\\\\ProfileManager\\:\\:getProfile\\(\\) expects string, mixed given\\.$#"
count: 1

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -6,7 +6,7 @@
"/build/app.41748d55.js"
],
"css": [
"/build/app.e64b4d08.css"
"/build/app.6bfb9c66.css"
]
},
"app-rtl": {
@@ -15,7 +15,7 @@
"/build/app-rtl.15853b82.js"
],
"css": [
"/build/app-rtl.6b5a0ee9.css"
"/build/app-rtl.16271884.css"
]
},
"export-pdf": {
@@ -54,7 +54,7 @@
"calendar": {
"js": [
"/build/runtime.684e9f6d.js",
"/build/calendar.36a92145.js"
"/build/calendar.7e3186b2.js"
],
"css": [
"/build/calendar.d757753e.css"
@@ -82,9 +82,9 @@
"integrity": {
"/build/runtime.684e9f6d.js": "sha384-suKiEX2de4fdNqQzdYbUd6osp4AepD9FiMXl+1QdvgMW9dcQqUWQNQasf3KWzwLr",
"/build/app.41748d55.js": "sha384-iVPHnOXYjVDuq8567h8I3U2gl5SCV3mNMFNQCJdgMiNuFMZ4KO7PXOLanjgcGzTt",
"/build/app.e64b4d08.css": "sha384-Rapl6RNn58g7oayglp7MWOER7tDNPpChx6RKOOWe0mg6uFgaJjy3M51ZSkxXm+Hg",
"/build/app.6bfb9c66.css": "sha384-38+zSolYBrZdSwNcrDi5qCaQGCQlBJRKwtsJV+pnrf8rCCpf6z/M7fEpV4tS79Sz",
"/build/app-rtl.15853b82.js": "sha384-UnKKgLMu9FnRT+CFE0no/+UiUks012bYriQdUWa6f02mo6Lswl947mPybjvKL503",
"/build/app-rtl.6b5a0ee9.css": "sha384-eW8BEaA6S1jAFPB8lUCBTvcm4wZZQ3SHbiHVZlxe814TXGT9L4/4BN8CuuGehZ6o",
"/build/app-rtl.16271884.css": "sha384-P1x4vYfjfM4JL55NQ3sfLiy+BSy8cM4bNcshj9M62nMJ4JD2xAWUioNv6VrmaK7U",
"/build/export-pdf.395749ab.js": "sha384-3Hjvmu4FC/0dhHnR8kyRBU7k2xMNy1lxBpGgOkrw8PxXnwyQDM8/5bQmkJbjVT1+",
"/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",
"/build/invoice.42b319e4.js": "sha384-xxK7sCe/ZhTjMPFPeX1xvILURxNRZz2hJHZxAGVaw9zE6TC++2/6y2eKqg8cz852",
@@ -92,7 +92,7 @@
"/build/invoice-pdf.26d98626.js": "sha384-gwNzQiU1y6qU/M9DPGiNW0MVZkLctEHk37sCES2X9ov+zugEaDABdkMjKBYOC9lz",
"/build/invoice-pdf.2b749265.css": "sha384-DXXgkz2WWnrWnfBnXX5fmfPQSPb98upMnWxYKwTGYS04EhrPIWfDCutB2unIrWh7",
"/build/chart.56f16a68.js": "sha384-SWnYjAbZ8OWEvTP+IZfGuBMWJIcH9OZWLYQ4p38KMPmqfufsc2zhzqWkALO3mCBO",
"/build/calendar.36a92145.js": "sha384-ZPLxxbep6OnPeUiIeBXRE1iscZFgbNuTpn3rx2Jpf1KR2l6sxIuNsieDUskbmQ3m",
"/build/calendar.7e3186b2.js": "sha384-umm2cw67dX5I6+PJ+PpNuHwWgKDJc+r2MmpN8az3dtAqUGa3N9ZGHD0SHc7xfFVB",
"/build/calendar.d757753e.css": "sha384-cTmQMgHYjd2gfObFWmEUph7qQLCyXaIkneSf+bQ2mqVmZwqOB+pJOm/UYTyTjALJ",
"/build/dashboard.9708ae5e.js": "sha384-QN7XIQuxFZu76sHVrgdpZL81+Q2VTwcgF3aI2CnpwYZiMoPbzrcTGfPL3RDy696t",
"/build/dashboard.b7129fa1.css": "sha384-2nn5hLA+3YedgHYBpge62S8Losj8aoPwK9Zk9EvN1xEYatvOUQ7H3rIR2UUJAGOS",

View File

@@ -1,7 +1,7 @@
{
"build/app.css": "/build/app.e64b4d08.css",
"build/app.css": "/build/app.6bfb9c66.css",
"build/app.js": "/build/app.41748d55.js",
"build/app-rtl.css": "/build/app-rtl.6b5a0ee9.css",
"build/app-rtl.css": "/build/app-rtl.16271884.css",
"build/app-rtl.js": "/build/app-rtl.15853b82.js",
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
"build/export-pdf.js": "/build/export-pdf.395749ab.js",
@@ -11,7 +11,7 @@
"build/invoice-pdf.js": "/build/invoice-pdf.26d98626.js",
"build/chart.js": "/build/chart.56f16a68.js",
"build/calendar.css": "/build/calendar.d757753e.css",
"build/calendar.js": "/build/calendar.36a92145.js",
"build/calendar.js": "/build/calendar.7e3186b2.js",
"build/dashboard.css": "/build/dashboard.b7129fa1.css",
"build/dashboard.js": "/build/dashboard.9708ae5e.js",
"build/highlight.css": "/build/highlight.98bf3927.css",

View File

@@ -18,6 +18,7 @@ use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use OpenApi\Attributes as OA;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -76,6 +77,9 @@ final class InvoiceController extends BaseApiController
/** @var array<int> $customers */
$customers = $paramFetcher->get('customers');
foreach ($customerRepository->findByIds(array_unique($customers)) as $customer) {
if (!$this->isGranted('access', $customer)) {
throw $this->createAccessDeniedException('Cannot access Customer: ' . $customer->getId());
}
$query->addCustomer($customer);
}
@@ -90,6 +94,7 @@ final class InvoiceController extends BaseApiController
* Fetch invoice
*/
#[IsGranted('view_invoice')]
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')]
#[OA\Response(response: 200, description: 'Returns one invoice', content: new OA\JsonContent(ref: '#/components/schemas/Invoice'))]
#[Route(methods: ['GET'], path: '/{id}', name: 'get_invoice', requirements: ['id' => '\d+'])]
public function getAction(Invoice $invoice): Response

View File

@@ -17,11 +17,11 @@ final class Constants
/**
* The current release version
*/
public const VERSION = '2.47.0';
public const VERSION = '2.51.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 24700;
public const VERSION_ID = 25100;
/**
* The software name
*/

View File

@@ -108,10 +108,30 @@ final class DoctorController extends AbstractController
'logLines' => $logLines,
'logSize' => $this->getLogSize(),
'composer' => $this->getComposerPackages(),
'release' => $latestRelease
'release' => $latestRelease,
'opcache' => $this->getOpcacheConfiguration()
]);
}
/**
* @return array{enabled: bool, status: false|array<mixed>}
*/
private function getOpcacheConfiguration(): array
{
$status = \function_exists('opcache_get_status') ? opcache_get_status() : false;
$enabled = \is_array($status) && $status['opcache_enabled'];
if ($enabled && \array_key_exists('scripts', $status)) {
unset($status['scripts']);
}
return [
'enabled' => $enabled,
'status' => $status,
];
}
/**
* @return array<string, string>
*/
@@ -264,7 +284,12 @@ final class DoctorController extends AbstractController
'sys_temp_dir',
'date.timezone',
'session.gc_maxlifetime',
'disable_functions'
'disable_functions',
'opcache.enable',
'opcache.memory_consumption',
'opcache.interned_strings_buffer',
'opcache.max_accelerated_files',
'opcache.validate_timestamps',
];
$settings = [];

View File

@@ -16,6 +16,7 @@ use App\Model\DateStatisticInterface;
use App\Model\Statistic\StatisticDate;
use App\Repository\ActivityRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\TimesheetStatisticQuery;
use App\Timesheet\TimesheetStatisticService;
use DateTimeInterface;
@@ -37,7 +38,7 @@ abstract class AbstractUserReportController extends AbstractController
protected function getStatisticDataRaw(DateTimeInterface $begin, DateTimeInterface $end, User $user): array
{
return $this->statisticService->getDailyStatisticsGrouped($begin, $end, [$user]);
return $this->statisticService->getDailyStatisticsGrouped(new TimesheetStatisticQuery($begin, $end, [$user]));
}
protected function createStatisticModel(DateTimeInterface $begin, DateTimeInterface $end, User $user): DateStatisticInterface

View File

@@ -17,6 +17,7 @@ use App\Model\DateStatisticInterface;
use App\Model\MonthlyStatistic;
use App\Reporting\YearByUser\YearByUser;
use App\Reporting\YearByUser\YearByUserForm;
use App\Repository\Query\TimesheetStatisticQuery;
use DateTime;
use DateTimeInterface;
use PhpOffice\PhpSpreadsheet\Reader\Html;
@@ -125,7 +126,7 @@ final class UserYearController extends AbstractUserReportController
protected function getStatisticDataRaw(DateTimeInterface $begin, DateTimeInterface $end, User $user): array
{
return $this->statisticService->getMonthlyStatisticsGrouped($begin, $end, [$user]);
return $this->statisticService->getMonthlyStatisticsGrouped(new TimesheetStatisticQuery($begin, $end, [$user]));
}
protected function createStatisticModel(DateTimeInterface $begin, DateTimeInterface $end, User $user): DateStatisticInterface

View File

@@ -256,6 +256,18 @@ class ExportTemplate
return \is_string($font) ? $font : null;
}
public function isAvailableForAll(): bool
{
$isAllowed = $this->getOption('user_access', false);
return \is_bool($isAllowed) ? $isAllowed : false;
}
public function setAvailableForAll(bool $userAccess): void
{
$this->setOption('user_access', $userAccess);
}
public function __toString(): string
{
return $this->title ?? 'New';

View File

@@ -36,6 +36,7 @@ class WorkingTime
#[ORM\Column(name: 'expected', type: Types::INTEGER, nullable: false)]
#[Assert\NotNull]
private int $expectedTime = 0;
private ?int $originalExpectedTime = null;
#[ORM\Column(name: 'actual', type: Types::INTEGER, nullable: false)]
#[Assert\NotNull]
private int $actualTime = 0;
@@ -75,6 +76,39 @@ class WorkingTime
public function setExpectedTime(int $expectedTime): void
{
$this->expectedTime = $expectedTime;
$this->storeOriginalExpectedTime();
}
public function storeOriginalExpectedTime(): void
{
if ($this->originalExpectedTime === null) {
$this->originalExpectedTime = $this->expectedTime;
}
}
public function getOriginalExpectedTime(): int
{
$this->storeOriginalExpectedTime();
return $this->originalExpectedTime ?? 0;
}
public function halveExpectedTime(): int
{
$this->storeOriginalExpectedTime();
$reduceBy = ($this->getOriginalExpectedTime() / 2);
$this->expectedTime = $this->expectedTime - $reduceBy;
return $reduceBy;
}
public function emptyExpectedTime(): void
{
$this->storeOriginalExpectedTime();
$this->expectedTime = 0;
}
public function getActualTime(): int

View File

@@ -0,0 +1,25 @@
<?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\Event;
use Doctrine\ORM\QueryBuilder;
use Symfony\Contracts\EventDispatcher\Event;
final class TimesheetStatisticsQueryEvent extends Event
{
public function __construct(private readonly QueryBuilder $queryBuilder)
{
}
public function getQueryBuilder(): QueryBuilder
{
return $this->queryBuilder;
}
}

View File

@@ -34,7 +34,7 @@ final class TimesheetsSubscriber extends AbstractActionsSubscriber
if ($this->isGranted('export_own_timesheet')) {
foreach ($this->serviceExport->getTimesheetExporter() as $exporter) {
$event->addActionToSubmenu('export', $exporter->getId(), ['url' => $this->path('timesheet_export', ['exporter' => $exporter->getId()]), 'class' => 'toolbar-action', 'title' => 'button.' . $exporter->getId(), 'translation_domain' => 'messages']);
$event->addActionToSubmenu('export', $exporter->getId(), ['url' => $this->path('timesheet_export', ['exporter' => $exporter->getId()]), 'class' => 'toolbar-action', 'title' => $exporter->getTitle()]);
}
}
}

View File

@@ -35,7 +35,7 @@ final class TimesheetsTeamSubscriber extends AbstractActionsSubscriber
if ($this->isGranted('export_other_timesheet')) {
foreach ($this->serviceExport->getTimesheetExporter() as $exporter) {
$event->addActionToSubmenu('export', $exporter->getId(), ['url' => $this->path('admin_timesheet_export', ['exporter' => $exporter->getId()]), 'class' => 'toolbar-action', 'title' => 'button.' . $exporter->getId(), 'translation_domain' => 'messages']);
$event->addActionToSubmenu('export', $exporter->getId(), ['url' => $this->path('admin_timesheet_export', ['exporter' => $exporter->getId()]), 'class' => 'toolbar-action', 'title' => $exporter->getTitle()]);
}
}
}

View File

@@ -35,7 +35,7 @@ final class CsvRendererFactory
public function createDefault(): CsvRenderer
{
$template = new DefaultTemplate($this->eventDispatcher, 'csv');
$template = new DefaultTemplate($this->eventDispatcher, 'csv', 'en', 'csv');
return new CsvRenderer($this->converter, $this->translator, $template);
}

View File

@@ -25,7 +25,7 @@ final class HtmlRendererFactory
) {
}
public function create(string $id, string $template): HtmlRenderer
public function create(string $id, string $template, string $title = 'print'): HtmlRenderer
{
return new HtmlRenderer(
$this->twig,
@@ -33,7 +33,7 @@ final class HtmlRendererFactory
$this->projectStatisticService,
$this->activityStatisticService,
$id,
'print',
$title,
$template
);
}

View File

@@ -27,7 +27,7 @@ final class XlsxRendererFactory
public function createDefault(): XlsxRenderer
{
$template = new DefaultTemplate($this->eventDispatcher, 'xlsx');
$template = new DefaultTemplate($this->eventDispatcher, 'xlsx', 'en', 'xlsx');
return new XlsxRenderer($this->converter, $this->translator, $template);
}

View File

@@ -10,6 +10,7 @@
namespace App\Export;
use App\Entity\ExportableItem;
use App\Entity\ExportTemplate;
use App\Event\ExportItemsQueryEvent;
use App\Export\Renderer\CsvRendererFactory;
use App\Export\Renderer\HtmlRendererFactory;
@@ -74,6 +75,15 @@ final class ServiceExport
$this->renderer[] = $renderer;
}
private function filenameToTitle(string $title): string
{
if (str_contains($title, '.')) {
$title = explode('.', $title)[0];
}
return str_replace(['-', '_'], ' ', $title);
}
/**
* @return ExportRendererInterface[]
*/
@@ -82,32 +92,15 @@ final class ServiceExport
$renderer = [
$this->csvRendererFactory->createDefault(),
$this->xlsxRendererFactory->createDefault(),
$this->pdfRendererFactory->create('pdf', 'export/pdf-layout.html.twig', 'default'),
$this->htmlRendererFactory->create('html', 'export/print.html.twig'),
$this->pdfRendererFactory->create('pdf', 'export/pdf-layout.html.twig', 'pdf'),
$this->htmlRendererFactory->create('print', 'export/print.html.twig'),
];
foreach ($this->exportTemplateRepository->findAll() as $template) {
$tpl = new Template((string) $template->getId(), $template->getTitle()); // @phpstan-ignore argument.type
$tpl->setColumns($template->getColumns());
$tpl->setLocale($template->getLanguage());
$tpl->setOptions($template->getOptions());
switch ($template->getRenderer()) {
case 'csv':
$renderer[] = $this->csvRendererFactory->create($tpl);
break;
case 'xlsx':
$renderer[] = $this->xlsxRendererFactory->create($tpl);
break;
case 'pdf':
$renderer[] = $this->pdfRendererFactory->createFromTemplate($tpl);
break;
default:
$this->logger->error('Unknown export template type: ' . $template->getRenderer());
break;
try {
$renderer[] = $this->createTemplateFromExportTemplate($template);
} catch (\Exception $exception) {
$this->logger->error('Unknown export template type: ' . $template->getRenderer());
}
}
@@ -124,7 +117,7 @@ final class ServiceExport
continue;
}
$renderer[] = $this->htmlRendererFactory->create($tplName, '@export/' . $tplName);
$renderer[] = $this->htmlRendererFactory->create($tplName, '@export/' . $tplName, $this->filenameToTitle($tplName));
}
}
@@ -136,7 +129,7 @@ final class ServiceExport
continue;
}
$renderer[] = $this->pdfRendererFactory->create($tplName, '@export/' . $tplName);
$renderer[] = $this->pdfRendererFactory->create($tplName, '@export/' . $tplName, $this->filenameToTitle($tplName));
}
}
}
@@ -144,6 +137,28 @@ final class ServiceExport
return array_merge($this->renderer, $renderer);
}
private function createTemplateFromExportTemplate(ExportTemplate $template): ExportRendererInterface
{
$tpl = new Template((string) $template->getId(), $template->getTitle()); // @phpstan-ignore argument.type
$tpl->setColumns($template->getColumns());
$tpl->setLocale($template->getLanguage());
$tpl->setOptions($template->getOptions());
switch ($template->getRenderer()) {
case 'csv':
return $this->csvRendererFactory->create($tpl);
case 'xlsx':
return $this->xlsxRendererFactory->create($tpl);
case 'pdf':
return $this->pdfRendererFactory->createFromTemplate($tpl);
default:
throw new \Exception('Unknown export template type: ' . $template->getRenderer());
}
}
public function getRendererById(string $id): ?ExportRendererInterface
{
foreach ($this->getRenderer() as $renderer) {
@@ -165,6 +180,7 @@ final class ServiceExport
*/
public function getTimesheetExporter(): array
{
// TODO 3.0 cache the result, as this is one extra database query on the timesheet pages
$exporter = [
$this->pdfRendererFactory->create('pdf', '@export/timesheet.pdf.twig'),
$this->xlsxRendererFactory->createDefault(),
@@ -172,6 +188,17 @@ final class ServiceExport
$this->htmlRendererFactory->create('print', 'timesheet/export.html.twig'),
];
foreach ($this->exportTemplateRepository->findAll() as $template) {
if (!$template->isAvailableForAll()) {
continue;
}
try {
$exporter[] = $this->createTemplateFromExportTemplate($template);
} catch (\Exception $exception) {
$this->logger->error('Unknown export template type: ' . $template->getRenderer());
}
}
return array_merge($this->timesheetExporter, $exporter);
}

View File

@@ -15,6 +15,7 @@ use App\Form\Type\ExportRendererType;
use App\Form\Type\ExportSummaryColumnsType;
use App\Form\Type\LanguageType;
use App\Form\Type\PdfFontType;
use App\Form\Type\YesNoType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
@@ -22,6 +23,9 @@ use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
/**
* TODO rename with 3.0 to ExportTemplateForm
*/
class ExportTemplateSpreadsheetForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
@@ -77,6 +81,11 @@ class ExportTemplateSpreadsheetForm extends AbstractType
'row_attr' => ['data-type' => 'pdf'],
'required' => false,
]);
$builder->add('availableForAll', YesNoType::class, [
'label' => 'user_access_all',
'required' => false,
]);
}
public function configureOptions(OptionsResolver $resolver): void

View File

@@ -19,9 +19,9 @@ final class ExportRendererType extends AbstractType
{
$resolver->setDefaults([
'choices' => [
'button.csv' => 'csv',
'button.xlsx' => 'xlsx',
'button.pdf' => 'pdf'
'csv' => 'csv',
'xlsx' => 'xlsx',
'pdf' => 'pdf'
],
]);
}

View File

@@ -95,6 +95,7 @@ final class QuickEntryTimesheetType extends AbstractType
$duration = $data->getDuration(false);
try {
if (null !== $duration) {
$duration += $data->getBreak();
$end = clone $data->getBegin();
$end->modify('+ ' . abs($duration) . ' seconds');
$data->setEnd($end);

View File

@@ -33,8 +33,9 @@ final class TeamType extends AbstractType
return $team->getName();
},
'documentation' => [
'type' => 'integer',
'description' => 'Team ID',
'type' => 'array',
'items' => ['type' => 'integer', 'description' => 'Team IDs'],
'description' => 'Array of Team IDs',
],
]);

View File

@@ -10,15 +10,19 @@
namespace App\Timesheet;
use App\Entity\User;
use App\Event\TimesheetStatisticsQueryEvent;
use App\Model\DailyStatistic;
use App\Model\MonthlyStatistic;
use App\Repository\Query\TimesheetStatisticQuery;
use App\Repository\TimesheetRepository;
use DateTimeInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
final class TimesheetStatisticService
{
public function __construct(private readonly TimesheetRepository $repository)
public function __construct(
private readonly TimesheetRepository $repository,
private readonly EventDispatcherInterface $eventDispatcher
)
{
}
@@ -72,6 +76,8 @@ final class TimesheetStatisticService
;
}
$this->eventDispatcher->dispatch(new TimesheetStatisticsQueryEvent($qb));
$results = $qb->getQuery()->getResult();
foreach ($results as $row) {
@@ -96,13 +102,14 @@ final class TimesheetStatisticService
/**
* @internal only for core development
* @param DateTimeInterface $begin
* @param DateTimeInterface $end
* @param User[] $users
* @return array
*/
public function getDailyStatisticsGrouped(DateTimeInterface $begin, DateTimeInterface $end, array $users): array
public function getDailyStatisticsGrouped(TimesheetStatisticQuery $query): array
{
$begin = $query->getBegin();
$end = $query->getEnd();
$users = $query->getUsers();
$stats = [];
$usersById = [];
@@ -137,6 +144,8 @@ final class TimesheetStatisticService
->addGroupBy('billable')
;
$this->eventDispatcher->dispatch(new TimesheetStatisticsQueryEvent($qb));
$results = $qb->getQuery()->getResult();
foreach ($results as $row) {
@@ -173,11 +182,14 @@ final class TimesheetStatisticService
/**
* @internal only for core development
* @param User[] $users
* @return array
*/
public function getMonthlyStatisticsGrouped(DateTimeInterface $begin, DateTimeInterface $end, array $users): array
public function getMonthlyStatisticsGrouped(TimesheetStatisticQuery $query): array
{
$begin = $query->getBegin();
$end = $query->getEnd();
$users = $query->getUsers();
$stats = [];
$usersById = [];
@@ -214,6 +226,8 @@ final class TimesheetStatisticService
->addGroupBy('billable')
;
$this->eventDispatcher->dispatch(new TimesheetStatisticsQueryEvent($qb));
$results = $qb->getQuery()->getResult();
foreach ($results as $row) {
@@ -311,6 +325,8 @@ final class TimesheetStatisticService
;
}
$this->eventDispatcher->dispatch(new TimesheetStatisticsQueryEvent($qb));
$results = $qb->getQuery()->getResult();
foreach ($results as $row) {

View File

@@ -0,0 +1,42 @@
<?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\Twig\SecurityPolicy;
use Symfony\Component\DependencyInjection\Attribute\Exclude;
use Twig\Sandbox\SecurityPolicyInterface;
/**
* @deprecated since 2.47.0 - use StrictPolicy instead
*/
#[Exclude]
final class DefaultPolicy implements SecurityPolicyInterface
{
private SecurityPolicyInterface $securityPolicy;
public function __construct()
{
$this->securityPolicy = new StrictPolicy();
}
public function checkSecurity($tags, $filters, $functions): void
{
$this->securityPolicy->checkSecurity($tags, $filters, $functions);
}
public function checkMethodAllowed($obj, $method): void
{
$this->securityPolicy->checkMethodAllowed($obj, $method);
}
public function checkPropertyAllowed($obj, $property): void
{
$this->securityPolicy->checkPropertyAllowed($obj, $property);
}
}

View File

@@ -0,0 +1,42 @@
<?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\Twig\SecurityPolicy;
use Symfony\Component\DependencyInjection\Attribute\Exclude;
use Twig\Sandbox\SecurityPolicyInterface;
/**
* @deprecated since 2.47.0 - use StrictPolicy instead
*/
#[Exclude]
final class ExportPolicy implements SecurityPolicyInterface
{
private SecurityPolicyInterface $securityPolicy;
public function __construct()
{
$this->securityPolicy = new StrictPolicy();
}
public function checkSecurity($tags, $filters, $functions): void
{
$this->securityPolicy->checkSecurity($tags, $filters, $functions);
}
public function checkMethodAllowed($obj, $method): void
{
$this->securityPolicy->checkMethodAllowed($obj, $method);
}
public function checkPropertyAllowed($obj, $property): void
{
$this->securityPolicy->checkPropertyAllowed($obj, $property);
}
}

View File

@@ -0,0 +1,42 @@
<?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\Twig\SecurityPolicy;
use Symfony\Component\DependencyInjection\Attribute\Exclude;
use Twig\Sandbox\SecurityPolicyInterface;
/**
* @deprecated since 2.47.0 - use StrictPolicy instead
*/
#[Exclude]
final class InvoicePolicy implements SecurityPolicyInterface
{
private SecurityPolicyInterface $securityPolicy;
public function __construct()
{
$this->securityPolicy = new StrictPolicy();
}
public function checkSecurity($tags, $filters, $functions): void
{
$this->securityPolicy->checkSecurity($tags, $filters, $functions);
}
public function checkMethodAllowed($obj, $method): void
{
$this->securityPolicy->checkMethodAllowed($obj, $method);
}
public function checkPropertyAllowed($obj, $property): void
{
$this->securityPolicy->checkPropertyAllowed($obj, $property);
}
}

View File

@@ -21,15 +21,17 @@ class Parsedown extends \Parsedown
{
$block = parent::blockHeader($Line);
$text = $block['element']['text'];
if (isset($block['element']['handler']['argument'])) {
$text = $block['element']['handler']['argument'];
if (\is_string($text) && $text !== '') {
$id = $this->getIDfromText($text);
if (\is_string($text) && $text !== '') {
$id = $this->getIDfromText($text);
// add id-attribute
$block['element']['attributes'] = [
'id' => $id
];
// add id-attribute
$block['element']['attributes'] = [
'id' => $id
];
}
}
return $block;

View File

@@ -44,55 +44,17 @@ final class ParsedownExtension extends Parsedown
];
/**
* Overwritten to add support for file:///
*/
protected $safeLinksWhitelist = [
'file:///',
'http://',
'https://',
'ftp://',
'ftps://',
'mailto:',
'data:image/png;base64,',
'data:image/gif;base64,',
'data:image/jpeg;base64,',
'irc:',
'ircs:',
'git:',
'ssh:',
'news:',
'steam:',
];
/**
* Overwritten:
* - added support for file:///
* - open links in new windows
* Overwritten to open links in new windows
*/
protected function inlineUrl($Excerpt): ?array
{
if ($this->urlsLinked !== true or !isset($Excerpt['text'][2]) or $Excerpt['text'][2] !== '/') {
return null;
$block = parent::inlineUrl($Excerpt);
if (isset($block['element']['attributes']) && \is_array($block['element']['attributes'])) {
$block['element']['attributes']['target'] = '_blank';
}
if (preg_match('/\b(https?:[\/]{2}|file:[\/]{3})[^\s<]+\b\/*/ui', $Excerpt['context'], $matches, PREG_OFFSET_CAPTURE)) {
$url = $matches[0][0];
return [
'extent' => \strlen($matches[0][0]),
'position' => $matches[0][1],
'element' => [
'name' => 'a',
'text' => $url,
'attributes' => [
'href' => $url,
'target' => '_blank'
],
],
];
}
return null;
return $block;
}
protected function blockTable($Line, ?array $Block = null) // @phpstan-ignore missingType.return,missingType.iterableValue,missingType.parameter

View File

@@ -59,6 +59,6 @@ final class UserDurationYear extends AbstractCounterYear
protected function getFinancialYearTitle(): string
{
return 'stats.durationFinancialYear';
return 'stats.userDurationYear';
}
}

View File

@@ -13,7 +13,12 @@ final class DayAddon
{
private bool $billable = true;
public function __construct(private string $title, private int $duration, private int $visibleDuration)
public function __construct(
private readonly string $title,
private readonly int $duration,
private readonly int $visibleDuration,
private readonly ?string $type = null
)
{
}
@@ -41,4 +46,9 @@ final class DayAddon
{
return $this->visibleDuration;
}
public function getType(): ?string
{
return $this->type;
}
}

View File

@@ -8,7 +8,7 @@
{% set currency = activity.project.customer.currency %}
{% endif %}
{% embed '@theme/embeds/card.html.twig' %}
{% embed '@theme/embeds/card.html.twig' with {fullsize: true} %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "customer/actions.html.twig" as customerActions %}
{% import "project/actions.html.twig" as projectActions %}
@@ -21,7 +21,6 @@
{{ widgets.card_tool_button('edit', {'class': 'modal-ajax-form open-edit', 'title': 'edit', 'url': path('admin_activity_edit', {'id': activity.id})}) }}
{% endif %}
{% endblock %}
{% block box_body_class %}p-0{% endblock %}
{% block box_body %}
{% if activity.comment is not empty %}
<div class="comment p-3">

View File

@@ -55,8 +55,7 @@
{% endif %}
<div class="col-xs-12 {% if hasTwoColumns %}col-md-8 col-lg-9 col-print-12{% endif %}">
{% embed '@theme/embeds/card.html.twig' %}
{% block box_body_class %}p-0{% endblock %}
{% embed '@theme/embeds/card.html.twig' with {fullsize: true} %}
{% block box_body %}
<div id="timesheet_calendar"></div>
{% endblock %}

View File

@@ -158,28 +158,35 @@
</td>
{% set dayCount = 0 %}
{% for day in month.days %}
{% set class = 'text-end contractDay text-nowrap' %}
{% if day.workingTime.expectedTime == 0 %}
{% set class = class ~ ' weekend' %}
{% endif %}
{% set style = null %}
{% if day.workingTime.expectedTime > 0 and day.workingTime.actualTime == 0 and now > day.day %}
{% set class = class ~ ' bg-orange-lt' %}
{% set style = 'missing' %}
{% elseif day.workingTime.expectedTime == 0 and day.workingTime.actualTime > 0 and now > day.day %}
{% set class = class ~ ' bg-yellow-lt' %}
{% set style = 'unexpected' %}
{% endif %}
<td class="{{ class }}">
{% if (day.workingTime.expectedTime != 0 or day.workingTime.actualTime != 0) and (now > day.day or day.workingTime.actualTime > 0) %}
{% if day.hasAddons() %}
{% set statusTitle = '' %}
{% for addon in day.getAddons() %}
{% set statusTitle = statusTitle ~ (addon.title|trans) ~ ' (' ~ addon.visibleDuration|duration(decimal) ~ ')' %}
{% if not loop.last %}
{% set statusTitle = statusTitle ~ ', ' %}
{% endif %}
{% endfor %}
<span class="status-dot status-azure" data-toggle="tooltip" data-placement="top" title="{{ statusTitle }}"></span>
{% set statusTitle = null %}
{% if day.hasAddons() %}
{% for addon in day.getAddons() %}
{% set style = addon.type ?? style %}
{% set statusTitle = (statusTitle ?? '') ~ (addon.title|trans) %}
{% if addon.visibleDuration > 0 %}
{% set statusTitle = statusTitle ~ ' (' ~ addon.visibleDuration|duration(decimal) ~ ')' %}
{% endif %}
{% if not loop.last %}
{% set statusTitle = statusTitle ~ ', ' %}
{% endif %}
{% endfor %}
{% endif %}
<td class="text-end contractDay text-nowrap{% if style is not null %} bg-{{ style }}{% endif %}"{% if statusTitle is not null %} data-toggle="tooltip" data-placement="top" title="{{ statusTitle }}"{% endif %}>
{% if (day.workingTime.expectedTime != 0 or day.workingTime.actualTime != 0 or day.hasAddons()) and (now > day.day or day.workingTime.actualTime > 0) %}
{% if style is not null and statusTitle is not null %}
<i class="{{ style|icon }} fa-xs"></i>
{% endif %}
{% if day.workingTime.expectedTime == 0 and day.workingTime.actualTime == 0 %}
--:--
{% else %}
{{ work_times_result(day.workingTime.expectedTime, day.workingTime.actualTime, decimal) }}
{% endif %}
{{ work_times_result(day.workingTime.expectedTime, day.workingTime.actualTime, decimal) }}
{% endif %}
</td>
{% set dayCount = dayCount + 1 %}

View File

@@ -4,7 +4,7 @@
{% block main %}
{% set can_edit = is_granted('edit', customer) %}
{% embed '@theme/embeds/card.html.twig' %}
{% embed '@theme/embeds/card.html.twig' with {fullsize: true} %}
{% import "macros/widgets.html.twig" as widgets %}
{% block box_attributes %}id="customer_details_box"{% endblock %}
{% block box_title %}
@@ -16,7 +16,6 @@
{{ widgets.card_tool_button('edit', {'class': 'modal-ajax-form open-edit', 'title': 'edit', 'url': path('admin_customer_edit', {'id': customer.id})}) }}
{% endif %}
{% endblock %}
{% block box_body_class %}p-0{% endblock %}
{% block box_body %}
{% if customer.comment is not empty %}
<div class="comment p-3">

View File

@@ -162,4 +162,72 @@
{% endblock %}
{% endembed %}
{% set opcache_warning = not opcache['enabled'] %}
{% embed '@theme/embeds/collapsible.html.twig' with {open: opcache_warning, boxtype: (opcache_warning ? 'danger' : 'success')} %}
{% block title %}OPCache{% endblock %}
{% block body_class %}p-0{% endblock %}
{% block body %}
{% import "macros/widgets.html.twig" as widgets %}
{% if opcache_warning %}
{{ widgets.alert('danger', 'Could not detect an active OPCache. This will significantly impact the performance of Kimai.') }}
{% endif %}
{% if opcache['status'] is iterable %}
<table class="table table-hover">
<tr>
<th class="text-nowrap w-min">Opcache Enabled</th>
<td class="text-break">{{ widgets.label_boolean(opcache['enabled']) }}</td>
</tr>
<tr>
<th class="text-nowrap w-min">Opcache Full</th>
<td class="text-break">{{ widgets.label_boolean(opcache['status']['cache_full']) }}</td>
</tr>
<tr>
<th class="text-nowrap w-min">Memory Used</th>
<td class="text-break">{{ (opcache['status']['memory_usage']['used_memory'] / 1024 / 1024)|number_format(2) }} MB</td>
</tr>
<tr>
<th class="text-nowrap w-min">Memory Free</th>
<td class="text-break">{{ (opcache['status']['memory_usage']['free_memory'] / 1024 / 1024)|number_format(2) }} MB</td>
</tr>
<tr>
<th class="text-nowrap w-min">Interned-Strings Used</th>
<td class="text-break">{{ (opcache['status']['interned_strings_usage']['used_memory'] / 1024 / 1024)|number_format(2) }} MB</td>
</tr>
<tr>
<th class="text-nowrap w-min">Interned-Strings Free</th>
<td class="text-break">{{ (opcache['status']['interned_strings_usage']['free_memory'] / 1024 / 1024)|number_format(2) }} MB</td>
</tr>
<tr>
<th class="text-nowrap w-min">Cached Scripts</th>
<td class="text-break">{{ opcache['status']['opcache_statistics']['num_cached_scripts'] }}</td>
</tr>
<tr>
<th class="text-nowrap w-min">Cached Keys</th>
<td class="text-break">{{ opcache['status']['opcache_statistics']['num_cached_keys'] }}</td>
</tr>
<tr>
<th class="text-nowrap w-min">Max Keys</th>
<td class="text-break">{{ opcache['status']['opcache_statistics']['max_cached_keys'] }}</td>
</tr>
<tr>
<th class="text-nowrap w-min">Hit Rate</th>
<td class="text-break">{{ opcache['status']['opcache_statistics']['opcache_hit_rate']|number_format(2) }}</td>
</tr>
<tr>
<th class="text-nowrap w-min">Jit enabled</th>
<td class="text-break">{{ widgets.label_boolean(opcache['status']['jit']['enabled']) }}</td>
</tr>
<tr>
<th class="text-nowrap w-min">Buffer Size</th>
<td class="text-break">{{ (opcache['status']['jit']['buffer_size'] / 1024 / 1024)|number_format(2) }} MB</td>
</tr>
<tr>
<th class="text-nowrap w-min">Buffer Free</th>
<td class="text-break">{{ (opcache['status']['jit']['buffer_free'] / 1024 / 1024)|number_format(2) }} MB</td>
</tr>
</table>
{% endif %}
{% endblock %}
{% endembed %}
{% endblock %}

View File

@@ -1,13 +1,12 @@
{% set timeBudgetVisible = is_granted('time', entity) %}
{% set durationTrans = entity.isMonthlyBudget() ? 'stats.durationMonth' : 'stats.workingTime' %}
{% if timeBudgetVisible %}
{% embed '@theme/embeds/card.html.twig' %}
{% embed '@theme/embeds/card.html.twig' with {fullsize: true} %}
{% import "macros/progressbar.html.twig" as progress %}
{% import "macros/widgets.html.twig" as widgets %}
{% block box_title %}
{{ 'timeBudget'|trans }} {% if entity.isMonthlyBudget() %}({{ 'budgetType_month'|trans }}){% endif %}
{% endblock %}
{% block box_body_class %}p-0{% endblock %}
{% block box_attributes %}id="time_budget_box"{% endblock %}
{% block box_body %}
<div class="row">
@@ -59,13 +58,12 @@
{% endembed %}
{% endif %}
{% if is_granted('budget', entity) %}
{% embed '@theme/embeds/card.html.twig' %}
{% embed '@theme/embeds/card.html.twig' with {fullsize: true} %}
{% import "macros/progressbar.html.twig" as progress %}
{% import "macros/widgets.html.twig" as widgets %}
{% block box_title %}
{{ 'budget'|trans }} {% if entity.isMonthlyBudget() %}({{ 'budgetType_month'|trans }}){% endif %}
{% endblock %}
{% block box_body_class %}p-0{% endblock %}
{% block box_attributes %}id="budget_box"{% endblock %}
{% block box_body %}
<div class="row">

View File

@@ -104,13 +104,12 @@
{% if entries is empty %}
{{ widgets.nothing_found() }}
{% else %}
{% embed '@theme/embeds/card.html.twig' %}
{% embed '@theme/embeds/card.html.twig' with {fullsize: true} %}
{% import "macros/widgets.html.twig" as widgets %}
{% block box_title %}
{{ 'preview'|trans }}
{% endblock %}
{% block box_attributes %}id="preview_export"{% endblock %}
{% block box_body_class %}p-0{% endblock %}
{% block box_footer %}
{% if showMarkAsExportedButton %}
<div class="d-flex">
@@ -124,17 +123,13 @@
<div class="btn-group me-auto" id="export-buttons" role="group">
{% set allowCreate = is_granted('create_export_template') %}
{% for type, group in buttons %}
{% set btnTitle = ('button.' ~ type)|trans %}
{% if btnTitle == ('button.' ~ type) %}
{% set btnTitle = type|trans %}
{% if type == 'html' %}
{% set type = 'print' %}
{% endif %}
{% set btnTitle = type|trans %}
{% if group|length == 1 %}
{% for id, cfg in group %}
{% set title = cfg.title %}
{% set btnTitle2 = ('button.' ~ title)|trans %}
{% if btnTitle2 != ('button.' ~ title) %}
{% set btnTitle = btnTitle2 %}
{% endif %}
<button type="button" id="export-{{ id }}-button" class="btn btn-primary startExportBtn" data-type="{{ id }}">
{{ btnTitle }}
</button>
@@ -146,13 +141,9 @@
</button>
<div class="dropdown-menu">
{% for id, cfg in group %}
{% set title = cfg.title %}
{% set btnTitle = id|trans({}, 'export') %}
{% if btnTitle == id %}
{% set btnTitle = title|trans %}
{% endif %}
{% if btnTitle == id %}
{% set btnTitle = id|split('.')|first|replace({'-': ' ', '_': ' '})|split(' ')|map(t => t|capitalize)|join(' ') %}
{% set btnTitle = (cfg.title)|trans %}
{% if loop.first %}
{% set btnTitle = 'default'|trans %}
{% endif %}
<a href="#" class="dropdown-item startExportBtn" data-type="{{ id }}">
{{ btnTitle }}

View File

@@ -1,6 +1,6 @@
<!DOCTYPE html>
<html lang="{{ app.locale }}">
{% from "macros/meta_fields.html.twig" import meta_field_value %}
{% from "macros/meta_fields.html.twig" import meta_field_value, form_type_value %}
{% set storageItemName = 'kimai_html_export' %}
{% set decimal = decimal|default(false) %}
{% set columnTitles = {} %}
@@ -507,7 +507,7 @@
<td class="column-{{ 'u_' ~ field.name }} text-nowrap" {% if not columns['u_' ~ field.name] %}style="display: none"{% endif %}>
{% set metaField = entry.user.preference(field.name) %}
{% if not metaField is null and metaField.value is not null and metaField.value is not empty %}
{{ _self.form_type_value(field.type, metaField.value, entry.user) }}
{{ form_type_value(field.type, metaField.value, entry.user) }}
{% endif %}
</td>
{% endfor %}

View File

@@ -6,7 +6,7 @@
{% set showCustomerSummary = false %}
{% set decimal = true %}
{% set showDateTimeShort = true %}
{% block title %}{{ 'timesheet.pdf.twig'|trans({}, 'export') }}{% endblock %}
{% block title %}{{ 'timesheet'|trans }}{% endblock %}
{% block description %}
{% if entry.description is not empty %}
{{ entry.description|desc2html }}

View File

@@ -35,14 +35,13 @@
{% endif %}
{% if documents|length > 0 %}
{% embed '@theme/embeds/card.html.twig' with {'documents': documents} %}
{% embed '@theme/embeds/card.html.twig' with {'documents': documents, fullsize: true} %}
{% import "invoice/actions.html.twig" as actions %}
{% import "macros/widgets.html.twig" as widgets %}
{% block box_title %}{{ 'invoice_renderer'|trans({}, 'invoice-renderer') }}{% endblock %}
{% block box_attributes %}
id="invoice_document_list"
{% endblock %}
{% block box_body_class %}p-0{% endblock %}
{% block box_body %}
<table class="table table-hover dataTable">
<thead>

View File

@@ -90,14 +90,13 @@
{% endif %}
{% if models|length > 0 %}
{% embed '@theme/embeds/card.html.twig' %}
{% embed '@theme/embeds/card.html.twig' with {fullsize: true} %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "macros/datatables.html.twig" as tables %}
{% block box_title %}
{{ 'preview'|trans }}
{% endblock %}
{% block box_attributes %}id="invoice_customer_forms"{% endblock %}
{% block box_body_class %}p-0{% endblock %}
{% block box_body %}
<table class="table dataTable">
<thead>

View File

@@ -3,40 +3,42 @@
{% set metaField = entity.metaField(field.name) %}
{% if not metaField is null %}
{% set metaField = metaField.merge(field) %}
{% set type = metaField.type %}
{% set value = metaField.value %}
{% if 'DurationType' in type %}
{{ value|duration }}
{% elseif 'YesNoType' in type or 'CheckboxType' in type %}
{{ value ? ('yes'|trans) : ('no'|trans) }}
{% elseif 'DatePickerType' in type %}
{{ value|date_short }}
{% elseif 'DateTimePickerType' in type %}
{{ value|date_time }}
{% elseif 'CountryType' in type %}
{{ value|country_name }}
{% elseif 'CurrencyType' in type %}
{{ value|currency_name }}
{% elseif 'LanguageType' in type %}
{{ value|locale_name }}
{% elseif 'MoneyType' in type %}
{% set classname = class_name(entity) %}
{% if classname == 'App\\Entity\\Timesheet' %}
{{ value|money(entity.project.customer.currency) }}
{% elseif classname == 'App\\Entity\\Customer' %}
{{ value|money(entity.currency) }}
{% elseif classname == 'App\\Entity\\Project' %}
{{ value|money(entity.customer.currency) }}
{% elseif classname == 'App\\Entity\\Activity' and entity.project is not null %}
{{ value|money(entity.project.customer.currency) }}
{% else %}
{{ value }}
{% endif %}
{% elseif 'TextareaType' in type %}
{{ value|nl2br }}
{% else %}
{# EmailType, UrlType, TagsType, ColorPickerType, ColorChoiceType #}
{{ value }}
{% endif %}
{{ _self.form_type_value(metaField.type, metaField.value, entity) }}
{% endif %}
{% endmacro %}
{% macro form_type_value(type, value, entity) %}
{% if 'DurationType' in type %}
{{ value|duration }}
{% elseif 'YesNoType' in type or 'CheckboxType' in type %}
{{ value ? ('yes'|trans) : ('no'|trans) }}
{% elseif 'DatePickerType' in type %}
{{ value|date_short }}
{% elseif 'DateTimePickerType' in type %}
{{ value|date_time }}
{% elseif 'CountryType' in type %}
{{ value|country_name }}
{% elseif 'CurrencyType' in type %}
{{ value|currency_name }}
{% elseif 'LanguageType' in type %}
{{ value|locale_name }}
{% elseif 'MoneyType' in type %}
{% set classname = class_name(entity) %}
{% if classname == 'App\\Entity\\Timesheet' %}
{{ value|money(entity.project.customer.currency) }}
{% elseif classname == 'App\\Entity\\Customer' %}
{{ value|money(entity.currency) }}
{% elseif classname == 'App\\Entity\\Project' %}
{{ value|money(entity.customer.currency) }}
{% elseif classname == 'App\\Entity\\Activity' and entity.project is not null %}
{{ value|money(entity.project.customer.currency) }}
{% else %}
{{ value }}
{% endif %}
{% elseif 'TextareaType' in type %}
{{ value|nl2br }}
{% else %}
{# EmailType, UrlType, TagsType, ColorPickerType, ColorChoiceType #}
{{ value }}
{% endif %}
{% endmacro %}

View File

@@ -7,9 +7,8 @@
{% if plugins|length == 0 %}
{{ widgets.callout('warning', 'plugin.none_installed'|trans({}, 'plugins')) }}
{% else %}
{% embed '@theme/embeds/card.html.twig' %}
{% embed '@theme/embeds/card.html.twig' with {fullsize: true} %}
{% block box_title %}{{ 'plugin.installed'|trans }}{% endblock %}
{% block box_body_class %}p-0{% endblock %}
{% block box_body %}
{% set columns = {
'name': {'class': 'alwaysVisible w-25'},
@@ -46,10 +45,9 @@
{% endif %}
{% if extensions|length > 0 %}
{% embed '@theme/embeds/card.html.twig' %}
{% embed '@theme/embeds/card.html.twig' with {fullsize: true} %}
{% from "macros/widgets.html.twig" import card_tool_button %}
{% block box_title %}{{ 'plugin.marketplace'|trans({}, 'plugins') }}{% endblock %}
{% block box_body_class %}p-0{% endblock %}
{% block box_tools %}
{{ card_tool_button('shop', {'title': 'shop', 'translation_domain': 'plugins', 'target': '_blank', 'url': constant('App\\Constants::HOMEPAGE') ~ '/store/', 'icon': false, 'class': 'btn-primary'}) }}
{% endblock %}

View File

@@ -4,7 +4,7 @@
{% block main %}
{% set can_edit = is_granted('edit', project) %}
{% embed '@theme/embeds/card.html.twig' %}
{% embed '@theme/embeds/card.html.twig' with {fullsize: true} %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "customer/actions.html.twig" as customerActions %}
{% block box_attributes %}id="project_details_box"{% endblock %}
@@ -16,7 +16,6 @@
{% block box_title %}
{{ widgets.label_dot(project.name, project.color) }}
{% endblock %}
{% block box_body_class %}p-0{% endblock %}
{% block box_body %}
{% if project.comment is not empty %}
<div class="comment p-3">

View File

@@ -17,5 +17,8 @@
{% block column_classes_total -%}
{% if column is weekend %} weekend{% endif %}
{%- endblock %}
{% block column_classes_customer_total -%}
{% if column is weekend %} weekend{% endif %}
{%- endblock %}
{% endembed %}
{% endblock %}

View File

@@ -46,6 +46,23 @@
{% elseif data.customer != totalCurrency %}
{% set totalCurrency = null %}
{% endif %}
{% set customerTotalsDuration = {} %}
{% set customerTotalsInternalRate = {} %}
{% set customerTotalsRate = {} %}
{% for column in period.dateTimes %}
{% set dateKey = column|report_date %}
{% set customerTotalsDuration = customerTotalsDuration|merge({(dateKey): 0}) %}
{% set customerTotalsInternalRate = customerTotalsInternalRate|merge({(dateKey): 0}) %}
{% set customerTotalsRate = customerTotalsRate|merge({(dateKey): 0}) %}
{% endfor %}
{% for project in data.projects %}
{% for column in project.data.data %}
{% set dateKey = column.date|report_date %}
{% set customerTotalsDuration = customerTotalsDuration|merge({(dateKey): (customerTotalsDuration[dateKey] + column.duration)}) %}
{% set customerTotalsInternalRate = customerTotalsInternalRate|merge({(dateKey): (customerTotalsInternalRate[dateKey] + column.internalRate)}) %}
{% set customerTotalsRate = customerTotalsRate|merge({(dateKey): (customerTotalsRate[dateKey] + column.rate)}) %}
{% endfor %}
{% endfor %}
<tr class="summary">
<td>{{ widgets.label_customer(data.customer) }}</td>
<td class="text-center total">
@@ -59,7 +76,20 @@
{% endif %}
{% if with_links %}</a>{% endif %}
</td>
<td colspan="{{ columns }}"></td>
{% for column in period.dateTimes %}
{% set dateKey = column|report_date %}
<td class="text-nowrap text-center day-total {% block column_classes_customer_total %}{% endblock %}">
{% if customerTotalsRate[dateKey] > 0 or customerTotalsInternalRate[dateKey] > 0 or customerTotalsDuration[dateKey] > 0 %}
{% if dataType == 'rate' %}
<strong>{{ customerTotalsRate[dateKey]|money(currency) }}</strong>
{% elseif dataType == 'internalRate' %}
<strong>{{ customerTotalsInternalRate[dateKey]|money(currency) }}</strong>
{% else %}
<strong>{{ customerTotalsDuration[dateKey]|duration(decimal) }}</strong>
{% endif %}
{% endif %}
</td>
{% endfor %}
</tr>
{% for project in data.projects %}
{% set absoluteDuration = absoluteDuration + project.duration %}

View File

@@ -14,5 +14,6 @@
{% block column_classes_project %}{% endblock %}
{% block column_classes_activity %}{% endblock %}
{% block column_classes_total %}{% endblock %}
{% block column_classes_customer_total %}{% endblock %}
{% endembed %}
{% endblock %}

View File

@@ -5,4 +5,5 @@
{% block column_classes_project %}{% endblock %}
{% block column_classes_activity %}{% endblock %}
{% block column_classes_total %}{% endblock %}
{% block column_classes_customer_total %}{% endblock %}
{% endembed %}

View File

@@ -9,8 +9,11 @@
namespace App\Tests\API;
use App\Entity\Customer;
use App\Entity\Invoice;
use App\Entity\Team;
use App\Entity\User;
use App\Repository\TeamRepository;
use App\Tests\DataFixtures\InvoiceFixtures;
use PHPUnit\Framework\Attributes\Group;
@@ -123,4 +126,59 @@ class InvoiceControllerTest extends APIControllerBaseTestCase
{
$this->assertEntityNotFound(User::ROLE_USER, '/api/invoices/' . PHP_INT_MAX);
}
public function testDownloadRespectsCustomerPermission(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$invoices = $this->importInvoiceFixtures(1, [Invoice::STATUS_NEW]);
$invoice = $invoices[0];
$customer = $invoice->getCustomer();
self::assertInstanceOf(Customer::class, $customer);
$this->assertAccessIsGranted($client, '/api/invoices/' . $invoice->getId());
$content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertApiResponseTypeStructure('Invoice', $result);
$team = new Team('foo');
$team->addTeamlead($this->getUserByRole(User::ROLE_ADMIN));
$team->addCustomer($customer);
$em = $this->getEntityManager();
/** @var TeamRepository $repository */
$repository = $em->getRepository(Team::class);
$repository->saveTeam($team);
$this->assertApiAccessDenied($client, '/api/invoices/' . $invoice->getId());
}
public function testCollectionRespectsCustomerPermission(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$invoices = $this->importInvoiceFixtures(1, [Invoice::STATUS_NEW]);
$invoice = $invoices[0];
$customer = $invoice->getCustomer();
self::assertInstanceOf(Customer::class, $customer);
$query = ['customers' => [$customer->getId()]];
$this->assertAccessIsGranted($client, '/api/invoices', 'GET', $query);
$team = new Team('foo');
$team->addTeamlead($this->getUserByRole(User::ROLE_ADMIN));
$team->addCustomer($customer);
$em = $this->getEntityManager();
/** @var TeamRepository $repository */
$repository = $em->getRepository(Team::class);
$repository->saveTeam($team);
$this->request($client, '/api/invoices', 'GET', $query);
$this->assertApiResponseAccessDenied($client->getResponse());
}
}

View File

@@ -105,7 +105,7 @@ class ExportControllerTest extends AbstractControllerBaseTestCase
// assert export type buttons are available
$expected = [
'csv' => 'csv',
'html' => 'html',
'print' => 'html',
'pdf' => 'pdf',
'xlsx' => 'xlsx'
];
@@ -162,7 +162,7 @@ class ExportControllerTest extends AbstractControllerBaseTestCase
// assert export type buttons are available
$expected = [
'csv' => 'csv',
'html' => 'html',
'print' => 'html',
'pdf' => 'pdf',
'xlsx' => 'xlsx'
];
@@ -227,7 +227,7 @@ class ExportControllerTest extends AbstractControllerBaseTestCase
// don't add daterange to make sure the current month is the default range
$client->submit($form, [
'renderer' => 'html',
'renderer' => 'print',
'markAsExported' => 1
]);

View File

@@ -50,4 +50,23 @@ class WorkingTimeTest extends TestCase
self::assertSame($approvedBy, $sut->getApprovedBy());
self::assertTrue($sut->isApproved());
}
public function testExpectedTime(): void
{
$user = new User();
$user->setUsername('bar');
$date = new \DateTimeImmutable();
$sut = new WorkingTime($user, $date);
$sut->setExpectedTime(222222);
self::assertEquals(222222, $sut->getExpectedTime());
self::assertEquals(222222, $sut->getOriginalExpectedTime());
$reducedBy = $sut->halveExpectedTime();
self::assertEquals(111111, $reducedBy);
self::assertEquals(111111, $sut->getExpectedTime());
self::assertEquals(222222, $sut->getOriginalExpectedTime());
$sut->emptyExpectedTime();
self::assertEquals(0, $sut->getExpectedTime());
self::assertEquals(222222, $sut->getOriginalExpectedTime());
}
}

View File

@@ -0,0 +1,31 @@
<?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\Tests\Event;
use App\Event\TimesheetStatisticsQueryEvent;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\QueryBuilder;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(TimesheetStatisticsQueryEvent::class)]
class TimesheetStatisticsQueryEventTest extends TestCase
{
public function testGetter(): void
{
$qb = new QueryBuilder($this->createMock(EntityManager::class));
self::assertCount(0, $qb->getParameters());
$sut = new TimesheetStatisticsQueryEvent($qb);
$qb->setParameter('foo', 'bar');
self::assertSame($qb, $sut->getQueryBuilder());
self::assertCount(1, $sut->getQueryBuilder()->getParameters());
}
}

View File

@@ -0,0 +1,40 @@
<?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\Tests\Event;
use App\Entity\User;
use App\Event\WorkingTimeQueryStatsEvent;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\QueryBuilder;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(WorkingTimeQueryStatsEvent::class)]
class WorkingTimeQueryStatsEventTest extends TestCase
{
public function testGetter(): void
{
$qb = new QueryBuilder($this->createMock(EntityManager::class));
self::assertCount(0, $qb->getParameters());
$user = new User();
$begin = new \DateTime('2004-02-13');
$end = new \DateTime('2099-12-31');
$sut = new WorkingTimeQueryStatsEvent($qb, $user, $begin, $end);
$qb->setParameter('foo', 'bar');
self::assertSame($qb, $sut->getQueryBuilder());
self::assertCount(1, $sut->getQueryBuilder()->getParameters());
self::assertSame($user, $sut->getUser());
self::assertSame($begin, $sut->getBegin());
self::assertSame($end, $sut->getEnd());
}
}

View File

@@ -54,7 +54,7 @@ class XlsxRendererTest extends AbstractRendererTestCase
self::assertEquals('xlsx', $sut->getType());
self::assertEquals('xlsx', $sut->getId());
self::assertEquals('default', $sut->getTitle());
self::assertEquals('xlsx', $sut->getTitle());
self::assertFalse($sut->isInternal());
$sut->setInternal(true);
self::assertTrue($sut->isInternal());

View File

@@ -15,13 +15,17 @@ use App\Form\Type\QuickEntryTimesheetType;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Form\FormExtensionInterface;
use Symfony\Component\Form\PreloadedExtension;
use Symfony\Component\Form\Test\TypeTestCase;
#[CoversClass(QuickEntryTimesheetType::class)]
class QuickEntryTimesheetTypeTest extends TypeTestCase
{
protected function getExtensions()
/**
* @return FormExtensionInterface[]
*/
protected function getExtensions(): array
{
$auth = $this->createMock(Security::class);
$auth->method('getUser')->willReturn(new User());
@@ -36,18 +40,19 @@ class QuickEntryTimesheetTypeTest extends TypeTestCase
public static function getTestData()
{
yield [4.5, 16200];
yield ['4,5', 16200];
yield ['4:30', 16200];
yield ['4h30m', 16200];
yield [4.5, 0, 16200];
yield ['4,5', 0, 16200];
yield ['4:30', 0, 16200];
yield ['4h30m', 0, 16200];
yield ['4h30m', 1800, 16200]; // it is important, that the duration does not change with breaks
}
#[DataProvider('getTestData')]
public function testSubmitValidData($value, $expectedDuration): void
public function testSubmitValidData(string|float $value, int $break, int $expectedDuration): void
{
$data = ['duration' => $value];
$model = $this->createDefaultModel();
$model = $this->createDefaultModel($expectedDuration, $break);
$form = $this->factory->create(QuickEntryTimesheetType::class, $model);
@@ -56,16 +61,19 @@ class QuickEntryTimesheetTypeTest extends TypeTestCase
self::assertTrue($form->isSynchronized());
self::assertEquals($expectedDuration, $model->getDuration());
self::assertEquals($expectedDuration, $model->getDuration(true));
self::assertEquals($break, $model->getBreak());
}
private function createDefaultModel(): Timesheet
private function createDefaultModel(int $duration = 0, int $break = 0): Timesheet
{
$begin = new \DateTime('2020-02-15 12:30:00');
$end = new \DateTime('2020-02-15 14:00:00');
$model = new Timesheet();
$model->setBegin($begin);
//$model->setDuration($duration);
$model->setEnd($end);
$model->setBreak($break);
return $model;
}

View File

@@ -33,7 +33,7 @@ class MarkdownTest extends TestCase
asdfasdfa</p>
<pre><code>ssdfsdf</code></pre>
<p><a href="http://example.com/foo-bar.html" target="_blank">http://example.com/foo-bar.html</a><br />
<a href="file:///home/kimai/images/beautiful-flower.png" target="_blank">file:///home/kimai/images/beautiful-flower.png</a></p>
file:///home/kimai/images/beautiful-flower.png</p>
<p>sdfsdf <a href="#test-1">asdfasdf</a> asdfasdf</p>
<p># test<br />
aasdfasdf<br />

View File

@@ -21,7 +21,7 @@ class ParsedownExtensionTest extends TestCase
public function testTableContainsCssClasses(): void
{
$sut = new ParsedownExtension();
$html = $sut->parse('
$html = $sut->text('
| Item | Price |
|---|---|
| Something | $ 472,78 |
@@ -34,7 +34,7 @@ class ParsedownExtensionTest extends TestCase
public function testHeaderIsNotConverted(): void
{
$sut = new ParsedownExtension();
$html = $sut->parse('
$html = $sut->text('
# Foo
');
self::assertEquals('<p># Foo</p>', $html);

View File

@@ -19,7 +19,7 @@ class ParsedownTest extends TestCase
public function testTableContainsCssClasses(): void
{
$sut = new Parsedown();
$html = $sut->parse('
$html = $sut->text('
| Item | Price |
|---|---|
| Something | $ 472,78 |
@@ -32,7 +32,7 @@ class ParsedownTest extends TestCase
public function testHeaderContainsId(): void
{
$sut = new Parsedown();
$html = $sut->parse('
$html = $sut->text('
# Foo
');
self::assertEquals('<h1 id="foo">Foo</h1>', $html);
@@ -41,7 +41,7 @@ class ParsedownTest extends TestCase
public function testHeaderContainsIdDoesNotDuplicate(): void
{
$sut = new Parsedown();
$html = $sut->parse('
$html = $sut->text('
# Foo
# Foo
# Foo

View File

@@ -1346,26 +1346,11 @@ parameters:
count: 1
path: Form/Type/DurationTypeTest.php
-
message: "#^Method App\\\\Tests\\\\Form\\\\Type\\\\QuickEntryTimesheetTypeTest\\:\\:getExtensions\\(\\) has no return type specified\\.$#"
count: 1
path: Form/Type/QuickEntryTimesheetTypeTest.php
-
message: "#^Method App\\\\Tests\\\\Form\\\\Type\\\\QuickEntryTimesheetTypeTest\\:\\:getTestData\\(\\) has no return type specified\\.$#"
count: 1
path: Form/Type/QuickEntryTimesheetTypeTest.php
-
message: "#^Method App\\\\Tests\\\\Form\\\\Type\\\\QuickEntryTimesheetTypeTest\\:\\:testSubmitValidData\\(\\) has parameter \\$expectedDuration with no type specified\\.$#"
count: 1
path: Form/Type/QuickEntryTimesheetTypeTest.php
-
message: "#^Method App\\\\Tests\\\\Form\\\\Type\\\\QuickEntryTimesheetTypeTest\\:\\:testSubmitValidData\\(\\) has parameter \\$value with no type specified\\.$#"
count: 1
path: Form/Type/QuickEntryTimesheetTypeTest.php
-
message: "#^Method App\\\\Tests\\\\Invoice\\\\Calculator\\\\AbstractCalculatorTestCase\\:\\:assertDescription\\(\\) has parameter \\$addActivity with no type specified\\.$#"
count: 1

View File

@@ -34,6 +34,18 @@
<source>This year — until today</source>
<target state="translated">Tahun ini — hingga hari ini</target>
</trans-unit>
<trans-unit id="8f61k.L" resname="daterangepicker.thisMonth" xml:space="preserve">
<source>daterangepicker.thisMonth</source>
<target state="translated">Bulan ini</target>
</trans-unit>
<trans-unit id="AdEC6w4" resname="daterangepicker.lastMonth" xml:space="preserve">
<source>daterangepicker.lastMonth</source>
<target state="translated">Bulan lalu</target>
</trans-unit>
<trans-unit id="mgJ2Z7M" resname="daterangepicker.thisFinancialYear" xml:space="preserve">
<source>daterangepicker.thisFinancialYear</source>
<target state="translated">Tahun fiskal ini</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -26,6 +26,10 @@
<source>daterangepicker.thisYear</source>
<target state="translated">올해</target>
</trans-unit>
<trans-unit id="AY_E9UR" resname="daterangepicker.thisYearUntilNow" xml:space="preserve">
<source>This year — until today</source>
<target state="translated">올해 — 오늘까지</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -42,6 +42,10 @@
<source>daterangepicker.lastMonth</source>
<target state="translated">Tháng trước</target>
</trans-unit>
<trans-unit id="mgJ2Z7M" resname="daterangepicker.thisFinancialYear" xml:space="preserve">
<source>daterangepicker.thisFinancialYear</source>
<target state="translated">Năm tài chính hiện tại</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -34,6 +34,18 @@
<source>daterangepicker.today</source>
<target state="final">今天</target>
</trans-unit>
<trans-unit id="8f61k.L" resname="daterangepicker.thisMonth" xml:space="preserve">
<source>daterangepicker.thisMonth</source>
<target state="translated">本月份</target>
</trans-unit>
<trans-unit id="AdEC6w4" resname="daterangepicker.lastMonth" xml:space="preserve">
<source>daterangepicker.lastMonth</source>
<target state="translated">上個月</target>
</trans-unit>
<trans-unit id="mgJ2Z7M" resname="daterangepicker.thisFinancialYear" xml:space="preserve">
<source>daterangepicker.thisFinancialYear</source>
<target state="translated">本會計年度</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -38,6 +38,18 @@
<source>Don't worry, Kimai will help you create a new one!</source>
<target state="needs-translation">… επομένως ξεχάσατε τον κωδικό σας; Μην ανησυχείτε: Το Kimai θα σας βοηθήσει να δημιουργήσετε ένα νέο:</target>
</trans-unit>
<trans-unit id="U.JjI9R" resname="approval_rejected_message" xml:space="preserve">
<source>approval_rejected_message</source>
<target state="translated">Η αίτηση εξουσιοδότησής σας απορρίφθηκε από τον/την %created_by%
%list%
Παρακαλούμε ελέγξτε τα αιτήματά σας στο:%url%</target>
</trans-unit>
<trans-unit id="YdGZyer" resname="approval_approved_subject" xml:space="preserve">
<source>approval_approved_subject</source>
<target state="translated">Το αίτημα σας έχει εγκριθεί</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -2,13 +2,13 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="es" datatype="plaintext" original="email.en.xlf">
<body>
<trans-unit id="8jDJmrK" resname="automated_email_dont_answer">
<trans-unit id="8jDJmrK" resname="automated_email_dont_answer" xml:space="preserve">
<source>automated_email_dont_answer</source>
<target state="translated">Este es un correo electrónico generado automáticamente. Por favor, no responda, la dirección no será leída.</target>
<target state="translated">Este es un correo-e auto-generado. Por favor, no responda, la dirección no será leída.</target>
</trans-unit>
<trans-unit id="pjr3FDv" resname="registration.subject">
<trans-unit id="pjr3FDv" resname="registration.subject" xml:space="preserve">
<source>registration.subject</source>
<target state="translated">activa tu cuenta</target>
<target state="translated">Activa tu cuenta</target>
</trans-unit>
<trans-unit id="QtcznHC" resname="registration.title">
<source>registration.title</source>
@@ -38,9 +38,9 @@
<source>Don't worry, Kimai will help you create a new one!</source>
<target state="translated">Este enlace solo será válido durante los próximos 15 minutos.</target>
</trans-unit>
<trans-unit id="yohIAA_" resname="absence_created_supervisor_subject">
<trans-unit id="yohIAA_" resname="absence_created_supervisor_subject" xml:space="preserve">
<source>absence_created_supervisor_subject</source>
<target state="translated">Nueva ausencia | %user% | %type%</target>
<target state="translated">Nueva ausencia: %user% (%type%)</target>
</trans-unit>
<trans-unit id="wwLx._J" resname="absence_created_supervisor_message" xml:space="preserve">
<source>A new absence was created for %user%.
@@ -48,7 +48,7 @@
%list%
Please review them at: %url%</source>
<target state="translated">Se ha creado una nueva ausencia para %user%.
<target state="translated">Se ha creado una ausencia nueva para %user%.
%list%

View File

@@ -32,7 +32,7 @@
</trans-unit>
<trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve">
<source>reset.button</source>
<target state="translated">Connectez-vous</target>
<target state="translated">Se connecter</target>
</trans-unit>
<trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve">
<source>Don't worry, Kimai will help you create a new one!</source>

View File

@@ -50,9 +50,9 @@ Please review them at: %url%</source>
Kérjük, tekintse át a következő címen: %url%</target>
</trans-unit>
<trans-unit id="yohIAA_" resname="absence_created_supervisor_subject">
<trans-unit id="yohIAA_" resname="absence_created_supervisor_subject" xml:space="preserve">
<source>absence_created_supervisor_subject</source>
<target state="translated">Új távollét | %user% | %type%</target>
<target state="translated">Új távollét: %user% (%type%)</target>
</trans-unit>
<trans-unit id="1q.VHdU" resname="approval_requested_subject" xml:space="preserve">
<source>approval_requested_subject</source>

View File

@@ -32,11 +32,11 @@
</trans-unit>
<trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve">
<source>reset.button</source>
<target state="needs-translation">Zresetuj swoje hasło</target>
<target state="translated">Zaloguj się</target>
</trans-unit>
<trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve">
<source>Don't worry, Kimai will help you create a new one!</source>
<target state="needs-translation">… zapomniałeś hasła? Nie martw się, Kimai pomoże Ci stworzyć nowe:</target>
<target state="translated">Link będzie ważny przez 15 najbliższych minut</target>
</trans-unit>
<trans-unit id="yohIAA_" resname="absence_created_supervisor_subject" xml:space="preserve">
<source>absence_created_supervisor_subject</source>
@@ -54,6 +54,42 @@ Please review them at: %url%</source>
Prosimy o zapoznanie się z nią na stronie: %url%</target>
</trans-unit>
<trans-unit id="1q.VHdU" resname="approval_requested_subject" xml:space="preserve">
<source>approval_requested_subject</source>
<target state="translated">Wniosek o zatwierdzenie: %user%</target>
</trans-unit>
<trans-unit id="Z172mQ8" resname="approval_requested_message" xml:space="preserve">
<source>approval_requested_message</source>
<target state="translated">Wysłano prośbę o zatwierdzenie dla %user%.
%list%
Proszę zapoznać się z nimi pod adresem: %url%</target>
</trans-unit>
<trans-unit id="dvPkiia" resname="approval_rejected_subject" xml:space="preserve">
<source>approval_rejected_subject</source>
<target state="translated">Twoja prośba została odrzucona</target>
</trans-unit>
<trans-unit id="U.JjI9R" resname="approval_rejected_message" xml:space="preserve">
<source>approval_rejected_message</source>
<target state="translated">Twoja prośba o autoryzację została odrzucona przez %created_by%.
%list%
Proszę sprawdzić swoje wnioski pod adresem: %url%</target>
</trans-unit>
<trans-unit id="YdGZyer" resname="approval_approved_subject" xml:space="preserve">
<source>approval_approved_subject</source>
<target state="translated">Twój wniosek został zaakceptowany</target>
</trans-unit>
<trans-unit id="4nn7Vi1" resname="approval_approved_message" xml:space="preserve">
<source>approval_approved_message</source>
<target state="translated">Twój wniosek został zaakceptowany przez %created_by%.
%list%
Wszystkie zapytania można znaleźć pod adresem: %url%</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -54,6 +54,42 @@ Please review them at: %url%</source>
Vui lòng xét duyệt tại: %url%</target>
</trans-unit>
<trans-unit id="1q.VHdU" resname="approval_requested_subject" xml:space="preserve">
<source>approval_requested_subject</source>
<target state="translated">Quyền cấp phép đã được yêu cầu:%user%</target>
</trans-unit>
<trans-unit id="Z172mQ8" resname="approval_requested_message" xml:space="preserve">
<source>approval_requested_message</source>
<target state="translated">Quyền cấp phép cho %user% đã được yêu cầu
%list%
Hãy xem qua tại:%url%</target>
</trans-unit>
<trans-unit id="dvPkiia" resname="approval_rejected_subject" xml:space="preserve">
<source>approval_rejected_subject</source>
<target state="translated">Yêu cầu của bạn đã bị từ chối</target>
</trans-unit>
<trans-unit id="U.JjI9R" resname="approval_rejected_message" xml:space="preserve">
<source>approval_rejected_message</source>
<target state="translated">Yêu cầu xác thực của bạn đã bị từ chối bởi %created_by%
%list%
Vui lòng xem qua yêu cầu của bạn tại: %url%</target>
</trans-unit>
<trans-unit id="YdGZyer" resname="approval_approved_subject" xml:space="preserve">
<source>approval_approved_subject</source>
<target state="translated">Yêu cầu của bạn đã được chấp nhận</target>
</trans-unit>
<trans-unit id="4nn7Vi1" resname="approval_approved_message" xml:space="preserve">
<source>approval_approved_message</source>
<target state="translated">Yêu cầu của bạn đã được chấp nhận bởi %created_by%
%list%
Bạn có thể tất cả truy vấn của bạn tại: %url%</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -54,6 +54,42 @@ Please review them at: %url%</source>
請在此審核:%url%</target>
</trans-unit>
<trans-unit id="1q.VHdU" resname="approval_requested_subject" xml:space="preserve">
<source>approval_requested_subject</source>
<target state="translated">待核准: %user%</target>
</trans-unit>
<trans-unit id="Z172mQ8" resname="approval_requested_message" xml:space="preserve">
<source>approval_requested_message</source>
<target state="translated">已為 %user% 提交審核請求。
%list%
請至以下連結審閱:%url%</target>
</trans-unit>
<trans-unit id="dvPkiia" resname="approval_rejected_subject" xml:space="preserve">
<source>approval_rejected_subject</source>
<target state="translated">您的請求已被拒絕</target>
</trans-unit>
<trans-unit id="YdGZyer" resname="approval_approved_subject" xml:space="preserve">
<source>approval_approved_subject</source>
<target state="translated">您的請求已獲批准</target>
</trans-unit>
<trans-unit id="4nn7Vi1" resname="approval_approved_message" xml:space="preserve">
<source>approval_approved_message</source>
<target state="translated">您的請求已獲 %created_by% 核准。
%list%
您可於以下連結查閱所有相關紀錄:%url%</target>
</trans-unit>
<trans-unit id="U.JjI9R" resname="approval_rejected_message" xml:space="preserve">
<source>approval_rejected_message</source>
<target state="translated">您的授權請求已被 %created_by% 拒絕。
%list%
請至以下連結檢視您的請求:%url%</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="ar" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target>ورقة التوقيت</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="bg" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig" xml:space="preserve">
<source>Timesheet</source>
<target state="translated">Работно време</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="ca" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig" xml:space="preserve">
<source>Timesheet</source>
<target state="translated">Registre d'hores</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="cs" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target state="translated">Pracovní výkaz</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="da" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig" xml:space="preserve">
<source>Timesheet</source>
<target state="translated">Timeoverblik</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="de" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target>Stundennachweis</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="de-CH" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target state="translated">Stundenzettel</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="el" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target>Χρονοδιάγραμμα</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="en" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target>Timesheet</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="eo" datatype="plaintext" original="export.en.xlf">
<body>
</body>
</file>
</xliff>

View File

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="es" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target state="translated">Parte de horas</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="et" datatype="plaintext" original="export.en.xlf">
<body>
</body>
</file>
</xliff>

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="eu" datatype="plaintext" original="export.en.xlf">
<body>
</body>
</file>
</xliff>

View File

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="fa" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target state="translated">تایم شیت</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="fi" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target state="translated">Kellokortti</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="fo" datatype="plaintext" original="export.en.xlf">
<body>
</body>
</file>
</xliff>

View File

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target>Feuille de temps</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="he" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target>גיליון שעות</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="hr" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig" xml:space="preserve">
<source>Timesheet</source>
<target state="translated">Vremenska tablica</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="hu" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig" xml:space="preserve">
<source>Timesheet</source>
<target state="translated">Munkaidő-nyilvántartás</target>
</trans-unit>
</body>
</file>
</xliff>

Some files were not shown because too many files have changed in this diff Show More