Compare commits

..

3 Commits

Author SHA1 Message Date
Kevin Papst
2d5cef1a2f phpstan 2024-10-12 00:00:46 +02:00
Kevin Papst
52a9ff9860 Merge branch 'refs/heads/main' into activity-report
# Conflicts:
#	config/packages/kimai.yaml
#	phpstan.neon
#	src/Activity/ActivityStatisticService.php
#	src/Repository/ActivityRepository.php
2024-10-11 23:45:40 +02:00
Kevin Papst
ecded0a065 added activity overview report 2023-11-19 16:10:32 +01:00
71 changed files with 1144 additions and 771 deletions

View File

@@ -13,19 +13,20 @@ try {
]);
} catch(\Exception $ex) {
switch ($ex->getCode()) {
// we can immediately stop startup here and show the error message
case 1045:
// we can immediately stop here and show the error message
echo 'Access denied (1045)';
die(1);
// we can immediately stop startup here and show the error message
case 1049:
// error "Unknown database (1049)" can be ignored, the database will be created by Kimai
return;
echo 'Unknown database (1049)';
die(2);
// a lot of errors share the same meaningless error code zero
case 0:
// this error includes the database name, so we can only search for the static part of the error message
if (stripos($ex->getMessage(), 'SQLSTATE[HY000] [1049] Unknown database') !== false) {
// error "Unknown database (1049)" can be ignored, the database will be created by Kimai
return;
echo 'Unknown database (0-1049)';
die(3);
}
switch ($ex->getMessage()) {
// eg. no response (fw) - the startup script should retry it a couple of times

View File

@@ -76,6 +76,7 @@ function handleStartup() {
function prepareKimai() {
# These are idempotent, so we can run them on every start-up
/opt/kimai/bin/console -n kimai:install
/opt/kimai/bin/console -n kimai:update
if [ ! -z "$ADMINPASS" ] && [ ! -a "$ADMINMAIL" ]; then
/opt/kimai/bin/console kimai:user:create admin "$ADMINMAIL" ROLE_SUPER_ADMIN "$ADMINPASS"
fi

18
.gitattributes vendored
View File

@@ -1,18 +0,0 @@
.docker export-ignore
.github export-ignore
assets export-ignore
tests export-ignore
.codecov.yml export-ignore
.editorconfig export-ignore
.eslintrc.js export-ignore
.gitattributes export-ignore
.gitignore export-ignore
php-cs-fixer.dist.php export-ignore
babel.config.js export-ignore
package.json export-ignore
php-cs-fixer.sh export-ignore
phpstan.neon export-ignore
phpstan.sh export-ignore
phpunit.xml.dist export-ignore
webpack.config.js export-ignore
yarn.lock export-ignore

1
.gitignore vendored
View File

@@ -18,7 +18,6 @@
# for keeping empty directories
/config/packages/local.yaml
/config/bundles-local.php
/var/dev/*
/var/data/*
/var/cache/*
/var/invoices*

View File

@@ -1,16 +1,15 @@
# Contributing
Kimai is an open source project, contributions made by the community are welcome.
But we can only accept contributions with a signed CLA (Contributor License Agreement) to prevent issues in the future (you will see a link when opening a PR).
Kimai is an open source project, contributions made by the community are welcome.
Send your ideas, code reviews, pull requests and feature requests to help to improve this project.
## Pull request rules
- Make your changes in a new git branch, based on the latest code in `main`
- Apply our code-style by running `composer codestyle-fix`
- Run the static code analysis with `composer phpstan`
- Verify everything still works with `composer tests-unit`
- Verify everything still works with `composer tests-unit` and `composer tests-integration`
- Add tests for your changes
- When sending in a PR, you must accept that your contributions/code will be published under MIT license (see the [LICENSE](LICENSE) file as well), otherwise your PR will be closed
- If one of the PR checks/builds fails, fix it before asking for a review
Further documentation can be found in the [developer documentation](https://www.kimai.org/documentation/developers.html).

View File

@@ -218,18 +218,20 @@ COPY --from=php-ext-intl /usr/local/lib/php/extensions/no-debug-non-zts-20230831
COPY --from=php-ext-opcache /usr/local/etc/php/conf.d/docker-php-ext-opcache.ini /usr/local/etc/php/conf.d/docker-php-ext-opcache.ini
###########################
# fetch Kimai sources
# Shared tools
###########################
FROM alpine:latest AS git-prod
# full kimai source
FROM alpine:latest AS git-dev
ARG KIMAI
ARG TIMEZONE
# the convention in the Kimai repository is: tags are always version numbers, branch names always start with a letter
# if the KIMAI variable starts with a number (e.g. 2.24.0) we assume its a tag, otherwise its a branch
RUN [[ $KIMAI =~ ^[0-9] ]] && export REF='tags' || export REF='heads' && \
wget -O "/opt/kimai.tar.gz" "https://github.com/kimai/kimai/archive/refs/${REF}/${KIMAI}.tar.gz" && \
tar -xpzf /opt/kimai.tar.gz -C /opt/ && \
mv /opt/kimai-${KIMAI} /opt/kimai
RUN apk add --no-cache git && \
git clone --depth 1 --branch ${KIMAI} https://github.com/kimai/kimai.git /opt/kimai
# production kimai source
FROM git-dev AS git-prod
WORKDIR /opt/kimai
RUN rm -r tests
###########################
# global base build
@@ -284,9 +286,10 @@ CMD [ "/entrypoint.sh" ]
# development build
FROM base AS dev
# copy kimai develop source
COPY --from=git-prod --chown=www-data:www-data /opt/kimai /opt/kimai
COPY --from=git-dev --chown=www-data:www-data /opt/kimai /opt/kimai
COPY .docker /assets
# do the composer deps installation
RUN echo \$PATH
RUN \
export COMPOSER_HOME=/composer && \
composer --no-ansi install --working-dir=/opt/kimai --optimize-autoloader && \
@@ -302,7 +305,7 @@ ENV APP_ENV=dev
ENV DATABASE_URL=
ENV memory_limit=512M
# the "prod" stage (production build) is configured as last stage in the file, as this is the default target in BuildKit
# production build
FROM base AS prod
# copy kimai production source
COPY --from=git-prod --chown=www-data:www-data /opt/kimai /opt/kimai

View File

@@ -7,4 +7,5 @@ As announced in the [README](README.md) security fixes will only be added to the
| main branch | :white_check_mark: |
| older releases | :x: |
You find all information in our [Bughunter documentation](https://www.kimai.org/documentation/bughunter.html).

0
assets/.gitignore vendored Normal file
View File

View File

@@ -104,7 +104,7 @@ kimai:
BILLABLE: ['edit_billable_own_timesheet','edit_billable_other_timesheet']
TEAMS: ['view_team','create_team','edit_team','delete_team']
LOCKDOWN: ['lockdown_grace_timesheet','lockdown_override_timesheet']
REPORTING: ['view_reporting','view_other_reporting','project_reporting','customer_reporting']
REPORTING: ['view_reporting','view_other_reporting','activity_reporting','project_reporting','customer_reporting']
EVERYONE: ['api_access','hours_own_profile']
# permissions which are deactivated, as these features are hidden for now
# brave users can try to activate them and be surprised what happens

View File

@@ -5,5 +5,7 @@ twig:
- 'form/vertical.html.twig'
exception_controller: null
paths:
# used in templates/emails/layout.html.twig
'%kernel.project_dir%/assets/css': css
'%kernel.project_dir%/templates/bundles/TablerBundle': theme
'%kernel.project_dir%/vendor/kevinpapst/tabler-bundle/templates': theme

116
kimai.sh
View File

@@ -1,116 +0,0 @@
#!/bin/bash
# --------------------------------------------------------------------------------
# This script was added with 2.24.0 and is in BETA status.
#
# To improve this script across platforms I need your feedback!
# --------------------------------------------------------------------------------
function update_kimai() {
if [[ "$1" =~ ^([0-9]+\.){2,3}[0-9]+$ ]]; then
export VERSION=$1
else
echo "You need to supply a full Kimai version like: \"2.24.0\""
exit 1
fi
git checkout -- composer.json
git checkout -- composer.lock
git checkout -- symfony.lock
if [[ -n $(git status --porcelain) ]]; then
echo "Cannot update: file changes detected. Run \"git status\" for details."
exit 1
fi
rm -rf var/cache/* 2>&1
git fetch --tags
git checkout "$VERSION"
$KIMAI_PHP "$KIMAI_COMPOSER" install --no-dev --optimize-autoloader
$KIMAI_PHP bin/console kimai:install
install_plugins
if [[ -z "${KIMAI_NO_PERMS}" ]]; then
set_permission
fi
}
function install_plugins() {
# detect if there are additional plugins that we need to install
packages="$($PHP bin/console kimai:plugin --composer)"
export PACKAGES=$packages
if [ -n "$PACKAGES" ]; then
$KIMAI_PHP "$KIMAI_COMPOSER" require "$PACKAGES"
$KIMAI_PHP bin/console kimai:plugins --install
fi
}
function set_permission() {
chown -R "$KIMAI_USER":"$KIMAI_GROUP" .
chmod -R g+r .
chmod -R g+rw var/
}
if [[ -z "${KIMAI_USER}" ]]; then
export KIMAI_USER=""
fi
if [[ -z "${KIMAI_GROUP}" ]]; then
export KIMAI_GROUP="www-data"
fi
if [[ -z "${KIMAI_PHP}" ]]; then
export KIMAI_PHP="php"
fi
if [[ -z "${KIMAI_COMPOSER}" ]]; then
export KIMAI_COMPOSER="composer"
fi
cd "$(dirname "$0")" || { echo "Cannot change working directory."; exit 1; }
# we need a few commands installed in order for this script to complete
command -v $KIMAI_COMPOSER >/dev/null 2>&1 || { echo >&2 "Update requires 'composer' but it's not installed."; exit 1; }
command -v git >/dev/null 2>&1 || { echo >&2 "Update requires 'git' but it's not installed."; exit 1; }
command -v $KIMAI_PHP >/dev/null 2>&1 || { echo >&2 "Update requires 'php' but it's not installed."; exit 1; }
if [[ -n $1 ]]; then
if [ "$1" == 'update' ]; then
update_kimai "$2"
exit
elif [ "$1" == 'permission' ]; then
set_permission
exit
elif [ "$1" == 'plugins' ]; then
install_plugins
exit
else
echo ""
echo ">> Unknown command: $1"
fi
fi
echo ""
echo "This script has the following sub-commands:"
echo ""
echo "$0 update <version> - Install Kimai version <version>"
echo "$0 permission - Fix file permissions"
echo "$0 plugins - Install plugins from var/packages/*.zip"
echo ""
echo "Use the following environment variables to customize the runtime:"
echo ""
echo "KIMAI_USER - Username of the webserver/php process that needs write access"
echo "KIMAI_GROUP - Group of the webserver/php process that needs write access"
echo "KIMAI_PHP - Full path to PHP executable in the correct version"
echo "KIMAI_COMPOSER - Path to composer executable or .phar file"
echo "KIMAI_NO_PERMS - Skip changing permissions"
echo ""
echo "Examples:"
echo ""
echo "$0 2.24.0"
echo "KIMAI_PHP=/usr/bin/php8.3 $0 2.24.0"
echo "KIMAI_PHP=/usr/bin/php8.3 KIMAI_COMPOSER=/tmp/composer.phar $0 2.24.0"
echo "KIMAI_PHP=php8.3 KIMAI_GROUP=httpd $0 2.24.0"
echo "KIMAI_NO_PERMS=1 KIMAI_PHP=/usr/bin/php8.3 $0 2.24.0"
echo ""

View File

@@ -329,6 +329,16 @@ parameters:
count: 4
path: src/Command/InstallCommand.php
-
message: "#^Cannot call method get\\(\\) on Symfony\\\\Component\\\\Console\\\\Helper\\\\HelperSet\\|null\\.$#"
count: 1
path: src/Command/InstallCommand.php
-
message: "#^Method App\\\\Command\\\\InstallCommand\\:\\:askConfirmation\\(\\) should return bool but returns mixed\\.$#"
count: 1
path: src/Command/InstallCommand.php
-
message: "#^Binary operation \"\\.\" between non\\-empty\\-string and non\\-empty\\-list\\<string\\>\\|string results in an error\\.$#"
count: 1
@@ -544,6 +554,11 @@ parameters:
count: 1
path: src/Command/TranslationCommand.php
-
message: "#^Cannot call method find\\(\\) on Symfony\\\\Component\\\\Console\\\\Application\\|null\\.$#"
count: 3
path: src/Command/UpdateCommand.php
-
message: "#^Method App\\\\Configuration\\\\LdapConfiguration\\:\\:getConnectionParameters\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
@@ -3654,6 +3669,11 @@ parameters:
count: 1
path: src/Plugin/PluginMetadata.php
-
message: "#^Parameter \\#2 \\$array of function array_key_exists expects array, mixed given\\.$#"
count: 1
path: src/Plugin/PluginMetadata.php
-
message: "#^Cannot access offset 'duration' on mixed\\.$#"
count: 3

View File

@@ -10,24 +10,34 @@
namespace App\Activity;
use App\Entity\Activity;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Event\ActivityBudgetStatisticEvent;
use App\Event\ActivityStatisticEvent;
use App\Model\ActivityBudgetStatisticModel;
use App\Model\ActivityStatistic;
use App\Reporting\ActivityView\ActivityViewModel;
use App\Reporting\ActivityView\ActivityViewQuery;
use App\Repository\ActivityRepository;
use App\Repository\Loader\ActivityLoader;
use App\Repository\TimesheetRepository;
use App\Timesheet\DateTimeFactory;
use DateTimeImmutable;
use DateTimeInterface;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
/**
* @final
*/
class ActivityStatisticService
{
public function __construct(private readonly TimesheetRepository $timesheetRepository, private readonly EventDispatcherInterface $dispatcher)
public function __construct(
private readonly TimesheetRepository $timesheetRepository,
private readonly ActivityRepository $activityRepository,
private readonly EventDispatcherInterface $dispatcher
)
{
}
@@ -197,4 +207,201 @@ class ActivityStatisticService
return $qb;
}
/**
* @return Activity[]
*/
public function findActivitiesForView(ActivityViewQuery $query): array
{
$user = $query->getUser();
$today = clone $query->getToday();
$qb = $this->activityRepository->createQueryBuilder('a');
$qb
->select('a')
->leftJoin('a.project', 'p')
->leftJoin('p.customer', 'c')
->andWhere($qb->expr()->eq('a.visible', true))
->andWhere(
$qb->expr()->orX(
$qb->expr()->isNull('a.project'),
$qb->expr()->andX(
$qb->expr()->eq('p.visible', true),
$qb->expr()->eq('c.visible', true),
$qb->expr()->orX(
$qb->expr()->isNull('p.end'),
$qb->expr()->gte('p.end', ':project_end')
)
)
)
)
->addGroupBy('a')
->setParameter('project_end', $today, Types::DATETIME_MUTABLE)
;
if ($query->getProject() !== null) {
$qb->andWhere($qb->expr()->eq('p', ':project'));
$qb->setParameter('project', $query->getProject()->getId());
}
if (!$query->isIncludeNoWork()) {
$qb
->leftJoin(Timesheet::class, 't', 'WITH', 'a.id = t.activity')
->andHaving($qb->expr()->gt('SUM(t.duration)', 0))
;
}
if ($query->isIncludeWithBudget()) {
$qb->andWhere(
$qb->expr()->orX(
$qb->expr()->gt('a.timeBudget', 0),
$qb->expr()->gt('a.budget', 0)
)
);
} elseif ($query->isIncludeWithoutBudget()) {
$qb->andWhere(
$qb->expr()->andX(
$qb->expr()->eq('a.timeBudget', 0),
$qb->expr()->eq('a.budget', 0)
)
);
}
$this->activityRepository->addPermissionCriteria($qb, $user);
/** @var Activity[] $activities */
$activities = $qb->getQuery()->getResult();
// pre-cache project objects instead of joining them
$loader = new ActivityLoader($this->activityRepository->createQueryBuilder('a')->getEntityManager());
$loader->loadResults($activities);
return $activities;
}
/**
* @param Activity[] $activities
* @return ActivityViewModel[]
*/
public function getActivityView(User $user, array $activities, DateTimeInterface $today): array
{
$factory = DateTimeFactory::createByUser($user);
$today = clone $today;
$startOfWeek = $factory->getStartOfWeek($today);
$endOfWeek = $factory->getEndOfWeek($today);
$startMonth = (clone $startOfWeek)->modify('first day of this month');
$endMonth = (clone $startOfWeek)->modify('last day of this month');
$activityView = [];
foreach ($activities as $activity) {
$activityView[$activity->getId()] = new ActivityViewModel($activity);
}
$budgetStats = $this->getBudgetStatisticModelForActivities($activities, $today);
foreach ($budgetStats as $model) {
$activityView[$model->getActivity()->getId()]->setBudgetStatisticModel($model);
}
$activityIds = array_keys($activityView);
$tplQb = $this->timesheetRepository->createQueryBuilder('t');
$tplQb
->select('IDENTITY(t.activity) AS id')
->addSelect('COUNT(t.id) as amount')
->addSelect('COALESCE(SUM(t.duration), 0) AS duration')
->addSelect('COALESCE(SUM(t.rate), 0) AS rate')
->andWhere($tplQb->expr()->in('t.activity', ':activity'))
->groupBy('t.activity')
->setParameter('activity', $activityIds)
;
$qb = clone $tplQb;
$qb->addSelect('MAX(t.date) as lastRecord');
/** @var array<int, array{id: int, amount: int, duration: int, rate: float, lastRecord: string}> $result */
$result = $qb->getQuery()->getScalarResult();
foreach ($result as $row) {
$activityView[$row['id']]->setDurationTotal($row['duration']);
$activityView[$row['id']]->setRateTotal($row['rate']);
$activityView[$row['id']]->setTimesheetCounter($row['amount']);
if ($row['lastRecord'] !== null) {
// might be the wrong timezone
$activityView[$row['id']]->setLastRecord($factory->createDateTime($row['lastRecord']));
}
}
// values for today
$qb = clone $tplQb;
$qb
->andWhere('DATE(t.date) = :start_date')
->setParameter('start_date', $today, Types::DATETIME_MUTABLE)
;
/** @var array<int, array{id: int, amount: int, duration: int, rate: float}> $result */
$result = $qb->getQuery()->getScalarResult();
foreach ($result as $row) {
$activityView[$row['id']]->setDurationDay($row['duration'] ?? 0);
}
// values for the current week
$qb = clone $tplQb;
$qb
->andWhere('DATE(t.date) BETWEEN :start_date AND :end_date')
->setParameter('start_date', $startOfWeek, Types::DATETIME_MUTABLE)
->setParameter('end_date', $endOfWeek, Types::DATETIME_MUTABLE)
;
/** @var array<int, array{id: int, amount: int, duration: int, rate: float}> $result */
$result = $qb->getQuery()->getScalarResult();
foreach ($result as $row) {
$activityView[$row['id']]->setDurationWeek($row['duration']);
}
// values for the current month
$qb = clone $tplQb;
$qb
->andWhere('DATE(t.date) BETWEEN :start_date AND :end_date')
->setParameter('start_date', $startMonth, Types::DATETIME_MUTABLE)
->setParameter('end_date', $endMonth, Types::DATETIME_MUTABLE)
;
/** @var array<int, array{id: int, amount: int, duration: int, rate: float}> $result */
$result = $qb->getQuery()->getScalarResult();
foreach ($result as $row) {
$activityView[$row['id']]->setDurationMonth($row['duration']);
}
$qb = clone $tplQb;
$qb
->addSelect('t.exported')
->addSelect('t.billable')
->addGroupBy('t.exported')
->addGroupBy('t.billable')
;
/** @var array<int, array{id: int, amount: int, duration: int, rate: float, exported: bool, billable: bool}> $result */
$result = $qb->getQuery()->getScalarResult();
foreach ($result as $row) {
/** @var ActivityViewModel $view */
$view = $activityView[$row['id']];
if ($row['billable'] === 1 && $row['exported'] === 1) {
$view->setBillableDuration($view->getBillableDuration() + $row['duration']);
$view->setBillableRate($view->getBillableRate() + $row['rate']);
} elseif ($row['billable'] === 1 && $row['exported'] === 0) {
$view->setBillableDuration($view->getBillableDuration() + $row['duration']);
$view->setBillableRate($view->getBillableRate() + $row['rate']);
$view->setNotExportedDuration($view->getNotExportedDuration() + $row['duration']);
$view->setNotExportedRate($view->getNotExportedRate() + $row['rate']);
$view->setNotBilledDuration($view->getNotBilledDuration() + $row['duration']);
$view->setNotBilledRate($view->getNotBilledRate() + $row['rate']);
} elseif ($row['billable'] === 0 && $row['exported'] === 0) {
$view->setNotExportedDuration($view->getNotExportedDuration() + $row['duration']);
$view->setNotExportedRate($view->getNotExportedRate() + $row['rate']);
}
// the last possible case $row['billable'] === 0 && $row['exported'] === 1 is extremely unlikely and not used
}
return array_values($activityView);
}
}

View File

@@ -11,13 +11,14 @@ namespace App\Command;
use App\Constants;
use Doctrine\DBAL\Connection;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
@@ -25,10 +26,10 @@ use Symfony\Component\Console\Style\SymfonyStyle;
*
* @codeCoverageIgnore
*/
#[AsCommand(name: 'kimai:install', description: 'Kimai installation command', aliases: ['kimai:update'])]
#[AsCommand(name: 'kimai:install')]
final class InstallCommand extends Command
{
public function __construct(private readonly Connection $connection)
public function __construct(private Connection $connection, private string $kernelEnvironment)
{
parent::__construct();
}
@@ -36,7 +37,8 @@ final class InstallCommand extends Command
protected function configure(): void
{
$this
->setHelp('This command will perform the installation steps to bootstrap the application, database and plugins.')
->setDescription('Basic installation for Kimai')
->setHelp('This command will perform the basic installation steps to get Kimai up and running.')
->addOption('no-cache', null, InputOption::VALUE_NONE, 'Skip cache re-generation')
;
}
@@ -45,23 +47,19 @@ final class InstallCommand extends Command
{
$io = new SymfonyStyle($input, $output);
$io->text('Start installation ...');
/** @var Application $application */
$application = $this->getApplication();
$environment = $application->getKernel()->getEnvironment();
$io->title('Kimai installation running ...');
// create the database, in case it is not yet existing
try {
// creates the database if it is not yet existing
$this->createDatabase($io, $output);
$this->createDatabase($io, $input, $output);
} catch (\Exception $ex) {
$io->error('Failed to create database: ' . $ex->getMessage());
return Command::FAILURE;
}
// bootstrap database ONLY via doctrine migrations, so all installation will have the correct and same state
try {
// bootstrap database ONLY via doctrine migrations, so all installation will have the same state
$this->importMigrations($io, $output);
} catch (\Exception $ex) {
$io->error('Failed to set migration status: ' . $ex->getMessage());
@@ -70,22 +68,12 @@ final class InstallCommand extends Command
}
if (!$input->getOption('no-cache')) {
// show manual steps in case this fails
$cacheResult = $this->rebuildCaches($environment, $io, $input, $output);
if ($cacheResult !== Command::SUCCESS) {
$io->warning(
[
'Please run the cache commands manually:',
'bin/console cache:clear --env=' . $environment . PHP_EOL .
'bin/console cache:warmup --env=' . $environment
]
);
}
// flush the cache, just to make sure ... and ignore result
$this->rebuildCaches($this->kernelEnvironment, $io, $input, $output);
}
$io->success(
\sprintf('Successfully installed %s version %s 🎉', Constants::SOFTWARE, Constants::VERSION)
\sprintf('Congratulations! Successfully installed %s version %s', Constants::SOFTWARE, Constants::VERSION)
);
return Command::SUCCESS;
@@ -93,13 +81,11 @@ final class InstallCommand extends Command
private function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output): int
{
$io->text('Rebuilding cache ...');
$io->text('Rebuilding your cache, please be patient ...');
$command = $this->getApplication()->find('cache:clear');
try {
if (0 !== $command->run(new ArrayInput(['--env' => $environment]), $output)) {
throw new \RuntimeException('Invalid file permissions?');
}
$command->run(new ArrayInput(['--env' => $environment]), $output);
} catch (\Exception $ex) {
$io->error('Failed to clear cache: ' . $ex->getMessage());
@@ -108,9 +94,7 @@ final class InstallCommand extends Command
$command = $this->getApplication()->find('cache:warmup');
try {
if (0 !== $command->run(new ArrayInput(['--env' => $environment]), $output)) {
throw new \RuntimeException('Invalid file permissions?');
}
$command->run(new ArrayInput(['--env' => $environment]), $output);
} catch (\Exception $ex) {
$io->error('Failed to warmup cache: ' . $ex->getMessage());
@@ -122,36 +106,55 @@ final class InstallCommand extends Command
private function importMigrations(SymfonyStyle $io, OutputInterface $output): void
{
$io->text('Creating database ...');
$command = $this->getApplication()->find('doctrine:migrations:migrate');
$cmdInput = new ArrayInput(['--allow-no-migration' => true]);
$cmdInput->setInteractive(false);
$result = $command->run($cmdInput, $output);
$command->run($cmdInput, $output);
if (0 !== $result) {
throw new \Exception('Failed updating database.');
}
$io->writeln('');
}
private function createDatabase(SymfonyStyle $io, OutputInterface $output): void
private function createDatabase(SymfonyStyle $io, InputInterface $input, OutputInterface $output): void
{
try {
if ($this->connection->isConnected()) {
// database exists: we can skip this step
$io->note(\sprintf('Database is existing and connection could be established'));
return;
}
} catch (\Exception $ex) {
// this means the database does not exist and the connection failed
if (!$this->askConfirmation($input, $output, \sprintf('Create the database "%s" (yes) or skip (no)?', $this->connection->getDatabase()), true)) {
throw new \Exception('Skipped database creation, aborting installation');
}
} catch (\Exception $exception) {
// this likely means that the database does not exist. the latest doctrine release
// changed the behavior: in previous version this code did not throw an exception.
}
$options = ['--if-not-exists' => true];
$command = $this->getApplication()->find('doctrine:database:create');
$cmdInput = new ArrayInput(['--if-not-exists' => true]);
$cmdInput->setInteractive(false);
$result = $command->run($cmdInput, $output);
$result = $command->run(new ArrayInput($options), $output);
if (0 !== $result) {
throw new \Exception('Failed creating database: check your DATABASE_URL.');
throw new \Exception('Failed creating database. Check your credentials in DATABASE_URL');
}
}
/**
* @param InputInterface $input
* @param OutputInterface $output
* @param string $question
* @param bool $default
* @return bool
*/
private function askConfirmation(InputInterface $input, OutputInterface $output, $question, $default = false): bool
{
/** @var QuestionHelper $questionHelper */
$questionHelper = $this->getHelperSet()->get('question');
$text = \sprintf('<info>%s (yes/no)</info> [<comment>%s</comment>]:', $question, $default ? 'yes' : 'no');
$question = new ConfirmationQuestion(' ' . $text . ' ', $default, '/^y|yes/i');
return $questionHelper->ask($input, $output, $question);
}
}

View File

@@ -9,130 +9,37 @@
namespace App\Command;
use App\Plugin\Package;
use App\Plugin\PackageManager;
use App\Plugin\Plugin;
use App\Plugin\PluginManager;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'kimai:plugins', description: 'Manage Kimai plugins')]
/**
* Command used to fetch plugin information.
*/
#[AsCommand(name: 'kimai:plugins')]
final class PluginCommand extends Command
{
public function __construct(
private readonly PluginManager $pluginManager,
private readonly PackageManager $packageManager
)
public function __construct(private PluginManager $plugins)
{
parent::__construct();
}
protected function configure(): void
{
$this->setHelp('Shows information about already installed plugins by default.');
$this->addOption('available', null, InputOption::VALUE_NONE, 'Show list of available plugins in ' . PackageManager::PACKAGE_DIR);
$this->addOption('composer', null, InputOption::VALUE_NONE, 'Dump list of available composer packages in ' . PackageManager::PACKAGE_DIR);
$this->addOption('install', null, InputOption::VALUE_NONE, 'Run plugins installer, previously installed via ./kimai.sh');
$this
->setDescription('Receive plugin information')
->setHelp('This command prints detailed plugin information.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
if ($input->getOption('available')) {
return $this->listPackages($io, $this->packageManager->getAvailablePackages());
} elseif ($input->getOption('composer')) {
return $this->listComposerPackages($io, $this->packageManager->getAvailablePackages());
} elseif ($input->getOption('install')) {
return $this->installPlugins($io, $output, $this->pluginManager->getPlugins());
}
return $this->listInstalledPlugins($io, $this->pluginManager->getPlugins());
}
/**
* @param Plugin[] $plugins
*/
private function installPlugins(SymfonyStyle $io, OutputInterface $output, array $plugins): int
{
foreach ($plugins as $plugin) {
$config = $plugin->getPath() . '/migrations/doctrine_migrations.yaml';
if (!file_exists($config)) {
$config = $plugin->getPath() . '/Migrations/doctrine_migrations.yaml';
if (!file_exists($config)) {
continue;
}
}
$command = $this->getApplication()?->find('doctrine:migrations:migrate');
if ($command === null) {
throw new \RuntimeException('Failed finding doctrine migrations command');
}
$cmdInput = new ArrayInput(['--allow-no-migration' => true, '--configuration' => $config]);
$cmdInput->setInteractive(false);
if (0 !== $command->run($cmdInput, $output)) {
$io->error('Failed to install bundle database: ' . $config);
}
}
return Command::SUCCESS;
}
/**
* @param Package[] $packages
*/
private function listComposerPackages(SymfonyStyle $io, array $packages): int
{
if (empty($packages)) {
return Command::SUCCESS;
}
$all = [];
foreach ($packages as $package) {
$all[] = $package->getMetadata()->getPackage();
}
$io->write(implode(' ', $all));
return Command::SUCCESS;
}
/**
* @param Package[] $packages
*/
private function listPackages(SymfonyStyle $io, array $packages): int
{
if (empty($packages)) {
$io->warning('No packages to install found');
return Command::SUCCESS;
}
$rows = [];
foreach ($packages as $package) {
$metadata = $package->getMetadata();
$rows[] = [
$metadata->getName(),
$metadata->getVersion(),
$metadata->getKimaiVersion(),
$metadata->getPackage(),
$package->getPackageFile()->getPathname(),
];
}
$io->table(['Name', 'Version', 'Requires', 'Package', 'Directory'], $rows);
return Command::SUCCESS;
}
/**
* @param array<Plugin> $plugins
*/
private function listInstalledPlugins(SymfonyStyle $io, array $plugins): int
{
$plugins = $this->plugins->getPlugins();
if (empty($plugins)) {
$io->warning('No plugins installed');

View File

@@ -0,0 +1,137 @@
<?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\Command;
use App\Constants;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception\ConnectionException;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Command used to update a Kimai installation.
*/
#[AsCommand(name: 'kimai:update')]
final class UpdateCommand extends Command
{
public function __construct(private Connection $connection, private string $kernelEnvironment)
{
parent::__construct();
}
protected function configure(): void
{
$this
->setDescription('Update your Kimai installation')
->setHelp('This command will execute all required steps to update your Kimai installation.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Kimai updates running ...');
$environment = $this->kernelEnvironment;
// make sure database is available, Kimai running and installed
try {
if (!$this->connection->createSchemaManager()->tablesExist(['kimai2_users', 'kimai2_timesheet'])) {
$io->error('Tables missing. Did you run the installer already?');
return Command::FAILURE;
}
if (!$this->connection->createSchemaManager()->tablesExist(['migration_versions'])) {
$io->error('Unknown migration status, aborting database update');
return Command::FAILURE;
}
} catch (ConnectionException $e) {
$io->error(['Database connection could not be established.', $e->getMessage()]);
return Command::FAILURE;
} catch (\Exception $ex) {
$io->error(['Failed to validate database.', $ex->getMessage()]);
return Command::FAILURE;
}
// execute latest doctrine migrations
try {
$command = $this->getApplication()->find('doctrine:migrations:migrate');
$cmdInput = new ArrayInput(['--allow-no-migration' => true]);
$cmdInput->setInteractive(false);
if (0 !== $command->run($cmdInput, $output)) {
throw new \RuntimeException('CRITICAL: problem when migrating database');
}
$io->writeln('');
} catch (\Exception $ex) {
$io->error($ex->getMessage());
return Command::FAILURE;
}
// flush the cache, in case values from the database are cached
$cacheResult = $this->rebuildCaches($environment, $io, $input, $output);
if ($cacheResult !== Command::SUCCESS) {
$io->warning(
[
\sprintf('Updated %s to version %s but the cache could not be rebuilt.', Constants::SOFTWARE, Constants::VERSION),
'Please run the cache commands manually:',
'bin/console cache:clear --env=' . $environment . PHP_EOL .
'bin/console cache:warmup --env=' . $environment
]
);
} else {
$io->success(
\sprintf('Congratulations! Successfully updated %s to version %s', Constants::SOFTWARE, Constants::VERSION)
);
}
return Command::SUCCESS;
}
private function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output): int
{
$io->text('Rebuilding your cache, please be patient ...');
$command = $this->getApplication()->find('cache:clear');
try {
if (0 !== $command->run(new ArrayInput(['--env' => $environment]), $output)) {
throw new \RuntimeException('Could not clear cache, missing permissions?');
}
} catch (\Exception $ex) {
$io->error($ex->getMessage());
return Command::FAILURE;
}
$command = $this->getApplication()->find('cache:warmup');
try {
if (0 !== $command->run(new ArrayInput(['--env' => $environment]), $output)) {
throw new \RuntimeException('Could not warmup cache, missing permissions?');
}
} catch (\Exception $ex) {
$io->error($ex->getMessage());
return Command::FAILURE;
}
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,57 @@
<?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\Controller\Reporting;
use App\Activity\ActivityStatisticService;
use App\Controller\AbstractController;
use App\Reporting\ActivityView\ActivityViewForm;
use App\Reporting\ActivityView\ActivityViewQuery;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
final class ActivityViewController extends AbstractController
{
#[Route(path: '/activity/project_view', name: 'report_activity_view', methods: ['GET', 'POST'])]
#[IsGranted('report:project')]
#[IsGranted(new Expression("is_granted('budget_any', 'activity')"))]
public function __invoke(Request $request, ActivityStatisticService $service): Response
{
$dateFactory = $this->getDateTimeFactory();
$user = $this->getUser();
$query = new ActivityViewQuery($dateFactory->createDateTime(), $user);
$form = $this->createFormForGetRequest(ActivityViewForm::class, $query);
$form->submit($request->query->all(), false);
$activities = $service->findActivitiesForView($query);
$entries = $service->getActivityView($user, $activities, $query->getToday());
$byCustomer = [];
foreach ($entries as $entry) {
$project = $entry->getActivity()->getProject();
$key = ($project === null || $project->getId() === null ? '__EMPTY__' : $project->getId());
if (!\array_key_exists($key, $byCustomer)) {
$byCustomer[$key] = ['project' => $project, 'activities' => [], 'name' => $project !== null ? $project->getName() : ''];
}
$byCustomer[$key]['activities'][] = $entry;
}
return $this->render('reporting/activity_view.html.twig', [
'entries' => $byCustomer,
'form' => $form->createView(),
'report_title' => 'report_activity_view',
'tableName' => 'activity_view_reporting',
'now' => $dateFactory->createDateTime(),
]);
}
}

View File

@@ -109,7 +109,7 @@ class Kernel extends BaseKernel
throw new \Exception(\sprintf('Bundle "%s" does not implement %s, which is not supported since 2.0.', $bundleName, PluginInterface::class));
}
$meta = PluginMetadata::createFromPath($fullPath);
$meta = new PluginMetadata($fullPath);
if ($meta->getKimaiVersion() > Constants::VERSION_ID) {
throw new \Exception(\sprintf('Bundle "%s" requires minimum Kimai version %s, but yours is lower: %s (%s). Please update Kimai or use a lower Plugin version.', $bundleName, $meta->getKimaiVersion(), Constants::VERSION, Constants::VERSION_ID));
@@ -169,8 +169,11 @@ class Kernel extends BaseKernel
$routes->import($configDir . '/routes/' . $this->environment . '/*.yaml');
}
// load application routes
$routes->import($configDir . '/routes.yaml');
foreach ($this->getBundles() as $bundle) {
if ($bundle instanceof PluginInterface || str_contains(\get_class($bundle), 'KimaiPlugin\\')) {
if (str_contains(\get_class($bundle), 'KimaiPlugin\\')) {
if (is_dir($bundle->getPath() . '/Resources/config/')) {
$routes->import($bundle->getPath() . '/Resources/config/routes' . self::CONFIG_EXTS);
} elseif (is_dir($bundle->getPath() . '/config/')) {
@@ -178,8 +181,5 @@ class Kernel extends BaseKernel
}
}
}
// load application routes as last one, so bundles cannot override application ones
$routes->import($configDir . '/routes.yaml');
}
}

View File

@@ -14,6 +14,17 @@ use Symfony\Component\HttpKernel\DependencyInjection\Extension;
abstract class AbstractPluginExtension extends Extension
{
protected function registerIcon(ContainerBuilder $container, string $name, string $icon): void
{
$container->setParameter(
'tabler_bundle.icons',
array_merge(
$container->getParameter('tabler_bundle.icons'),
[$name => $icon]
)
);
}
protected function registerBundleConfiguration(ContainerBuilder $container, array $configs): void
{
$bundleConfig = [$this->getAlias() => $configs];

View File

@@ -1,30 +0,0 @@
<?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\Plugin;
/**
* @internaö
*/
final class Package
{
public function __construct(private readonly \SplFileInfo $packageFile, private readonly PluginMetadata $pluginMetadata)
{
}
public function getPackageFile(): \SplFileInfo
{
return $this->packageFile;
}
public function getMetadata(): PluginMetadata
{
return $this->pluginMetadata;
}
}

View File

@@ -1,155 +0,0 @@
<?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\Plugin;
/**
* @internal
*/
final class PackageManager
{
public const PACKAGE_DIR = 'var/packages';
public function __construct(private readonly string $projectDirectory)
{
}
/**
* @return Package[]
*/
public function getAvailablePackages(): array
{
return $this->findAvailablePackages($this->projectDirectory . '/' . self::PACKAGE_DIR);
}
/**
* Copied from Composer\Repository\ArtifactRepository
* @see https://github.com/composer/composer/blob/main/src/Composer/Repository/ArtifactRepository.php
*
* @return Package[]
*/
private function findAvailablePackages(string $path): array
{
$packages = [];
$directory = new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::FOLLOW_SYMLINKS);
$iterator = new \RecursiveIteratorIterator($directory);
$regex = new \RegexIterator($iterator, '/^.+\.zip$/i');
/** @var \SplFileInfo $file */
foreach ($regex as $file) {
if (!$file->isFile()) {
continue;
}
$package = $this->getComposerJson($file->getPathname());
if ($package === null) {
continue;
}
$content = json_decode($package, true);
if (\JSON_ERROR_NONE !== json_last_error() || !\is_array($content)) {
throw new \RuntimeException('Failed to parse composer.json file in: ' . $file->getPathname());
}
$packages[] = new Package($file, PluginMetadata::createFromArray($content));
}
return $packages;
}
/**
* Copied from Composer\Util\Zip
* @see https://github.com/composer/composer/blob/main/src/Composer/Util/Zip.php
*/
private function getComposerJson(string $pathToZip): ?string
{
if (!\extension_loaded('zip')) {
throw new \RuntimeException('The Zip Util requires PHP\'s zip extension');
}
$zip = new \ZipArchive();
if ($zip->open($pathToZip) !== true) {
return null;
}
if (0 === $zip->numFiles) {
$zip->close();
return null;
}
$foundFileIndex = self::locateFile($zip, 'composer.json');
$content = null;
$configurationFileName = $zip->getNameIndex($foundFileIndex);
if ($configurationFileName !== false) {
$stream = $zip->getStream($configurationFileName);
if (false !== $stream) {
$content = stream_get_contents($stream);
if ($content === false) {
$content = null;
}
}
}
$zip->close();
return $content;
}
/**
* Copied from Composer\Util\Zip
* @see https://github.com/composer/composer/blob/main/src/Composer/Util/Zip.php
*/
private static function locateFile(\ZipArchive $zip, string $filename): int
{
// return root composer.json if it is there and is a file
if (false !== ($index = $zip->locateName($filename)) && $zip->getFromIndex($index) !== false) {
return $index;
}
$topLevelPaths = [];
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
if ($name === false) {
continue;
}
$dirname = \dirname($name);
// ignore OSX specific resource fork folder
if (strpos($name, '__MACOSX') !== false) {
continue;
}
// handle archives with proper TOC
if ($dirname === '.') {
$topLevelPaths[$name] = true;
if (\count($topLevelPaths) > 1) {
throw new \RuntimeException('Archive has more than one top level directories, and no composer.json was found on the top level, so it\'s an invalid archive. Top level paths found were: ' . implode(',', array_keys($topLevelPaths)));
}
continue;
}
// handle archives which do not have a TOC record for the directory itself
if (false === strpos($dirname, '\\') && false === strpos($dirname, '/')) {
$topLevelPaths[$dirname . '/'] = true;
if (\count($topLevelPaths) > 1) {
throw new \RuntimeException('Archive has more than one top level directories, and no composer.json was found on the top level, so it\'s an invalid archive. Top level paths found were: ' . implode(',', array_keys($topLevelPaths)));
}
}
}
if ($topLevelPaths && false !== ($index = $zip->locateName(key($topLevelPaths) . $filename)) && $zip->getFromIndex($index) !== false) {
return $index;
}
throw new \RuntimeException('No composer.json found either at the top level or within the topmost directory');
}
}

View File

@@ -20,7 +20,7 @@ final class Plugin
public function getMetadata(): PluginMetadata
{
if ($this->metadata === null) {
$this->metadata = PluginMetadata::createFromPath($this->getPath());
$this->metadata = new PluginMetadata($this->getPath());
}
return $this->metadata;
@@ -33,7 +33,12 @@ final class Plugin
public function getName(): string
{
return $this->getMetadata()->getName();
$meta = $this->getMetadata();
if ($meta->getName() !== null) {
return $meta->getName();
}
return $this->getId();
}
public function getId(): string

View File

@@ -13,79 +13,57 @@ use App\Constants;
class PluginMetadata
{
private string $package;
private string $version;
private int $kimaiVersion;
private string $homepage;
private string $description;
private string $name;
private ?string $version = null;
private ?int $kimaiVersion = null;
private ?string $homepage = null;
private ?string $description = null;
private ?string $name = null;
public static function createFromPath(string $path): self
/**
* @throws \Exception
*/
public function __construct(string $path)
{
if (!is_dir($path) || !is_readable($path)) {
throw new \Exception(\sprintf('Bundle directory "%s" cannot be accessed.', $path));
}
$pluginName = basename($path);
$composer = $path . '/composer.json';
if (!file_exists($composer) || !is_readable($composer)) {
throw new \Exception('Bundle does not ship composer.json, which is required since 2.0.');
throw new \Exception(\sprintf('Bundle "%s" does not ship composer.json, which is required since 2.0.', $pluginName));
}
/** @var array<mixed>|null $json */
$json = json_decode(file_get_contents($composer), true);
if ($json === null) {
throw new \Exception('Could not parse composer.json, invalid JSON?');
}
return self::createFromArray($json);
}
/**
* @param array<mixed> $json
*/
public static function createFromArray(array $json): self
{
if (!\array_key_exists('extra', $json)) {
throw new \Exception('Bundle "%s" does not define an "extra" node in composer.json, which is required since 2.0.');
throw new \Exception(\sprintf('Bundle "%s" does not define an "extra" node in composer.json, which is required since 2.0.', $pluginName));
}
if (!\array_key_exists('kimai', $json['extra'])) {
throw new \Exception('Bundle does not define the "extra.kimai" node in composer.json, which is required since 2.0.');
throw new \Exception(\sprintf('Bundle "%s" does not define the "extra.kimai" node in composer.json, which is required since 2.0.', $pluginName));
}
if (!\array_key_exists('require', $json['extra']['kimai'])) {
throw new \Exception('Bundle does not define the minimum Kimai version in "extra.kimai.required" in composer.json, which is required since 2.0.');
throw new \Exception(\sprintf('Bundle "%s" does not define the minimum Kimai version in "extra.kimai.required" in composer.json, which is required since 2.0.', $pluginName));
}
if (!\array_key_exists('name', $json['extra']['kimai'])) {
throw new \Exception('Bundle does not define its name in "extra.kimai.name" in composer.json, which is required since 2.0.');
throw new \Exception(\sprintf('Bundle "%s" does not define its name in "extra.kimai.name" in composer.json, which is required since 2.0.', $pluginName));
}
if (!\is_int($json['extra']['kimai']['require'])) {
throw new \Exception('Bundle defines an invalid Kimai minimum version in extra.kimai.require. Please provide an integer as in Constants::VERSION_ID.');
throw new \Exception(\sprintf('Bundle "%s" defines an invalid Kimai minimum version in extra.kimai.require. Please provide an integer as in Constants::VERSION_ID.', $pluginName));
}
$meta = new self();
$meta->package = $json['name'] ?? '';
$meta->description = $json['description'] ?? '';
$meta->homepage = $json['homepage'] ?? Constants::HOMEPAGE . '/store/';
$meta->name = $json['extra']['kimai']['name'];
$meta->kimaiVersion = $json['extra']['kimai']['require'];
$this->description = $json['description'] ?? '';
$this->homepage = $json['homepage'] ?? Constants::HOMEPAGE . '/store/';
$this->name = $json['extra']['kimai']['name'];
$this->kimaiVersion = $json['extra']['kimai']['require'];
// the version field is required if we use composer to install a plugin via var/packages/
$meta->version = $json['extra']['kimai']['version'] ?? ($json['version'] ?? 'unknown');
return $meta;
}
private function __construct() {}
public function getPackage(): string
{
return $this->package;
$this->version = $json['extra']['kimai']['version'] ?? ($json['version'] ?? 'unknown');
}
public function getDescription(): ?string
@@ -93,22 +71,22 @@ class PluginMetadata
return $this->description;
}
public function getVersion(): string
public function getVersion(): ?string
{
return $this->version;
}
public function getKimaiVersion(): int
public function getKimaiVersion(): ?int
{
return $this->kimaiVersion;
}
public function getHomepage(): string
public function getHomepage(): ?string
{
return $this->homepage;
}
public function getName(): string
public function getName(): ?string
{
return $this->name;
}

View File

@@ -0,0 +1,55 @@
<?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\Reporting\ActivityView;
use App\Form\Type\ProjectType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractType<ActivityViewQuery>
*/
final class ActivityViewForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('project', ProjectType::class, [
'required' => false,
'width' => false,
]);
$builder->add('budgetType', ChoiceType::class, [
'label' => false,
'required' => false,
'placeholder' => null,
'expanded' => true,
'choices' => [
'all' => null,
'includeWithBudget' => true,
'includeNoBudget' => false
]
]);
$builder->add('includeNoWork', CheckboxType::class, [
'required' => false,
'label' => 'includeNoWork',
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => ActivityViewQuery::class,
'csrf_protection' => false,
'method' => 'GET',
]);
}
}

View File

@@ -0,0 +1,181 @@
<?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\Reporting\ActivityView;
use App\Entity\Activity;
use App\Model\BudgetStatisticModelInterface;
use DateTime;
final class ActivityViewModel
{
private int $timesheetCounter = 0;
private int $durationDay = 0;
private int $durationWeek = 0;
private int $durationMonth = 0;
private int $durationTotal = 0;
private float $rateTotal = 0.00;
private int $notExportedDuration = 0;
private float $notExportedRate = 0.00;
private int $notBilledDuration = 0;
private float $notBilledRate = 0.00;
private int $billableDuration = 0;
private float $billableRate = 0.00;
private ?DateTime $lastRecord = null;
private ?BudgetStatisticModelInterface $budgetStatisticModel = null;
public function __construct(private readonly Activity $activity)
{
}
public function getActivity(): Activity
{
return $this->activity;
}
public function getTimesheetCounter(): int
{
return $this->timesheetCounter;
}
public function setTimesheetCounter(int $timesheetCounter): void
{
$this->timesheetCounter = $timesheetCounter;
}
public function getDurationDay(): int
{
return $this->durationDay;
}
public function setDurationDay(int $durationDay): void
{
$this->durationDay = $durationDay;
}
public function getDurationWeek(): int
{
return $this->durationWeek;
}
public function setDurationWeek(int $durationWeek): void
{
$this->durationWeek = $durationWeek;
}
public function getDurationMonth(): int
{
return $this->durationMonth;
}
public function setDurationMonth(int $durationMonth): void
{
$this->durationMonth = $durationMonth;
}
public function getDurationTotal(): int
{
return $this->durationTotal;
}
public function setDurationTotal(int $durationTotal): void
{
$this->durationTotal = $durationTotal;
}
public function getNotExportedDuration(): int
{
return $this->notExportedDuration;
}
public function setNotExportedDuration(int $notExportedDuration): void
{
$this->notExportedDuration = $notExportedDuration;
}
public function getNotExportedRate(): float
{
return $this->notExportedRate;
}
public function setNotExportedRate(float $notExportedRate): void
{
$this->notExportedRate = $notExportedRate;
}
public function getNotBilledDuration(): int
{
return $this->notBilledDuration;
}
public function setNotBilledDuration(int $notBilledDuration): void
{
$this->notBilledDuration = $notBilledDuration;
}
public function getNotBilledRate(): float
{
return $this->notBilledRate;
}
public function setNotBilledRate(float $notBilledRate): void
{
$this->notBilledRate = $notBilledRate;
}
public function getBillableDuration(): int
{
return $this->billableDuration;
}
public function setBillableDuration(int $billableDuration): void
{
$this->billableDuration = $billableDuration;
}
public function getBillableRate(): float
{
return $this->billableRate;
}
public function setBillableRate(float $billableRate): void
{
$this->billableRate = $billableRate;
}
public function getRateTotal(): float
{
return $this->rateTotal;
}
public function setRateTotal(float $rateTotal): void
{
$this->rateTotal = $rateTotal;
}
public function getLastRecord(): ?DateTime
{
return $this->lastRecord;
}
public function setLastRecord(DateTime $lastRecord): void
{
$this->lastRecord = $lastRecord;
}
public function getBudgetStatisticModel(): ?BudgetStatisticModelInterface
{
return $this->budgetStatisticModel;
}
public function setBudgetStatisticModel(BudgetStatisticModelInterface $budgetStatisticModel): void
{
$this->budgetStatisticModel = $budgetStatisticModel;
}
}

View File

@@ -0,0 +1,77 @@
<?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\Reporting\ActivityView;
use App\Entity\Project;
use App\Entity\User;
final class ActivityViewQuery
{
private ?Project $project = null;
private bool $includeNoWork = false;
private ?bool $budgetType = true;
public function __construct(private readonly \DateTimeInterface $today, private readonly User $user)
{
}
public function getUser(): User
{
return $this->user;
}
public function getBudgetType(): ?bool
{
return $this->budgetType;
}
/**
* @internal
*/
public function setBudgetType(?bool $budgetType): void
{
$this->budgetType = $budgetType;
}
public function isIncludeWithoutBudget(): bool
{
return $this->budgetType === false;
}
public function isIncludeWithBudget(): bool
{
return $this->budgetType === true;
}
public function isIncludeNoWork(): bool
{
return $this->includeNoWork;
}
public function setIncludeNoWork(bool $includeNoWork): void
{
$this->includeNoWork = $includeNoWork;
}
public function getProject(): ?Project
{
return $this->project;
}
public function setProject(Project $project): void
{
$this->project = $project;
}
public function getToday(): \DateTimeInterface
{
return $this->today;
}
}

View File

@@ -41,6 +41,12 @@ final class ReportingService
$event->addReport(new Report('yearly_users_list', 'report_yearly_users', 'report_yearly_users', 'users'));
}
if ($this->security->isGranted('report:activity')) {
if ($viewOther) {
$event->addReport(new Report('activity_view', 'report_activity_view', 'report_activity_view', 'activity'));
}
}
if ($this->security->isGranted('report:project')) {
if ($this->security->isGranted('details', 'project')) {
$event->addReport(new Report('project_details', 'report_project_details', 'report_project_details', 'project'));

View File

@@ -94,7 +94,7 @@ class ActivityRepository extends EntityRepository
/**
* @param array<Team> $teams
*/
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [], bool $globalsOnly = false): void
public function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [], bool $globalsOnly = false): void
{
$permissions = $this->getPermissionCriteria($qb, $user, $teams, $globalsOnly);
if ($permissions->count() > 0) {

View File

@@ -22,6 +22,7 @@ final class ReportingVoter extends Voter
private const ALLOWED_ATTRIBUTES = [
'report:customer',
'report:other',
'report:activity',
'report:project',
'report:user',
];
@@ -65,6 +66,10 @@ final class ReportingVoter extends Voter
$permissions[] = 'view_other_timesheet';
break;
case 'report:activity':
$permissions[] = 'activity_reporting';
break;
case 'report:project':
$permissions[] = 'project_reporting';
break;

View File

@@ -1,4 +1,4 @@
{% apply inky_to_html|inline_css(source('emails/css/emails.css')) %}
{% apply inky_to_html|inline_css(source('@css/emails.css')) %}
<spacer size="32"></spacer>

View File

@@ -0,0 +1,96 @@
{% extends 'reporting/layout.html.twig' %}
{% import "macros/datatables.html.twig" as tables %}
{% set availableColumns = {
'name': {'class': 'alwaysVisible'},
} %}
{% if is_granted('budget_time', 'activity') %}
{% set availableColumns = availableColumns|merge({
'timeBudget': {'class': 'd-none d-md-table-cell', 'title': 'timeBudget'|trans},
}) %}
{% endif %}
{% if is_granted('budget_money', 'activity') %}
{% set availableColumns = availableColumns|merge({
'budget': {'class': 'd-none d-md-table-cell', 'title': 'budget'|trans},
}) %}
{% endif %}
{% set availableColumns = availableColumns|merge({
'durationTotal': {'class': 'text-end hw-min w-min', 'title': 'stats.durationTotal'|trans, 'columnClass': 'w-min'},
'actions': {'class': 'actions alwaysVisible'},
}) %}
{% set tableName = tableName|default('activity_view_reporting') %}
{% set skipColumns = skipColumns is defined ? skipColumns : {} %}
{% set columns = {} %}
{% for name, config in availableColumns %}
{% if name not in skipColumns %}
{% set columns = columns|merge({(name): config}) %}
{% endif %}
{% endfor %}
{% block main_before %}
{{ tables.data_table_column_modal(tableName, columns) }}
{% endblock %}
{% block report %}
{% set hasData = entries|length > 0 %}
{% embed '@theme/embeds/card.html.twig' %}
{% import "macros/progressbar.html.twig" as progress %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "macros/datatables.html.twig" as tables %}
{% import "activity/actions.html.twig" as activityActions %}
{% block box_body_class %}{{ tableName }}-box {% if hasData %}p-0{% endif %}{% endblock %}
{% block box_body %}
{% if not hasData %}
{{ widgets.nothing_found() }}
{% else %}
{{ tables.datatable_header(tableName, columns, null, {boxClass: ''}) }}
{% for id, mapping in entries|sort((a, b) => a.name <=> b.name) %}
{% if mapping.project is not null %}
<tr class="summary">
<td colspan="{{ columns|length }}">
{{ widgets.label_customer(mapping.project) }}
</td>
</tr>
{% endif %}
{% for entry in mapping.activities|sort((a, b) => a.activity.name <=> b.activity.name) %}
{% set activity = entry.activity %}
{% set project = activity.project %}
{% set budgetStats = entry.getBudgetStatisticModel() %}
{% set currency = null %}
{% if project is not null %}
{% set currency = project.customer.currency %}
{% endif %}
<tr {{ widgets.activity_row_attr(activity, now) }}>
{% for name, column_config in columns %}
<td class="{{ tables.data_table_column_class(tableName, columns, name) }}">
{% if name == 'name' %}
{{ widgets.label_activity(activity) }}
{% elseif name == 'durationTotal' %}
{{ entry.durationTotal|duration }}
{% elseif name == 'timeBudget' %}
{% if budgetStats.hasTimeBudget() and is_granted('time', activity) %}
{{ progress.progressbar_timebudget(budgetStats) }}
{% endif %}
{% elseif name == 'budget' %}
{% if activity.hasBudget() and is_granted('budget', activity) %}
{{ progress.progressbar_budget(budgetStats, currency) }}
{% endif %}
{% elseif name == 'comment' %}
{{ activity.comment }}
{% elseif name == 'actions' %}
{{ activityActions.activity(activity, 'custom') }}
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
{% endfor %}
{{ tables.data_table_footer(entries) }}
{% endif %}
{% endblock %}
{% endembed %}
{% endblock %}

View File

@@ -0,0 +1,21 @@
{% extends 'reporting/activity_list_data.html.twig' %}
{% block report_form_layout %}
{{ form_widget(form.project, {'label': false, 'placeholder': 'please_choose'}) }}
<div class="dropdown">
<button type="button" class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{{ icon('filter', true) }}
</button>
<ul class="dropdown-menu checkbox-menu">
{% for option in form.budgetType.children %}
<li class="dropdown-item">
{{ form_widget(option) }}
</li>
{% endfor %}
<li class="dropdown-divider"></li>
<li class="dropdown-item">
{{ form_widget(form.includeNoWork) }}
</li>
</ul>
</div>
{% endblock %}

View File

@@ -29,7 +29,8 @@ class InstallCommandTest extends KernelTestCase
$container = self::$kernel->getContainer();
$this->application->add(new InstallCommand(
$container->get('doctrine')->getConnection()
$container->get('doctrine')->getConnection(),
$this->application->getKernel()->getEnvironment()
));
}

View File

@@ -10,7 +10,6 @@
namespace App\Tests\Command;
use App\Command\PluginCommand;
use App\Plugin\PackageManager;
use App\Plugin\PluginInterface;
use App\Plugin\PluginManager;
use Symfony\Bundle\FrameworkBundle\Console\Application;
@@ -42,7 +41,7 @@ class PluginCommandTest extends KernelTestCase
{
$kernel = self::bootKernel();
$this->application = new Application($kernel);
$this->application->add(new PluginCommand(new PluginManager($plugins), new PackageManager(__DIR__ . '/../../')));
$this->application->add(new PluginCommand(new PluginManager($plugins)));
$command = $this->application->find('kimai:plugins');
$commandTester = new CommandTester($command);

View File

@@ -0,0 +1,63 @@
<?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\Command;
use App\Command\UpdateCommand;
use App\Constants;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
/**
* @covers \App\Command\UpdateCommand
* @group integration
*/
class UpdateCommandTest extends KernelTestCase
{
private Application $application;
protected function getCommand(): Command
{
$kernel = self::bootKernel();
$this->application = new Application($kernel);
$container = self::$kernel->getContainer();
$this->application->add(new UpdateCommand(
$container->get('doctrine')->getConnection(),
$this->application->getKernel()->getEnvironment()
));
return $this->application->find('kimai:update');
}
public function testFullRun(): void
{
$command = $this->getCommand();
$commandTester = new CommandTester($command);
$commandTester->setInputs(['no']);
$commandTester->execute([
'command' => $command->getName(),
]);
$result = $commandTester->getDisplay();
self::assertStringContainsString('Kimai updates running', $result);
// make sure migrations run always
self::assertStringContainsString('[OK] Already at the latest version ("DoctrineMigrations\\', $result);
self::assertStringContainsString(
\sprintf('[OK] Congratulations! Successfully updated Kimai to version %s', Constants::VERSION),
$result
);
self::assertEquals(0, $commandTester->getStatusCode());
}
}

View File

@@ -20,8 +20,8 @@ class PluginMetadataTest extends TestCase
public function testNonExistingDirectoryThrowsException(): void
{
$this->expectException(\Exception::class);
$this->expectExceptionMessage('Bundle does not ship composer.json, which is required since 2.0.');
$this->expectExceptionMessage('Bundle "Plugin" does not ship composer.json, which is required since 2.0.');
PluginMetadata::createFromPath(__DIR__);
new PluginMetadata(__DIR__);
}
}

View File

@@ -729,6 +729,11 @@ parameters:
count: 1
path: Command/PromoteUserCommandTest.php
-
message: "#^Cannot call method getConnection\\(\\) on object\\|null\\.$#"
count: 1
path: Command/UpdateCommandTest.php
-
message: "#^Method App\\\\Tests\\\\Command\\\\VersionCommandTest\\:\\:getCommandTester\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
count: 1

View File

@@ -24,19 +24,19 @@
</trans-unit>
<trans-unit id="1ZLD_Y0" resname="reset.subject" xml:space="preserve">
<source>reset.subject</source>
<target state="translated">Přihlaste se ke službě Kimai</target>
<target state="needs-translation">Reset hesla</target>
</trans-unit>
<trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve">
<source>Forgot your password?</source>
<target state="translated">Přihlaste se ke svému účtu</target>
<target state="needs-translation">Zapomněli jste heslo?</target>
</trans-unit>
<trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve">
<source>reset.button</source>
<target state="translated">Přihlásit se</target>
<target state="needs-translation">Reset hesla</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="translated">Tento odkaz bude platný pouze po dobu následujících 15 minut.</target>
<target state="needs-translation">Nebojte se, Kimai vám pomůže vytvořit nové!</target>
</trans-unit>
<trans-unit id="yohIAA_" resname="absence_created_supervisor_subject" xml:space="preserve">
<source>absence_created_supervisor_subject</source>

View File

@@ -24,19 +24,19 @@
</trans-unit>
<trans-unit id="1ZLD_Y0" resname="reset.subject" xml:space="preserve">
<source>reset.subject</source>
<target state="translated">Iniciar sesión en Kimai</target>
<target state="needs-translation">Restablecer su contraseña</target>
</trans-unit>
<trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve">
<source>Forgot your password?</source>
<target state="translated">Inicia sesión en tu cuenta</target>
<target state="needs-translation">¿Olvidaste tu contraseña?</target>
</trans-unit>
<trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve">
<source>reset.button</source>
<target state="translated">Iniciar sesión</target>
<target state="needs-translation">Restablecer su contraseña</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="translated">Este enlace solo será válido durante los próximos 15 minutos.</target>
<target state="needs-translation">¡No te preocupes, Kimai te ayudará a crear uno nuevo!</target>
</trans-unit>
<trans-unit id="yohIAA_" resname="absence_created_supervisor_subject">
<source>absence_created_supervisor_subject</source>

View File

@@ -22,21 +22,21 @@
<source>You signed up at the Kimai time-tracker with the e-mail address %email%. Please activate your account in the next few hours, before the link expires.</source>
<target state="translated">נרשמת למערכת דיווח השעות Kimai עם כתובת הדוא״ל %email%. נא להפעיל את החשבון בשעות הקרובות לפני שיפוג תוקף הקישור.</target>
</trans-unit>
<trans-unit id="1ZLD_Y0" resname="reset.subject" xml:space="preserve" approved="yes">
<trans-unit id="1ZLD_Y0" resname="reset.subject" xml:space="preserve">
<source>reset.subject</source>
<target state="final">כניסה ל־Kimai</target>
<target state="needs-translation">איפוס הסיסמה שלך</target>
</trans-unit>
<trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve" approved="yes">
<trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve" approved="no">
<source>Forgot your password?</source>
<target state="final">כניסה לחשבון שלך</target>
<target state="needs-translation">שכחת את הסיסמה שלך?</target>
</trans-unit>
<trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve" approved="yes">
<trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve" approved="no">
<source>reset.button</source>
<target state="final">כניסה</target>
<target state="needs-translation">איפוס הסיסמה שלך</target>
</trans-unit>
<trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve" approved="yes">
<trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve" approved="no">
<source>Don't worry, Kimai will help you create a new one!</source>
<target state="final">הקישור הזה יהיה תקף למשך 15 הדקות הקרובות בלבד.</target>
<target state="needs-translation">אל דאגה, Kimai יעזור לך ליצור סיסמה חדשה!</target>
</trans-unit>
<trans-unit id="yohIAA_" resname="absence_created_supervisor_subject" xml:space="preserve" approved="yes">
<source>absence_created_supervisor_subject</source>

View File

@@ -24,19 +24,19 @@
</trans-unit>
<trans-unit id="1ZLD_Y0" resname="reset.subject" xml:space="preserve">
<source>reset.subject</source>
<target state="translated">Kimai'de oturum açın</target>
<target state="needs-translation">Parolanızı sıfırlayın</target>
</trans-unit>
<trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve">
<source>Forgot your password?</source>
<target state="translated">Hesabınızda oturum açın</target>
<target state="needs-translation">Parolanızı mı unuttunuz?</target>
</trans-unit>
<trans-unit id="qbt.84Y" resname="reset.button" xml:space="preserve">
<source>reset.button</source>
<target state="translated">Oturum aç</target>
<target state="needs-translation">Parolanızı sıfırlayın</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="translated">Bu bağlantı yalnızca önümüzdeki 15 dakika boyunca geçerli olacaktır.</target>
<target state="needs-translation">Endişelenmeyin, Kimai yeni bir tane oluşturmanıza yardım edecek!</target>
</trans-unit>
<trans-unit id="yohIAA_" resname="absence_created_supervisor_subject">
<source>absence_created_supervisor_subject</source>

View File

@@ -180,7 +180,7 @@
</trans-unit>
<trans-unit id="qbD0dHa" resname="timeBudget" xml:space="preserve">
<source>timeBudget</source>
<target state="translated">Hodinová kvóta</target>
<target state="needs-translation">Hodinový rozpočet</target>
</trans-unit>
<trans-unit id="BlGGO.X" resname="activity">
<source>activity</source>
@@ -1790,14 +1790,6 @@
<source>Updated</source>
<target state="translated">Aktualizováno</target>
</trans-unit>
<trans-unit id="0jvo0RQ" resname="profit" xml:space="preserve">
<source>profit</source>
<target state="translated">Zisk</target>
</trans-unit>
<trans-unit id="vN5pKOU" resname="costs" xml:space="preserve">
<source>costs</source>
<target state="translated">Náklady</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -196,7 +196,7 @@
</trans-unit>
<trans-unit id="qbD0dHa" resname="timeBudget" xml:space="preserve">
<source>timeBudget</source>
<target state="translated">Precio por hora</target>
<target state="needs-translation">Presupuesto de tiempo</target>
</trans-unit>
<trans-unit id="BlGGO.X" resname="activity">
<source>activity</source>
@@ -1790,18 +1790,6 @@
<source>hours_per_day</source>
<target state="translated">Horas diarias</target>
</trans-unit>
<trans-unit id="0jvo0RQ" resname="profit" xml:space="preserve">
<source>profit</source>
<target state="translated">Ganancia</target>
</trans-unit>
<trans-unit id="vN5pKOU" resname="costs" xml:space="preserve">
<source>costs</source>
<target state="translated">Costes</target>
</trans-unit>
<trans-unit id="FOvlalA" resname="break" xml:space="preserve">
<source>break</source>
<target state="translated">Pausa</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -218,9 +218,9 @@
<source>budget</source>
<target>תקציב</target>
</trans-unit>
<trans-unit id="qbD0dHa" resname="timeBudget" xml:space="preserve" approved="yes">
<trans-unit id="qbD0dHa" resname="timeBudget" xml:space="preserve">
<source>timeBudget</source>
<target state="final">מכסה שעתית</target>
<target state="needs-translation">תקציב זמן</target>
</trans-unit>
<trans-unit id="BlGGO.X" resname="activity">
<source>activity</source>
@@ -1790,18 +1790,6 @@
<source>work_hours_mode</source>
<target state="final">חישוב זמן עבודה</target>
</trans-unit>
<trans-unit id="vN5pKOU" resname="costs" xml:space="preserve" approved="yes">
<source>costs</source>
<target state="final">עלויות</target>
</trans-unit>
<trans-unit id="0jvo0RQ" resname="profit" xml:space="preserve" approved="yes">
<source>profit</source>
<target state="final">רווח</target>
</trans-unit>
<trans-unit id="FOvlalA" resname="break" xml:space="preserve" approved="yes">
<source>break</source>
<target state="final">הפסקה</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -1798,10 +1798,6 @@
<source>costs</source>
<target state="final">Costi</target>
</trans-unit>
<trans-unit id="FOvlalA" resname="break" xml:space="preserve" approved="yes">
<source>break</source>
<target state="final">Pausa</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -196,7 +196,7 @@
</trans-unit>
<trans-unit id="qbD0dHa" resname="timeBudget" xml:space="preserve">
<source>timeBudget</source>
<target state="translated">Uur quota</target>
<target state="needs-translation">Tijdsbegroting</target>
</trans-unit>
<trans-unit id="BlGGO.X" resname="activity">
<source>activity</source>
@@ -328,7 +328,7 @@
</trans-unit>
<trans-unit id="Fm.kwVn" resname="profile.first_entry" xml:space="preserve">
<source>profile.first_entry</source>
<target state="translated">Eerste werkdag</target>
<target state="needs-translation">Aan het werk sinds</target>
</trans-unit>
<trans-unit id="xEYQXPy" resname="profile.settings">
<source>profile.settings</source>
@@ -1420,7 +1420,7 @@
</trans-unit>
<trans-unit id="1btzNDa" resname="work_times_should" xml:space="preserve">
<source>Expected number of hours</source>
<target state="translated">Verwachte uren</target>
<target state="needs-translation">Streefuren</target>
</trans-unit>
<trans-unit id="8yqaKzX" resname="confirmed_by_at">
<source>confirmed_by_at</source>
@@ -1428,7 +1428,7 @@
</trans-unit>
<trans-unit id="31dFXhR" resname="work_times_is" xml:space="preserve">
<source>work_times_is</source>
<target state="translated">Gewerkte uren</target>
<target state="needs-translation">Is-uren</target>
</trans-unit>
<trans-unit id="uHrZlbq" resname="work_times_result">
<source>work_times_result</source>
@@ -1496,7 +1496,7 @@
</trans-unit>
<trans-unit id="N6juwc4" resname="default" xml:space="preserve">
<source>default</source>
<target state="translated">Standaard</target>
<target state="translated">Default</target>
</trans-unit>
<trans-unit id="_WlU8Fp" resname="sickness">
<source>sickness</source>
@@ -1664,7 +1664,7 @@
</trans-unit>
<trans-unit id="Nw2x5hI" resname="sickness_child" xml:space="preserve">
<source>Sickness of a relative</source>
<target state="translated">Ziekte Familielid</target>
<target state="needs-translation">Ziekte kind</target>
</trans-unit>
<trans-unit id="CDTC1gc" resname="supervisor" xml:space="preserve">
<source>supervisor</source>
@@ -1750,58 +1750,6 @@
<source>days</source>
<target state="translated">Dagen</target>
</trans-unit>
<trans-unit id="2qbRs28" resname="hours_per_day" xml:space="preserve">
<source>hours_per_day</source>
<target state="translated">Uren per dag</target>
</trans-unit>
<trans-unit id="y7KtuSS" resname="hours_per_week" xml:space="preserve">
<source>hours_per_week</source>
<target state="translated">Uren per week</target>
</trans-unit>
<trans-unit id="07e8ywS" resname="hours_per_month" xml:space="preserve">
<source>hours_per_month</source>
<target state="translated">Uren per maand</target>
</trans-unit>
<trans-unit id="7p_pFHl" resname="work_days" xml:space="preserve">
<source>work_days</source>
<target state="translated">Werkdagen</target>
</trans-unit>
<trans-unit id="2pbkbn_" resname="work_hours_mode" xml:space="preserve">
<source>work_hours_mode</source>
<target state="translated">Werktijd berekening</target>
</trans-unit>
<trans-unit id="IFT9pou" resname="last_working_day" xml:space="preserve">
<source>last_working_day</source>
<target state="translated">Laatste werkdag</target>
</trans-unit>
<trans-unit id="J_teUVB" resname="updated" xml:space="preserve">
<source>Updated</source>
<target state="translated">Bijgewerkt</target>
</trans-unit>
<trans-unit id="vN5pKOU" resname="costs" xml:space="preserve">
<source>costs</source>
<target state="translated">Kosten</target>
</trans-unit>
<trans-unit id="0jvo0RQ" resname="profit" xml:space="preserve">
<source>profit</source>
<target state="translated">Winst</target>
</trans-unit>
<trans-unit id="QG7.sen" resname="created" xml:space="preserve">
<source>Created</source>
<target state="translated">Gecreëerd</target>
</trans-unit>
<trans-unit id="dOIWgOr" resname="approve" xml:space="preserve">
<source>approve</source>
<target state="translated">Goedkeuren</target>
</trans-unit>
<trans-unit id="H1i5FFs" resname="request" xml:space="preserve">
<source>request</source>
<target state="translated">Verzoek</target>
</trans-unit>
<trans-unit id="FOvlalA" resname="break" xml:space="preserve">
<source>break</source>
<target state="translated">Rustpauze</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -1798,10 +1798,6 @@
<source>costs</source>
<target state="translated">Custos</target>
</trans-unit>
<trans-unit id="FOvlalA" resname="break" xml:space="preserve" approved="no">
<source>break</source>
<target state="translated">Intervalo</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -196,7 +196,7 @@
</trans-unit>
<trans-unit id="qbD0dHa" resname="timeBudget" xml:space="preserve">
<source>timeBudget</source>
<target state="translated">Saatlik kota</target>
<target state="needs-translation">Zaman bütçesi</target>
</trans-unit>
<trans-unit id="BlGGO.X" resname="activity">
<source>activity</source>
@@ -1790,18 +1790,6 @@
<source>work_days</source>
<target state="translated">Çalışma günleri</target>
</trans-unit>
<trans-unit id="vN5pKOU" resname="costs" xml:space="preserve">
<source>costs</source>
<target state="translated">Maliyetler</target>
</trans-unit>
<trans-unit id="0jvo0RQ" resname="profit" xml:space="preserve">
<source>profit</source>
<target state="translated">Kar</target>
</trans-unit>
<trans-unit id="FOvlalA" resname="break" xml:space="preserve" approved="no">
<source>break</source>
<target state="translated">Ara ver</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -1426,9 +1426,9 @@
<source>deleted</source>
<target state="translated">Видалено</target>
</trans-unit>
<trans-unit id="N6juwc4" resname="default" xml:space="preserve">
<trans-unit id="N6juwc4" resname="default">
<source>default</source>
<target state="translated">За замовчуванням</target>
<target state="translated">Типово</target>
</trans-unit>
<trans-unit id="W8XQTkJ" resname="complete_month.help" xml:space="preserve">
<source>complete_month.help</source>

View File

@@ -1076,7 +1076,7 @@
</trans-unit>
<trans-unit id="Fm.kwVn" resname="profile.first_entry" xml:space="preserve" approved="no">
<source>profile.first_entry</source>
<target state="translated">第一個工作日</target>
<target state="needs-translation">工作開始於</target>
</trans-unit>
<trans-unit id="EwPAaws" resname="type" xml:space="preserve" approved="yes">
<source>type</source>
@@ -1762,38 +1762,6 @@
<source>request</source>
<target state="translated">請求</target>
</trans-unit>
<trans-unit id="2qbRs28" resname="hours_per_day" xml:space="preserve">
<source>hours_per_day</source>
<target state="translated">每日工時</target>
</trans-unit>
<trans-unit id="07e8ywS" resname="hours_per_month" xml:space="preserve">
<source>hours_per_month</source>
<target state="translated">每月工時</target>
</trans-unit>
<trans-unit id="7p_pFHl" resname="work_days" xml:space="preserve">
<source>work_days</source>
<target state="translated">工作天</target>
</trans-unit>
<trans-unit id="2pbkbn_" resname="work_hours_mode" xml:space="preserve">
<source>work_hours_mode</source>
<target state="translated">工時計算</target>
</trans-unit>
<trans-unit id="IFT9pou" resname="last_working_day" xml:space="preserve">
<source>last_working_day</source>
<target state="translated">最後工作日</target>
</trans-unit>
<trans-unit id="M_WE1pa" resname="booking_allow_only_work_days" xml:space="preserve">
<source>booking_allow_only_work_days</source>
<target state="translated">僅允許在勞動契約中定義有預期工時的日子進行時間記錄</target>
</trans-unit>
<trans-unit id="y7KtuSS" resname="hours_per_week" xml:space="preserve">
<source>hours_per_week</source>
<target state="translated">每週工時</target>
</trans-unit>
<trans-unit id="FOvlalA" resname="break" xml:space="preserve" approved="no">
<source>break</source>
<target state="translated">休息</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -26,6 +26,10 @@
<source>report_yearly_users</source>
<target>Jahresansicht für alle Benutzer</target>
</trans-unit>
<trans-unit id="7.486Fn" resname="report_activity_view">
<source>report_activity_view</source>
<target>Tätigkeitsübersicht</target>
</trans-unit>
<trans-unit id="R.9yUkf" resname="report_project_view">
<source>report_project_view</source>
<target>Projektübersicht</target>

View File

@@ -26,6 +26,10 @@
<source>report_yearly_users</source>
<target>Yearly view for all users</target>
</trans-unit>
<trans-unit id="7.486Fn" resname="report_activity_view">
<source>report_activity_view</source>
<target>Activity overview</target>
</trans-unit>
<trans-unit id="R.9yUkf" resname="report_project_view">
<source>report_project_view</source>
<target>Project overview</target>

View File

@@ -122,9 +122,9 @@
<source>An absence must have a date.</source>
<target state="translated">Nepřítomnost musí mít datum.</target>
</trans-unit>
<trans-unit id="SZO4xjC" resname="An absence cannot be booked before your first working day." xml:space="preserve">
<trans-unit id="SZO4xjC" resname="An absence cannot be booked before your first working day.">
<source>An absence cannot be booked before your first working day.</source>
<target state="needs-translation">Absenci nelze rezervovat před prvním pracovním dnem.</target>
<target state="translated">Absenci nelze rezervovat před prvním pracovním dnem.</target>
</trans-unit>
<trans-unit id="IN37g5N" resname="Not enough holidays left.">
<source>Not enough holidays left.</source>

View File

@@ -88,7 +88,7 @@
</trans-unit>
<trans-unit id="SZO4xjC" resname="An absence cannot be booked before your first working day." xml:space="preserve">
<source>An absence cannot be booked before your first working day.</source>
<target state="needs-translation">Et fravær kan ikke bookes før din første arbejdsdag.</target>
<target state="translated">Et fravær kan ikke bookes før din første arbejdsdag.</target>
</trans-unit>
<trans-unit id="c2B6.lW" resname="You can book a maximum of {{ value }} days at once." xml:space="preserve">
<source>You can book a maximum of {{ value }} days at once.</source>

View File

@@ -122,9 +122,9 @@
<source>An absence must have a date.</source>
<target>Eine Abwesenheit muss ein Datum haben.</target>
</trans-unit>
<trans-unit id="SZO4xjC" resname="An absence cannot be booked before your first working day." xml:space="preserve">
<trans-unit id="SZO4xjC" resname="An absence cannot be booked before your first working day.">
<source>An absence cannot be booked before your first working day.</source>
<target state="needs-translation">Eine Abwesenheit kann nicht vor dem ersten Arbeitstag gebucht werden.</target>
<target>Eine Abwesenheit kann nicht vor dem ersten Arbeitstag gebucht werden.</target>
</trans-unit>
<trans-unit id="IN37g5N" resname="Not enough holidays left.">
<source>Not enough holidays left.</source>

View File

@@ -110,9 +110,9 @@
<source>You do not have enough overtime, remaining are {{ value }}.</source>
<target state="translated">No tiene suficientes horas extra, las restantes son {{ value }}.</target>
</trans-unit>
<trans-unit id="SZO4xjC" resname="An absence cannot be booked before your first working day." xml:space="preserve">
<trans-unit id="SZO4xjC" resname="An absence cannot be booked before your first working day.">
<source>An absence cannot be booked before your first working day.</source>
<target state="translated">No se podrá reservar una ausencia antes del primer día laborable.</target>
<target state="translated">No se puede reservar una ausencia antes del primer día laborable.</target>
</trans-unit>
<trans-unit id="IN37g5N" resname="Not enough holidays left.">
<source>Not enough holidays left.</source>
@@ -154,10 +154,6 @@
<source>The CSRF token is invalid. Please try to resubmit the form.</source>
<target state="translated">Por favor, intenta volver a enviar el formulario. Si el problema persiste, actualiza tu navegador.</target>
</trans-unit>
<trans-unit id="G2wt0aW" resname="An absence cannot be booked after the last working day." xml:space="preserve">
<source>An absence cannot be booked after the last working day.</source>
<target state="translated">No se podrá reservar ninguna ausencia después del último día laborable.</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -118,9 +118,9 @@
<source>An absence must have a date.</source>
<target state="translated">Une absence doit avoir une date.</target>
</trans-unit>
<trans-unit id="SZO4xjC" resname="An absence cannot be booked before your first working day." xml:space="preserve">
<trans-unit id="SZO4xjC" resname="An absence cannot be booked before your first working day.">
<source>An absence cannot be booked before your first working day.</source>
<target state="needs-translation">Une absence ne peut être programmée avant votre première journée de travail.</target>
<target state="translated">Une absence ne peut être programmée avant votre première journée de travail.</target>
</trans-unit>
<trans-unit id="kSBiyaF" resname="This is a public holiday, booking not allowed.">
<source>This is a public holiday, booking not allowed.</source>

View File

@@ -120,7 +120,7 @@
</trans-unit>
<trans-unit id="SZO4xjC" resname="An absence cannot be booked before your first working day." xml:space="preserve" approved="yes">
<source>An absence cannot be booked before your first working day.</source>
<target state="final">אי אפשר להזמין היעדרות לפני יום העבודה הראשון.</target>
<target state="final">אי אפשר להזמין היעדרות לפני יום העבודה הראשון שלך.</target>
</trans-unit>
<trans-unit id="IN37g5N" resname="Not enough holidays left.">
<source>Not enough holidays left.</source>
@@ -154,10 +154,6 @@
<source>The CSRF token is invalid. Please try to resubmit the form.</source>
<target state="translated">נא לנסות להגיש את הקובץ שוב. אם הבעיה חוזרת על עצמה, מוטב לרענן את הדפדפן.</target>
</trans-unit>
<trans-unit id="G2wt0aW" resname="An absence cannot be booked after the last working day." xml:space="preserve" approved="yes">
<source>An absence cannot be booked after the last working day.</source>
<target state="final">אי אפשר לתעד העדרות אחרי יום העבודה האחרון.</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -110,9 +110,9 @@
<source>You do not have enough overtime, remaining are {{ value }}.</source>
<target state="translated">Nemaš dovoljno prekovremenskih sati. Preostalo je {{ value }}.</target>
</trans-unit>
<trans-unit id="SZO4xjC" resname="An absence cannot be booked before your first working day." xml:space="preserve">
<trans-unit id="SZO4xjC" resname="An absence cannot be booked before your first working day.">
<source>An absence cannot be booked before your first working day.</source>
<target state="needs-translation">Odsutnost se ne može rezervirati prije prvog radnog dana.</target>
<target state="translated">Odsutnost se ne može rezervirati prije prvog radnog dana.</target>
</trans-unit>
<trans-unit id="IN37g5N" resname="Not enough holidays left.">
<source>Not enough holidays left.</source>

View File

@@ -110,7 +110,7 @@
<source>You do not have enough overtime, remaining are {{ value }}.</source>
<target state="final">Non hai abbastanza straordinari, ti restano {{ value }}.</target>
</trans-unit>
<trans-unit id="SZO4xjC" resname="An absence cannot be booked before your first working day." approved="yes" xml:space="preserve">
<trans-unit id="SZO4xjC" resname="An absence cannot be booked before your first working day." approved="yes">
<source>An absence cannot be booked before your first working day.</source>
<target state="final">Un congedo non può essere prenotato prima del primo giorno lavorativo.</target>
</trans-unit>
@@ -154,10 +154,6 @@
<source>The CSRF token is invalid. Please try to resubmit the form.</source>
<target state="final">Prova a inviare nuovamente il modulo. Se il problema persiste, aggiorna il browser.</target>
</trans-unit>
<trans-unit id="G2wt0aW" resname="An absence cannot be booked after the last working day." xml:space="preserve" approved="yes">
<source>An absence cannot be booked after the last working day.</source>
<target state="final">Un congedo non può essere prenotato dopo l'ultimo giorno lavorativo.</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -88,7 +88,7 @@
</trans-unit>
<trans-unit id="SZO4xjC" resname="An absence cannot be booked before your first working day." xml:space="preserve">
<source>An absence cannot be booked before your first working day.</source>
<target state="needs-translation">Een afwezigheid kan niet worden geboekt voor je eerste werkdag.</target>
<target state="translated">Een afwezigheid kan niet worden geboekt voor je eerste werkdag.</target>
</trans-unit>
<trans-unit id="gSAwscA" resname="Validation Failed" xml:space="preserve">
<source>Validation Failed</source>

View File

@@ -124,7 +124,7 @@
</trans-unit>
<trans-unit id="SZO4xjC" resname="An absence cannot be booked before your first working day." xml:space="preserve">
<source>An absence cannot be booked before your first working day.</source>
<target state="needs-translation">Nieobecność nie może być zaksięgowana przed Twoim pierwszym dniem roboczym.</target>
<target state="translated">Nieobecność nie może być zaksięgowana przed Twoim pierwszym dniem roboczym.</target>
</trans-unit>
<trans-unit id="IN37g5N" resname="Not enough holidays left." xml:space="preserve">
<source>Not enough holidays left.</source>

View File

@@ -114,9 +114,9 @@
<source>The chosen date is already locked.</source>
<target state="translated">A data escolhida já está bloqueada.</target>
</trans-unit>
<trans-unit id="SZO4xjC" resname="An absence cannot be booked before your first working day." xml:space="preserve">
<trans-unit id="SZO4xjC" resname="An absence cannot be booked before your first working day.">
<source>An absence cannot be booked before your first working day.</source>
<target state="needs-translation">Uma ausência não pode ser marcada antes do seu primeiro dia útil.</target>
<target state="translated">Uma ausência não pode ser marcada antes do seu primeiro dia útil.</target>
</trans-unit>
<trans-unit id="IN37g5N" resname="Not enough holidays left.">
<source>Not enough holidays left.</source>

View File

@@ -110,9 +110,9 @@
<source>You do not have enough overtime, remaining are {{ value }}.</source>
<target state="translated">Você não tem horas extras suficientes, o restante é {{ value }}.</target>
</trans-unit>
<trans-unit id="SZO4xjC" resname="An absence cannot be booked before your first working day." xml:space="preserve">
<trans-unit id="SZO4xjC" resname="An absence cannot be booked before your first working day.">
<source>An absence cannot be booked before your first working day.</source>
<target state="translated">Uma ausência não pode ser marcada antes do primeiro dia útil.</target>
<target state="translated">Uma ausência não pode ser marcada antes de seu primeiro dia útil.</target>
</trans-unit>
<trans-unit id="IN37g5N" resname="Not enough holidays left.">
<source>Not enough holidays left.</source>
@@ -154,10 +154,6 @@
<source>The CSRF token is invalid. Please try to resubmit the form.</source>
<target state="translated">Por favor, tente reenviar o formulário. Se o problema persistir, atualize seu navegador.</target>
</trans-unit>
<trans-unit id="G2wt0aW" resname="An absence cannot be booked after the last working day." xml:space="preserve" approved="no">
<source>An absence cannot be booked after the last working day.</source>
<target state="translated">Uma ausência não pode ser marcada após o último dia útil.</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -36,7 +36,7 @@
</trans-unit>
<trans-unit id="SZO4xjC" resname="An absence cannot be booked before your first working day." xml:space="preserve">
<source>An absence cannot be booked before your first working day.</source>
<target state="needs-translation">Odsotnosti ni mogoče zabeležiti pred prvim delovnim dnem.</target>
<target state="translated">Odsotnosti ni mogoče zabeležiti pred prvim delovnim dnem.</target>
</trans-unit>
<trans-unit id="ZObikpA" resname="This absence type is not known." xml:space="preserve">
<source>This absence type is not known.</source>

View File

@@ -132,7 +132,7 @@
</trans-unit>
<trans-unit id="SZO4xjC" resname="An absence cannot be booked before your first working day." xml:space="preserve">
<source>An absence cannot be booked before your first working day.</source>
<target state="needs-translation">Frånvaro kan inte registreras innan din första arbetsdag.</target>
<target state="translated">Frånvaro kan inte registreras innan din första arbetsdag.</target>
</trans-unit>
<trans-unit id="IN37g5N" resname="Not enough holidays left." xml:space="preserve">
<source>Not enough holidays left.</source>

View File

@@ -118,9 +118,9 @@
<source>An absence must have a date.</source>
<target state="translated">Devamsızlığın bir tarihi olmalıdır.</target>
</trans-unit>
<trans-unit id="SZO4xjC" resname="An absence cannot be booked before your first working day." xml:space="preserve">
<trans-unit id="SZO4xjC" resname="An absence cannot be booked before your first working day.">
<source>An absence cannot be booked before your first working day.</source>
<target state="translated">İlk iş gününden önce devamsızlık rezervasyonu yapılamaz.</target>
<target state="translated">İlk iş gününüzden önce devamsızlık rezervasyonu yapılamaz.</target>
</trans-unit>
<trans-unit id="ByeEMxW" resname="You do not have enough overtime, remaining are {{ value }}.">
<source>You do not have enough overtime, remaining are {{ value }}.</source>
@@ -154,10 +154,6 @@
<source>The CSRF token is invalid. Please try to resubmit the form.</source>
<target state="translated">Lütfen formu yeniden göndermeyi deneyin. Sorun devam ederse tarayıcınızı yenileyin.</target>
</trans-unit>
<trans-unit id="G2wt0aW" resname="An absence cannot be booked after the last working day." xml:space="preserve" approved="no">
<source>An absence cannot be booked after the last working day.</source>
<target state="translated">İlk iş gününden sonra devamsızlık rezervasyonu yapılamaz.</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -110,9 +110,9 @@
<source>You do not have enough overtime, remaining are {{ value }}.</source>
<target state="translated">您的加班时间不足,剩余时间为 {{ value }}。</target>
</trans-unit>
<trans-unit id="SZO4xjC" resname="An absence cannot be booked before your first working day." xml:space="preserve">
<trans-unit id="SZO4xjC" resname="An absence cannot be booked before your first working day.">
<source>An absence cannot be booked before your first working day.</source>
<target state="needs-translation">在第一个工作日之前不能预约缺席。</target>
<target state="translated">在第一个工作日之前不能预约缺席。</target>
</trans-unit>
<trans-unit id="IN37g5N" resname="Not enough holidays left.">
<source>Not enough holidays left.</source>

View File

@@ -22,9 +22,9 @@
<source>An absence must have a date.</source>
<target state="final">缺勤必須指定日期。</target>
</trans-unit>
<trans-unit id="SZO4xjC" resname="An absence cannot be booked before your first working day." xml:space="preserve" approved="no">
<trans-unit id="SZO4xjC" resname="An absence cannot be booked before your first working day." xml:space="preserve" approved="yes">
<source>An absence cannot be booked before your first working day.</source>
<target state="needs-translation">不能在您的第一個工作日之前預定缺勤時間。</target>
<target state="final">不能在您的第一個工作日之前預定缺勤時間。</target>
</trans-unit>
<trans-unit id="IN37g5N" resname="Not enough holidays left." xml:space="preserve" approved="yes">
<source>Not enough holidays left.</source>

View File

@@ -1 +1 @@
Put your Kimai plugin ZIP files in this directory and run "./kimai.sh plugins"
This directory can store as Kimai plugin/bundle folder.