Skip to content

Commit 2c73e7e

Browse files
Merge pull request #216 from developeregrem/4.7.0-dev
4.7.0 dev
2 parents 760631d + 7c19d7b commit 2c73e7e

54 files changed

Lines changed: 2428 additions & 411 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

assets/controllers/invoices_controller.js

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,27 @@ export default class extends Controller {
193193
}
194194

195195
fillFieldsFromPriceCategoryAction(event) {
196-
const values = (event.currentTarget.value || '').split('|');
196+
const select = event.currentTarget;
197+
const selected = select.options[select.selectedIndex];
198+
const values = (select.value || '').split('|');
199+
const isPackage = !!(selected && selected.dataset.isPackage === '1');
200+
const priceId = selected ? selected.dataset.priceId || '' : '';
201+
202+
const packageHidden = document.getElementById('packagePriceId');
203+
const packageInfo = document.getElementById('package-info');
204+
const description = document.getElementById('invoice_misc_position_description');
205+
const vat = document.getElementById('invoice_misc_position_vat');
206+
const price = document.getElementById('invoice_misc_position_price');
207+
const includesVat = document.getElementById('invoice_misc_position_includesVat');
208+
const isFlatPrice = document.getElementById('invoice_misc_position_isFlatPrice');
209+
const isPerRoom = document.getElementById('invoice_misc_position_isPerRoom');
210+
211+
if (packageHidden) packageHidden.value = isPackage ? priceId : '';
212+
if (packageInfo) packageInfo.classList.toggle('d-none', !isPackage);
213+
[description, vat, price, includesVat, isFlatPrice, isPerRoom].forEach((node) => {
214+
if (node) node.disabled = isPackage;
215+
});
216+
197217
if (values.length === 2) return;
198218
const map = [
199219
['invoice_misc_position_vat', 0],
@@ -204,13 +224,10 @@ export default class extends Controller {
204224
const node = document.getElementById(id);
205225
if (node) node.value = values[idx] || '';
206226
});
207-
const includesVat = document.getElementById('invoice_misc_position_includesVat');
208-
const isFlatPrice = document.getElementById('invoice_misc_position_isFlatPrice');
209-
const isPerRoom = document.getElementById('invoice_misc_position_isPerRoom');
210227
if (includesVat) includesVat.checked = values[3] === '1';
211228
if (isFlatPrice) isFlatPrice.checked = values[4] === '1';
212229
if (isPerRoom) isPerRoom.checked = values[5] === '1';
213-
if (isFlatPrice) {
230+
if (isFlatPrice && !isPackage) {
214231
this.applyFlatPriceState(isFlatPrice, isPerRoom);
215232
}
216233
const amount = document.getElementById('invoice_misc_position_amount');
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { Controller } from '@hotwired/stimulus';
2+
3+
/* stimulusFetch: 'lazy' */
4+
5+
export default class extends Controller {
6+
static values = {
7+
priceId: String,
8+
};
9+
10+
connect() {
11+
this.rowCounter = 0;
12+
this.applyRemainderStates();
13+
this.updateSumPreview();
14+
}
15+
16+
togglePackageAction(event) {
17+
const priceId = event.currentTarget.dataset.priceId || this.priceIdValue;
18+
const body = this.element.querySelector(`#package-body-${priceId}`);
19+
if (!body) return;
20+
const enabled = event.currentTarget.checked;
21+
body.classList.toggle('d-none', !enabled);
22+
if (enabled && this.getRows(priceId).length === 0) {
23+
this.addRow(priceId);
24+
this.addRow(priceId);
25+
}
26+
this.updateSumPreview();
27+
}
28+
29+
addRowAction(event) {
30+
event.preventDefault();
31+
const priceId = event.currentTarget.dataset.priceId || this.priceIdValue;
32+
this.addRow(priceId);
33+
this.updateSumPreview();
34+
}
35+
36+
removeRowAction(event) {
37+
event.preventDefault();
38+
const row = event.currentTarget.closest('.package-row');
39+
if (row) {
40+
row.remove();
41+
this.updateSumPreview();
42+
}
43+
}
44+
45+
updateSumPreviewAction() {
46+
this.updateSumPreview();
47+
}
48+
49+
toggleRemainderAction() {
50+
this.applyRemainderStates();
51+
this.updateSumPreview();
52+
}
53+
54+
applyRemainderStates() {
55+
const priceId = this.priceIdValue;
56+
this.getRows(priceId).forEach((row) => {
57+
const remRadio = row.querySelector('input[name^="component-remainder-"]');
58+
const typeSel = row.querySelector('select[name^="component-type-"]');
59+
const valInput = row.querySelector('input[name^="component-value-"]');
60+
const isRemainder = !!(remRadio && remRadio.checked);
61+
if (typeSel) typeSel.disabled = isRemainder;
62+
if (valInput) valInput.disabled = isRemainder;
63+
});
64+
}
65+
66+
addRow(priceId) {
67+
const template = document.getElementById(`package-row-template-${priceId}`);
68+
const tbody = document.getElementById(`package-rows-${priceId}`);
69+
if (!template || !tbody) return;
70+
const key = `n${Date.now()}${this.rowCounter++}`;
71+
const html = template.innerHTML.replace(/__KEY__/g, key);
72+
const wrapper = document.createElement('tbody');
73+
wrapper.innerHTML = html.trim();
74+
const row = wrapper.querySelector('tr');
75+
if (row) {
76+
tbody.appendChild(row);
77+
}
78+
}
79+
80+
getRows(priceId) {
81+
const tbody = document.getElementById(`package-rows-${priceId}`);
82+
return tbody ? tbody.querySelectorAll('.package-row') : [];
83+
}
84+
85+
updateSumPreview() {
86+
const priceId = this.priceIdValue;
87+
const priceInput = document.querySelector(`input[name="price-${priceId}"]`);
88+
if (!priceInput) return;
89+
const total = parseFloat((priceInput.value || '0').replace(',', '.')) || 0;
90+
91+
let percentSum = 0;
92+
let amountSum = 0;
93+
let hasRemainder = false;
94+
95+
this.getRows(priceId).forEach((row) => {
96+
const typeSel = row.querySelector('select[name^="component-type-"]');
97+
const valInput = row.querySelector('input[name^="component-value-"]');
98+
const remRadio = row.querySelector('input[name^="component-remainder-"]');
99+
if (!typeSel || !valInput) return;
100+
if (remRadio && remRadio.checked) {
101+
hasRemainder = true;
102+
return;
103+
}
104+
const val = parseFloat((valInput.value || '0').replace(',', '.')) || 0;
105+
if (typeSel.value === 'percent') {
106+
percentSum += val;
107+
} else {
108+
amountSum += val;
109+
}
110+
});
111+
112+
const preview = document.getElementById(`package-sum-preview-${priceId}`);
113+
if (!preview) return;
114+
const span = preview.querySelector('[data-sum-preview]');
115+
if (!span) return;
116+
117+
const covered = total * (percentSum / 100) + amountSum;
118+
const fmt = (n) => n.toFixed(2).replace('.', ',');
119+
if (hasRemainder) {
120+
const remainder = total - covered;
121+
span.textContent = `${fmt(covered)} € + ${fmt(Math.max(0, remainder))} € (Rest) = ${fmt(Math.max(covered, total))} €`;
122+
preview.classList.remove('bg-warning-subtle', 'bg-success-subtle');
123+
preview.classList.add(Math.abs(remainder) < 0.01 || remainder > 0 ? 'bg-success-subtle' : 'bg-warning-subtle');
124+
} else {
125+
span.textContent = `${fmt(covered)} € / ${fmt(total)} €`;
126+
preview.classList.remove('bg-warning-subtle', 'bg-success-subtle');
127+
preview.classList.add(Math.abs(covered - total) < 0.01 ? 'bg-success-subtle' : 'bg-warning-subtle');
128+
}
129+
}
130+
}

assets/controllers/prices_controller.js

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { Controller } from '@hotwired/stimulus';
2-
import { iniStartOrEndDate } from '../js/utils.js';
2+
import {
3+
disposeTooltips,
4+
enableTooltips,
5+
iniStartOrEndDate,
6+
whenBootstrapAndIconsReady,
7+
} from '../js/utils.js';
38

49
/* stimulusFetch: 'lazy' */
510

@@ -19,12 +24,27 @@ export default class extends Controller {
1924
}
2025
this.initState();
2126
this.observePeriodList();
27+
this.initPopovers();
28+
this.initTooltips();
29+
}
30+
31+
async initPopovers() {
32+
const ready = await whenBootstrapAndIconsReady();
33+
if (!ready) return;
34+
this.element.querySelectorAll('[data-bs-toggle="popover"]').forEach((el) => {
35+
window.bootstrap.Popover.getOrCreateInstance(el);
36+
});
37+
}
38+
39+
async initTooltips() {
40+
await enableTooltips(this.element);
2241
}
2342

2443
disconnect() {
2544
if (this.periodObserver) {
2645
this.periodObserver.disconnect();
2746
}
47+
disposeTooltips(this.element);
2848
}
2949

3050
beforeSubmitAction(event) {
@@ -171,6 +191,20 @@ export default class extends Controller {
171191
if (bookableOnlineCheckbox) {
172192
bookableOnlineCheckbox.disabled = !isMisc;
173193
}
194+
const packageWrapper = this.element.querySelector(`#package-wrap-${priceId}`);
195+
if (packageWrapper) {
196+
packageWrapper.style.display = isMisc ? '' : 'none';
197+
if (!isMisc) {
198+
const packageCheckbox = this.element.querySelector(`#is-package-${priceId}`);
199+
if (packageCheckbox) {
200+
packageCheckbox.checked = false;
201+
}
202+
const packageBody = this.element.querySelector(`#package-body-${priceId}`);
203+
if (packageBody) {
204+
packageBody.classList.add('d-none');
205+
}
206+
}
207+
}
174208
if (priceId === 'new' && isAppartment && isPerRoomCheckbox && !isPerRoomCheckbox.disabled) {
175209
isPerRoomCheckbox.checked = true;
176210
}

config/services.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ parameters:
1010
uploadDirectory: '%kernel.project_dir%/public/%publicUploadDirectory%'
1111
roomCategoryImageDirectory: '%kernel.project_dir%/public/resources/images/room-categories'
1212
roomCategoryImagePublicDirectory: 'resources/images/room-categories'
13-
version: '4.6.2'
13+
version: '4.7.0'
1414
mailCopy: '%env(default:default_mailcopy:MAIL_COPY)%'
1515
default_mailcopy: 'true'
1616
passkey_enabled: '%env(bool:PASSKEY_ENABLED)%'

migrations/Version20260421120000.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,4 +98,9 @@ public function down(Schema $schema): void
9898
$this->addSql('UPDATE tax_rates SET datev_bu_key = datev_output_bu_key WHERE datev_output_bu_key IS NOT NULL');
9999
$this->addSql('ALTER TABLE tax_rates DROP datev_output_bu_key, DROP datev_input_bu_key, DROP chart_preset');
100100
}
101+
102+
public function isTransactional(): bool
103+
{
104+
return false;
105+
}
101106
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace DoctrineMigrations;
6+
7+
use Doctrine\DBAL\Schema\Schema;
8+
use Doctrine\Migrations\AbstractMigration;
9+
10+
final class Version20260422130000 extends AbstractMigration
11+
{
12+
public function getDescription(): string
13+
{
14+
return 'Add price_components for split-VAT packages, optional revenue_account overrides on prices, price_components, invoice_positions and invoice_appartments, plus accounting_settings labels for booking journal remarks.';
15+
}
16+
17+
public function up(Schema $schema): void
18+
{
19+
$this->addSql('CREATE TABLE price_components (
20+
id INT AUTO_INCREMENT NOT NULL,
21+
price_id INT NOT NULL,
22+
revenue_account_id INT DEFAULT NULL,
23+
description VARCHAR(100) NOT NULL,
24+
vat NUMERIC(10, 2) NOT NULL,
25+
allocation_type VARCHAR(16) NOT NULL,
26+
allocation_value NUMERIC(10, 4) NOT NULL,
27+
is_remainder TINYINT(1) NOT NULL,
28+
sort_order SMALLINT NOT NULL,
29+
INDEX IDX_PRICE_COMPONENTS_PRICE (price_id),
30+
INDEX IDX_PRICE_COMPONENTS_REVENUE_ACCOUNT (revenue_account_id),
31+
PRIMARY KEY(id)
32+
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
33+
34+
$this->addSql('ALTER TABLE price_components
35+
ADD CONSTRAINT FK_PRICE_COMPONENTS_PRICE FOREIGN KEY (price_id) REFERENCES prices (id) ON DELETE CASCADE');
36+
$this->addSql('ALTER TABLE price_components
37+
ADD CONSTRAINT FK_PRICE_COMPONENTS_REVENUE_ACCOUNT FOREIGN KEY (revenue_account_id) REFERENCES accounting_accounts (id) ON DELETE SET NULL');
38+
39+
$this->addSql('ALTER TABLE prices ADD revenue_account_id INT DEFAULT NULL');
40+
$this->addSql('ALTER TABLE prices
41+
ADD CONSTRAINT FK_PRICES_REVENUE_ACCOUNT FOREIGN KEY (revenue_account_id) REFERENCES accounting_accounts (id) ON DELETE SET NULL');
42+
$this->addSql('CREATE INDEX IDX_PRICES_REVENUE_ACCOUNT ON prices (revenue_account_id)');
43+
44+
$this->addSql('ALTER TABLE invoice_positions ADD revenue_account_id INT DEFAULT NULL');
45+
$this->addSql('ALTER TABLE invoice_positions
46+
ADD CONSTRAINT FK_INVOICE_POSITIONS_REVENUE_ACCOUNT FOREIGN KEY (revenue_account_id) REFERENCES accounting_accounts (id) ON DELETE SET NULL');
47+
$this->addSql('CREATE INDEX IDX_INVOICE_POSITIONS_REVENUE_ACCOUNT ON invoice_positions (revenue_account_id)');
48+
49+
$this->addSql('ALTER TABLE invoice_appartments ADD revenue_account_id INT DEFAULT NULL');
50+
$this->addSql('ALTER TABLE invoice_appartments
51+
ADD CONSTRAINT FK_INVOICE_APPARTMENTS_REVENUE_ACCOUNT FOREIGN KEY (revenue_account_id) REFERENCES accounting_accounts (id) ON DELETE SET NULL');
52+
$this->addSql('CREATE INDEX IDX_INVOICE_APPARTMENTS_REVENUE_ACCOUNT ON invoice_appartments (revenue_account_id)');
53+
54+
$this->addSql('ALTER TABLE accounting_settings
55+
ADD main_position_label VARCHAR(60) DEFAULT NULL,
56+
ADD misc_position_label VARCHAR(60) DEFAULT NULL');
57+
58+
// Prevent deleting tax rates and accounting accounts from clearing references on booking journal entries.
59+
$this->addSql('ALTER TABLE booking_entries DROP FOREIGN KEY FK_be_debit');
60+
$this->addSql('ALTER TABLE booking_entries DROP FOREIGN KEY FK_be_credit');
61+
$this->addSql('ALTER TABLE booking_entries DROP FOREIGN KEY FK_be_taxrate');
62+
63+
$this->addSql('ALTER TABLE booking_entries
64+
ADD CONSTRAINT FK_be_debit FOREIGN KEY (debit_account_id) REFERENCES accounting_accounts (id)');
65+
$this->addSql('ALTER TABLE booking_entries
66+
ADD CONSTRAINT FK_be_credit FOREIGN KEY (credit_account_id) REFERENCES accounting_accounts (id)');
67+
$this->addSql('ALTER TABLE booking_entries
68+
ADD CONSTRAINT FK_be_taxrate FOREIGN KEY (tax_rate_id) REFERENCES tax_rates (id)');
69+
70+
}
71+
72+
public function down(Schema $schema): void
73+
{
74+
$this->addSql('ALTER TABLE accounting_settings
75+
DROP main_position_label,
76+
DROP misc_position_label');
77+
78+
$this->addSql('ALTER TABLE invoice_appartments DROP FOREIGN KEY FK_INVOICE_APPARTMENTS_REVENUE_ACCOUNT');
79+
$this->addSql('DROP INDEX IDX_INVOICE_APPARTMENTS_REVENUE_ACCOUNT ON invoice_appartments');
80+
$this->addSql('ALTER TABLE invoice_appartments DROP revenue_account_id');
81+
82+
$this->addSql('ALTER TABLE invoice_positions DROP FOREIGN KEY FK_INVOICE_POSITIONS_REVENUE_ACCOUNT');
83+
$this->addSql('DROP INDEX IDX_INVOICE_POSITIONS_REVENUE_ACCOUNT ON invoice_positions');
84+
$this->addSql('ALTER TABLE invoice_positions DROP revenue_account_id');
85+
86+
$this->addSql('ALTER TABLE prices DROP FOREIGN KEY FK_PRICES_REVENUE_ACCOUNT');
87+
$this->addSql('DROP INDEX IDX_PRICES_REVENUE_ACCOUNT ON prices');
88+
$this->addSql('ALTER TABLE prices DROP revenue_account_id');
89+
90+
$this->addSql('ALTER TABLE price_components DROP FOREIGN KEY FK_PRICE_COMPONENTS_REVENUE_ACCOUNT');
91+
$this->addSql('ALTER TABLE price_components DROP FOREIGN KEY FK_PRICE_COMPONENTS_PRICE');
92+
$this->addSql('DROP TABLE price_components');
93+
94+
$this->addSql('ALTER TABLE booking_entries DROP FOREIGN KEY FK_be_debit');
95+
$this->addSql('ALTER TABLE booking_entries DROP FOREIGN KEY FK_be_credit');
96+
$this->addSql('ALTER TABLE booking_entries DROP FOREIGN KEY FK_be_taxrate');
97+
98+
$this->addSql('ALTER TABLE booking_entries
99+
ADD CONSTRAINT FK_be_debit FOREIGN KEY (debit_account_id) REFERENCES accounting_accounts (id) ON DELETE SET NULL');
100+
$this->addSql('ALTER TABLE booking_entries
101+
ADD CONSTRAINT FK_be_credit FOREIGN KEY (credit_account_id) REFERENCES accounting_accounts (id) ON DELETE SET NULL');
102+
$this->addSql('ALTER TABLE booking_entries
103+
ADD CONSTRAINT FK_be_taxrate FOREIGN KEY (tax_rate_id) REFERENCES tax_rates (id) ON DELETE SET NULL');
104+
}
105+
106+
public function isTransactional(): bool
107+
{
108+
return false;
109+
}
110+
}

0 commit comments

Comments
 (0)