Release 2.0.11 (#3932)

- added "today" as selector in date-range dropdown
- added feature to prevent auto-select of dropdowns with only one entry
- added hint that no changes were detected in batch update
- added negative invoice sums are possible (e.g. for credit notes)
- fix project list is expanded after submission
- fix invalid date parsing causes 500
- fix: prevent auto-select of activities in export and invoice form (in case only one global activity exists)
- fix team assignments for customer and project were not saved (using API now)
- fix form fieldset with legend styling (e.g. team project assignment)
- fix required meta-field were forced to have a value in batch update
- fix tomselect meta-field was not disabled in batch update
- fix unset internal rate is shown as 0
- fix one minute rounding problem in duration-only mode  with "now" being default time
- fix column width and label for duration-only mode
- tech debt: cleanup invoice template (remove invoice layout)
- tech debt: reorder for simpler comparison with invoice form
- possible BC for devs: remove unused methods from form trait
- bump composer packages (includes new translations for auth screens)
This commit is contained in:
Kevin Papst
2023-03-21 12:42:18 +01:00
committed by GitHub
parent 252652082d
commit 3a5d7a62de
41 changed files with 590 additions and 1288 deletions

View File

@@ -18,13 +18,13 @@
<th>
{{ 'user'|trans }}
</th>
<th>
<th class="text-end">
{{ 'hourlyRate'|trans }}
</th>
<th class="d-none d-sm-table-cell">
<th class="d-none d-sm-table-cell text-end">
{{ 'internalRate'|trans }}
</th>
<th>
<th class="text-center">
{{ 'fixedRate'|trans }}
</th>
<th class="actions"></th>
@@ -40,13 +40,17 @@
&ndash;
{% endif %}
</td>
<td>
<td class="text-end">
{{ rate.rate|money(currency) }}
</td>
<td class="d-none d-sm-table-cell">
{{ rate.internalRate|money(currency) }}
<td class="d-none d-sm-table-cell text-end">
{% if rate.internalRate is not null %}
{{ rate.internalRate|money(currency) }}
{% else %}
&ndash;
{% endif %}
</td>
<td>
<td class="text-center">
{{ widgets.label_boolean(rate.fixed) }}
</td>
<td class="actions">

View File

@@ -1,3 +1,35 @@
{# added for the team project form, to show divider for the customer name between all projects #}
{% block choice_widget_expanded -%}
{%- if '-inline' in label_attr.class|default('') -%}
{%- for child in form %}
{{- form_widget(child, {
parent_label_class: label_attr.class|default(''),
translation_domain: choice_translation_domain,
}) -}}
{% endfor -%}
{%- else -%}
<div {{ block('widget_container_attributes') }}>
{% if form.vars.choices|length != form.children|length %}
{% for name, choices in form.vars.choices %}
<fieldset class="form-fieldset">
<legend>{{ name }}</legend>
{% for key, choice in choices %}
{{- form_widget(form[key]) -}}
{{- form_label(form[key]) -}}
{% endfor %}
</fieldset>
{% endfor %}
{% else %}
{%- for child in form %}
{{- form_widget(child, {
parent_label_class: label_attr.class|default(''),
translation_domain: choice_translation_domain,
}) -}}
{% endfor -%}
{% endif %}
</div>
{%- endif -%}
{%- endblock choice_widget_expanded %}
{% block quick_entry_week_row %}
<tr{% with {attr: row_attr|merge({class: (row_attr.class|default('') ~ ' form-group qe-entry-week-row' ~ (not valid ? ' is-invalid'))|trim})} %}{{ block('attributes') }}{% endwith %}>

View File

@@ -1,21 +0,0 @@
<!DOCTYPE html>
{% set fallback = app.request is not null ? app.request.locale : 'en' %}
{% set language = model.template.language|default(fallback) %}
<html lang="{{ language }}">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport">
<title>{% block title %}{{ model.invoiceNumber }}-{{ model.customer.company|default(model.customer.name)|u.snake }}{% endblock %}</title>
<style type="text/css">
{{ encore_entry_css_source('invoice')|raw }}
</style>
</head>
<body class="invoice_print">
<div class="wrapper">
<section class="invoice">
{% block invoice %}{% endblock %}
</section>
</div>
</body>
</html>

View File

@@ -1,148 +1,166 @@
{% extends 'invoice/layout.html.twig' %}
{% block invoice %}
<!DOCTYPE html>
{% set fallback = app.request is not null ? app.request.locale : 'en' %}
{% set language = model.template.language|default(fallback) %}
{% set isDecimal = true %}
{% set currency = model.currency %}
{% set project = model.query.project %}
<div class="row">
<div class="col-xs-12">
<h2 class="page-header">
<span contenteditable="true">{{ model.template.title }}</span>
<small class="pull-right">{{ 'date'|trans }}: {{ model.invoiceDate|date_short }}</small>
</h2>
</div>
</div>
<html lang="{{ language }}">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport">
<title>{% block title %}{{ model.invoiceNumber }}-{{ model.customer.company|default(model.customer.name)|u.snake }}{% endblock %}</title>
<style type="text/css">
{{ encore_entry_css_source('invoice')|raw }}
</style>
</head>
<body class="invoice_print">
<div class="wrapper">
<section class="invoice">
<div class="row">
<div class="col-sm-5">
{{ 'invoice.to'|trans }}
<address contenteditable="true">
<strong>{{ model.customer.company|default(model.customer.name) }}</strong><br>
{{ model.customer.address|nl2br }}
{% if model.customer.vatId is not empty %}
<br>
{{ 'vat_id'|trans }}: {{ model.customer.vatId }}
{% endif %}
{% if model.customer.number is not empty %}
<br>
{{ 'number'|trans }}: {{ model.customer.number }}
{% endif %}
{% if project is not null and project.orderNumber is not empty %}
<br>
{{ 'orderNumber'|trans }}: {{ project.orderNumber }}
{% endif %}
</address>
</div>
<div class="col-sm-2"></div>
<div class="col-sm-5">
{{ 'invoice.from'|trans }}
<address contenteditable="true">
<strong>{{ model.template.company }}</strong><br>
{{ model.template.address|trim|nl2br }}
{% if model.template.vatId is not empty %}
<br>
{{ 'vat_id'|trans }}:
{{ model.template.vatId }}
{% endif %}
</address>
</div>
</div>
<div class="row">
<div class="col-sm-5">
<p contenteditable="true">
<strong>{{ 'invoice.number'|trans }}:</strong>
{{ model.invoiceNumber }}
<br>
<strong>{{ 'invoice.due_days'|trans }}:</strong>
{{ model.dueDate|date_short }}
</p>
</div>
<div class="col-sm-7"></div>
</div>
<div class="row invoice-items mt-2 mb-3">
<div class="col-xs-12 table-responsive">
<table class="table">
<thead>
<tr>
<th>{{ 'date'|trans }}</th>
<th>{{ 'description'|trans }}</th>
<th class="text-end">{{ 'unit_price'|trans }}</th>
<th class="text-end">{{ 'amount'|trans }}</th>
<th class="text-end">{{ 'total_rate'|trans }}</th>
</tr>
</thead>
<tbody>
{% for entry in model.calculator.entries %}
{% set duration = entry.duration|duration(isDecimal) %}
{% if entry.fixedRate %}
{% set rate = entry.fixedRate %}
{% set duration = entry.amount|amount %}
{% else %}
{% set rate = entry.hourlyRate %}
{% endif %}
<tr>
<td nowrap class="text-nowrap">{{ entry.begin|date_short }}</td>
<td contenteditable="true">
{% if entry.description is not empty %}
{{ entry.description|nl2br }}
{% else %}
{% if entry.activity is not null %}{{ entry.activity.name }} / {% endif %}{{ entry.project.name }}
{% endif %}
</td>
<td nowrap class="text-nowrap text-end">{{ rate|money(currency) }}</td>
<td nowrap class="text-nowrap text-end">{{ duration }}</td>
<td nowrap class="text-nowrap text-end">{{ entry.rate|money(currency) }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
{% if model.calculator.tax > 0 or not model.isHideZeroTax() %}
<tr>
<td colspan="4" class="text-end">
{{ 'invoice.subtotal'|trans }}
</td>
<td class="text-end">{{ model.calculator.subtotal|money(currency) }}</td>
</tr>
<tr>
<td colspan="4" class="text-end">
{{ 'invoice.tax'|trans }} ({{ model.calculator.vat }}%)
</td>
<td class="text-end">{{ model.calculator.tax|money(currency) }}</td>
</tr>
{% endif %}
<tr>
<td colspan="4" class="text-end text-nowrap">
<strong>{{ 'invoice.total'|trans }}</strong>
</td>
<td class="text-end">
<strong>{{ model.calculator.total|money(currency) }}</strong>
</td>
</tr>
</tfoot>
</table>
</div>
</div>
<div class="row">
<div class="col-xs-12">
{% if model.template.paymentTerms is not empty %}
<div contenteditable="true" class="paymentTerms">
{{ model.template.paymentTerms|md2html }}
<div class="row">
<div class="col-xs-12">
<h2 class="page-header">
<span contenteditable="true">{{ model.template.title }}</span>
<small class="pull-right">{{ 'date'|trans }}: {{ model.invoiceDate|date_short }}</small>
</h2>
</div>
</div>
{% endif %}
</div>
</div>
<footer class="footer">
<p>
<strong>{{ 'address'|trans }}</strong>: {{ model.template.company }} &ndash; {{ model.template.address|nl2str(' – ') }}
<br>
<strong>{{ 'invoice_bank_account'|trans }}</strong>: {{ model.template.paymentDetails|nl2str(' – ') }}
<br>
<strong>{{ 'contact'|trans }}</strong>: {{ model.template.contact|nl2str(' – ') }}
</p>
</footer>
{% endblock %}
<div class="row">
<div class="col-sm-5">
{{ 'invoice.to'|trans }}
<address contenteditable="true">
<strong>{{ model.customer.company|default(model.customer.name) }}</strong><br>
{{ model.customer.address|nl2br }}
{% if model.customer.vatId is not empty %}
<br>
{{ 'vat_id'|trans }}: {{ model.customer.vatId }}
{% endif %}
{% if model.customer.number is not empty %}
<br>
{{ 'number'|trans }}: {{ model.customer.number }}
{% endif %}
{% if project is not null and project.orderNumber is not empty %}
<br>
{{ 'orderNumber'|trans }}: {{ project.orderNumber }}
{% endif %}
</address>
</div>
<div class="col-sm-2"></div>
<div class="col-sm-5">
{{ 'invoice.from'|trans }}
<address contenteditable="true">
<strong>{{ model.template.company }}</strong><br>
{{ model.template.address|trim|nl2br }}
{% if model.template.vatId is not empty %}
<br>
{{ 'vat_id'|trans }}:
{{ model.template.vatId }}
{% endif %}
</address>
</div>
</div>
<div class="row">
<div class="col-sm-5">
<p contenteditable="true">
<strong>{{ 'invoice.number'|trans }}:</strong>
{{ model.invoiceNumber }}
<br>
<strong>{{ 'invoice.due_days'|trans }}:</strong>
{{ model.dueDate|date_short }}
</p>
</div>
<div class="col-sm-7"></div>
</div>
<div class="row invoice-items mt-2 mb-3">
<div class="col-xs-12 table-responsive">
<table class="table">
<thead>
<tr>
<th>{{ 'date'|trans }}</th>
<th>{{ 'description'|trans }}</th>
<th class="text-end">{{ 'unit_price'|trans }}</th>
<th class="text-end">{{ 'amount'|trans }}</th>
<th class="text-end">{{ 'total_rate'|trans }}</th>
</tr>
</thead>
<tbody>
{% for entry in model.calculator.entries %}
{% set duration = entry.duration|duration(isDecimal) %}
{% if entry.fixedRate %}
{% set rate = entry.fixedRate %}
{% set duration = entry.amount|amount %}
{% else %}
{% set rate = entry.hourlyRate %}
{% endif %}
<tr>
<td nowrap class="text-nowrap">{{ entry.begin|date_short }}</td>
<td contenteditable="true">
{% if entry.description is not empty %}
{{ entry.description|nl2br }}
{% else %}
{% if entry.activity is not null %}{{ entry.activity.name }} / {% endif %}{{ entry.project.name }}
{% endif %}
</td>
<td nowrap class="text-nowrap text-end">{{ rate|money(currency) }}</td>
<td nowrap class="text-nowrap text-end">{{ duration }}</td>
<td nowrap class="text-nowrap text-end">{{ entry.rate|money(currency) }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
{% if model.calculator.tax > 0 or not model.isHideZeroTax() %}
<tr>
<td colspan="4" class="text-end">
{{ 'invoice.subtotal'|trans }}
</td>
<td class="text-end">{{ model.calculator.subtotal|money(currency) }}</td>
</tr>
<tr>
<td colspan="4" class="text-end">
{{ 'invoice.tax'|trans }} ({{ model.calculator.vat }}%)
</td>
<td class="text-end">{{ model.calculator.tax|money(currency) }}</td>
</tr>
{% endif %}
<tr>
<td colspan="4" class="text-end text-nowrap">
<strong>{{ 'invoice.total'|trans }}</strong>
</td>
<td class="text-end">
<strong>{{ model.calculator.total|money(currency) }}</strong>
</td>
</tr>
</tfoot>
</table>
</div>
</div>
<div class="row">
<div class="col-xs-12">
{% if model.template.paymentTerms is not empty %}
<div contenteditable="true" class="paymentTerms">
{{ model.template.paymentTerms|md2html }}
</div>
{% endif %}
</div>
</div>
<footer class="footer">
<p>
<strong>{{ 'address'|trans }}</strong>: {{ model.template.company }} &ndash; {{ model.template.address|nl2str(' – ') }}
<br>
<strong>{{ 'invoice_bank_account'|trans }}</strong>: {{ model.template.paymentDetails|nl2str(' – ') }}
<br>
<strong>{{ 'contact'|trans }}</strong>: {{ model.template.contact|nl2str(' – ') }}
</p>
</footer>
</section>
</div>
</body>
</html>

View File

@@ -7,21 +7,93 @@
{% include 'team/member-form.html.twig' %}
</div>
<div class="col-md-6">
{% set formEditTemplate = kimai_context.modalRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig' %}
{% if customerForm is defined and customerForm is not null %}
{{ include(formEditTemplate, {
'title': 'teams.customer_access'|trans({}, 'teams'),
'form': customerForm,
}) }}
{% embed '@theme/embeds/card.html.twig' with {collapsible: true, collapsed: (team.customers|length == 0)} %}
{% from "macros/status.html.twig" import status_count %}
{% block box_title %}
{{ 'teams.customer_access'|trans({}, 'teams') }}
{{ status_count(team.customers|length) }}
{% endblock %}
{% block box_body %}
{{ form(customerForm) }}
{% endblock %}
{% endembed %}
{% endif %}
{% if projectForm is defined and projectForm is not null %}
{{ include(formEditTemplate, {
'title': 'teams.project_access'|trans({}, 'teams'),
'form': projectForm,
}) }}
{% embed '@theme/embeds/card.html.twig' with {collapsible: true, collapsed: (team.projects|length == 0)} %}
{% from "macros/status.html.twig" import status_count %}
{% block box_title %}
{{ 'teams.project_access'|trans({}, 'teams') }}
{{ status_count(team.projects|length) }}
{% endblock %}
{% block box_body %}
{{ form(projectForm) }}
{% endblock %}
{% endembed %}
{% endif %}
</div>
</div>
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script type="text/javascript">
document.addEventListener('kimai.initialized', (event) => {
/** @type {KimaiAPI} API */
const API = event.detail.kimai.getPlugin('api');
/** @type {KimaiAlert} ALERT */
const ALERT = event.detail.kimai.getPlugin('alert');
{% if customerForm is defined and customerForm is not null %}
document.getElementById('team_customer_form').addEventListener('change',
(event) => {
if (event.target.matches('input[type=checkbox]')) {
const checkbox = event.target;
const customerId = checkbox.value;
if (checkbox.checked) {
API.post(
'{{ path('post_team_customer', {'id': team.id, 'customerId': '000'}) }}'.replace(/000/, customerId),
{},
(json) => {ALERT.success('action.update.success');},
(error) => {ALERT.error('action.update.error', error);}, // TODO test me
);
} else {
API.delete(
'{{ path('delete_team_customer', {'id': team.id, 'customerId': '000'}) }}'.replace(/000/, customerId),
(json) => {ALERT.success('action.delete.success');},
(error) => {ALERT.error('action.delete.error', error);}, // TODO test me
);
}
}
}
);
{% endif %}
{% if projectForm is defined and customerForm is not null %}
document.getElementById('team_project_form').addEventListener('change',
(event) => {
if (event.target.matches('input[type=checkbox]')) {
const checkbox = event.target;
const projectId = checkbox.value;
if (checkbox.checked) {
API.post(
'{{ path('post_team_project', {'id': team.id, 'projectId': '000'}) }}'.replace(/000/, projectId),
{},
(json) => {ALERT.success('action.update.success');},
(error) => {ALERT.error('action.update.error', error);}, // TODO test me
);
} else {
API.delete(
'{{ path('delete_team_project', {'id': team.id, 'projectId': '000'}) }}'.replace(/000/, projectId),
(json) => {ALERT.success('action.delete.success');},
(error) => {ALERT.error('action.delete.error', error);}, // TODO test me
);
}
}
}
);
{% endif %}
});
</script>
{% endblock %}

View File

@@ -17,8 +17,16 @@
{% endif %}
{% if form.end_time is defined or form.duration is defined %}
<div class="mb-3 row">
<label class="col-form-label col-sm-2" for="{{ form.vars.name }}_duration">{{ 'duration'|trans }} / {{ 'endtime'|trans }}</label>
{% set size = form.duration is defined ? 5 : 10 %}
<label class="col-form-label col-sm-2" for="{{ form.vars.name }}_duration">
{% if form.duration is defined %}
{{ 'duration'|trans }}
{% if form.end_time is defined %} / {% endif %}
{% endif %}
{% if form.end_time is defined %}
{{ 'endtime'|trans }}
{% endif %}
</label>
{% set size = form.duration is defined and form.end_time is defined ? 5 : 10 %}
{% if form.duration is defined %}
<div class="col-sm-{{ size }}">
{{ form_widget(form.duration, {'label': false}) }}

View File

@@ -9,7 +9,7 @@
} %}
{% embed formEditTemplate with formOptions %}
{% block form_body %}
<fieldset>
<fieldset class="form-fieldset">
<div class="row">
<div class="col-md-4">
{{ form_row(form.customer) }}
@@ -22,27 +22,27 @@
</div>
</div>
</fieldset>
<fieldset>
<fieldset class="form-fieldset">
{{ form_row(form.replaceTags) }}
{{ form_row(form.tags) }}
</fieldset>
{% if form.user is defined %}
<fieldset>
<fieldset class="form-fieldset">
{{ form_row(form.user) }}
</fieldset>
{% endif %}
{% if form.exported is defined %}
<fieldset>
<fieldset class="form-fieldset">
{{ form_row(form.exported) }}
</fieldset>
{% endif %}
{% if form.billable is defined %}
<fieldset>
<fieldset class="form-fieldset">
{{ form_row(form.billable) }}
</fieldset>
{% endif %}
{% if form.recalculateRates is defined %}
<fieldset>
<fieldset class="form-fieldset">
{{ form_row(form.recalculateRates) }}
<div class="row">
<div class="col-md-6">
@@ -55,7 +55,7 @@
</fieldset>
{% endif %}
{% if form.metaFields is defined %}
<fieldset>
<fieldset class="form-fieldset">
<div class="row">
<div class="col-md-6">
{{ form_row(form.updateMeta) }}
@@ -97,6 +97,9 @@
if (elAttr.indexOf('{{ form.metaFields.vars.full_name }}') !== -1) {
{# disable all custom fields, each one has to be activated before it can be used #}
el.setAttribute('disabled', 'disabled');
if (el.tomselect !== undefined) {
el.tomselect.disable();
}
} else if (elAttr.indexOf('{{ form.updateMeta.vars.full_name }}') !== -1) {
el.checked = false;
el.onclick = function() {
@@ -117,8 +120,14 @@
}
if (metaCheckbox.checked) {
current.removeAttribute('disabled');
if (current.tomselect !== undefined) {
current.tomselect.enable();
}
} else {
current.setAttribute('disabled', 'disabled');
if (current.tomselect !== undefined) {
current.tomselect.disable();
}
}
}
};