Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d429c56687 | ||
|
|
6a86afb5fd | ||
|
|
9e87fc131b | ||
|
|
5a0c783a76 | ||
|
|
26361da873 | ||
|
|
26f8abfcc6 | ||
|
|
27c2b912f7 | ||
|
|
7e72f144cb | ||
|
|
3c3d6379a8 |
@@ -21,7 +21,3 @@ indent_size = 4
|
||||
[*.yml]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.xml]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
@@ -45,7 +45,3 @@ APP_SECRET=change_this_to_something_unique
|
||||
#================================================================================
|
||||
# unlikely, that you need to change this one
|
||||
CORS_ALLOW_ORIGIN=^https?://localhost(:[0-9]+)?$
|
||||
|
||||
###> symfony/messenger ###
|
||||
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
|
||||
###< symfony/messenger ###
|
||||
|
||||
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -5,8 +5,10 @@ tests export-ignore
|
||||
.codecov.yml export-ignore
|
||||
.editorconfig export-ignore
|
||||
eslint.config.js export-ignore
|
||||
eslint.config.mjs export-ignore
|
||||
.gitattributes export-ignore
|
||||
.gitignore export-ignore
|
||||
.php-cs-fixer.dist.php export-ignore
|
||||
php-cs-fixer.dist.php export-ignore
|
||||
babel.config.js export-ignore
|
||||
package.json export-ignore
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -53,6 +53,7 @@ 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.4**
|
||||
**Compatible with PHP 8.1 to 8.5**
|
||||
|
||||
$CHANGES
|
||||
|
||||
|
||||
1
.github/workflows/docker.yaml
vendored
1
.github/workflows/docker.yaml
vendored
@@ -60,7 +60,6 @@ jobs:
|
||||
tags: |
|
||||
kimai/kimai2:latest
|
||||
kimai/kimai2:fpm
|
||||
kimai/kimai2:fpm-${{ env.kimai_version }}
|
||||
push: true
|
||||
|
||||
- name: Apache image
|
||||
|
||||
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']
|
||||
php: ['8.1', '8.2', '8.3', '8.4', '8.5']
|
||||
|
||||
name: Integration (${{ matrix.php }})
|
||||
steps:
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
MAILER_URL: null://localhost
|
||||
|
||||
- name: Full test-suite
|
||||
if: matrix.php != '8.2'
|
||||
if: matrix.php != '8.5'
|
||||
run: vendor/bin/phpunit tests/
|
||||
env:
|
||||
DATABASE_URL: mysql://root:kimai@127.0.0.1:${{ job.services.mysql.ports['3306'] }}/kimai?charset=utf8mb4&serverVersion=8.0.35
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
MAILER_URL: null://localhost
|
||||
|
||||
- name: Full test-suite with coverage
|
||||
if: matrix.php == '8.2'
|
||||
if: matrix.php == '8.5'
|
||||
run: vendor/bin/phpunit tests/ --coverage-clover=coverage.xml
|
||||
env:
|
||||
DATABASE_URL: mysql://root:kimai@127.0.0.1:${{ job.services.mysql.ports['3306'] }}/kimai?charset=utf8mb4&serverVersion=8.0.35
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
MAILER_URL: null://localhost
|
||||
|
||||
- name: Upload code coverage
|
||||
if: matrix.php == '8.2'
|
||||
if: matrix.php == '8.5'
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,6 +3,8 @@
|
||||
/.env-*
|
||||
/.idea/
|
||||
.DS_Store
|
||||
var/templates/
|
||||
|
||||
|
||||
# custom apache rules e.g. to deactivate ioncube loader
|
||||
/public/.user.ini
|
||||
@@ -14,6 +16,7 @@
|
||||
# YARN 2
|
||||
/.yarnrc.yml
|
||||
/.yarn
|
||||
/.pnp.*
|
||||
|
||||
# for keeping empty directories
|
||||
/config/packages/local.yaml
|
||||
|
||||
@@ -10,7 +10,7 @@ Send your ideas, code reviews, pull requests and feature requests to help to imp
|
||||
- Make your changes in a new git branch, based on the latest code in `main`
|
||||
- Apply our code-style by running `composer codestyle-fix`
|
||||
- Run the static code analysis with `composer phpstan`
|
||||
- Verify everything still works with `composer tests-unit`
|
||||
- Verify everything still works with `composer tests`
|
||||
- Add tests for your changes
|
||||
|
||||
Further documentation can be found in the [developer documentation](https://www.kimai.org/documentation/developers.html).
|
||||
|
||||
@@ -29,7 +29,7 @@ and so much more.
|
||||
|
||||
### Requirements
|
||||
|
||||
- PHP 8.1.3 minimum (support for PHP 8.2, 8.3, 8.4)
|
||||
- PHP 8.1.3 minimum with support for 8.2, 8.3, 8.4, 8.5
|
||||
- MariaDB or MySQL
|
||||
- A webserver and subdomain (subdirectory is not supported)
|
||||
- PHP extensions: `gd`, `intl`, `json`, `mbstring`, `pdo`, `tokenizer`, `xml`, `xsl`, `zip`
|
||||
@@ -44,7 +44,7 @@ and so much more.
|
||||
|
||||
There are more documented ways for [on-premise hosting](https://www.kimai.org/documentation/chapter-on-premise.html).
|
||||
|
||||
And if you don't want to host Kimai, you can use [the Cloud version](https://www.kimai.cloud/) of it.
|
||||
And if you don't want to host Kimai, you can use the [Cloud version](https://www.kimai.cloud/) of it.
|
||||
|
||||
### Updating Kimai
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ Perform EACH version specific task between your version and the new one, otherwi
|
||||
|
||||
### Developer
|
||||
|
||||
Do not use method chaining: all fluent interface, especially in Entities, are no longer supported.
|
||||
|
||||
Removed translations:
|
||||
- `action.edit`: use `edit` instead
|
||||
- `my.profile`: use `user_profile` instead
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
|
||||
/**
|
||||
* @deprecated use invoice-pdf instead
|
||||
*/
|
||||
require('./sass/_invoice.scss');
|
||||
|
||||
@@ -24,7 +24,7 @@ export default class KimaiAutocompleteTags extends KimaiAutocomplete {
|
||||
API.get(apiUrl, {'name': query}, (data) => {
|
||||
let results = [];
|
||||
for (let item of data) {
|
||||
results.push({text: item.name, value: item.name, color: item.color});
|
||||
results.push({text: item.name, value: item.name, color: item['color-safe']});
|
||||
}
|
||||
callback(results);
|
||||
}, () => {
|
||||
|
||||
@@ -73,6 +73,7 @@ export default class KimaiAPILink extends KimaiPlugin {
|
||||
const ALERT = this.getContainer().getPlugin('alert');
|
||||
const successHandle = () => {
|
||||
EVENTS.trigger(eventName);
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
|
||||
if (attributes['msgSuccess'] !== undefined) {
|
||||
ALERT.success(attributes['msgSuccess']);
|
||||
}
|
||||
@@ -82,6 +83,7 @@ export default class KimaiAPILink extends KimaiPlugin {
|
||||
if (attributes['msgError'] !== undefined) {
|
||||
message = attributes['msgError'];
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
|
||||
API.handleError(message, error);
|
||||
};
|
||||
|
||||
@@ -90,6 +92,8 @@ export default class KimaiAPILink extends KimaiPlugin {
|
||||
data = attributes['payload'];
|
||||
}
|
||||
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadContent'));
|
||||
|
||||
if (method === 'PATCH') {
|
||||
API.patch(url, data, successHandle, errorHandle);
|
||||
} else if (method === 'POST') {
|
||||
|
||||
@@ -217,8 +217,6 @@ export default class KimaiAjaxModalForm extends KimaiReducedClickHandler {
|
||||
const eventName = form.dataset['formEvent'];
|
||||
/** @type {KimaiEvent} alert */
|
||||
const events = this.getContainer().getPlugin('event');
|
||||
/** @type {KimaiAlert} alert */
|
||||
const alert = this.getContainer().getPlugin('alert');
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -259,15 +257,8 @@ export default class KimaiAjaxModalForm extends KimaiReducedClickHandler {
|
||||
} else {
|
||||
events.trigger(eventName);
|
||||
|
||||
// try to find form defined message first, but
|
||||
let msg = form.dataset['msgSuccess'];
|
||||
// if that is not available: use a generic fallback message
|
||||
if (msg === null || msg === undefined || msg === '') {
|
||||
msg = 'action.update.success';
|
||||
}
|
||||
this._isDirty = false;
|
||||
this._getModal().hide();
|
||||
alert.success(msg);
|
||||
}
|
||||
});
|
||||
})
|
||||
@@ -277,6 +268,8 @@ export default class KimaiAjaxModalForm extends KimaiReducedClickHandler {
|
||||
message = 'action.update.error';
|
||||
}
|
||||
|
||||
/** @type {KimaiAlert} alert */
|
||||
const alert = this.getContainer().getPlugin('alert');
|
||||
alert.error(message, error.message);
|
||||
|
||||
// this is useful for changing form fields and retrying to save (and in development to test form changes)
|
||||
|
||||
@@ -42,7 +42,7 @@ export default class KimaiThemeInitializer extends KimaiPlugin {
|
||||
}
|
||||
|
||||
// at which element we append the loading screen
|
||||
let container = 'body';
|
||||
let container = 'div.page-wrapper';
|
||||
if (event.detail !== undefined && event.detail !== null) {
|
||||
container = event.detail;
|
||||
}
|
||||
|
||||
@@ -70,8 +70,6 @@ export default class KimaiCalendar {
|
||||
const DATES = this.kimai.getPlugin('date');
|
||||
/** @type {KimaiAjaxModalForm} MODAL */
|
||||
const MODAL = this.kimai.getPlugin('modal');
|
||||
/** @type {KimaiAlert} ALERT */
|
||||
const ALERT = this.kimai.getPlugin('alert');
|
||||
|
||||
// Instead of using "buttonIcons" the theme needs to be adjusted directly
|
||||
// https://fullcalendar.io/docs/buttonIcons
|
||||
@@ -282,6 +280,7 @@ export default class KimaiCalendar {
|
||||
droppable: true,
|
||||
// drop function handles external draggable events
|
||||
drop: (dropInfo) => {
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadContent'));
|
||||
const entry = dropInfo.draggedEl;
|
||||
const source = entry.parentElement;
|
||||
let data = JSON.parse(entry.dataset.entry);
|
||||
@@ -324,7 +323,7 @@ export default class KimaiCalendar {
|
||||
(result) => {
|
||||
const newItem = this.convertSourceForCalendar(result);
|
||||
this.getCalendar().addEvent(newItem, true);
|
||||
ALERT.success('action.update.success');
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
|
||||
}
|
||||
);
|
||||
} else {
|
||||
@@ -334,7 +333,7 @@ export default class KimaiCalendar {
|
||||
(result) => {
|
||||
const newItem = this.convertSourceForCalendar(result);
|
||||
this.getCalendar().addEvent(newItem, true);
|
||||
ALERT.success('action.update.success');
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -703,8 +702,6 @@ export default class KimaiCalendar {
|
||||
|
||||
/** @type {KimaiAPI} API */
|
||||
const API = this.kimai.getPlugin('api');
|
||||
/** @type {KimaiAlert} ALERT */
|
||||
const ALERT = this.kimai.getPlugin('alert');
|
||||
/** @type {KimaiDateUtils} DATE */
|
||||
const DATES = this.kimai.getPlugin('date');
|
||||
|
||||
@@ -716,11 +713,14 @@ export default class KimaiCalendar {
|
||||
payload.end = null;
|
||||
}
|
||||
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadContent'));
|
||||
|
||||
const updateUrl = this.options.url.update(event.id);
|
||||
API.patch(updateUrl, JSON.stringify(payload), () => {
|
||||
ALERT.success('action.update.success');
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
|
||||
}, (error) => {
|
||||
eventArg.revert();
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
|
||||
API.handleError('action.update.error', error);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1021;
|
||||
background-color: var(--tblr-backdrop-bg);
|
||||
/*background-color: var(--tblr-backdrop-bg);*/
|
||||
opacity: $modal-backdrop-opacity;
|
||||
}
|
||||
|
||||
@@ -48,4 +48,12 @@ fieldset.form-fieldset > legend {
|
||||
.page-title {
|
||||
color: var(--tblr-body-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Highlighted text is not visible - https://github.com/tabler/tabler/issues/2603 */
|
||||
[data-bs-theme=dark] {
|
||||
::selection,
|
||||
.text-selected {
|
||||
background-color: var(--#{$prefix}primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "8.1.*||8.2.*||8.3.*||8.4.*",
|
||||
"php": "8.1.*||8.2.*||8.3.*||8.4.*||8.5.*",
|
||||
"ext-gd": "*",
|
||||
"ext-intl": "*",
|
||||
"ext-json": "*",
|
||||
@@ -74,7 +74,6 @@
|
||||
"symfony/translation": "^6.0",
|
||||
"symfony/twig-bundle": "^6.0",
|
||||
"symfony/validator": "^6.0",
|
||||
"symfony/webhook": "^6.0",
|
||||
"symfony/webpack-encore-bundle": "^2.0",
|
||||
"symfony/yaml": "^6.0",
|
||||
"twig/cssinliner-extra": "^3.0",
|
||||
|
||||
947
composer.lock
generated
947
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,22 +0,0 @@
|
||||
framework:
|
||||
messenger:
|
||||
# Uncomment this (and the failed transport below) to send failed messages to this transport for later handling.
|
||||
# failure_transport: failed
|
||||
|
||||
transports:
|
||||
# https://symfony.com/doc/current/messenger.html#transport-configuration
|
||||
# async: '%env(MESSENGER_TRANSPORT_DSN)%'
|
||||
# failed: 'doctrine://default?queue_name=failed'
|
||||
sync: 'sync://'
|
||||
|
||||
routing:
|
||||
# Route your messages to the transports
|
||||
# 'App\Message\YourMessage': async
|
||||
|
||||
# when@test:
|
||||
# framework:
|
||||
# messenger:
|
||||
# transports:
|
||||
# # replace with your transport name here (e.g., my_transport: 'in-memory://')
|
||||
# # For more Messenger testing tools, see https://github.com/zenstruck/messenger-test
|
||||
# async: 'in-memory://'
|
||||
@@ -1,3 +0,0 @@
|
||||
#webhook:
|
||||
# resource: '@FrameworkBundle/Resources/config/routing/webhook.xml'
|
||||
# prefix: /webhook
|
||||
70
phpstan.neon
70
phpstan.neon
@@ -2644,16 +2644,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Form/Type/UserPreferenceType.php
|
||||
|
||||
-
|
||||
message: "#^PHPDoc tag @var for variable \\$collection contains generic class Doctrine\\\\Common\\\\Collections\\\\ArrayCollection but does not specify its types\\: TKey, T$#"
|
||||
count: 1
|
||||
path: src/Form/Type/UserPreferencesCollectionType.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$key of method Doctrine\\\\Common\\\\Collections\\\\ArrayCollection\\<\\(int\\|string\\),mixed\\>\\:\\:set\\(\\) expects \\(int\\|string\\), string\\|null given\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Type/UserPreferencesCollectionType.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access offset mixed on mixed\\.$#"
|
||||
count: 1
|
||||
@@ -3819,61 +3809,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Utils/ProfileManager.php
|
||||
|
||||
-
|
||||
message: "#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/ReleaseVersion.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access offset 0 on mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/ReleaseVersion.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access property \\$body on mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/ReleaseVersion.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access property \\$draft on mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/ReleaseVersion.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access property \\$html_url on mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/ReleaseVersion.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access property \\$prerelease on mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/ReleaseVersion.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access property \\$published_at on mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/ReleaseVersion.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access property \\$tag_name on mixed\\.$#"
|
||||
count: 2
|
||||
path: src/Utils/ReleaseVersion.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access property \\$zipball_url on mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/ReleaseVersion.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Utils\\\\ReleaseVersion\\:\\:getReleasesFromGithub\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/ReleaseVersion.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$json of function json_decode expects string, string\\|false given\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/ReleaseVersion.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access offset 'pattern' on mixed\\.$#"
|
||||
count: 1
|
||||
@@ -4008,8 +3943,3 @@ parameters:
|
||||
message: "#^Method App\\\\Form\\\\MultiUpdate\\\\MultiUpdateTableDTO\\:\\:setEntities\\(\\) has parameter \\$entities with generic interface Doctrine\\\\Common\\\\Collections\\\\Collection but does not specify its types\\: TKey, T$#"
|
||||
count: 1
|
||||
path: src/Form/MultiUpdate/MultiUpdateTableDTO.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Entity\\\\InvoiceTemplate\\:\\:getMetaFields\\(\\) return type with generic interface Doctrine\\\\Common\\\\Collections\\\\Collection does not specify its types\\: TKey, T$#"
|
||||
count: 1
|
||||
path: src/Entity/InvoiceTemplate.php
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<ini name="error_reporting" value="-1"/>
|
||||
<ini name="max_execution_time" value="-1"/>
|
||||
<ini name="date.timezone" value="UTC"/>
|
||||
<ini name="intl.default_locale" value="en_US"/>
|
||||
<ini name="intl.default_locale" value="en"/>
|
||||
<ini name="date.timezone" value="Europe/Vienna"/>
|
||||
<env name="KERNEL_CLASS" value="App\Kernel" force="true"/>
|
||||
<env name="SYMFONY_DEPRECATIONS_HELPER" value="weak"/>
|
||||
@@ -38,13 +38,6 @@
|
||||
GRANT execute,select,insert,update,delete,create,alter,drop,index,references ON `kimai2_test`.* TO kimai2_test@127.0.0.1;
|
||||
-->
|
||||
<env name="BOOTSTRAP_RESET_DATABASE" value="true"/>
|
||||
|
||||
<!-- ###+ symfony/messenger ### -->
|
||||
<!-- Choose one of the transports below -->
|
||||
<!-- MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages -->
|
||||
<!-- MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages -->
|
||||
<env name="MESSENGER_TRANSPORT_DSN" value="doctrine://default?auto_setup=0"/>
|
||||
<!-- ###- symfony/messenger ### -->
|
||||
</php>
|
||||
<testsuites>
|
||||
<testsuite name="Kimai">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<tile>
|
||||
<square150x150logo src="favicon/mstile-150x150.png"/>
|
||||
<square310x310logo src="favicon/mstile-large.jpg"/>
|
||||
<TileColor>#00a300</TileColor>
|
||||
<TileColor>#ffffff</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
public/build/app.41748d55.js
Normal file
2
public/build/app.41748d55.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
public/build/calendar.36a92145.js
Normal file
2
public/build/calendar.36a92145.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
public/build/chart.56f16a68.js
Normal file
2
public/build/chart.56f16a68.js
Normal file
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
|
||||
/*!
|
||||
* @kurkle/color v0.3.2
|
||||
* @kurkle/color v0.3.4
|
||||
* https://github.com/kurkle/color#readme
|
||||
* (c) 2023 Jukka Kurkela
|
||||
* (c) 2024 Jukka Kurkela
|
||||
* Released under the MIT License
|
||||
*/
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
public/build/dashboard.9708ae5e.js
Normal file
2
public/build/dashboard.9708ae5e.js
Normal file
File diff suppressed because one or more lines are too long
@@ -2,25 +2,25 @@
|
||||
"entrypoints": {
|
||||
"app": {
|
||||
"js": [
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/app.9662939e.js"
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/app.41748d55.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/app.c18ba3c6.css"
|
||||
"/build/app.e64b4d08.css"
|
||||
]
|
||||
},
|
||||
"app-rtl": {
|
||||
"js": [
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/app-rtl.15853b82.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/app-rtl.2003dce5.css"
|
||||
"/build/app-rtl.6b5a0ee9.css"
|
||||
]
|
||||
},
|
||||
"export-pdf": {
|
||||
"js": [
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/export-pdf.395749ab.js"
|
||||
],
|
||||
"css": [
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"invoice": {
|
||||
"js": [
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/invoice.42b319e4.js"
|
||||
],
|
||||
"css": [
|
||||
@@ -38,7 +38,7 @@
|
||||
},
|
||||
"invoice-pdf": {
|
||||
"js": [
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/invoice-pdf.26d98626.js"
|
||||
],
|
||||
"css": [
|
||||
@@ -47,14 +47,14 @@
|
||||
},
|
||||
"chart": {
|
||||
"js": [
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/chart.bafa38e7.js"
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/chart.56f16a68.js"
|
||||
]
|
||||
},
|
||||
"calendar": {
|
||||
"js": [
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/calendar.b2d70caa.js"
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/calendar.36a92145.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/calendar.d757753e.css"
|
||||
@@ -62,8 +62,8 @@
|
||||
},
|
||||
"dashboard": {
|
||||
"js": [
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/dashboard.6ce7ac9c.js"
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/dashboard.9708ae5e.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/dashboard.b7129fa1.css"
|
||||
@@ -71,8 +71,8 @@
|
||||
},
|
||||
"highlight": {
|
||||
"js": [
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/highlight.13d5d50e.js"
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/highlight.0296a734.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/highlight.98bf3927.css"
|
||||
@@ -80,23 +80,23 @@
|
||||
}
|
||||
},
|
||||
"integrity": {
|
||||
"/build/runtime.6c399d29.js": "sha384-/rm616f12czi8l/27GvWXtb3g608vJZf2XTUKxqCRI4tsa2vUHP+BW90edTok5zC",
|
||||
"/build/app.9662939e.js": "sha384-bRL78SVI1wjTweqKeb+ZSSbA78i6By89S8/xx+55zmbOVtF1TCu3lf3pvdki5ZVJ",
|
||||
"/build/app.c18ba3c6.css": "sha384-qkIgqLzngG2NchdFVImbGMU49lWlZg6Y9D0z2P0u1j2NQstDicA4KqIkkNYcpErm",
|
||||
"/build/runtime.684e9f6d.js": "sha384-suKiEX2de4fdNqQzdYbUd6osp4AepD9FiMXl+1QdvgMW9dcQqUWQNQasf3KWzwLr",
|
||||
"/build/app.41748d55.js": "sha384-iVPHnOXYjVDuq8567h8I3U2gl5SCV3mNMFNQCJdgMiNuFMZ4KO7PXOLanjgcGzTt",
|
||||
"/build/app.e64b4d08.css": "sha384-Rapl6RNn58g7oayglp7MWOER7tDNPpChx6RKOOWe0mg6uFgaJjy3M51ZSkxXm+Hg",
|
||||
"/build/app-rtl.15853b82.js": "sha384-UnKKgLMu9FnRT+CFE0no/+UiUks012bYriQdUWa6f02mo6Lswl947mPybjvKL503",
|
||||
"/build/app-rtl.2003dce5.css": "sha384-pl8GyGo8sRRw1zLh9D42ZAvo450onfxeszZWocUUuzq70jAOwOlaoPYh/yyc20U7",
|
||||
"/build/app-rtl.6b5a0ee9.css": "sha384-eW8BEaA6S1jAFPB8lUCBTvcm4wZZQ3SHbiHVZlxe814TXGT9L4/4BN8CuuGehZ6o",
|
||||
"/build/export-pdf.395749ab.js": "sha384-3Hjvmu4FC/0dhHnR8kyRBU7k2xMNy1lxBpGgOkrw8PxXnwyQDM8/5bQmkJbjVT1+",
|
||||
"/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",
|
||||
"/build/invoice.42b319e4.js": "sha384-xxK7sCe/ZhTjMPFPeX1xvILURxNRZz2hJHZxAGVaw9zE6TC++2/6y2eKqg8cz852",
|
||||
"/build/invoice.36018785.css": "sha384-jukM9uZ6pexDxXKgZThSxiqXimzsxzniBMHz08N9x8ryXZYkM5r/ZgaainCV0+J6",
|
||||
"/build/invoice-pdf.26d98626.js": "sha384-gwNzQiU1y6qU/M9DPGiNW0MVZkLctEHk37sCES2X9ov+zugEaDABdkMjKBYOC9lz",
|
||||
"/build/invoice-pdf.2b749265.css": "sha384-DXXgkz2WWnrWnfBnXX5fmfPQSPb98upMnWxYKwTGYS04EhrPIWfDCutB2unIrWh7",
|
||||
"/build/chart.bafa38e7.js": "sha384-Ays2qGKvOqs4NSeN/zJOPcrnzIEC/uYSCIkC3kN0KQye+mA5Lq7UkfjZDjulvFUX",
|
||||
"/build/calendar.b2d70caa.js": "sha384-FS7Q9iCWHpo2Nzk0tmNvnuTbxJKLdbXFba9F6WcXfHbKFAS2Q+lW29H9Mv+ap5kA",
|
||||
"/build/chart.56f16a68.js": "sha384-SWnYjAbZ8OWEvTP+IZfGuBMWJIcH9OZWLYQ4p38KMPmqfufsc2zhzqWkALO3mCBO",
|
||||
"/build/calendar.36a92145.js": "sha384-ZPLxxbep6OnPeUiIeBXRE1iscZFgbNuTpn3rx2Jpf1KR2l6sxIuNsieDUskbmQ3m",
|
||||
"/build/calendar.d757753e.css": "sha384-cTmQMgHYjd2gfObFWmEUph7qQLCyXaIkneSf+bQ2mqVmZwqOB+pJOm/UYTyTjALJ",
|
||||
"/build/dashboard.6ce7ac9c.js": "sha384-5BOoyjZx/ZKr9IbogWfT/aBRHx7XA1fePADdUpUNlSF/rqVo3taXLLcrIoaDZ+8b",
|
||||
"/build/dashboard.9708ae5e.js": "sha384-QN7XIQuxFZu76sHVrgdpZL81+Q2VTwcgF3aI2CnpwYZiMoPbzrcTGfPL3RDy696t",
|
||||
"/build/dashboard.b7129fa1.css": "sha384-2nn5hLA+3YedgHYBpge62S8Losj8aoPwK9Zk9EvN1xEYatvOUQ7H3rIR2UUJAGOS",
|
||||
"/build/highlight.13d5d50e.js": "sha384-+RqBBPIzp5YQpbBeHgVNXzHiewK1PZtizPh4in1PlBU6RcT1bDleW3QSJMDeBnPO",
|
||||
"/build/highlight.0296a734.js": "sha384-vGFKI/KM+uyGIlzKsKhQh4w41hQteB4u14oRYqObMT03/YTZXq6Re5nUN6g3sGQH",
|
||||
"/build/highlight.98bf3927.css": "sha384-YgweSwDwN0dI4DEmh478xYVw/TewJYvCO1QTbWHpaHFDPuLrZvYMR0Tc+QfFxVPE"
|
||||
}
|
||||
}
|
||||
1
public/build/highlight.0296a734.js
Normal file
1
public/build/highlight.0296a734.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"build/app.css": "/build/app.c18ba3c6.css",
|
||||
"build/app.js": "/build/app.9662939e.js",
|
||||
"build/app-rtl.css": "/build/app-rtl.2003dce5.css",
|
||||
"build/app.css": "/build/app.e64b4d08.css",
|
||||
"build/app.js": "/build/app.41748d55.js",
|
||||
"build/app-rtl.css": "/build/app-rtl.6b5a0ee9.css",
|
||||
"build/app-rtl.js": "/build/app-rtl.15853b82.js",
|
||||
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
|
||||
"build/export-pdf.js": "/build/export-pdf.395749ab.js",
|
||||
@@ -9,14 +9,14 @@
|
||||
"build/invoice.js": "/build/invoice.42b319e4.js",
|
||||
"build/invoice-pdf.css": "/build/invoice-pdf.2b749265.css",
|
||||
"build/invoice-pdf.js": "/build/invoice-pdf.26d98626.js",
|
||||
"build/chart.js": "/build/chart.bafa38e7.js",
|
||||
"build/chart.js": "/build/chart.56f16a68.js",
|
||||
"build/calendar.css": "/build/calendar.d757753e.css",
|
||||
"build/calendar.js": "/build/calendar.b2d70caa.js",
|
||||
"build/calendar.js": "/build/calendar.36a92145.js",
|
||||
"build/dashboard.css": "/build/dashboard.b7129fa1.css",
|
||||
"build/dashboard.js": "/build/dashboard.6ce7ac9c.js",
|
||||
"build/dashboard.js": "/build/dashboard.9708ae5e.js",
|
||||
"build/highlight.css": "/build/highlight.98bf3927.css",
|
||||
"build/highlight.js": "/build/highlight.13d5d50e.js",
|
||||
"build/runtime.js": "/build/runtime.6c399d29.js",
|
||||
"build/highlight.js": "/build/highlight.0296a734.js",
|
||||
"build/runtime.js": "/build/runtime.684e9f6d.js",
|
||||
"build/fonts/fa-solid-900.ttf": "/build/fonts/fa-solid-900.2582b0e4.ttf",
|
||||
"build/fonts/fa-brands-400.ttf": "/build/fonts/fa-brands-400.1815e004.ttf",
|
||||
"build/fonts/fa-solid-900.woff2": "/build/fonts/fa-solid-900.2463b90d.woff2",
|
||||
|
||||
1
public/build/runtime.684e9f6d.js
Normal file
1
public/build/runtime.684e9f6d.js
Normal file
@@ -0,0 +1 @@
|
||||
!function(){"use strict";var e,r={},n={};function t(e){var o=n[e];if(void 0!==o)return o.exports;var i=n[e]={id:e,loaded:!1,exports:{}};return r[e].call(i.exports,i,i.exports,t),i.loaded=!0,i.exports}t.m=r,t.amdO={},e=[],t.O=function(r,n,o,i){if(!n){var u=1/0;for(l=0;l<e.length;l++){n=e[l][0],o=e[l][1],i=e[l][2];for(var f=!0,a=0;a<n.length;a++)(!1&i||u>=i)&&Object.keys(t.O).every(function(e){return t.O[e](n[a])})?n.splice(a--,1):(f=!1,i<u&&(u=i));if(f){e.splice(l--,1);var c=o();void 0!==c&&(r=c)}}return r}i=i||0;for(var l=e.length;l>0&&e[l-1][2]>i;l--)e[l]=e[l-1];e[l]=[n,o,i]},t.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(r,{a:r}),r},t.d=function(e,r){for(var n in r)t.o(r,n)&&!t.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:r[n]})},t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),t.hmd=function(e){return(e=Object.create(e)).children||(e.children=[]),Object.defineProperty(e,"exports",{enumerable:!0,set:function(){throw new Error("ES Modules may not assign module.exports or exports.*, Use ESM export syntax, instead: "+e.id)}}),e},t.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},t.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},function(){var e={121:0};t.O.j=function(r){return 0===e[r]};var r=function(r,n){var o,i,u=n[0],f=n[1],a=n[2],c=0;if(u.some(function(r){return 0!==e[r]})){for(o in f)t.o(f,o)&&(t.m[o]=f[o]);if(a)var l=a(t)}for(r&&r(n);c<u.length;c++)i=u[c],t.o(e,i)&&e[i]&&e[i][0](),e[i]=0;return t.O(l)},n=self.webpackChunkkimai=self.webpackChunkkimai||[];n.forEach(r.bind(null,0)),n.push=r.bind(null,n.push.bind(n))}()}();
|
||||
@@ -1 +0,0 @@
|
||||
!function(){"use strict";var e,r={},n={};function t(e){var o=n[e];if(void 0!==o)return o.exports;var i=n[e]={id:e,loaded:!1,exports:{}};return r[e].call(i.exports,i,i.exports,t),i.loaded=!0,i.exports}t.m=r,t.amdO={},e=[],t.O=function(r,n,o,i){if(!n){var u=1/0;for(l=0;l<e.length;l++){n=e[l][0],o=e[l][1],i=e[l][2];for(var f=!0,a=0;a<n.length;a++)(!1&i||u>=i)&&Object.keys(t.O).every((function(e){return t.O[e](n[a])}))?n.splice(a--,1):(f=!1,i<u&&(u=i));if(f){e.splice(l--,1);var c=o();void 0!==c&&(r=c)}}return r}i=i||0;for(var l=e.length;l>0&&e[l-1][2]>i;l--)e[l]=e[l-1];e[l]=[n,o,i]},t.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(r,{a:r}),r},t.d=function(e,r){for(var n in r)t.o(r,n)&&!t.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:r[n]})},t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),t.hmd=function(e){return(e=Object.create(e)).children||(e.children=[]),Object.defineProperty(e,"exports",{enumerable:!0,set:function(){throw new Error("ES Modules may not assign module.exports or exports.*, Use ESM export syntax, instead: "+e.id)}}),e},t.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},t.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},function(){var e={121:0};t.O.j=function(r){return 0===e[r]};var r=function(r,n){var o,i,u=n[0],f=n[1],a=n[2],c=0;if(u.some((function(r){return 0!==e[r]}))){for(o in f)t.o(f,o)&&(t.m[o]=f[o]);if(a)var l=a(t)}for(r&&r(n);c<u.length;c++)i=u[c],t.o(e,i)&&e[i]&&e[i][0](),e[i]=0;return t.O(l)},n=self.webpackChunkkimai=self.webpackChunkkimai||[];n.forEach(r.bind(null,0)),n.push=r.bind(null,n.push.bind(n))}()}();
|
||||
@@ -25,7 +25,7 @@
|
||||
],
|
||||
"scope": "./",
|
||||
"start_url": "./",
|
||||
"theme-color": "#1d273b",
|
||||
"theme-color": "#262626",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
@@ -43,8 +43,8 @@ final class InvoiceController extends BaseApiController
|
||||
#[IsGranted('view_invoice')]
|
||||
#[OA\Response(response: 200, description: 'Returns a collection of invoices', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/InvoiceCollection')))]
|
||||
#[Route(methods: ['GET'], path: '', name: 'get_invoices')]
|
||||
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records after this date will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
|
||||
#[Rest\QueryParam(name: 'end', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records before this date will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
|
||||
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only invoices created at or after this date-time will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
|
||||
#[Rest\QueryParam(name: 'end', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only invoices created before or at this date-time will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
|
||||
#[Rest\QueryParam(name: 'customers', map: true, requirements: '\d+', strict: true, nullable: true, default: [], description: 'List of customer IDs to filter, e.g.: customers[]=1&customers[]=2')]
|
||||
#[Rest\QueryParam(name: 'status', map: true, requirements: 'pending|paid|canceled|new', strict: true, nullable: true, default: [], description: 'Invoice status: pending, paid, canceled, new. Default: all')]
|
||||
#[Rest\QueryParam(name: 'page', requirements: '\d+', strict: true, nullable: true, description: 'The page to display, renders a 404 if not found (default: 1)')]
|
||||
|
||||
@@ -86,8 +86,8 @@ final class TimesheetController extends BaseApiController
|
||||
#[Rest\QueryParam(name: 'tags', map: true, strict: true, nullable: true, default: [], description: 'List of tag names, e.g. tags[]=bar&tags[]=foo')]
|
||||
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|begin|end|rate', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, begin, end, rate (default: begin)')]
|
||||
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: DESC)')]
|
||||
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records after this date will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
|
||||
#[Rest\QueryParam(name: 'end', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records before this date will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
|
||||
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records started at or after this date-time will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
|
||||
#[Rest\QueryParam(name: 'end', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records started at or before this date-time will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
|
||||
#[Rest\QueryParam(name: 'exported', requirements: '0|1', strict: true, nullable: true, description: 'Use this flag if you want to filter for export state. Allowed values: 0=not exported, 1=exported (default: all)')]
|
||||
#[Rest\QueryParam(name: 'active', requirements: '0|1', strict: true, nullable: true, description: 'Filter for running/active records. Allowed values: 0=stopped, 1=active (default: all)')]
|
||||
#[Rest\QueryParam(name: 'billable', requirements: '0|1', strict: true, nullable: true, description: 'Filter for non-/billable records. Allowed values: 0=non-billable, 1=billable (default: all)')]
|
||||
@@ -380,7 +380,7 @@ final class TimesheetController extends BaseApiController
|
||||
#[IsGranted('view_own_timesheet')]
|
||||
#[OA\Response(response: 200, description: 'Returns a collection of recent user activities (always the latest entry of a unique working set grouped by customer, project and activity)', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TimesheetCollectionExpanded')))]
|
||||
#[Route(methods: ['GET'], path: '/recent', name: 'recent_timesheet')]
|
||||
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records after this date will be included. Default: today - 1 year (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
|
||||
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records started at or after this date will be included. Default: today - 1 year (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
|
||||
#[Rest\QueryParam(name: 'size', requirements: '\d+', strict: true, nullable: true, description: 'The amount of entries (default: 10)')]
|
||||
public function recentAction(ParamFetcherInterface $paramFetcher): Response
|
||||
{
|
||||
|
||||
@@ -47,6 +47,8 @@ final class TranslationCommand extends Command
|
||||
->addOption('fill-empty', null, InputOption::VALUE_NONE, 'Pre-fills empty translations with the english version')
|
||||
->addOption('delete-empty', null, InputOption::VALUE_NONE, 'Delete all empty keys and files which have no translated key at all')
|
||||
->addOption('move-resname', null, InputOption::VALUE_REQUIRED, 'Move a resname from one file to another (needs "source" and "target" options)')
|
||||
->addOption('copy-resname', null, InputOption::VALUE_REQUIRED, 'Copy a resname within one file (needs "source" option)')
|
||||
->addOption('target-resname', null, InputOption::VALUE_REQUIRED, 'Target resname when copying (needs "source" option)')
|
||||
->addOption('move-all', null, InputOption::VALUE_NONE, 'Move all keys from one file to another (needs "source" and "target" options)')
|
||||
->addOption('source', null, InputOption::VALUE_REQUIRED, 'Single source file to use')
|
||||
->addOption('only-core', null, InputOption::VALUE_NONE, 'Do not include plugin and theme directories')
|
||||
@@ -150,6 +152,21 @@ final class TranslationCommand extends Command
|
||||
return $this->moveResname($io, $moveResname, $sources, $targets);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Move resname from source to target
|
||||
// ==========================================================================
|
||||
$copyResname = $input->getOption('copy-resname');
|
||||
$targetResname = $input->getOption('target-resname');
|
||||
if (\is_string($copyResname)) {
|
||||
if (!\is_string($targetResname)) {
|
||||
$io->error('To copy a resname, we need a target-resname');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
return $this->copyResname($io, $copyResname, $targetResname, $sources);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Move all keys from source to target
|
||||
// ==========================================================================
|
||||
@@ -491,7 +508,7 @@ final class TranslationCommand extends Command
|
||||
\sprintf('Missing english translation for key: %s in file %s', $key, $file)
|
||||
);
|
||||
}
|
||||
$unit->target[0] = $translations[$key];
|
||||
$unit->target[0] = $translations[$key]; // @phpstan-ignore-line
|
||||
$unit->target['state'] = 'needs-translation';
|
||||
$foundEmpty = true;
|
||||
}
|
||||
@@ -654,6 +671,62 @@ final class TranslationCommand extends Command
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string> $sources
|
||||
*/
|
||||
private function copyResname(SymfonyStyle $io, string $resname, string $target, array $sources): int
|
||||
{
|
||||
foreach ($sources as $source) {
|
||||
$tmp = basename($source);
|
||||
$pos = strpos($tmp, '.');
|
||||
if ($pos === false) {
|
||||
$io->error('Unexpected filename: ' . $source);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$sourceDocument = new \DOMDocument('1.0');
|
||||
$sourceDocument->load($source);
|
||||
|
||||
$copiedNode = false;
|
||||
|
||||
/** @var \DOMElement $element */
|
||||
foreach ($sourceDocument->getElementsByTagName('trans-unit') as $element) {
|
||||
if (!$element->hasAttribute('resname')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $element->getAttribute('resname');
|
||||
|
||||
if ($key === $resname) {
|
||||
$newElement = clone $element;
|
||||
$newElement->setAttribute('resname', $target);
|
||||
foreach ($newElement->childNodes->getIterator() as $child) {
|
||||
if ($child->nodeName === 'source') {
|
||||
$child->textContent = $target;
|
||||
}
|
||||
}
|
||||
$newElement->setAttribute('id', $this->generateId($target));
|
||||
|
||||
$newNode = $sourceDocument->importNode($newElement, true);
|
||||
$sourceDocument->documentElement->firstElementChild->firstElementChild->appendChild($newNode); // @phpstan-ignore-line
|
||||
$copiedNode = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($copiedNode) {
|
||||
$xmlDocument = new \DOMDocument('1.0');
|
||||
$xmlDocument->preserveWhiteSpace = false;
|
||||
$xmlDocument->formatOutput = true;
|
||||
$xmlDocument->loadXML($sourceDocument->saveXML()); // @phpstan-ignore-line
|
||||
file_put_contents($source, $xmlDocument->saveXML());
|
||||
}
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string> $sources
|
||||
* @param array<string> $targets
|
||||
|
||||
@@ -17,11 +17,11 @@ final class Constants
|
||||
/**
|
||||
* The current release version
|
||||
*/
|
||||
public const VERSION = '2.45.0';
|
||||
public const VERSION = '2.47.0';
|
||||
/**
|
||||
* The current release: major * 10000 + minor * 100 + patch
|
||||
*/
|
||||
public const VERSION_ID = 24500;
|
||||
public const VERSION_ID = 24700;
|
||||
/**
|
||||
* The software name
|
||||
*/
|
||||
|
||||
@@ -90,7 +90,7 @@ final class CustomerController extends AbstractController
|
||||
$table->addColumn('company', ['class' => 'd-none']);
|
||||
$table->addColumn('vat_id', ['class' => 'd-none w-min']);
|
||||
$table->addColumn('contact', ['class' => 'd-none']);
|
||||
$table->addColumn('address', ['class' => 'd-none']);
|
||||
$table->addColumn('city', ['class' => 'd-none']);
|
||||
$table->addColumn('country', ['class' => 'd-none w-min']);
|
||||
$table->addColumn('currency', ['class' => 'd-none w-min']);
|
||||
$table->addColumn('phone', ['class' => 'd-none']);
|
||||
|
||||
@@ -61,18 +61,15 @@ use Twig\Environment;
|
||||
final class InvoiceController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ServiceInvoice $service,
|
||||
private readonly InvoiceTemplateRepository $templateRepository,
|
||||
private readonly InvoiceRepository $invoiceRepository,
|
||||
private readonly EventDispatcherInterface $dispatcher
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route(path: '/', name: 'invoice', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('create_invoice')]
|
||||
public function indexAction(Request $request, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
public function indexAction(Request $request, ServiceInvoice $service, InvoiceTemplateRepository $templateRepository): Response
|
||||
{
|
||||
if (!$this->templateRepository->hasTemplate()) {
|
||||
if (!$templateRepository->hasTemplate()) {
|
||||
if ($this->isGranted('manage_invoice_template')) {
|
||||
return $this->redirectToRoute('admin_invoice_template_create');
|
||||
}
|
||||
@@ -95,7 +92,7 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
if ($form->isValid() && $query->getTemplate() !== null) {
|
||||
try {
|
||||
$models = $this->service->createModels($query);
|
||||
$models = $service->createModels($query);
|
||||
$searched = true;
|
||||
} catch (Exception $ex) {
|
||||
$this->flashUpdateException($ex);
|
||||
@@ -139,12 +136,8 @@ final class InvoiceController extends AbstractController
|
||||
#[Route(path: '/preview/{customer}/{token}', name: 'invoice_preview', methods: ['GET'])]
|
||||
#[IsGranted('create_invoice')]
|
||||
#[IsGranted('access', 'customer')]
|
||||
public function previewAction(Customer $customer, string $token, Request $request): Response
|
||||
public function previewAction(Customer $customer, string $token, Request $request, ServiceInvoice $service): Response
|
||||
{
|
||||
if (!$this->templateRepository->hasTemplate()) {
|
||||
return $this->redirectToRoute('invoice');
|
||||
}
|
||||
|
||||
if (!$this->isCsrfTokenValid('invoice.preview', $token)) {
|
||||
$this->flashError('action.csrf.error');
|
||||
|
||||
@@ -164,10 +157,10 @@ final class InvoiceController extends AbstractController
|
||||
if ($form->isValid()) {
|
||||
try {
|
||||
$query->setCustomers([$customer]);
|
||||
$model = $this->service->createModel($query);
|
||||
$model = $service->createModel($query);
|
||||
$model->setPreview(true);
|
||||
|
||||
return $this->service->renderInvoice($model, $this->dispatcher, true);
|
||||
return $service->renderInvoice($model, $this->dispatcher, true);
|
||||
} catch (Exception $ex) {
|
||||
$this->flashUpdateException($ex);
|
||||
}
|
||||
@@ -181,12 +174,8 @@ final class InvoiceController extends AbstractController
|
||||
#[Route(path: '/save-invoice/{customer}/{token}', name: 'invoice_create', methods: ['GET'])]
|
||||
#[IsGranted('create_invoice')]
|
||||
#[IsGranted('access', 'customer')]
|
||||
public function createInvoiceAction(Customer $customer, string $token, Request $request, CustomerRepository $customerRepository): Response
|
||||
public function createInvoiceAction(Customer $customer, string $token, Request $request, CustomerRepository $customerRepository, ServiceInvoice $service): Response
|
||||
{
|
||||
if (!$this->templateRepository->hasTemplate()) {
|
||||
return $this->redirectToRoute('invoice');
|
||||
}
|
||||
|
||||
if (!$this->isCsrfTokenValid('invoice.create', $token)) {
|
||||
$this->flashError('action.csrf.error');
|
||||
|
||||
@@ -203,7 +192,7 @@ final class InvoiceController extends AbstractController
|
||||
if ($form->isValid()) {
|
||||
try {
|
||||
$query->setCustomers([$customer]);
|
||||
$model = $this->service->createModel($query);
|
||||
$model = $service->createModel($query);
|
||||
|
||||
// save default template for customer if not yet set
|
||||
if ($customer->getInvoiceTemplate() === null) {
|
||||
@@ -211,7 +200,7 @@ final class InvoiceController extends AbstractController
|
||||
$customerRepository->saveCustomer($customer);
|
||||
}
|
||||
|
||||
$invoice = $this->service->createInvoice($model, $this->dispatcher);
|
||||
$invoice = $service->createInvoice($model, $this->dispatcher);
|
||||
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
@@ -229,7 +218,7 @@ final class InvoiceController extends AbstractController
|
||||
#[Route(path: '/change-status/{id}/{status}/{token}', name: 'admin_invoice_status', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('create_invoice')]
|
||||
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')]
|
||||
public function changeStatusAction(Invoice $invoice, string $status, string $token, Request $request, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
public function changeStatusAction(Invoice $invoice, string $status, string $token, Request $request, CsrfTokenManagerInterface $csrfTokenManager, ServiceInvoice $service): Response
|
||||
{
|
||||
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.status', $token))) {
|
||||
$this->flashError('action.csrf.error');
|
||||
@@ -254,7 +243,7 @@ final class InvoiceController extends AbstractController
|
||||
}
|
||||
|
||||
try {
|
||||
$this->service->changeInvoiceStatus($invoice, $status);
|
||||
$service->changeInvoiceStatus($invoice, $status);
|
||||
$this->flashSuccess('action.update.success');
|
||||
} catch (Exception $ex) {
|
||||
$this->flashUpdateException($ex);
|
||||
@@ -266,14 +255,14 @@ final class InvoiceController extends AbstractController
|
||||
#[Route(path: '/edit/{id}', name: 'admin_invoice_edit', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('create_invoice')]
|
||||
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')]
|
||||
public function editAction(Invoice $invoice, Request $request): Response
|
||||
public function editAction(Invoice $invoice, Request $request, InvoiceRepository $invoiceRepository): Response
|
||||
{
|
||||
$form = $this->createInvoiceEditForm($invoice);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
try {
|
||||
$this->invoiceRepository->saveInvoice($invoice);
|
||||
$invoiceRepository->saveInvoice($invoice);
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute('admin_invoice_list');
|
||||
@@ -292,7 +281,7 @@ final class InvoiceController extends AbstractController
|
||||
#[Route(path: '/delete/{id}/{token}', name: 'admin_invoice_delete', methods: ['GET'])]
|
||||
#[IsGranted('delete_invoice')]
|
||||
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')]
|
||||
public function deleteInvoiceAction(Invoice $invoice, string $token, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
public function deleteInvoiceAction(Invoice $invoice, string $token, CsrfTokenManagerInterface $csrfTokenManager, ServiceInvoice $service): Response
|
||||
{
|
||||
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.status', $token))) {
|
||||
$this->flashError('action.csrf.error');
|
||||
@@ -303,7 +292,7 @@ final class InvoiceController extends AbstractController
|
||||
$csrfTokenManager->refreshToken('invoice.status');
|
||||
|
||||
try {
|
||||
$this->service->deleteInvoice($invoice, $this->dispatcher);
|
||||
$service->deleteInvoice($invoice, $this->dispatcher);
|
||||
$this->flashSuccess('action.delete.success');
|
||||
} catch (Exception $ex) {
|
||||
$this->flashDeleteException($ex);
|
||||
@@ -315,9 +304,9 @@ final class InvoiceController extends AbstractController
|
||||
#[Route(path: '/download/{id}', name: 'admin_invoice_download', methods: ['GET'])]
|
||||
#[IsGranted('view_invoice')]
|
||||
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')]
|
||||
public function downloadAction(Invoice $invoice): Response
|
||||
public function downloadAction(Invoice $invoice, ServiceInvoice $service): Response
|
||||
{
|
||||
$file = $this->service->getInvoiceFile($invoice);
|
||||
$file = $service->getInvoiceFile($invoice);
|
||||
|
||||
if (null === $file) {
|
||||
throw $this->createNotFoundException(
|
||||
@@ -330,12 +319,12 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
#[Route(path: '/show/{page}', defaults: ['page' => 1], requirements: ['page' => '[1-9]\d*'], name: 'admin_invoice_list', methods: ['GET'])]
|
||||
#[IsGranted('view_invoice')]
|
||||
public function showInvoicesAction(Request $request, int $page): Response
|
||||
public function showInvoicesAction(Request $request, int $page, InvoiceRepository $invoiceRepository): Response
|
||||
{
|
||||
$invoice = null;
|
||||
|
||||
if (null !== ($id = $request->query->get('id'))) {
|
||||
$invoice = $this->invoiceRepository->find($id);
|
||||
$invoice = $invoiceRepository->find($id);
|
||||
}
|
||||
|
||||
$query = new InvoiceArchiveQuery();
|
||||
@@ -347,7 +336,7 @@ final class InvoiceController extends AbstractController
|
||||
return $this->redirectToRoute('admin_invoice_list');
|
||||
}
|
||||
|
||||
$entries = $this->invoiceRepository->getPagerfantaForQuery($query);
|
||||
$entries = $invoiceRepository->getPagerfantaForQuery($query);
|
||||
$metaColumns = $this->findMetaColumns($query);
|
||||
|
||||
$table = new DataTable('invoices', $query);
|
||||
@@ -389,7 +378,7 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
#[Route(path: '/export', name: 'invoice_export', methods: ['GET'])]
|
||||
#[IsGranted('view_invoice')]
|
||||
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter): Response
|
||||
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter, InvoiceRepository $invoiceRepository, InvoiceTemplateRepository $templateRepository): Response
|
||||
{
|
||||
$query = new InvoiceArchiveQuery();
|
||||
$query->setCurrentUser($this->getUser());
|
||||
@@ -398,7 +387,7 @@ final class InvoiceController extends AbstractController
|
||||
$form->setData($query);
|
||||
$form->submit($request->query->all(), false);
|
||||
|
||||
$entries = $this->invoiceRepository->getInvoicesForQuery($query);
|
||||
$entries = $invoiceRepository->getInvoicesForQuery($query);
|
||||
|
||||
$spreadsheet = $exporter->export(
|
||||
Invoice::class,
|
||||
@@ -412,12 +401,12 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
#[Route(path: '/template/{page}', requirements: ['page' => '[1-9]\d*'], defaults: ['page' => 1], name: 'admin_invoice_template', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('manage_invoice_template')]
|
||||
public function listTemplateAction(int $page): Response
|
||||
public function listTemplateAction(int $page, InvoiceTemplateRepository $templateRepository): Response
|
||||
{
|
||||
$query = new BaseQuery();
|
||||
$query->setPage($page);
|
||||
|
||||
$entries = $this->templateRepository->getPagerfantaForQuery($query);
|
||||
$entries = $templateRepository->getPagerfantaForQuery($query);
|
||||
|
||||
$table = new DataTable('invoice_template', $query);
|
||||
$table->setPagination($entries);
|
||||
@@ -449,16 +438,16 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
#[Route(path: '/template/{id}/edit', name: 'admin_invoice_template_edit', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('manage_invoice_template')]
|
||||
public function editTemplateAction(InvoiceTemplate $template, Request $request): Response
|
||||
public function editTemplateAction(InvoiceTemplate $template, Request $request, InvoiceTemplateRepository $templateRepository): Response
|
||||
{
|
||||
return $this->renderTemplateForm($template, $request);
|
||||
return $this->renderTemplateForm($template, $request, $templateRepository);
|
||||
}
|
||||
|
||||
#[Route(path: '/document_download/{document}', name: 'admin_invoice_document_download', methods: ['GET'])]
|
||||
#[IsGranted('upload_invoice_template')]
|
||||
public function downloadDocument(string $document, Environment $twig): Response
|
||||
public function downloadDocument(string $document, ServiceInvoice $service): Response
|
||||
{
|
||||
$event = new InvoiceDocumentsEvent($this->service->getDocuments(true));
|
||||
$event = new InvoiceDocumentsEvent($service->getDocuments(true));
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
foreach ($event->getInvoiceDocuments() as $doc) {
|
||||
@@ -472,7 +461,7 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
#[Route(path: '/document_upload', name: 'admin_invoice_document_upload', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('upload_invoice_template')]
|
||||
public function uploadDocumentAction(Request $request, string $projectDirectory, InvoiceDocumentRepository $documentRepository, Environment $twig, SystemConfiguration $systemConfiguration): Response
|
||||
public function uploadDocumentAction(Request $request, string $projectDirectory, InvoiceDocumentRepository $documentRepository, Environment $twig, SystemConfiguration $systemConfiguration, ServiceInvoice $service, InvoiceTemplateRepository $templateRepository): Response
|
||||
{
|
||||
$dir = $documentRepository->getUploadDirectory();
|
||||
$invoiceDir = $dir;
|
||||
@@ -484,11 +473,11 @@ final class InvoiceController extends AbstractController
|
||||
$invoiceDir = rtrim($invoiceDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
|
||||
|
||||
$used = [];
|
||||
foreach ($this->templateRepository->findAll() as $template) {
|
||||
foreach ($templateRepository->findAll() as $template) {
|
||||
$used[$template->getRenderer()] = $template;
|
||||
}
|
||||
|
||||
$event = new InvoiceDocumentsEvent($this->service->getDocuments(true));
|
||||
$event = new InvoiceDocumentsEvent($service->getDocuments(true));
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
$documents = [];
|
||||
@@ -606,7 +595,7 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
#[Route(path: '/document/{id}/delete/{token}', name: 'invoice_document_delete', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('manage_invoice_template')]
|
||||
public function deleteDocument(string $id, string $token, CsrfTokenManagerInterface $csrfTokenManager, InvoiceDocumentRepository $documentRepository): Response
|
||||
public function deleteDocument(string $id, string $token, CsrfTokenManagerInterface $csrfTokenManager, InvoiceDocumentRepository $documentRepository, InvoiceTemplateRepository $templateRepository): Response
|
||||
{
|
||||
$document = $documentRepository->findByName($id);
|
||||
if ($document === null) {
|
||||
@@ -629,7 +618,7 @@ final class InvoiceController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->templateRepository->findAll() as $template) {
|
||||
foreach ($templateRepository->findAll() as $template) {
|
||||
if ($template->getRenderer() === $id) {
|
||||
$this->flashError('Document is used and cannot be deleted.');
|
||||
|
||||
@@ -649,19 +638,19 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
#[Route(path: '/template/create/{id}', name: 'admin_invoice_template_copy', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('manage_invoice_template')]
|
||||
public function copyTemplateAction(Request $request, InvoiceTemplate $copyFrom): Response
|
||||
public function copyTemplateAction(Request $request, InvoiceTemplate $copyFrom, InvoiceTemplateRepository $templateRepository): Response
|
||||
{
|
||||
return $this->createTemplate($request, $copyFrom);
|
||||
return $this->createTemplate($request, $templateRepository, $copyFrom);
|
||||
}
|
||||
|
||||
#[Route(path: '/template/create', name: 'admin_invoice_template_create', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('manage_invoice_template')]
|
||||
public function createTemplateAction(Request $request): Response
|
||||
public function createTemplateAction(Request $request, InvoiceTemplateRepository $templateRepository): Response
|
||||
{
|
||||
return $this->createTemplate($request, null);
|
||||
return $this->createTemplate($request, $templateRepository, null);
|
||||
}
|
||||
|
||||
private function createTemplate(Request $request, ?InvoiceTemplate $copyFrom = null): Response
|
||||
private function createTemplate(Request $request, InvoiceTemplateRepository $templateRepository, ?InvoiceTemplate $copyFrom = null): Response
|
||||
{
|
||||
$template = new InvoiceTemplate();
|
||||
$template->setLanguage($request->getLocale());
|
||||
@@ -671,12 +660,12 @@ final class InvoiceController extends AbstractController
|
||||
$template->setName($copyFrom->getName() . ' (1)');
|
||||
}
|
||||
|
||||
return $this->renderTemplateForm($template, $request);
|
||||
return $this->renderTemplateForm($template, $request, $templateRepository);
|
||||
}
|
||||
|
||||
#[Route(path: '/template/{id}/delete/{csrfToken}', name: 'admin_invoice_template_delete', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('manage_invoice_template')]
|
||||
public function deleteTemplate(InvoiceTemplate $template, string $csrfToken, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
public function deleteTemplate(InvoiceTemplate $template, string $csrfToken, CsrfTokenManagerInterface $csrfTokenManager, InvoiceTemplateRepository $templateRepository): Response
|
||||
{
|
||||
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.delete_template', $csrfToken))) {
|
||||
$this->flashError('action.csrf.error');
|
||||
@@ -687,7 +676,7 @@ final class InvoiceController extends AbstractController
|
||||
$csrfTokenManager->refreshToken('invoice.delete_template');
|
||||
|
||||
try {
|
||||
$this->templateRepository->removeTemplate($template);
|
||||
$templateRepository->removeTemplate($template);
|
||||
$this->flashSuccess('action.delete.success');
|
||||
} catch (Exception $ex) {
|
||||
$this->flashDeleteException($ex);
|
||||
@@ -727,7 +716,7 @@ final class InvoiceController extends AbstractController
|
||||
$this->flashError('action.update.error', $err);
|
||||
}
|
||||
|
||||
private function renderTemplateForm(InvoiceTemplate $template, Request $request): Response
|
||||
private function renderTemplateForm(InvoiceTemplate $template, Request $request, InvoiceTemplateRepository $templateRepository): Response
|
||||
{
|
||||
$event = new InvoiceTemplateMetaDefinitionEvent($template);
|
||||
$this->dispatcher->dispatch($event);
|
||||
@@ -738,7 +727,7 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
if ($editForm->isSubmitted() && $editForm->isValid()) {
|
||||
try {
|
||||
$this->templateRepository->saveTemplate($template);
|
||||
$templateRepository->saveTemplate($template);
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute('admin_invoice_template');
|
||||
|
||||
@@ -23,7 +23,8 @@ trait ColorTrait
|
||||
* The assigned color in HTML hex format, e.g. #dd1d00
|
||||
*/
|
||||
#[ORM\Column(name: 'color', type: Types::STRING, length: 7, nullable: true)]
|
||||
#[Serializer\Exclude]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'color')]
|
||||
#[Constraints\HexColor]
|
||||
private ?string $color = null;
|
||||
@@ -50,10 +51,10 @@ trait ColorTrait
|
||||
abstract public function getName(): ?string;
|
||||
|
||||
/**
|
||||
* Internal value: this color will never be empty and is generated by the tag name if not set explicit.
|
||||
* Color will never be empty and is generated from the entity tag name if not set explicit.
|
||||
*/
|
||||
#[Serializer\VirtualProperty]
|
||||
#[Serializer\SerializedName('color')]
|
||||
#[Serializer\SerializedName('color-safe')]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
public function getColorSafe(): string
|
||||
{
|
||||
|
||||
@@ -231,7 +231,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
#[Assert\Length(max: 50)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Customer_Entity'])]
|
||||
#[Exporter\Expose(label: 'buyer_reference')]
|
||||
#[Exporter\Expose(label: 'buyerReference')]
|
||||
private ?string $buyerReference = null;
|
||||
|
||||
public function __construct(string $name)
|
||||
|
||||
@@ -12,8 +12,8 @@ namespace App\Entity;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
|
||||
/**
|
||||
* @method getTags() array
|
||||
* @method getBreak() int
|
||||
* @method array<Tag> getTags()
|
||||
* @method int getBreak()
|
||||
*/
|
||||
interface ExportableItem
|
||||
{
|
||||
|
||||
@@ -88,6 +88,10 @@ class InvoiceTemplate implements EntityWithMetaFields
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'template', targetEntity: InvoiceTemplateMeta::class, cascade: ['persist'])]
|
||||
private Collection $meta;
|
||||
/**
|
||||
* @var array<Tax>
|
||||
*/
|
||||
private array $taxRates = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -268,23 +272,36 @@ class InvoiceTemplate implements EntityWithMetaFields
|
||||
$this->language = $language;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<Tax> $taxRates
|
||||
*/
|
||||
public function setTaxRates(array $taxRates): void
|
||||
{
|
||||
$this->taxRates = $taxRates;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Tax[]
|
||||
*/
|
||||
public function getTaxRates(): array
|
||||
{
|
||||
// TODO make me configurable via UI
|
||||
if (\count($this->taxRates) > 0) {
|
||||
return $this->taxRates;
|
||||
}
|
||||
|
||||
$tax = new Tax(
|
||||
TaxType::STANDARD,
|
||||
'VAT',
|
||||
$this->vat ?? 0.00
|
||||
$this->vat ?? 0.00,
|
||||
'vat',
|
||||
true,
|
||||
null
|
||||
);
|
||||
|
||||
return [$tax];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection|MetaTableTypeInterface[]
|
||||
* @return Collection<int, InvoiceTemplateMeta>
|
||||
*/
|
||||
public function getMetaFields(): Collection
|
||||
{
|
||||
|
||||
@@ -11,6 +11,10 @@ namespace App\Entity;
|
||||
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
|
||||
/**
|
||||
* @method null|string getSection()
|
||||
* @method void setSection(?string $name)
|
||||
*/
|
||||
interface MetaTableTypeInterface
|
||||
{
|
||||
/**
|
||||
@@ -43,6 +47,8 @@ interface MetaTableTypeInterface
|
||||
/**
|
||||
* This will merge the current object with the values from the given $meta instance.
|
||||
* It should NOT update the name or value, but only the form settings.
|
||||
*
|
||||
* Settings from the given $meta object will win over the current ones.
|
||||
*/
|
||||
public function merge(MetaTableTypeInterface $meta): MetaTableTypeInterface;
|
||||
|
||||
@@ -132,6 +138,16 @@ interface MetaTableTypeInterface
|
||||
*/
|
||||
public function getOrder(): int;
|
||||
|
||||
/**
|
||||
* FIXME activate with 3.0
|
||||
*/
|
||||
//public function setSection(?string $section): void;
|
||||
|
||||
/**
|
||||
* FIXME activate with 3.0
|
||||
*/
|
||||
//public function getSection(): ?string;
|
||||
|
||||
/**
|
||||
* Whether true if this field is defined by a plugin, or false if it is a value stored in the database.
|
||||
*/
|
||||
|
||||
@@ -67,6 +67,7 @@ trait MetaTableTypeTrait
|
||||
*/
|
||||
private mixed $data = null;
|
||||
private bool $updated = false;
|
||||
private ?string $section = null;
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
@@ -193,13 +194,11 @@ trait MetaTableTypeTrait
|
||||
|
||||
public function merge(MetaTableTypeInterface $meta): MetaTableTypeInterface
|
||||
{
|
||||
$this
|
||||
->setConstraints($meta->getConstraints())
|
||||
->setIsRequired($meta->isRequired())
|
||||
->setIsVisible($meta->isVisible())
|
||||
->setOptions($meta->getOptions())
|
||||
->setOrder($meta->getOrder())
|
||||
;
|
||||
$this->setConstraints($meta->getConstraints());
|
||||
$this->setIsRequired($meta->isRequired());
|
||||
$this->setIsVisible($meta->isVisible());
|
||||
$this->setOptions($meta->getOptions());
|
||||
$this->setOrder($meta->getOrder());
|
||||
|
||||
if ($meta->getLabel() !== null) {
|
||||
$this->setLabel($meta->getLabel());
|
||||
@@ -209,6 +208,10 @@ trait MetaTableTypeTrait
|
||||
$this->setType($meta->getType());
|
||||
}
|
||||
|
||||
if ($meta->getSection() !== null) {
|
||||
$this->setSection($meta->getSection());
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -266,6 +269,16 @@ trait MetaTableTypeTrait
|
||||
}
|
||||
}
|
||||
|
||||
public function setSection(?string $section): void
|
||||
{
|
||||
$this->section = $section;
|
||||
}
|
||||
|
||||
public function getSection(): ?string
|
||||
{
|
||||
return $this->section;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this field is defined by a plugin or just a value stored in the database.
|
||||
*/
|
||||
|
||||
@@ -13,8 +13,10 @@ final class Tax
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TaxType $type,
|
||||
private readonly string $name = 'VAT',
|
||||
private readonly float $rate = 0.0,
|
||||
private readonly float $rate,
|
||||
private readonly string $name,
|
||||
private readonly bool $show,
|
||||
private readonly ?string $note,
|
||||
)
|
||||
{
|
||||
}
|
||||
@@ -33,4 +35,14 @@ final class Tax
|
||||
{
|
||||
return $this->rate;
|
||||
}
|
||||
|
||||
public function isShow(): bool
|
||||
{
|
||||
return $this->rate > 0.0 || $this->show;
|
||||
}
|
||||
|
||||
public function getNote(): ?string
|
||||
{
|
||||
return $this->note;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,14 +376,24 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is called from the "edit user preferences" form.
|
||||
* Therefor it just merges the values for existing preferences and adds new ones.
|
||||
* But it will NOT remove existing preferences or replace the underlying collection.
|
||||
*
|
||||
* @param iterable<UserPreference> $preferences
|
||||
*/
|
||||
public function setPreferences(iterable $preferences): User
|
||||
{
|
||||
$this->preferences = new ArrayCollection();
|
||||
|
||||
foreach ($preferences as $preference) {
|
||||
$this->addPreference($preference);
|
||||
if (($name = $preference->getName()) === null) {
|
||||
continue;
|
||||
}
|
||||
$p = $this->getPreference($name);
|
||||
if ($p === null) {
|
||||
$this->addPreference($preference);
|
||||
} else {
|
||||
$p->setValue($preference->getValue());
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
class WebhookConfiguration
|
||||
{
|
||||
private string $name;
|
||||
private string $url;
|
||||
private string $transport;
|
||||
private string $secret;
|
||||
private string $authentication;
|
||||
|
||||
public function __construct(
|
||||
string $name,
|
||||
string $url,
|
||||
string $transport,
|
||||
#[\SensitiveParameter]
|
||||
string $secret,
|
||||
string $authentication
|
||||
)
|
||||
{
|
||||
$this->name = $name;
|
||||
$this->url = $url;
|
||||
$this->transport = $transport;
|
||||
$this->secret = $secret;
|
||||
$this->authentication = $authentication;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function getUrl(): string
|
||||
{
|
||||
return $this->url;
|
||||
}
|
||||
|
||||
public function getTransport(): string
|
||||
{
|
||||
return $this->transport;
|
||||
}
|
||||
|
||||
public function getSecret(): string
|
||||
{
|
||||
return $this->secret;
|
||||
}
|
||||
|
||||
public function getAuthentication(): string
|
||||
{
|
||||
return $this->authentication;
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
class WebhookEvent
|
||||
{
|
||||
private string $name;
|
||||
private WebhookConfiguration $configuration;
|
||||
|
||||
public function __construct(string $name, WebhookConfiguration $configuration)
|
||||
{
|
||||
$this->name = $name;
|
||||
$this->configuration = $configuration;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function getConfiguration(): WebhookConfiguration
|
||||
{
|
||||
return $this->configuration;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,14 @@ use App\Invoice\RendererInterface;
|
||||
use App\Model\InvoiceDocument;
|
||||
use Symfony\Contracts\EventDispatcher\Event;
|
||||
|
||||
/**
|
||||
* Triggered right before an invoice is rendered.
|
||||
*
|
||||
* You can use this event to:
|
||||
* - add invoice hydrator
|
||||
* - read and change invoice model
|
||||
* - change tax rates
|
||||
*/
|
||||
final class InvoicePreRenderEvent extends Event
|
||||
{
|
||||
public function __construct(
|
||||
|
||||
@@ -24,7 +24,7 @@ use App\Repository\Query\ActivityQuery;
|
||||
use App\Repository\Query\CustomerQuery;
|
||||
use App\Repository\Query\ProjectQuery;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use App\Twig\SecurityPolicy\ExportPolicy;
|
||||
use App\Twig\SecurityPolicy\StrictPolicy;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Exclude;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -100,9 +100,12 @@ class HtmlRenderer implements ExportRendererInterface
|
||||
$summary = $this->calculateSummary($exportItems);
|
||||
|
||||
// enable basic security measures
|
||||
$sandbox = new SandboxExtension(new ExportPolicy());
|
||||
if (!$this->twig->hasExtension(SandboxExtension::class)) {
|
||||
$this->twig->addExtension(new SandboxExtension(new StrictPolicy()));
|
||||
}
|
||||
|
||||
$sandbox = $this->twig->getExtension(SandboxExtension::class);
|
||||
$sandbox->enableSandbox();
|
||||
$this->twig->addExtension($sandbox);
|
||||
|
||||
$content = $this->twig->render($this->getTemplate(), array_merge([
|
||||
'entries' => $exportItems,
|
||||
@@ -117,7 +120,11 @@ class HtmlRenderer implements ExportRendererInterface
|
||||
'userPreferences' => $userPreferences,
|
||||
], $this->getOptions($query)));
|
||||
|
||||
// allows to run in development mode, otherwise toolbar would be blocked
|
||||
$sandbox->disableSandbox();
|
||||
|
||||
$response = new Response();
|
||||
$response->headers->set('Content-Type', 'text/html');
|
||||
$response->setContent($content);
|
||||
|
||||
return $response;
|
||||
|
||||
@@ -17,7 +17,7 @@ use App\Pdf\PdfContext;
|
||||
use App\Pdf\PdfRendererTrait;
|
||||
use App\Project\ProjectStatisticService;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use App\Twig\SecurityPolicy\ExportPolicy;
|
||||
use App\Twig\SecurityPolicy\StrictPolicy;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Exclude;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Twig\Environment;
|
||||
@@ -103,9 +103,12 @@ class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface
|
||||
$summary = $this->calculateSummary($exportItems);
|
||||
|
||||
// enable basic security measures
|
||||
$sandbox = new SandboxExtension(new ExportPolicy());
|
||||
if (!$this->twig->hasExtension(SandboxExtension::class)) {
|
||||
$this->twig->addExtension(new SandboxExtension(new StrictPolicy()));
|
||||
}
|
||||
|
||||
$sandbox = $this->twig->getExtension(SandboxExtension::class);
|
||||
$sandbox->enableSandbox();
|
||||
$this->twig->addExtension($sandbox);
|
||||
|
||||
$content = $this->twig->render($this->getTemplate(), array_merge([
|
||||
'entries' => $exportItems,
|
||||
@@ -116,6 +119,8 @@ class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface
|
||||
'pdfContext' => $context
|
||||
], $this->getOptions($query)));
|
||||
|
||||
$sandbox->disableSandbox();
|
||||
|
||||
$pdfOptions = array_merge($context->getOptions(), $this->getPdfOptions());
|
||||
|
||||
$content = $this->converter->convertToPdf($content, $pdfOptions);
|
||||
|
||||
@@ -14,9 +14,9 @@ use App\Repository\Query\TimesheetQuery;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* TODO change interface for 3.0
|
||||
* @method getType() string
|
||||
* @method isInternal() bool
|
||||
* FIXME change interface for 3.0
|
||||
* @method string getType()
|
||||
* @method bool isInternal()
|
||||
*/
|
||||
interface ExportRendererInterface
|
||||
{
|
||||
|
||||
@@ -30,11 +30,10 @@ final class UserPreferencesCollectionType extends AbstractType
|
||||
$builder->addEventListener(
|
||||
FormEvents::PRE_SET_DATA,
|
||||
function (FormEvent $event): void {
|
||||
/** @var ArrayCollection<UserPreference> $collection */
|
||||
/** @var ArrayCollection<int, UserPreference> $collection */
|
||||
$collection = $event->getData();
|
||||
$preferences = new ArrayCollection();
|
||||
foreach ($collection as $collectionItem) {
|
||||
$collection->removeElement($collectionItem);
|
||||
|
||||
if (!($collectionItem instanceof UserPreference)) {
|
||||
continue;
|
||||
}
|
||||
@@ -44,8 +43,9 @@ final class UserPreferencesCollectionType extends AbstractType
|
||||
continue;
|
||||
}
|
||||
|
||||
$collection->set($collectionItem->getName(), $collectionItem);
|
||||
$preferences->set($collectionItem->getName(), clone $collectionItem);
|
||||
}
|
||||
$event->setData($preferences);
|
||||
},
|
||||
// must be a higher priority then the listener in UserPreferenceType
|
||||
100
|
||||
|
||||
@@ -31,6 +31,10 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
|
||||
$subtotal = $calculator->getSubtotal();
|
||||
$formatter = $model->getFormatter();
|
||||
$language = $template->getLanguage();
|
||||
if ($language === null) {
|
||||
throw new \InvalidArgumentException('InvoiceTemplate needs a language');
|
||||
}
|
||||
|
||||
$taxRows = $calculator->getTaxRows();
|
||||
|
||||
$vat = 0.00;
|
||||
@@ -48,7 +52,7 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
|
||||
'invoice.language' => $language, // since 1.9
|
||||
'invoice.currency_symbol' => $formatter->getCurrencySymbol($currency),
|
||||
'invoice.vat' => $vat, // @deprecated, use invoice.tax_rows instead
|
||||
'invoice.tax_hide' => $model->isHideZeroTax() && $tax === 0.00,
|
||||
'invoice.tax_hide' => $model->isHideZeroTax() && $tax === 0.00, // @deprecated, use invoice.tax_rows instead
|
||||
'invoice.tax' => $formatter->getFormattedMoney($tax, $currency), // @deprecated, use invoice.tax_rows instead
|
||||
'invoice.tax_nc' => $formatter->getFormattedMoney($tax, $currency, false), // @deprecated, use invoice.tax_rows instead
|
||||
'invoice.tax_plain' => $tax, // @deprecated, use invoice.tax_rows instead
|
||||
@@ -91,11 +95,17 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
|
||||
];
|
||||
|
||||
$values['invoice.tax_rows'] = [];
|
||||
$counter = 1;
|
||||
foreach ($taxRows as $taxRow) {
|
||||
$tax = $taxRow->getTax();
|
||||
$values['invoice.tax_rows'][] = [
|
||||
'type' => $taxRow->getTax()->getType()->value,
|
||||
'name' => $taxRow->getTax()->getName(),
|
||||
'rate' => $taxRow->getTax()->getRate(),
|
||||
'counter' => $counter++,
|
||||
'type' => $tax->getType()->value,
|
||||
'name' => $tax->getName(),
|
||||
'rate' => $tax->getRate(),
|
||||
'note' => $tax->getNote(),
|
||||
'show' => $tax->isShow(),
|
||||
'currency' => $currency,
|
||||
'amount' => $taxRow->getAmount(), // do not format, only available in twig anyway
|
||||
'base' => $taxRow->getBasePrice(), // do not format, only available in twig anyway
|
||||
];
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
|
||||
namespace App\Invoice;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
|
||||
|
||||
#[AutoconfigureTag]
|
||||
interface InvoiceItemHydrator
|
||||
{
|
||||
public function setInvoiceModel(InvoiceModel $model): void;
|
||||
|
||||
@@ -9,20 +9,10 @@
|
||||
|
||||
namespace App\Invoice;
|
||||
|
||||
use App\Activity\ActivityStatisticService;
|
||||
use App\Customer\CustomerStatisticService;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\ExportableItem;
|
||||
use App\Entity\InvoiceTemplate;
|
||||
use App\Entity\User;
|
||||
use App\Invoice\Hydrator\InvoiceItemDefaultHydrator;
|
||||
use App\Invoice\Hydrator\InvoiceModelActivityHydrator;
|
||||
use App\Invoice\Hydrator\InvoiceModelCustomerHydrator;
|
||||
use App\Invoice\Hydrator\InvoiceModelDefaultHydrator;
|
||||
use App\Invoice\Hydrator\InvoiceModelIssuerHydrator;
|
||||
use App\Invoice\Hydrator\InvoiceModelProjectHydrator;
|
||||
use App\Invoice\Hydrator\InvoiceModelUserHydrator;
|
||||
use App\Project\ProjectStatisticService;
|
||||
use App\Repository\Query\InvoiceQuery;
|
||||
use App\Timesheet\RateCalculator\RateCalculatorMode;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Exclude;
|
||||
@@ -43,7 +33,6 @@ final class InvoiceModel
|
||||
private ?NumberGeneratorInterface $generator = null;
|
||||
private \DateTimeInterface $invoiceDate;
|
||||
private ?User $user = null;
|
||||
private InvoiceFormatter $formatter;
|
||||
/**
|
||||
* @var InvoiceModelHydrator[]
|
||||
*/
|
||||
@@ -64,24 +53,13 @@ final class InvoiceModel
|
||||
* @internal use InvoiceModelFactory
|
||||
*/
|
||||
public function __construct(
|
||||
InvoiceFormatter $formatter,
|
||||
CustomerStatisticService $customerStatistic,
|
||||
ProjectStatisticService $projectStatistic,
|
||||
ActivityStatisticService $activityStatistic,
|
||||
private InvoiceFormatter $formatter,
|
||||
private readonly Customer $customer,
|
||||
private readonly InvoiceTemplate $template,
|
||||
private readonly RateCalculatorMode $rateCalculatorMode
|
||||
)
|
||||
{
|
||||
$this->invoiceDate = new \DateTimeImmutable();
|
||||
$this->formatter = $formatter;
|
||||
$this->addModelHydrator(new InvoiceModelDefaultHydrator());
|
||||
$this->addModelHydrator(new InvoiceModelCustomerHydrator($customerStatistic));
|
||||
$this->addModelHydrator(new InvoiceModelIssuerHydrator());
|
||||
$this->addModelHydrator(new InvoiceModelProjectHydrator($projectStatistic));
|
||||
$this->addModelHydrator(new InvoiceModelActivityHydrator($activityStatistic));
|
||||
$this->addModelHydrator(new InvoiceModelUserHydrator());
|
||||
$this->addItemHydrator(new InvoiceItemDefaultHydrator());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,27 +9,36 @@
|
||||
|
||||
namespace App\Invoice;
|
||||
|
||||
use App\Activity\ActivityStatisticService;
|
||||
use App\Customer\CustomerStatisticService;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\InvoiceTemplate;
|
||||
use App\Project\ProjectStatisticService;
|
||||
use App\Repository\Query\InvoiceQuery;
|
||||
use App\Timesheet\RateCalculator\RateCalculatorMode;
|
||||
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
|
||||
|
||||
final class InvoiceModelFactory
|
||||
{
|
||||
/**
|
||||
* @param iterable<InvoiceModelHydrator> $modelHydrators
|
||||
* @param iterable<InvoiceItemHydrator> $itemHydrators
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly CustomerStatisticService $customerStatisticService,
|
||||
private readonly ProjectStatisticService $projectStatisticService,
|
||||
private readonly ActivityStatisticService $activityStatisticService,
|
||||
private readonly RateCalculatorMode $rateCalculatorMode
|
||||
private readonly RateCalculatorMode $rateCalculatorMode,
|
||||
#[TaggedIterator(InvoiceModelHydrator::class)]
|
||||
private readonly iterable $modelHydrators,
|
||||
#[TaggedIterator(InvoiceItemHydrator::class)]
|
||||
private readonly iterable $itemHydrators,
|
||||
) {
|
||||
}
|
||||
|
||||
public function createModel(InvoiceFormatter $formatter, Customer $customer, InvoiceTemplate $template, InvoiceQuery $query): InvoiceModel
|
||||
{
|
||||
$model = new InvoiceModel($formatter, $this->customerStatisticService, $this->projectStatisticService, $this->activityStatisticService, $customer, $template, $this->rateCalculatorMode);
|
||||
$model = new InvoiceModel($formatter, $customer, $template, $this->rateCalculatorMode);
|
||||
foreach ($this->modelHydrators as $modelHydrator) {
|
||||
$model->addModelHydrator($modelHydrator);
|
||||
}
|
||||
foreach ($this->itemHydrators as $itemHydrator) {
|
||||
$model->addItemHydrator($itemHydrator);
|
||||
}
|
||||
|
||||
$model->setQuery($query);
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
|
||||
namespace App\Invoice;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
|
||||
|
||||
#[AutoconfigureTag]
|
||||
interface InvoiceModelHydrator
|
||||
{
|
||||
public function hydrate(InvoiceModel $model): array;
|
||||
|
||||
@@ -13,7 +13,7 @@ use App\Invoice\InvoiceModel;
|
||||
use App\Invoice\RendererInterface;
|
||||
use App\Model\InvoiceDocument;
|
||||
use App\Twig\LocaleFormatExtensions;
|
||||
use App\Twig\SecurityPolicy\InvoicePolicy;
|
||||
use App\Twig\SecurityPolicy\StrictPolicy;
|
||||
use Symfony\Bridge\Twig\Extension\TranslationExtension;
|
||||
use Symfony\Contracts\Translation\LocaleAwareInterface;
|
||||
use Twig\Environment;
|
||||
@@ -64,7 +64,7 @@ abstract class AbstractTwigRenderer implements RendererInterface
|
||||
}
|
||||
|
||||
if (!$twig->hasExtension(SandboxExtension::class)) {
|
||||
$twig->addExtension(new SandboxExtension(new InvoicePolicy()));
|
||||
$twig->addExtension(new SandboxExtension(new StrictPolicy()));
|
||||
}
|
||||
|
||||
$sandbox = $twig->getExtension(SandboxExtension::class);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Serializer;
|
||||
|
||||
use JMS\Serializer\Context;
|
||||
use JMS\Serializer\DeserializationContext;
|
||||
use JMS\Serializer\SerializationContext;
|
||||
use JMS\Serializer\Serializer as CoreSerializer;
|
||||
use JMS\Serializer\SerializerInterface as CoreSerializerInterface;
|
||||
|
||||
class Serializer implements SerializerInterface
|
||||
{
|
||||
public function __construct(private readonly CoreSerializerInterface $serializer)
|
||||
{
|
||||
}
|
||||
|
||||
private function prepareContext(Context $jmsContext, array $context = []): void
|
||||
{
|
||||
$groups = ['Default'];
|
||||
if (\array_key_exists('groups', $context)) {
|
||||
$groups = $context['groups'];
|
||||
}
|
||||
|
||||
$jmsContext->setGroups($groups);
|
||||
$jmsContext->enableMaxDepthChecks();
|
||||
}
|
||||
|
||||
public function toArray(mixed $data, array $context = []): array
|
||||
{
|
||||
$jmsContext = SerializationContext::create();
|
||||
$this->prepareContext($jmsContext, $context);
|
||||
|
||||
if ($this->serializer instanceof CoreSerializer) {
|
||||
return $this->serializer->toArray($data, $jmsContext);
|
||||
}
|
||||
|
||||
$json = $this->serializer->serialize($data, 'json', $jmsContext);
|
||||
|
||||
return json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
public function serialize(mixed $data, string $format, array $context = []): string
|
||||
{
|
||||
$jmsContext = SerializationContext::create();
|
||||
$this->prepareContext($jmsContext, $context);
|
||||
|
||||
return $this->serializer->serialize($data, $format, $jmsContext);
|
||||
}
|
||||
|
||||
public function deserialize(mixed $data, string $type, string $format, array $context = []): mixed
|
||||
{
|
||||
$jmsContext = DeserializationContext::create();
|
||||
$this->prepareContext($jmsContext, $context);
|
||||
|
||||
return $this->serializer->deserialize($data, $type, $format, $jmsContext);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Serializer;
|
||||
|
||||
use Symfony\Component\Serializer\SerializerInterface as BaseSerializerInterface;
|
||||
|
||||
interface SerializerInterface extends BaseSerializerInterface
|
||||
{
|
||||
public function toArray(mixed $data, array $context = []): array;
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Twig\SecurityPolicy;
|
||||
|
||||
use Twig\Sandbox\SecurityPolicyInterface;
|
||||
|
||||
final class ChainPolicy implements SecurityPolicyInterface
|
||||
{
|
||||
/** @var array<SecurityPolicyInterface> */
|
||||
private array $policies = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public function addPolicy(SecurityPolicyInterface $policy): void
|
||||
{
|
||||
$this->policies[] = $policy;
|
||||
}
|
||||
|
||||
public function checkSecurity($tags, $filters, $functions): void
|
||||
{
|
||||
foreach ($this->policies as $policy) {
|
||||
$policy->checkSecurity($tags, $filters, $functions);
|
||||
}
|
||||
}
|
||||
|
||||
public function checkMethodAllowed($obj, $method): void
|
||||
{
|
||||
foreach ($this->policies as $policy) {
|
||||
$policy->checkMethodAllowed($obj, $method);
|
||||
}
|
||||
}
|
||||
|
||||
public function checkPropertyAllowed($obj, $property): void
|
||||
{
|
||||
foreach ($this->policies as $policy) {
|
||||
$policy->checkPropertyAllowed($obj, $property);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Twig\SecurityPolicy;
|
||||
|
||||
use Twig\Sandbox\SecurityPolicyInterface;
|
||||
|
||||
/**
|
||||
* The Twig environment needs the sandbox extension, which itself needs a policy to start working.
|
||||
*/
|
||||
final class DefaultPolicy implements SecurityPolicyInterface
|
||||
{
|
||||
public function checkSecurity($tags, $filters, $functions): void
|
||||
{
|
||||
}
|
||||
|
||||
public function checkMethodAllowed($obj, $method): void
|
||||
{
|
||||
}
|
||||
|
||||
public function checkPropertyAllowed($obj, $property): void
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Twig\SecurityPolicy;
|
||||
|
||||
use Twig\Sandbox\SecurityPolicyInterface;
|
||||
|
||||
/**
|
||||
* Represents the security policy for custom Twig export templates.
|
||||
*/
|
||||
final class ExportPolicy implements SecurityPolicyInterface
|
||||
{
|
||||
private ChainPolicy $policy;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->policy = new ChainPolicy();
|
||||
$this->policy->addPolicy(new DefaultPolicy());
|
||||
}
|
||||
|
||||
public function checkSecurity($tags, $filters, $functions): void
|
||||
{
|
||||
$this->policy->checkSecurity($tags, $filters, $functions);
|
||||
}
|
||||
|
||||
public function checkMethodAllowed($obj, $method): void
|
||||
{
|
||||
$this->policy->checkMethodAllowed($obj, $method);
|
||||
}
|
||||
|
||||
public function checkPropertyAllowed($obj, $property): void
|
||||
{
|
||||
$this->policy->checkPropertyAllowed($obj, $property);
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Twig\SecurityPolicy;
|
||||
|
||||
use Twig\Markup;
|
||||
use Twig\Sandbox\SecurityNotAllowedFilterError;
|
||||
use Twig\Sandbox\SecurityNotAllowedFunctionError;
|
||||
use Twig\Sandbox\SecurityNotAllowedMethodError;
|
||||
use Twig\Sandbox\SecurityNotAllowedPropertyError;
|
||||
use Twig\Sandbox\SecurityNotAllowedTagError;
|
||||
use Twig\Sandbox\SecurityPolicyInterface;
|
||||
use Twig\Template;
|
||||
|
||||
/**
|
||||
* A blocking approach for Twig templates.
|
||||
*/
|
||||
final class ForbiddenPolicy implements SecurityPolicyInterface
|
||||
{
|
||||
/** @var array<string, array<string>> */
|
||||
private array $forbiddenMethods = [];
|
||||
|
||||
/**
|
||||
* @param array<string> $forbiddenTags
|
||||
* @param array<string> $forbiddenFilters
|
||||
* @param array<string, array<string>> $forbiddenMethods
|
||||
* @param array<string, array<string>> $forbiddenProperties
|
||||
* @param array<string> $forbiddenFunctions
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $forbiddenTags = [],
|
||||
private readonly array $forbiddenFilters = [],
|
||||
array $forbiddenMethods = [],
|
||||
private readonly array $forbiddenProperties = [],
|
||||
private readonly array $forbiddenFunctions = []
|
||||
)
|
||||
{
|
||||
$this->forbiddenMethods = [];
|
||||
foreach ($forbiddenMethods as $class => $m) {
|
||||
$this->forbiddenMethods[$class] = array_map(function ($value) { return strtr($value, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'); }, \is_array($m) ? $m : [$m]);
|
||||
}
|
||||
}
|
||||
|
||||
public function checkSecurity($tags, $filters, $functions): void
|
||||
{
|
||||
foreach ($tags as $tag) {
|
||||
if (\in_array($tag, $this->forbiddenTags)) {
|
||||
throw new SecurityNotAllowedTagError(\sprintf('Tag "%s" is not allowed.', $tag), $tag);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($filters as $filter) {
|
||||
if (\in_array($filter, $this->forbiddenFilters)) {
|
||||
throw new SecurityNotAllowedFilterError(\sprintf('Filter "%s" is not allowed.', $filter), $filter);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($functions as $function) {
|
||||
if (\in_array($function, $this->forbiddenFunctions)) {
|
||||
throw new SecurityNotAllowedFunctionError(\sprintf('Function "%s" is not allowed.', $function), $function);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function checkMethodAllowed($obj, $method): void
|
||||
{
|
||||
if ($obj instanceof Template || $obj instanceof Markup) { // @phpstan-ignore instanceof.internalClass
|
||||
return;
|
||||
}
|
||||
|
||||
$forbidden = false;
|
||||
$method = strtr($method, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz');
|
||||
foreach ($this->forbiddenMethods as $class => $methods) {
|
||||
if ($obj instanceof $class) {
|
||||
$forbidden = \in_array($method, $methods);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($forbidden) {
|
||||
$class = \get_class($obj);
|
||||
throw new SecurityNotAllowedMethodError(\sprintf('Calling "%s" method on a "%s" object is not allowed.', $method, $class), $class, $method);
|
||||
}
|
||||
}
|
||||
|
||||
public function checkPropertyAllowed($obj, $property): void
|
||||
{
|
||||
$forbidden = false;
|
||||
foreach ($this->forbiddenProperties as $class => $properties) {
|
||||
if ($obj instanceof $class) {
|
||||
$forbidden = \in_array($property, \is_array($properties) ? $properties : [$properties]);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($forbidden) {
|
||||
$class = \get_class($obj);
|
||||
throw new SecurityNotAllowedPropertyError(\sprintf('Calling "%s" property on a "%s" object is not allowed.', $property, $class), $class, $property);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Twig\SecurityPolicy;
|
||||
|
||||
use App\Invoice\InvoiceModel;
|
||||
use App\Pdf\PdfContext;
|
||||
use Symfony\Component\String\UnicodeString;
|
||||
use Twig\Markup;
|
||||
use Twig\Sandbox\SecurityPolicy;
|
||||
use Twig\Sandbox\SecurityPolicyInterface;
|
||||
use Twig\Template;
|
||||
|
||||
/**
|
||||
* Represents the security policy for custom Twig invoice templates.
|
||||
*/
|
||||
final class InvoicePolicy implements SecurityPolicyInterface
|
||||
{
|
||||
private ChainPolicy $policy;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->policy = new ChainPolicy();
|
||||
$this->policy->addPolicy(new DefaultPolicy());
|
||||
$this->policy->addPolicy(new SecurityPolicy(
|
||||
['block', 'if', 'for', 'set', 'extends', 'import'],
|
||||
[
|
||||
// =================================================================
|
||||
// vendor/twig/twig/src/Extension/CoreExtension.php
|
||||
|
||||
// formatting filters
|
||||
'date',
|
||||
'date_modify',
|
||||
'format',
|
||||
'replace',
|
||||
'number_format',
|
||||
'abs',
|
||||
'round',
|
||||
|
||||
// encoding
|
||||
'url_encode',
|
||||
'json_encode',
|
||||
'convert_encoding',
|
||||
|
||||
// string filters
|
||||
'title',
|
||||
'capitalize',
|
||||
'upper',
|
||||
'lower',
|
||||
'striptags',
|
||||
'trim',
|
||||
'nl2br',
|
||||
'spaceless',
|
||||
|
||||
// array helpers
|
||||
'join',
|
||||
'split',
|
||||
'sort',
|
||||
'merge',
|
||||
'batch',
|
||||
'column',
|
||||
'filter',
|
||||
'map',
|
||||
'reduce',
|
||||
|
||||
// string/array filters
|
||||
'reverse',
|
||||
'length',
|
||||
'slice',
|
||||
'first',
|
||||
'last',
|
||||
|
||||
// iteration and runtime
|
||||
'default',
|
||||
'keys',
|
||||
|
||||
// =================================================================
|
||||
// vendor/twig/twig/src/Extension/EscaperExtension.php
|
||||
'escape',
|
||||
'e',
|
||||
'raw',
|
||||
|
||||
// =================================================================
|
||||
// vendor/symfony/twig-bridge/Extension/TranslationExtension.php
|
||||
'trans',
|
||||
|
||||
// =================================================================
|
||||
// vendor/twig/string-extra/StringExtension.php
|
||||
'u',
|
||||
'slug',
|
||||
|
||||
// =================================================================
|
||||
// vendor/twig/intl-extra/IntlExtension.php
|
||||
'country_name',
|
||||
'currency_name',
|
||||
'currency_symbol',
|
||||
'language_name',
|
||||
'locale_name',
|
||||
'timezone_name',
|
||||
'format_currency',
|
||||
'format_number',
|
||||
'format_decimal_number',
|
||||
'format_currency_number',
|
||||
'format_percent_number',
|
||||
'format_scientific_number',
|
||||
'format_spellout_number',
|
||||
'format_ordinal_number',
|
||||
'format_duration_number',
|
||||
'format_datetime',
|
||||
'format_date',
|
||||
'format_time',
|
||||
|
||||
// =================================================================
|
||||
// src/Twig/LocaleFormatExtensions.php
|
||||
'month_name',
|
||||
'day_name',
|
||||
'date_short',
|
||||
'date_time',
|
||||
'date_full',
|
||||
'date_format',
|
||||
'date_weekday',
|
||||
'time',
|
||||
'duration',
|
||||
'duration_decimal',
|
||||
'money',
|
||||
'amount',
|
||||
|
||||
// =================================================================
|
||||
// src/Twig/RuntimeExtensions.php
|
||||
'md2html',
|
||||
'desc2html',
|
||||
'comment2html',
|
||||
'comment1line',
|
||||
|
||||
// =================================================================
|
||||
// src/Twig/Extensions.php
|
||||
'multiline_indent',
|
||||
'color',
|
||||
'font_contrast',
|
||||
'default_color',
|
||||
'nl2str',
|
||||
],
|
||||
[
|
||||
PdfContext::class => ['setoption'],
|
||||
InvoiceModel::class => ['toarray'],
|
||||
],
|
||||
[], // properties
|
||||
[
|
||||
// =================================================================
|
||||
// vendor/twig/twig/src/Extension/CoreExtension.php
|
||||
'max',
|
||||
'min',
|
||||
'range',
|
||||
'constant',
|
||||
'cycle',
|
||||
'random',
|
||||
'date',
|
||||
'asset',
|
||||
'range',
|
||||
|
||||
// =================================================================
|
||||
// vendor/symfony/twig-bridge/Extension/TranslationExtension.php
|
||||
't',
|
||||
|
||||
// =================================================================
|
||||
// vendor/symfony/webpack-encore-bundle/src/Twig/EntryFilesTwigExtension.php
|
||||
'encore_entry_css_source',
|
||||
|
||||
// =================================================================
|
||||
// vendor/symfony/twig-bridge/Extension/AssetExtension.php
|
||||
'asset',
|
||||
|
||||
// =================================================================
|
||||
// vendor/symfony/twig-bridge/Extension/SecurityExtension.php
|
||||
'is_granted',
|
||||
|
||||
// =================================================================
|
||||
// Twig/RuntimeExtensions.php
|
||||
'qr_code_data_uri',
|
||||
|
||||
// =================================================================
|
||||
// Twig/Configuration.php
|
||||
'config',
|
||||
|
||||
// =================================================================
|
||||
// Twig/LocaleFormatExtensions.php
|
||||
'create_date',
|
||||
'month_names',
|
||||
'locale_format',
|
||||
]
|
||||
));
|
||||
}
|
||||
|
||||
public function checkSecurity($tags, $filters, $functions): void
|
||||
{
|
||||
$this->policy->checkSecurity($tags, $filters, $functions);
|
||||
}
|
||||
|
||||
public function checkMethodAllowed($obj, $method): void
|
||||
{
|
||||
if ($obj instanceof Template || $obj instanceof Markup || $obj instanceof UnicodeString) { // @phpstan-ignore instanceof.internalClass
|
||||
return;
|
||||
}
|
||||
|
||||
$lm = strtolower($method);
|
||||
|
||||
if (str_starts_with($lm, 'get') || str_starts_with($lm, 'is') || str_starts_with($lm, 'has')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($lm === '__tostring') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->policy->checkMethodAllowed($obj, $method);
|
||||
}
|
||||
|
||||
public function checkPropertyAllowed($obj, $property): void
|
||||
{
|
||||
$this->policy->checkPropertyAllowed($obj, $property);
|
||||
}
|
||||
}
|
||||
165
src/Twig/SecurityPolicy/StrictPolicy.php
Normal file
165
src/Twig/SecurityPolicy/StrictPolicy.php
Normal file
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Twig\SecurityPolicy;
|
||||
|
||||
use App\Entity\MetaTableTypeInterface;
|
||||
use App\Entity\User;
|
||||
use App\Pdf\PdfContext;
|
||||
use Symfony\Bridge\Twig\AppVariable;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\ServerBag;
|
||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||
use Symfony\Component\String\UnicodeString;
|
||||
use Twig\Sandbox\SecurityNotAllowedFilterError;
|
||||
use Twig\Sandbox\SecurityNotAllowedFunctionError;
|
||||
use Twig\Sandbox\SecurityNotAllowedMethodError;
|
||||
use Twig\Sandbox\SecurityNotAllowedTagError;
|
||||
use Twig\Sandbox\SecurityPolicyInterface;
|
||||
|
||||
/**
|
||||
* The Twig environment needs the sandbox extension, which itself needs a policy to start working.
|
||||
*/
|
||||
final class StrictPolicy implements SecurityPolicyInterface
|
||||
{
|
||||
/** @var string[] */
|
||||
private array $allowedTags = ['block', 'if', 'for', 'set', 'macro', 'import', 'extends', 'from'];
|
||||
/** @var string[] */
|
||||
private array $allowedFunctions = [
|
||||
// vendor/twig/twig/src/Extension/CoreExtension.php
|
||||
'max', 'min', 'range', 'constant', 'cycle', 'random', 'date',
|
||||
// vendor/symfony/twig-bridge/Extension/TranslationExtension.php
|
||||
't',
|
||||
// vendor/symfony/webpack-encore-bundle/src/Twig/EntryFilesTwigExtension.php
|
||||
'encore_entry_css_source', 'encore_entry_link_tags', 'encore_entry_script_tags',
|
||||
// vendor/symfony/twig-bridge/Extension/SecurityExtension.php
|
||||
'is_granted',
|
||||
// Twig/RuntimeExtensions.php
|
||||
'qr_code_data_uri',
|
||||
// Twig/Configuration.php
|
||||
'config',
|
||||
// Twig/LocaleFormatExtensions.php
|
||||
'create_date', 'month_names', 'locale_format',
|
||||
// Twig/Extensions.php
|
||||
'class_name'
|
||||
];
|
||||
/** @var string[] */
|
||||
private array $allowedFilters = [
|
||||
// vendor/twig/twig/src/Extension/CoreExtension.php
|
||||
// formatting filters
|
||||
'date', 'date_modify', 'format', 'replace', 'number_format', 'abs', 'round',
|
||||
// encoding
|
||||
'url_encode', 'json_encode',
|
||||
// string filters
|
||||
'title', 'capitalize', 'upper', 'lower', 'striptags', 'trim', 'nl2br', 'spaceless',
|
||||
// array helpers
|
||||
'join', 'split', 'sort', 'merge', 'column', 'filter', 'map',
|
||||
// string/array filters
|
||||
'reverse', 'length', 'slice', 'first', 'last',
|
||||
// iteration and runtime
|
||||
'default', 'keys',
|
||||
// vendor/twig/twig/src/Extension/EscaperExtension.php
|
||||
'escape', 'e', 'raw',
|
||||
// vendor/symfony/twig-bridge/Extension/TranslationExtension.php
|
||||
'trans',
|
||||
// vendor/twig/string-extra/StringExtension.php
|
||||
'u', 'slug',
|
||||
// vendor/twig/intl-extra/IntlExtension.php
|
||||
'country_name', 'currency_name', 'currency_symbol', 'language_name', 'locale_name', 'timezone_name',
|
||||
'format_currency', 'format_number', 'format_decimal_number', 'format_currency_number',
|
||||
'format_duration_number', 'format_datetime', 'format_date', 'format_time',
|
||||
// src/Twig/LocaleFormatExtensions.php
|
||||
'month_name', 'day_name', 'date_short', 'date_time', 'date_full', 'date_format',
|
||||
'date_weekday', 'time', 'duration', 'duration_decimal', 'money', 'amount',
|
||||
// src/Twig/RuntimeExtensions.php
|
||||
'md2html', 'desc2html', 'comment2html', 'comment1line',
|
||||
// src/Twig/Extensions.php
|
||||
'multiline_indent', 'color', 'nl2str'
|
||||
];
|
||||
|
||||
public function checkSecurity($tags, $filters, $functions): void
|
||||
{
|
||||
foreach ($tags as $tag) {
|
||||
if (!\in_array($tag, $this->allowedTags, true)) {
|
||||
throw new SecurityNotAllowedTagError(\sprintf('Tag "%s" is not allowed.', $tag), $tag);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($filters as $filter) {
|
||||
if (!\in_array($filter, $this->allowedFilters, true)) {
|
||||
throw new SecurityNotAllowedFilterError(\sprintf('Filter "%s" is not allowed.', $filter), $filter);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($functions as $function) {
|
||||
if (!\in_array($function, $this->allowedFunctions, true)) {
|
||||
throw new SecurityNotAllowedFunctionError(\sprintf('Function "%s" is not allowed.', $function), $function);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function checkMethodAllowed($obj, $method): void
|
||||
{
|
||||
if ($obj instanceof UnicodeString) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($obj instanceof ServerBag) {
|
||||
throw new SecurityNotAllowedMethodError('Tried to access server environment', ServerBag::class, $method);
|
||||
}
|
||||
|
||||
if ($obj instanceof SessionInterface) {
|
||||
throw new SecurityNotAllowedMethodError('Tried to access session', SessionInterface::class, $method);
|
||||
}
|
||||
|
||||
$lcm = strtolower($method);
|
||||
|
||||
if ($obj instanceof PdfContext) {
|
||||
if ($lcm !== 'setoption') {
|
||||
throw new SecurityNotAllowedMethodError('Tried to access forbidden method on PdfContext', PdfContext::class, $method);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($obj instanceof MetaTableTypeInterface && $lcm === 'merge') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($obj instanceof Request) {
|
||||
if (!str_starts_with($lcm, 'get')) {
|
||||
throw new SecurityNotAllowedMethodError('Tried to call setter() of app variable', AppVariable::class, $method);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($obj instanceof AppVariable) {
|
||||
if (!\in_array($lcm, ['getrequest', 'getuser', 'getlocale'], true)) {
|
||||
throw new SecurityNotAllowedMethodError('Tried to access forbidden app variable method', User::class, $method);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!str_starts_with($lcm, 'get') && !str_starts_with($lcm, 'has') && !str_starts_with($lcm, 'is') && $lcm !== '__tostring') {
|
||||
throw new SecurityNotAllowedMethodError('Tried to access non-read method', $obj::class, $method);
|
||||
}
|
||||
|
||||
if ($obj instanceof User) {
|
||||
if (\in_array($lcm, ['getpassword', 'gettotpsecret', 'getplainpassword', 'getconfirmationtoken', 'gettotpauthenticationconfiguration'], true)) {
|
||||
throw new SecurityNotAllowedMethodError('Tried to access user secrets', User::class, $method);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function checkPropertyAllowed($obj, $property): void
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,7 @@ final class ReleaseVersion
|
||||
/**
|
||||
* Get all releases from GitHub.
|
||||
*
|
||||
* @throws \Exception
|
||||
* @return array|null
|
||||
* @return array<string, array{'version': string, 'date': \DateTimeInterface, 'url': string, 'download': string, 'content': string}>
|
||||
* @return array
|
||||
* @return array<string, array{'version': non-empty-string, 'date': \DateTimeInterface, 'url': non-empty-string, 'download': non-empty-string, 'content': string}>
|
||||
*/
|
||||
private function getReleasesFromGithub(): array
|
||||
{
|
||||
@@ -41,19 +38,24 @@ final class ReleaseVersion
|
||||
$context = stream_context_create($opts);
|
||||
|
||||
$releases = file_get_contents('https://api.github.com/repos/' . Constants::GITHUB_REPO . '/releases', false, $context);
|
||||
$releases = json_decode($releases);
|
||||
|
||||
if (!isset($releases[0])) {
|
||||
throw new \Exception('API error - no release found at GitHub repository: ' . Constants::GITHUB_REPO);
|
||||
if ($releases === false) {
|
||||
throw new \Exception('Could not load releases from GitHub repository: ' . Constants::GITHUB_REPO);
|
||||
}
|
||||
/** @var array<string, array{url: non-empty-string, html_url: non-empty-string, tag_name: non-empty-string, name: non-empty-string, draft: bool, immutable: bool, prerelease: bool, created_at: non-empty-string, updated_at: non-empty-string, published_at: non-empty-string, zipball_url: non-empty-string, body: string}> $releases */
|
||||
$releases = json_decode($releases, true);
|
||||
|
||||
if ($releases === false) {
|
||||
throw new \Exception('Failed parsing release found at GitHub repository: ' . Constants::GITHUB_REPO);
|
||||
}
|
||||
|
||||
$parsed = [];
|
||||
foreach ($releases as $release) {
|
||||
if ($release->draft || $release->prerelease) {
|
||||
if ($release['draft'] || $release['prerelease']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$normalized = $versionParser->normalize($release->tag_name);
|
||||
$normalized = $versionParser->normalize($release['tag_name']);
|
||||
} catch (\UnexpectedValueException $e) {
|
||||
continue;
|
||||
}
|
||||
@@ -62,19 +64,19 @@ final class ReleaseVersion
|
||||
continue;
|
||||
}
|
||||
|
||||
$date = $release->published_at;
|
||||
$date = $release['published_at'];
|
||||
try {
|
||||
$date = new \DateTimeImmutable($date);
|
||||
} catch (\Exception $ex) {
|
||||
// can be ignored, we return a string
|
||||
continue;
|
||||
}
|
||||
|
||||
$parsed[$normalized] = [
|
||||
'version' => $release->tag_name,
|
||||
'version' => $release['tag_name'],
|
||||
'date' => $date,
|
||||
'url' => $release->html_url,
|
||||
'download' => $release->zipball_url,
|
||||
'content' => $release->body,
|
||||
'url' => $release['html_url'],
|
||||
'download' => $release['zipball_url'],
|
||||
'content' => $release['body'],
|
||||
];
|
||||
}
|
||||
$versions = Semver::rsort(array_keys($parsed));
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Webhook;
|
||||
|
||||
use App\Event\ActivityCreatePostEvent;
|
||||
use App\Event\ActivityDeleteEvent;
|
||||
use App\Event\ActivityUpdatePostEvent;
|
||||
use App\Event\CustomerCreatePostEvent;
|
||||
use App\Event\CustomerDeleteEvent;
|
||||
use App\Event\CustomerUpdatePostEvent;
|
||||
use App\Event\InvoiceCreatedEvent;
|
||||
use App\Event\InvoiceDeleteEvent;
|
||||
use App\Event\ProjectCreatePostEvent;
|
||||
use App\Event\ProjectDeleteEvent;
|
||||
use App\Event\ProjectUpdatePostEvent;
|
||||
use App\Event\TeamCreatePostEvent;
|
||||
use App\Event\TeamDeleteEvent;
|
||||
use App\Event\TeamUpdatePostEvent;
|
||||
use App\Event\TimesheetCreatePostEvent;
|
||||
use App\Event\TimesheetStopPostEvent;
|
||||
use App\Event\TimesheetUpdatePostEvent;
|
||||
use App\Event\UserCreatePostEvent;
|
||||
use App\Event\UserDeletePostEvent;
|
||||
use App\Event\UserUpdatePostEvent;
|
||||
use App\Webhook\Attribute\AsWebhook;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
|
||||
use Symfony\Contracts\EventDispatcher\Event;
|
||||
|
||||
final class WebhookListener implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(private readonly WebhookService $webhookService)
|
||||
{
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
ActivityCreatePostEvent::class => ['triggerWebhook', 1000],
|
||||
ActivityDeleteEvent::class => ['triggerWebhook', 1000],
|
||||
ActivityUpdatePostEvent::class => ['triggerWebhook', 1000],
|
||||
|
||||
CustomerCreatePostEvent::class => ['triggerWebhook', 1000],
|
||||
CustomerDeleteEvent::class => ['triggerWebhook', 1000],
|
||||
CustomerUpdatePostEvent::class => ['triggerWebhook', 1000],
|
||||
|
||||
InvoiceCreatedEvent::class => ['triggerWebhook', 1000],
|
||||
InvoiceDeleteEvent::class => ['triggerWebhook', 1000],
|
||||
|
||||
ProjectCreatePostEvent::class => ['triggerWebhook', 1000],
|
||||
ProjectDeleteEvent::class => ['triggerWebhook', 1000],
|
||||
ProjectUpdatePostEvent::class => ['triggerWebhook', 1000],
|
||||
|
||||
TimesheetCreatePostEvent::class => ['triggerWebhook', 1000],
|
||||
TimesheetStopPostEvent::class => ['triggerWebhook', 1000],
|
||||
TimesheetUpdatePostEvent::class => ['triggerWebhook', 1000],
|
||||
|
||||
UserCreatePostEvent::class => ['triggerWebhook', 1000],
|
||||
UserDeletePostEvent::class => ['triggerWebhook', 1000],
|
||||
UserUpdatePostEvent::class => ['triggerWebhook', 1000],
|
||||
|
||||
TeamCreatePostEvent::class => ['triggerWebhook', 1000],
|
||||
TeamDeleteEvent::class => ['triggerWebhook', 1000],
|
||||
TeamUpdatePostEvent::class => ['triggerWebhook', 1000],
|
||||
];
|
||||
}
|
||||
|
||||
public function triggerWebhook(Event $event): void
|
||||
{
|
||||
$attribute = $this->findAttribute($event);
|
||||
if ($attribute === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->webhookService->hasWebhook($attribute->name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$expressionLanguage = new ExpressionLanguage();
|
||||
$parsed = $expressionLanguage->parse($attribute->payload, ['object']);
|
||||
$payload = $parsed->getNodes()->evaluate([], ['object' => $event]);
|
||||
|
||||
$this->webhookService->trigger($attribute->name, $payload);
|
||||
}
|
||||
|
||||
private function findAttribute(Event $event): ?AsWebhook
|
||||
{
|
||||
try {
|
||||
$reflectionClass = new \ReflectionClass($event);
|
||||
$attributes = $reflectionClass->getAttributes(AsWebhook::class);
|
||||
if (\count($attributes) === 1) {
|
||||
$attribute = $attributes[0];
|
||||
$args = $attribute->getArguments();
|
||||
|
||||
return new AsWebhook($args['name'], $args['description'], $args['payload']);
|
||||
}
|
||||
} catch (\Exception $ex) {
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Webhook;
|
||||
|
||||
use App\Entity\WebhookConfiguration;
|
||||
use App\Entity\WebhookEvent;
|
||||
use App\Serializer\SerializerInterface;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\RemoteEvent\RemoteEvent;
|
||||
use Symfony\Component\Webhook\Messenger\SendWebhookMessage;
|
||||
use Symfony\Component\Webhook\Subscriber;
|
||||
|
||||
final class WebhookService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SerializerInterface $serializer,
|
||||
private readonly MessageBusInterface $bus
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
public function hasWebhook(string $name): bool
|
||||
{
|
||||
return $name === 'user.created';
|
||||
}
|
||||
|
||||
public function trigger(string $name, mixed $payload): void
|
||||
{
|
||||
$content = $this->serializer->toArray($payload, ['groups' => ['Default', 'Entity', 'Expanded']]);
|
||||
|
||||
foreach ($this->findEventsByName($name) as $event) {
|
||||
$configuration = $event->getConfiguration();
|
||||
$subscriber = new Subscriber($configuration->getUrl(), $configuration->getSecret());
|
||||
$remoteEvent = new RemoteEvent($name, $name . '_' . uniqid(microtime()), $content);
|
||||
$message = new SendWebhookMessage($subscriber, $remoteEvent);
|
||||
$this->bus->dispatch($message);
|
||||
}
|
||||
}
|
||||
|
||||
public function findEventsByName(string $name): array
|
||||
{
|
||||
$url = 'TODO';
|
||||
$secret = 'TODO';
|
||||
|
||||
$configuration = new WebhookConfiguration('n8n', $url, 'json', $secret, 'bearer');
|
||||
|
||||
return [
|
||||
new WebhookEvent($name, $configuration),
|
||||
];
|
||||
}
|
||||
}
|
||||
30
symfony.lock
30
symfony.lock
@@ -261,12 +261,6 @@
|
||||
"psr/event-dispatcher": {
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"psr/http-client": {
|
||||
"version": "1.0.1"
|
||||
},
|
||||
"psr/http-factory": {
|
||||
"version": "1.0.1"
|
||||
},
|
||||
"psr/http-message": {
|
||||
"version": "1.0.1"
|
||||
},
|
||||
@@ -467,18 +461,6 @@
|
||||
"config/packages/mailer.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/messenger": {
|
||||
"version": "6.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "6.0",
|
||||
"ref": "d8936e2e2230637ef97e5eecc0eea074eecae58b"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/messenger.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/mime": {
|
||||
"version": "v4.3.3"
|
||||
},
|
||||
@@ -610,18 +592,6 @@
|
||||
"ref": "6bdfa1a95f6b2e677ab985cd1af2eae35d62e0f6"
|
||||
}
|
||||
},
|
||||
"symfony/webhook": {
|
||||
"version": "6.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "6.3",
|
||||
"ref": "0bc853a74c98e448116ba53e118a7b6b3cdf9a03"
|
||||
},
|
||||
"files": [
|
||||
"config/routes/webhook.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/webpack-encore-bundle": {
|
||||
"version": "1.0",
|
||||
"recipe": {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
'back': path('admin_activity')
|
||||
} %}
|
||||
{% embed formEditTemplate with formOptions %}
|
||||
{% import "macros/form_helper.html.twig" as form_helper %}
|
||||
{% block form_body %}
|
||||
<div class="row">
|
||||
<div class="col-md-9">
|
||||
@@ -49,6 +50,18 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ form_helper.metafields(form, null) }}
|
||||
{% embed '@theme/embeds/collapsible.html.twig' with {id: 'activity_invoice_settings'} %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% import "macros/form_helper.html.twig" as form_helper %}
|
||||
{% block title %}{{ 'invoices'|trans }}{% endblock %}
|
||||
{% block body %}
|
||||
{% if form.invoiceText is defined %}
|
||||
{{ form_row(form.invoiceText) }}
|
||||
{% endif %}
|
||||
{{ form_helper.metafields(form, 'invoice') }}
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
{{ form_row(form.visible) }}
|
||||
@@ -57,18 +70,6 @@
|
||||
{{ form_row(form.billable) }}
|
||||
</div>
|
||||
</div>
|
||||
{% if form.metaFields is defined and form.metaFields is not empty %}
|
||||
{{ form_row(form.metaFields) }}
|
||||
{% endif %}
|
||||
{% embed '@theme/embeds/collapsible.html.twig' with {id: 'activity_invoice_settings'} %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% block title %}{{ 'invoices'|trans }}{% endblock %}
|
||||
{% block body %}
|
||||
{% if form.invoiceText is defined %}
|
||||
{{ form_row(form.invoiceText) }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
{{ form_rest(form) }}
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
|
||||
@@ -26,19 +26,21 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block page_content_start %}
|
||||
{% if app.session and app.session.started and app.session.flashbag.peekAll|length > 0 %}
|
||||
{% set flashes = app.flashes %}
|
||||
{% if flashes|length > 0 %}
|
||||
{% set domain = 'flashmessages' %}
|
||||
<script type="text/javascript">
|
||||
document.addEventListener('kimai.initialized', function(options) {
|
||||
const ALERT = options.detail.kimai.getPlugin('alert');
|
||||
{% for type, messages in app.session.flashbag.all %}
|
||||
{% for type, messages in flashes %}
|
||||
{% for message in messages %}
|
||||
{% if type == 'error' %}
|
||||
ALERT.error('{{ message|trans({}, domain)|e('js') }}');
|
||||
{% elseif type == 'warning' %}
|
||||
ALERT.warning('{{ message|trans({}, domain)|e('js') }}');
|
||||
{% elseif type == 'success' %}
|
||||
ALERT.success('{{ message|trans({}, domain)|e('js') }}');
|
||||
// success is expected so we do not need to show messages like "saved successfully"
|
||||
// ALERT.success('{{ message|trans({}, domain)|e('js') }}');
|
||||
{% else %}
|
||||
ALERT.info('{{ message|trans({}, domain)|e('js') }}');
|
||||
{% endif %}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
'back': path('admin_customer')
|
||||
} %}
|
||||
{% embed formEditTemplate with formOptions %}
|
||||
{% import "macros/form_helper.html.twig" as form_helper %}
|
||||
{% block form_body %}
|
||||
<div class="row">
|
||||
<div class="col-md-9">
|
||||
@@ -75,11 +76,10 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if form.metaFields is defined and form.metaFields is not empty %}
|
||||
{{ form_row(form.metaFields) }}
|
||||
{% endif %}
|
||||
{{ form_helper.metafields(form, null) }}
|
||||
{% embed '@theme/embeds/collapsible.html.twig' with {id: 'customer_address_settings'} %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% import "macros/form_helper.html.twig" as form_helper %}
|
||||
{% block title %}{{ 'address'|trans }}{% endblock %}
|
||||
{% block body %}
|
||||
<div class="row">
|
||||
@@ -125,11 +125,12 @@
|
||||
{{ form_row(form.timezone) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ form_helper.metafields(form, 'address') }}
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
{% embed '@theme/embeds/collapsible.html.twig' with {id: 'customer_invoice_settings'} %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% import "macros/form_helper.html.twig" as form_helper %}
|
||||
{% block title %}{{ 'invoices'|trans }}{% endblock %}
|
||||
{% block body %}
|
||||
{% if form.buyerReference is defined %}
|
||||
@@ -141,6 +142,7 @@
|
||||
{% if form.invoiceText is defined %}
|
||||
{{ form_row(form.invoiceText) }}
|
||||
{% endif %}
|
||||
{{ form_helper.metafields(form, 'invoice') }}
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
<div class="row">
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
{{ entry.vatId }}
|
||||
{% elseif column == 'contact' %}
|
||||
{{ entry.contact }}
|
||||
{% elseif column == 'address' %}
|
||||
{{ entry.address }}
|
||||
{% elseif column == 'city' %}
|
||||
{{ entry.postcode }} {{ entry.city }}
|
||||
{% elseif column == 'country' %}
|
||||
{{ entry.country|country_name }}
|
||||
{% elseif column == 'currency' %}
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
<td class="actions">
|
||||
<div class="btn-group">
|
||||
<a href="{{ path(edit_route, {'id': entity.id, 'rate': rate.id}) }}" class="modal-ajax-form btn btn-icon">{{ icon('edit', true) }}</a>
|
||||
<a href="{{ path(delete_route, {'id': entity.id, 'rateId': rate.id}) }}" class="btn btn-icon api-link" data-question="confirm.delete" data-event="kimai.rateUpdate kimai.rateDelete" data-method="DELETE" data-msg-error="action.delete.error" data-msg-success="action.delete.success">{{ icon('delete', true) }}</a>
|
||||
<a href="{{ path(delete_route, {'id': entity.id, 'rateId': rate.id}) }}" class="btn btn-icon api-link" data-question="confirm.delete" data-event="kimai.rateUpdate kimai.rateDelete" data-method="DELETE" data-msg-error="action.delete.error">{{ icon('delete', true) }}</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -158,7 +158,7 @@
|
||||
{{ btnTitle }}
|
||||
{% if allowCreate and cfg.internal %}
|
||||
<span class="dropdown-action modal-ajax-form open-edit ps-2 ms-auto" data-href="{{ path('export_template_edit', {exportTemplate: id}) }}">{{ icon('edit') }}</span>
|
||||
<span class="dropdown-action api-link text-red dd-ts-trash text-danger ms-2" href="{{ path('delete_export_template', {id: id}) }}" data-event="kimai.exportTemplate" data-method="DELETE" data-question="confirm.delete" data-msg-error="action.delete.error" data-msg-success="action.delete.success">{{ icon('delete') }}</span>
|
||||
<span class="dropdown-action api-link text-red dd-ts-trash text-danger ms-2" href="{{ path('delete_export_template', {id: id}) }}" data-event="kimai.exportTemplate" data-method="DELETE" data-question="confirm.delete" data-msg-error="action.delete.error">{{ icon('delete') }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ app.request.locale }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport">
|
||||
<link rel="stylesheet" href="{{ asset('bundles/tabler/tabler.css') }}">
|
||||
{{ encore_entry_link_tags('app') }}
|
||||
{{ encore_entry_script_tags('app') }}
|
||||
{% block styles %}{% endblock %}
|
||||
{% if block('document_title') is defined %}
|
||||
<title>{{ block('document_title') }}</title>
|
||||
{% endif %}
|
||||
</head>
|
||||
<body class="pad export_print">
|
||||
<div class="wrapper">
|
||||
<section class="export">
|
||||
{% block export %}{% endblock %}
|
||||
</section>
|
||||
</div>
|
||||
{% block javascripts %}
|
||||
<script src="{{ asset('bundles/tabler/tabler.js') }}"></script>
|
||||
{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -474,7 +474,9 @@
|
||||
|
||||
{%- block meta_fields_collection_widget -%}
|
||||
{% for meta in form|sort((a, b) => a.vars.data.order <=> b.vars.data.order) %}
|
||||
{{ form_row(meta.value, {'row_attr': {'class': 'mb-3 ' ~ form.parent.vars.name ~ '_row_' ~ form.vars.name ~ '_' ~ meta.vars.name}}) }}
|
||||
{% if not meta.rendered %}
|
||||
{{ form_row(meta.value, {'row_attr': {'class': 'mb-3 ' ~ form.parent.vars.name ~ '_row_' ~ form.vars.name ~ '_' ~ meta.vars.name}}) }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{%- endblock meta_fields_collection_widget %}
|
||||
|
||||
|
||||
@@ -135,20 +135,25 @@ mpdf-->
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
{% if not invoice['invoice.tax_hide'] %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-right">
|
||||
{{ 'invoice.subtotal'|trans }}
|
||||
</td>
|
||||
<td class="last text-right">{{ invoice['invoice.subtotal'] }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="4" class="text-right">
|
||||
{{ 'invoice.tax'|trans }} ({{ invoice['invoice.vat'] }}%)
|
||||
</td>
|
||||
<td class="last text-right">{{ invoice['invoice.tax'] }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% for taxRow in invoice['invoice.tax_rows'] %}
|
||||
{% if taxRow['show'] %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-right">
|
||||
{{ taxRow['name']|trans }} ({{ taxRow['rate'] }}%)
|
||||
{% if taxRow['note'] is not null %}
|
||||
<sup>[{{ taxRow['counter'] }}]</sup>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="last text-right">{{ taxRow['amount']|money(taxRow['currency']) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-right text-nowrap">
|
||||
<strong>{{ 'invoice.total'|trans }}</strong>
|
||||
@@ -160,10 +165,17 @@ mpdf-->
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
{% for taxRow in invoice['invoice.tax_rows'] %}
|
||||
{% if taxRow['note'] is not null %}
|
||||
<p>
|
||||
<sup>[{{ taxRow['counter'] }}]</sup>
|
||||
{{ taxRow['note']|trans }}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if invoice['template.payment_terms'] is not empty %}
|
||||
<div class="paymentTerms">
|
||||
{{ invoice['template.payment_terms']|md2html }}
|
||||
</div>
|
||||
{{ invoice['template.payment_terms']|md2html }}
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<h2 class="page-header">
|
||||
<span contenteditable="true">{{ invoice['template.title'] }}</span>
|
||||
<span>{{ invoice['template.title'] }}</span>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
@@ -22,7 +22,7 @@
|
||||
<div class="row">
|
||||
<div class="col-sm-5">
|
||||
{{ 'invoice.to'|trans }}
|
||||
<address contenteditable="true">
|
||||
<address>
|
||||
<strong>{{ invoice['customer.company']|default(invoice['customer.name']) }}</strong><br>
|
||||
{{ invoice['customer.address']|nl2br }}
|
||||
{% set country = invoice['customer.country']|country_name(invoice['invoice.language']) %}
|
||||
@@ -46,7 +46,7 @@
|
||||
<div class="col-sm-2"></div>
|
||||
<div class="col-sm-5">
|
||||
{{ 'invoice.from'|trans }}
|
||||
<address contenteditable="true">
|
||||
<address>
|
||||
<strong>{{ invoice['template.company'] }}</strong><br>
|
||||
{{ invoice['template.address']|trim|nl2br }}
|
||||
{% if invoice['template.country'] is not null %}
|
||||
@@ -66,7 +66,7 @@
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-5">
|
||||
<p contenteditable="true">
|
||||
<p>
|
||||
<strong>{{ 'date'|trans }}:</strong>
|
||||
{{ invoice['invoice.date'] }}
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
{% for invoiceLineItem in entries %}
|
||||
<tr>
|
||||
<td nowrap class="text-nowrap">{{ invoiceLineItem['entry.begin'] }}</td>
|
||||
<td contenteditable="true">
|
||||
<td>
|
||||
{% if invoiceLineItem['entry.description'] is not empty %}
|
||||
{{ invoiceLineItem['entry.description']|nl2br }}
|
||||
{% else %}
|
||||
@@ -115,20 +115,25 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
{% if not invoice['invoice.tax_hide'] %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-end">
|
||||
{{ 'invoice.subtotal'|trans }}
|
||||
</td>
|
||||
<td class="text-end">{{ invoice['invoice.subtotal'] }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="4" class="text-end">
|
||||
{{ 'invoice.tax'|trans }} ({{ invoice['invoice.vat'] }}%)
|
||||
</td>
|
||||
<td class="text-end">{{ invoice['invoice.tax'] }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% for taxRow in invoice['invoice.tax_rows'] %}
|
||||
{% if taxRow['show'] %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-end">
|
||||
{{ taxRow['name']|trans }} ({{ taxRow['rate'] }}%)
|
||||
{% if taxRow['note'] is not null %}
|
||||
<sup>[{{ taxRow['counter'] }}]</sup>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="last text-right">{{ taxRow['amount']|money(taxRow['currency']) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-end text-nowrap">
|
||||
<strong>{{ 'invoice.total'|trans }}</strong>
|
||||
@@ -144,10 +149,16 @@
|
||||
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
{% for taxRow in invoice['invoice.tax_rows'] %}
|
||||
{% if taxRow['note'] is not null %}
|
||||
<p>
|
||||
<sup>[{{ taxRow['counter'] }}]</sup>
|
||||
{{ taxRow['note']|trans }}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if invoice['template.payment_terms'] is not empty %}
|
||||
<div contenteditable="true" class="paymentTerms">
|
||||
{{ invoice['template.payment_terms']|md2html }}
|
||||
</div>
|
||||
{{ invoice['template.payment_terms']|md2html }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -150,20 +150,25 @@ mpdf-->
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
{% if not invoice['invoice.tax_hide'] %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-right">
|
||||
{{ 'invoice.subtotal'|trans }}
|
||||
</td>
|
||||
<td class="last text-right">{{ invoice['invoice.subtotal'] }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="3" class="text-right">
|
||||
{{ 'invoice.tax'|trans }} ({{ invoice['invoice.vat'] }}%)
|
||||
</td>
|
||||
<td class="last text-right">{{ invoice['invoice.tax'] }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% for taxRow in invoice['invoice.tax_rows'] %}
|
||||
{% if taxRow['show'] %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-right">
|
||||
{{ taxRow['name']|trans }} ({{ taxRow['rate'] }}%)
|
||||
{% if taxRow['note'] is not null %}
|
||||
<sup>[{{ taxRow['counter'] }}]</sup>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="last text-right">{{ taxRow['amount']|money(taxRow['currency']) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-right text-nowrap">
|
||||
<strong>{{ 'invoice.total'|trans }}</strong>
|
||||
@@ -175,10 +180,17 @@ mpdf-->
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
{% for taxRow in invoice['invoice.tax_rows'] %}
|
||||
{% if taxRow['note'] is not null %}
|
||||
<p>
|
||||
<sup>[{{ taxRow['counter'] }}]</sup>
|
||||
{{ taxRow['note']|trans }}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if invoice['template.payment_terms'] is not empty %}
|
||||
<div class="paymentTerms">
|
||||
{{ invoice['template.payment_terms']|md2html }}
|
||||
</div>
|
||||
{{ invoice['template.payment_terms']|md2html }}
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user