Compare commits
3 Commits
2.24.0
...
activity-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d5cef1a2f | ||
|
|
52a9ff9860 | ||
|
|
ecded0a065 |
@@ -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
|
||||
|
||||
@@ -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
18
.gitattributes
vendored
@@ -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
1
.gitignore
vendored
@@ -18,7 +18,6 @@
|
||||
# for keeping empty directories
|
||||
/config/packages/local.yaml
|
||||
/config/bundles-local.php
|
||||
/var/dev/*
|
||||
/var/data/*
|
||||
/var/cache/*
|
||||
/var/invoices*
|
||||
|
||||
@@ -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).
|
||||
|
||||
23
Dockerfile
23
Dockerfile
@@ -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
|
||||
|
||||
@@ -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
0
assets/.gitignore
vendored
Normal 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
|
||||
|
||||
@@ -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
116
kimai.sh
@@ -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 ""
|
||||
20
phpstan.neon
20
phpstan.neon
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
137
src/Command/UpdateCommand.php
Normal file
137
src/Command/UpdateCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
57
src/Controller/Reporting/ActivityViewController.php
Normal file
57
src/Controller/Reporting/ActivityViewController.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
55
src/Reporting/ActivityView/ActivityViewForm.php
Normal file
55
src/Reporting/ActivityView/ActivityViewForm.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
181
src/Reporting/ActivityView/ActivityViewModel.php
Normal file
181
src/Reporting/ActivityView/ActivityViewModel.php
Normal 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;
|
||||
}
|
||||
}
|
||||
77
src/Reporting/ActivityView/ActivityViewQuery.php
Normal file
77
src/Reporting/ActivityView/ActivityViewQuery.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
96
templates/reporting/activity_list_data.html.twig
Normal file
96
templates/reporting/activity_list_data.html.twig
Normal 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 %}
|
||||
21
templates/reporting/activity_view.html.twig
Normal file
21
templates/reporting/activity_view.html.twig
Normal 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 %}
|
||||
@@ -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()
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
63
tests/Command/UpdateCommandTest.php
Normal file
63
tests/Command/UpdateCommandTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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__);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user