added export module (#538)

This commit is contained in:
Kevin Papst
2019-02-05 21:37:33 +01:00
committed by GitHub
parent 062735c9e3
commit 816866549c
104 changed files with 3770 additions and 608 deletions

View File

@@ -1,6 +1,10 @@
build: build:
environment: environment:
node: v8.11.2 node: v8.11.2
php:
version: 7.2
ini:
date.timezone: "UTC"
nodes: nodes:
analysis: analysis:
tests: tests:

View File

@@ -23,7 +23,7 @@ Kimai is a [multi-language application](var/docs/translations.md) and already tr
### Requirements ### Requirements
- PHP 7.1.3 or higher (test your system compatibility with the [requirements-checker](http://symfony.com/doc/current/reference/requirements.html)) - PHP 7.1.3 or higher (test your system compatibility with the [requirements-checker](http://symfony.com/doc/current/reference/requirements.html))
- The PHP extensions [intl](https://php.net/manual/en/book.intl.php), [zip](https://php.net/manual/en/book.zip.php) and [PDO](https://php.net/manual/en/book.pdo.php) with either [pdo_sqlite](https://php.net/manual/en/ref.pdo-sqlite.php) or [pdo_mysql](https://php.net/manual/en/ref.pdo-mysql.php) enabled - The PHP extensions [mbstring](http://php.net/manual/en/book.mbstring.php), [gd](http://php.net/manual/en/book.image.php), [intl](https://php.net/manual/en/book.intl.php), [zip](https://php.net/manual/en/book.zip.php) and [PDO](https://php.net/manual/en/book.pdo.php) with either [pdo_sqlite](https://php.net/manual/en/ref.pdo-sqlite.php) or [pdo_mysql](https://php.net/manual/en/ref.pdo-mysql.php) enabled
- If you use MariaDB, make sure its at least v10.2.7 (see [FAQ](var/docs/faq.md)) - If you use MariaDB, make sure its at least v10.2.7 (see [FAQ](var/docs/faq.md))
- Kimai needs to be installed in the root directory of a domain or you need to [recompile the frontend assets](var/docs/developers.md) - Kimai needs to be installed in the root directory of a domain or you need to [recompile the frontend assets](var/docs/developers.md)
- A modern browser, Kimai v2 might be broken on old browsers like IE 10 - A modern browser, Kimai v2 might be broken on old browsers like IE 10

View File

@@ -120,9 +120,14 @@ table.dataTable thead .sorting_desc_disabled:after {
} }
table.dataTable thead > tr > th { table.dataTable thead > tr > th {
padding-right: 30px; white-space: nowrap;
} }
/*
table.dataTable thead > tr > th {
padding-right: 30px;
}
*/
table.dataTable th:active { table.dataTable th:active {
outline: none; outline: none;
} }
@@ -223,14 +228,17 @@ div.dataTables_scrollHead table.table-bordered {
background-color: #017ebc; background-color: #017ebc;
} }
table.dataTable {
td {
/* /*
Make sure that the action buttons do not line-break if another column takes all available space Make sure that the action buttons do not line-break if another column takes all available space
See https://github.com/twbs/bootstrap/issues/9939#issuecomment-37682952 See https://github.com/twbs/bootstrap/issues/9939#issuecomment-37682952
*/ */
table.dataTable {
td {
.btn-group { .btn-group {
display: flex; display: flex;
} }
.label {
font-size: 85%;
}
} }
} }

View File

@@ -285,7 +285,7 @@ table.invoice-sum th, table.invoice-sum td {
* { * {
-webkit-print-color-adjust: exact; -webkit-print-color-adjust: exact;
} }
body, .invoice, .wrapper, #freelancer-invoice { body, .wrapper, #freelancer-invoice {
margin: 0; margin: 0;
padding: 0; padding: 0;
} }

View File

@@ -25,6 +25,10 @@
} }
} }
.toolbar-pad {
padding: 10px;
}
/* The filter form is available on most pages above the datatable */ /* The filter form is available on most pages above the datatable */
@media (min-width: $screen-sm-min) { @media (min-width: $screen-sm-min) {
.toolbar { .toolbar {
@@ -43,5 +47,8 @@
} }
} }
} }
.toolbar-pad {
padding: 10px 0;
}
} }

View File

@@ -5,7 +5,9 @@
"description": "Kimai Time-Tracking 2", "description": "Kimai Time-Tracking 2",
"require": { "require": {
"php": "^7.1.3", "php": "^7.1.3",
"ext-gd": "*",
"ext-intl": "*", "ext-intl": "*",
"ext-mbstring": "*",
"ext-pdo": "*", "ext-pdo": "*",
"ext-zip": "*", "ext-zip": "*",
"beberlei/DoctrineExtensions": "^1.0", "beberlei/DoctrineExtensions": "^1.0",
@@ -17,6 +19,7 @@
"fzaninotto/faker": "^1.8", "fzaninotto/faker": "^1.8",
"jms/serializer-bundle": "^2.4", "jms/serializer-bundle": "^2.4",
"kevinpapst/adminlte-bundle": "~2.1", "kevinpapst/adminlte-bundle": "~2.1",
"mpdf/mpdf": "^7.1",
"nelmio/api-doc-bundle": "^3.2", "nelmio/api-doc-bundle": "^3.2",
"ocramius/proxy-manager": "2.1.1", "ocramius/proxy-manager": "2.1.1",
"phpoffice/phpspreadsheet": "^1.4", "phpoffice/phpspreadsheet": "^1.4",
@@ -78,6 +81,9 @@
"symfony/polyfill-php70": "*", "symfony/polyfill-php70": "*",
"symfony/polyfill-php56": "*" "symfony/polyfill-php56": "*"
}, },
"suggest": {
"ext-mbstring": "If ext-mbstring is not available you MUST install symfony/polyfill-mbstring"
},
"scripts": { "scripts": {
"auto-scripts": { "auto-scripts": {
"cache:clear": "symfony-cmd", "cache:clear": "symfony-cmd",

217
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "303432e4fedef20a41e3279148147685", "content-hash": "230c5b328a69b340e1261bc392acf9c9",
"packages": [ "packages": [
{ {
"name": "beberlei/DoctrineExtensions", "name": "beberlei/DoctrineExtensions",
@@ -2350,6 +2350,122 @@
], ],
"time": "2017-06-19T01:22:40+00:00" "time": "2017-06-19T01:22:40+00:00"
}, },
{
"name": "mpdf/mpdf",
"version": "v7.1.8",
"source": {
"type": "git",
"url": "https://github.com/mpdf/mpdf.git",
"reference": "5a124ed382cf9241597c7ba12f98a756daa7f8df"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/mpdf/mpdf/zipball/5a124ed382cf9241597c7ba12f98a756daa7f8df",
"reference": "5a124ed382cf9241597c7ba12f98a756daa7f8df",
"shasum": ""
},
"require": {
"ext-gd": "*",
"ext-mbstring": "*",
"myclabs/deep-copy": "^1.7",
"paragonie/random_compat": "^1.4|^2.0|9.99.99",
"php": "^5.6 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0",
"psr/log": "^1.0",
"setasign/fpdi": "1.6.*"
},
"require-dev": {
"mockery/mockery": "^0.9.5",
"phpunit/phpunit": "^5.0",
"squizlabs/php_codesniffer": "^2.7.0",
"tracy/tracy": "^2.4"
},
"suggest": {
"ext-bcmath": "Needed for generation of some types of barcodes",
"ext-xml": "Needed mainly for SVG manipulation",
"ext-zlib": "Needed for compression of embedded resources, such as fonts"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-development": "7.x-dev"
}
},
"autoload": {
"psr-4": {
"Mpdf\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"GPL-2.0-only"
],
"authors": [
{
"name": "Matěj Humpál",
"role": "Developer, maintainer"
},
{
"name": "Ian Back",
"role": "Developer (retired)"
}
],
"description": "PHP library generating PDF files from UTF-8 encoded HTML",
"homepage": "https://mpdf.github.io",
"keywords": [
"pdf",
"php",
"utf-8"
],
"time": "2019-01-08T11:38:17+00:00"
},
{
"name": "myclabs/deep-copy",
"version": "1.8.1",
"source": {
"type": "git",
"url": "https://github.com/myclabs/DeepCopy.git",
"reference": "3e01bdad3e18354c3dce54466b7fbe33a9f9f7f8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/3e01bdad3e18354c3dce54466b7fbe33a9f9f7f8",
"reference": "3e01bdad3e18354c3dce54466b7fbe33a9f9f7f8",
"shasum": ""
},
"require": {
"php": "^7.1"
},
"replace": {
"myclabs/deep-copy": "self.version"
},
"require-dev": {
"doctrine/collections": "^1.0",
"doctrine/common": "^2.6",
"phpunit/phpunit": "^7.1"
},
"type": "library",
"autoload": {
"psr-4": {
"DeepCopy\\": "src/DeepCopy/"
},
"files": [
"src/DeepCopy/deep_copy.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Create deep copies (clones) of your objects",
"keywords": [
"clone",
"copy",
"duplicate",
"object",
"object graph"
],
"time": "2018-06-11T23:09:50+00:00"
},
{ {
"name": "nelmio/api-doc-bundle", "name": "nelmio/api-doc-bundle",
"version": "v3.3.0", "version": "v3.3.0",
@@ -3519,6 +3635,55 @@
], ],
"time": "2018-09-30T05:16:57+00:00" "time": "2018-09-30T05:16:57+00:00"
}, },
{
"name": "setasign/fpdi",
"version": "1.6.2",
"source": {
"type": "git",
"url": "https://github.com/Setasign/FPDI.git",
"reference": "a6ad58897a6d97cc2d2cd2adaeda343b25a368ea"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Setasign/FPDI/zipball/a6ad58897a6d97cc2d2cd2adaeda343b25a368ea",
"reference": "a6ad58897a6d97cc2d2cd2adaeda343b25a368ea",
"shasum": ""
},
"suggest": {
"setasign/fpdf": "FPDI will extend this class but as it is also possible to use \"tecnickcom/tcpdf\" as an alternative there's no fixed dependency configured.",
"setasign/fpdi-fpdf": "Use this package to automatically evaluate dependencies to FPDF.",
"setasign/fpdi-tcpdf": "Use this package to automatically evaluate dependencies to TCPDF."
},
"type": "library",
"autoload": {
"classmap": [
"filters/",
"fpdi.php",
"fpdf_tpl.php",
"fpdi_pdf_parser.php",
"pdf_context.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jan Slabon",
"email": "jan.slabon@setasign.com",
"homepage": "https://www.setasign.com"
}
],
"description": "FPDI is a collection of PHP classes facilitating developers to read pages from existing PDF documents and use them as templates in FPDF. Because it is also possible to use FPDI with TCPDF, there are no fixed dependencies defined. Please see suggestions for packages which evaluates the dependencies automatically.",
"homepage": "https://www.setasign.com/fpdi",
"keywords": [
"fpdf",
"fpdi",
"pdf"
],
"time": "2017-05-11T14:25:49+00:00"
},
{ {
"name": "swiftmailer/swiftmailer", "name": "swiftmailer/swiftmailer",
"version": "v6.1.3", "version": "v6.1.3",
@@ -7451,54 +7616,6 @@
"description": "A tool to automatically fix PHP code style", "description": "A tool to automatically fix PHP code style",
"time": "2018-08-23T13:15:44+00:00" "time": "2018-08-23T13:15:44+00:00"
}, },
{
"name": "myclabs/deep-copy",
"version": "1.8.1",
"source": {
"type": "git",
"url": "https://github.com/myclabs/DeepCopy.git",
"reference": "3e01bdad3e18354c3dce54466b7fbe33a9f9f7f8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/3e01bdad3e18354c3dce54466b7fbe33a9f9f7f8",
"reference": "3e01bdad3e18354c3dce54466b7fbe33a9f9f7f8",
"shasum": ""
},
"require": {
"php": "^7.1"
},
"replace": {
"myclabs/deep-copy": "self.version"
},
"require-dev": {
"doctrine/collections": "^1.0",
"doctrine/common": "^2.6",
"phpunit/phpunit": "^7.1"
},
"type": "library",
"autoload": {
"psr-4": {
"DeepCopy\\": "src/DeepCopy/"
},
"files": [
"src/DeepCopy/deep_copy.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Create deep copies (clones) of your objects",
"keywords": [
"clone",
"copy",
"duplicate",
"object",
"object graph"
],
"time": "2018-06-11T23:09:50+00:00"
},
{ {
"name": "phar-io/manifest", "name": "phar-io/manifest",
"version": "1.0.3", "version": "1.0.3",
@@ -9020,7 +9137,9 @@
"prefer-lowest": false, "prefer-lowest": false,
"platform": { "platform": {
"php": "^7.1.3", "php": "^7.1.3",
"ext-gd": "*",
"ext-intl": "*", "ext-intl": "*",
"ext-mbstring": "*",
"ext-pdo": "*", "ext-pdo": "*",
"ext-zip": "*" "ext-zip": "*"
}, },

View File

@@ -66,8 +66,8 @@ kimai:
# -------------------------------------------------------------------------------- # --------------------------------------------------------------------------------
# Free configurable permission system, see var/docs/permissions.md # Free configurable permission system, see var/docs/permissions.md
permissions: permissions:
# mapping complex rulesets of single permissions to named "sets" ("set name" = [array of "permissions"])
sets: sets:
# mapping complex rulesets of single permissions to named "sets" ("set name" = [array of "permissions"])
ACTIVITIES: [view_activity,create_activity,edit_activity,delete_activity] ACTIVITIES: [view_activity,create_activity,edit_activity,delete_activity]
PROJECTS: [view_project,create_project,edit_project,delete_project] PROJECTS: [view_project,create_project,edit_project,delete_project]
CUSTOMERS: [view_customer,create_customer,edit_customer,delete_customer] CUSTOMERS: [view_customer,create_customer,edit_customer,delete_customer]
@@ -80,18 +80,19 @@ kimai:
USER: [view_user,create_user,delete_user] USER: [view_user,create_user,delete_user]
RATE: [view_rate_own_timesheet,edit_rate_own_timesheet] RATE: [view_rate_own_timesheet,edit_rate_own_timesheet]
RATE_OTHER: [view_rate_other_timesheet,edit_rate_other_timesheet] RATE_OTHER: [view_rate_other_timesheet,edit_rate_other_timesheet]
# mapping "sets" or permissions to user roles ("role name" = [array of "set names"]) EXPORT: [view_export,create_export,edit_export_own_timesheet,edit_export_other_timesheet]
maps: maps:
# mapping "sets" or permissions to user roles ("role name" = [array of "set names"])
ROLE_USER: [TIMESHEET,PROFILE] ROLE_USER: [TIMESHEET,PROFILE]
ROLE_TEAMLEAD: [INVOICE,TIMESHEET,TIMESHEET_OTHER,PROFILE] ROLE_TEAMLEAD: [INVOICE,TIMESHEET,TIMESHEET_OTHER,PROFILE,EXPORT]
ROLE_ADMIN: [ACTIVITIES,PROJECTS,CUSTOMERS,INVOICE,INVOICE_TEMPLATE,TIMESHEET,TIMESHEET_OTHER,PROFILE,RATE,RATE_OTHER] ROLE_ADMIN: [ACTIVITIES,PROJECTS,CUSTOMERS,INVOICE,INVOICE_TEMPLATE,TIMESHEET,TIMESHEET_OTHER,PROFILE,RATE,RATE_OTHER,EXPORT]
ROLE_SUPER_ADMIN: [ACTIVITIES,PROJECTS,CUSTOMERS,INVOICE,INVOICE_TEMPLATE,TIMESHEET,TIMESHEET_OTHER,PROFILE,PROFILE_OTHER,USER,RATE,RATE_OTHER] ROLE_SUPER_ADMIN: [ACTIVITIES,PROJECTS,CUSTOMERS,INVOICE,INVOICE_TEMPLATE,TIMESHEET,TIMESHEET_OTHER,PROFILE,PROFILE_OTHER,USER,RATE,RATE_OTHER,EXPORT]
# adding single permissions to user roles, extending the definition from "sets" ("role name" = [array of "permissions"])
roles: roles:
# adding single permissions to user roles, extending the definition from "sets" ("role name" = [array of "permissions"])
ROLE_USER: [] ROLE_USER: []
ROLE_TEAMLEAD: [view_invoice_template,create_invoice_template,edit_invoice_template,view_rate_own_timesheet,view_rate_other_timesheet,hourly-rate_own_profile] ROLE_TEAMLEAD: [view_invoice_template,create_invoice_template,edit_invoice_template,view_rate_own_timesheet,view_rate_other_timesheet,hourly-rate_own_profile]
ROLE_ADMIN: [hourly-rate_own_profile] ROLE_ADMIN: [hourly-rate_own_profile]
ROLE_SUPER_ADMIN: [hourly-rate_own_profile,hourly-rate_other_profile,delete_own_profile,roles_own_profile] ROLE_SUPER_ADMIN: [hourly-rate_own_profile,hourly-rate_other_profile,delete_own_profile,roles_own_profile,system_information]
# -------------------------------------------------------------------------------- # --------------------------------------------------------------------------------
# Language specific settings, like the date formats # Language specific settings, like the date formats
@@ -102,6 +103,7 @@ kimai:
date_type: 'dd.MM.yyyy' date_type: 'dd.MM.yyyy'
date_picker: 'DD.MM.YYYY' date_picker: 'DD.MM.YYYY'
date: 'd.m.Y' date: 'd.m.Y'
date_time: 'd.m. H:i'
duration: '%%h:%%m h' duration: '%%h:%%m h'
en: en:
date_time_type: 'yyyy-MM-dd HH:mm' date_time_type: 'yyyy-MM-dd HH:mm'
@@ -109,6 +111,7 @@ kimai:
date_type: 'yyyy-MM-dd' date_type: 'yyyy-MM-dd'
date_picker: 'YYYY-MM-DD' date_picker: 'YYYY-MM-DD'
date: 'Y-m-d' date: 'Y-m-d'
date_time: 'm-d H:i'
duration: '%%h:%%m h' duration: '%%h:%%m h'
pt_BR: pt_BR:
date_time_type: 'dd-MM-yyyy HH:mm' date_time_type: 'dd-MM-yyyy HH:mm'
@@ -116,6 +119,7 @@ kimai:
date_type: 'dd-MM-yyyy' date_type: 'dd-MM-yyyy'
date_picker: 'DD-MM-YYYY' date_picker: 'DD-MM-YYYY'
date: 'd-m-Y' date: 'd-m-Y'
date_time: 'd-m H:i'
duration: '%%h:%%m h' duration: '%%h:%%m h'
it: it:
date_time_type: 'dd.MM.yyyy HH:mm' date_time_type: 'dd.MM.yyyy HH:mm'
@@ -123,6 +127,7 @@ kimai:
date_type: 'dd.MM.yyyy' date_type: 'dd.MM.yyyy'
date_picker: 'DD.MM.YYYY' date_picker: 'DD.MM.YYYY'
date: 'd.m.Y' date: 'd.m.Y'
date_time: 'd.m. H:i'
duration: '%%h:%%m h' duration: '%%h:%%m h'
fr: fr:
date_time_type: 'dd/MM/yyyy HH:mm' date_time_type: 'dd/MM/yyyy HH:mm'
@@ -130,6 +135,7 @@ kimai:
date_type: 'dd/MM/yyyy' date_type: 'dd/MM/yyyy'
date_picker: 'DD/MM/YYYY' date_picker: 'DD/MM/YYYY'
date: 'd/m/Y' date: 'd/m/Y'
date_time: 'd/m H:i'
duration: '%%h h %%m' duration: '%%h h %%m'
es: es:
date_time_type: 'dd.MM.yyyy HH:mm' date_time_type: 'dd.MM.yyyy HH:mm'
@@ -137,6 +143,7 @@ kimai:
date_type: 'dd.MM.yyyy' date_type: 'dd.MM.yyyy'
date_picker: 'DD.MM.YYYY' date_picker: 'DD.MM.YYYY'
date: 'd.m.Y' date: 'd.m.Y'
date_time: 'd.m. H:i'
duration: '%%h:%%m h' duration: '%%h:%%m h'
ru: ru:
date_time_type: 'dd.MM.yyyy HH:mm' date_time_type: 'dd.MM.yyyy HH:mm'
@@ -144,6 +151,7 @@ kimai:
date_type: 'dd.MM.yyyy' date_type: 'dd.MM.yyyy'
date_picker: 'DD.MM.YYYY' date_picker: 'DD.MM.YYYY'
date: 'd.m.Y' date: 'd.m.Y'
date_time: 'd.m. H:i'
duration: '%%h:%%m h' duration: '%%h:%%m h'
ar: ar:
date_time_type: 'yyyy-MM-dd HH:mm' date_time_type: 'yyyy-MM-dd HH:mm'
@@ -151,6 +159,7 @@ kimai:
date_type: 'yyyy-MM-dd' date_type: 'yyyy-MM-dd'
date_picker: 'YYYY-MM-DD' date_picker: 'YYYY-MM-DD'
date: 'Y-m-d' date: 'Y-m-d'
date_time: 'm-d H:i'
duration: '%%h:%%m h' duration: '%%h:%%m h'
hu: hu:
date_time_type: 'yyyy.MM.dd HH:mm' date_time_type: 'yyyy.MM.dd HH:mm'
@@ -158,6 +167,7 @@ kimai:
date_type: 'yyyy.MM.dd' date_type: 'yyyy.MM.dd'
date_picker: 'YYYY.MM.DD' date_picker: 'YYYY.MM.DD'
date: 'Y.m.d.' date: 'Y.m.d.'
date_time: 'm.d. H:i'
duration: '%%h:%%m h' duration: '%%h:%%m h'
# -------------------------------------------------------------------------------- # --------------------------------------------------------------------------------

View File

@@ -1,6 +1,6 @@
App\Entity\Timesheet: App\Entity\Timesheet:
exclusion_policy: All exclusion_policy: All
custom_accessor_order: [id, begin, end, duration, rate, activity, project, user, description, fixedRate, hourlyRate] custom_accessor_order: [id, begin, end, duration, rate, activity, project, user, description, fixedRate, hourlyRate, exported]
properties: properties:
id: id:
include: true include: true
@@ -26,6 +26,9 @@ App\Entity\Timesheet:
hourlyRate: hourlyRate:
include: true include: true
groups: [Entity] groups: [Entity]
exported:
include: true
groups: [Entity]
activity: activity:
include: false include: false
exclude: true exclude: true

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
{ {
"build/app.js": "/build/app.js?bf44c766db95ef7d121c", "build/app.js": "/build/app.js?bf44c766db95ef7d121c",
"build/app.css": "/build/app.css?cc325c031344117b92b2eaada5cf2319", "build/app.css": "/build/app.css?caadc4d6101e9889a4fa28e77f1d9362",
"build/images/blue@2x.png": "/build/images/blue@2x.png?2694acfd", "build/images/blue@2x.png": "/build/images/blue@2x.png?2694acfd",
"build/images/blue.png": "/build/images/blue.png?96f8a905", "build/images/blue.png": "/build/images/blue.png?96f8a905",
"build/fonts/fa-solid-900.woff2": "/build/fonts/fa-solid-900.woff2?e8a92a29", "build/fonts/fa-solid-900.woff2": "/build/fonts/fa-solid-900.woff2?e8a92a29",

View File

@@ -169,6 +169,7 @@ class TimesheetController extends BaseApiController
$form = $this->createForm(TimesheetEditForm::class, $timesheet, [ $form = $this->createForm(TimesheetEditForm::class, $timesheet, [
'csrf_protection' => false, 'csrf_protection' => false,
'include_rate' => $this->isGranted('edit_rate', $timesheet), 'include_rate' => $this->isGranted('edit_rate', $timesheet),
'include_exported' => $this->isGranted('edit_export', $timesheet),
]); ]);
$form->setData($timesheet); $form->setData($timesheet);
@@ -216,4 +217,64 @@ class TimesheetController extends BaseApiController
return $this->viewHandler->handle($view); return $this->viewHandler->handle($view);
} }
/**
* @SWG\Post(
* description="Update an existing timesheet entry, you can pass all or just a subset of all attributes",
* @SWG\Schema(ref="#/definitions/TimesheetFormEntity"),
* @SWG\Response(
* response=200,
* description="Returns the updated timesheet entry",
* @SWG\Schema(ref="#/definitions/TimesheetEntity"),
* )
* )
*
* @param Request $request
* @param string $id
* @return Response
*/
public function patchAction(Request $request, string $id)
{
$timesheet = $this->repository->find($id);
if (!$this->isGranted('edit', $timesheet)) {
throw $this->createAccessDeniedException('User cannot update timesheet');
}
$form = $this->createForm(TimesheetEditForm::class, $timesheet, [
'csrf_protection' => false,
'include_rate' => $this->isGranted('edit_rate', $timesheet),
'include_exported' => $this->isGranted('edit_export', $timesheet),
]);
$form->setData($timesheet);
$form->submit($request->request->all(), false);
if (false === $form->isValid()) {
$view = new View($form, Response::HTTP_OK);
$view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']);
return $this->viewHandler->handle($view);
}
if ($form->has('duration')) {
$duration = $form->get('duration')->getData();
if ($duration > 0) {
/** @var Timesheet $record */
$record = $form->getData();
$end = clone $record->getBegin();
$end->modify('+ ' . $duration . 'seconds');
$record->setEnd($end);
}
}
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($timesheet);
$entityManager->flush();
$view = new View($timesheet, Response::HTTP_OK);
$view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']);
return $this->viewHandler->handle($view);
}
} }

View File

@@ -848,7 +848,7 @@ class KimaiImporterCommand extends Command
* ["description"]=> NULL * ["description"]=> NULL
* ["comment"]=> string(36) "a work description" * ["comment"]=> string(36) "a work description"
* -- ["commentType"]=> string(1) "0" * -- ["commentType"]=> string(1) "0"
* -- ["cleared"]=> string(1) "0" * ["cleared"]=> string(1) "0"
* -- ["location"]=> string(0) "" * -- ["location"]=> string(0) ""
* -- ["trackingNumber"]=> NULL * -- ["trackingNumber"]=> NULL
* ["rate"]=> string(5) "50.00" * ["rate"]=> string(5) "50.00"
@@ -936,6 +936,7 @@ class KimaiImporterCommand extends Command
->setDuration($duration) ->setDuration($duration)
->setActivity($activity) ->setActivity($activity)
->setProject($project) ->setProject($project)
->setExported(intval($oldRecord['cleared']) !== 0)
; ;
if (!$this->validateImport($io, $timesheet)) { if (!$this->validateImport($io, $timesheet)) {

View File

@@ -14,6 +14,10 @@ namespace App;
*/ */
class Constants class Constants
{ {
/**
* The software name
*/
public const SOFTWARE = 'Kimai 2';
/** /**
* The current release version * The current release version
*/ */

View File

@@ -19,7 +19,7 @@ use Symfony\Component\Routing\Annotation\Route;
* Controller used for executing system relevant tasks. * Controller used for executing system relevant tasks.
* *
* @Route(path="/admin/about") * @Route(path="/admin/about")
* @Security("is_granted('ROLE_SUPER_ADMIN')") * @Security("is_granted('system_information')")
*/ */
class AboutController extends AbstractController class AboutController extends AbstractController
{ {

View File

@@ -189,6 +189,7 @@ class TimesheetController extends AbstractController
'page' => $page, 'page' => $page,
]), ]),
'include_rate' => $this->isGranted('edit_rate', $entry), 'include_rate' => $this->isGranted('edit_rate', $entry),
'include_exported' => $this->isGranted('edit_export', $entry),
'include_user' => true, 'include_user' => true,
]); ]);
} }

View File

@@ -0,0 +1,162 @@
<?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;
use App\Entity\Timesheet;
use App\Export\ServiceExport;
use App\Form\Toolbar\ExportToolbarForm;
use App\Repository\Query\ExportQuery;
use App\Repository\TimesheetRepository;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
/**
* Controller used to export timesheet data.
*
* @Route(path="/export")
* @Security("is_granted('view_export')")
*/
class ExportController extends AbstractController
{
/**
* @var TimesheetRepository
*/
protected $timesheetRepository;
/**
* @var ServiceExport
*/
protected $export;
/**
* @param TimesheetRepository $timesheet
* @param ServiceExport $export
*/
public function __construct(TimesheetRepository $timesheet, ServiceExport $export)
{
$this->timesheetRepository = $timesheet;
$this->export = $export;
}
/**
* @return ExportQuery
* @throws \Exception
*/
protected function getDefaultQuery()
{
$begin = new \DateTime('first day of this month');
$end = new \DateTime('last day of this month');
$query = new ExportQuery();
$query->setOrder(ExportQuery::ORDER_ASC);
$query->setBegin($begin);
$query->setEnd($end);
$query->setState(ExportQuery::STATE_STOPPED);
$query->setExported(ExportQuery::STATE_NOT_EXPORTED);
return $query;
}
/**
* @Route(path="/", name="export", methods={"GET", "POST"})
* @Security("is_granted('view_export')")
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
* @throws \Exception
*/
public function indexAction(Request $request)
{
$query = $this->getDefaultQuery();
$form = $this->getToolbarForm($query);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var ExportQuery $query */
$query = $form->getData();
}
$entries = $this->getEntries($query);
return $this->render('export/index.html.twig', [
'entries' => $entries,
'form' => $form->createView(),
'renderer' => $this->export->getRenderer(),
]);
}
/**
* @Route(path="/data", name="export_data", methods={"GET", "POST"})
* @Security("is_granted('create_export')")
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
* @throws \Exception
*/
public function export(Request $request)
{
$query = $this->getDefaultQuery();
$form = $this->getToolbarForm($query);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var ExportQuery $query */
$query = $form->getData();
}
$type = $query->getType();
if (null === $type) {
throw $this->createNotFoundException('Missing export renderer');
}
$renderer = $this->export->getRendererById($type);
// this code should not be reached, as the query already filters invalid values
// when trying to call setType() with an unknown value
if (null === $renderer) {
throw $this->createNotFoundException('Invalid export renderer');
}
$entries = $this->getEntries($query);
return $renderer->render($entries, $query);
}
/**
* @param ExportQuery $query
* @return Timesheet[]
*/
protected function getEntries(ExportQuery $query)
{
$query->setResultType(ExportQuery::RESULT_TYPE_QUERYBUILDER);
$query->getBegin()->setTime(0, 0, 0);
$query->getEnd()->setTime(23, 59, 59);
$queryBuilder = $this->timesheetRepository->findByQuery($query);
return $queryBuilder->getQuery()->getResult();
}
/**
* @param ExportQuery $query
* @return \Symfony\Component\Form\FormInterface
*/
protected function getToolbarForm(ExportQuery $query)
{
return $this->createForm(ExportToolbarForm::class, $query, [
'action' => $this->generateUrl('export', []),
'method' => 'POST',
'attr' => [
'id' => 'export-form'
]
]);
}
}

View File

@@ -269,6 +269,7 @@ class TimesheetController extends AbstractController
'origin' => $redirectRoute, 'origin' => $redirectRoute,
]), ]),
'include_rate' => $this->isGranted('edit_rate', $entry), 'include_rate' => $this->isGranted('edit_rate', $entry),
'include_exported' => $this->isGranted('edit_export', $entry),
]); ]);
} }

View File

@@ -0,0 +1,41 @@
<?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\DependencyInjection\Compiler;
use App\Export\ServiceExport;
use App\Kernel;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
/**
* Dynamically adds all dependencies to the ExportService.
*/
class ExportServiceCompilerPass implements CompilerPassInterface
{
/**
* @param ContainerBuilder $container
* @throws \Exception
*/
public function process(ContainerBuilder $container)
{
// always first check if the primary service is defined
if (!$container->has(ServiceExport::class)) {
return;
}
$definition = $container->findDefinition(ServiceExport::class);
$taggedRenderer = $container->findTaggedServiceIds(Kernel::TAG_EXPORT_RENDERER);
foreach ($taggedRenderer as $id => $tags) {
$definition->addMethodCall('addRenderer', [new Reference($id)]);
}
}
}

View File

@@ -177,6 +177,7 @@ class Configuration implements ConfigurationInterface
->scalarNode('date_type')->defaultValue('yyyy-MM-dd')->end() // for DateType ->scalarNode('date_type')->defaultValue('yyyy-MM-dd')->end() // for DateType
->scalarNode('date_picker')->defaultValue('YYYY-MM-DD')->end() // for DateType JS component ->scalarNode('date_picker')->defaultValue('YYYY-MM-DD')->end() // for DateType JS component
->scalarNode('date')->defaultValue('Y-m-d')->end() // for display via twig ->scalarNode('date')->defaultValue('Y-m-d')->end() // for display via twig
->scalarNode('date_time')->defaultValue('m-d H:i')->end() // for display via twig
->scalarNode('duration')->defaultValue('%%h:%%m h')->end() // for display via twig ->scalarNode('duration')->defaultValue('%%h:%%m h')->end() // for display via twig
->end() ->end()
->end() ->end()

View File

@@ -119,6 +119,14 @@ class Timesheet
*/ */
private $hourlyRate = null; private $hourlyRate = null;
/**
* @var bool
*
* @ORM\Column(name="exported", type="boolean", nullable=false)
* @Assert\NotNull()
*/
private $exported = false;
/** /**
* Get entry id * Get entry id
* *
@@ -345,6 +353,25 @@ class Timesheet
return $this; return $this;
} }
/**
* @return bool
*/
public function isExported(): bool
{
return $this->exported;
}
/**
* @param bool $exported
* @return Timesheet
*/
public function setExported(bool $exported)
{
$this->exported = $exported;
return $this;
}
/** /**
* @param ExecutionContextInterface $context * @param ExecutionContextInterface $context
* @param $payload * @param $payload

View File

@@ -70,6 +70,12 @@ class MenuSubscriber implements EventSubscriberInterface
new MenuItemModel('invoice', 'menu.invoice', 'invoice', [], 'fas fa-file-invoice') new MenuItemModel('invoice', 'menu.invoice', 'invoice', [], 'fas fa-file-invoice')
); );
} }
if ($auth->isGranted('view_export')) {
$menu->addItem(
new MenuItemModel('export', 'menu.export', 'export', [], 'fas fa-file-export')
);
}
} }
/** /**

View File

@@ -0,0 +1,215 @@
<?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\Export\Renderer;
use App\Entity\Timesheet;
use App\Repository\Query\TimesheetQuery;
use App\Twig\DateExtensions;
use App\Twig\Extensions;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Translation\TranslatorInterface;
abstract class AbstractSpreadsheetRenderer
{
/**
* @var DateExtensions
*/
protected $dateExtension;
/**
* @var Extensions
*/
protected $extension;
/**
* @var TranslatorInterface
*/
protected $translator;
/**
* @param TranslatorInterface $translator
* @param DateExtensions $dateExtension
* @param Extensions $extensions
*/
public function __construct(TranslatorInterface $translator, DateExtensions $dateExtension, Extensions $extensions)
{
$this->translator = $translator;
$this->dateExtension = $dateExtension;
$this->extension = $extensions;
}
/**
* @param \DateTime $date
* @return mixed
*/
protected function getFormattedDateTime(\DateTime $date)
{
return $this->dateExtension->dateShort($date) . ' ' . date('H:i', $date->getTimestamp());
}
/**
* @param $amount
* @return mixed
*/
protected function getFormattedMoney($amount, $currency)
{
return $this->extension->money($amount, $currency);
}
/**
* @param Timesheet $timesheet
* @return string
*/
protected function getUsername(Timesheet $timesheet)
{
if (!empty($timesheet->getUser()->getAlias())) {
return $timesheet->getUser()->getAlias();
}
return $timesheet->getUser()->getUsername();
}
/**
* @param $seconds
* @return mixed
*/
protected function getFormattedDuration($seconds)
{
return $this->extension->duration($seconds);
}
/**
* @param Timesheet[] $timesheets
* @param TimesheetQuery $query
* @return Spreadsheet
* @throws \PhpOffice\PhpSpreadsheet\Exception
*/
protected function fromArrayToSpreadsheet(array $timesheets, TimesheetQuery $query): Spreadsheet
{
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$recordsHeaderColumn = 1;
$recordsHeaderRow = 1;
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.begin'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.end'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.user'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.customer'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.project'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.activity'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.description'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.exported'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.hourly_rate'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.fixed_rate'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.duration'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.rate'));
$entryHeaderRow = $recordsHeaderRow + 1;
$durationTotal = 0;
$currency = false;
$rateTotal = 0;
foreach ($timesheets as $timesheet) {
$entryHeaderColumn = 1;
$durationTotal += $timesheet->getDuration();
$rateTotal += $timesheet->getRate();
if ($currency === false) {
$currency = $timesheet->getProject()->getCustomer()->getCurrency();
}
if ($currency !== $timesheet->getProject()->getCustomer()->getCurrency()) {
$currency = null;
}
$customerCurrency = $timesheet->getProject()->getCustomer()->getCurrency();
$exported = $timesheet->isExported() ? 'entryState.exported' : 'entryState.not_exported';
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->getFormattedDateTime($timesheet->getBegin()));
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->getFormattedDateTime($timesheet->getEnd()));
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->getUsername($timesheet));
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $timesheet->getProject()->getCustomer()->getName());
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $timesheet->getProject()->getName());
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $timesheet->getActivity()->getName());
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $timesheet->getDescription());
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->translator->trans($exported));
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->getFormattedMoney($timesheet->getHourlyRate(), $customerCurrency));
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->getFormattedMoney($timesheet->getFixedRate(), $customerCurrency));
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->getFormattedDuration($timesheet->getDuration()));
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->getFormattedMoney($timesheet->getRate(), $customerCurrency));
$entryHeaderRow++;
}
$sheet->setCellValueByColumnAndRow(11, $entryHeaderRow, $this->getFormattedDuration($durationTotal));
$sheet->setCellValueByColumnAndRow(12, $entryHeaderRow, $this->getFormattedMoney($rateTotal, $currency));
$sheet->getCellByColumnAndRow(11, $entryHeaderRow)->getStyle()->getBorders()->getTop()->setBorderStyle(\PhpOffice\PhpSpreadsheet\Style\Border::BORDER_THIN);
$sheet->getCellByColumnAndRow(11, $entryHeaderRow)->getStyle()->getFont()->setBold(true);
$sheet->getCellByColumnAndRow(12, $entryHeaderRow)->getStyle()->getBorders()->getTop()->setBorderStyle(\PhpOffice\PhpSpreadsheet\Style\Border::BORDER_THIN);
$sheet->getCellByColumnAndRow(12, $entryHeaderRow)->getStyle()->getFont()->setBold(true);
return $spreadsheet;
}
/**
* @param Timesheet[] $timesheets
* @param TimesheetQuery $query
* @return Response
* @throws \PhpOffice\PhpSpreadsheet\Exception
* @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
*/
public function render(array $timesheets, TimesheetQuery $query): Response
{
$spreadsheet = $this->fromArrayToSpreadsheet($timesheets, $query);
$filename = $this->saveSpreadsheet($spreadsheet);
return $this->getFileResponse($filename, 'kimai-export' . $this->getFileExtension());
}
/**
* @return string
*/
abstract public function getFileExtension(): string;
/**
* @param mixed $file
* @param string $filename
* @return BinaryFileResponse
*/
protected function getFileResponse($file, $filename): Response
{
$response = new BinaryFileResponse($file);
$disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename);
$response->headers->set('Content-Type', $this->getContentType());
$response->headers->set('Content-Disposition', $disposition);
$response->deleteFileAfterSend(true);
return $response;
}
/**
* @return string
*/
abstract protected function getContentType(): string;
/**
* @param Spreadsheet $spreadsheet
* @return string
* @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
*/
abstract protected function saveSpreadsheet(Spreadsheet $spreadsheet): string;
}

View File

@@ -0,0 +1,71 @@
<?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\Export\Renderer;
use App\Export\RendererInterface;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
class CsvRenderer extends AbstractSpreadsheetRenderer implements RendererInterface
{
/**
* @return string
*/
public function getFileExtension(): string
{
return '.csv';
}
/**
* @return string
*/
protected function getContentType(): string
{
return 'text/csv';
}
/**
* @param Spreadsheet $spreadsheet
* @return bool|string
* @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
*/
protected function saveSpreadsheet(Spreadsheet $spreadsheet): string
{
$filename = tempnam(sys_get_temp_dir(), 'kimai-export-csv');
$writer = IOFactory::createWriter($spreadsheet, 'Csv');
$writer->save($filename);
return $filename;
}
/**
* @return string
*/
public function getId(): string
{
return 'csv';
}
/**
* @return string
*/
public function getIcon(): string
{
return 'csv';
}
/**
* @return string
*/
public function getTitle(): string
{
return 'csv';
}
}

View File

@@ -0,0 +1,79 @@
<?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\Export\Renderer;
use App\Entity\Timesheet;
use App\Export\RendererInterface;
use App\Repository\Query\TimesheetQuery;
use Symfony\Component\HttpFoundation\Response;
class HtmlRenderer implements RendererInterface
{
use RendererTrait;
/**
* @var \Twig_Environment
*/
protected $twig;
/**
* @param \Twig_Environment $twig
*/
public function __construct(\Twig_Environment $twig)
{
$this->twig = $twig;
}
/**
* @param Timesheet[] $timesheets
* @param TimesheetQuery $query
* @return Response
* @throws \Twig_Error_Loader
* @throws \Twig_Error_Runtime
* @throws \Twig_Error_Syntax
*/
public function render(array $timesheets, TimesheetQuery $query): Response
{
$content = $this->twig->render('export/renderer/default.html.twig', [
'entries' => $timesheets,
'query' => $query,
'summaries' => $this->calculateSummary($timesheets),
]);
$response = new Response();
$response->setContent($content);
return $response;
}
/**
* @return string
*/
public function getId(): string
{
return 'html';
}
/**
* @return string
*/
public function getIcon(): string
{
return 'print';
}
/**
* @return string
*/
public function getTitle(): string
{
return 'print';
}
}

View File

@@ -0,0 +1,71 @@
<?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\Export\Renderer;
use App\Export\RendererInterface;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
class OdsRenderer extends AbstractSpreadsheetRenderer implements RendererInterface
{
/**
* @return string
*/
public function getFileExtension(): string
{
return '.ods';
}
/**
* @return string
*/
protected function getContentType(): string
{
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
}
/**
* @param Spreadsheet $spreadsheet
* @return bool|string
* @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
*/
protected function saveSpreadsheet(Spreadsheet $spreadsheet): string
{
$filename = tempnam(sys_get_temp_dir(), 'kimai-export-ods');
$writer = IOFactory::createWriter($spreadsheet, 'Ods');
$writer->save($filename);
return $filename;
}
/**
* @return string
*/
public function getId(): string
{
return 'ods';
}
/**
* @return string
*/
public function getIcon(): string
{
return 'ods';
}
/**
* @return string
*/
public function getTitle(): string
{
return 'ods';
}
}

View File

@@ -0,0 +1,94 @@
<?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\Export\Renderer;
use App\Entity\Timesheet;
use App\Export\RendererInterface;
use App\Repository\Query\TimesheetQuery;
use Mpdf\Mpdf;
use Mpdf\Output\Destination;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
class PDFRenderer implements RendererInterface
{
use RendererTrait;
/**
* @var \Twig_Environment
*/
protected $twig;
/**
* @param \Twig_Environment $twig
*/
public function __construct(\Twig_Environment $twig)
{
$this->twig = $twig;
}
/**
* @param Timesheet[] $timesheets
* @param TimesheetQuery $query
* @return Response
* @throws \Mpdf\MpdfException
* @throws \Twig_Error_Loader
* @throws \Twig_Error_Runtime
* @throws \Twig_Error_Syntax
*/
public function render(array $timesheets, TimesheetQuery $query): Response
{
$content = $this->twig->render('export/renderer/pdf.html.twig', [
'entries' => $timesheets,
'query' => $query,
'now' => new \DateTime(),
'summaries' => $this->calculateSummary($timesheets),
]);
//return new Response($content);
$mpdf = new Mpdf();
$mpdf->WriteHTML($content);
$content = $mpdf->Output('test', Destination::STRING_RETURN);
$response = new Response($content);
$disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, 'kimai-export.pdf');
$response->headers->set('Content-Type', 'application/pdf');
$response->headers->set('Content-Disposition', $disposition);
return $response;
}
/**
* @return string
*/
public function getId(): string
{
return 'pdf';
}
/**
* @return string
*/
public function getIcon(): string
{
return 'pdf';
}
/**
* @return string
*/
public function getTitle(): string
{
return 'pdf';
}
}

View File

@@ -0,0 +1,43 @@
<?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\Export\Renderer;
use App\Entity\Timesheet;
trait RendererTrait
{
/**
* @param Timesheet[] $timesheets
* @return array
*/
protected function calculateSummary(array $timesheets)
{
$summary = [];
foreach ($timesheets as $timesheet) {
$id = $timesheet->getProject()->getCustomer()->getId() . '_' . $timesheet->getProject()->getId();
if (!isset($summary[$id])) {
$summary[$id] = [
'customer' => $timesheet->getProject()->getCustomer()->getName(),
'project' => $timesheet->getProject()->getName(),
'currency' => $timesheet->getProject()->getCustomer()->getCurrency(),
'rate' => 0,
'duration' => 0,
];
}
$summary[$id]['rate'] += $timesheet->getRate();
$summary[$id]['duration'] += $timesheet->getDuration();
}
asort($summary);
return $summary;
}
}

View File

@@ -0,0 +1,71 @@
<?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\Export\Renderer;
use App\Export\RendererInterface;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
class XlsxRenderer extends AbstractSpreadsheetRenderer implements RendererInterface
{
/**
* @return string
*/
public function getFileExtension(): string
{
return '.xlsx';
}
/**
* @return string
*/
protected function getContentType(): string
{
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
}
/**
* @param Spreadsheet $spreadsheet
* @return bool|string
* @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
*/
protected function saveSpreadsheet(Spreadsheet $spreadsheet): string
{
$filename = tempnam(sys_get_temp_dir(), 'kimai-export-xlsx');
$writer = IOFactory::createWriter($spreadsheet, 'Xlsx');
$writer->save($filename);
return $filename;
}
/**
* @return string
*/
public function getId(): string
{
return 'xlsx';
}
/**
* @return string
*/
public function getIcon(): string
{
return 'xlsx';
}
/**
* @return string
*/
public function getTitle(): string
{
return 'xlsx';
}
}

View File

@@ -0,0 +1,39 @@
<?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\Export;
use App\Entity\Timesheet;
use App\Repository\Query\TimesheetQuery;
use Symfony\Component\HttpFoundation\Response;
interface RendererInterface
{
/**
* @param Timesheet[] $timesheets
* @param TimesheetQuery $query
* @return Response
*/
public function render(array $timesheets, TimesheetQuery $query): Response;
/**
* @return string
*/
public function getId(): string;
/**
* @return string
*/
public function getIcon(): string;
/**
* @return string
*/
public function getTitle(): string;
}

View File

@@ -0,0 +1,54 @@
<?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\Export;
class ServiceExport
{
/**
* @var RendererInterface[]
*/
protected $renderer = [];
/**
* @param RendererInterface $renderer
* @return $this
*/
public function addRenderer(RendererInterface $renderer)
{
$this->renderer[] = $renderer;
return $this;
}
/**
* Returns an array of export renderer.
*
* @return RendererInterface[]
*/
public function getRenderer()
{
return $this->renderer;
}
/**
* @param string $id
* @return RendererInterface|null
*/
public function getRendererById(string $id)
{
foreach ($this->renderer as $renderer) {
if ($renderer->getId() === $id) {
return $renderer;
}
}
return null;
}
}

View File

@@ -16,6 +16,7 @@ use App\Form\Type\DateTimePickerType;
use App\Form\Type\DurationType; use App\Form\Type\DurationType;
use App\Form\Type\ProjectType; use App\Form\Type\ProjectType;
use App\Form\Type\UserType; use App\Form\Type\UserType;
use App\Form\Type\YesNoType;
use App\Repository\ActivityRepository; use App\Repository\ActivityRepository;
use App\Repository\CustomerRepository; use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository; use App\Repository\ProjectRepository;
@@ -226,6 +227,12 @@ class TimesheetEditForm extends AbstractType
if ($options['include_user']) { if ($options['include_user']) {
$builder->add('user', UserType::class); $builder->add('user', UserType::class);
} }
if ($options['include_exported']) {
$builder->add('exported', YesNoType::class, [
'label' => 'label.exported'
]);
}
} }
/** /**
@@ -240,6 +247,7 @@ class TimesheetEditForm extends AbstractType
'csrf_token_id' => 'timesheet_edit', 'csrf_token_id' => 'timesheet_edit',
'duration_only' => $this->durationOnly, 'duration_only' => $this->durationOnly,
'include_user' => false, 'include_user' => false,
'include_exported' => false,
'include_rate' => true, 'include_rate' => true,
'docu_chapter' => 'timesheet', 'docu_chapter' => 'timesheet',
'method' => 'POST', 'method' => 'POST',

View File

@@ -0,0 +1,73 @@
<?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\Form\Toolbar;
use App\Repository\Query\ExportQuery;
use App\Repository\Query\TimesheetQuery;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Defines the form used for filtering timesheet entries for exports.
*/
class ExportToolbarForm extends AbstractToolbarForm
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$this->addExportStateChoice($builder);
$this->addUserChoice($builder);
$this->addDateRangeChoice($builder);
$this->addCustomerChoice($builder);
$this->addProjectChoice($builder);
$this->addActivityChoice($builder);
$this->addExportType($builder);
}
/**
* @param FormBuilderInterface $builder
*/
protected function addExportType(FormBuilderInterface $builder)
{
$builder->add('type', HiddenType::class, []);
}
/**
* @param FormBuilderInterface $builder
*/
protected function addExportStateChoice(FormBuilderInterface $builder)
{
$builder->add('exported', ChoiceType::class, [
'label' => 'label.exported',
'required' => false,
'placeholder' => null,
'choices' => [
'entryState.all' => TimesheetQuery::STATE_ALL,
'entryState.exported' => TimesheetQuery::STATE_EXPORTED,
'entryState.not_exported' => TimesheetQuery::STATE_NOT_EXPORTED
],
]);
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => ExportQuery::class,
'csrf_protection' => false,
]);
}
}

View File

@@ -25,7 +25,7 @@ class YesNoType extends AbstractType
{ {
$resolver->setDefaults([ $resolver->setDefaults([
'value' => true, 'value' => true,
'false_values' => [null, 0, false], 'false_values' => [null, 0, false, 'false'],
'required' => false, 'required' => false,
]); ]);
} }

View File

@@ -38,7 +38,7 @@ class CsvRenderer extends AbstractSpreadsheetRenderer implements RendererInterfa
*/ */
protected function saveSpreadsheet(Spreadsheet $spreadsheet) protected function saveSpreadsheet(Spreadsheet $spreadsheet)
{ {
$filename = tempnam(sys_get_temp_dir(), 'kimai-csv'); $filename = tempnam(sys_get_temp_dir(), 'kimai-invoice-csv');
$writer = IOFactory::createWriter($spreadsheet, 'Csv'); $writer = IOFactory::createWriter($spreadsheet, 'Csv');
$writer->save($filename); $writer->save($filename);

View File

@@ -38,7 +38,7 @@ class OdsRenderer extends AbstractSpreadsheetRenderer implements RendererInterfa
*/ */
protected function saveSpreadsheet(Spreadsheet $spreadsheet) protected function saveSpreadsheet(Spreadsheet $spreadsheet)
{ {
$filename = tempnam(sys_get_temp_dir(), 'kimai-ods'); $filename = tempnam(sys_get_temp_dir(), 'kimai-invoice-ods');
$writer = IOFactory::createWriter($spreadsheet, 'Ods'); $writer = IOFactory::createWriter($spreadsheet, 'Ods');
$writer->save($filename); $writer->save($filename);

View File

@@ -38,7 +38,7 @@ class XlsxRenderer extends AbstractSpreadsheetRenderer implements RendererInterf
*/ */
protected function saveSpreadsheet(Spreadsheet $spreadsheet) protected function saveSpreadsheet(Spreadsheet $spreadsheet)
{ {
$filename = tempnam(sys_get_temp_dir(), 'kimai-xslx'); $filename = tempnam(sys_get_temp_dir(), 'kimai-invoice-xlsx');
$writer = IOFactory::createWriter($spreadsheet, 'Xlsx'); $writer = IOFactory::createWriter($spreadsheet, 'Xlsx');
$writer->save($filename); $writer->save($filename);

View File

@@ -11,11 +11,13 @@ namespace App;
use App\DependencyInjection\AppExtension; use App\DependencyInjection\AppExtension;
use App\DependencyInjection\Compiler\DoctrineCompilerPass; use App\DependencyInjection\Compiler\DoctrineCompilerPass;
use App\DependencyInjection\Compiler\ExportServiceCompilerPass;
use App\DependencyInjection\Compiler\InvoiceServiceCompilerPass; use App\DependencyInjection\Compiler\InvoiceServiceCompilerPass;
use App\DependencyInjection\Compiler\TwigContextCompilerPass; use App\DependencyInjection\Compiler\TwigContextCompilerPass;
use App\Export\RendererInterface as ExportRendererInterface;
use App\Invoice\CalculatorInterface as InvoiceCalculator; use App\Invoice\CalculatorInterface as InvoiceCalculator;
use App\Invoice\NumberGeneratorInterface; use App\Invoice\NumberGeneratorInterface;
use App\Invoice\RendererInterface; use App\Invoice\RendererInterface as InvoiceRendererInterface;
use App\Timesheet\CalculatorInterface as TimesheetCalculator; use App\Timesheet\CalculatorInterface as TimesheetCalculator;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\Config\Loader\LoaderInterface;
@@ -30,6 +32,7 @@ class Kernel extends BaseKernel
public const CONFIG_EXTS = '.{php,xml,yaml,yml}'; public const CONFIG_EXTS = '.{php,xml,yaml,yml}';
public const TAG_EXPORT_RENDERER = 'export.renderer';
public const TAG_INVOICE_RENDERER = 'invoice.renderer'; public const TAG_INVOICE_RENDERER = 'invoice.renderer';
public const TAG_INVOICE_NUMBER_GENERATOR = 'invoice.number_generator'; public const TAG_INVOICE_NUMBER_GENERATOR = 'invoice.number_generator';
public const TAG_INVOICE_CALCULATOR = 'invoice.calculator'; public const TAG_INVOICE_CALCULATOR = 'invoice.calculator';
@@ -47,7 +50,8 @@ class Kernel extends BaseKernel
protected function build(ContainerBuilder $container) protected function build(ContainerBuilder $container)
{ {
$container->registerForAutoconfiguration(TimesheetCalculator::class)->addTag('timesheet.calculator'); $container->registerForAutoconfiguration(TimesheetCalculator::class)->addTag('timesheet.calculator');
$container->registerForAutoconfiguration(RendererInterface::class)->addTag(self::TAG_INVOICE_RENDERER); $container->registerForAutoconfiguration(ExportRendererInterface::class)->addTag(self::TAG_EXPORT_RENDERER);
$container->registerForAutoconfiguration(InvoiceRendererInterface::class)->addTag(self::TAG_INVOICE_RENDERER);
$container->registerForAutoconfiguration(NumberGeneratorInterface::class)->addTag(self::TAG_INVOICE_NUMBER_GENERATOR); $container->registerForAutoconfiguration(NumberGeneratorInterface::class)->addTag(self::TAG_INVOICE_NUMBER_GENERATOR);
$container->registerForAutoconfiguration(InvoiceCalculator::class)->addTag(self::TAG_INVOICE_CALCULATOR); $container->registerForAutoconfiguration(InvoiceCalculator::class)->addTag(self::TAG_INVOICE_CALCULATOR);
} }
@@ -80,6 +84,7 @@ class Kernel extends BaseKernel
$container->addCompilerPass(new DoctrineCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000); $container->addCompilerPass(new DoctrineCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000);
$container->addCompilerPass(new TwigContextCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000); $container->addCompilerPass(new TwigContextCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000);
$container->addCompilerPass(new InvoiceServiceCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000); $container->addCompilerPass(new InvoiceServiceCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000);
$container->addCompilerPass(new ExportServiceCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000);
} }
protected function configureRoutes(RouteCollectionBuilder $routes) protected function configureRoutes(RouteCollectionBuilder $routes)

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
/*
* 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 DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* Adds the exported column to the timesheet table
*/
final class Version20190124004014 extends AbstractMigration
{
public function up(Schema $schema): void
{
$platform = $this->getPlatform();
if (!in_array($platform, ['sqlite', 'mysql'])) {
$this->abortIf(true, 'Unsupported database platform: ' . $platform);
}
$timesheet = $this->getTableName('timesheet');
if ($platform === 'sqlite') {
$this->addSql('ALTER TABLE ' . $timesheet . ' ADD COLUMN exported BOOLEAN NOT NULL DEFAULT false');
} else {
$this->addSql('ALTER TABLE ' . $timesheet . ' ADD exported TINYINT(1) NOT NULL DEFAULT false');
}
}
public function down(Schema $schema): void
{
$platform = $this->getPlatform();
if (!in_array($platform, ['sqlite', 'mysql'])) {
$this->abortIf(true, 'Unsupported database platform: ' . $platform);
}
$timesheet = $this->getTableName('timesheet');
if ($platform === 'sqlite') {
$this->addSql('DROP INDEX IDX_4F60C6B1166D1F9C');
$this->addSql('DROP INDEX IDX_4F60C6B18D93D649');
$this->addSql('DROP INDEX IDX_4F60C6B181C06096');
$this->addSql('CREATE TEMPORARY TABLE __temp__' . $timesheet . ' AS SELECT id, user, activity_id, project_id, start_time, end_time, duration, description, rate, fixed_rate, hourly_rate FROM ' . $timesheet);
$this->addSql('DROP TABLE ' . $timesheet);
$this->addSql('CREATE TABLE ' . $timesheet . ' (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user INTEGER NOT NULL, activity_id INTEGER NOT NULL, project_id INTEGER NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME DEFAULT NULL, duration INTEGER DEFAULT NULL, description CLOB DEFAULT NULL, rate NUMERIC(10, 2) NOT NULL, fixed_rate NUMERIC(10, 2) DEFAULT NULL, hourly_rate NUMERIC(10, 2) DEFAULT NULL)');
$this->addSql('INSERT INTO ' . $timesheet . ' (id, user, activity_id, project_id, start_time, end_time, duration, description, rate, fixed_rate, hourly_rate) SELECT id, user, activity_id, project_id, start_time, end_time, duration, description, rate, fixed_rate, hourly_rate FROM __temp__' . $timesheet);
$this->addSql('DROP TABLE __temp__' . $timesheet);
$this->addSql('CREATE INDEX IDX_4F60C6B1166D1F9C ON ' . $timesheet . ' (project_id)');
$this->addSql('CREATE INDEX IDX_4F60C6B18D93D649 ON ' . $timesheet . ' (user)');
$this->addSql('CREATE INDEX IDX_4F60C6B181C06096 ON ' . $timesheet . ' (activity_id)');
} else {
$this->addSql('ALTER TABLE ' . $timesheet . ' DROP exported');
}
}
}

View File

@@ -0,0 +1,48 @@
<?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\Repository\Query;
/**
* Can be used for export queries.
*/
class ExportQuery extends TimesheetQuery
{
public const TYPE_HTML = 'html';
public const TYPE_CSV = 'csv';
public const TYPE_PDF = 'pdf';
public const TYPE_XLSX = 'xlsx';
public const TYPE_ODS = 'ods';
/**
* @var string
*/
protected $type;
/**
* @return string
*/
public function getType(): ?string
{
return $this->type;
}
/**
* @param string $type
* @return ExportQuery
*/
public function setType(string $type)
{
if (in_array($type, [self::TYPE_PDF, self::TYPE_CSV, self::TYPE_HTML, self::TYPE_XLSX, self::TYPE_ODS])) {
$this->type = $type;
}
return $this;
}
}

View File

@@ -22,6 +22,8 @@ class InvoiceQuery extends TimesheetQuery
protected $template; protected $template;
/** /**
* TODO can this be removed ???
*
* @var InvoiceTemplate[] * @var InvoiceTemplate[]
*/ */
protected $templates = []; protected $templates = [];

View File

@@ -21,6 +21,8 @@ class TimesheetQuery extends ActivityQuery
public const STATE_ALL = 1; public const STATE_ALL = 1;
public const STATE_RUNNING = 2; public const STATE_RUNNING = 2;
public const STATE_STOPPED = 3; public const STATE_STOPPED = 3;
public const STATE_EXPORTED = 4;
public const STATE_NOT_EXPORTED = 5;
/** /**
* Overwritten for different default order * Overwritten for different default order
@@ -44,6 +46,10 @@ class TimesheetQuery extends ActivityQuery
* @var int * @var int
*/ */
protected $state = self::STATE_ALL; protected $state = self::STATE_ALL;
/**
* @var int
*/
protected $exported = self::STATE_ALL;
/** /**
* @var DateRange * @var DateRange
*/ */
@@ -84,10 +90,10 @@ class TimesheetQuery extends ActivityQuery
} }
/** /**
* @param Activity $activity * @param Activity|int $activity
* @return TimesheetQuery * @return TimesheetQuery
*/ */
public function setActivity(Activity $activity = null) public function setActivity($activity = null)
{ {
$this->activity = $activity; $this->activity = $activity;
@@ -108,7 +114,7 @@ class TimesheetQuery extends ActivityQuery
*/ */
public function setState($state) public function setState($state)
{ {
if (!is_int($state) && $state != (int) $state) { if (!is_int($state) && $state !== (int) $state) {
return $this; return $this;
} }
@@ -120,6 +126,32 @@ class TimesheetQuery extends ActivityQuery
return $this; return $this;
} }
/**
* @return int
*/
public function getExported()
{
return $this->exported;
}
/**
* @param int $exported
* @return TimesheetQuery
*/
public function setExported($exported)
{
if (!is_int($exported) && $exported !== (int) $exported) {
return $this;
}
$exported = (int) $exported;
if (in_array($exported, [self::STATE_ALL, self::STATE_EXPORTED, self::STATE_NOT_EXPORTED], true)) {
$this->exported = $exported;
}
return $this;
}
/** /**
* @return \DateTime * @return \DateTime
*/ */

View File

@@ -338,11 +338,18 @@ class TimesheetRepository extends AbstractRepository
->setParameter('end', $query->getEnd()); ->setParameter('end', $query->getEnd());
} }
if ($query->getExported() === TimesheetQuery::STATE_EXPORTED) {
$qb->andWhere('t.exported = :exported')->setParameter('exported', true);
} elseif ($query->getExported() === TimesheetQuery::STATE_NOT_EXPORTED) {
$qb->andWhere('t.exported = :exported')->setParameter('exported', false);
}
if (null !== $query->getActivity()) { if (null !== $query->getActivity()) {
$qb->andWhere('t.activity = :activity') $qb->andWhere('t.activity = :activity')
->setParameter('activity', $query->getActivity()); ->setParameter('activity', $query->getActivity());
} }
// TODO if activity is an int, this will fail
if (null === $query->getActivity() || null === $query->getActivity()->getProject()) { if (null === $query->getActivity() || null === $query->getActivity()->getProject()) {
if (null !== $query->getProject()) { if (null !== $query->getProject()) {
$qb->andWhere('t.project = :project') $qb->andWhere('t.project = :project')

View File

@@ -39,6 +39,7 @@ class DateExtensions extends \Twig_Extension
return [ return [
new TwigFilter('month_name', [$this, 'monthName']), new TwigFilter('month_name', [$this, 'monthName']),
new TwigFilter('date_short', [$this, 'dateShort']), new TwigFilter('date_short', [$this, 'dateShort']),
new TwigFilter('date_time', [$this, 'dateTime']),
]; ];
} }
@@ -53,6 +54,17 @@ class DateExtensions extends \Twig_Extension
return date_format($date, $format); return date_format($date, $format);
} }
/**
* @param DateTime $date
* @return string
*/
public function dateTime(DateTime $date)
{
$format = $this->localeSettings->getDateTimeFormat();
return date_format($date, $format);
}
/** /**
* @param \DateTime $date * @param \DateTime $date
* @return string * @return string

View File

@@ -86,6 +86,13 @@ class Extensions extends \Twig_Extension
'user' => 'fas fa-user', 'user' => 'fas fa-user',
'visibility' => 'far fa-eye', 'visibility' => 'far fa-eye',
'settings' => 'fas fa-wrench', 'settings' => 'fas fa-wrench',
'export' => 'fas fa-database',
'pdf' => 'fas fa-file-pdf',
'csv' => 'fas fa-table',
'ods' => 'fas fa-table',
'xlsx' => 'fas fa-file-excel',
'on' => 'fas fa-toggle-on',
'off' => 'fas fa-toggle-off',
]; ];
/** /**

View File

@@ -111,6 +111,17 @@ class LocaleSettings
return $this->getConfigByLocaleAndKey('date', $locale); return $this->getConfigByLocaleAndKey('date', $locale);
} }
/**
* Returns the locale specific datetime format, which should be used in combination with the twig filter "|date".
*
* @param null|string $locale
* @return string
*/
public function getDateTimeFormat(?string $locale = null): string
{
return $this->getConfigByLocaleAndKey('date_time', $locale);
}
/** /**
* Returns the format used in the "|duration" twig filter to display a Timesheet duration. * Returns the format used in the "|duration" twig filter to display a Timesheet duration.
* *
@@ -143,5 +154,4 @@ class LocaleSettings
return $this->settings[$locale][$key]; return $this->settings[$locale][$key];
} }
} }

View File

@@ -26,6 +26,7 @@ class TimesheetVoter extends AbstractVoter
public const EXPORT = 'export'; public const EXPORT = 'export';
public const VIEW_RATE = 'view_rate'; public const VIEW_RATE = 'view_rate';
public const EDIT_RATE = 'edit_rate'; public const EDIT_RATE = 'edit_rate';
public const EDIT_EXPORT = 'edit_export';
/** /**
* support rules based on the given $subject (here: Timesheet) * support rules based on the given $subject (here: Timesheet)
@@ -38,6 +39,7 @@ class TimesheetVoter extends AbstractVoter
self::EXPORT, self::EXPORT,
self::VIEW_RATE, self::VIEW_RATE,
self::EDIT_RATE, self::EDIT_RATE,
self::EDIT_EXPORT,
]; ];
/** /**
@@ -92,6 +94,7 @@ class TimesheetVoter extends AbstractVoter
case self::EDIT: case self::EDIT:
case self::DELETE: case self::DELETE:
case self::EXPORT: case self::EXPORT:
case self::EDIT_EXPORT:
$permission .= $attribute; $permission .= $attribute;
break; break;

View File

@@ -167,6 +167,9 @@
"monolog/monolog": { "monolog/monolog": {
"version": "1.23.0" "version": "1.23.0"
}, },
"mpdf/mpdf": {
"version": "v7.1.8"
},
"myclabs/deep-copy": { "myclabs/deep-copy": {
"version": "1.7.0" "version": "1.7.0"
}, },
@@ -311,6 +314,9 @@
"ref": "aaddfdf43cdecd4cf91f992052d76c2cadc04543" "ref": "aaddfdf43cdecd4cf91f992052d76c2cadc04543"
} }
}, },
"setasign/fpdi": {
"version": "1.6.2"
},
"squizlabs/php_codesniffer": { "squizlabs/php_codesniffer": {
"version": "3.2.2" "version": "3.2.2"
}, },

View File

@@ -18,10 +18,10 @@
{% endblock %} {% endblock %}
{% block main %} {% block main %}
{% if entries.count == 0 %} {% if entries.count == 0 %}
{{ widgets.callout('warning', 'error.no_entries_found') }} {{ widgets.callout('warning', 'error.no_entries_found') }}
{% endif %} {% else %}
{% set columns = { {% set columns = {
'name': 'alwaysVisible', 'name': 'alwaysVisible',
'customer': 'hidden-xs', 'customer': 'hidden-xs',
@@ -68,4 +68,6 @@
{% endfor %} {% endfor %}
{{ tables.data_table_footer(entries, 'admin_activity_paginated') }} {{ tables.data_table_footer(entries, 'admin_activity_paginated') }}
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -18,13 +18,12 @@
{% endblock %} {% endblock %}
{% block main %} {% block main %}
{% if entries.count == 0 %} {% if entries.count == 0 %}
{{ widgets.callout('warning', 'error.no_entries_found') }} {{ widgets.callout('warning', 'error.no_entries_found') }}
{% endif %} {% else %}
{% set columns = { {% set columns = {
'name': 'alwaysVisible', 'name': 'alwaysVisible',
'project': '',
'comment': 'hidden-xs', 'comment': 'hidden-xs',
'country': 'hidden-xs', 'country': 'hidden-xs',
'customer_number': 'hidden-xs', 'customer_number': 'hidden-xs',
@@ -40,12 +39,6 @@
{% for entry in entries %} {% for entry in entries %}
<tr> <tr>
<td>{{ entry.name }} {% if entry.company is not empty %}({{ entry.company }}){% endif %}</td> <td>{{ entry.name }} {% if entry.company is not empty %}({{ entry.company }}){% endif %}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'project') }}">
{% for project in entry.projects %}
<a href="{{ path('admin_project_edit', {'id' : project.id}) }}">{{ widgets.label_project(project) }}</a>
<br class="visible-xs-block">
{% endfor %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'comment') }}">{{ entry.comment }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'comment') }}">{{ entry.comment }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'country') }}">{{ entry.country|country }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'country') }}">{{ entry.country|country }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'customer_number') }}">{{ entry.number }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'customer_number') }}">{{ entry.number }}</td>
@@ -56,6 +49,9 @@
{% if is_granted('edit', entry) %} {% if is_granted('edit', entry) %}
{% set actionButtons = {'edit': path('admin_customer_edit', {'id': entry.id})}|merge(actionButtons) %} {% set actionButtons = {'edit': path('admin_customer_edit', {'id': entry.id})}|merge(actionButtons) %}
{% endif %} {% endif %}
{% if is_granted('view_project') %}
{% set actionButtons = actionButtons|merge({'project': path('admin_project', {'customer': entry.id})}) %}
{% endif %}
{% if is_granted('delete', entry) %} {% if is_granted('delete', entry) %}
{% set actionButtons = actionButtons|merge({'trash': path('admin_customer_delete', {'id': entry.id})}) %} {% set actionButtons = actionButtons|merge({'trash': path('admin_customer_delete', {'id': entry.id})}) %}
{% endif %} {% endif %}
@@ -65,4 +61,6 @@
{% endfor %} {% endfor %}
{{ tables.data_table_footer(entries, 'admin_customer_paginated') }} {{ tables.data_table_footer(entries, 'admin_customer_paginated') }}
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -18,10 +18,10 @@
{% endblock %} {% endblock %}
{% block main %} {% block main %}
{% if entries.count == 0 %} {% if entries.count == 0 %}
{{ widgets.callout('warning', 'error.no_entries_found') }} {{ widgets.callout('warning', 'error.no_entries_found') }}
{% endif %} {% else %}
{% set columns = { {% set columns = {
'name': 'alwaysVisible', 'name': 'alwaysVisible',
'customer': '', 'customer': '',
@@ -49,6 +49,9 @@
{% if is_granted('edit', entry) %} {% if is_granted('edit', entry) %}
{% set actionButtons = {'edit': path('admin_project_edit', {'id': entry.id})}|merge(actionButtons) %} {% set actionButtons = {'edit': path('admin_project_edit', {'id': entry.id})}|merge(actionButtons) %}
{% endif %} {% endif %}
{% if is_granted('view_activity') %}
{% set actionButtons = actionButtons|merge({'activity': path('admin_activity', {'customer': entry.customer.id, 'project': entry.id})}) %}
{% endif %}
{% if is_granted('delete', entry) %} {% if is_granted('delete', entry) %}
{% set actionButtons = actionButtons|merge({'trash': path('admin_project_delete', {'id': entry.id})}) %} {% set actionButtons = actionButtons|merge({'trash': path('admin_project_delete', {'id': entry.id})}) %}
{% endif %} {% endif %}
@@ -58,4 +61,6 @@
{% endfor %} {% endfor %}
{{ tables.data_table_footer(entries, 'admin_project_paginated') }} {{ tables.data_table_footer(entries, 'admin_project_paginated') }}
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -22,10 +22,10 @@
{% endblock %} {% endblock %}
{% block main %} {% block main %}
{% if entries.count == 0 %} {% if entries.count == 0 %}
{{ widgets.callout('warning', 'error.no_entries_found') }} {{ widgets.callout('warning', 'error.no_entries_found') }}
{% endif %} {% else %}
{% set columns = {'date': ''} %} {% set columns = {'date': ''} %}
{% if not duration_only %} {% if not duration_only %}
@@ -49,7 +49,7 @@
{% for entry in entries %} {% for entry in entries %}
<tr> <tr>
<td class="{{ tables.data_table_column_class(tableName, columns, 'date') }}">{{ entry.begin|date_short }}</td> <td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'date') }}">{{ entry.begin|date_short }}</td>
{% if not duration_only %} {% if not duration_only %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'starttime') }}">{{ entry.begin|date("H:i") }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'starttime') }}">{{ entry.begin|date("H:i") }}</td>
@@ -59,14 +59,14 @@
{% if not duration_only %} {% if not duration_only %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'endtime') }}">{{ entry.end|date("H:i") }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'endtime') }}">{{ entry.end|date("H:i") }}</td>
{% endif %} {% endif %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'duration') }}">{{ entry.duration|duration }}</td> <td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'duration') }}">{{ entry.duration|duration }}</td>
{% else %} {% else %}
{% if not duration_only %} {% if not duration_only %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'endtime') }}">&dash;</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'endtime') }}">&dash;</td>
{% endif %} {% endif %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'duration') }}"><i>{{ entry|duration }}</i></td> <td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'duration') }}"><i>{{ entry|duration }}</i></td>
{% endif %} {% endif %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'rate') }}"> <td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'rate') }}">
{% if not entry.end or not is_granted('view_rate', entry) %} {% if not entry.end or not is_granted('view_rate', entry) %}
&dash; &dash;
{% else %} {% else %}
@@ -101,6 +101,8 @@
{% endfor %} {% endfor %}
{{ tables.data_table_footer(entries, 'admin_timesheet_paginated') }} {{ tables.data_table_footer(entries, 'admin_timesheet_paginated') }}
{% endif %}
{% endblock %} {% endblock %}
{% block javascripts %} {% block javascripts %}

View File

@@ -19,12 +19,11 @@
{% endblock %} {% endblock %}
{% block main %} {% block main %}
{% if entries.count == 0 %} {% if entries.count == 0 %}
{{ widgets.callout('warning', 'error.no_entries_found') }} {{ widgets.callout('warning', 'error.no_entries_found') }}
{% endif %} {% else %}
{% set columns = { {% set columns = {
'id': 'hidden-xs',
'alias': 'alwaysVisible', 'alias': 'alwaysVisible',
'username': 'hidden-xs', 'username': 'hidden-xs',
'email': 'hidden-xs hidden-sm', 'email': 'hidden-xs hidden-sm',
@@ -40,7 +39,6 @@
{% for entry in entries %} {% for entry in entries %}
<tr> <tr>
<td class="{{ tables.data_table_column_class(tableName, columns, 'id') }}">{{ entry.id }}</td>
<td>{{ widgets.username(entry) }}</td> <td>{{ widgets.username(entry) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'username') }}">{{ entry.username }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'username') }}">{{ entry.username }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'email') }}">{{ entry.email }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'email') }}">{{ entry.email }}</td>
@@ -54,7 +52,10 @@
<td> <td>
{% set actionButtons = {} %} {% set actionButtons = {} %}
{% if is_granted('edit', entry) %} {% if is_granted('edit', entry) %}
{% set actionButtons = {'edit': path('user_profile', {'username' : entry.username})}|merge(actionButtons) %} {% set actionButtons = actionButtons|merge({'edit': path('user_profile', {'username' : entry.username})}) %}
{% endif %}
{% if is_granted('view_other_timesheet') %}
{% set actionButtons = actionButtons|merge({'timesheet': path('admin_timesheet', {'user' : entry.id})}) %}
{% endif %} {% endif %}
{% if is_granted('delete', entry) %} {% if is_granted('delete', entry) %}
{% set actionButtons = actionButtons|merge({'trash': path('admin_user_delete', {'id': entry.id})}) %} {% set actionButtons = actionButtons|merge({'trash': path('admin_user_delete', {'id': entry.id})}) %}
@@ -65,4 +66,6 @@
{% endfor %} {% endfor %}
{{ tables.data_table_footer(entries, 'admin_user_paginated') }} {{ tables.data_table_footer(entries, 'admin_user_paginated') }}
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -1,7 +1,7 @@
{% extends '@AdminLTE/layout/default-layout-avanzu.html.twig' %} {% extends '@AdminLTE/layout/default-layout-avanzu.html.twig' %}
{% block avanzu_page_content_before %} {% block avanzu_page_content_before %}
<div class="pad no-print"> <div class="toolbar-pad no-print">
{% block main_before %}{% endblock %} {% block main_before %}{% endblock %}
</div> </div>
{% endblock %} {% endblock %}
@@ -42,14 +42,14 @@
{% block avanzu_navbar_messages %} {% block avanzu_navbar_messages %}
{% if app.user is not null and is_granted('IS_AUTHENTICATED_REMEMBERED') %} {% if app.user is not null and is_granted('IS_AUTHENTICATED_REMEMBERED') %}
{% if is_granted('ROLE_USER') %} {% if is_granted('view_own_timesheet') %}
<li class="visible-xs-inline-block"> <li class="visible-xs-inline-block">
<a href="{{ path('timesheet') }}" class="ddt-large"> <a href="{{ path('timesheet') }}" class="ddt-large">
<i class="{{ 'timesheet'|icon }} fa-2x"></i> <i class="{{ 'timesheet'|icon }} fa-2x"></i>
</a> </a>
</li> </li>
{% endif %} {% endif %}
{% if is_granted('ROLE_TEAMLEAD') %} {% if is_granted('view_invoice') %}
<li class="visible-xs-inline-block"> <li class="visible-xs-inline-block">
<a href="{{ path('invoice') }}" class="ddt-large"> <a href="{{ path('invoice') }}" class="ddt-large">
<i class="{{ 'invoice'|icon }} fa-2x"></i> <i class="{{ 'invoice'|icon }} fa-2x"></i>

View File

@@ -0,0 +1,249 @@
{% extends 'base.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "macros/toolbar.html.twig" as toolbar %}
{% import "macros/datatables.html.twig" as tables %}
{% block page_title %}{{ 'export.title'|trans }}{% endblock %}
{% block page_subtitle %}{{ 'export.subtitle'|trans }}{% endblock %}
{% block page_actions %}
{% set actions = {
'filter': '#collapseExport',
'fas fa-toggle-off': {'id':'export-toggle-button'}
} %}
{{ widgets.page_actions(actions) }}
{% endblock %}
{% block main_before %}
{{ toolbar.toolbar(form, 'collapseExport', true) }}
{% endblock %}
{% block main_after %}
<div class="clearfix"></div>
{% endblock %}
{% block main %}
{% if entries is empty %}
{{ widgets.callout('warning', 'error.no_entries_found') }}
{% else %}
{#
{% set columns = {'date': ''} %}
{% if not duration_only %}
{% set columns = columns|merge({'starttime': 'hidden-xs', 'endtime': 'hidden-xs'}) %}
{% endif %}
{% set columns = columns|merge({
'duration': '',
'rate': '',
'customer': 'hidden-xs hidden-sm',
'project': 'hidden-xs hidden-sm',
'activity': 'hidden-xs hidden-sm',
'username': 'hidden-xs',
'description': 'hidden-xs hidden-sm',
'actions': 'alwaysVisible',
}) %}
#}
{% set columns = {
'date': '',
'user': 'hidden-xs hidden-sm',
'customer': 'hidden-xs hidden-sm',
'project': 'hidden-xs hidden-sm',
'activity': 'hidden-xs hidden-sm',
'description': 'hidden-xs hidden-sm',
'unit_price': '',
'duration': '',
'total_rate': '',
'exported': 'alwaysVisible',
} %}
{% set tableName = 'export' %}
{{ tables.data_table_header(tableName, columns) }}
{% for entry in entries %}
{% set currency = entry.project.customer.currency %}
{% set duration = entry.duration|duration() %}
{% if entry.fixedRate is not null %}
{% set rate = entry.fixedRate %}
{% set duration = 1 %}
{% elseif entry.hourlyRate is not null %}
{% set rate = entry.hourlyRate %}
{% else %}
{% set rate = entry.user.preferenceValue('hourly_rate') %}
{% endif %}
<tr>
<td class="text-nowrap">{{ entry.begin|date_short }}</td>
<td class="hidden-xs hidden-sm">{{ widgets.label_user(entry.user) }}</td>
<td class="hidden-xs hidden-sm">{{ widgets.label_customer(entry.project.customer) }}</td>
<td class="hidden-xs hidden-sm">{{ widgets.label_project(entry.project) }}</td>
<td class="hidden-xs hidden-sm">{{ widgets.label_activity(entry.activity) }}</td>
<td class="hidden-xs hidden-sm timesheet-description">
{{ entry.description }}
</td>
<td class="text-nowrap">
{{ rate|money(currency) }}
</td>
<td class="text-nowrap">
{{ duration }}
</td>
<td class="text-nowrap">
{{ entry.rate|money(currency) }}
</td>
<td>
{% if is_granted('edit_export', entry) %}
{% if entry.exported %}
<button type="button" class="btn btn-default exportBtn active" data-toggle="button" aria-pressed="true" autocomplete="off"
data-exported-text="{{ 'entryState.exported'|trans }}" data-clean-text="{{ 'entryState.not_exported'|trans }}" data-timesheet="{{ entry.id }}">
{{ 'entryState.exported'|trans }}
</button>
{% else %}
<button type="button" class="btn btn-default exportBtn" data-toggle="button" aria-pressed="false" autocomplete="off"
data-exported-text="{{ 'entryState.exported'|trans }}" data-clean-text="{{ 'entryState.not_exported'|trans }}" data-timesheet="{{ entry.id }}">
{{ 'entryState.not_exported'|trans }}
</button>
{% endif %}
{% else %}
{% if entry.exported %}
{{ 'entryState.exported'|trans }}
{% else %}
{{ 'entryState.not_exported'|trans }}
{% endif %}
{% endif %}
</td>
</tr>
{% endfor %}
{{ tables.data_table_footer(entries) }}
{% if is_granted('create_export') %}
<div class="row no-print">
<div class="col-xs-12">
<div class="btn-group pull-right" id="export-buttons" role="group">
{% for button in renderer %}
<button type="button" id="export-{{ button.id }}-button" class="btn btn-success startExportBtn" data-type="{{ button.id }}">
<i class="{{ button.icon|icon }}"></i> {{ ('button.' ~ button.title)|trans }}
</button>
{% endfor %}
</div>
</div>
</div>
{% endif %}
{% endif %}
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script type="text/javascript">
function confirmToggleState()
{
if ($('#export-toggle-button').hasClass('export-off')) {
if (confirm('{{ 'export.clear_all'|trans }}')) {
$('.exportBtn').each(function () {
if ($(this).hasClass('active')) {
$(this).click();
}
});
return true;
}
} else {
if (confirm('{{ 'export.mark_all'|trans }}')) {
$('.exportBtn').each(function () {
if (!$(this).hasClass('active')) {
$(this).click();
}
});
return true;
}
}
return false;
}
function updateTimesheetExportState(button, id, exported)
{
$.ajax({
url: '{{ path('patch_timesheet', {id: '-s-'}) }}'.replace('-s-', id),
headers: {
'X-AUTH-SESSION': true,
'Content-Type':'application/json'
},
method: 'PATCH',
dataType: 'json',
data: JSON.stringify({'exported': exported}),
success: function(data) {
if (exported) {
button.button('exported');
} else {
button.button('clean');
}
if ($('select#exported').val() !== '{{ constant('App\\Repository\\Query\\TimesheetQuery::STATE_ALL') }}') {
button.closest('tr').hide('ease', function() {
$(this).remove();
if($(this).closest('table').find('tbody tr:visible').length === 0) {
$('#export-buttons button').prop('disabled', true);
}
});
}
}, error: function(jqXHR, textStatus, errorThrown) {
var message = 'An error occured';
if (jqXHR.responseJSON !== undefined) {
console.log(jqXHR);
message = jqXHR.responseJSON.message;
if (jqXHR.responseJSON.errors !== undefined)
{
var errors = jqXHR.responseJSON.errors.errors;
for (var i = 0; i < errors.length; i++) {
message += ' / ' + errors[i];
}
}
}
alert('{{ 'action.update.error'|trans({}, 'flashmessages') }}'.replace('%reason%', message));
button.button('reset');
if (exported) {
button.removeClass('active');
} else {
button.addClass('active');
}
}
});
}
$(document).ready(function () {
$('body').on('click', '.exportBtn', function() {
var button = $(this);
var id = button.attr('data-timesheet');
if (button.hasClass('active')) {
updateTimesheetExportState(button, id, false);
} else {
updateTimesheetExportState(button, id, true);
}
});
$('#export-toggle-button').on('click', function () {
if (!confirmToggleState()) {
return;
}
if ($(this).hasClass('export-off')) {
$(this).removeClass('export-off');
$(this).html('<i class="fas fa-toggle-off"></i>');
} else {
$(this).addClass('export-off');
$(this).html('<i class="fas fa-toggle-on"></i>');
}
});
$('body').on('click', '#export-buttons .startExportBtn', function() {
$('#type').val($(this).attr('data-type'));
var $form = $("#export-form");
var prevAction = $form.attr('action');
$form.attr('target', '_blank').attr('action', '{{ path('export_data') }}');
$form.submit();
$('#type').val('');
$form.removeAttr('target').attr('action', prevAction);
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="{{ app.request.locale }}">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport">
<link rel="stylesheet" href="{{ asset('build/app.css') }}">
<script src="{{ asset('build/app.js') }}"></script>
</head>
<body class="pad export_print">
<div class="wrapper">
<section class="export">
{% block export %}{% endblock %}
</section>
</div>
</body>
</html>

View File

@@ -0,0 +1,172 @@
{% import "macros/widgets.html.twig" as widgets %}
{% extends 'export/layout.html.twig' %}
{% block export %}
<style>
.items tr.summary {
}
.items td.totals {
font-weight: bold;
text-align: right;
}
.items td.duration,
.items th.duration,
.items th.cost,
.items td.cost {
text-align: center;
}
</style>
<div class="row">
<div class="col-xs-12">
<h2>{{ 'export.document_title'|trans }}</h2>
<p>
{{ 'export.period'|trans }}:
{{ query.begin|date_short }} - {{ query.end|date_short }}
</p>
</div>
</div>
<div class="row">
<div class="col-xs-12">
<h3>{{ 'export.summary'|trans }}</h3>
<table class="items table table-condensed table-bordered dataTable">
<thead>
<tr>
<th>{{ 'label.customer'|trans }}</th>
<th>{{ 'label.project'|trans }}</th>
<th class="duration">{{ 'label.duration'|trans }}</th>
<th class="cost">{{ 'label.rate'|trans }}</th>
</tr>
</thead>
<tbody>
{% set customer = null %}
{% set customerDuration = 0 %}
{% set customerRate = 0 %}
{% set customerCurrency = null %}
{% for summary in summaries %}
{% if customer is same as(null) %}
{% set customer = summary.customer %}
{% set customerCurrency = summary.currency %}
{% endif %}
{% if customer is not same as(summary.customer) %}
<tr class="summary">
<td colspan="2"></td>
<td class="totals duration">{{ customerDuration|duration }}</td>
<td class="totals cost">{{ customerRate|money(customerCurrency) }}</td>
</tr>
{% set customerCurrency = summary.currency %}
{% set customer = summary.customer %}
{% set customerDuration = 0 %}
{% set customerRate = 0 %}
{% endif %}
<tr>
<td>{{ summary.customer }}</td>
<td>{{ summary.project }}</td>
<td class="duration">{{ summary.duration|duration }}</td>
<td class="cost">{{ summary.rate|money(summary.currency) }}</td>
</tr>
{% set customerDuration = customerDuration + summary.duration %}
{% set customerRate = customerRate + summary.rate %}
{% endfor %}
{% if customer is not same as(null) %}
<tr class="summary">
<td colspan="2"></td>
<td class="totals duration">{{ customerDuration|duration }}</td>
<td class="totals cost">{{ customerRate|money(customerCurrency) }}</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
{% set columns = [
'label.date',
'label.begin',
'label.end',
'label.username',
'label.customer',
'label.project',
'label.activity',
'label.description',
'label.exported',
'label.hourly_rate',
'label.fixed_rate',
'label.duration',
'label.rate',
] %}
<div class="row" id="export-records">
<div class="col-xs-12">
<h3>{{ 'export.full_list'|trans }}</h3>
<table class="table table-condensed table-bordered dataTable">
<thead>
<tr>
{% for columnTitle in columns %}
<th>{{ columnTitle|trans }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% set timeWorked = 0 %}
{% set rateTotal = 0 %}
{% set currency = false %}
{% for entry in entries %}
{% set timeWorked = timeWorked + entry.duration %}
{% set rateTotal = rateTotal + entry.rate %}
{% if currency is same as(false) %}
{% set currency = entry.project.customer.currency %}
{% endif %}
{% if currency is not same as(entry.project.customer.currency) %}
{% set currency = null %}
{% endif %}
<tr>
<td class="text-nowrap">{{ entry.begin|date_short }}</td>
<td class="text-nowrap">{{ entry.begin|date("H:i") }}</td>
<td class="text-nowrap">{{ entry.end|date("H:i") }}</td>
<td>{{ widgets.username(entry.user) }}</td>
<td>{{ entry.project.customer.name }}</td>
<td>{{ entry.project.name }}</td>
<td>{{ entry.activity.name }}</td>
<td>
{% if entry.description is not empty %}
{{ entry.description|desc2html }}
{% endif %}
</td>
<td>
{% if entry.exported %}
{{ 'entryState.exported'|trans }}
{% else %}
{{ 'entryState.not_exported'|trans }}
{% endif %}
</td>
<td class="text-nowrap">{{ entry.hourlyRate|money(entry.project.customer.currency) }}</td>
<td class="text-nowrap">{{ entry.fixedRate|money(entry.project.customer.currency) }}</td>
<td class="text-nowrap">{{ entry.duration|duration }}</td>
<td class="text-nowrap">
{{ entry.rate|money(entry.project.customer.currency) }}
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<th colspan="{{ ( columns|length ) - 2 }}" class="text-right"></th>
<th class="text-nowrap">
{{ timeWorked|duration }}
</th>
<th class="text-nowrap">
{% if currency is not null and currency is not same as(false) %}
{{ rateTotal|money(currency) }}
{% else %}
{{ rateTotal|money }}
{% endif %}
</th>
</tr>
</tfoot>
</table>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,169 @@
<html>
<head>
<style>
body {font-family: sans-serif;
font-size: 10pt;
}
p { margin: 0pt; }
table.items {
border: 0.1mm solid #000000;
}
td { vertical-align: top; }
.items td {
border-left: 0.1mm solid #000000;
border-right: 0.1mm solid #000000;
}
.items tr.even {
background-color: #e0ebff;
}
.items tr.summary {
background-color: #efefef;
}
.items tr.summary td {
font-weight: bold;
border-top: 0.1mm solid #000000;
border-bottom: 0.1mm solid #000000;
}
table thead td {
background-color: #ececec;
text-align: center;
border: 0.1mm solid #000000;
font-variant: small-caps;
}
.items td.totals {
font-weight: bold;
text-align: right;
border: 0.1mm solid #000000;
}
.items td.duration,
.items td.cost {
text-align: center;
}
</style>
</head>
<body>
<!--mpdf
<htmlpagefooter name="myfooter">
<table style="border-top: 1px solid #000000; font-size: 9pt; padding-top: 3mm; width: 100%">
<tr>
<td align="left">
{{ 'export.page_of'|trans({'%page%': '{PAGENO}', '%pages%': '{nb}'}) }}
</td>
<td align="right">
{{ 'export.date_copyright'|trans({'%date%': now|date_short ~ ' ' ~ now|date('H:i'), '%kimai%': '<a href="' ~ constant('App\\Constants::HOMEPAGE') ~ '">' ~ constant('App\\Constants::SOFTWARE') ~ '</a>'})|raw }}
</td>
</tr>
</table>
</htmlpagefooter>
<sethtmlpagefooter name="myfooter" value="on" />
mpdf-->
<h2 style="margin-bottom: 0; padding-bottom: 0">{{ 'export.document_title'|trans }}</h2>
<p>
{{ 'export.period'|trans }}:
{{ query.begin|date_short }} - {{ query.end|date_short }}
</p>
<h3>{{ 'export.summary'|trans }}</h3>
<table class="items" width="100%" style="font-size: 9pt; border-collapse: collapse; " cellpadding="8">
<thead>
<tr>
<td>{{ 'label.customer'|trans }}</td>
<td>{{ 'label.project'|trans }}</td>
<td>{{ 'label.duration'|trans }}</td>
<td>{{ 'label.rate'|trans }}</td>
</tr>
</thead>
<tbody>
{% set customer = null %}
{% set customerDuration = 0 %}
{% set customerRate = 0 %}
{% set customerCurrency = null %}
{% set customerCount = 0 %}
{% for summary in summaries %}
{% if customer is same as(null) %}
{% set customer = summary.customer %}
{% set customerCurrency = summary.currency %}
{% endif %}
{% if customer is not same as(summary.customer) %}
<tr class="summary">
<td colspan="2"></td>
<td class="totals duration">{{ customerDuration|duration }}</td>
<td class="totals cost">{{ customerRate|money(customerCurrency) }}</td>
</tr>
{% set customerCurrency = summary.currency %}
{% set customer = summary.customer %}
{% set customerDuration = 0 %}
{% set customerRate = 0 %}
{% set customerCount = 0 %}
{% endif %}
<tr class="{{ cycle(['odd', 'even'], customerCount) }}">
<td>{{ summary.customer }}</td>
<td>{{ summary.project }}</td>
<td class="duration">{{ summary.duration|duration }}</td>
<td class="cost">{{ summary.rate|money(summary.currency) }}</td>
</tr>
{% set customerDuration = customerDuration + summary.duration %}
{% set customerRate = customerRate + summary.rate %}
{% set customerCount = customerCount + 1 %}
{% endfor %}
{% if customer is not same as(null) %}
<tr class="summary">
<td colspan="2"></td>
<td class="totals duration">{{ customerDuration|duration }}</td>
<td class="totals cost">{{ customerRate|money(customerCurrency) }}</td>
</tr>
{% endif %}
</tbody>
</table>
<pagebreak>
<h3>{{ 'export.full_list'|trans }}</h3>
{% set duration = 0 %}
{% set rate = 0 %}
{% set currency = false %}
<table class="items" width="100%" style="font-size: 9pt; border-collapse: collapse; " cellpadding="8">
<thead>
<tr>
<td>{{ 'label.date'|trans }}</td>
<td width="">{{ 'label.description'|trans }}</td>
<td>{{ 'label.duration'|trans }}</td>
<td>{{ 'label.rate'|trans }}</td>
</tr>
</thead>
<tbody>
{% for entry in entries %}
{% set duration = duration + entry.duration %}
{% set rate = rate + entry.rate %}
{% if currency is same as(false) %}
{% set currency = entry.project.customer.currency %}
{% endif %}
{% if currency is not same as(entry.project.customer.currency) %}
{% set currency = null %}
{% endif %}
<tr class="{{ cycle(['odd', 'even'], loop.index0) }}">
<td>
{{ entry.begin|date_time }}
<br>
{{ entry.end|date_time }}
</td>
<td>
{{ entry.project.customer.name }} - {{ entry.project.name }} - {{ entry.activity.name }}
{% if entry.description is not empty %}
<br>
<i>{{ entry.description|desc2html }}</i>
{% endif %}
</td>
<td class="duration">{{ entry.duration|duration }}</td>
<td class="cost">{{ entry.rate|money(entry.project.customer.currency) }}</td>
</tr>
{% endfor %}
<tr class="summary">
<td colspan="2"></td>
<td class="totals duration">{{ duration|duration }}</td>
<td class="totals cost">{{ rate|money(currency) }}</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -1,6 +1,7 @@
{% extends 'base.html.twig' %} {% extends 'base.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %} {% import "macros/widgets.html.twig" as widgets %}
{% import "macros/toolbar.html.twig" as toolbar %} {% import "macros/toolbar.html.twig" as toolbar %}
{% import "macros/datatables.html.twig" as tables %}
{% block page_title %}{{ 'invoice.title'|trans }}{% endblock %} {% block page_title %}{{ 'invoice.title'|trans }}{% endblock %}
{% block page_subtitle %}{{ 'invoice.subtitle'|trans }}{% endblock %} {% block page_subtitle %}{{ 'invoice.subtitle'|trans }}{% endblock %}
@@ -15,8 +16,6 @@
{{ widgets.page_actions(actions) }} {{ widgets.page_actions(actions) }}
{% endblock %} {% endblock %}
{% block page_content_class %}{{ parent() }} invoice{% endblock %}
{% block main_before %} {% block main_before %}
{{ toolbar.toolbar(form, 'collapseInvoice', true) }} {{ toolbar.toolbar(form, 'collapseInvoice', true) }}
{% endblock %} {% endblock %}
@@ -27,10 +26,54 @@
{% block main %} {% block main %}
{% if model.entries is empty %} {% set entries = {} %}
{{ widgets.callout('warning', 'invoice.select_filter') }} {% if model.calculator is not empty and model.calculator.entries is not empty %}
{% set entries = model.calculator.entries %}
{% endif %}
{% if entries is empty %}
{{ widgets.callout('warning', 'error.no_entries_found') }}
{% else %} {% else %}
{{ include('invoice/preview.html.twig') }} {{ widgets.callout('success', 'invoice.preview') }}
{% set columns = {
'date': '',
'user': 'hidden-xs hidden-sm',
'activity': 'hidden-xs hidden-sm',
'unit_price': 'text-center',
'amount': 'text-center',
'total_rate': 'text-right',
} %}
{% set tableName = 'invoice' %}
{{ tables.data_table_header(tableName, columns) }}
{% for entry in entries %}
{% set duration = entry.duration|duration() %}
{% if entry.fixedRate is not null %}
{% set rate = entry.fixedRate %}
{% set duration = 1 %}
{% elseif entry.hourlyRate is not null %}
{% set rate = entry.hourlyRate %}
{% else %}
{% set rate = entry.user.preferenceValue('hourly_rate') %}
{% endif %}
<tr>
<td>{{ entry.begin|date_short }}</td>
<td class="hidden-xs hidden-sm">{{ widgets.username(entry.user) }}</td>
<td class="hidden-xs hidden-sm timesheet-description">
{% if entry.description is not empty %}
{{ entry.description|desc2html }}
{% else %}
{{ entry.activity.name }} / {{ entry.project.name }}
{% endif %}
</td>
<td class="text-center">{{ rate|money(model.calculator.currency) }}</td>
<td class="text-center">{{ duration }}</td>
<td class="text-right">{{ entry.rate|money(model.calculator.currency) }}</td>
</tr>
{% endfor %}
{{ tables.data_table_footer(entries) }}
{% if is_granted('create_invoice') %} {% if is_granted('create_invoice') %}
<div class="row no-print"> <div class="row no-print">

View File

@@ -1,52 +0,0 @@
{% import "macros/widgets.html.twig" as widgets %}
{{ widgets.callout('success', 'invoice.preview') }}
<div class="row">
<div class="col-md-12">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>{{ 'label.date'|trans }}</th>
<th class="hidden-xs hidden-sm">{{ 'label.user'|trans }}</th>
<th class="hidden-xs hidden-sm">{{ 'label.activity'|trans }}</th>
<th class="text-center">{{ 'invoice.unit_price'|trans }}</th>
<th class="text-center">{{ 'invoice.amount'|trans }}</th>
<th class="text-right">{{ 'invoice.total_rate'|trans }}</th>
</tr>
</thead>
<tbody>
{% for entry in model.calculator.entries %}
{% set duration = entry.duration|duration() %}
{% if entry.fixedRate is not null %}
{% set rate = entry.fixedRate %}
{% set duration = 1 %}
{% elseif entry.hourlyRate is not null %}
{% set rate = entry.hourlyRate %}
{% else %}
{% set rate = app.user.getPreferenceValue('hourly_rate') %}
{% endif %}
<tr>
<td>{{ entry.begin|date_short }}</td>
<td class="hidden-xs hidden-sm">{{ widgets.username(entry.user) }}</td>
<td class="hidden-xs hidden-sm timesheet-description">
{% if entry.description is not empty %}
{{ entry.description|desc2html }}
{% else %}
{{ entry.activity.name }} / {{ entry.project.name }}
{% endif %}
</td>
<td class="text-center" contenteditable="true">
{{ rate|money(model.calculator.currency) }}
</td>
<td class="text-center">
{{ duration }}
</td>
<td class="text-right">{{ entry.rate|money(model.calculator.currency) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>

View File

@@ -57,9 +57,9 @@
<tr> <tr>
<th>{{ 'label.date'|trans }}</th> <th>{{ 'label.date'|trans }}</th>
<th>{{ 'label.activity'|trans }}</th> <th>{{ 'label.activity'|trans }}</th>
<th>{{ 'invoice.unit_price'|trans }}</th> <th>{{ 'label.unit_price'|trans }}</th>
<th>{{ 'label.hours'|trans }}</th> <th>{{ 'label.hours'|trans }}</th>
<th>{{ 'invoice.total_rate'|trans }}</th> <th>{{ 'label.total_rate'|trans }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>

View File

@@ -70,9 +70,9 @@
<thead> <thead>
<tr> <tr>
<th>{{ 'label.description'|trans }}</th> <th>{{ 'label.description'|trans }}</th>
<th>{{ 'invoice.unit_price'|trans }}</th> <th>{{ 'label.unit_price'|trans }}</th>
<th>{{ 'invoice.amount'|trans }}</th> <th>{{ 'label.amount'|trans }}</th>
<th>{{ 'invoice.total_rate'|trans }}</th> <th>{{ 'label.total_rate'|trans }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>

View File

@@ -89,11 +89,9 @@
<table class="table table-striped table-hover dataTable" role="grid"> <table class="table table-striped table-hover dataTable" role="grid">
<thead> <thead>
<tr> <tr>
{% for title, class in entries %} {%- for title, class in entries -%}
<th class="{{ macro.data_table_column_class(name, entries, title) }}"> <th class="{{ macro.data_table_column_class(name, entries, title) }}">{{ ('label.' ~ title)|trans }}</th>
{{ ('label.' ~ title)|trans }} {%- endfor -%}
</th>
{% endfor %}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -104,23 +102,12 @@
</table> </table>
</div> </div>
</div> </div>
{#<div class="row">
<div class="col-sm-5">
{#<div class="dataTables_info" id="example2_info" role="status" aria-live="polite">
{{ 'datatables.entry_counter'|trans({'%from%': '1', '%to%': '10', '%total%': entries.count}) }}
</div># }
</div>
<div class="col-sm-7">
<div class="dataTables_paginate paging_simple_numbers">
{{ pagerfanta(entries, 'twitter_bootstrap3_translated', { routeName: route }) }}
</div>
</div>
</div>#}
</div> </div>
</div> </div>
</div> </div>
{% if route is not empty %}
<div class="navigation text-center no-print"> <div class="navigation text-center no-print">
{{ pagerfanta(entries, 'twitter_bootstrap3_translated', { proximity: 1, routeName: route }) }} {{ pagerfanta(entries, 'twitter_bootstrap3_translated', { proximity: 1, routeName: route }) }}
</div> </div>
{% endif %}
{% endmacro %} {% endmacro %}

View File

@@ -2,26 +2,58 @@
<i class="{{ icon|icon(icon) }}"></i> <i class="{{ icon|icon(icon) }}"></i>
{% endmacro %} {% endmacro %}
{% macro page_actions(tools) %} {%- macro page_actions(tools) -%}
<div class="breadcrumb"> <div class="breadcrumb">
<div class="box-tools"> <div class="box-tools">
<div class="btn-group"> <div class="btn-group">
{%- for icon,url in tools %} {%- for icon,values in tools %}
<a class="btn btn-default" href="{{ url }}" {% spaceless %}
{% if '#collapse' in url %} {% set id = null %}
data-toggle="collapse" {% set onclick = null %}
{% elseif '#modal' in url %} {% set modal = null %}
data-toggle="modal" data-target="{{ url }}" {% set toggle = null %}
{% endif %} {% set url = null %}
{% if not values is iterable %}
{% set url = values %}
{% if 'onclick:' in url %} {% if 'onclick:' in url %}
onclick="{{ url|replace({'onclick:': ''}) }}" {% set onclick = url|replace({'onclick:': ''}) %}
{% set url = '#' %}
{% endif %} {% endif %}
{% if '#collapse' in url %}
{% set toggle = 'collapse' %}
{% endif %}
{% if '#modal' in url %}
{% set modal = url %}
{% set url = '#' %}
{% endif %}
{% else %}
{% set url = values.url ?? '#' %}
{% set onclick = values.onclick ?? null %}
{% set modal = values.modal ?? null %}
{% set toggle = values.toggle ?? null %}
{% set id = values.id ?? null %}
{% endif %}
{% endspaceless %}
<a class="btn btn-default" href="{{ url }}"
{%- if id is not empty -%}
id="{{ id }}"
{%- endif -%}
{%- if toggle is not empty -%}
data-toggle="{{ toggle }}"
{%- endif -%}
{%- if modal is not empty -%}
data-toggle="modal" data-target="{{ modal }}"
{%- endif -%}
{%- if onclick is not empty -%}
onclick="{{ onclick }}"
{%- endif -%}
><i class="{{ icon|icon(icon) }}"></i></a> ><i class="{{ icon|icon(icon) }}"></i></a>
{% endfor -%} {% endfor -%}
</div> </div>
</div> </div>
</div> </div>
{% endmacro %} {%- endmacro -%}
{% macro page_header(title) %} {% macro page_header(title) %}
<h2 class="page-header">{{ title|trans }}</h2> <h2 class="page-header">{{ title|trans }}</h2>

View File

@@ -18,7 +18,12 @@
<i class="{{ 'stop-small'|icon }} fa-2x"></i> <i class="{{ 'stop-small'|icon }} fa-2x"></i>
</div> </div>
<h4> <h4>
{# TODO find a CSS solution for this #}
{% if entry.activity.name|length > 23 %}
{{ entry.activity.name|slice(0,20) }}...
{% else %}
{{ entry.activity.name }} {{ entry.activity.name }}
{% endif %}
<small><i class="{{ 'timesheet'|icon }}"></i> {{ entry|duration }}</small> <small><i class="{{ 'timesheet'|icon }}"></i> {{ entry|duration }}</small>
</h4> </h4>
<p>{{ entry.project.name }} ({{ entry.project.customer.name }})</p> <p>{{ entry.project.name }} ({{ entry.project.customer.name }})</p>

View File

@@ -28,7 +28,7 @@
</div> </div>
</a> </a>
</li> </li>
{% if is_granted('ROLE_SUPER_ADMIN') %} {% if is_granted('system_information') %}
<li> <li>
<a href="{{ path('about') }}"> <a href="{{ path('about') }}">
<i class="menu-icon far fa-copyright bg-aqua"></i> <i class="menu-icon far fa-copyright bg-aqua"></i>

View File

@@ -26,8 +26,7 @@
{% if entries.count == 0 %} {% if entries.count == 0 %}
{{ widgets.callout('warning', 'error.no_entries_found') }} {{ widgets.callout('warning', 'error.no_entries_found') }}
{% endif %} {% else %}
{% set columns = {'date': ''} %} {% set columns = {'date': ''} %}
{% if not duration_only %} {% if not duration_only %}
@@ -54,7 +53,7 @@
{% for entry in entries %} {% for entry in entries %}
<tr> <tr>
<td class="{{ tables.data_table_column_class(tableName, columns, 'date') }}">{{ entry.begin|date_short }}</td> <td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'date') }}">{{ entry.begin|date_short }}</td>
{% if not duration_only %} {% if not duration_only %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'starttime') }}">{{ entry.begin|date("H:i") }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'starttime') }}">{{ entry.begin|date("H:i") }}</td>
@@ -64,15 +63,15 @@
{% if not duration_only %} {% if not duration_only %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'endtime') }}">{{ entry.end|date("H:i") }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'endtime') }}">{{ entry.end|date("H:i") }}</td>
{% endif %} {% endif %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'duration') }}">{{ entry.duration|duration }}</td> <td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'duration') }}">{{ entry.duration|duration }}</td>
{% if is_granted('view_rate', entry) %} {% if is_granted('view_rate', entry) %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'rate') }}">{{ entry.rate|money(entry.project.customer.currency) }}</td> <td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'rate') }}">{{ entry.rate|money(entry.project.customer.currency) }}</td>
{% endif %} {% endif %}
{% else %} {% else %}
{% if not duration_only %} {% if not duration_only %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'endtime') }}">&dash;</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'endtime') }}">&dash;</td>
{% endif %} {% endif %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'duration') }}"><i>{{ entry|duration }}</i></td> <td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'duration') }}"><i>{{ entry|duration }}</i></td>
{% if is_granted('view_rate', entry) %} {% if is_granted('view_rate', entry) %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'rate') }}">&dash;</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'rate') }}">&dash;</td>
{% endif %} {% endif %}
@@ -107,6 +106,7 @@
{% endfor %} {% endfor %}
{{ tables.data_table_footer(entries, 'timesheet_paginated') }} {{ tables.data_table_footer(entries, 'timesheet_paginated') }}
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -54,6 +54,19 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->assertDefaultStructure($result[0], false); $this->assertDefaultStructure($result[0], false);
} }
public function testGetCollectionWithQuery()
{
$query = ['customer' => 1, 'project' => 1, 'page' => 2, 'size' => 5, 'order' => 'DESC', 'orderBy' => 'rate'];
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->assertAccessIsGranted($client, '/api/timesheets', 'GET', $query);
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertInternalType('array', $result);
$this->assertNotEmpty($result);
$this->assertEquals(5, count($result));
$this->assertDefaultStructure($result[0], false);
}
public function testGetEntity() public function testGetEntity()
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
@@ -166,6 +179,45 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->assertEntityNotFound(User::ROLE_USER, '/api/timesheets/20'); $this->assertEntityNotFound(User::ROLE_USER, '/api/timesheets/20');
} }
public function testPatchAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$data = [
'activity' => 1,
'project' => 1,
'begin' => (new \DateTime('- 7 hours'))->format('Y-m-d H:m'),
'end' => (new \DateTime())->format('Y-m-d H:m'),
'description' => 'foo',
'exported' => true,
];
$this->request($client, '/api/timesheets/1', 'PATCH', [], json_encode($data));
$this->assertTrue($client->getResponse()->isSuccessful());
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertInternalType('array', $result);
$this->assertDefaultStructure($result);
$this->assertNotEmpty($result['id']);
$this->assertEquals(25200, $result['duration']);
$this->assertEquals(1, $result['exported']);
}
public function testInvalidPatchAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$data = [
'activity' => 10,
'project' => 1,
'begin' => (new \DateTime())->format('Y-m-d H:m'),
'end' => (new \DateTime('- 7 hours'))->format('Y-m-d H:m'),
'description' => 'foo',
];
$this->request($client, '/api/timesheets/1', 'PATCH', [], json_encode($data));
$response = $client->getResponse();
$this->assertEquals(400, $response->getStatusCode());
$this->assertApiCallValidationError($response, ['end', 'activity']);
}
protected function assertDefaultStructure(array $result, $full = true) protected function assertDefaultStructure(array $result, $full = true)
{ {
$expectedKeys = [ $expectedKeys = [
@@ -174,7 +226,7 @@ class TimesheetControllerTest extends APIControllerBaseTest
if ($full) { if ($full) {
$expectedKeys = array_merge($expectedKeys, [ $expectedKeys = array_merge($expectedKeys, [
'description', 'fixed_rate', 'hourly_rate' 'exported', 'description', 'fixed_rate', 'hourly_rate'
]); ]);
} }

View File

@@ -123,8 +123,8 @@ class ActivityControllerTest extends ControllerBaseTest
$this->request($client, '/admin/activity/1/delete'); $this->request($client, '/admin/activity/1/delete');
$this->assertIsRedirect($client, $this->createUrl('/admin/activity/')); $this->assertIsRedirect($client, $this->createUrl('/admin/activity/'));
$client->followRedirect(); $client->followRedirect();
$this->assertHasDataTable($client); $this->assertHasFlashDeleteSuccess($client);
$this->assertHasFlashSuccess($client); $this->assertHasNoEntriesWithFilter($client);
$this->request($client, '/admin/activity/1/edit'); $this->request($client, '/admin/activity/1/edit');
$this->assertFalse($client->getResponse()->isSuccessful()); $this->assertFalse($client->getResponse()->isSuccessful());
@@ -157,8 +157,8 @@ class ActivityControllerTest extends ControllerBaseTest
$this->assertIsRedirect($client, $this->createUrl('/admin/activity/')); $this->assertIsRedirect($client, $this->createUrl('/admin/activity/'));
$client->followRedirect(); $client->followRedirect();
$this->assertHasDataTable($client); $this->assertHasFlashDeleteSuccess($client);
$this->assertHasFlashSuccess($client); $this->assertHasNoEntriesWithFilter($client);
// SQLIte does not necessarly support onCascade delete, so these timesheet will stay after deletion // SQLIte does not necessarly support onCascade delete, so these timesheet will stay after deletion
// $em->clear(); // $em->clear();

View File

@@ -130,8 +130,8 @@ class CustomerControllerTest extends ControllerBaseTest
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/')); $this->assertIsRedirect($client, $this->createUrl('/admin/customer/'));
$client->followRedirect(); $client->followRedirect();
$this->assertHasDataTable($client); $this->assertHasFlashDeleteSuccess($client);
$this->assertHasFlashSuccess($client); $this->assertHasNoEntriesWithFilter($client);
// SQLIte does not necessarly support onCascade delete, so these timesheet will stay after deletion // SQLIte does not necessarly support onCascade delete, so these timesheet will stay after deletion
// $em->clear(); // $em->clear();

View File

@@ -153,8 +153,8 @@ class ProjectControllerTest extends ControllerBaseTest
$this->assertIsRedirect($client, $this->createUrl('/admin/project/')); $this->assertIsRedirect($client, $this->createUrl('/admin/project/'));
$client->followRedirect(); $client->followRedirect();
$this->assertHasDataTable($client); $this->assertHasFlashDeleteSuccess($client);
$this->assertHasFlashSuccess($client); $this->assertHasNoEntriesWithFilter($client);
// SQLIte does not necessarly support onCascade delete, so these timesheet will stay after deletion // SQLIte does not necessarly support onCascade delete, so these timesheet will stay after deletion
// $em->clear(); // $em->clear();

View File

@@ -31,7 +31,10 @@ class TimesheetControllerTest extends ControllerBaseTest
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD); $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$this->assertAccessIsGranted($client, '/team/timesheet/'); $this->assertAccessIsGranted($client, '/team/timesheet/');
$this->assertHasDataTable($client); $this->assertTrue($client->getResponse()->isSuccessful());
// there are no records by default in the test database
$this->assertHasNoEntriesWithFilter($client);
$result = $client->getCrawler()->filter('div.breadcrumb div.box-tools div.btn-group a.btn'); $result = $client->getCrawler()->filter('div.breadcrumb div.box-tools div.btn-group a.btn');
$this->assertEquals(4, count($result)); $this->assertEquals(4, count($result));
@@ -47,9 +50,10 @@ class TimesheetControllerTest extends ControllerBaseTest
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD); $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$em = $client->getContainer()->get('doctrine.orm.entity_manager'); $em = $client->getContainer()->get('doctrine.orm.entity_manager');
$user = $this->getUserByRole($em, User::ROLE_USER);
$fixture = new TimesheetFixtures(); $fixture = new TimesheetFixtures();
$fixture->setAmount(10); $fixture->setAmount(10);
$fixture->setUser($this->getUserByRole($em, User::ROLE_USER)); $fixture->setUser($user);
$fixture->setStartDate(new \DateTime('-10 days')); $fixture->setStartDate(new \DateTime('-10 days'));
$this->importFixture($em, $fixture); $this->importFixture($em, $fixture);
@@ -61,7 +65,7 @@ class TimesheetControllerTest extends ControllerBaseTest
$form = $client->getCrawler()->filter('form.navbar-form')->form(); $form = $client->getCrawler()->filter('form.navbar-form')->form();
$client->submit($form, [ $client->submit($form, [
'state' => 1, 'state' => 1,
'user' => 1, 'user' => $user->getId(),
'pageSize' => 25, 'pageSize' => 25,
'daterange' => $dateRange, 'daterange' => $dateRange,
'customer' => null, 'customer' => null,
@@ -69,8 +73,7 @@ class TimesheetControllerTest extends ControllerBaseTest
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
$this->assertHasDataTable($client); $this->assertHasDataTable($client);
$this->assertDataTableRowCount($client, 'datatable_timesheet_admin', 10);
// TODO more assertions
} }
public function testExportAction() public function testExportAction()

View File

@@ -178,6 +178,17 @@ abstract class ControllerBaseTest extends WebTestCase
$this->assertContains('<table class="table table-striped table-hover dataTable" role="grid">', $client->getResponse()->getContent()); $this->assertContains('<table class="table table-striped table-hover dataTable" role="grid">', $client->getResponse()->getContent());
} }
/**
* @param Client $client
* @param string $id
* @param int $count
*/
protected function assertDataTableRowCount(Client $client, string $id, int $count)
{
$node = $client->getCrawler()->filter('section.content div#' . $id . ' table.table-striped tbody tr');
$this->assertEquals($count, $node->count());
}
/** /**
* @param string $role the USER role to use for the request * @param string $role the USER role to use for the request
* @param string $url the URL of the page displaying the initial form to submit * @param string $url the URL of the page displaying the initial form to submit
@@ -221,13 +232,32 @@ abstract class ControllerBaseTest extends WebTestCase
} }
} }
protected function assertHasNoEntriesWithFilter(Client $client)
{
$node = $client->getCrawler()->filter('div.callout.callout-warning.lead');
$this->assertContains('No entries were found based on your selected filters.', $node->text());
}
/** /**
* @param Client $client * @param Client $client
* @param string|null $message
*/ */
protected function assertHasFlashSuccess(Client $client) protected function assertHasFlashDeleteSuccess(Client $client)
{
$this->assertHasFlashSuccess($client, 'Entry was deleted successful');
}
/**
* @param Client $client
* @param string|null $message
*/
protected function assertHasFlashSuccess(Client $client, string $message = null)
{ {
$node = $client->getCrawler()->filter('div.alert.alert-success.alert-dismissible'); $node = $client->getCrawler()->filter('div.alert.alert-success.alert-dismissible');
$this->assertNotEmpty($node->text()); $this->assertNotEmpty($node->text());
if (null !== $message) {
$this->assertContains($message, $node->text());
}
} }
/** /**

View File

@@ -0,0 +1,134 @@
<?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\Controller;
use App\Entity\User;
use App\Tests\DataFixtures\TimesheetFixtures;
/**
* @coversDefaultClass \App\Controller\ExportController
* @group integration
*/
class ExportControllerTest extends ControllerBaseTest
{
public function testIsSecure()
{
$this->assertUrlIsSecured('/export/');
$this->assertUrlIsSecuredForRole(User::ROLE_USER, '/export/');
}
public function testIndexActionHasErrorMessageOnEmptyQuery()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$this->request($client, '/export/');
$this->assertTrue($client->getResponse()->isSuccessful());
$this->assertHasNoEntriesWithFilter($client);
}
public function testIndexActionWithEntries()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
$begin = new \DateTime('first day of this month');
$end = new \DateTime('last day of this month');
$fixture = new TimesheetFixtures();
$fixture
->setUser($this->getUserByRole($em, User::ROLE_USER))
->setAmount(20)
->setStartDate($begin)
;
$this->importFixture($em, $fixture);
$this->request($client, '/export/');
$this->assertTrue($client->getResponse()->isSuccessful());
// make sure all existing records are displayed
$this->assertHasDataTable($client);
$this->assertDataTableRowCount($client, 'datatable_export', 20);
// assert export type buttons are available
$expected = ['csv', 'html', 'pdf', 'ods', 'xlsx'];
$node = $client->getCrawler()->filter('#export-buttons button');
$this->assertEquals(count($expected), $node->count());
foreach ($node->getIterator() as $button) {
$type = $button->getAttribute('data-type');
$this->assertContains($type, $expected);
}
}
public function testExportActionWithMissingRenderer()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$this->request($client, '/export/data');
$response = $client->getResponse();
$this->assertFalse($response->isSuccessful());
$this->assertEquals(404, $response->getStatusCode());
}
public function testExportActionWithInvalidRenderer()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$this->request($client, '/export/');
$this->assertTrue($client->getResponse()->isSuccessful());
$form = $client->getCrawler()->filter('#export-form')->form();
$form->getFormNode()->setAttribute('action', $this->createUrl('/export/data'));
$client->submit($form, [
'type' => 'default'
]);
$response = $client->getResponse();
$this->assertFalse($response->isSuccessful());
$this->assertEquals(404, $response->getStatusCode());
}
public function testExportAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
$begin = new \DateTime('first day of this month');
$fixture = new TimesheetFixtures();
$fixture
->setUser($this->getUserByRole($em, User::ROLE_USER))
->setAmount(20)
->setStartDate($begin)
;
$this->importFixture($em, $fixture);
$this->request($client, '/export/');
$this->assertTrue($client->getResponse()->isSuccessful());
$form = $client->getCrawler()->filter('#export-form')->form();
$form->getFormNode()->setAttribute('action', $this->createUrl('/export/data'));
// don't add daterange to make sure the current month is the default range
$client->submit($form, [
'type' => 'html'
]);
$response = $client->getResponse();
$this->assertTrue($response->isSuccessful());
$node = $client->getCrawler()->filter('body');
$this->assertEquals(1, $node->count());
// poor mans assertions ;-)
$this->assertContains('export_print', $node->getIterator()[0]->getAttribute('class'));
$this->assertContains('<h2>List of expenses</h2>', $response->getContent());
$this->assertContains('<h3>Summary</h3>', $response->getContent());
$node = $client->getCrawler()->filter('section.export div#export-records table.dataTable tbody tr');
$this->assertEquals(20, $node->count());
}
}

View File

@@ -46,9 +46,7 @@ class InvoiceControllerTest extends ControllerBaseTest
$this->request($client, '/invoice/'); $this->request($client, '/invoice/');
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
$node = $client->getCrawler()->filter('div.callout.callout-warning.lead'); $this->assertHasNoEntriesWithFilter($client);
$this->assertNotEmpty($node->text());
$this->assertContains('No invoice entries were found based on your selected filters.', $node->text());
} }
public function testListTemplateAction() public function testListTemplateAction()
@@ -150,6 +148,7 @@ class InvoiceControllerTest extends ControllerBaseTest
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
// no datatable should be displayed
$node = $client->getCrawler()->filter('div.callout.callout-warning.lead'); $node = $client->getCrawler()->filter('div.callout.callout-warning.lead');
$this->assertEquals(0, $node->count()); $this->assertEquals(0, $node->count());
@@ -157,8 +156,7 @@ class InvoiceControllerTest extends ControllerBaseTest
$this->assertNotEmpty($node->text()); $this->assertNotEmpty($node->text());
$this->assertContains('This is a preview of the data that will show up in your invoice document.', $node->text()); $this->assertContains('This is a preview of the data that will show up in your invoice document.', $node->text());
$node = $client->getCrawler()->filter('section.invoice div.table-responsive table.table-striped tbody tr'); $this->assertDataTableRowCount($client, 'datatable_invoice', 20);
$this->assertEquals(20, $node->count());
$form = $client->getCrawler()->filter('#invoice-print-form')->form(); $form = $client->getCrawler()->filter('#invoice-print-form')->form();
$form->getFormNode()->setAttribute('action', $this->createUrl('/invoice/print')); $form->getFormNode()->setAttribute('action', $this->createUrl('/invoice/print'));

View File

@@ -30,7 +30,9 @@ class TimesheetControllerTest extends ControllerBaseTest
$client = $this->getClientForAuthenticatedUser(); $client = $this->getClientForAuthenticatedUser();
$this->request($client, '/timesheet/'); $this->request($client, '/timesheet/');
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
$this->assertHasDataTable($client);
// there are no records by default in the test database
$this->assertHasNoEntriesWithFilter($client);
$result = $client->getCrawler()->filter('div.breadcrumb div.box-tools div.btn-group a.btn'); $result = $client->getCrawler()->filter('div.breadcrumb div.box-tools div.btn-group a.btn');
$this->assertEquals(5, count($result)); $this->assertEquals(5, count($result));
@@ -43,7 +45,7 @@ class TimesheetControllerTest extends ControllerBaseTest
public function testIndexActionWithQuery() public function testIndexActionWithQuery()
{ {
$client = $this->getClientForAuthenticatedUser(); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$em = $client->getContainer()->get('doctrine.orm.entity_manager'); $em = $client->getContainer()->get('doctrine.orm.entity_manager');
$fixture = new TimesheetFixtures(); $fixture = new TimesheetFixtures();
@@ -67,8 +69,7 @@ class TimesheetControllerTest extends ControllerBaseTest
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
$this->assertHasDataTable($client); $this->assertHasDataTable($client);
$this->assertDataTableRowCount($client, 'datatable_timesheet', 5);
// TODO more assertions
} }
public function testExportAction() public function testExportAction()

View File

@@ -0,0 +1,148 @@
<?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\Export\Renderer;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Export\RendererInterface;
use App\Repository\Query\TimesheetQuery;
use App\Twig\DateExtensions;
use App\Twig\Extensions;
use App\Utils\LocaleSettings;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Translation\TranslatorInterface;
abstract class AbstractRendererTest extends KernelTestCase
{
/**
* @param string $classname
* @return RendererInterface
*/
protected function getAbstractRenderer(string $classname)
{
$requestStack = new RequestStack();
$languages = [
'en' => [
'date' => 'Y.m.d',
'duration' => '%h:%m h'
]
];
$request = new Request();
$request->setLocale('en');
$requestStack->push($request);
$localeSettings = new LocaleSettings($requestStack, $languages);
$translator = $this->getMockBuilder(TranslatorInterface::class)->getMock();
$dateExtension = new DateExtensions($localeSettings);
$extensions = new Extensions($requestStack, $localeSettings);
return new $classname($translator, $dateExtension, $extensions);
}
/**
* @param RendererInterface $renderer
* @return \Symfony\Component\HttpFoundation\Response
*/
protected function render(RendererInterface $renderer)
{
$customer = new Customer();
$customer->setName('Customer Name');
$project = new Project();
$project->setName('project name');
$project->setCustomer($customer);
$activity = new Activity();
$activity->setName('activity description');
$activity->setProject($project);
$userMethods = ['getId', 'getPreferenceValue', 'getUsername'];
$user1 = $this->getMockBuilder(User::class)->setMethods($userMethods)->disableOriginalConstructor()->getMock();
$user1->method('getId')->willReturn(1);
$user1->method('getPreferenceValue')->willReturn('50');
$user1->method('getUsername')->willReturn('foo-bar');
$user2 = $this->getMockBuilder(User::class)->setMethods($userMethods)->disableOriginalConstructor()->getMock();
$user2->method('getId')->willReturn(2);
$user2->method('getUsername')->willReturn('hello-world');
$timesheet = new Timesheet();
$timesheet
->setDuration(3600)
->setRate(293.27)
->setUser($user1)
->setActivity($activity)
->setProject($project)
->setBegin(new \DateTime())
->setEnd(new \DateTime())
;
$timesheet2 = new Timesheet();
$timesheet2
->setDuration(400)
->setRate(84.75)
->setUser($user2)
->setActivity($activity)
->setProject($project)
->setBegin(new \DateTime())
->setEnd(new \DateTime())
;
$timesheet3 = new Timesheet();
$timesheet3
->setDuration(1800)
->setRate(111.11)
->setUser($user1)
->setActivity($activity)
->setProject($project)
->setBegin(new \DateTime())
->setEnd(new \DateTime())
;
$timesheet4 = new Timesheet();
$timesheet4
->setDuration(400)
->setRate(1947.99)
->setUser($user2)
->setActivity($activity)
->setProject($project)
->setBegin(new \DateTime())
->setEnd(new \DateTime())
;
$timesheet5 = new Timesheet();
$timesheet5
->setDuration(400)
->setFixedRate(84)
->setUser((new User())->setUsername('kevin'))
->setActivity($activity)
->setProject($project)
->setBegin(new \DateTime())
->setEnd(new \DateTime())
;
$entries = [$timesheet, $timesheet2, $timesheet3, $timesheet4, $timesheet5];
$query = new TimesheetQuery();
$query->setActivity($activity);
$query->setBegin(new \DateTime());
$query->setEnd(new \DateTime());
$query->setProject($project);
return $renderer->render($entries, $query);
}
}

View File

@@ -0,0 +1,71 @@
<?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\Export\Renderer;
use App\Export\Renderer\CsvRenderer;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* @covers \App\Export\Renderer\CsvRenderer
* @covers \App\Export\Renderer\AbstractSpreadsheetRenderer
* @covers \App\Export\Renderer\RendererTrait
*/
class CsvRendererTest extends AbstractRendererTest
{
public function testConfiguration()
{
$sut = $this->getAbstractRenderer(CsvRenderer::class);
$this->assertEquals('csv', $sut->getId());
$this->assertEquals('csv', $sut->getTitle());
$this->assertEquals('csv', $sut->getIcon());
}
public function getTestModel()
{
return [
['01:50 h', '2,437.12 €', '1,947.99 €', 7, 5, 1, 2, 2]
];
}
/**
* @dataProvider getTestModel
*/
public function testRender($totalDuration, $totalRate, $expectedRate, $expectedRows, $expectedDescriptions, $expectedUser1, $expectedUser2, $expectedUser3)
{
$sut = $this->getAbstractRenderer(CsvRenderer::class);
/** @var BinaryFileResponse $response */
$response = $this->render($sut);
$file = $response->getFile();
$this->assertEquals('text/csv', $response->headers->get('Content-Type'));
$this->assertEquals('attachment; filename=kimai-export.csv', $response->headers->get('Content-Disposition'));
$this->assertTrue(file_exists($file->getRealPath()));
$content = file_get_contents($file->getRealPath());
$this->assertContains('"' . $totalDuration . '"', $content);
$this->assertContains('"' . $totalRate . '"', $content);
$this->assertContains('"' . $expectedRate . '"', $content);
$this->assertEquals($expectedRows, substr_count($content, PHP_EOL));
$this->assertEquals($expectedDescriptions, substr_count($content, 'activity description'));
$this->assertEquals($expectedUser1, substr_count($content, ',"kevin",'));
$this->assertEquals($expectedUser3, substr_count($content, ',"hello-world",'));
$this->assertEquals($expectedUser2, substr_count($content, ',"foo-bar",'));
ob_start();
$response->sendContent();
$content2 = ob_get_clean();
$this->assertEquals($content, $content2);
$this->assertFalse(file_exists($file->getRealPath()));
}
}

View File

@@ -0,0 +1,62 @@
<?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\Export\Renderer;
use App\Export\Renderer\HtmlRenderer;
use Symfony\Component\HttpFoundation\Request;
use Twig\Loader\FilesystemLoader;
/**
* @covers \App\Export\Renderer\HtmlRenderer
* @covers \App\Export\Renderer\RendererTrait
*/
class HtmlRendererTest extends AbstractRendererTest
{
public function testConfiguration()
{
$sut = new HtmlRenderer(
$this->getMockBuilder(\Twig_Environment::class)->disableOriginalConstructor()->getMock()
);
$this->assertEquals('html', $sut->getId());
$this->assertEquals('print', $sut->getTitle());
$this->assertEquals('print', $sut->getIcon());
}
public function testRender()
{
$kernel = self::bootKernel();
/** @var \Twig_Environment $twig */
$twig = $kernel->getContainer()->get('twig');
$stack = $kernel->getContainer()->get('request_stack');
$request = new Request();
$request->setLocale('en');
$stack->push($request);
/** @var FilesystemLoader $loader */
$loader = $twig->getLoader();
$sut = new HtmlRenderer($twig);
$response = $this->render($sut);
$content = $response->getContent();
$this->assertContains('<h2>List of expenses</h2>', $content);
$this->assertContains('<h3>Summary</h3>', $content);
$this->assertContains('<td>Customer Name</td>', $content);
$this->assertContains('<td>project name</td>', $content);
$this->assertContains('<td class="duration">01:50 h</td>', $content);
$this->assertContains('<td class="cost">2,437.12 €</td>', $content);
$this->assertEquals(5, substr_count($content, '<td>activity description</td>'));
}
}

View File

@@ -0,0 +1,51 @@
<?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\Export\Renderer;
use App\Export\Renderer\OdsRenderer;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* @covers \App\Export\Renderer\OdsRenderer
* @covers \App\Export\Renderer\AbstractSpreadsheetRenderer
* @covers \App\Export\Renderer\RendererTrait
*/
class OdsRendererTest extends AbstractRendererTest
{
public function testConfiguration()
{
$sut = $this->getAbstractRenderer(OdsRenderer::class);
$this->assertEquals('ods', $sut->getId());
$this->assertEquals('ods', $sut->getTitle());
$this->assertEquals('ods', $sut->getIcon());
}
public function testRender()
{
$sut = $this->getAbstractRenderer(OdsRenderer::class);
/** @var BinaryFileResponse $response */
$response = $this->render($sut);
$file = $response->getFile();
$this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('Content-Type'));
$this->assertEquals('attachment; filename=kimai-export.ods', $response->headers->get('Content-Disposition'));
$this->assertTrue(file_exists($file->getRealPath()));
ob_start();
$response->sendContent();
$content2 = ob_get_clean();
$this->assertNotEmpty($content2);
$this->assertFalse(file_exists($file->getRealPath()));
}
}

View File

@@ -0,0 +1,55 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Export\Renderer;
use App\Export\Renderer\PDFRenderer;
use Symfony\Component\HttpFoundation\Request;
use Twig\Loader\FilesystemLoader;
/**
* @covers \App\Export\Renderer\PDFRenderer
* @covers \App\Export\Renderer\RendererTrait
*/
class PdfRendererTest extends AbstractRendererTest
{
public function testConfiguration()
{
$sut = new PDFRenderer(
$this->getMockBuilder(\Twig_Environment::class)->disableOriginalConstructor()->getMock()
);
$this->assertEquals('pdf', $sut->getId());
$this->assertEquals('pdf', $sut->getTitle());
$this->assertEquals('pdf', $sut->getIcon());
}
public function testRender()
{
$kernel = self::bootKernel();
/** @var \Twig_Environment $twig */
$twig = $kernel->getContainer()->get('twig');
$stack = $kernel->getContainer()->get('request_stack');
$request = new Request();
$request->setLocale('en');
$stack->push($request);
/** @var FilesystemLoader $loader */
$loader = $twig->getLoader();
$sut = new PDFRenderer($twig);
$response = $this->render($sut);
$this->assertEquals('application/pdf', $response->headers->get('Content-Type'));
$this->assertEquals('attachment; filename=kimai-export.pdf', $response->headers->get('Content-Disposition'));
$this->assertNotEmpty($response->getContent());
}
}

View File

@@ -0,0 +1,51 @@
<?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\Export\Renderer;
use App\Export\Renderer\XlsxRenderer;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* @covers \App\Export\Renderer\XlsxRenderer
* @covers \App\Export\Renderer\AbstractSpreadsheetRenderer
* @covers \App\Export\Renderer\RendererTrait
*/
class XlsxRendererTest extends AbstractRendererTest
{
public function testConfiguration()
{
$sut = $this->getAbstractRenderer(XlsxRenderer::class);
$this->assertEquals('xlsx', $sut->getId());
$this->assertEquals('xlsx', $sut->getTitle());
$this->assertEquals('xlsx', $sut->getIcon());
}
public function testRender()
{
$sut = $this->getAbstractRenderer(XlsxRenderer::class);
/** @var BinaryFileResponse $response */
$response = $this->render($sut);
$file = $response->getFile();
$this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('Content-Type'));
$this->assertEquals('attachment; filename=kimai-export.xlsx', $response->headers->get('Content-Disposition'));
$this->assertTrue(file_exists($file->getRealPath()));
ob_start();
$response->sendContent();
$content2 = ob_get_clean();
$this->assertNotEmpty($content2);
$this->assertFalse(file_exists($file->getRealPath()));
}
}

View File

@@ -0,0 +1,43 @@
<?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\Export;
use App\Export\Renderer\HtmlRenderer;
use App\Export\ServiceExport;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Export\ServiceExport
*/
class ServiceExportTest extends TestCase
{
public function testEmptyObject()
{
$sut = new ServiceExport();
$this->assertEmpty($sut->getRenderer());
}
public function testUnknownRendererReturnsNull()
{
$sut = new ServiceExport();
$this->assertNull($sut->getRendererById('default'));
}
public function testAdd()
{
$sut = new ServiceExport();
$sut->addRenderer(new HtmlRenderer(
$this->getMockBuilder(\Twig_Environment::class)->disableOriginalConstructor()->getMock()
));
$this->assertEquals(1, count($sut->getRenderer()));
}
}

View File

@@ -17,6 +17,9 @@ use App\Invoice\ServiceInvoice;
use App\Repository\InvoiceDocumentRepository; use App\Repository\InvoiceDocumentRepository;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
/**
* @covers \App\Invoice\ServiceInvoice
*/
class ServiceInvoiceTest extends TestCase class ServiceInvoiceTest extends TestCase
{ {
public function testEmptyObject() public function testEmptyObject()

View File

@@ -0,0 +1,131 @@
<?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\Repository\Query;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\User;
use App\Repository\Query\ExportQuery;
/**
* @covers \App\Repository\Query\ExportQuery
*/
class ExportQueryTest extends BaseQueryTest
{
public function testQuery()
{
$sut = new ExportQuery();
$this->assertResultType($sut);
$this->assertHiddenEntity($sut);
$this->assertPage($sut);
$this->assertPageSize($sut);
$this->assertOrderBy($sut, 'begin');
$this->assertOrder($sut, ExportQuery::ORDER_DESC);
$this->assertUser($sut);
$this->assertCustomer($sut);
$this->assertProject($sut);
$this->assertActivity($sut);
$this->assertState($sut);
$this->assertExported($sut);
$this->assertType($sut);
}
protected function assertUser(ExportQuery $sut)
{
$this->assertNull($sut->getUser());
$expected = new User();
$expected->setUsername('foo-bar');
$sut->setUser($expected);
$this->assertEquals($expected, $sut->getUser());
}
protected function assertCustomer(ExportQuery $sut)
{
$this->assertNull($sut->getCustomer());
$expected = new Customer();
$expected->setName('foo-bar');
$sut->setCustomer($expected);
$this->assertEquals($expected, $sut->getCustomer());
}
protected function assertProject(ExportQuery $sut)
{
$this->assertNull($sut->getProject());
$expected = new Project();
$expected->setName('foo-bar');
$sut->setProject($expected);
$this->assertEquals($expected, $sut->getProject());
}
protected function assertActivity(ExportQuery $sut)
{
$this->assertNull($sut->getActivity());
$expected = new Activity();
$expected->setName('foo-bar');
$sut->setActivity($expected);
$this->assertEquals($expected, $sut->getActivity());
}
protected function assertState(ExportQuery $sut)
{
$this->assertEquals(ExportQuery::STATE_ALL, $sut->getState());
$sut->setState(PHP_INT_MAX);
$this->assertEquals(ExportQuery::STATE_ALL, $sut->getState());
$sut->setState(ExportQuery::STATE_STOPPED);
$this->assertEquals(ExportQuery::STATE_STOPPED, $sut->getState());
$sut->setState(ExportQuery::STATE_RUNNING);
$this->assertEquals(ExportQuery::STATE_RUNNING, $sut->getState());
$sut->setState(ExportQuery::STATE_ALL);
$this->assertEquals(ExportQuery::STATE_ALL, $sut->getState());
}
protected function assertExported(ExportQuery $sut)
{
$this->assertEquals(ExportQuery::STATE_ALL, $sut->getExported());
$sut->setExported(PHP_INT_MAX);
$this->assertEquals(ExportQuery::STATE_ALL, $sut->getExported());
$sut->setExported(ExportQuery::STATE_EXPORTED);
$this->assertEquals(ExportQuery::STATE_EXPORTED, $sut->getExported());
$sut->setExported(ExportQuery::STATE_NOT_EXPORTED);
$this->assertEquals(ExportQuery::STATE_NOT_EXPORTED, $sut->getExported());
$sut->setExported(ExportQuery::STATE_ALL);
$this->assertEquals(ExportQuery::STATE_ALL, $sut->getExported());
}
protected function assertType(ExportQuery $sut)
{
$this->assertNull($sut->getType());
$allowed = ['html', 'csv', 'pdf', 'xlsx', 'ods'];
foreach ($allowed as $type) {
$sut->setType($type);
$this->assertEquals($type, $sut->getType());
}
$sut->setType('foo');
$this->assertEquals('ods', $sut->getType());
}
}

View File

@@ -36,6 +36,7 @@ class TimesheetQueryTest extends BaseQueryTest
$this->assertProject($sut); $this->assertProject($sut);
$this->assertActivity($sut); $this->assertActivity($sut);
$this->assertState($sut); $this->assertState($sut);
$this->assertExported($sut);
} }
protected function assertUser(TimesheetQuery $sut) protected function assertUser(TimesheetQuery $sut)
@@ -94,4 +95,24 @@ class TimesheetQueryTest extends BaseQueryTest
$sut->setState(TimesheetQuery::STATE_ALL); $sut->setState(TimesheetQuery::STATE_ALL);
$this->assertEquals(TimesheetQuery::STATE_ALL, $sut->getState()); $this->assertEquals(TimesheetQuery::STATE_ALL, $sut->getState());
} }
protected function assertExported(TimesheetQuery $sut)
{
$this->assertEquals(TimesheetQuery::STATE_ALL, $sut->getExported());
$sut->setExported(PHP_INT_MAX);
$this->assertEquals(TimesheetQuery::STATE_ALL, $sut->getExported());
$sut->setExported(TimesheetQuery::STATE_EXPORTED);
$this->assertEquals(TimesheetQuery::STATE_EXPORTED, $sut->getExported());
$sut->setExported(TimesheetQuery::STATE_NOT_EXPORTED);
$this->assertEquals(TimesheetQuery::STATE_NOT_EXPORTED, $sut->getExported());
$sut->setExported(TimesheetQuery::STATE_ALL);
$this->assertEquals(TimesheetQuery::STATE_ALL, $sut->getExported());
$sut->setExported('02');
$this->assertEquals(TimesheetQuery::STATE_ALL, $sut->getExported());
}
} }

View File

@@ -40,7 +40,7 @@ class DateExtensionsTest extends TestCase
public function testGetFilters() public function testGetFilters()
{ {
$filters = ['month_name', 'date_short']; $filters = ['month_name', 'date_short', 'date_time'];
$sut = $this->getSut('de', []); $sut = $this->getSut('de', []);
$twigFilters = $sut->getFilters(); $twigFilters = $sut->getFilters();
$this->assertCount(count($filters), $twigFilters); $this->assertCount(count($filters), $twigFilters);
@@ -77,6 +77,29 @@ class DateExtensionsTest extends TestCase
]; ];
} }
/**
* @param string $locale
* @param \DateTime $date
* @param string $result
* @dataProvider getDateTimeData
*/
public function testDateTime($locale, \DateTime $date, $result)
{
$sut = $this->getSut($locale, [
'de' => ['date_time' => 'd.m.Y H:i:s'],
'en' => ['date_time' => 'Y-m-d h:m A'],
]);
$this->assertEquals($result, $sut->dateTime($date));
}
public function getDateTimeData()
{
return [
['en', new \DateTime('7 January 2010'), '2010-01-07 12:01 AM'],
['de', (new \DateTime('1980-12-14'))->setTime(13, 27, 55), '14.12.1980 13:27:55'],
];
}
/** /**
* @param \DateTime $date * @param \DateTime $date
* @param string $result * @param string $result

View File

@@ -19,7 +19,6 @@ use Symfony\Component\HttpFoundation\RequestStack;
*/ */
class LocaleSettingsTest extends TestCase class LocaleSettingsTest extends TestCase
{ {
protected function getRequestStack(string $locale) protected function getRequestStack(string $locale)
{ {
$request = new Request(); $request = new Request();
@@ -44,6 +43,7 @@ class LocaleSettingsTest extends TestCase
'date_type' => 'dd.MM.yyyy', 'date_type' => 'dd.MM.yyyy',
'date_picker' => 'DD.MM.YYYY', 'date_picker' => 'DD.MM.YYYY',
'date' => 'd.m.Y', 'date' => 'd.m.Y',
'date_time' => 'd.m. H:i',
'duration' => '%h:%m h', 'duration' => '%h:%m h',
], ],
'en' => [ 'en' => [
@@ -52,6 +52,7 @@ class LocaleSettingsTest extends TestCase
'date_type' => 'yyyy-MM-dd', 'date_type' => 'yyyy-MM-dd',
'date_picker' => 'YYYY-MM-DD', 'date_picker' => 'YYYY-MM-DD',
'date' => 'Y-m-d', 'date' => 'Y-m-d',
'date_time' => 'm-d H:i',
'duration' => '%h:%m h', 'duration' => '%h:%m h',
], ],
'pt_BR' => [ 'pt_BR' => [
@@ -162,6 +163,13 @@ class LocaleSettingsTest extends TestCase
$this->assertEquals('d.m.Y', $sut->getDateFormat('de')); $this->assertEquals('d.m.Y', $sut->getDateFormat('de'));
} }
public function testGetDateTimeFormat()
{
$sut = $this->getSut('en', $this->getDefaultSettings());
$this->assertEquals('m-d H:i', $sut->getDateTimeFormat());
$this->assertEquals('d.m. H:i', $sut->getDateTimeFormat('de'));
}
public function testGetDateTypeFormat() public function testGetDateTypeFormat()
{ {
$sut = $this->getSut('en', $this->getDefaultSettings()); $sut = $this->getSut('en', $this->getDefaultSettings());

View File

@@ -681,10 +681,6 @@
<source>invoice.subtitle</source> <source>invoice.subtitle</source>
<target>م بإنشاء فواتير من بيانات ورقة التوقيت الخاصة بك.</target> <target>م بإنشاء فواتير من بيانات ورقة التوقيت الخاصة بك.</target>
</trans-unit> </trans-unit>
<trans-unit id="invoice.select_filter">
<source>invoice.select_filter</source>
<target>لم يتم العثور على بيانات الفواتير بناءً على الفلاتر المحددة.</target>
</trans-unit>
<trans-unit id="invoice.preview"> <trans-unit id="invoice.preview">
<source>invoice.preview</source> <source>invoice.preview</source>
<target>هذه معاينة للبيانات التي ستظهر في مستند الفاتورة.</target> <target>هذه معاينة للبيانات التي ستظهر في مستند الفاتورة.</target>
@@ -737,16 +733,16 @@
<source>invoice.service_date</source> <source>invoice.service_date</source>
<target>تاريخ الخدمة</target> <target>تاريخ الخدمة</target>
</trans-unit> </trans-unit>
<trans-unit id="invoice.amount"> <trans-unit id="label.amount">
<source>invoice.amount</source> <source>label.amount</source>
<target>كمية</target> <target>كمية</target>
</trans-unit> </trans-unit>
<trans-unit id="invoice.total_rate"> <trans-unit id="label.total_rate">
<source>invoice.total_rate</source> <source>label.total_rate</source>
<target>السعر الكلي</target> <target>السعر الكلي</target>
</trans-unit> </trans-unit>
<trans-unit id="invoice.unit_price"> <trans-unit id="label.unit_price">
<source>invoice.unit_price</source> <source>label.unit_price</source>
<target>سعر الوحدة</target> <target>سعر الوحدة</target>
</trans-unit> </trans-unit>
<trans-unit id="label.payment_terms"> <trans-unit id="label.payment_terms">

View File

@@ -95,6 +95,10 @@
<source>menu.invoice</source> <source>menu.invoice</source>
<target>Rechnungen</target> <target>Rechnungen</target>
</trans-unit> </trans-unit>
<trans-unit id="menu.export">
<source>menu.export</source>
<target>Export</target>
</trans-unit>
<trans-unit id="menu.admin_timesheet"> <trans-unit id="menu.admin_timesheet">
<source>menu.admin_timesheet</source> <source>menu.admin_timesheet</source>
<target>Zeiterfassung</target> <target>Zeiterfassung</target>
@@ -121,7 +125,7 @@
--> -->
<trans-unit id="error.no_entries_found"> <trans-unit id="error.no_entries_found">
<source>error.no_entries_found</source> <source>error.no_entries_found</source>
<target>Keine Einträge vorhanden</target> <target>Anhand ihrer ausgewählten Filter wurden keine Einträge gefunden.</target>
</trans-unit> </trans-unit>
<!-- <!--
@@ -722,10 +726,6 @@
<source>invoice.subtitle</source> <source>invoice.subtitle</source>
<target>Hier erstellen Sie Rechnungen für ihre aufgezeichneten Zeiten.</target> <target>Hier erstellen Sie Rechnungen für ihre aufgezeichneten Zeiten.</target>
</trans-unit> </trans-unit>
<trans-unit id="invoice.select_filter">
<source>invoice.select_filter</source>
<target>Anhand ihrer ausgewählten Filter wurden keine Rechnungseinträge gefunden.</target>
</trans-unit>
<trans-unit id="invoice.preview"> <trans-unit id="invoice.preview">
<source>invoice.preview</source> <source>invoice.preview</source>
<target>Dies ist eine Vorschau der Daten, die in Ihrer Rechnung enthalten sein werden.</target> <target>Dies ist eine Vorschau der Daten, die in Ihrer Rechnung enthalten sein werden.</target>
@@ -734,6 +734,22 @@
<source>button.print</source> <source>button.print</source>
<target>Drucken</target> <target>Drucken</target>
</trans-unit> </trans-unit>
<trans-unit id="button.csv">
<source>button.csv</source>
<target>CSV</target>
</trans-unit>
<trans-unit id="button.xlsx">
<source>button.xlsx</source>
<target>Excel</target>
</trans-unit>
<trans-unit id="button.pdf">
<source>button.pdf</source>
<target>PDF</target>
</trans-unit>
<trans-unit id="button.ods">
<source>button.ods</source>
<target>ODS</target>
</trans-unit>
<trans-unit id="invoice_print"> <trans-unit id="invoice_print">
<source>invoice_print</source> <source>invoice_print</source>
<target>Rechnung</target> <target>Rechnung</target>
@@ -778,16 +794,16 @@
<source>invoice.service_date</source> <source>invoice.service_date</source>
<target>Leistungsdatum</target> <target>Leistungsdatum</target>
</trans-unit> </trans-unit>
<trans-unit id="invoice.amount"> <trans-unit id="label.amount">
<source>invoice.amount</source> <source>label.amount</source>
<target>Anzahl</target> <target>Anzahl</target>
</trans-unit> </trans-unit>
<trans-unit id="invoice.total_rate"> <trans-unit id="label.total_rate">
<source>invoice.total_rate</source> <source>label.total_rate</source>
<target>Gesamtpreis</target> <target>Gesamtpreis</target>
</trans-unit> </trans-unit>
<trans-unit id="invoice.unit_price"> <trans-unit id="label.unit_price">
<source>invoice.unit_price</source> <source>label.unit_price</source>
<target>Einzelbetrag</target> <target>Einzelbetrag</target>
</trans-unit> </trans-unit>
<trans-unit id="label.payment_terms"> <trans-unit id="label.payment_terms">
@@ -827,6 +843,42 @@
</target> </target>
</trans-unit> </trans-unit>
<!--
Export
-->
<trans-unit id="export.title">
<source>export.title</source>
<target>Export</target>
</trans-unit>
<trans-unit id="export.subtitle">
<source>export.subtitle</source>
<target>Exportieren Sie geloggte Zeiten in veschiedene Zielformate</target>
</trans-unit>
<trans-unit id="export.period">
<source>export.period</source>
<target>Zeitraum</target>
</trans-unit>
<trans-unit id="export.document_title">
<source>export.document_title</source>
<target>Aufstellung zu Aufwänden</target>
</trans-unit>
<trans-unit id="export.full_list">
<source>export.full_list</source>
<target>Vollständige Auflistung</target>
</trans-unit>
<trans-unit id="export.summary">
<source>export.summary</source>
<target>Zusammenfassung</target>
</trans-unit>
<trans-unit id="export.page_of">
<source>export.page_of</source>
<target>Seite %page% von %pages%</target>
</trans-unit>
<trans-unit id="export.date_copyright">
<source>export.date_copyright</source>
<target>Erstellt %date% mit %kimai%</target>
</trans-unit>
<!-- <!--
Month names Month names
--> -->
@@ -916,6 +968,18 @@
<source>label.entryState</source> <source>label.entryState</source>
<target>Zeiten</target> <target>Zeiten</target>
</trans-unit> </trans-unit>
<trans-unit id="label.exported">
<source>label.exported</source>
<target>Exportiert</target>
</trans-unit>
<trans-unit id="entryState.exported">
<source>entryState.exported</source>
<target>Abgerechnet</target>
</trans-unit>
<trans-unit id="entryState.not_exported">
<source>entryState.not_exported</source>
<target>Offen</target>
</trans-unit>
<trans-unit id="entryState.all"> <trans-unit id="entryState.all">
<source>entryState.all</source> <source>entryState.all</source>
<target>Alle</target> <target>Alle</target>
@@ -928,6 +992,14 @@
<source>entryState.stopped</source> <source>entryState.stopped</source>
<target>Beendete</target> <target>Beendete</target>
</trans-unit> </trans-unit>
<trans-unit id="export.clear_all">
<source>export.clear_all</source>
<target>Alle angezeigten Einträge als offen markieren?</target>
</trans-unit>
<trans-unit id="export.mark_all">
<source>export.mark_all</source>
<target>Alle angezeigten Einträge als abgerechnet markieren?</target>
</trans-unit>
</body> </body>
</file> </file>
</xliff> </xliff>

View File

@@ -95,6 +95,10 @@
<source>menu.invoice</source> <source>menu.invoice</source>
<target>Invoices</target> <target>Invoices</target>
</trans-unit> </trans-unit>
<trans-unit id="menu.export">
<source>menu.export</source>
<target>Export</target>
</trans-unit>
<trans-unit id="menu.admin_timesheet"> <trans-unit id="menu.admin_timesheet">
<source>menu.admin_timesheet</source> <source>menu.admin_timesheet</source>
<target>Timesheets</target> <target>Timesheets</target>
@@ -121,7 +125,7 @@
--> -->
<trans-unit id="error.no_entries_found"> <trans-unit id="error.no_entries_found">
<source>error.no_entries_found</source> <source>error.no_entries_found</source>
<target>No entries existing</target> <target>No entries were found based on your selected filters.</target>
</trans-unit> </trans-unit>
<!-- <!--
@@ -722,10 +726,6 @@
<source>invoice.subtitle</source> <source>invoice.subtitle</source>
<target>Create invoices from your recorded timesheet entries.</target> <target>Create invoices from your recorded timesheet entries.</target>
</trans-unit> </trans-unit>
<trans-unit id="invoice.select_filter">
<source>invoice.select_filter</source>
<target>No invoice entries were found based on your selected filters.</target>
</trans-unit>
<trans-unit id="invoice.preview"> <trans-unit id="invoice.preview">
<source>invoice.preview</source> <source>invoice.preview</source>
<target>This is a preview of the data that will show up in your invoice document.</target> <target>This is a preview of the data that will show up in your invoice document.</target>
@@ -734,6 +734,22 @@
<source>button.print</source> <source>button.print</source>
<target>Print</target> <target>Print</target>
</trans-unit> </trans-unit>
<trans-unit id="button.csv">
<source>button.csv</source>
<target>CSV</target>
</trans-unit>
<trans-unit id="button.xlsx">
<source>button.xlsx</source>
<target>Excel</target>
</trans-unit>
<trans-unit id="button.pdf">
<source>button.pdf</source>
<target>PDF</target>
</trans-unit>
<trans-unit id="button.ods">
<source>button.ods</source>
<target>ODS</target>
</trans-unit>
<trans-unit id="invoice_print"> <trans-unit id="invoice_print">
<source>invoice_print</source> <source>invoice_print</source>
<target>Invoice</target> <target>Invoice</target>
@@ -778,16 +794,16 @@
<source>invoice.service_date</source> <source>invoice.service_date</source>
<target>Service date</target> <target>Service date</target>
</trans-unit> </trans-unit>
<trans-unit id="invoice.amount"> <trans-unit id="label.amount">
<source>invoice.amount</source> <source>label.amount</source>
<target>Quantity</target> <target>Quantity</target>
</trans-unit> </trans-unit>
<trans-unit id="invoice.total_rate"> <trans-unit id="label.total_rate">
<source>invoice.total_rate</source> <source>label.total_rate</source>
<target>Total price</target> <target>Total price</target>
</trans-unit> </trans-unit>
<trans-unit id="invoice.unit_price"> <trans-unit id="label.unit_price">
<source>invoice.unit_price</source> <source>label.unit_price</source>
<target>Unit price</target> <target>Unit price</target>
</trans-unit> </trans-unit>
<trans-unit id="label.payment_terms"> <trans-unit id="label.payment_terms">
@@ -827,6 +843,42 @@
</target> </target>
</trans-unit> </trans-unit>
<!--
Export
-->
<trans-unit id="export.title">
<source>export.title</source>
<target>Export</target>
</trans-unit>
<trans-unit id="export.subtitle">
<source>export.subtitle</source>
<target>Export timesheet data to various formats</target>
</trans-unit>
<trans-unit id="export.period">
<source>export.period</source>
<target>Period</target>
</trans-unit>
<trans-unit id="export.document_title">
<source>export.document_title</source>
<target>List of expenses</target>
</trans-unit>
<trans-unit id="export.full_list">
<source>export.full_list</source>
<target>Full list</target>
</trans-unit>
<trans-unit id="export.summary">
<source>export.summary</source>
<target>Summary</target>
</trans-unit>
<trans-unit id="export.page_of">
<source>export.page_of</source>
<target>Page %page% of %pages%</target>
</trans-unit>
<trans-unit id="export.date_copyright">
<source>export.date_copyright</source>
<target>Created %date% with %kimai%</target>
</trans-unit>
<!-- <!--
Month names Month names
--> -->
@@ -916,6 +968,18 @@
<source>label.entryState</source> <source>label.entryState</source>
<target>Records</target> <target>Records</target>
</trans-unit> </trans-unit>
<trans-unit id="label.exported">
<source>label.exported</source>
<target>Exported</target>
</trans-unit>
<trans-unit id="entryState.exported">
<source>entryState.exported</source>
<target>Cleared</target>
</trans-unit>
<trans-unit id="entryState.not_exported">
<source>entryState.not_exported</source>
<target>Open</target>
</trans-unit>
<trans-unit id="entryState.all"> <trans-unit id="entryState.all">
<source>entryState.all</source> <source>entryState.all</source>
<target>All</target> <target>All</target>
@@ -928,6 +992,14 @@
<source>entryState.stopped</source> <source>entryState.stopped</source>
<target>Stopped</target> <target>Stopped</target>
</trans-unit> </trans-unit>
<trans-unit id="export.clear_all">
<source>export.clear_all</source>
<target>Mark all shown records as open?</target>
</trans-unit>
<trans-unit id="export.mark_all">
<source>export.mark_all</source>
<target>Mark all shown records as cleared?</target>
</trans-unit>
</body> </body>
</file> </file>
</xliff> </xliff>

View File

@@ -653,10 +653,6 @@
<source>invoice.subtitle</source> <source>invoice.subtitle</source>
<target>Crear facturas desde los registros de sus hojas de tiempo.</target> <target>Crear facturas desde los registros de sus hojas de tiempo.</target>
</trans-unit> </trans-unit>
<trans-unit id="invoice.select_filter">
<source>invoice.select_filter</source>
<target>Antes de crear una factura, tiene que seleccionar al menos un filtro de cliente.</target>
</trans-unit>
<trans-unit id="button.print"> <trans-unit id="button.print">
<source>button.print</source> <source>button.print</source>
<target>Imprimir</target> <target>Imprimir</target>
@@ -705,12 +701,12 @@
<source>invoice.service_date</source> <source>invoice.service_date</source>
<target>Fecha de servicio</target> <target>Fecha de servicio</target>
</trans-unit> </trans-unit>
<trans-unit id="invoice.amount"> <trans-unit id="label.amount">
<source>invoice.amount</source> <source>label.amount</source>
<target>Cantidad</target> <target>Cantidad</target>
</trans-unit> </trans-unit>
<trans-unit id="invoice.total_rate"> <trans-unit id="label.total_rate">
<source>invoice.total_rate</source> <source>label.total_rate</source>
<target>Precio total</target> <target>Precio total</target>
</trans-unit> </trans-unit>
<trans-unit id="label.payment_terms"> <trans-unit id="label.payment_terms">

View File

@@ -686,10 +686,6 @@
<source>invoice.subtitle</source> <source>invoice.subtitle</source>
<target>Générer des factures à partir des fiches de temps.</target> <target>Générer des factures à partir des fiches de temps.</target>
</trans-unit> </trans-unit>
<trans-unit id="invoice.select_filter">
<source>invoice.select_filter</source>
<target>Pour générer une facture, vous devez sélectionner au moins un client.</target>
</trans-unit>
<trans-unit id="button.print"> <trans-unit id="button.print">
<source>button.print</source> <source>button.print</source>
<target>Imprimer</target> <target>Imprimer</target>
@@ -738,12 +734,12 @@
<source>invoice.service_date</source> <source>invoice.service_date</source>
<target>Service date</target> <target>Service date</target>
</trans-unit> </trans-unit>
<trans-unit id="invoice.amount"> <trans-unit id="label.amount">
<source>invoice.amount</source> <source>label.amount</source>
<target>Montant</target> <target>Montant</target>
</trans-unit> </trans-unit>
<trans-unit id="invoice.total_rate"> <trans-unit id="label.total_rate">
<source>invoice.total_rate</source> <source>label.total_rate</source>
<target>Total price</target> <target>Total price</target>
</trans-unit> </trans-unit>
<trans-unit id="label.payment_terms"> <trans-unit id="label.payment_terms">

View File

@@ -700,10 +700,6 @@
<source>invoice.subtitle</source> <source>invoice.subtitle</source>
<target>Számlák készítése az időbejegyzéseidből</target> <target>Számlák készítése az időbejegyzéseidből</target>
</trans-unit> </trans-unit>
<trans-unit id="invoice.select_filter">
<source>invoice.select_filter</source>
<target>Nem található keresési feltételeknek megfelelő számla.</target>
</trans-unit>
<trans-unit id="invoice.preview"> <trans-unit id="invoice.preview">
<source>invoice.preview</source> <source>invoice.preview</source>
@@ -757,16 +753,16 @@
<source>invoice.service_date</source> <source>invoice.service_date</source>
<target>Teljesítés időpontja</target> <target>Teljesítés időpontja</target>
</trans-unit> </trans-unit>
<trans-unit id="invoice.amount"> <trans-unit id="label.amount">
<source>invoice.amount</source> <source>label.amount</source>
<target>Mennyiség</target> <target>Mennyiség</target>
</trans-unit> </trans-unit>
<trans-unit id="invoice.total_rate"> <trans-unit id="label.total_rate">
<source>invoice.total_rate</source> <source>label.total_rate</source>
<target>Teljes ár</target> <target>Teljes ár</target>
</trans-unit> </trans-unit>
<trans-unit id="invoice.unit_price"> <trans-unit id="label.unit_price">
<source>invoice.unit_price</source> <source>label.unit_price</source>
<target>Egységár</target> <target>Egységár</target>
</trans-unit> </trans-unit>
<trans-unit id="label.payment_terms"> <trans-unit id="label.payment_terms">

View File

@@ -631,10 +631,6 @@
<source>invoice.subtitle</source> <source>invoice.subtitle</source>
<target>Crea fattura dalle registrazioni.</target> <target>Crea fattura dalle registrazioni.</target>
</trans-unit> </trans-unit>
<trans-unit id="invoice.select_filter">
<source>invoice.select_filter</source>
<target>Prima di creare una fattura, devi selezionare almeno un cliente.</target>
</trans-unit>
<trans-unit id="button.print"> <trans-unit id="button.print">
<source>button.print</source> <source>button.print</source>
<target>Stampa</target> <target>Stampa</target>
@@ -683,12 +679,12 @@
<source>invoice.service_date</source> <source>invoice.service_date</source>
<target>Data di servizio</target> <target>Data di servizio</target>
</trans-unit> </trans-unit>
<trans-unit id="invoice.amount"> <trans-unit id="label.amount">
<source>invoice.amount</source> <source>label.amount</source>
<target>Quantità</target> <target>Quantità</target>
</trans-unit> </trans-unit>
<trans-unit id="invoice.total_rate"> <trans-unit id="label.total_rate">
<source>invoice.total_rate</source> <source>label.total_rate</source>
<target>Prezzo totale</target> <target>Prezzo totale</target>
</trans-unit> </trans-unit>
<trans-unit id="label.payment_terms"> <trans-unit id="label.payment_terms">

View File

@@ -702,14 +702,9 @@
<source>invoice.subtitle</source> <source>invoice.subtitle</source>
<target>Criar faturas a partir de suas entradas de quadro de horários registradas.</target> <target>Criar faturas a partir de suas entradas de quadro de horários registradas.</target>
</trans-unit> </trans-unit>
<trans-unit id="invoice.select_filter">
<source>invoice.select_filter</source>
<target>Nenhuma entrada de fatura foi encontrada com base nos filtros selecionados.</target>
</trans-unit>
<trans-unit id="invoice.preview"> <trans-unit id="invoice.preview">
<source>invoice.preview</source> <source>invoice.preview</source>
<target>Esta é uma prévia dos dados que serão exibidos no documento da fatura <target>Esta é uma prévia dos dados que serão exibidos no documento da fatura.</target>
.</target>
</trans-unit> </trans-unit>
<trans-unit id="button.print"> <trans-unit id="button.print">
<source>button.print</source> <source>button.print</source>
@@ -759,16 +754,16 @@
<source>invoice.service_date</source> <source>invoice.service_date</source>
<target>Data de serviço</target> <target>Data de serviço</target>
</trans-unit> </trans-unit>
<trans-unit id="invoice.amount"> <trans-unit id="label.amount">
<source>invoice.amount</source> <source>label.amount</source>
<target>Quantidade</target> <target>Quantidade</target>
</trans-unit> </trans-unit>
<trans-unit id="invoice.total_rate"> <trans-unit id="label.total_rate">
<source>invoice.total_rate</source> <source>label.total_rate</source>
<target>Preço Total</target> <target>Preço Total</target>
</trans-unit> </trans-unit>
<trans-unit id="invoice.unit_price"> <trans-unit id="label.unit_price">
<source>invoice.unit_price</source> <source>label.unit_price</source>
<target>Preço unitário</target> <target>Preço unitário</target>
</trans-unit> </trans-unit>
<trans-unit id="label.payment_terms"> <trans-unit id="label.payment_terms">

View File

@@ -677,10 +677,6 @@
<source>invoice.subtitle</source> <source>invoice.subtitle</source>
<target>Создайте счета для записанных периодов времени.</target> <target>Создайте счета для записанных периодов времени.</target>
</trans-unit> </trans-unit>
<trans-unit id="invoice.select_filter">
<source>invoice.select_filter</source>
<target>Прежде чем создать счет, необходимо выбрать фильтр сортировки клиентов.</target>
</trans-unit>
<trans-unit id="button.print"> <trans-unit id="button.print">
<source>button.print</source> <source>button.print</source>
<target>Печать</target> <target>Печать</target>
@@ -729,12 +725,12 @@
<source>invoice.service_date</source> <source>invoice.service_date</source>
<target>Дата выполненной работы</target> <target>Дата выполненной работы</target>
</trans-unit> </trans-unit>
<trans-unit id="invoice.amount"> <trans-unit id="label.amount">
<source>invoice.amount</source> <source>label.amount</source>
<target>Количество</target> <target>Количество</target>
</trans-unit> </trans-unit>
<trans-unit id="invoice.total_rate"> <trans-unit id="label.total_rate">
<source>invoice.total_rate</source> <source>label.total_rate</source>
<target>Общая стоимость</target> <target>Общая стоимость</target>
</trans-unit> </trans-unit>
<trans-unit id="label.payment_terms"> <trans-unit id="label.payment_terms">

Binary file not shown.

View File

@@ -7,8 +7,9 @@ and we will add it as soon as possible.
## User manual ## User manual
- [Invoices](invoices.md) - export your data to different formats with customizable templates
- [Timesheets](timesheet.md) - information about timesheets - [Timesheets](timesheet.md) - information about timesheets
- [Export](export.md) - export filtered timesheet data
- [Invoices](invoices.md) - create invoices from timesheet data in different formats with customizable templates
- [User and Security](users.md) - docu for user and security topics, like authentication, registration and roles - [User and Security](users.md) - docu for user and security topics, like authentication, registration and roles
## Admins and Developers ## Admins and Developers

View File

@@ -188,10 +188,10 @@ In the config `kimai.invoice.documents`, you can add a list of directories with
## Adding invoice calculator ## Adding invoice calculator
An invoice calculator is a class extending `App\Invoice\CalculatorInterface` and is responsible for calculating An invoice calculator is a class implementing `App\Invoice\CalculatorInterface` and it is responsible for calculating
invoice rates, taxes and taking care of all timesheet entries that should be displayed. invoice rates, taxes and taking care of all timesheet entries that should be displayed.
Every invoice calculator class will be picked up when refreshing the application cache by the [InvoiceServiceCompilerPass](https://github.com/kevinpapst/kimai2/blob/master/src/DependencyInjection/Compiler/InvoiceServiceCompilerPass.php): Every invoice calculator class will be automatically available when refreshing the application cache by the [InvoiceServiceCompilerPass](../../src/DependencyInjection/Compiler/InvoiceServiceCompilerPass.php):
The ID of the calculator must be unique, please prefix it with your vendor or bundle name and make sure it only contains The ID of the calculator must be unique, please prefix it with your vendor or bundle name and make sure it only contains
character as it will be stored in a database column. character as it will be stored in a database column.
@@ -200,16 +200,30 @@ Translations are stored in the `invoice-calculator.xx.xliff`.
## Adding invoice-number generator ## Adding invoice-number generator
An invoice-number generator is a class extending `App\Invoice\NumberGeneratorInterface` and its only task is to generate An invoice-number generator is a class implementing `App\Invoice\NumberGeneratorInterface` and its only task is to generate
a number for the invoice. In most cases you do not want to mix multiple invoice-number generators throughout your invoices. a number for the invoice. In most cases you do not want to mix multiple invoice-number generators throughout your invoices.
Every invoice number-generator class will be picked up when refreshing the application cache by the [InvoiceServiceCompilerPass](https://github.com/kevinpapst/kimai2/blob/master/src/DependencyInjection/Compiler/InvoiceServiceCompilerPass.php): Every invoice number-generator class will be automatically available up when refreshing the application cache by the [InvoiceServiceCompilerPass](../../src/DependencyInjection/Compiler/InvoiceServiceCompilerPass.php):
The ID of the number generator must be unique, please prefix it with your vendor or bundle name and make sure it only contains The ID of the number generator must be unique, please prefix it with your vendor or bundle name and make sure it only contains
character as it will be stored in a database column. character as it will be stored in a database column.
Translations are stored in the `invoice-numbergenerator.xx.xliff`. Translations are stored in the `invoice-numbergenerator.xx.xliff`.
## Adding invoice renderer
An invoice renderer is a class implementing `App\Invoice\RendererInterface` and it is responsible to convert an `InvoiceModel` (the actual data)
with the use of an `InvoiceDocument` (the template file) into a downloadable/printable document.
Every invoice renderer class will be automatically available when refreshing the application cache by the [InvoiceServiceCompilerPass](../../src/DependencyInjection/Compiler/InvoiceServiceCompilerPass.php):
## Adding export renderer
An export renderer is a class implementing `App\Export\RendererInterface` and it is responsible to convert ar array of `Timesheet` objects
into a downloadable/printable document.
Every export renderer class will be automatically available when refreshing the application cache by the [ExportServiceCompilerPass](../../src/DependencyInjection/Compiler/ExportServiceCompilerPass.php):
## Adding timesheet calculator ## Adding timesheet calculator
A timesheet calculator will be called on stopped timesheet records. It can rewrite all values but will normally take care A timesheet calculator will be called on stopped timesheet records. It can rewrite all values but will normally take care

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