Compare commits

..

8 Commits

Author SHA1 Message Date
Kevin Papst
6a86afb5fd Release 2.46 (#5757) 2026-01-07 00:59:47 +01:00
Kevin Papst
9e87fc131b fade-out customer address (#5749) 2025-12-27 17:53:03 +01:00
Kevin Papst
5a0c783a76 added section name for meta-fields positioning (#5747) 2025-12-26 12:54:15 +01:00
Kevin Papst
26361da873 Support PHP 8.5 (#5746) 2025-12-25 16:44:11 +01:00
Weblate (bot)
26f8abfcc6 Translated using Weblate (#5745)
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Santiago Atienza Ferro <satienza@gmail.com>
Co-authored-by: World170 <dylanartigas10@gmail.com>
2025-12-25 14:10:09 +01:00
Kevin Papst
27c2b912f7 run CI also for php 8.5 2025-12-25 12:20:56 +01:00
Kevin Papst
7e72f144cb clarify api doc 2025-12-25 12:20:05 +01:00
Kevin Papst
3c3d6379a8 Invoice tax rates API (#5740) 2025-12-24 18:47:42 +01:00
113 changed files with 2152 additions and 2061 deletions

View File

@@ -21,7 +21,3 @@ indent_size = 4
[*.yml]
indent_style = space
indent_size = 4
[*.xml]
indent_style = space
indent_size = 4

View File

@@ -45,7 +45,3 @@ APP_SECRET=change_this_to_something_unique
#================================================================================
# unlikely, that you need to change this one
CORS_ALLOW_ORIGIN=^https?://localhost(:[0-9]+)?$
###> symfony/messenger ###
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
###< symfony/messenger ###

2
.gitattributes vendored
View File

@@ -5,8 +5,10 @@ tests export-ignore
.codecov.yml export-ignore
.editorconfig export-ignore
eslint.config.js export-ignore
eslint.config.mjs export-ignore
.gitattributes export-ignore
.gitignore export-ignore
.php-cs-fixer.dist.php export-ignore
php-cs-fixer.dist.php export-ignore
babel.config.js export-ignore
package.json export-ignore

View File

@@ -53,6 +53,7 @@ body:
attributes:
label: Which PHP version are you using?
options:
- "8.5"
- "8.4"
- "8.2"
- "8.3"

View File

@@ -22,7 +22,7 @@ version-resolver:
- 'translation'
default: patch
template: |
**Compatible with PHP 8.1 to 8.4**
**Compatible with PHP 8.1 to 8.5**
$CHANGES

View File

@@ -19,7 +19,7 @@ jobs:
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
strategy:
matrix:
php: ['8.1', '8.2', '8.3', '8.4']
php: ['8.1', '8.2', '8.3', '8.4', '8.5']
name: Integration (${{ matrix.php }})
steps:
@@ -87,7 +87,7 @@ jobs:
MAILER_URL: null://localhost
- name: Full test-suite
if: matrix.php != '8.2'
if: matrix.php != '8.5'
run: vendor/bin/phpunit tests/
env:
DATABASE_URL: mysql://root:kimai@127.0.0.1:${{ job.services.mysql.ports['3306'] }}/kimai?charset=utf8mb4&serverVersion=8.0.35
@@ -95,7 +95,7 @@ jobs:
MAILER_URL: null://localhost
- name: Full test-suite with coverage
if: matrix.php == '8.2'
if: matrix.php == '8.5'
run: vendor/bin/phpunit tests/ --coverage-clover=coverage.xml
env:
DATABASE_URL: mysql://root:kimai@127.0.0.1:${{ job.services.mysql.ports['3306'] }}/kimai?charset=utf8mb4&serverVersion=8.0.35
@@ -103,7 +103,7 @@ jobs:
MAILER_URL: null://localhost
- name: Upload code coverage
if: matrix.php == '8.2'
if: matrix.php == '8.5'
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}

3
.gitignore vendored
View File

@@ -3,6 +3,8 @@
/.env-*
/.idea/
.DS_Store
var/templates/
# custom apache rules e.g. to deactivate ioncube loader
/public/.user.ini
@@ -14,6 +16,7 @@
# YARN 2
/.yarnrc.yml
/.yarn
/.pnp.*
# for keeping empty directories
/config/packages/local.yaml

View File

@@ -10,7 +10,7 @@ Send your ideas, code reviews, pull requests and feature requests to help to imp
- Make your changes in a new git branch, based on the latest code in `main`
- Apply our code-style by running `composer codestyle-fix`
- Run the static code analysis with `composer phpstan`
- Verify everything still works with `composer tests-unit`
- Verify everything still works with `composer tests`
- Add tests for your changes
Further documentation can be found in the [developer documentation](https://www.kimai.org/documentation/developers.html).

View File

@@ -29,7 +29,7 @@ and so much more.
### Requirements
- PHP 8.1.3 minimum (support for PHP 8.2, 8.3, 8.4)
- PHP 8.1.3 minimum with support for 8.2, 8.3, 8.4, 8.5
- MariaDB or MySQL
- A webserver and subdomain (subdirectory is not supported)
- PHP extensions: `gd`, `intl`, `json`, `mbstring`, `pdo`, `tokenizer`, `xml`, `xsl`, `zip`
@@ -44,7 +44,7 @@ and so much more.
There are more documented ways for [on-premise hosting](https://www.kimai.org/documentation/chapter-on-premise.html).
And if you don't want to host Kimai, you can use [the Cloud version](https://www.kimai.cloud/) of it.
And if you don't want to host Kimai, you can use the [Cloud version](https://www.kimai.cloud/) of it.
### Updating Kimai

View File

@@ -13,6 +13,8 @@ Perform EACH version specific task between your version and the new one, otherwi
### Developer
Do not use method chaining: all fluent interface, especially in Entities, are no longer supported.
Removed translations:
- `action.edit`: use `edit` instead
- `my.profile`: use `user_profile` instead

View File

@@ -1,2 +1,4 @@
/**
* @deprecated use invoice-pdf instead
*/
require('./sass/_invoice.scss');

View File

@@ -24,7 +24,7 @@ export default class KimaiAutocompleteTags extends KimaiAutocomplete {
API.get(apiUrl, {'name': query}, (data) => {
let results = [];
for (let item of data) {
results.push({text: item.name, value: item.name, color: item.color});
results.push({text: item.name, value: item.name, color: item['color-safe']});
}
callback(results);
}, () => {

View File

@@ -14,7 +14,7 @@
}
],
"require": {
"php": "8.1.*||8.2.*||8.3.*||8.4.*",
"php": "8.1.*||8.2.*||8.3.*||8.4.*||8.5.*",
"ext-gd": "*",
"ext-intl": "*",
"ext-json": "*",
@@ -74,7 +74,6 @@
"symfony/translation": "^6.0",
"symfony/twig-bundle": "^6.0",
"symfony/validator": "^6.0",
"symfony/webhook": "^6.0",
"symfony/webpack-encore-bundle": "^2.0",
"symfony/yaml": "^6.0",
"twig/cssinliner-extra": "^3.0",

671
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +0,0 @@
framework:
messenger:
# Uncomment this (and the failed transport below) to send failed messages to this transport for later handling.
# failure_transport: failed
transports:
# https://symfony.com/doc/current/messenger.html#transport-configuration
# async: '%env(MESSENGER_TRANSPORT_DSN)%'
# failed: 'doctrine://default?queue_name=failed'
sync: 'sync://'
routing:
# Route your messages to the transports
# 'App\Message\YourMessage': async
# when@test:
# framework:
# messenger:
# transports:
# # replace with your transport name here (e.g., my_transport: 'in-memory://')
# # For more Messenger testing tools, see https://github.com/zenstruck/messenger-test
# async: 'in-memory://'

View File

@@ -1,3 +0,0 @@
#webhook:
# resource: '@FrameworkBundle/Resources/config/routing/webhook.xml'
# prefix: /webhook

View File

@@ -38,13 +38,6 @@
GRANT execute,select,insert,update,delete,create,alter,drop,index,references ON `kimai2_test`.* TO kimai2_test@127.0.0.1;
-->
<env name="BOOTSTRAP_RESET_DATABASE" value="true"/>
<!-- ###+ symfony/messenger ### -->
<!-- Choose one of the transports below -->
<!-- MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages -->
<!-- MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages -->
<env name="MESSENGER_TRANSPORT_DSN" value="doctrine://default?auto_setup=0"/>
<!-- ###- symfony/messenger ### -->
</php>
<testsuites>
<testsuite name="Kimai">

View File

@@ -4,7 +4,7 @@
<tile>
<square150x150logo src="favicon/mstile-150x150.png"/>
<square310x310logo src="favicon/mstile-large.jpg"/>
<TileColor>#00a300</TileColor>
<TileColor>#ffffff</TileColor>
</tile>
</msapplication>
</browserconfig>

View File

@@ -1 +0,0 @@
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[113],{876:function(i,n,u){"use strict";u.r(n)},2395:function(i,n,u){u(876)}},function(i){var n;n=2395,i(i.s=n)}]);

View File

@@ -0,0 +1 @@
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[113],{177:function(i,n,u){u(7996)},7996:function(i,n,u){"use strict";u.r(n)}},function(i){var n;n=177,i(i.s=n)}]);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -3,7 +3,7 @@
"app": {
"js": [
"/build/runtime.6c399d29.js",
"/build/app.9662939e.js"
"/build/app.307ea672.js"
],
"css": [
"/build/app.c18ba3c6.css"
@@ -12,7 +12,7 @@
"app-rtl": {
"js": [
"/build/runtime.6c399d29.js",
"/build/app-rtl.15853b82.js"
"/build/app-rtl.fe7c5bf2.js"
],
"css": [
"/build/app-rtl.2003dce5.css"
@@ -21,7 +21,7 @@
"export-pdf": {
"js": [
"/build/runtime.6c399d29.js",
"/build/export-pdf.395749ab.js"
"/build/export-pdf.5905454a.js"
],
"css": [
"/build/export-pdf.d8a6c23b.css"
@@ -30,7 +30,7 @@
"invoice": {
"js": [
"/build/runtime.6c399d29.js",
"/build/invoice.42b319e4.js"
"/build/invoice.0217cc18.js"
],
"css": [
"/build/invoice.36018785.css"
@@ -39,7 +39,7 @@
"invoice-pdf": {
"js": [
"/build/runtime.6c399d29.js",
"/build/invoice-pdf.26d98626.js"
"/build/invoice-pdf.1b6e735c.js"
],
"css": [
"/build/invoice-pdf.2b749265.css"
@@ -48,13 +48,13 @@
"chart": {
"js": [
"/build/runtime.6c399d29.js",
"/build/chart.bafa38e7.js"
"/build/chart.2c85f027.js"
]
},
"calendar": {
"js": [
"/build/runtime.6c399d29.js",
"/build/calendar.b2d70caa.js"
"/build/calendar.cef61816.js"
],
"css": [
"/build/calendar.d757753e.css"
@@ -63,7 +63,7 @@
"dashboard": {
"js": [
"/build/runtime.6c399d29.js",
"/build/dashboard.6ce7ac9c.js"
"/build/dashboard.faf42d4e.js"
],
"css": [
"/build/dashboard.b7129fa1.css"
@@ -72,7 +72,7 @@
"highlight": {
"js": [
"/build/runtime.6c399d29.js",
"/build/highlight.13d5d50e.js"
"/build/highlight.718fe73d.js"
],
"css": [
"/build/highlight.98bf3927.css"
@@ -81,22 +81,22 @@
},
"integrity": {
"/build/runtime.6c399d29.js": "sha384-/rm616f12czi8l/27GvWXtb3g608vJZf2XTUKxqCRI4tsa2vUHP+BW90edTok5zC",
"/build/app.9662939e.js": "sha384-bRL78SVI1wjTweqKeb+ZSSbA78i6By89S8/xx+55zmbOVtF1TCu3lf3pvdki5ZVJ",
"/build/app.307ea672.js": "sha384-XNjtkar2JbWyJQan/C6jHZAYFI9X5rPcExLeDV86FYZMPJl0vNWMh8Pa8NgXX/EB",
"/build/app.c18ba3c6.css": "sha384-qkIgqLzngG2NchdFVImbGMU49lWlZg6Y9D0z2P0u1j2NQstDicA4KqIkkNYcpErm",
"/build/app-rtl.15853b82.js": "sha384-UnKKgLMu9FnRT+CFE0no/+UiUks012bYriQdUWa6f02mo6Lswl947mPybjvKL503",
"/build/app-rtl.fe7c5bf2.js": "sha384-UyLXlR/3/H6oFjRrIMH04PA8bAjVQ8rdVWkWoiO4LLYasg4H3UNWJ+Xh+FrYekKh",
"/build/app-rtl.2003dce5.css": "sha384-pl8GyGo8sRRw1zLh9D42ZAvo450onfxeszZWocUUuzq70jAOwOlaoPYh/yyc20U7",
"/build/export-pdf.395749ab.js": "sha384-3Hjvmu4FC/0dhHnR8kyRBU7k2xMNy1lxBpGgOkrw8PxXnwyQDM8/5bQmkJbjVT1+",
"/build/export-pdf.5905454a.js": "sha384-AkNj018YEQJSxC1e/3jIvlh0uR5tHnlUe9ciBbHKIoRIwj7658tCLNVaT98SNFEX",
"/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",
"/build/invoice.42b319e4.js": "sha384-xxK7sCe/ZhTjMPFPeX1xvILURxNRZz2hJHZxAGVaw9zE6TC++2/6y2eKqg8cz852",
"/build/invoice.0217cc18.js": "sha384-ORySQp4DBSYc7so90AAb8h5abgQtz3tdoO+Zv1uHJ2XqSPMAGtFBrb+Rdzc6sZg1",
"/build/invoice.36018785.css": "sha384-jukM9uZ6pexDxXKgZThSxiqXimzsxzniBMHz08N9x8ryXZYkM5r/ZgaainCV0+J6",
"/build/invoice-pdf.26d98626.js": "sha384-gwNzQiU1y6qU/M9DPGiNW0MVZkLctEHk37sCES2X9ov+zugEaDABdkMjKBYOC9lz",
"/build/invoice-pdf.1b6e735c.js": "sha384-8I5YMkETBNl7wD12V/hKX2YGfwH+V6PXWsu4I+1xXXx9bDzqDhtq+S7x/jey2/Wr",
"/build/invoice-pdf.2b749265.css": "sha384-DXXgkz2WWnrWnfBnXX5fmfPQSPb98upMnWxYKwTGYS04EhrPIWfDCutB2unIrWh7",
"/build/chart.bafa38e7.js": "sha384-Ays2qGKvOqs4NSeN/zJOPcrnzIEC/uYSCIkC3kN0KQye+mA5Lq7UkfjZDjulvFUX",
"/build/calendar.b2d70caa.js": "sha384-FS7Q9iCWHpo2Nzk0tmNvnuTbxJKLdbXFba9F6WcXfHbKFAS2Q+lW29H9Mv+ap5kA",
"/build/chart.2c85f027.js": "sha384-5Dm9aFCCSzlh5QY/vtU7/UrBkKt6aWnTccjljmpbQVFfzSEfFh0wUUV8qlXqyFJj",
"/build/calendar.cef61816.js": "sha384-qCpJrSB6fZLXT94oiWHofGbX8C0TFl86oCMTdeKWqCmNGJxABkIVo+8k19p7sGlY",
"/build/calendar.d757753e.css": "sha384-cTmQMgHYjd2gfObFWmEUph7qQLCyXaIkneSf+bQ2mqVmZwqOB+pJOm/UYTyTjALJ",
"/build/dashboard.6ce7ac9c.js": "sha384-5BOoyjZx/ZKr9IbogWfT/aBRHx7XA1fePADdUpUNlSF/rqVo3taXLLcrIoaDZ+8b",
"/build/dashboard.faf42d4e.js": "sha384-xkM5D8wBK1sJekLI9DUQBxTjxjomfPQWWtWCo7NX03jNnFZtMv5anM0IISLWaFY0",
"/build/dashboard.b7129fa1.css": "sha384-2nn5hLA+3YedgHYBpge62S8Losj8aoPwK9Zk9EvN1xEYatvOUQ7H3rIR2UUJAGOS",
"/build/highlight.13d5d50e.js": "sha384-+RqBBPIzp5YQpbBeHgVNXzHiewK1PZtizPh4in1PlBU6RcT1bDleW3QSJMDeBnPO",
"/build/highlight.718fe73d.js": "sha384-eHb21blXJ7SKqCFlMSxl4oblpusDif1B0Tsr2Iw1xQfUqxZxwtFwYYvMPllfw7BY",
"/build/highlight.98bf3927.css": "sha384-YgweSwDwN0dI4DEmh478xYVw/TewJYvCO1QTbWHpaHFDPuLrZvYMR0Tc+QfFxVPE"
}
}

View File

@@ -1 +0,0 @@
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[872],{878:function(i,n,u){u(3385)},3385:function(i,n,u){"use strict";u.r(n)}},function(i){var n;n=878,i(i.s=n)}]);

View File

@@ -0,0 +1 @@
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[872],{585:function(i,n,u){"use strict";u.r(n)},2352:function(i,n,u){u(585)}},function(i){var n;n=2352,i(i.s=n)}]);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[117],{2049:function(i,n,u){u(4860)},4860:function(i,n,u){"use strict";u.r(n)}},function(i){var n;n=2049,i(i.s=n)}]);

View File

@@ -1 +0,0 @@
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[117],{1555:function(i,n,u){u(6620)},6620:function(i,n,u){"use strict";u.r(n)}},function(i){var n;n=1555,i(i.s=n)}]);

View File

@@ -0,0 +1 @@
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[896],{239:function(i,n,u){"use strict";u.r(n)},8478:function(i,n,u){u(239)}},function(i){var n;n=8478,i(i.s=n)}]);

View File

@@ -1 +0,0 @@
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[896],{3631:function(i,n,u){"use strict";u.r(n)},4820:function(i,n,u){u(3631)}},function(i){var n;n=4820,i(i.s=n)}]);

View File

@@ -1,21 +1,21 @@
{
"build/app.css": "/build/app.c18ba3c6.css",
"build/app.js": "/build/app.9662939e.js",
"build/app.js": "/build/app.307ea672.js",
"build/app-rtl.css": "/build/app-rtl.2003dce5.css",
"build/app-rtl.js": "/build/app-rtl.15853b82.js",
"build/app-rtl.js": "/build/app-rtl.fe7c5bf2.js",
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
"build/export-pdf.js": "/build/export-pdf.395749ab.js",
"build/export-pdf.js": "/build/export-pdf.5905454a.js",
"build/invoice.css": "/build/invoice.36018785.css",
"build/invoice.js": "/build/invoice.42b319e4.js",
"build/invoice.js": "/build/invoice.0217cc18.js",
"build/invoice-pdf.css": "/build/invoice-pdf.2b749265.css",
"build/invoice-pdf.js": "/build/invoice-pdf.26d98626.js",
"build/chart.js": "/build/chart.bafa38e7.js",
"build/invoice-pdf.js": "/build/invoice-pdf.1b6e735c.js",
"build/chart.js": "/build/chart.2c85f027.js",
"build/calendar.css": "/build/calendar.d757753e.css",
"build/calendar.js": "/build/calendar.b2d70caa.js",
"build/calendar.js": "/build/calendar.cef61816.js",
"build/dashboard.css": "/build/dashboard.b7129fa1.css",
"build/dashboard.js": "/build/dashboard.6ce7ac9c.js",
"build/dashboard.js": "/build/dashboard.faf42d4e.js",
"build/highlight.css": "/build/highlight.98bf3927.css",
"build/highlight.js": "/build/highlight.13d5d50e.js",
"build/highlight.js": "/build/highlight.718fe73d.js",
"build/runtime.js": "/build/runtime.6c399d29.js",
"build/fonts/fa-solid-900.ttf": "/build/fonts/fa-solid-900.2582b0e4.ttf",
"build/fonts/fa-brands-400.ttf": "/build/fonts/fa-brands-400.1815e004.ttf",

View File

@@ -25,7 +25,7 @@
],
"scope": "./",
"start_url": "./",
"theme-color": "#1d273b",
"theme-color": "#262626",
"background_color": "#ffffff",
"display": "standalone"
}

View File

@@ -43,8 +43,8 @@ final class InvoiceController extends BaseApiController
#[IsGranted('view_invoice')]
#[OA\Response(response: 200, description: 'Returns a collection of invoices', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/InvoiceCollection')))]
#[Route(methods: ['GET'], path: '', name: 'get_invoices')]
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records after this date will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
#[Rest\QueryParam(name: 'end', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records before this date will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only invoices created at or after this date-time will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
#[Rest\QueryParam(name: 'end', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only invoices created before or at this date-time will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
#[Rest\QueryParam(name: 'customers', map: true, requirements: '\d+', strict: true, nullable: true, default: [], description: 'List of customer IDs to filter, e.g.: customers[]=1&customers[]=2')]
#[Rest\QueryParam(name: 'status', map: true, requirements: 'pending|paid|canceled|new', strict: true, nullable: true, default: [], description: 'Invoice status: pending, paid, canceled, new. Default: all')]
#[Rest\QueryParam(name: 'page', requirements: '\d+', strict: true, nullable: true, description: 'The page to display, renders a 404 if not found (default: 1)')]

View File

@@ -86,8 +86,8 @@ final class TimesheetController extends BaseApiController
#[Rest\QueryParam(name: 'tags', map: true, strict: true, nullable: true, default: [], description: 'List of tag names, e.g. tags[]=bar&tags[]=foo')]
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|begin|end|rate', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, begin, end, rate (default: begin)')]
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: DESC)')]
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records after this date will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
#[Rest\QueryParam(name: 'end', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records before this date will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records started at or after this date-time will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
#[Rest\QueryParam(name: 'end', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records started at or before this date-time will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
#[Rest\QueryParam(name: 'exported', requirements: '0|1', strict: true, nullable: true, description: 'Use this flag if you want to filter for export state. Allowed values: 0=not exported, 1=exported (default: all)')]
#[Rest\QueryParam(name: 'active', requirements: '0|1', strict: true, nullable: true, description: 'Filter for running/active records. Allowed values: 0=stopped, 1=active (default: all)')]
#[Rest\QueryParam(name: 'billable', requirements: '0|1', strict: true, nullable: true, description: 'Filter for non-/billable records. Allowed values: 0=non-billable, 1=billable (default: all)')]
@@ -380,7 +380,7 @@ final class TimesheetController extends BaseApiController
#[IsGranted('view_own_timesheet')]
#[OA\Response(response: 200, description: 'Returns a collection of recent user activities (always the latest entry of a unique working set grouped by customer, project and activity)', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TimesheetCollectionExpanded')))]
#[Route(methods: ['GET'], path: '/recent', name: 'recent_timesheet')]
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records after this date will be included. Default: today - 1 year (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records started at or after this date will be included. Default: today - 1 year (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
#[Rest\QueryParam(name: 'size', requirements: '\d+', strict: true, nullable: true, description: 'The amount of entries (default: 10)')]
public function recentAction(ParamFetcherInterface $paramFetcher): Response
{

View File

@@ -47,6 +47,8 @@ final class TranslationCommand extends Command
->addOption('fill-empty', null, InputOption::VALUE_NONE, 'Pre-fills empty translations with the english version')
->addOption('delete-empty', null, InputOption::VALUE_NONE, 'Delete all empty keys and files which have no translated key at all')
->addOption('move-resname', null, InputOption::VALUE_REQUIRED, 'Move a resname from one file to another (needs "source" and "target" options)')
->addOption('copy-resname', null, InputOption::VALUE_REQUIRED, 'Copy a resname within one file (needs "source" option)')
->addOption('target-resname', null, InputOption::VALUE_REQUIRED, 'Target resname when copying (needs "source" option)')
->addOption('move-all', null, InputOption::VALUE_NONE, 'Move all keys from one file to another (needs "source" and "target" options)')
->addOption('source', null, InputOption::VALUE_REQUIRED, 'Single source file to use')
->addOption('only-core', null, InputOption::VALUE_NONE, 'Do not include plugin and theme directories')
@@ -150,6 +152,21 @@ final class TranslationCommand extends Command
return $this->moveResname($io, $moveResname, $sources, $targets);
}
// ==========================================================================
// Move resname from source to target
// ==========================================================================
$copyResname = $input->getOption('copy-resname');
$targetResname = $input->getOption('target-resname');
if (\is_string($copyResname)) {
if (!\is_string($targetResname)) {
$io->error('To copy a resname, we need a target-resname');
return Command::FAILURE;
}
return $this->copyResname($io, $copyResname, $targetResname, $sources);
}
// ==========================================================================
// Move all keys from source to target
// ==========================================================================
@@ -654,6 +671,62 @@ final class TranslationCommand extends Command
return Command::SUCCESS;
}
/**
* @param array<string> $sources
*/
private function copyResname(SymfonyStyle $io, string $resname, string $target, array $sources): int
{
foreach ($sources as $source) {
$tmp = basename($source);
$pos = strpos($tmp, '.');
if ($pos === false) {
$io->error('Unexpected filename: ' . $source);
return Command::FAILURE;
}
$sourceDocument = new \DOMDocument('1.0');
$sourceDocument->load($source);
$copiedNode = false;
/** @var \DOMElement $element */
foreach ($sourceDocument->getElementsByTagName('trans-unit') as $element) {
if (!$element->hasAttribute('resname')) {
continue;
}
$key = $element->getAttribute('resname');
if ($key === $resname) {
$newElement = clone $element;
$newElement->setAttribute('resname', $target);
foreach ($newElement->childNodes->getIterator() as $child) {
if ($child->nodeName === 'source') {
$child->textContent = $target;
}
}
$newElement->setAttribute('id', $this->generateId($target));
$newNode = $sourceDocument->importNode($newElement, true);
$sourceDocument->documentElement->firstElementChild->firstElementChild->appendChild($newNode); // @phpstan-ignore-line
$copiedNode = true;
break;
}
}
if ($copiedNode) {
$xmlDocument = new \DOMDocument('1.0');
$xmlDocument->preserveWhiteSpace = false;
$xmlDocument->formatOutput = true;
$xmlDocument->loadXML($sourceDocument->saveXML()); // @phpstan-ignore-line
file_put_contents($source, $xmlDocument->saveXML());
}
}
return Command::SUCCESS;
}
/**
* @param array<string> $sources
* @param array<string> $targets

View File

@@ -17,11 +17,11 @@ final class Constants
/**
* The current release version
*/
public const VERSION = '2.45.0';
public const VERSION = '2.46.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 24500;
public const VERSION_ID = 24600;
/**
* The software name
*/

View File

@@ -90,7 +90,7 @@ final class CustomerController extends AbstractController
$table->addColumn('company', ['class' => 'd-none']);
$table->addColumn('vat_id', ['class' => 'd-none w-min']);
$table->addColumn('contact', ['class' => 'd-none']);
$table->addColumn('address', ['class' => 'd-none']);
$table->addColumn('city', ['class' => 'd-none']);
$table->addColumn('country', ['class' => 'd-none w-min']);
$table->addColumn('currency', ['class' => 'd-none w-min']);
$table->addColumn('phone', ['class' => 'd-none']);

View File

@@ -61,18 +61,15 @@ use Twig\Environment;
final class InvoiceController extends AbstractController
{
public function __construct(
private readonly ServiceInvoice $service,
private readonly InvoiceTemplateRepository $templateRepository,
private readonly InvoiceRepository $invoiceRepository,
private readonly EventDispatcherInterface $dispatcher
) {
}
#[Route(path: '/', name: 'invoice', methods: ['GET', 'POST'])]
#[IsGranted('create_invoice')]
public function indexAction(Request $request, CsrfTokenManagerInterface $csrfTokenManager): Response
public function indexAction(Request $request, ServiceInvoice $service, InvoiceTemplateRepository $templateRepository): Response
{
if (!$this->templateRepository->hasTemplate()) {
if (!$templateRepository->hasTemplate()) {
if ($this->isGranted('manage_invoice_template')) {
return $this->redirectToRoute('admin_invoice_template_create');
}
@@ -95,7 +92,7 @@ final class InvoiceController extends AbstractController
if ($form->isValid() && $query->getTemplate() !== null) {
try {
$models = $this->service->createModels($query);
$models = $service->createModels($query);
$searched = true;
} catch (Exception $ex) {
$this->flashUpdateException($ex);
@@ -139,12 +136,8 @@ final class InvoiceController extends AbstractController
#[Route(path: '/preview/{customer}/{token}', name: 'invoice_preview', methods: ['GET'])]
#[IsGranted('create_invoice')]
#[IsGranted('access', 'customer')]
public function previewAction(Customer $customer, string $token, Request $request): Response
public function previewAction(Customer $customer, string $token, Request $request, ServiceInvoice $service): Response
{
if (!$this->templateRepository->hasTemplate()) {
return $this->redirectToRoute('invoice');
}
if (!$this->isCsrfTokenValid('invoice.preview', $token)) {
$this->flashError('action.csrf.error');
@@ -164,10 +157,10 @@ final class InvoiceController extends AbstractController
if ($form->isValid()) {
try {
$query->setCustomers([$customer]);
$model = $this->service->createModel($query);
$model = $service->createModel($query);
$model->setPreview(true);
return $this->service->renderInvoice($model, $this->dispatcher, true);
return $service->renderInvoice($model, $this->dispatcher, true);
} catch (Exception $ex) {
$this->flashUpdateException($ex);
}
@@ -181,12 +174,8 @@ final class InvoiceController extends AbstractController
#[Route(path: '/save-invoice/{customer}/{token}', name: 'invoice_create', methods: ['GET'])]
#[IsGranted('create_invoice')]
#[IsGranted('access', 'customer')]
public function createInvoiceAction(Customer $customer, string $token, Request $request, CustomerRepository $customerRepository): Response
public function createInvoiceAction(Customer $customer, string $token, Request $request, CustomerRepository $customerRepository, ServiceInvoice $service): Response
{
if (!$this->templateRepository->hasTemplate()) {
return $this->redirectToRoute('invoice');
}
if (!$this->isCsrfTokenValid('invoice.create', $token)) {
$this->flashError('action.csrf.error');
@@ -203,7 +192,7 @@ final class InvoiceController extends AbstractController
if ($form->isValid()) {
try {
$query->setCustomers([$customer]);
$model = $this->service->createModel($query);
$model = $service->createModel($query);
// save default template for customer if not yet set
if ($customer->getInvoiceTemplate() === null) {
@@ -211,7 +200,7 @@ final class InvoiceController extends AbstractController
$customerRepository->saveCustomer($customer);
}
$invoice = $this->service->createInvoice($model, $this->dispatcher);
$invoice = $service->createInvoice($model, $this->dispatcher);
$this->flashSuccess('action.update.success');
@@ -229,7 +218,7 @@ final class InvoiceController extends AbstractController
#[Route(path: '/change-status/{id}/{status}/{token}', name: 'admin_invoice_status', methods: ['GET', 'POST'])]
#[IsGranted('create_invoice')]
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')]
public function changeStatusAction(Invoice $invoice, string $status, string $token, Request $request, CsrfTokenManagerInterface $csrfTokenManager): Response
public function changeStatusAction(Invoice $invoice, string $status, string $token, Request $request, CsrfTokenManagerInterface $csrfTokenManager, ServiceInvoice $service): Response
{
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.status', $token))) {
$this->flashError('action.csrf.error');
@@ -254,7 +243,7 @@ final class InvoiceController extends AbstractController
}
try {
$this->service->changeInvoiceStatus($invoice, $status);
$service->changeInvoiceStatus($invoice, $status);
$this->flashSuccess('action.update.success');
} catch (Exception $ex) {
$this->flashUpdateException($ex);
@@ -266,14 +255,14 @@ final class InvoiceController extends AbstractController
#[Route(path: '/edit/{id}', name: 'admin_invoice_edit', methods: ['GET', 'POST'])]
#[IsGranted('create_invoice')]
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')]
public function editAction(Invoice $invoice, Request $request): Response
public function editAction(Invoice $invoice, Request $request, InvoiceRepository $invoiceRepository): Response
{
$form = $this->createInvoiceEditForm($invoice);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
try {
$this->invoiceRepository->saveInvoice($invoice);
$invoiceRepository->saveInvoice($invoice);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_invoice_list');
@@ -292,7 +281,7 @@ final class InvoiceController extends AbstractController
#[Route(path: '/delete/{id}/{token}', name: 'admin_invoice_delete', methods: ['GET'])]
#[IsGranted('delete_invoice')]
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')]
public function deleteInvoiceAction(Invoice $invoice, string $token, CsrfTokenManagerInterface $csrfTokenManager): Response
public function deleteInvoiceAction(Invoice $invoice, string $token, CsrfTokenManagerInterface $csrfTokenManager, ServiceInvoice $service): Response
{
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.status', $token))) {
$this->flashError('action.csrf.error');
@@ -303,7 +292,7 @@ final class InvoiceController extends AbstractController
$csrfTokenManager->refreshToken('invoice.status');
try {
$this->service->deleteInvoice($invoice, $this->dispatcher);
$service->deleteInvoice($invoice, $this->dispatcher);
$this->flashSuccess('action.delete.success');
} catch (Exception $ex) {
$this->flashDeleteException($ex);
@@ -315,9 +304,9 @@ final class InvoiceController extends AbstractController
#[Route(path: '/download/{id}', name: 'admin_invoice_download', methods: ['GET'])]
#[IsGranted('view_invoice')]
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')]
public function downloadAction(Invoice $invoice): Response
public function downloadAction(Invoice $invoice, ServiceInvoice $service): Response
{
$file = $this->service->getInvoiceFile($invoice);
$file = $service->getInvoiceFile($invoice);
if (null === $file) {
throw $this->createNotFoundException(
@@ -330,12 +319,12 @@ final class InvoiceController extends AbstractController
#[Route(path: '/show/{page}', defaults: ['page' => 1], requirements: ['page' => '[1-9]\d*'], name: 'admin_invoice_list', methods: ['GET'])]
#[IsGranted('view_invoice')]
public function showInvoicesAction(Request $request, int $page): Response
public function showInvoicesAction(Request $request, int $page, InvoiceRepository $invoiceRepository): Response
{
$invoice = null;
if (null !== ($id = $request->query->get('id'))) {
$invoice = $this->invoiceRepository->find($id);
$invoice = $invoiceRepository->find($id);
}
$query = new InvoiceArchiveQuery();
@@ -347,7 +336,7 @@ final class InvoiceController extends AbstractController
return $this->redirectToRoute('admin_invoice_list');
}
$entries = $this->invoiceRepository->getPagerfantaForQuery($query);
$entries = $invoiceRepository->getPagerfantaForQuery($query);
$metaColumns = $this->findMetaColumns($query);
$table = new DataTable('invoices', $query);
@@ -389,7 +378,7 @@ final class InvoiceController extends AbstractController
#[Route(path: '/export', name: 'invoice_export', methods: ['GET'])]
#[IsGranted('view_invoice')]
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter): Response
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter, InvoiceRepository $invoiceRepository, InvoiceTemplateRepository $templateRepository): Response
{
$query = new InvoiceArchiveQuery();
$query->setCurrentUser($this->getUser());
@@ -398,7 +387,7 @@ final class InvoiceController extends AbstractController
$form->setData($query);
$form->submit($request->query->all(), false);
$entries = $this->invoiceRepository->getInvoicesForQuery($query);
$entries = $invoiceRepository->getInvoicesForQuery($query);
$spreadsheet = $exporter->export(
Invoice::class,
@@ -412,12 +401,12 @@ final class InvoiceController extends AbstractController
#[Route(path: '/template/{page}', requirements: ['page' => '[1-9]\d*'], defaults: ['page' => 1], name: 'admin_invoice_template', methods: ['GET', 'POST'])]
#[IsGranted('manage_invoice_template')]
public function listTemplateAction(int $page): Response
public function listTemplateAction(int $page, InvoiceTemplateRepository $templateRepository): Response
{
$query = new BaseQuery();
$query->setPage($page);
$entries = $this->templateRepository->getPagerfantaForQuery($query);
$entries = $templateRepository->getPagerfantaForQuery($query);
$table = new DataTable('invoice_template', $query);
$table->setPagination($entries);
@@ -449,16 +438,16 @@ final class InvoiceController extends AbstractController
#[Route(path: '/template/{id}/edit', name: 'admin_invoice_template_edit', methods: ['GET', 'POST'])]
#[IsGranted('manage_invoice_template')]
public function editTemplateAction(InvoiceTemplate $template, Request $request): Response
public function editTemplateAction(InvoiceTemplate $template, Request $request, InvoiceTemplateRepository $templateRepository): Response
{
return $this->renderTemplateForm($template, $request);
return $this->renderTemplateForm($template, $request, $templateRepository);
}
#[Route(path: '/document_download/{document}', name: 'admin_invoice_document_download', methods: ['GET'])]
#[IsGranted('upload_invoice_template')]
public function downloadDocument(string $document, Environment $twig): Response
public function downloadDocument(string $document, ServiceInvoice $service): Response
{
$event = new InvoiceDocumentsEvent($this->service->getDocuments(true));
$event = new InvoiceDocumentsEvent($service->getDocuments(true));
$this->dispatcher->dispatch($event);
foreach ($event->getInvoiceDocuments() as $doc) {
@@ -472,7 +461,7 @@ final class InvoiceController extends AbstractController
#[Route(path: '/document_upload', name: 'admin_invoice_document_upload', methods: ['GET', 'POST'])]
#[IsGranted('upload_invoice_template')]
public function uploadDocumentAction(Request $request, string $projectDirectory, InvoiceDocumentRepository $documentRepository, Environment $twig, SystemConfiguration $systemConfiguration): Response
public function uploadDocumentAction(Request $request, string $projectDirectory, InvoiceDocumentRepository $documentRepository, Environment $twig, SystemConfiguration $systemConfiguration, ServiceInvoice $service, InvoiceTemplateRepository $templateRepository): Response
{
$dir = $documentRepository->getUploadDirectory();
$invoiceDir = $dir;
@@ -484,11 +473,11 @@ final class InvoiceController extends AbstractController
$invoiceDir = rtrim($invoiceDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$used = [];
foreach ($this->templateRepository->findAll() as $template) {
foreach ($templateRepository->findAll() as $template) {
$used[$template->getRenderer()] = $template;
}
$event = new InvoiceDocumentsEvent($this->service->getDocuments(true));
$event = new InvoiceDocumentsEvent($service->getDocuments(true));
$this->dispatcher->dispatch($event);
$documents = [];
@@ -606,7 +595,7 @@ final class InvoiceController extends AbstractController
#[Route(path: '/document/{id}/delete/{token}', name: 'invoice_document_delete', methods: ['GET', 'POST'])]
#[IsGranted('manage_invoice_template')]
public function deleteDocument(string $id, string $token, CsrfTokenManagerInterface $csrfTokenManager, InvoiceDocumentRepository $documentRepository): Response
public function deleteDocument(string $id, string $token, CsrfTokenManagerInterface $csrfTokenManager, InvoiceDocumentRepository $documentRepository, InvoiceTemplateRepository $templateRepository): Response
{
$document = $documentRepository->findByName($id);
if ($document === null) {
@@ -629,7 +618,7 @@ final class InvoiceController extends AbstractController
}
}
foreach ($this->templateRepository->findAll() as $template) {
foreach ($templateRepository->findAll() as $template) {
if ($template->getRenderer() === $id) {
$this->flashError('Document is used and cannot be deleted.');
@@ -649,19 +638,19 @@ final class InvoiceController extends AbstractController
#[Route(path: '/template/create/{id}', name: 'admin_invoice_template_copy', methods: ['GET', 'POST'])]
#[IsGranted('manage_invoice_template')]
public function copyTemplateAction(Request $request, InvoiceTemplate $copyFrom): Response
public function copyTemplateAction(Request $request, InvoiceTemplate $copyFrom, InvoiceTemplateRepository $templateRepository): Response
{
return $this->createTemplate($request, $copyFrom);
return $this->createTemplate($request, $templateRepository, $copyFrom);
}
#[Route(path: '/template/create', name: 'admin_invoice_template_create', methods: ['GET', 'POST'])]
#[IsGranted('manage_invoice_template')]
public function createTemplateAction(Request $request): Response
public function createTemplateAction(Request $request, InvoiceTemplateRepository $templateRepository): Response
{
return $this->createTemplate($request, null);
return $this->createTemplate($request, $templateRepository, null);
}
private function createTemplate(Request $request, ?InvoiceTemplate $copyFrom = null): Response
private function createTemplate(Request $request, InvoiceTemplateRepository $templateRepository, ?InvoiceTemplate $copyFrom = null): Response
{
$template = new InvoiceTemplate();
$template->setLanguage($request->getLocale());
@@ -671,12 +660,12 @@ final class InvoiceController extends AbstractController
$template->setName($copyFrom->getName() . ' (1)');
}
return $this->renderTemplateForm($template, $request);
return $this->renderTemplateForm($template, $request, $templateRepository);
}
#[Route(path: '/template/{id}/delete/{csrfToken}', name: 'admin_invoice_template_delete', methods: ['GET', 'POST'])]
#[IsGranted('manage_invoice_template')]
public function deleteTemplate(InvoiceTemplate $template, string $csrfToken, CsrfTokenManagerInterface $csrfTokenManager): Response
public function deleteTemplate(InvoiceTemplate $template, string $csrfToken, CsrfTokenManagerInterface $csrfTokenManager, InvoiceTemplateRepository $templateRepository): Response
{
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.delete_template', $csrfToken))) {
$this->flashError('action.csrf.error');
@@ -687,7 +676,7 @@ final class InvoiceController extends AbstractController
$csrfTokenManager->refreshToken('invoice.delete_template');
try {
$this->templateRepository->removeTemplate($template);
$templateRepository->removeTemplate($template);
$this->flashSuccess('action.delete.success');
} catch (Exception $ex) {
$this->flashDeleteException($ex);
@@ -727,7 +716,7 @@ final class InvoiceController extends AbstractController
$this->flashError('action.update.error', $err);
}
private function renderTemplateForm(InvoiceTemplate $template, Request $request): Response
private function renderTemplateForm(InvoiceTemplate $template, Request $request, InvoiceTemplateRepository $templateRepository): Response
{
$event = new InvoiceTemplateMetaDefinitionEvent($template);
$this->dispatcher->dispatch($event);
@@ -738,7 +727,7 @@ final class InvoiceController extends AbstractController
if ($editForm->isSubmitted() && $editForm->isValid()) {
try {
$this->templateRepository->saveTemplate($template);
$templateRepository->saveTemplate($template);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_invoice_template');

View File

@@ -23,7 +23,8 @@ trait ColorTrait
* The assigned color in HTML hex format, e.g. #dd1d00
*/
#[ORM\Column(name: 'color', type: Types::STRING, length: 7, nullable: true)]
#[Serializer\Exclude]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'color')]
#[Constraints\HexColor]
private ?string $color = null;
@@ -50,10 +51,10 @@ trait ColorTrait
abstract public function getName(): ?string;
/**
* Internal value: this color will never be empty and is generated by the tag name if not set explicit.
* Color will never be empty and is generated from the entity tag name if not set explicit.
*/
#[Serializer\VirtualProperty]
#[Serializer\SerializedName('color')]
#[Serializer\SerializedName('color-safe')]
#[Serializer\Groups(['Default'])]
public function getColorSafe(): string
{

View File

@@ -231,7 +231,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[Assert\Length(max: 50)]
#[Serializer\Expose]
#[Serializer\Groups(['Customer_Entity'])]
#[Exporter\Expose(label: 'buyer_reference')]
#[Exporter\Expose(label: 'buyerReference')]
private ?string $buyerReference = null;
public function __construct(string $name)

View File

@@ -12,8 +12,8 @@ namespace App\Entity;
use Doctrine\Common\Collections\Collection;
/**
* @method getTags() array
* @method getBreak() int
* @method array<Tag> getTags()
* @method int getBreak()
*/
interface ExportableItem
{

View File

@@ -88,6 +88,10 @@ class InvoiceTemplate implements EntityWithMetaFields
*/
#[ORM\OneToMany(mappedBy: 'template', targetEntity: InvoiceTemplateMeta::class, cascade: ['persist'])]
private Collection $meta;
/**
* @var array<Tax>
*/
private array $taxRates = [];
public function __construct()
{
@@ -268,16 +272,29 @@ class InvoiceTemplate implements EntityWithMetaFields
$this->language = $language;
}
/**
* @param array<Tax> $taxRates
*/
public function setTaxRates(array $taxRates): void
{
$this->taxRates = $taxRates;
}
/**
* @return Tax[]
*/
public function getTaxRates(): array
{
// TODO make me configurable via UI
if (\count($this->taxRates) > 0) {
return $this->taxRates;
}
$tax = new Tax(
TaxType::STANDARD,
'VAT',
$this->vat ?? 0.00
$this->vat ?? 0.00,
'vat',
true,
null
);
return [$tax];

View File

@@ -11,6 +11,10 @@ namespace App\Entity;
use Symfony\Component\Validator\Constraint;
/**
* @method null|string getSection()
* @method void setSection(?string $name)
*/
interface MetaTableTypeInterface
{
/**
@@ -43,6 +47,8 @@ interface MetaTableTypeInterface
/**
* This will merge the current object with the values from the given $meta instance.
* It should NOT update the name or value, but only the form settings.
*
* Settings from the given $meta object will win over the current ones.
*/
public function merge(MetaTableTypeInterface $meta): MetaTableTypeInterface;
@@ -132,6 +138,16 @@ interface MetaTableTypeInterface
*/
public function getOrder(): int;
/**
* FIXME activate with 3.0
*/
//public function setSection(?string $section): void;
/**
* FIXME activate with 3.0
*/
//public function getSection(): ?string;
/**
* Whether true if this field is defined by a plugin, or false if it is a value stored in the database.
*/

View File

@@ -67,6 +67,7 @@ trait MetaTableTypeTrait
*/
private mixed $data = null;
private bool $updated = false;
private ?string $section = null;
public function getName(): ?string
{
@@ -193,13 +194,11 @@ trait MetaTableTypeTrait
public function merge(MetaTableTypeInterface $meta): MetaTableTypeInterface
{
$this
->setConstraints($meta->getConstraints())
->setIsRequired($meta->isRequired())
->setIsVisible($meta->isVisible())
->setOptions($meta->getOptions())
->setOrder($meta->getOrder())
;
$this->setConstraints($meta->getConstraints());
$this->setIsRequired($meta->isRequired());
$this->setIsVisible($meta->isVisible());
$this->setOptions($meta->getOptions());
$this->setOrder($meta->getOrder());
if ($meta->getLabel() !== null) {
$this->setLabel($meta->getLabel());
@@ -209,6 +208,10 @@ trait MetaTableTypeTrait
$this->setType($meta->getType());
}
if ($meta->getSection() !== null) {
$this->setSection($meta->getSection());
}
return $this;
}
@@ -266,6 +269,16 @@ trait MetaTableTypeTrait
}
}
public function setSection(?string $section): void
{
$this->section = $section;
}
public function getSection(): ?string
{
return $this->section;
}
/**
* Whether this field is defined by a plugin or just a value stored in the database.
*/

View File

@@ -13,8 +13,10 @@ final class Tax
{
public function __construct(
private readonly TaxType $type,
private readonly string $name = 'VAT',
private readonly float $rate = 0.0,
private readonly float $rate,
private readonly string $name,
private readonly bool $show,
private readonly ?string $note,
)
{
}
@@ -33,4 +35,14 @@ final class Tax
{
return $this->rate;
}
public function isShow(): bool
{
return $this->rate > 0.0 || $this->show;
}
public function getNote(): ?string
{
return $this->note;
}
}

View File

@@ -1,60 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Entity;
class WebhookConfiguration
{
private string $name;
private string $url;
private string $transport;
private string $secret;
private string $authentication;
public function __construct(
string $name,
string $url,
string $transport,
#[\SensitiveParameter]
string $secret,
string $authentication
)
{
$this->name = $name;
$this->url = $url;
$this->transport = $transport;
$this->secret = $secret;
$this->authentication = $authentication;
}
public function getName(): string
{
return $this->name;
}
public function getUrl(): string
{
return $this->url;
}
public function getTransport(): string
{
return $this->transport;
}
public function getSecret(): string
{
return $this->secret;
}
public function getAuthentication(): string
{
return $this->authentication;
}
}

View File

@@ -1,32 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Entity;
class WebhookEvent
{
private string $name;
private WebhookConfiguration $configuration;
public function __construct(string $name, WebhookConfiguration $configuration)
{
$this->name = $name;
$this->configuration = $configuration;
}
public function getName(): string
{
return $this->name;
}
public function getConfiguration(): WebhookConfiguration
{
return $this->configuration;
}
}

View File

@@ -14,6 +14,14 @@ use App\Invoice\RendererInterface;
use App\Model\InvoiceDocument;
use Symfony\Contracts\EventDispatcher\Event;
/**
* Triggered right before an invoice is rendered.
*
* You can use this event to:
* - add invoice hydrator
* - read and change invoice model
* - change tax rates
*/
final class InvoicePreRenderEvent extends Event
{
public function __construct(

View File

@@ -117,7 +117,11 @@ class HtmlRenderer implements ExportRendererInterface
'userPreferences' => $userPreferences,
], $this->getOptions($query)));
// allows to run in development mode, otherwise toolbar would be blocked
$sandbox->disableSandbox();
$response = new Response();
$response->headers->set('Content-Type', 'text/html');
$response->setContent($content);
return $response;

View File

@@ -14,9 +14,9 @@ use App\Repository\Query\TimesheetQuery;
use Symfony\Component\HttpFoundation\Response;
/**
* TODO change interface for 3.0
* @method getType() string
* @method isInternal() bool
* FIXME change interface for 3.0
* @method string getType()
* @method bool isInternal()
*/
interface ExportRendererInterface
{

View File

@@ -31,6 +31,10 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
$subtotal = $calculator->getSubtotal();
$formatter = $model->getFormatter();
$language = $template->getLanguage();
if ($language === null) {
throw new \InvalidArgumentException('InvoiceTemplate needs a language');
}
$taxRows = $calculator->getTaxRows();
$vat = 0.00;
@@ -48,7 +52,7 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
'invoice.language' => $language, // since 1.9
'invoice.currency_symbol' => $formatter->getCurrencySymbol($currency),
'invoice.vat' => $vat, // @deprecated, use invoice.tax_rows instead
'invoice.tax_hide' => $model->isHideZeroTax() && $tax === 0.00,
'invoice.tax_hide' => $model->isHideZeroTax() && $tax === 0.00, // @deprecated, use invoice.tax_rows instead
'invoice.tax' => $formatter->getFormattedMoney($tax, $currency), // @deprecated, use invoice.tax_rows instead
'invoice.tax_nc' => $formatter->getFormattedMoney($tax, $currency, false), // @deprecated, use invoice.tax_rows instead
'invoice.tax_plain' => $tax, // @deprecated, use invoice.tax_rows instead
@@ -91,11 +95,17 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
];
$values['invoice.tax_rows'] = [];
$counter = 1;
foreach ($taxRows as $taxRow) {
$tax = $taxRow->getTax();
$values['invoice.tax_rows'][] = [
'type' => $taxRow->getTax()->getType()->value,
'name' => $taxRow->getTax()->getName(),
'rate' => $taxRow->getTax()->getRate(),
'counter' => $counter++,
'type' => $tax->getType()->value,
'name' => $tax->getName(),
'rate' => $tax->getRate(),
'note' => $tax->getNote(),
'show' => $tax->isShow(),
'currency' => $currency,
'amount' => $taxRow->getAmount(), // do not format, only available in twig anyway
'base' => $taxRow->getBasePrice(), // do not format, only available in twig anyway
];

View File

@@ -9,6 +9,9 @@
namespace App\Invoice;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
#[AutoconfigureTag]
interface InvoiceItemHydrator
{
public function setInvoiceModel(InvoiceModel $model): void;

View File

@@ -9,20 +9,10 @@
namespace App\Invoice;
use App\Activity\ActivityStatisticService;
use App\Customer\CustomerStatisticService;
use App\Entity\Customer;
use App\Entity\ExportableItem;
use App\Entity\InvoiceTemplate;
use App\Entity\User;
use App\Invoice\Hydrator\InvoiceItemDefaultHydrator;
use App\Invoice\Hydrator\InvoiceModelActivityHydrator;
use App\Invoice\Hydrator\InvoiceModelCustomerHydrator;
use App\Invoice\Hydrator\InvoiceModelDefaultHydrator;
use App\Invoice\Hydrator\InvoiceModelIssuerHydrator;
use App\Invoice\Hydrator\InvoiceModelProjectHydrator;
use App\Invoice\Hydrator\InvoiceModelUserHydrator;
use App\Project\ProjectStatisticService;
use App\Repository\Query\InvoiceQuery;
use App\Timesheet\RateCalculator\RateCalculatorMode;
use Symfony\Component\DependencyInjection\Attribute\Exclude;
@@ -43,7 +33,6 @@ final class InvoiceModel
private ?NumberGeneratorInterface $generator = null;
private \DateTimeInterface $invoiceDate;
private ?User $user = null;
private InvoiceFormatter $formatter;
/**
* @var InvoiceModelHydrator[]
*/
@@ -64,24 +53,13 @@ final class InvoiceModel
* @internal use InvoiceModelFactory
*/
public function __construct(
InvoiceFormatter $formatter,
CustomerStatisticService $customerStatistic,
ProjectStatisticService $projectStatistic,
ActivityStatisticService $activityStatistic,
private InvoiceFormatter $formatter,
private readonly Customer $customer,
private readonly InvoiceTemplate $template,
private readonly RateCalculatorMode $rateCalculatorMode
)
{
$this->invoiceDate = new \DateTimeImmutable();
$this->formatter = $formatter;
$this->addModelHydrator(new InvoiceModelDefaultHydrator());
$this->addModelHydrator(new InvoiceModelCustomerHydrator($customerStatistic));
$this->addModelHydrator(new InvoiceModelIssuerHydrator());
$this->addModelHydrator(new InvoiceModelProjectHydrator($projectStatistic));
$this->addModelHydrator(new InvoiceModelActivityHydrator($activityStatistic));
$this->addModelHydrator(new InvoiceModelUserHydrator());
$this->addItemHydrator(new InvoiceItemDefaultHydrator());
}
/**

View File

@@ -9,27 +9,36 @@
namespace App\Invoice;
use App\Activity\ActivityStatisticService;
use App\Customer\CustomerStatisticService;
use App\Entity\Customer;
use App\Entity\InvoiceTemplate;
use App\Project\ProjectStatisticService;
use App\Repository\Query\InvoiceQuery;
use App\Timesheet\RateCalculator\RateCalculatorMode;
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
final class InvoiceModelFactory
{
/**
* @param iterable<InvoiceModelHydrator> $modelHydrators
* @param iterable<InvoiceItemHydrator> $itemHydrators
*/
public function __construct(
private readonly CustomerStatisticService $customerStatisticService,
private readonly ProjectStatisticService $projectStatisticService,
private readonly ActivityStatisticService $activityStatisticService,
private readonly RateCalculatorMode $rateCalculatorMode
private readonly RateCalculatorMode $rateCalculatorMode,
#[TaggedIterator(InvoiceModelHydrator::class)]
private readonly iterable $modelHydrators,
#[TaggedIterator(InvoiceItemHydrator::class)]
private readonly iterable $itemHydrators,
) {
}
public function createModel(InvoiceFormatter $formatter, Customer $customer, InvoiceTemplate $template, InvoiceQuery $query): InvoiceModel
{
$model = new InvoiceModel($formatter, $this->customerStatisticService, $this->projectStatisticService, $this->activityStatisticService, $customer, $template, $this->rateCalculatorMode);
$model = new InvoiceModel($formatter, $customer, $template, $this->rateCalculatorMode);
foreach ($this->modelHydrators as $modelHydrator) {
$model->addModelHydrator($modelHydrator);
}
foreach ($this->itemHydrators as $itemHydrator) {
$model->addItemHydrator($itemHydrator);
}
$model->setQuery($query);

View File

@@ -9,6 +9,9 @@
namespace App\Invoice;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
#[AutoconfigureTag]
interface InvoiceModelHydrator
{
public function hydrate(InvoiceModel $model): array;

View File

@@ -16,14 +16,14 @@ class CustomerQuery extends BaseQuery implements VisibilityInterface
public const CUSTOMER_ORDER_ALLOWED = [
'name',
'description' => 'comment',
'country', 'number',
'country',
'number',
'homepage',
'email',
'mobile',
'fax',
'phone',
'currency',
'address',
'contact',
'company',
'vat_id',

View File

@@ -1,64 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Serializer;
use JMS\Serializer\Context;
use JMS\Serializer\DeserializationContext;
use JMS\Serializer\SerializationContext;
use JMS\Serializer\Serializer as CoreSerializer;
use JMS\Serializer\SerializerInterface as CoreSerializerInterface;
class Serializer implements SerializerInterface
{
public function __construct(private readonly CoreSerializerInterface $serializer)
{
}
private function prepareContext(Context $jmsContext, array $context = []): void
{
$groups = ['Default'];
if (\array_key_exists('groups', $context)) {
$groups = $context['groups'];
}
$jmsContext->setGroups($groups);
$jmsContext->enableMaxDepthChecks();
}
public function toArray(mixed $data, array $context = []): array
{
$jmsContext = SerializationContext::create();
$this->prepareContext($jmsContext, $context);
if ($this->serializer instanceof CoreSerializer) {
return $this->serializer->toArray($data, $jmsContext);
}
$json = $this->serializer->serialize($data, 'json', $jmsContext);
return json_decode($json, true, 512, JSON_THROW_ON_ERROR);
}
public function serialize(mixed $data, string $format, array $context = []): string
{
$jmsContext = SerializationContext::create();
$this->prepareContext($jmsContext, $context);
return $this->serializer->serialize($data, $format, $jmsContext);
}
public function deserialize(mixed $data, string $type, string $format, array $context = []): mixed
{
$jmsContext = DeserializationContext::create();
$this->prepareContext($jmsContext, $context);
return $this->serializer->deserialize($data, $type, $format, $jmsContext);
}
}

View File

@@ -1,17 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Serializer;
use Symfony\Component\Serializer\SerializerInterface as BaseSerializerInterface;
interface SerializerInterface extends BaseSerializerInterface
{
public function toArray(mixed $data, array $context = []): array;
}

View File

@@ -16,10 +16,6 @@ final class ChainPolicy implements SecurityPolicyInterface
/** @var array<SecurityPolicyInterface> */
private array $policies = [];
public function __construct()
{
}
public function addPolicy(SecurityPolicyInterface $policy): void
{
$this->policies[] = $policy;

View File

@@ -9,6 +9,13 @@
namespace App\Twig\SecurityPolicy;
use App\Entity\User;
use App\Pdf\PdfContext;
use Symfony\Bridge\Twig\AppVariable;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\ServerBag;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Twig\Sandbox\SecurityNotAllowedMethodError;
use Twig\Sandbox\SecurityPolicyInterface;
/**
@@ -22,6 +29,49 @@ final class DefaultPolicy implements SecurityPolicyInterface
public function checkMethodAllowed($obj, $method): void
{
if ($obj instanceof ServerBag) {
throw new SecurityNotAllowedMethodError('Tried to access server environment', ServerBag::class, $method);
}
if ($obj instanceof SessionInterface) {
throw new SecurityNotAllowedMethodError('Tried to access session', SessionInterface::class, $method);
}
$lcm = strtolower($method);
if ($obj instanceof PdfContext) {
if ($lcm !== 'setoption') {
throw new SecurityNotAllowedMethodError('Tried to access forbidden method on PdfContext', PdfContext::class, $method);
}
return;
}
if (!str_starts_with($lcm, 'has') && !str_starts_with($lcm, 'is') && !str_starts_with($lcm, 'get') && $lcm !== '__tostring') {
throw new SecurityNotAllowedMethodError('Tried to access non-read method', $obj::class, $method);
}
if ($obj instanceof Request) {
if (!str_starts_with($lcm, 'get')) {
throw new SecurityNotAllowedMethodError('Tried to call setter() of app variable', AppVariable::class, $method);
}
return;
}
if ($obj instanceof AppVariable) {
if (!\in_array($lcm, ['getrequest', 'getuser', 'getlocale'], true)) {
throw new SecurityNotAllowedMethodError('Tried to access forbidden app variable method', User::class, $method);
}
return;
}
if ($obj instanceof User) {
if (\in_array($lcm, ['getpassword', 'gettotpsecret', 'getplainpassword', 'getconfirmationtoken', 'gettotpauthenticationconfiguration'], true)) {
throw new SecurityNotAllowedMethodError('Tried to access user secrets', User::class, $method);
}
}
}
public function checkPropertyAllowed($obj, $property): void

View File

@@ -1,109 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Twig\SecurityPolicy;
use Twig\Markup;
use Twig\Sandbox\SecurityNotAllowedFilterError;
use Twig\Sandbox\SecurityNotAllowedFunctionError;
use Twig\Sandbox\SecurityNotAllowedMethodError;
use Twig\Sandbox\SecurityNotAllowedPropertyError;
use Twig\Sandbox\SecurityNotAllowedTagError;
use Twig\Sandbox\SecurityPolicyInterface;
use Twig\Template;
/**
* A blocking approach for Twig templates.
*/
final class ForbiddenPolicy implements SecurityPolicyInterface
{
/** @var array<string, array<string>> */
private array $forbiddenMethods = [];
/**
* @param array<string> $forbiddenTags
* @param array<string> $forbiddenFilters
* @param array<string, array<string>> $forbiddenMethods
* @param array<string, array<string>> $forbiddenProperties
* @param array<string> $forbiddenFunctions
*/
public function __construct(
private readonly array $forbiddenTags = [],
private readonly array $forbiddenFilters = [],
array $forbiddenMethods = [],
private readonly array $forbiddenProperties = [],
private readonly array $forbiddenFunctions = []
)
{
$this->forbiddenMethods = [];
foreach ($forbiddenMethods as $class => $m) {
$this->forbiddenMethods[$class] = array_map(function ($value) { return strtr($value, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'); }, \is_array($m) ? $m : [$m]);
}
}
public function checkSecurity($tags, $filters, $functions): void
{
foreach ($tags as $tag) {
if (\in_array($tag, $this->forbiddenTags)) {
throw new SecurityNotAllowedTagError(\sprintf('Tag "%s" is not allowed.', $tag), $tag);
}
}
foreach ($filters as $filter) {
if (\in_array($filter, $this->forbiddenFilters)) {
throw new SecurityNotAllowedFilterError(\sprintf('Filter "%s" is not allowed.', $filter), $filter);
}
}
foreach ($functions as $function) {
if (\in_array($function, $this->forbiddenFunctions)) {
throw new SecurityNotAllowedFunctionError(\sprintf('Function "%s" is not allowed.', $function), $function);
}
}
}
public function checkMethodAllowed($obj, $method): void
{
if ($obj instanceof Template || $obj instanceof Markup) { // @phpstan-ignore instanceof.internalClass
return;
}
$forbidden = false;
$method = strtr($method, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz');
foreach ($this->forbiddenMethods as $class => $methods) {
if ($obj instanceof $class) {
$forbidden = \in_array($method, $methods);
break;
}
}
if ($forbidden) {
$class = \get_class($obj);
throw new SecurityNotAllowedMethodError(\sprintf('Calling "%s" method on a "%s" object is not allowed.', $method, $class), $class, $method);
}
}
public function checkPropertyAllowed($obj, $property): void
{
$forbidden = false;
foreach ($this->forbiddenProperties as $class => $properties) {
if ($obj instanceof $class) {
$forbidden = \in_array($property, \is_array($properties) ? $properties : [$properties]);
break;
}
}
if ($forbidden) {
$class = \get_class($obj);
throw new SecurityNotAllowedPropertyError(\sprintf('Calling "%s" property on a "%s" object is not allowed.', $property, $class), $class, $property);
}
}
}

View File

@@ -22,13 +22,13 @@ use Twig\Template;
*/
final class InvoicePolicy implements SecurityPolicyInterface
{
private ChainPolicy $policy;
private SecurityPolicyInterface $default;
private SecurityPolicyInterface $security;
public function __construct()
{
$this->policy = new ChainPolicy();
$this->policy->addPolicy(new DefaultPolicy());
$this->policy->addPolicy(new SecurityPolicy(
$this->default = new DefaultPolicy();
$this->security = new SecurityPolicy(
['block', 'if', 'for', 'set', 'extends', 'import'],
[
// =================================================================
@@ -194,12 +194,13 @@ final class InvoicePolicy implements SecurityPolicyInterface
'month_names',
'locale_format',
]
));
);
}
public function checkSecurity($tags, $filters, $functions): void
{
$this->policy->checkSecurity($tags, $filters, $functions);
$this->default->checkSecurity($tags, $filters, $functions);
$this->security->checkSecurity($tags, $filters, $functions);
}
public function checkMethodAllowed($obj, $method): void
@@ -208,21 +209,20 @@ final class InvoicePolicy implements SecurityPolicyInterface
return;
}
$this->default->checkMethodAllowed($obj, $method);
$lm = strtolower($method);
if (str_starts_with($lm, 'get') || str_starts_with($lm, 'is') || str_starts_with($lm, 'has')) {
if (str_starts_with($lm, 'get') || str_starts_with($lm, 'is') || str_starts_with($lm, 'has') || $lm === '__tostring') {
return;
}
if ($lm === '__tostring') {
return;
}
$this->policy->checkMethodAllowed($obj, $method);
$this->security->checkMethodAllowed($obj, $method);
}
public function checkPropertyAllowed($obj, $property): void
{
$this->policy->checkPropertyAllowed($obj, $property);
$this->default->checkPropertyAllowed($obj, $property);
$this->security->checkPropertyAllowed($obj, $property);
}
}

View File

@@ -1,109 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Webhook;
use App\Event\ActivityCreatePostEvent;
use App\Event\ActivityDeleteEvent;
use App\Event\ActivityUpdatePostEvent;
use App\Event\CustomerCreatePostEvent;
use App\Event\CustomerDeleteEvent;
use App\Event\CustomerUpdatePostEvent;
use App\Event\InvoiceCreatedEvent;
use App\Event\InvoiceDeleteEvent;
use App\Event\ProjectCreatePostEvent;
use App\Event\ProjectDeleteEvent;
use App\Event\ProjectUpdatePostEvent;
use App\Event\TeamCreatePostEvent;
use App\Event\TeamDeleteEvent;
use App\Event\TeamUpdatePostEvent;
use App\Event\TimesheetCreatePostEvent;
use App\Event\TimesheetStopPostEvent;
use App\Event\TimesheetUpdatePostEvent;
use App\Event\UserCreatePostEvent;
use App\Event\UserDeletePostEvent;
use App\Event\UserUpdatePostEvent;
use App\Webhook\Attribute\AsWebhook;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
use Symfony\Contracts\EventDispatcher\Event;
final class WebhookListener implements EventSubscriberInterface
{
public function __construct(private readonly WebhookService $webhookService)
{
}
public static function getSubscribedEvents(): array
{
return [
ActivityCreatePostEvent::class => ['triggerWebhook', 1000],
ActivityDeleteEvent::class => ['triggerWebhook', 1000],
ActivityUpdatePostEvent::class => ['triggerWebhook', 1000],
CustomerCreatePostEvent::class => ['triggerWebhook', 1000],
CustomerDeleteEvent::class => ['triggerWebhook', 1000],
CustomerUpdatePostEvent::class => ['triggerWebhook', 1000],
InvoiceCreatedEvent::class => ['triggerWebhook', 1000],
InvoiceDeleteEvent::class => ['triggerWebhook', 1000],
ProjectCreatePostEvent::class => ['triggerWebhook', 1000],
ProjectDeleteEvent::class => ['triggerWebhook', 1000],
ProjectUpdatePostEvent::class => ['triggerWebhook', 1000],
TimesheetCreatePostEvent::class => ['triggerWebhook', 1000],
TimesheetStopPostEvent::class => ['triggerWebhook', 1000],
TimesheetUpdatePostEvent::class => ['triggerWebhook', 1000],
UserCreatePostEvent::class => ['triggerWebhook', 1000],
UserDeletePostEvent::class => ['triggerWebhook', 1000],
UserUpdatePostEvent::class => ['triggerWebhook', 1000],
TeamCreatePostEvent::class => ['triggerWebhook', 1000],
TeamDeleteEvent::class => ['triggerWebhook', 1000],
TeamUpdatePostEvent::class => ['triggerWebhook', 1000],
];
}
public function triggerWebhook(Event $event): void
{
$attribute = $this->findAttribute($event);
if ($attribute === null) {
return;
}
if (!$this->webhookService->hasWebhook($attribute->name)) {
return;
}
$expressionLanguage = new ExpressionLanguage();
$parsed = $expressionLanguage->parse($attribute->payload, ['object']);
$payload = $parsed->getNodes()->evaluate([], ['object' => $event]);
$this->webhookService->trigger($attribute->name, $payload);
}
private function findAttribute(Event $event): ?AsWebhook
{
try {
$reflectionClass = new \ReflectionClass($event);
$attributes = $reflectionClass->getAttributes(AsWebhook::class);
if (\count($attributes) === 1) {
$attribute = $attributes[0];
$args = $attribute->getArguments();
return new AsWebhook($args['name'], $args['description'], $args['payload']);
}
} catch (\Exception $ex) {
}
return null;
}
}

View File

@@ -1,58 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Webhook;
use App\Entity\WebhookConfiguration;
use App\Entity\WebhookEvent;
use App\Serializer\SerializerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\RemoteEvent\RemoteEvent;
use Symfony\Component\Webhook\Messenger\SendWebhookMessage;
use Symfony\Component\Webhook\Subscriber;
final class WebhookService
{
public function __construct(
private readonly SerializerInterface $serializer,
private readonly MessageBusInterface $bus
)
{
}
public function hasWebhook(string $name): bool
{
return $name === 'user.created';
}
public function trigger(string $name, mixed $payload): void
{
$content = $this->serializer->toArray($payload, ['groups' => ['Default', 'Entity', 'Expanded']]);
foreach ($this->findEventsByName($name) as $event) {
$configuration = $event->getConfiguration();
$subscriber = new Subscriber($configuration->getUrl(), $configuration->getSecret());
$remoteEvent = new RemoteEvent($name, $name . '_' . uniqid(microtime()), $content);
$message = new SendWebhookMessage($subscriber, $remoteEvent);
$this->bus->dispatch($message);
}
}
public function findEventsByName(string $name): array
{
$url = 'TODO';
$secret = 'TODO';
$configuration = new WebhookConfiguration('n8n', $url, 'json', $secret, 'bearer');
return [
new WebhookEvent($name, $configuration),
];
}
}

View File

@@ -467,18 +467,6 @@
"config/packages/mailer.yaml"
]
},
"symfony/messenger": {
"version": "6.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.0",
"ref": "d8936e2e2230637ef97e5eecc0eea074eecae58b"
},
"files": [
"config/packages/messenger.yaml"
]
},
"symfony/mime": {
"version": "v4.3.3"
},
@@ -610,18 +598,6 @@
"ref": "6bdfa1a95f6b2e677ab985cd1af2eae35d62e0f6"
}
},
"symfony/webhook": {
"version": "6.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.3",
"ref": "0bc853a74c98e448116ba53e118a7b6b3cdf9a03"
},
"files": [
"config/routes/webhook.yaml"
]
},
"symfony/webpack-encore-bundle": {
"version": "1.0",
"recipe": {

View File

@@ -8,6 +8,7 @@
'back': path('admin_activity')
} %}
{% embed formEditTemplate with formOptions %}
{% import "macros/form_helper.html.twig" as form_helper %}
{% block form_body %}
<div class="row">
<div class="col-md-9">
@@ -49,6 +50,18 @@
</div>
</div>
{% endif %}
{{ form_helper.metafields(form, null) }}
{% embed '@theme/embeds/collapsible.html.twig' with {id: 'activity_invoice_settings'} %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "macros/form_helper.html.twig" as form_helper %}
{% block title %}{{ 'invoices'|trans }}{% endblock %}
{% block body %}
{% if form.invoiceText is defined %}
{{ form_row(form.invoiceText) }}
{% endif %}
{{ form_helper.metafields(form, 'invoice') }}
{% endblock %}
{% endembed %}
<div class="row">
<div class="col-md-6">
{{ form_row(form.visible) }}
@@ -57,18 +70,6 @@
{{ form_row(form.billable) }}
</div>
</div>
{% if form.metaFields is defined and form.metaFields is not empty %}
{{ form_row(form.metaFields) }}
{% endif %}
{% embed '@theme/embeds/collapsible.html.twig' with {id: 'activity_invoice_settings'} %}
{% import "macros/widgets.html.twig" as widgets %}
{% block title %}{{ 'invoices'|trans }}{% endblock %}
{% block body %}
{% if form.invoiceText is defined %}
{{ form_row(form.invoiceText) }}
{% endif %}
{% endblock %}
{% endembed %}
{{ form_rest(form) }}
{% endblock %}
{% endembed %}

View File

@@ -8,6 +8,7 @@
'back': path('admin_customer')
} %}
{% embed formEditTemplate with formOptions %}
{% import "macros/form_helper.html.twig" as form_helper %}
{% block form_body %}
<div class="row">
<div class="col-md-9">
@@ -75,11 +76,10 @@
</div>
</div>
{% endif %}
{% if form.metaFields is defined and form.metaFields is not empty %}
{{ form_row(form.metaFields) }}
{% endif %}
{{ form_helper.metafields(form, null) }}
{% embed '@theme/embeds/collapsible.html.twig' with {id: 'customer_address_settings'} %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "macros/form_helper.html.twig" as form_helper %}
{% block title %}{{ 'address'|trans }}{% endblock %}
{% block body %}
<div class="row">
@@ -125,11 +125,12 @@
{{ form_row(form.timezone) }}
</div>
</div>
{{ form_helper.metafields(form, 'address') }}
{% endblock %}
{% endembed %}
{% embed '@theme/embeds/collapsible.html.twig' with {id: 'customer_invoice_settings'} %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "macros/form_helper.html.twig" as form_helper %}
{% block title %}{{ 'invoices'|trans }}{% endblock %}
{% block body %}
{% if form.buyerReference is defined %}
@@ -141,6 +142,7 @@
{% if form.invoiceText is defined %}
{{ form_row(form.invoiceText) }}
{% endif %}
{{ form_helper.metafields(form, 'invoice') }}
{% endblock %}
{% endembed %}
<div class="row">

View File

@@ -17,8 +17,8 @@
{{ entry.vatId }}
{% elseif column == 'contact' %}
{{ entry.contact }}
{% elseif column == 'address' %}
{{ entry.address }}
{% elseif column == 'city' %}
{{ entry.postcode }} {{ entry.city }}
{% elseif column == 'country' %}
{{ entry.country|country_name }}
{% elseif column == 'currency' %}

View File

@@ -1,7 +1,6 @@
{% extends 'export/layout.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "macros/datatables.html.twig" as tables %}
{% import "macros/webloader.html.twig" as webloader %}
{% block document_title %}{{ 'export'|trans }}{% endblock %}
@@ -52,147 +51,144 @@
}) %}
{% block javascripts %}
{{ webloader.init_frontend_loader() }}
<script type="text/javascript">
let initialized = false;
document.addEventListener('kimai.initialized', function(event) {
document.getElementById('duration-decimal').addEventListener('click', function(event) {
var spans = document.getElementsByClassName('duration-format');
for (var span of spans) {
if (!event.target.checked) {
if (span.dataset['duration'] !== undefined) {
span.innerHTML = span.dataset['duration'];
}
} else {
if (span.dataset['durationDecimal'] !== undefined) {
span.innerHTML = span.dataset['durationDecimal'];
}
document.getElementById('duration-decimal').addEventListener('click', function(event) {
var spans = document.getElementsByClassName('duration-format');
for (var span of spans) {
if (!event.target.checked) {
if (span.dataset['duration'] !== undefined) {
span.innerHTML = span.dataset['duration'];
}
} else {
if (span.dataset['durationDecimal'] !== undefined) {
span.innerHTML = span.dataset['durationDecimal'];
}
}
saveVisibility();
});
document.getElementById('summary-by-activities').addEventListener('click', function(event) {
document.getElementById('summary-project').style.display = event.target.checked ? 'none' : 'table';
document.getElementById('summary-activity').style.display = event.target.checked ? 'table' : 'none';
saveVisibility();
});
document.getElementById('date-format').addEventListener('change', function(event) {
changedDateFormat(event.target.value, 'dateformat');
});
document.getElementById('begin-format').addEventListener('change', function(event) {
changedDateFormat(event.target.value, 'beginformat');
});
document.getElementById('end-format').addEventListener('change', function(event) {
changedDateFormat(event.target.value, 'endformat');
});
document.getElementById('summary-show').addEventListener('click', function(event) {
document.getElementById('export-summary').style.display = event.target.checked ? 'block' : 'none';
saveVisibility();
});
document.getElementById('summary-timeBudget').addEventListener('click', function(event) {
let cells = document.getElementsByClassName('export-timeBudget');
for (let columnCell of cells) {
columnCell.style.display = event.target.checked ? 'table-cell' : 'none';
}
saveVisibility();
});
document.getElementById('summary-budget').addEventListener('click', function(event) {
let cells = document.getElementsByClassName('export-budget');
for (let columnCell of cells) {
columnCell.style.display = event.target.checked ? 'table-cell' : 'none';
}
saveVisibility();
});
document.getElementById('summary-duration').addEventListener('click', function(event) {
let cells = document.getElementsByClassName('summary-duration');
for (let columnCell of cells) {
columnCell.style.display = event.target.checked ? 'table-cell' : 'none';
}
saveVisibility();
});
document.getElementById('summary-rate').addEventListener('click', function(event) {
let cells = document.getElementsByClassName('summary-rate');
for (let columnCell of cells) {
columnCell.style.display = event.target.checked ? 'table-cell' : 'none';
}
saveVisibility();
});
document.getElementById('summary-internalRate').addEventListener('click', function(event) {
let cells = document.getElementsByClassName('summary-internalRate');
for (let columnCell of cells) {
columnCell.style.display = event.target.checked ? 'table-cell' : 'none';
}
saveVisibility();
});
document.getElementById('summary-show').addEventListener('click', function(event) {
document.getElementById('export-summary').style.display = event.target.checked ? 'block' : 'none';
saveVisibility();
});
document.getElementById('records-show').addEventListener('click', function(event) {
document.getElementById('export-records').style.display = event.target.checked ? 'block' : 'none';
saveVisibility();
});
let columnCheckboxes = document.getElementsByClassName('column-visibility-changer');
for (let checkbox of columnCheckboxes) {
checkbox.addEventListener('click', function(event) {
changeVisibility(event.target.name, event.target.checked);
});
}
saveVisibility();
});
let editableTitles = document.querySelectorAll('[contenteditable=true]');
document.getElementById('summary-by-activities').addEventListener('click', function(event) {
document.getElementById('summary-project').style.display = event.target.checked ? 'none' : 'table';
document.getElementById('summary-activity').style.display = event.target.checked ? 'table' : 'none';
saveVisibility();
});
document.getElementById('date-format').addEventListener('change', function(event) {
changedDateFormat(event.target.value, 'dateformat');
});
document.getElementById('begin-format').addEventListener('change', function(event) {
changedDateFormat(event.target.value, 'beginformat');
});
document.getElementById('end-format').addEventListener('change', function(event) {
changedDateFormat(event.target.value, 'endformat');
});
document.getElementById('summary-show').addEventListener('click', function(event) {
document.getElementById('export-summary').style.display = event.target.checked ? 'block' : 'none';
saveVisibility();
});
document.getElementById('summary-timeBudget').addEventListener('click', function(event) {
let cells = document.getElementsByClassName('export-timeBudget');
for (let columnCell of cells) {
columnCell.style.display = event.target.checked ? 'table-cell' : 'none';
}
saveVisibility();
});
document.getElementById('summary-budget').addEventListener('click', function(event) {
let cells = document.getElementsByClassName('export-budget');
for (let columnCell of cells) {
columnCell.style.display = event.target.checked ? 'table-cell' : 'none';
}
saveVisibility();
});
document.getElementById('summary-duration').addEventListener('click', function(event) {
let cells = document.getElementsByClassName('summary-duration');
for (let columnCell of cells) {
columnCell.style.display = event.target.checked ? 'table-cell' : 'none';
}
saveVisibility();
});
document.getElementById('summary-rate').addEventListener('click', function(event) {
let cells = document.getElementsByClassName('summary-rate');
for (let columnCell of cells) {
columnCell.style.display = event.target.checked ? 'table-cell' : 'none';
}
saveVisibility();
});
document.getElementById('summary-internalRate').addEventListener('click', function(event) {
let cells = document.getElementsByClassName('summary-internalRate');
for (let columnCell of cells) {
columnCell.style.display = event.target.checked ? 'table-cell' : 'none';
}
saveVisibility();
});
document.getElementById('summary-show').addEventListener('click', function(event) {
document.getElementById('export-summary').style.display = event.target.checked ? 'block' : 'none';
saveVisibility();
});
document.getElementById('records-show').addEventListener('click', function(event) {
document.getElementById('export-records').style.display = event.target.checked ? 'block' : 'none';
saveVisibility();
});
for (let editable of editableTitles) {
editable.addEventListener('input', function(event) {
if (event.target.innerText === '') {
return;
let columnCheckboxes = document.getElementsByClassName('column-visibility-changer');
for (let checkbox of columnCheckboxes) {
checkbox.addEventListener('click', function(event) {
changeVisibility(event.target.name, event.target.checked);
});
}
let editableTitles = document.querySelectorAll('[contenteditable=true]');
for (let editable of editableTitles) {
editable.addEventListener('input', function(event) {
if (event.target.innerText === '') {
return;
}
saveVisibility();
});
}
// needs to be executed as last action in the flow, after the listener were registered
let config = localStorage.getItem('{{ storageItemName }}');
if (config !== null) {
try {
config = JSON.parse(config);
for (const elName in config) {
if (!config.hasOwnProperty(elName)) {
continue;
}
saveVisibility();
});
}
// needs to be executed as last action in the flow, after the listener were registered
let config = localStorage.getItem('{{ storageItemName }}');
if (config !== null) {
try {
config = JSON.parse(config);
for (const elName in config) {
if (!config.hasOwnProperty(elName)) {
let elValue = config[elName];
let el = document.getElementById(elName);
if (el === undefined || el === null) {
el = document.getElementById('records-column-' + elName);
if (el === undefined || el === null) {
continue;
}
let elValue = config[elName];
let el = document.getElementById(elName);
if (el === undefined || el === null) {
el = document.getElementById('records-column-' + elName);
if (el === undefined || el === null) {
continue;
}
}
if (el.type === 'checkbox') {
if (elValue !== el.checked) {
el.click();
}
} else if (el.type === 'select-one') {
if (el.value !== elValue) {
el.value = elValue;
el.dispatchEvent(new Event('change'));
}
} else if (el.isContentEditable) {
el.innerText = elValue;
}
}
} catch (e) {
// ignore error in restoring
console.log('Failed to restore settings, removing invalid settings', e);
localStorage.removeItem('{{ storageItemName }}');
if (el.type === 'checkbox') {
if (elValue !== el.checked) {
el.click();
}
} else if (el.type === 'select-one') {
if (el.value !== elValue) {
el.value = elValue;
el.dispatchEvent(new Event('change'));
}
} else if (el.isContentEditable) {
el.innerText = elValue;
}
}
} catch (e) {
// ignore error in restoring
console.log('Failed to restore settings, removing invalid settings', e);
localStorage.removeItem('{{ storageItemName }}');
}
}
initialized = true;
});
initialized = true;
function changeVisibility(column, visible)
{

View File

@@ -474,7 +474,9 @@
{%- block meta_fields_collection_widget -%}
{% for meta in form|sort((a, b) => a.vars.data.order <=> b.vars.data.order) %}
{{ form_row(meta.value, {'row_attr': {'class': 'mb-3 ' ~ form.parent.vars.name ~ '_row_' ~ form.vars.name ~ '_' ~ meta.vars.name}}) }}
{% if not meta.rendered %}
{{ form_row(meta.value, {'row_attr': {'class': 'mb-3 ' ~ form.parent.vars.name ~ '_row_' ~ form.vars.name ~ '_' ~ meta.vars.name}}) }}
{% endif %}
{% endfor %}
{%- endblock meta_fields_collection_widget %}

View File

@@ -135,20 +135,25 @@ mpdf-->
{% endfor %}
</tbody>
<tfoot>
{% if not invoice['invoice.tax_hide'] %}
<tr>
<td colspan="4" class="text-right">
{{ 'invoice.subtotal'|trans }}
</td>
<td class="last text-right">{{ invoice['invoice.subtotal'] }}</td>
</tr>
<tr>
<td colspan="4" class="text-right">
{{ 'invoice.tax'|trans }} ({{ invoice['invoice.vat'] }}%)
</td>
<td class="last text-right">{{ invoice['invoice.tax'] }}</td>
</tr>
{% endif %}
{% for taxRow in invoice['invoice.tax_rows'] %}
{% if taxRow['show'] %}
<tr>
<td colspan="4" class="text-right">
{{ taxRow['name']|trans }} ({{ taxRow['rate'] }}%)
{% if taxRow['note'] is not null %}
<sup>[{{ taxRow['counter'] }}]</sup>
{% endif %}
</td>
<td class="last text-right">{{ taxRow['amount']|money(taxRow['currency']) }}</td>
</tr>
{% endif %}
{% endfor %}
<tr>
<td colspan="4" class="text-right text-nowrap">
<strong>{{ 'invoice.total'|trans }}</strong>
@@ -160,10 +165,17 @@ mpdf-->
</tfoot>
</table>
{% for taxRow in invoice['invoice.tax_rows'] %}
{% if taxRow['note'] is not null %}
<p>
<sup>[{{ taxRow['counter'] }}]</sup>
{{ taxRow['note']|trans }}
</p>
{% endif %}
{% endfor %}
{% if invoice['template.payment_terms'] is not empty %}
<div class="paymentTerms">
{{ invoice['template.payment_terms']|md2html }}
</div>
{{ invoice['template.payment_terms']|md2html }}
{% endif %}
</body>
</html>

View File

@@ -14,7 +14,7 @@
<div class="row">
<div class="col-xs-12">
<h2 class="page-header">
<span contenteditable="true">{{ invoice['template.title'] }}</span>
<span>{{ invoice['template.title'] }}</span>
</h2>
</div>
</div>
@@ -22,7 +22,7 @@
<div class="row">
<div class="col-sm-5">
{{ 'invoice.to'|trans }}
<address contenteditable="true">
<address>
<strong>{{ invoice['customer.company']|default(invoice['customer.name']) }}</strong><br>
{{ invoice['customer.address']|nl2br }}
{% set country = invoice['customer.country']|country_name(invoice['invoice.language']) %}
@@ -46,7 +46,7 @@
<div class="col-sm-2"></div>
<div class="col-sm-5">
{{ 'invoice.from'|trans }}
<address contenteditable="true">
<address>
<strong>{{ invoice['template.company'] }}</strong><br>
{{ invoice['template.address']|trim|nl2br }}
{% if invoice['template.country'] is not null %}
@@ -66,7 +66,7 @@
<div class="row">
<div class="col-sm-5">
<p contenteditable="true">
<p>
<strong>{{ 'date'|trans }}:</strong>
{{ invoice['invoice.date'] }}
@@ -98,7 +98,7 @@
{% for invoiceLineItem in entries %}
<tr>
<td nowrap class="text-nowrap">{{ invoiceLineItem['entry.begin'] }}</td>
<td contenteditable="true">
<td>
{% if invoiceLineItem['entry.description'] is not empty %}
{{ invoiceLineItem['entry.description']|nl2br }}
{% else %}
@@ -115,20 +115,25 @@
{% endfor %}
</tbody>
<tfoot>
{% if not invoice['invoice.tax_hide'] %}
<tr>
<td colspan="4" class="text-end">
{{ 'invoice.subtotal'|trans }}
</td>
<td class="text-end">{{ invoice['invoice.subtotal'] }}</td>
</tr>
<tr>
<td colspan="4" class="text-end">
{{ 'invoice.tax'|trans }} ({{ invoice['invoice.vat'] }}%)
</td>
<td class="text-end">{{ invoice['invoice.tax'] }}</td>
</tr>
{% endif %}
{% for taxRow in invoice['invoice.tax_rows'] %}
{% if taxRow['show'] %}
<tr>
<td colspan="4" class="text-end">
{{ taxRow['name']|trans }} ({{ taxRow['rate'] }}%)
{% if taxRow['note'] is not null %}
<sup>[{{ taxRow['counter'] }}]</sup>
{% endif %}
</td>
<td class="last text-right">{{ taxRow['amount']|money(taxRow['currency']) }}</td>
</tr>
{% endif %}
{% endfor %}
<tr>
<td colspan="4" class="text-end text-nowrap">
<strong>{{ 'invoice.total'|trans }}</strong>
@@ -144,10 +149,16 @@
<div class="row">
<div class="col-xs-12">
{% for taxRow in invoice['invoice.tax_rows'] %}
{% if taxRow['note'] is not null %}
<p>
<sup>[{{ taxRow['counter'] }}]</sup>
{{ taxRow['note']|trans }}
</p>
{% endif %}
{% endfor %}
{% if invoice['template.payment_terms'] is not empty %}
<div contenteditable="true" class="paymentTerms">
{{ invoice['template.payment_terms']|md2html }}
</div>
{{ invoice['template.payment_terms']|md2html }}
{% endif %}
</div>
</div>

View File

@@ -150,20 +150,25 @@ mpdf-->
{% endfor %}
</tbody>
<tfoot>
{% if not invoice['invoice.tax_hide'] %}
<tr>
<td colspan="3" class="text-right">
{{ 'invoice.subtotal'|trans }}
</td>
<td class="last text-right">{{ invoice['invoice.subtotal'] }}</td>
</tr>
<tr>
<td colspan="3" class="text-right">
{{ 'invoice.tax'|trans }} ({{ invoice['invoice.vat'] }}%)
</td>
<td class="last text-right">{{ invoice['invoice.tax'] }}</td>
</tr>
{% endif %}
{% for taxRow in invoice['invoice.tax_rows'] %}
{% if taxRow['show'] %}
<tr>
<td colspan="3" class="text-right">
{{ taxRow['name']|trans }} ({{ taxRow['rate'] }}%)
{% if taxRow['note'] is not null %}
<sup>[{{ taxRow['counter'] }}]</sup>
{% endif %}
</td>
<td class="last text-right">{{ taxRow['amount']|money(taxRow['currency']) }}</td>
</tr>
{% endif %}
{% endfor %}
<tr>
<td colspan="3" class="text-right text-nowrap">
<strong>{{ 'invoice.total'|trans }}</strong>
@@ -175,10 +180,17 @@ mpdf-->
</tfoot>
</table>
{% for taxRow in invoice['invoice.tax_rows'] %}
{% if taxRow['note'] is not null %}
<p>
<sup>[{{ taxRow['counter'] }}]</sup>
{{ taxRow['note']|trans }}
</p>
{% endif %}
{% endfor %}
{% if invoice['template.payment_terms'] is not empty %}
<div class="paymentTerms">
{{ invoice['template.payment_terms']|md2html }}
</div>
{{ invoice['template.payment_terms']|md2html }}
{% endif %}
</body>
</html>

View File

@@ -0,0 +1,7 @@
{% macro metafields(form, section) %}
{% for meta in form.metaFields|sort((a, b) => a.vars.data.order <=> b.vars.data.order) %}
{% if meta.vars.data.section is same as (section) %}
{{ form_row(meta.value, {'row_attr': {'class': 'mb-3 ' ~ form.vars.name ~ '_row_' ~ form.metaFields.vars.name ~ '_' ~ meta.vars.name}}) }}
{% endif %}
{% endfor %}
{% endmacro %}

View File

@@ -14,7 +14,7 @@
<meta name="mobile-web-app-capable" content="yes">
<meta name="application-name" content="{{ constant('App\\Constants::SOFTWARE') }}">
<meta name="msapplication-config" content="{{ asset('browserconfig.xml') }}">
<meta name="theme-color" content="#1d273b">
<meta name="theme-color" content="#262626">
{% if tabler_bundle.isThemeAuto() %}
<script>if (window.matchMedia) { document.documentElement.setAttribute('data-bs-theme', (window.matchMedia("(prefers-color-scheme: dark)").matches) ? 'dark': 'light'); }</script>
{% endif %}

View File

@@ -8,6 +8,7 @@
'back': path('admin_project')
} %}
{% embed formEditTemplate with formOptions %}
{% import "macros/form_helper.html.twig" as form_helper %}
{% block form_body %}
<div class="row">
<div class="col-md-9">
@@ -58,11 +59,10 @@
</div>
</div>
{% endif %}
{% if form.metaFields is defined and form.metaFields is not empty %}
{{ form_row(form.metaFields) }}
{% endif %}
{{ form_helper.metafields(form, null) }}
{% embed '@theme/embeds/collapsible.html.twig' with {id: 'project_invoice_settings'} %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "macros/form_helper.html.twig" as form_helper %}
{% block title %}{{ 'invoices'|trans }}{% endblock %}
{% block body %}
<div class="row">
@@ -76,6 +76,7 @@
{% if form.invoiceText is defined %}
{{ form_row(form.invoiceText) }}
{% endif %}
{{ form_helper.metafields(form, 'invoice') }}
{% endblock %}
{% endembed %}
<div class="row">

View File

@@ -327,7 +327,8 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
return [
'id' => 'int',
'name' => 'string',
'color' => 'string',
'color' => '@string',
'color-safe' => 'string',
'visible' => 'bool',
];
@@ -358,7 +359,8 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'enabled' => 'bool',
'apiToken' => 'bool',
'systemAccount' => 'bool',
'color' => 'string',
'color' => '@string',
'color-safe' => 'string',
'avatar' => '@string',
'alias' => '@string',
'accountNumber' => '@string',
@@ -378,7 +380,8 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'enabled' => 'bool',
'apiToken' => 'bool',
'systemAccount' => 'bool',
'color' => 'string',
'color' => '@string',
'color-safe' => 'string',
'avatar' => '@string',
'alias' => '@string',
'accountNumber' => '@string',
@@ -402,7 +405,8 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
return [
'id' => 'int',
'name' => 'string',
'color' => 'string',
'color' => '@string',
'color-safe' => 'string',
];
// explicitly requested team
@@ -410,7 +414,8 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
return [
'id' => 'int',
'name' => 'string',
'color' => 'string',
'color' => '@string',
'color-safe' => 'string',
'members' => ['result' => 'array', 'type' => 'TeamMember'],
// TODO more info in entity than in collection
'customers' => ['result' => 'array', 'type' => '@Customer'],
@@ -439,7 +444,8 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'name' => 'string',
'visible' => 'bool',
'billable' => 'bool',
'color' => 'string',
'color' => '@string',
'color-safe' => 'string',
'number' => '@string',
'comment' => '@string',
'currency' => 'string',
@@ -460,7 +466,8 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'name' => 'string',
'visible' => 'boolean',
'billable' => 'bool',
'color' => 'string',
'color' => '@string',
'color-safe' => 'string',
'number' => '@string',
'comment' => '@string',
'currency' => 'string',
@@ -482,7 +489,8 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'name' => 'string',
'visible' => 'bool',
'billable' => 'bool',
'color' => 'string',
'color' => '@string',
'color-safe' => 'string',
'number' => '@string',
'comment' => '@string',
'currency' => 'string',
@@ -519,7 +527,8 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'name' => 'string',
'visible' => 'bool',
'billable' => 'bool',
'color' => 'string',
'color' => '@string',
'color-safe' => 'string',
'customer' => 'int',
'number' => '@string',
'orderNumber' => '@string',
@@ -538,7 +547,8 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'name' => 'string',
'visible' => 'bool',
'billable' => 'bool',
'color' => 'string',
'color' => '@string',
'color-safe' => 'string',
'customer' => ['result' => 'object', 'type' => 'Customer'],
'number' => '@string',
'orderNumber' => '@string',
@@ -557,7 +567,8 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'name' => 'string',
'visible' => 'bool',
'billable' => 'bool',
'color' => 'string',
'color' => '@string',
'color-safe' => 'string',
'customer' => 'int',
'number' => '@string',
'orderNumber' => '@string',
@@ -578,7 +589,8 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'name' => 'string',
'visible' => 'bool',
'billable' => 'bool',
'color' => 'string',
'color' => '@string',
'color-safe' => 'string',
'customer' => 'int',
'number' => '@string',
'orderNumber' => '@string',
@@ -605,7 +617,8 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'billable' => 'bool',
'project' => '@int',
'number' => '@string',
'color' => 'string',
'color' => '@string',
'color-safe' => 'string',
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'], // since 2.45
'comment' => '@string',
];
@@ -618,7 +631,8 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'billable' => 'bool',
'project' => ['result' => 'object', 'type' => '@ProjectExpanded'],
'number' => '@string',
'color' => 'string',
'color' => '@string',
'color-safe' => 'string',
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'], // since 2.45
'comment' => '@string',
];
@@ -632,7 +646,8 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'billable' => 'bool',
'project' => '@int',
'number' => '@string',
'color' => 'string',
'color' => '@string',
'color-safe' => 'string',
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'],
'comment' => '@string',
'parentTitle' => '@string',
@@ -648,7 +663,8 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'billable' => 'bool',
'project' => '@int',
'number' => '@string',
'color' => 'string',
'color' => '@string',
'color-safe' => 'string',
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'],
'comment' => '@string',
'parentTitle' => '@string',

View File

@@ -244,7 +244,6 @@ class ActivityControllerTest extends APIControllerBaseTestCase
self::assertIsArray($result);
self::assertApiResponseTypeStructure('ActivityEntity', $result);
self::assertCount(14, array_keys($result));
self::assertNull($result['parentTitle']);
self::assertNotEmpty($result['id']);
self::assertIsArray($result['teams']);
@@ -258,7 +257,8 @@ class ActivityControllerTest extends APIControllerBaseTestCase
self::assertNull($result['budgetType']);
self::assertNull($result['number']);
self::assertEquals('Test comment', $result['comment']);
self::assertEquals('#5319e7', $result['color']);
self::assertNull($result['color']);
self::assertEquals('#5319e7', $result['color-safe']);
self::assertTrue($result['visible']);
self::assertTrue($result['billable']);
}
@@ -297,7 +297,7 @@ class ActivityControllerTest extends APIControllerBaseTestCase
self::assertEquals('Test', $result['parentTitle']);
self::assertNotEmpty($result['id']);
self::assertIsArray($result['teams']);
self::assertEquals([['id' => 1, 'name' => 'Test team', 'color' => '#03A9F4']], $result['teams']);
self::assertEquals([['id' => 1, 'name' => 'Test team', 'color' => null, 'color-safe' => '#03A9F4']], $result['teams']);
self::assertIsArray($result['metaFields']);
self::assertEquals([], $result['metaFields']);
self::assertEquals('foo', $result['name']);

View File

@@ -229,7 +229,6 @@ class CustomerControllerTest extends APIControllerBaseTestCase
self::assertIsArray($result);
self::assertApiResponseTypeStructure('CustomerEntity', $result);
self::assertCount(30, array_keys($result));
self::assertNotEmpty($result['id']);
self::assertIsArray($result['teams']);
self::assertCount(1, $result['teams']);
@@ -262,7 +261,8 @@ class CustomerControllerTest extends APIControllerBaseTestCase
self::assertNull($result['homepage']);
self::assertEquals('Europe/Berlin', $result['timezone']);
self::assertNull($result['buyerReference']);
self::assertEquals('#5319e7', $result['color']);
self::assertNull($result['color']);
self::assertEquals('#5319e7', $result['color-safe']);
self::assertTrue($result['visible']);
self::assertTrue($result['billable']);
}
@@ -316,7 +316,7 @@ class CustomerControllerTest extends APIControllerBaseTestCase
self::assertApiResponseTypeStructure('CustomerEntity', $result);
self::assertNotEmpty($result['id']);
self::assertIsArray($result['teams']);
self::assertEquals([['id' => 1, 'name' => 'Test team', 'color' => '#03A9F4']], $result['teams']);
self::assertEquals([['id' => 1, 'name' => 'Test team', 'color' => null, 'color-safe' => '#03A9F4']], $result['teams']);
self::assertIsArray($result['metaFields']);
self::assertEquals([], $result['metaFields']);
self::assertEquals('foo', $result['name']);

View File

@@ -311,7 +311,6 @@ class ProjectControllerTest extends APIControllerBaseTestCase
self::assertIsArray($result);
self::assertApiResponseTypeStructure('ProjectEntity', $result);
self::assertCount(19, array_keys($result));
self::assertEquals('first one', $result['parentTitle']);
self::assertEquals($project->getId(), $result['id']);
self::assertIsArray($result['teams']);
@@ -329,7 +328,8 @@ class ProjectControllerTest extends APIControllerBaseTestCase
self::assertNull($result['orderNumber']);
self::assertNull($result['number']);
self::assertNull($result['comment']);
self::assertEquals('#2ECC40', $result['color']);
self::assertNull($result['color']);
self::assertEquals('#2ECC40', $result['color-safe']);
self::assertTrue($result['globalActivities']);
self::assertTrue($result['billable']);
self::assertTrue($result['visible']);
@@ -374,7 +374,7 @@ class ProjectControllerTest extends APIControllerBaseTestCase
self::assertEquals('Test', $result['parentTitle']);
self::assertNotEmpty($result['id']);
self::assertIsArray($result['teams']);
self::assertEquals([['id' => 1, 'name' => 'Test team', 'color' => '#03A9F4']], $result['teams']);
self::assertEquals([['id' => 1, 'name' => 'Test team', 'color' => null, 'color-safe' => '#03A9F4']], $result['teams']);
self::assertIsArray($result['metaFields']);
self::assertEquals([], $result['metaFields']);
self::assertEquals('foo', $result['name']);

View File

@@ -38,6 +38,7 @@ abstract class AbstractMetaEntityTestCase extends TestCase
self::assertFalse($sut->isVisible());
self::assertFalse($sut->isRequired());
self::assertEquals(0, $sut->getOrder());
self::assertNull($sut->getSection());
}
public function testSetterAndGetter(): void
@@ -83,6 +84,11 @@ abstract class AbstractMetaEntityTestCase extends TestCase
$entity = $this->getEntity();
self::assertInstanceOf(MetaTableTypeInterface::class, $sut->setEntity($entity));
self::assertSame($entity, $sut->getEntity());
$sut->setSection('foo');
self::assertEquals('foo', $sut->getSection());
$sut->setSection(null);
self::assertNull($sut->getSection());
}
public function testMerge(): void
@@ -90,18 +96,19 @@ abstract class AbstractMetaEntityTestCase extends TestCase
$entity1 = $this->getEntity();
$entity2 = $this->getEntity();
$meta1 = $this->getMetaEntity();
$meta1
->setName('foo')
->setValue('bar')
->setType('blub')
->setEntity($entity1)
->setConstraints([new NotNull()])
->setOptions(['foo' => 'bar', 'bar' => 'foo'])
;
$meta1->setName('foo');
$meta1->setValue('bar');
$meta1->setType('blub');
$meta1->setEntity($entity1);
$meta1->setConstraints([new NotNull()]);
$meta1->setOptions(['foo' => 'bar', 'bar' => 'foo']);
$meta1->setSection('hello');
self::assertEquals('foo', $meta1->getName());
self::assertEquals('bar', $meta1->getValue());
self::assertEquals('blub', $meta1->getType());
self::assertEquals('foo', $meta1->getLabel());
self::assertEquals('hello', $meta1->getSection());
self::assertFalse($meta1->isRequired());
self::assertFalse($meta1->isVisible());
self::assertSame($entity1, $meta1->getEntity());
@@ -109,18 +116,17 @@ abstract class AbstractMetaEntityTestCase extends TestCase
self::assertCount(2, $meta1->getOptions());
$meta2 = $this->getMetaEntity();
$meta2
->setName('foo2')
->setLabel('TRALALA')
->setValue('bar2')
->setType('blub2')
->setEntity($entity2)
->setIsRequired(true)
->setIsVisible(true)
->setOrder(93)
->setConstraints([new NotBlank(), new Length(['min' => 1])])
->setOptions(['foo1' => 'bar1'])
;
$meta2->setName('foo2');
$meta2->setLabel('TRALALA');
$meta2->setValue('bar2');
$meta2->setType('blub2');
$meta2->setSection('world');
$meta2->setEntity($entity2);
$meta2->setIsRequired(true);
$meta2->setIsVisible(true);
$meta2->setOrder(93);
$meta2->setConstraints([new NotBlank(), new Length(['min' => 1])]);
$meta2->setOptions(['foo1' => 'bar1']);
self::assertInstanceOf(MetaTableTypeInterface::class, $meta1->merge($meta2));
@@ -128,6 +134,7 @@ abstract class AbstractMetaEntityTestCase extends TestCase
self::assertEquals('bar', $meta1->getValue());
self::assertEquals('blub2', $meta1->getType());
self::assertEquals('TRALALA', $meta1->getLabel());
self::assertEquals('world', $meta1->getSection());
self::assertEquals(93, $meta1->getOrder());
self::assertTrue($meta1->isRequired());
self::assertTrue($meta1->isVisible());

View File

@@ -280,7 +280,7 @@ zip 12345 looney toon', $sut->getFormattedAddress());
['visible', 'boolean'],
['comment', 'string'],
['billable', 'boolean'],
['buyer_reference', 'string'],
['buyerReference', 'string'],
];
self::assertCount(\count($expected), $columns);

View File

@@ -13,6 +13,8 @@ use App\Entity\Customer;
use App\Entity\CustomerMeta;
use App\Entity\InvoiceTemplate;
use App\Entity\InvoiceTemplateMeta;
use App\Entity\Tax;
use App\Entity\TaxType;
use Doctrine\Common\Collections\Collection;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
@@ -44,6 +46,48 @@ class InvoiceTemplateTest extends TestCase
self::assertNull($sut->getMetaField('foo'));
}
public function testTaxRates(): void
{
$sut = new InvoiceTemplate();
$rates = $sut->getTaxRates();
self::assertCount(1, $rates);
self::assertEquals(TaxType::STANDARD, $rates[0]->getType());
self::assertEquals(0.00, $rates[0]->getRate());
self::assertEquals('vat', $rates[0]->getName());
self::assertNull($rates[0]->getNote());
$tax1 = new Tax(
TaxType::REVERSE,
$this->vat ?? 0.00,
'tax.name.reverse_charge',
true,
null
);
$tax2 = new Tax(
TaxType::EXEMPT,
$this->vat ?? 0.00,
'tax.name.exempt',
true,
null
);
$sut->setTaxRates([$tax1, $tax2]);
$rates = $sut->getTaxRates();
self::assertCount(2, $rates);
self::assertEquals(TaxType::REVERSE, $rates[0]->getType());
self::assertEquals(0.00, $rates[0]->getRate());
self::assertEquals('tax.name.reverse_charge', $rates[0]->getName());
self::assertNull($rates[0]->getNote());
self::assertEquals(TaxType::EXEMPT, $rates[1]->getType());
self::assertEquals(0.00, $rates[1]->getRate());
self::assertEquals('tax.name.exempt', $rates[1]->getName());
self::assertNull($rates[1]->getNote());
}
public function testSetterAndGetter(): void
{
$sut = new InvoiceTemplate();

View File

@@ -0,0 +1,128 @@
<?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\Base;
use App\Export\Base\CsvRenderer;
use App\Export\Base\HtmlRenderer;
use App\Export\Base\PDFRenderer;
use App\Export\Base\XlsxRenderer;
use App\Export\ServiceExport;
use App\Repository\ExportTemplateRepository;
use App\Tests\Export\Renderer\AbstractRendererTestCase;
use App\Tests\Mocks\Export\CsvRendererFactoryMock;
use App\Tests\Mocks\Export\HtmlRendererFactoryMock;
use App\Tests\Mocks\Export\PdfRendererFactoryMock;
use App\Tests\Mocks\Export\XlsxRendererFactoryMock;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Group;
use Psr\Log\LoggerInterface;
use Symfony\Component\Finder\Finder;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
#[CoversClass(ServiceExport::class)]
#[CoversClass(CsvRenderer::class)]
#[CoversClass(XlsxRenderer::class)]
#[CoversClass(PDFRenderer::class)]
#[CoversClass(HtmlRenderer::class)]
#[Group('integration')]
class DefaultRendererTest extends AbstractRendererTestCase
{
private function createServiceExport(): ServiceExport
{
$repository = $this->createMock(ExportTemplateRepository::class);
$repository->expects($this->once())->method('findAll')->willReturn([]);
$logger = $this->createMock(LoggerInterface::class);
return new ServiceExport(
$this->createMock(EventDispatcherInterface::class),
(new HtmlRendererFactoryMock($this))->create(),
(new PdfRendererFactoryMock($this))->create(),
(new CsvRendererFactoryMock($this))->create(),
(new XlsxRendererFactoryMock($this))->create(),
$repository,
$logger,
);
}
public function testRenderDefaultTemplates(): void
{
$sut = $this->createServiceExport();
$renderer = $sut->getRenderer();
self::assertCount(4, $renderer);
self::assertInstanceOf(CsvRenderer::class, $renderer[0]);
self::assertInstanceOf(XlsxRenderer::class, $renderer[1]);
self::assertInstanceOf(PDFRenderer::class, $renderer[2]);
self::assertInstanceOf(HtmlRenderer::class, $renderer[3]);
// make sure that the default templates do NOT violate the Twig SecurityPolicy
$response = $this->render($renderer[0]);
self::assertEquals('text/csv', $response->headers->get('Content-Type'));
self::assertStringContainsString('attachment; filename', $response->headers->get('Content-Disposition') ?? '');
$response = $this->render($renderer[1]);
self::assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('Content-Type') ?? '');
self::assertStringContainsString('attachment; filename', $response->headers->get('Content-Disposition') ?? '');
$response = $this->render($renderer[2]);
self::assertEquals('application/pdf', $response->headers->get('Content-Type') ?? '');
self::assertStringContainsString('attachment; filename', $response->headers->get('Content-Disposition') ?? '');
$response = $this->render($renderer[3]);
self::assertEquals('text/html', $response->headers->get('Content-Type') ?? '');
// HTML is attached to the body and twig is a mock in this setupo, so we just receive an empty string
}
public function testRenderCustomTemplates(): void
{
$searchDir = __DIR__ . '/../../../var/templates';
if (!is_dir($searchDir)) {
$this->expectNotToPerformAssertions();
return;
}
$finder = new Finder();
$finder
->in($searchDir)
->name('*.twig')
->path('export-tpl/')
->files()
;
$files = [];
$dirs = [];
foreach ($finder->getIterator() as $filename => $splFile) {
$files[] = $splFile->getRealPath();
$dir = \dirname($splFile->getRealPath());
$dirs[$dir] = $dir;
}
$dirs = array_keys($dirs);
if (\count($dirs) === 0) {
$this->expectNotToPerformAssertions();
return;
}
$sut = $this->createServiceExport();
foreach ($dirs as $dir) {
$sut->addDirectory($dir);
}
$renderers = $sut->getRenderer();
self::assertCount(4 + \count($files), $renderers);
foreach ($renderers as $renderer) {
$response = $this->render($renderer);
self::assertInstanceOf(Response::class, $response);
}
}
}

View File

@@ -129,8 +129,8 @@ abstract class AbstractRendererTestCase extends KernelTestCase
$entries = [$timesheet, $timesheet2, $timesheet3, $timesheet4, $timesheet5, $timesheet6];
$currentUser = $this->createMock(User::class);
$currentUser->expects($this->any())->method('isExportDecimal')->willReturn($exportDecimal);
$currentUser = new User();
$currentUser->setPreferenceValue('export_decimal', $exportDecimal);
$query = new TimesheetQuery();
$query->setActivities([$activity]);

View File

@@ -14,10 +14,12 @@ use App\Invoice\InvoiceItemHydrator;
use App\Invoice\InvoiceModel;
use App\Invoice\InvoiceModelHydrator;
use App\Model\InvoiceDocument;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Response;
#[CoversClass(DebugRenderer::class)]
class DebugRendererTest extends TestCase
{
use RendererTestTrait;

View File

@@ -18,6 +18,7 @@ use App\Tests\Mocks\FileHelperFactory;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Group;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Finder\Finder;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Twig\Environment;
@@ -118,12 +119,26 @@ class PdfRendererTest extends KernelTestCase
$dirs = [
__DIR__ . '/../../../templates/invoice/renderer/',
//__DIR__ . '/../../../var/invoices/',
//__DIR__ . '/../../../var/invoices_customer/',
//__DIR__ . '/../../../var/invoices_old/',
];
$files = [];
$additionalTemplatesDir = __DIR__ . '/../../../var/templates';
if (is_dir($additionalTemplatesDir)) {
$finder = new Finder();
$finder
->in($additionalTemplatesDir)
->name('*.pdf.twig')
->path('invoice-tpl/')
->files();
foreach ($finder->getIterator() as $splFile) {
$filename = $splFile->getRealPath();
$files[] = $filename;
$loader->addPath(\dirname($filename) . '/', 'invoice');
}
}
foreach ($dirs as $dir) {
if (!is_dir($dir)) {
continue;
@@ -145,7 +160,6 @@ class PdfRendererTest extends KernelTestCase
$response = $sut->render($document, $model);
self::assertEquals('application/pdf', $response->headers->get('Content-Type'));
self::assertStringContainsString('attachment; filename', $response->headers->get('Content-Disposition'));
self::assertNotEmpty($response->getContent());
}
}
}

View File

@@ -32,7 +32,6 @@ use App\Model\InvoiceDocument;
use App\Repository\InvoiceRepository;
use App\Repository\Query\InvoiceQuery;
use App\Tests\Mocks\InvoiceModelFactoryFactory;
use Doctrine\Common\Collections\ArrayCollection;
trait RendererTestTrait
{
@@ -85,6 +84,10 @@ trait RendererTestTrait
protected function getInvoiceModel(): InvoiceModel
{
$activityId = new \ReflectionProperty(Activity::class, 'id');
$projectId = new \ReflectionProperty(Project::class, 'id');
$userId = new \ReflectionProperty(User::class, 'id');
$user = new User();
$user->setUserIdentifier('one-user');
$user->setTitle('user title');
@@ -107,59 +110,54 @@ trait RendererTestTrait
$pMeta = new ProjectMeta();
$pMeta->setName('foo-project')->setValue('bar-project')->setIsVisible(true);
$project = $this->createMock(Project::class);
$project->method('getId')->willReturn(0);
$project->method('getName')->willReturn('project name');
$project->method('getCustomer')->willReturn($customer);
$project->method('getMetaFields')->willReturn(new ArrayCollection([$pMeta]));
$project->method('getVisibleMetaFields')->willReturn([$pMeta]);
$project = new Project();
$projectId->setValue($project, 0);
$project->setName('project name');
$project->setCustomer($customer);
$project->setMetaField($pMeta);
$aMeta = new ActivityMeta();
$aMeta->setName('foo-activity');
$aMeta->setValue('bar-activity');
$aMeta->setIsVisible(true);
$activity = $this->createMock(Activity::class);
$activity->method('getId')->willReturn(0);
$activity->method('getName')->willReturn('activity description');
$activity->method('getProject')->willReturn($project);
$activity->method('getMetaFields')->willReturn(new ArrayCollection([$aMeta]));
$activity->method('getVisibleMetaFields')->willReturn([$aMeta]);
$activity = new Activity();
$activityId->setValue($activity, 0);
$activity->setName('activity description');
$activity->setProject($project);
$activity->setMetaField($aMeta);
$pMeta2 = new ProjectMeta();
$pMeta2->setName('foo-project')->setValue('bar-project2')->setIsVisible(true);
$project2 = $this->createMock(Project::class);
$project2->method('getId')->willReturn(1);
$project2->method('getName')->willReturn('project 2 name');
$project2->method('getCustomer')->willReturn($customer);
$project2->method('getMetaFields')->willReturn(new ArrayCollection([$pMeta2]));
$project2->method('getVisibleMetaFields')->willReturn([$pMeta2]);
$project2 = new Project();
$projectId->setValue($project2, 1);
$project2->setName('project 2 name');
$project2->setCustomer($customer);
$project2->setMetaField($pMeta2);
$aMeta2 = new ActivityMeta();
$aMeta2->setName('foo-activity');
$aMeta2->setValue('bar-activity2');
$aMeta2->setIsVisible(true);
$activity2 = $this->createMock(Activity::class);
$activity2->method('getId')->willReturn(1);
$activity2->method('getName')->willReturn('activity 1 description');
$activity2->method('getProject')->willReturn($project2);
$activity2->method('getMetaFields')->willReturn(new ArrayCollection([$aMeta2]));
$activity2->method('getVisibleMetaFields')->willReturn([$aMeta2]);
$activity2 = new Activity();
$activityId->setValue($activity2, 1);
$activity2->setName('activity 1 description');
$activity2->setProject($project2);
$activity2->setMetaField($aMeta2);
$pref1 = new UserPreference('foo', 'bar');
$pref2 = new UserPreference('mad', 123.45);
$userMethods = ['getId', 'getPreferenceValue', 'getVisiblePreferences', 'getUsername', 'getUserIdentifier'];
$user1 = $this->getMockBuilder(User::class)->onlyMethods($userMethods)->disableOriginalConstructor()->getMock();
$user1->method('getId')->willReturn(1);
$user1->method('getPreferenceValue')->willReturn('50');
$user1->method('getUsername')->willReturn('foo-bar');
$user1->method('getUserIdentifier')->willReturn('foo-bar');
$user1->method('getVisiblePreferences')->willReturn([$pref1, $pref2]);
$user1 = new User();
$user1->setUserIdentifier('foo-bar');
$userId->setValue($user1, 1);
//$user1->method('getPreferenceValue')->willReturn('50');
$user1->addPreference($pref1);
$user1->addPreference($pref2);
$user2 = $this->createMock(User::class);
$user2->method('getId')->willReturn(2);
$user2->method('getUsername')->willReturn('hello-world');
$user2->method('getUserIdentifier')->willReturn('hello-world');
$user2->method('getVisiblePreferences')->willReturn([$pref1, $pref2]);
$user2 = new User();
$userId->setValue($user2, 2);
$user2->setUserIdentifier('hello-world');
$user2->addPreference($pref1);
$user2->addPreference($pref2);
$timesheet = new Timesheet();
$timesheet->setDuration(3600);
@@ -302,12 +300,14 @@ trait RendererTestTrait
$pref1 = new UserPreference('foo', 'bar');
$pref2 = new UserPreference('mad', 123.45);
$user1 = $this->createMock(User::class);
$user1->method('getId')->willReturn(1);
$user1->method('getPreferenceValue')->willReturn('50');
$user1->method('getUsername')->willReturn('foo-bar');
$user1->method('getUserIdentifier')->willReturn('foo-bar');
$user1->method('getVisiblePreferences')->willReturn([$pref1, $pref2]);
$userId = new \ReflectionProperty(User::class, 'id');
$user1 = new User();
$user1->setUserIdentifier('foo-bar');
$user1->addPreference($pref1);
$user1->addPreference($pref2);
$userId->setValue($user1, 1);
//$user1->method('getPreferenceValue')->willReturn('50');
$timesheet = new Timesheet();
$timesheet->setDuration(3600);

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8">
<style type="text/css">
{{ encore_entry_css_source('invoice')|raw }}
{{ encore_entry_css_source('invoice-pdf')|raw }}
</style>
</head>
<body class="invoice_print">

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