Compare commits

...

13 Commits
dev ... 2.50.0

Author SHA1 Message Date
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
202 changed files with 3936 additions and 1833 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

@@ -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); };

803
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

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": {
@@ -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",

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",

View File

@@ -17,11 +17,11 @@ final class Constants
/**
* The current release version
*/
public const VERSION = '2.47.0';
public const VERSION = '2.50.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 24700;
public const VERSION_ID = 25000;
/**
* 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

@@ -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>

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="id" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig" xml:space="preserve">
<source>Timesheet</source>
<target state="translated">Lembar Waktu</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="it" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig" approved="yes">
<source>Timesheet</source>
<target state="final">Foglio di presenza</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="ja" 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="ko" 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,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="lv" datatype="plaintext" original="export.en.xlf">
<body>
</body>
</file>
</xliff>

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