Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef170a8ca7 |
@@ -21,3 +21,7 @@ indent_size = 4
|
||||
[*.yml]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.xml]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
@@ -45,3 +45,7 @@ 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 ###
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -53,7 +53,6 @@ body:
|
||||
attributes:
|
||||
label: Which PHP version are you using?
|
||||
options:
|
||||
- "8.5"
|
||||
- "8.4"
|
||||
- "8.2"
|
||||
- "8.3"
|
||||
|
||||
2
.github/release-drafter.yml
vendored
2
.github/release-drafter.yml
vendored
@@ -22,7 +22,7 @@ version-resolver:
|
||||
- 'translation'
|
||||
default: patch
|
||||
template: |
|
||||
**Compatible with PHP 8.1 to 8.5**
|
||||
**Compatible with PHP 8.1 to 8.4**
|
||||
|
||||
$CHANGES
|
||||
|
||||
|
||||
8
.github/workflows/testing.yaml
vendored
8
.github/workflows/testing.yaml
vendored
@@ -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', '8.5']
|
||||
php: ['8.1', '8.2', '8.3', '8.4']
|
||||
|
||||
name: Integration (${{ matrix.php }})
|
||||
steps:
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
MAILER_URL: null://localhost
|
||||
|
||||
- name: Full test-suite
|
||||
if: matrix.php != '8.5'
|
||||
if: matrix.php != '8.2'
|
||||
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.5'
|
||||
if: matrix.php == '8.2'
|
||||
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.5'
|
||||
if: matrix.php == '8.2'
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
@@ -29,7 +29,7 @@ and so much more.
|
||||
|
||||
### Requirements
|
||||
|
||||
- PHP 8.1.3 minimum with support for 8.2, 8.3, 8.4, 8.5
|
||||
- PHP 8.1.3 minimum (support for PHP 8.2, 8.3, 8.4)
|
||||
- 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
|
||||
|
||||
|
||||
@@ -13,8 +13,6 @@ 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
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
/**
|
||||
* @deprecated use invoice-pdf instead
|
||||
*/
|
||||
|
||||
require('./sass/_invoice.scss');
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "8.1.*||8.2.*||8.3.*||8.4.*||8.5.*",
|
||||
"php": "8.1.*||8.2.*||8.3.*||8.4.*",
|
||||
"ext-gd": "*",
|
||||
"ext-intl": "*",
|
||||
"ext-json": "*",
|
||||
@@ -74,6 +74,7 @@
|
||||
"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",
|
||||
|
||||
274
composer.lock
generated
274
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "4f02c81fc72ab3bd887f6e8cf1361757",
|
||||
"content-hash": "9d1ceee2eb5d9ad5d14add5cd65f49d2",
|
||||
"packages": [
|
||||
{
|
||||
"name": "azuyalabs/yasumi",
|
||||
@@ -775,16 +775,16 @@
|
||||
},
|
||||
{
|
||||
"name": "doctrine/doctrine-bundle",
|
||||
"version": "2.18.2",
|
||||
"version": "2.18.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/doctrine/DoctrineBundle.git",
|
||||
"reference": "0ff098b29b8b3c68307c8987dcaed7fd829c6546"
|
||||
"reference": "b769877014de053da0e5cbbb63d0ea2f3b2fea76"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/0ff098b29b8b3c68307c8987dcaed7fd829c6546",
|
||||
"reference": "0ff098b29b8b3c68307c8987dcaed7fd829c6546",
|
||||
"url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/b769877014de053da0e5cbbb63d0ea2f3b2fea76",
|
||||
"reference": "b769877014de053da0e5cbbb63d0ea2f3b2fea76",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -876,7 +876,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/doctrine/DoctrineBundle/issues",
|
||||
"source": "https://github.com/doctrine/DoctrineBundle/tree/2.18.2"
|
||||
"source": "https://github.com/doctrine/DoctrineBundle/tree/2.18.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -892,7 +892,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-12-20T21:35:32+00:00"
|
||||
"time": "2025-11-05T14:42:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "doctrine/doctrine-migrations-bundle",
|
||||
@@ -4138,16 +4138,16 @@
|
||||
},
|
||||
{
|
||||
"name": "phpdocumentor/reflection-docblock",
|
||||
"version": "5.6.6",
|
||||
"version": "5.6.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/phpDocumentor/ReflectionDocBlock.git",
|
||||
"reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8"
|
||||
"reference": "90614c73d3800e187615e2dd236ad0e2a01bf761"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/5cee1d3dfc2d2aa6599834520911d246f656bcb8",
|
||||
"reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8",
|
||||
"url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/90614c73d3800e187615e2dd236ad0e2a01bf761",
|
||||
"reference": "90614c73d3800e187615e2dd236ad0e2a01bf761",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -4157,7 +4157,7 @@
|
||||
"phpdocumentor/reflection-common": "^2.2",
|
||||
"phpdocumentor/type-resolver": "^1.7",
|
||||
"phpstan/phpdoc-parser": "^1.7|^2.0",
|
||||
"webmozart/assert": "^1.9.1 || ^2"
|
||||
"webmozart/assert": "^1.9.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "~1.3.5 || ~1.6.0",
|
||||
@@ -4196,9 +4196,9 @@
|
||||
"description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.",
|
||||
"support": {
|
||||
"issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues",
|
||||
"source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.6"
|
||||
"source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.5"
|
||||
},
|
||||
"time": "2025-12-22T21:13:58+00:00"
|
||||
"time": "2025-11-27T19:50:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpdocumentor/type-resolver",
|
||||
@@ -7725,6 +7725,97 @@
|
||||
],
|
||||
"time": "2025-10-24T13:29:09+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/messenger",
|
||||
"version": "v6.4.28",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/messenger.git",
|
||||
"reference": "6a758210a2392c4a326de84a6c8c70e7e18296f9"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/messenger/zipball/6a758210a2392c4a326de84a6c8c70e7e18296f9",
|
||||
"reference": "6a758210a2392c4a326de84a6c8c70e7e18296f9",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"psr/log": "^1|^2|^3",
|
||||
"symfony/clock": "^6.3|^7.0",
|
||||
"symfony/deprecation-contracts": "^2.5|^3"
|
||||
},
|
||||
"conflict": {
|
||||
"symfony/console": "<6.3",
|
||||
"symfony/event-dispatcher": "<5.4",
|
||||
"symfony/event-dispatcher-contracts": "<2.5",
|
||||
"symfony/framework-bundle": "<5.4",
|
||||
"symfony/http-kernel": "<5.4",
|
||||
"symfony/serializer": "<5.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"psr/cache": "^1.0|^2.0|^3.0",
|
||||
"symfony/console": "^6.3|^7.0",
|
||||
"symfony/dependency-injection": "^5.4|^6.0|^7.0",
|
||||
"symfony/event-dispatcher": "^5.4|^6.0|^7.0",
|
||||
"symfony/http-kernel": "^5.4|^6.0|^7.0",
|
||||
"symfony/process": "^5.4|^6.0|^7.0",
|
||||
"symfony/property-access": "^5.4|^6.0|^7.0",
|
||||
"symfony/rate-limiter": "^5.4|^6.0|^7.0",
|
||||
"symfony/routing": "^5.4|^6.0|^7.0",
|
||||
"symfony/serializer": "^5.4|^6.0|^7.0",
|
||||
"symfony/service-contracts": "^2.5|^3",
|
||||
"symfony/stopwatch": "^5.4|^6.0|^7.0",
|
||||
"symfony/validator": "^5.4|^6.0|^7.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Messenger\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Samuel Roze",
|
||||
"email": "samuel.roze@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Helps applications send and receive messages to/from other applications or via message queues",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/messenger/tree/v6.4.28"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-11-06T11:12:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/mime",
|
||||
"version": "v6.4.30",
|
||||
@@ -8595,6 +8686,75 @@
|
||||
],
|
||||
"time": "2025-07-10T08:14:14+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/remote-event",
|
||||
"version": "v6.4.24",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/remote-event.git",
|
||||
"reference": "920e68c4f09f460609cf39a3540f1aa19da72c21"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/remote-event/zipball/920e68c4f09f460609cf39a3540f1aa19da72c21",
|
||||
"reference": "920e68c4f09f460609cf39a3540f1aa19da72c21",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"symfony/messenger": "^5.4|^6.1|^7.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\RemoteEvent\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Eases handling remote events",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"event"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/remote-event/tree/v6.4.24"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-07-10T08:14:14+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/routing",
|
||||
"version": "v6.4.30",
|
||||
@@ -10131,6 +10291,78 @@
|
||||
],
|
||||
"time": "2025-09-11T09:57:09+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/webhook",
|
||||
"version": "v6.4.24",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/webhook.git",
|
||||
"reference": "95d6f437a17224029983f2bed3cfaca09e530a17"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/webhook/zipball/95d6f437a17224029983f2bed3cfaca09e530a17",
|
||||
"reference": "95d6f437a17224029983f2bed3cfaca09e530a17",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"symfony/http-foundation": "^6.3|^7.0",
|
||||
"symfony/http-kernel": "^6.3|^7.0",
|
||||
"symfony/messenger": "^5.4|^6.1|^7.0",
|
||||
"symfony/remote-event": "^6.3|^7.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Webhook\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Eases sending and consuming webhooks",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"webhook"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/webhook/tree/v6.4.24"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-07-10T08:14:14+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/webpack-encore-bundle",
|
||||
"version": "v2.4.0",
|
||||
@@ -12686,16 +12918,16 @@
|
||||
},
|
||||
{
|
||||
"name": "react/child-process",
|
||||
"version": "v0.6.7",
|
||||
"version": "v0.6.6",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/reactphp/child-process.git",
|
||||
"reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3"
|
||||
"reference": "1721e2b93d89b745664353b9cfc8f155ba8a6159"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3",
|
||||
"reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3",
|
||||
"url": "https://api.github.com/repos/reactphp/child-process/zipball/1721e2b93d89b745664353b9cfc8f155ba8a6159",
|
||||
"reference": "1721e2b93d89b745664353b9cfc8f155ba8a6159",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -12749,7 +12981,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/reactphp/child-process/issues",
|
||||
"source": "https://github.com/reactphp/child-process/tree/v0.6.7"
|
||||
"source": "https://github.com/reactphp/child-process/tree/v0.6.6"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -12757,7 +12989,7 @@
|
||||
"type": "open_collective"
|
||||
}
|
||||
],
|
||||
"time": "2025-12-23T15:25:20+00:00"
|
||||
"time": "2025-01-01T16:37:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "react/dns",
|
||||
@@ -14544,7 +14776,7 @@
|
||||
"prefer-stable": false,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
"php": "8.1.*||8.2.*||8.3.*||8.4.*||8.5.*",
|
||||
"php": "8.1.*||8.2.*||8.3.*||8.4.*",
|
||||
"ext-gd": "*",
|
||||
"ext-intl": "*",
|
||||
"ext-json": "*",
|
||||
|
||||
22
config/packages/messenger.yaml
Normal file
22
config/packages/messenger.yaml
Normal file
@@ -0,0 +1,22 @@
|
||||
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://'
|
||||
3
config/routes/webhook.yaml
Normal file
3
config/routes/webhook.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
#webhook:
|
||||
# resource: '@FrameworkBundle/Resources/config/routing/webhook.xml'
|
||||
# prefix: /webhook
|
||||
@@ -38,6 +38,13 @@
|
||||
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">
|
||||
|
||||
@@ -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 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: '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: '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)')]
|
||||
|
||||
@@ -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 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: '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: '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 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: '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: 'size', requirements: '\d+', strict: true, nullable: true, description: 'The amount of entries (default: 10)')]
|
||||
public function recentAction(ParamFetcherInterface $paramFetcher): Response
|
||||
{
|
||||
|
||||
@@ -47,8 +47,6 @@ 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')
|
||||
@@ -152,21 +150,6 @@ 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
|
||||
// ==========================================================================
|
||||
@@ -671,62 +654,6 @@ 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
|
||||
|
||||
@@ -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('city', ['class' => 'd-none']);
|
||||
$table->addColumn('address', ['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']);
|
||||
|
||||
@@ -61,15 +61,18 @@ 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, ServiceInvoice $service, InvoiceTemplateRepository $templateRepository): Response
|
||||
public function indexAction(Request $request, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
{
|
||||
if (!$templateRepository->hasTemplate()) {
|
||||
if (!$this->templateRepository->hasTemplate()) {
|
||||
if ($this->isGranted('manage_invoice_template')) {
|
||||
return $this->redirectToRoute('admin_invoice_template_create');
|
||||
}
|
||||
@@ -92,7 +95,7 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
if ($form->isValid() && $query->getTemplate() !== null) {
|
||||
try {
|
||||
$models = $service->createModels($query);
|
||||
$models = $this->service->createModels($query);
|
||||
$searched = true;
|
||||
} catch (Exception $ex) {
|
||||
$this->flashUpdateException($ex);
|
||||
@@ -136,8 +139,12 @@ 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, ServiceInvoice $service): Response
|
||||
public function previewAction(Customer $customer, string $token, Request $request): Response
|
||||
{
|
||||
if (!$this->templateRepository->hasTemplate()) {
|
||||
return $this->redirectToRoute('invoice');
|
||||
}
|
||||
|
||||
if (!$this->isCsrfTokenValid('invoice.preview', $token)) {
|
||||
$this->flashError('action.csrf.error');
|
||||
|
||||
@@ -157,10 +164,10 @@ final class InvoiceController extends AbstractController
|
||||
if ($form->isValid()) {
|
||||
try {
|
||||
$query->setCustomers([$customer]);
|
||||
$model = $service->createModel($query);
|
||||
$model = $this->service->createModel($query);
|
||||
$model->setPreview(true);
|
||||
|
||||
return $service->renderInvoice($model, $this->dispatcher, true);
|
||||
return $this->service->renderInvoice($model, $this->dispatcher, true);
|
||||
} catch (Exception $ex) {
|
||||
$this->flashUpdateException($ex);
|
||||
}
|
||||
@@ -174,8 +181,12 @@ 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, ServiceInvoice $service): Response
|
||||
public function createInvoiceAction(Customer $customer, string $token, Request $request, CustomerRepository $customerRepository): Response
|
||||
{
|
||||
if (!$this->templateRepository->hasTemplate()) {
|
||||
return $this->redirectToRoute('invoice');
|
||||
}
|
||||
|
||||
if (!$this->isCsrfTokenValid('invoice.create', $token)) {
|
||||
$this->flashError('action.csrf.error');
|
||||
|
||||
@@ -192,7 +203,7 @@ final class InvoiceController extends AbstractController
|
||||
if ($form->isValid()) {
|
||||
try {
|
||||
$query->setCustomers([$customer]);
|
||||
$model = $service->createModel($query);
|
||||
$model = $this->service->createModel($query);
|
||||
|
||||
// save default template for customer if not yet set
|
||||
if ($customer->getInvoiceTemplate() === null) {
|
||||
@@ -200,7 +211,7 @@ final class InvoiceController extends AbstractController
|
||||
$customerRepository->saveCustomer($customer);
|
||||
}
|
||||
|
||||
$invoice = $service->createInvoice($model, $this->dispatcher);
|
||||
$invoice = $this->service->createInvoice($model, $this->dispatcher);
|
||||
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
@@ -218,7 +229,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, ServiceInvoice $service): Response
|
||||
public function changeStatusAction(Invoice $invoice, string $status, string $token, Request $request, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
{
|
||||
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.status', $token))) {
|
||||
$this->flashError('action.csrf.error');
|
||||
@@ -243,7 +254,7 @@ final class InvoiceController extends AbstractController
|
||||
}
|
||||
|
||||
try {
|
||||
$service->changeInvoiceStatus($invoice, $status);
|
||||
$this->service->changeInvoiceStatus($invoice, $status);
|
||||
$this->flashSuccess('action.update.success');
|
||||
} catch (Exception $ex) {
|
||||
$this->flashUpdateException($ex);
|
||||
@@ -255,14 +266,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, InvoiceRepository $invoiceRepository): Response
|
||||
public function editAction(Invoice $invoice, Request $request): Response
|
||||
{
|
||||
$form = $this->createInvoiceEditForm($invoice);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
try {
|
||||
$invoiceRepository->saveInvoice($invoice);
|
||||
$this->invoiceRepository->saveInvoice($invoice);
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute('admin_invoice_list');
|
||||
@@ -281,7 +292,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, ServiceInvoice $service): Response
|
||||
public function deleteInvoiceAction(Invoice $invoice, string $token, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
{
|
||||
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.status', $token))) {
|
||||
$this->flashError('action.csrf.error');
|
||||
@@ -292,7 +303,7 @@ final class InvoiceController extends AbstractController
|
||||
$csrfTokenManager->refreshToken('invoice.status');
|
||||
|
||||
try {
|
||||
$service->deleteInvoice($invoice, $this->dispatcher);
|
||||
$this->service->deleteInvoice($invoice, $this->dispatcher);
|
||||
$this->flashSuccess('action.delete.success');
|
||||
} catch (Exception $ex) {
|
||||
$this->flashDeleteException($ex);
|
||||
@@ -304,9 +315,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, ServiceInvoice $service): Response
|
||||
public function downloadAction(Invoice $invoice): Response
|
||||
{
|
||||
$file = $service->getInvoiceFile($invoice);
|
||||
$file = $this->service->getInvoiceFile($invoice);
|
||||
|
||||
if (null === $file) {
|
||||
throw $this->createNotFoundException(
|
||||
@@ -319,12 +330,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, InvoiceRepository $invoiceRepository): Response
|
||||
public function showInvoicesAction(Request $request, int $page): Response
|
||||
{
|
||||
$invoice = null;
|
||||
|
||||
if (null !== ($id = $request->query->get('id'))) {
|
||||
$invoice = $invoiceRepository->find($id);
|
||||
$invoice = $this->invoiceRepository->find($id);
|
||||
}
|
||||
|
||||
$query = new InvoiceArchiveQuery();
|
||||
@@ -336,7 +347,7 @@ final class InvoiceController extends AbstractController
|
||||
return $this->redirectToRoute('admin_invoice_list');
|
||||
}
|
||||
|
||||
$entries = $invoiceRepository->getPagerfantaForQuery($query);
|
||||
$entries = $this->invoiceRepository->getPagerfantaForQuery($query);
|
||||
$metaColumns = $this->findMetaColumns($query);
|
||||
|
||||
$table = new DataTable('invoices', $query);
|
||||
@@ -378,7 +389,7 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
#[Route(path: '/export', name: 'invoice_export', methods: ['GET'])]
|
||||
#[IsGranted('view_invoice')]
|
||||
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter, InvoiceRepository $invoiceRepository, InvoiceTemplateRepository $templateRepository): Response
|
||||
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter): Response
|
||||
{
|
||||
$query = new InvoiceArchiveQuery();
|
||||
$query->setCurrentUser($this->getUser());
|
||||
@@ -387,7 +398,7 @@ final class InvoiceController extends AbstractController
|
||||
$form->setData($query);
|
||||
$form->submit($request->query->all(), false);
|
||||
|
||||
$entries = $invoiceRepository->getInvoicesForQuery($query);
|
||||
$entries = $this->invoiceRepository->getInvoicesForQuery($query);
|
||||
|
||||
$spreadsheet = $exporter->export(
|
||||
Invoice::class,
|
||||
@@ -401,12 +412,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, InvoiceTemplateRepository $templateRepository): Response
|
||||
public function listTemplateAction(int $page): Response
|
||||
{
|
||||
$query = new BaseQuery();
|
||||
$query->setPage($page);
|
||||
|
||||
$entries = $templateRepository->getPagerfantaForQuery($query);
|
||||
$entries = $this->templateRepository->getPagerfantaForQuery($query);
|
||||
|
||||
$table = new DataTable('invoice_template', $query);
|
||||
$table->setPagination($entries);
|
||||
@@ -438,16 +449,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, InvoiceTemplateRepository $templateRepository): Response
|
||||
public function editTemplateAction(InvoiceTemplate $template, Request $request): Response
|
||||
{
|
||||
return $this->renderTemplateForm($template, $request, $templateRepository);
|
||||
return $this->renderTemplateForm($template, $request);
|
||||
}
|
||||
|
||||
#[Route(path: '/document_download/{document}', name: 'admin_invoice_document_download', methods: ['GET'])]
|
||||
#[IsGranted('upload_invoice_template')]
|
||||
public function downloadDocument(string $document, ServiceInvoice $service): Response
|
||||
public function downloadDocument(string $document, Environment $twig): Response
|
||||
{
|
||||
$event = new InvoiceDocumentsEvent($service->getDocuments(true));
|
||||
$event = new InvoiceDocumentsEvent($this->service->getDocuments(true));
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
foreach ($event->getInvoiceDocuments() as $doc) {
|
||||
@@ -461,7 +472,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, ServiceInvoice $service, InvoiceTemplateRepository $templateRepository): Response
|
||||
public function uploadDocumentAction(Request $request, string $projectDirectory, InvoiceDocumentRepository $documentRepository, Environment $twig, SystemConfiguration $systemConfiguration): Response
|
||||
{
|
||||
$dir = $documentRepository->getUploadDirectory();
|
||||
$invoiceDir = $dir;
|
||||
@@ -473,11 +484,11 @@ final class InvoiceController extends AbstractController
|
||||
$invoiceDir = rtrim($invoiceDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
|
||||
|
||||
$used = [];
|
||||
foreach ($templateRepository->findAll() as $template) {
|
||||
foreach ($this->templateRepository->findAll() as $template) {
|
||||
$used[$template->getRenderer()] = $template;
|
||||
}
|
||||
|
||||
$event = new InvoiceDocumentsEvent($service->getDocuments(true));
|
||||
$event = new InvoiceDocumentsEvent($this->service->getDocuments(true));
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
$documents = [];
|
||||
@@ -595,7 +606,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, InvoiceTemplateRepository $templateRepository): Response
|
||||
public function deleteDocument(string $id, string $token, CsrfTokenManagerInterface $csrfTokenManager, InvoiceDocumentRepository $documentRepository): Response
|
||||
{
|
||||
$document = $documentRepository->findByName($id);
|
||||
if ($document === null) {
|
||||
@@ -618,7 +629,7 @@ final class InvoiceController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($templateRepository->findAll() as $template) {
|
||||
foreach ($this->templateRepository->findAll() as $template) {
|
||||
if ($template->getRenderer() === $id) {
|
||||
$this->flashError('Document is used and cannot be deleted.');
|
||||
|
||||
@@ -638,19 +649,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, InvoiceTemplateRepository $templateRepository): Response
|
||||
public function copyTemplateAction(Request $request, InvoiceTemplate $copyFrom): Response
|
||||
{
|
||||
return $this->createTemplate($request, $templateRepository, $copyFrom);
|
||||
return $this->createTemplate($request, $copyFrom);
|
||||
}
|
||||
|
||||
#[Route(path: '/template/create', name: 'admin_invoice_template_create', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('manage_invoice_template')]
|
||||
public function createTemplateAction(Request $request, InvoiceTemplateRepository $templateRepository): Response
|
||||
public function createTemplateAction(Request $request): Response
|
||||
{
|
||||
return $this->createTemplate($request, $templateRepository, null);
|
||||
return $this->createTemplate($request, null);
|
||||
}
|
||||
|
||||
private function createTemplate(Request $request, InvoiceTemplateRepository $templateRepository, ?InvoiceTemplate $copyFrom = null): Response
|
||||
private function createTemplate(Request $request, ?InvoiceTemplate $copyFrom = null): Response
|
||||
{
|
||||
$template = new InvoiceTemplate();
|
||||
$template->setLanguage($request->getLocale());
|
||||
@@ -660,12 +671,12 @@ final class InvoiceController extends AbstractController
|
||||
$template->setName($copyFrom->getName() . ' (1)');
|
||||
}
|
||||
|
||||
return $this->renderTemplateForm($template, $request, $templateRepository);
|
||||
return $this->renderTemplateForm($template, $request);
|
||||
}
|
||||
|
||||
#[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, InvoiceTemplateRepository $templateRepository): Response
|
||||
public function deleteTemplate(InvoiceTemplate $template, string $csrfToken, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
{
|
||||
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.delete_template', $csrfToken))) {
|
||||
$this->flashError('action.csrf.error');
|
||||
@@ -676,7 +687,7 @@ final class InvoiceController extends AbstractController
|
||||
$csrfTokenManager->refreshToken('invoice.delete_template');
|
||||
|
||||
try {
|
||||
$templateRepository->removeTemplate($template);
|
||||
$this->templateRepository->removeTemplate($template);
|
||||
$this->flashSuccess('action.delete.success');
|
||||
} catch (Exception $ex) {
|
||||
$this->flashDeleteException($ex);
|
||||
@@ -716,7 +727,7 @@ final class InvoiceController extends AbstractController
|
||||
$this->flashError('action.update.error', $err);
|
||||
}
|
||||
|
||||
private function renderTemplateForm(InvoiceTemplate $template, Request $request, InvoiceTemplateRepository $templateRepository): Response
|
||||
private function renderTemplateForm(InvoiceTemplate $template, Request $request): Response
|
||||
{
|
||||
$event = new InvoiceTemplateMetaDefinitionEvent($template);
|
||||
$this->dispatcher->dispatch($event);
|
||||
@@ -727,7 +738,7 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
if ($editForm->isSubmitted() && $editForm->isValid()) {
|
||||
try {
|
||||
$templateRepository->saveTemplate($template);
|
||||
$this->templateRepository->saveTemplate($template);
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute('admin_invoice_template');
|
||||
|
||||
@@ -231,7 +231,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
#[Assert\Length(max: 50)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Customer_Entity'])]
|
||||
#[Exporter\Expose(label: 'buyerReference')]
|
||||
#[Exporter\Expose(label: 'buyer_reference')]
|
||||
private ?string $buyerReference = null;
|
||||
|
||||
public function __construct(string $name)
|
||||
|
||||
@@ -12,8 +12,8 @@ namespace App\Entity;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
|
||||
/**
|
||||
* @method array<Tag> getTags()
|
||||
* @method int getBreak()
|
||||
* @method getTags() array
|
||||
* @method getBreak() int
|
||||
*/
|
||||
interface ExportableItem
|
||||
{
|
||||
|
||||
@@ -88,10 +88,6 @@ 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()
|
||||
{
|
||||
@@ -272,29 +268,16 @@ 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
|
||||
{
|
||||
if (\count($this->taxRates) > 0) {
|
||||
return $this->taxRates;
|
||||
}
|
||||
|
||||
// TODO make me configurable via UI
|
||||
$tax = new Tax(
|
||||
TaxType::STANDARD,
|
||||
$this->vat ?? 0.00,
|
||||
'vat',
|
||||
true,
|
||||
null
|
||||
'VAT',
|
||||
$this->vat ?? 0.00
|
||||
);
|
||||
|
||||
return [$tax];
|
||||
|
||||
@@ -11,10 +11,6 @@ namespace App\Entity;
|
||||
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
|
||||
/**
|
||||
* @method null|string getSection()
|
||||
* @method void setSection(?string $name)
|
||||
*/
|
||||
interface MetaTableTypeInterface
|
||||
{
|
||||
/**
|
||||
@@ -47,8 +43,6 @@ 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;
|
||||
|
||||
@@ -138,16 +132,6 @@ 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.
|
||||
*/
|
||||
|
||||
@@ -67,7 +67,6 @@ trait MetaTableTypeTrait
|
||||
*/
|
||||
private mixed $data = null;
|
||||
private bool $updated = false;
|
||||
private ?string $section = null;
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
@@ -194,11 +193,13 @@ trait MetaTableTypeTrait
|
||||
|
||||
public function merge(MetaTableTypeInterface $meta): MetaTableTypeInterface
|
||||
{
|
||||
$this->setConstraints($meta->getConstraints());
|
||||
$this->setIsRequired($meta->isRequired());
|
||||
$this->setIsVisible($meta->isVisible());
|
||||
$this->setOptions($meta->getOptions());
|
||||
$this->setOrder($meta->getOrder());
|
||||
$this
|
||||
->setConstraints($meta->getConstraints())
|
||||
->setIsRequired($meta->isRequired())
|
||||
->setIsVisible($meta->isVisible())
|
||||
->setOptions($meta->getOptions())
|
||||
->setOrder($meta->getOrder())
|
||||
;
|
||||
|
||||
if ($meta->getLabel() !== null) {
|
||||
$this->setLabel($meta->getLabel());
|
||||
@@ -208,10 +209,6 @@ trait MetaTableTypeTrait
|
||||
$this->setType($meta->getType());
|
||||
}
|
||||
|
||||
if ($meta->getSection() !== null) {
|
||||
$this->setSection($meta->getSection());
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -269,16 +266,6 @@ 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.
|
||||
*/
|
||||
|
||||
@@ -13,10 +13,8 @@ final class Tax
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TaxType $type,
|
||||
private readonly float $rate,
|
||||
private readonly string $name,
|
||||
private readonly bool $show,
|
||||
private readonly ?string $note,
|
||||
private readonly string $name = 'VAT',
|
||||
private readonly float $rate = 0.0,
|
||||
)
|
||||
{
|
||||
}
|
||||
@@ -35,14 +33,4 @@ final class Tax
|
||||
{
|
||||
return $this->rate;
|
||||
}
|
||||
|
||||
public function isShow(): bool
|
||||
{
|
||||
return $this->rate > 0.0 || $this->show;
|
||||
}
|
||||
|
||||
public function getNote(): ?string
|
||||
{
|
||||
return $this->note;
|
||||
}
|
||||
}
|
||||
|
||||
60
src/Entity/WebhookConfiguration.php
Normal file
60
src/Entity/WebhookConfiguration.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
32
src/Entity/WebhookEvent.php
Normal file
32
src/Entity/WebhookEvent.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -14,14 +14,6 @@ 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(
|
||||
|
||||
@@ -14,9 +14,9 @@ use App\Repository\Query\TimesheetQuery;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* FIXME change interface for 3.0
|
||||
* @method string getType()
|
||||
* @method bool isInternal()
|
||||
* TODO change interface for 3.0
|
||||
* @method getType() string
|
||||
* @method isInternal() bool
|
||||
*/
|
||||
interface ExportRendererInterface
|
||||
{
|
||||
|
||||
@@ -31,10 +31,6 @@ 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;
|
||||
@@ -52,7 +48,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, // @deprecated, use invoice.tax_rows instead
|
||||
'invoice.tax_hide' => $model->isHideZeroTax() && $tax === 0.00,
|
||||
'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
|
||||
@@ -95,17 +91,11 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
|
||||
];
|
||||
|
||||
$values['invoice.tax_rows'] = [];
|
||||
$counter = 1;
|
||||
foreach ($taxRows as $taxRow) {
|
||||
$tax = $taxRow->getTax();
|
||||
$values['invoice.tax_rows'][] = [
|
||||
'counter' => $counter++,
|
||||
'type' => $tax->getType()->value,
|
||||
'name' => $tax->getName(),
|
||||
'rate' => $tax->getRate(),
|
||||
'note' => $tax->getNote(),
|
||||
'show' => $tax->isShow(),
|
||||
'currency' => $currency,
|
||||
'type' => $taxRow->getTax()->getType()->value,
|
||||
'name' => $taxRow->getTax()->getName(),
|
||||
'rate' => $taxRow->getTax()->getRate(),
|
||||
'amount' => $taxRow->getAmount(), // do not format, only available in twig anyway
|
||||
'base' => $taxRow->getBasePrice(), // do not format, only available in twig anyway
|
||||
];
|
||||
|
||||
@@ -9,9 +9,6 @@
|
||||
|
||||
namespace App\Invoice;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
|
||||
|
||||
#[AutoconfigureTag]
|
||||
interface InvoiceItemHydrator
|
||||
{
|
||||
public function setInvoiceModel(InvoiceModel $model): void;
|
||||
|
||||
@@ -9,10 +9,20 @@
|
||||
|
||||
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;
|
||||
@@ -33,6 +43,7 @@ final class InvoiceModel
|
||||
private ?NumberGeneratorInterface $generator = null;
|
||||
private \DateTimeInterface $invoiceDate;
|
||||
private ?User $user = null;
|
||||
private InvoiceFormatter $formatter;
|
||||
/**
|
||||
* @var InvoiceModelHydrator[]
|
||||
*/
|
||||
@@ -53,13 +64,24 @@ final class InvoiceModel
|
||||
* @internal use InvoiceModelFactory
|
||||
*/
|
||||
public function __construct(
|
||||
private InvoiceFormatter $formatter,
|
||||
InvoiceFormatter $formatter,
|
||||
CustomerStatisticService $customerStatistic,
|
||||
ProjectStatisticService $projectStatistic,
|
||||
ActivityStatisticService $activityStatistic,
|
||||
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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,36 +9,27 @@
|
||||
|
||||
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 RateCalculatorMode $rateCalculatorMode,
|
||||
#[TaggedIterator(InvoiceModelHydrator::class)]
|
||||
private readonly iterable $modelHydrators,
|
||||
#[TaggedIterator(InvoiceItemHydrator::class)]
|
||||
private readonly iterable $itemHydrators,
|
||||
private readonly CustomerStatisticService $customerStatisticService,
|
||||
private readonly ProjectStatisticService $projectStatisticService,
|
||||
private readonly ActivityStatisticService $activityStatisticService,
|
||||
private readonly RateCalculatorMode $rateCalculatorMode
|
||||
) {
|
||||
}
|
||||
|
||||
public function createModel(InvoiceFormatter $formatter, Customer $customer, InvoiceTemplate $template, InvoiceQuery $query): InvoiceModel
|
||||
{
|
||||
$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 = new InvoiceModel($formatter, $this->customerStatisticService, $this->projectStatisticService, $this->activityStatisticService, $customer, $template, $this->rateCalculatorMode);
|
||||
|
||||
$model->setQuery($query);
|
||||
|
||||
|
||||
@@ -9,9 +9,6 @@
|
||||
|
||||
namespace App\Invoice;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
|
||||
|
||||
#[AutoconfigureTag]
|
||||
interface InvoiceModelHydrator
|
||||
{
|
||||
public function hydrate(InvoiceModel $model): array;
|
||||
|
||||
@@ -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',
|
||||
|
||||
64
src/Serializer/Serializer.php
Normal file
64
src/Serializer/Serializer.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
17
src/Serializer/SerializerInterface.php
Normal file
17
src/Serializer/SerializerInterface.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?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;
|
||||
}
|
||||
109
src/Webhook/WebhookListener.php
Normal file
109
src/Webhook/WebhookListener.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
58
src/Webhook/WebhookService.php
Normal file
58
src/Webhook/WebhookService.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?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),
|
||||
];
|
||||
}
|
||||
}
|
||||
24
symfony.lock
24
symfony.lock
@@ -467,6 +467,18 @@
|
||||
"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"
|
||||
},
|
||||
@@ -598,6 +610,18 @@
|
||||
"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": {
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
'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">
|
||||
@@ -50,18 +49,6 @@
|
||||
</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) }}
|
||||
@@ -70,6 +57,18 @@
|
||||
{{ 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 %}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
'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">
|
||||
@@ -76,10 +75,11 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ form_helper.metafields(form, null) }}
|
||||
{% if form.metaFields is defined and form.metaFields is not empty %}
|
||||
{{ form_row(form.metaFields) }}
|
||||
{% endif %}
|
||||
{% 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,12 +125,11 @@
|
||||
{{ 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 %}
|
||||
@@ -142,7 +141,6 @@
|
||||
{% if form.invoiceText is defined %}
|
||||
{{ form_row(form.invoiceText) }}
|
||||
{% endif %}
|
||||
{{ form_helper.metafields(form, 'invoice') }}
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
<div class="row">
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
{{ entry.vatId }}
|
||||
{% elseif column == 'contact' %}
|
||||
{{ entry.contact }}
|
||||
{% elseif column == 'city' %}
|
||||
{{ entry.postcode }} {{ entry.city }}
|
||||
{% elseif column == 'address' %}
|
||||
{{ entry.address }}
|
||||
{% elseif column == 'country' %}
|
||||
{{ entry.country|country_name }}
|
||||
{% elseif column == 'currency' %}
|
||||
|
||||
@@ -474,9 +474,7 @@
|
||||
|
||||
{%- block meta_fields_collection_widget -%}
|
||||
{% for meta in form|sort((a, b) => a.vars.data.order <=> b.vars.data.order) %}
|
||||
{% 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 %}
|
||||
{{ form_row(meta.value, {'row_attr': {'class': 'mb-3 ' ~ form.parent.vars.name ~ '_row_' ~ form.vars.name ~ '_' ~ meta.vars.name}}) }}
|
||||
{% endfor %}
|
||||
{%- endblock meta_fields_collection_widget %}
|
||||
|
||||
|
||||
@@ -135,25 +135,20 @@ 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>
|
||||
{% 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">
|
||||
{{ 'invoice.tax'|trans }} ({{ invoice['invoice.vat'] }}%)
|
||||
</td>
|
||||
<td class="last text-right">{{ invoice['invoice.tax'] }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-right text-nowrap">
|
||||
<strong>{{ 'invoice.total'|trans }}</strong>
|
||||
@@ -165,17 +160,10 @@ 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 %}
|
||||
{{ invoice['template.payment_terms']|md2html }}
|
||||
<div class="paymentTerms">
|
||||
{{ invoice['template.payment_terms']|md2html }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<h2 class="page-header">
|
||||
<span>{{ invoice['template.title'] }}</span>
|
||||
<span contenteditable="true">{{ invoice['template.title'] }}</span>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
@@ -22,7 +22,7 @@
|
||||
<div class="row">
|
||||
<div class="col-sm-5">
|
||||
{{ 'invoice.to'|trans }}
|
||||
<address>
|
||||
<address contenteditable="true">
|
||||
<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>
|
||||
<address contenteditable="true">
|
||||
<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>
|
||||
<p contenteditable="true">
|
||||
<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>
|
||||
<td contenteditable="true">
|
||||
{% if invoiceLineItem['entry.description'] is not empty %}
|
||||
{{ invoiceLineItem['entry.description']|nl2br }}
|
||||
{% else %}
|
||||
@@ -115,25 +115,20 @@
|
||||
{% 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>
|
||||
{% 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">
|
||||
{{ 'invoice.tax'|trans }} ({{ invoice['invoice.vat'] }}%)
|
||||
</td>
|
||||
<td class="text-end">{{ invoice['invoice.tax'] }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-end text-nowrap">
|
||||
<strong>{{ 'invoice.total'|trans }}</strong>
|
||||
@@ -149,16 +144,10 @@
|
||||
|
||||
<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 %}
|
||||
{{ invoice['template.payment_terms']|md2html }}
|
||||
<div contenteditable="true" class="paymentTerms">
|
||||
{{ invoice['template.payment_terms']|md2html }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -150,25 +150,20 @@ 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>
|
||||
{% 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">
|
||||
{{ 'invoice.tax'|trans }} ({{ invoice['invoice.vat'] }}%)
|
||||
</td>
|
||||
<td class="last text-right">{{ invoice['invoice.tax'] }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-right text-nowrap">
|
||||
<strong>{{ 'invoice.total'|trans }}</strong>
|
||||
@@ -180,17 +175,10 @@ 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 %}
|
||||
{{ invoice['template.payment_terms']|md2html }}
|
||||
<div class="paymentTerms">
|
||||
{{ invoice['template.payment_terms']|md2html }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -8,7 +8,6 @@
|
||||
'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">
|
||||
@@ -59,10 +58,11 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ form_helper.metafields(form, null) }}
|
||||
{% if form.metaFields is defined and form.metaFields is not empty %}
|
||||
{{ form_row(form.metaFields) }}
|
||||
{% endif %}
|
||||
{% 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,7 +76,6 @@
|
||||
{% if form.invoiceText is defined %}
|
||||
{{ form_row(form.invoiceText) }}
|
||||
{% endif %}
|
||||
{{ form_helper.metafields(form, 'invoice') }}
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
<div class="row">
|
||||
|
||||
@@ -38,7 +38,6 @@ 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
|
||||
@@ -84,11 +83,6 @@ 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
|
||||
@@ -96,19 +90,18 @@ abstract class AbstractMetaEntityTestCase extends TestCase
|
||||
$entity1 = $this->getEntity();
|
||||
$entity2 = $this->getEntity();
|
||||
$meta1 = $this->getMetaEntity();
|
||||
$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');
|
||||
|
||||
$meta1
|
||||
->setName('foo')
|
||||
->setValue('bar')
|
||||
->setType('blub')
|
||||
->setEntity($entity1)
|
||||
->setConstraints([new NotNull()])
|
||||
->setOptions(['foo' => 'bar', 'bar' => 'foo'])
|
||||
;
|
||||
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());
|
||||
@@ -116,17 +109,18 @@ abstract class AbstractMetaEntityTestCase extends TestCase
|
||||
self::assertCount(2, $meta1->getOptions());
|
||||
|
||||
$meta2 = $this->getMetaEntity();
|
||||
$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']);
|
||||
$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'])
|
||||
;
|
||||
|
||||
self::assertInstanceOf(MetaTableTypeInterface::class, $meta1->merge($meta2));
|
||||
|
||||
@@ -134,7 +128,6 @@ 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());
|
||||
|
||||
@@ -280,7 +280,7 @@ zip 12345 looney toon', $sut->getFormattedAddress());
|
||||
['visible', 'boolean'],
|
||||
['comment', 'string'],
|
||||
['billable', 'boolean'],
|
||||
['buyerReference', 'string'],
|
||||
['buyer_reference', 'string'],
|
||||
];
|
||||
|
||||
self::assertCount(\count($expected), $columns);
|
||||
|
||||
@@ -13,8 +13,6 @@ 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;
|
||||
@@ -46,48 +44,6 @@ 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();
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style type="text/css">
|
||||
{{ encore_entry_css_source('invoice-pdf')|raw }}
|
||||
{{ encore_entry_css_source('invoice')|raw }}
|
||||
</style>
|
||||
</head>
|
||||
<body class="invoice_print">
|
||||
|
||||
@@ -11,13 +11,6 @@ namespace App\Tests\Mocks;
|
||||
|
||||
use App\Activity\ActivityStatisticService;
|
||||
use App\Customer\CustomerStatisticService;
|
||||
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\Invoice\InvoiceModelFactory;
|
||||
use App\Project\ProjectStatisticService;
|
||||
use App\Timesheet\RateCalculator\DecimalRateCalculator;
|
||||
@@ -32,19 +25,8 @@ class InvoiceModelFactoryFactory extends AbstractMockFactory
|
||||
$projectStatistic = $this->getMockBuilder(ProjectStatisticService::class)->disableOriginalConstructor()->getMock();
|
||||
/** @var ActivityStatisticService $activityStatistic */
|
||||
$activityStatistic = $this->getMockBuilder(ActivityStatisticService::class)->disableOriginalConstructor()->getMock();
|
||||
$rateMode = new DecimalRateCalculator();
|
||||
|
||||
$modelHydrators = [
|
||||
new InvoiceModelDefaultHydrator(),
|
||||
new InvoiceModelCustomerHydrator($customerStatistic),
|
||||
new InvoiceModelIssuerHydrator(),
|
||||
new InvoiceModelProjectHydrator($projectStatistic),
|
||||
new InvoiceModelActivityHydrator($activityStatistic),
|
||||
new InvoiceModelUserHydrator(),
|
||||
];
|
||||
$itemHydrators = [
|
||||
new InvoiceItemDefaultHydrator(),
|
||||
];
|
||||
|
||||
return new InvoiceModelFactory(new DecimalRateCalculator(), $modelHydrators, $itemHydrators);
|
||||
return new InvoiceModelFactory($customerStatistic, $projectStatistic, $activityStatistic, $rateMode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2046,26 +2046,6 @@
|
||||
<source>payment_account_name</source>
|
||||
<target state="translated">Titular del compte o targeta</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="QnN_ONc" resname="invoice_tax_type" xml:space="preserve">
|
||||
<source>invoice_tax_type</source>
|
||||
<target state="translated">Estat fiscal</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="Mi7y8Q3" resname="tax.type.exempt" xml:space="preserve">
|
||||
<source>tax.type.exempt</source>
|
||||
<target state="translated">Exempt</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="lU1Njcy" resname="tax.type.standard" xml:space="preserve">
|
||||
<source>tax.type.standard</source>
|
||||
<target state="translated">Sotmès a impostos</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="E470U8W" resname="tax.type.reverse" xml:space="preserve">
|
||||
<source>tax.type.reverse</source>
|
||||
<target state="translated">Inversió del subjecte passiu</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="hi31a3v" resname="tax.note.reverse_charge" xml:space="preserve">
|
||||
<source>tax.note.reverse_charge</source>
|
||||
<target state="translated">Impost a pagar en règim d’inversió del subjecte passiu</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -2046,26 +2046,6 @@
|
||||
<source>electronic_invoice</source>
|
||||
<target>E-Rechnung</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="QnN_ONc" resname="invoice_tax_type">
|
||||
<source>invoice_tax_type</source>
|
||||
<target>Steuerstatus</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="Mi7y8Q3" resname="tax.type.exempt">
|
||||
<source>tax.type.exempt</source>
|
||||
<target>Ausgenommen</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="lU1Njcy" resname="tax.type.standard">
|
||||
<source>tax.type.standard</source>
|
||||
<target>Steuerpflichtig</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="E470U8W" resname="tax.type.reverse">
|
||||
<source>tax.type.reverse</source>
|
||||
<target>Umkehrung der Steuerschuld</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="hi31a3v" resname="tax.note.reverse_charge">
|
||||
<source>tax.note.reverse_charge</source>
|
||||
<target>Steuer zahlbar auf Reverse-Charge-Basis</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -2046,26 +2046,6 @@
|
||||
<source>electronic_invoice</source>
|
||||
<target>E-Invoice</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="QnN_ONc" resname="invoice_tax_type">
|
||||
<source>invoice_tax_type</source>
|
||||
<target>Tax status</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="Mi7y8Q3" resname="tax.type.exempt">
|
||||
<source>tax.type.exempt</source>
|
||||
<target>Exempt</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="lU1Njcy" resname="tax.type.standard">
|
||||
<source>tax.type.standard</source>
|
||||
<target>Taxable</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="E470U8W" resname="tax.type.reverse">
|
||||
<source>tax.type.reverse</source>
|
||||
<target>Reverse charge</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="hi31a3v" resname="tax.note.reverse_charge">
|
||||
<source>tax.note.reverse_charge</source>
|
||||
<target>Tax to be paid on reverse charge basis</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -2046,26 +2046,6 @@
|
||||
<source>electronic_invoice</source>
|
||||
<target state="final">Fattura elettronica</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="QnN_ONc" resname="invoice_tax_type" xml:space="preserve" approved="yes">
|
||||
<source>invoice_tax_type</source>
|
||||
<target state="final">Stato fiscale</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="Mi7y8Q3" resname="tax.type.exempt" xml:space="preserve" approved="yes">
|
||||
<source>tax.type.exempt</source>
|
||||
<target state="final">Esonera</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="lU1Njcy" resname="tax.type.standard" xml:space="preserve" approved="yes">
|
||||
<source>tax.type.standard</source>
|
||||
<target state="final">Tassabile</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="E470U8W" resname="tax.type.reverse" xml:space="preserve" approved="yes">
|
||||
<source>tax.type.reverse</source>
|
||||
<target state="final">Addebito inverso</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="hi31a3v" resname="tax.note.reverse_charge" xml:space="preserve" approved="yes">
|
||||
<source>tax.note.reverse_charge</source>
|
||||
<target state="final">Tassa da pagare in base all'addebito inverso</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -310,10 +310,6 @@
|
||||
<source>copy_teams_on_create</source>
|
||||
<target state="translated">Hereta els equips de l'usuari connectat en crear noves entrades</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="6AaVxB2" resname="invoice.rounding_mode" approved="no" xml:space="preserve">
|
||||
<source>invoice.rounding_mode</source>
|
||||
<target state="translated">Mode d’arrodoniment</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -310,10 +310,6 @@
|
||||
<source>activity.number_format</source>
|
||||
<target state="translated">Formato del número de actividad</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="6AaVxB2" resname="invoice.rounding_mode" approved="no" xml:space="preserve">
|
||||
<source>invoice.rounding_mode</source>
|
||||
<target state="translated">Modo de redondeo</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
Reference in New Issue
Block a user