Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions assets/bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import TemplatesController from './controllers/templates_controller.js';
import PricesController from './controllers/prices_controller.js';
import ThemeController from './controllers/theme_controller.js';
import CalendarImportsController from './controllers/calendar_imports_controller.js';
import HousekeepingController from './controllers/housekeeping_controller.js';
import OperationsReportsController from './controllers/operations_reports_controller.js';

const app = startStimulusApp();
app.register('login', LoginController);
Expand All @@ -31,3 +33,5 @@ app.register('templates', TemplatesController);
app.register('prices', PricesController);
app.register('theme', ThemeController);
app.register('calendar-imports', CalendarImportsController);
app.register('housekeeping', HousekeepingController);
app.register('operations-reports', OperationsReportsController);
51 changes: 51 additions & 0 deletions assets/controllers/housekeeping_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Controller } from '@hotwired/stimulus';
import { request as httpRequest, serializeForm as httpSerializeForm } from './http_controller.js';

export default class extends Controller {
static targets = ['form', 'spinner'];

submitFilters(event) {
this.spin();
if (event) {
event.preventDefault();
}

if (this.formTarget && typeof this.formTarget.requestSubmit === 'function') {
this.formTarget.requestSubmit();
} else if (this.formTarget) {
this.formTarget.submit();
}
}

spin() {
if (this.hasSpinnerTarget) {
this.spinnerTarget.classList.add('fa-spin');
}
}

async saveRow(event) {
event.preventDefault();
const form = event.target;
const submitter = event.submitter || form.querySelector('button[type="submit"]');

if (submitter) {
submitter.disabled = true;
}

httpRequest({
url: form.action,
method: form.method || 'POST',
data: httpSerializeForm(form),
loader: false,
onSuccess: () => {},
onComplete: () => {
if (submitter) {
submitter.disabled = false;
}
},
onError: (message) => {
console.warn('[housekeeping] save failed', message);
},
});
}
}
69 changes: 69 additions & 0 deletions assets/controllers/operations_reports_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Controller } from '@hotwired/stimulus';
import { request as httpRequest, serializeForm as httpSerializeForm } from './http_controller.js';

export default class extends Controller {
static targets = ['form', 'download', 'preview', 'spinner'];

connect() {
this.updateLinks();
this.loadPreview();
}

preventSubmit(event) {
if (event) {
event.preventDefault();
}
}

updateLinks() {
if (!this.hasFormTarget) {
return;
}
const query = httpSerializeForm(this.formTarget);
if (this.hasDownloadTarget) {
const baseUrl = this.downloadTarget.dataset.baseUrl || this.downloadTarget.href;
this.downloadTarget.href = this.appendQuery(baseUrl, query);
}
if (this.hasPreviewTarget) {
const basePreview = this.previewTarget.dataset.basePreviewUrl || '';
if (basePreview) {
this.previewTarget.dataset.previewUrl = this.appendQuery(basePreview, query);
}
}
}

loadPreview(event) {
if (event) {
event.preventDefault();
}
this.updateLinks();
if (!this.hasPreviewTarget) {
return;
}
const url = this.previewTarget.dataset.previewUrl;
if (!url) {
return;
}
if (this.hasSpinnerTarget) {
this.spinnerTarget.classList.add('fa-spin');
}
httpRequest({
url,
method: 'GET',
target: this.previewTarget,
loader: false,
onComplete: () => {
if (this.hasSpinnerTarget) {
this.spinnerTarget.classList.remove('fa-spin');
}
},
});
}

appendQuery(baseUrl, query) {
if (!query) {
return baseUrl;
}
return baseUrl + (baseUrl.includes('?') ? '&' : '?') + query;
}
}
19 changes: 19 additions & 0 deletions assets/controllers/reservations_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -1051,6 +1051,25 @@ export default class extends Controller {
}
}

updateReservationStatusAction(event) {
const select = event.currentTarget;
const url = select.dataset.url;
const token = select.dataset.token;
if (!url || !token) {
return;
}
select.disabled = true;
httpRequest({
url,
method: 'POST',
loader: false,
data: { status: select.value, _token: token },
onComplete: () => {
select.disabled = false;
}
});
}

showFeedback(data, target = null) {
if (!data || typeof data !== 'string' || data.trim().length === 0) {
return false;
Expand Down
41 changes: 38 additions & 3 deletions assets/controllers/statistics_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export default class extends Controller {
monthlyOriginUrl: String,
yearlyOriginUrl: String,
snapshotUrl: String,
snapshotIgnoreUrl: String,
snapshotArrivalsLabel: String,
snapshotOvernightsLabel: String,
snapshotRoomLabel: String,
Expand Down Expand Up @@ -312,6 +313,7 @@ export default class extends Controller {
const params = this.snapshotParams(force);
const response = await fetch(`${this.snapshotUrlValue}?${params.toString()}`);
const data = await response.json();
this.currentSnapshotId = data.id || null;
const countryNames = data.countryNames || {};

this.updateSnapshotSummary(data.metrics || {});
Expand Down Expand Up @@ -369,20 +371,34 @@ export default class extends Controller {
updateSnapshotWarnings(warnings) {
if (!this.hasSnapshotWarningsTarget) return;
this.snapshotWarningsTarget.innerHTML = '';
if (!warnings.length) {
const activeWarnings = warnings.filter((warning) => !warning.ignored);
if (!activeWarnings.length) {
const li = document.createElement('li');
li.className = 'text-muted';
li.textContent = this.snapshotWarningsTarget.dataset.emptyText || '';
this.snapshotWarningsTarget.appendChild(li);
return;
}
warnings.forEach((warning) => {
activeWarnings.forEach((warning) => {
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'form-check-input me-2';
checkbox.checked = false;
checkbox.addEventListener('change', async () => {
await this.toggleWarningIgnore(warning, checkbox.checked);
this.drawSnapshot(false);
});

const li = document.createElement('li');
li.className = 'd-flex align-items-start mb-1';
const start = warning.start_date || '';
const end = warning.end_date || '';
const roomLabel = this.snapshotRoomLabelValue || '';
const room = warning.appartment_number ? ` ${roomLabel} ${warning.appartment_number}` : '';
li.textContent = `${warning.message || ''}${room} ${start} - ${end}`.trim();
const text = document.createElement('span');
text.textContent = `${warning.message || ''}${room} ${start} - ${end}`.trim();
li.appendChild(checkbox);
li.appendChild(text);
this.snapshotWarningsTarget.appendChild(li);
});
}
Expand Down Expand Up @@ -475,4 +491,23 @@ export default class extends Controller {
const upper = code.toUpperCase();
return countryNames[upper] || countryNames[code] || code;
}

async toggleWarningIgnore(warning, ignored) {
if (!this.snapshotIgnoreUrlValue) return;
if (!this.currentSnapshotId) return;
const params = new URLSearchParams();
params.append('reservationId', warning.reservation_id);
params.append('ignored', ignored ? '1' : '0');
const csrfToken = document.getElementById('statistics_csrf_token');
if (csrfToken) {
params.append('_csrf_token', csrfToken.value);
}

const baseUrl = this.snapshotIgnoreUrlValue.replace(/\/0$/, '');
await fetch(`${baseUrl}/${this.currentSnapshotId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});
}
}
38 changes: 35 additions & 3 deletions assets/controllers/templates_controller.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
static targets = ['importNotice'];

connect() {
this.modalContent = document.getElementById('modal-content-ajax');
const modalDialog = document.querySelector('#modalCenter .modal-dialog');
Expand All @@ -11,6 +13,7 @@ export default class extends Controller {
this.registerTinyMceFocusFix();
this.observeModalContent();
this.initTinyMceEditor();
this.hideDismissedImportNotice();
}

disconnect() {
Expand Down Expand Up @@ -101,14 +104,14 @@ export default class extends Controller {
protect: [
/\{\%[\s\S]*?%\}/g,
/\{\#[\s\S]*?#\}/g,
/<\/?.*(html)?pageheader.*?>/g,
/<\/?.*(html)?pagefooter.*?>/g,
],
custom_elements: 'htmlpageheader,htmlpagefooter,sethtmlpageheader,sethtmlpagefooter',
extended_valid_elements: 'htmlpageheader[name|class|style],htmlpagefooter[name|class|style],sethtmlpageheader[name|value|show-this-page],sethtmlpagefooter[name|value|page]',
templates: templatesUrl,
entity_encoding: 'raw',
branding: false,
promotion: false,
valid_children: '+body[style]',
valid_children: '+body[style|htmlpageheader|htmlpagefooter|sethtmlpageheader|sethtmlpagefooter],+htmlpageheader[div|span|p|br|#text],+htmlpagefooter[div|span|p|br|#text]',
content_css: [
`${basepath}/resources/css/editor.css`,
],
Expand Down Expand Up @@ -149,4 +152,33 @@ export default class extends Controller {
observer.observe(this.modalContent, { childList: true, subtree: true });
this.modalObserver = observer;
}

dismissImportNotice() {
this.storeImportNoticeDismissed();
if (this.hasImportNoticeTarget) {
this.importNoticeTarget.remove();
}
}

hideDismissedImportNotice() {
if (this.isImportNoticeDismissed() && this.hasImportNoticeTarget) {
this.importNoticeTarget.remove();
}
}

isImportNoticeDismissed() {
try {
return window.localStorage.getItem('templates.operations.import.dismissed') === '1';
} catch (error) {
return false;
}
}

storeImportNoticeDismissed() {
try {
window.localStorage.setItem('templates.operations.import.dismissed', '1');
} catch (error) {
// Ignore storage issues.
}
}
}
4 changes: 4 additions & 0 deletions assets/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ body {
font-size: 0.9rem
}

.hk-row-form {
display: contents;
}

.btn {
font-size: 0.9rem;
}
Expand Down
4 changes: 4 additions & 0 deletions config/packages/security.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ security:
- ROLE_REGISTRATIONBOOK
- ROLE_STATISTICS
- ROLE_CASHJOURNAL
- ROLE_HOUSEKEEPING

# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
Expand All @@ -59,6 +60,9 @@ security:
- { path: ^/register, roles: PUBLIC_ACCESS}
- { path: ^/apartments/calendar, roles: PUBLIC_ACCESS }
- { path: ^/reservation/*, roles: [ROLE_RESERVATIONS_RO, ROLE_RESERVATIONS] }
- { path: ^/operations/housekeeping, roles: ROLE_HOUSEKEEPING }
- { path: ^/operations/frontdesk, roles: ROLE_HOUSEKEEPING }
- { path: ^/operations/reports, roles: ROLE_HOUSEKEEPING }
- { path: ^/customers, roles: ROLE_CUSTOMERS }
- { path: ^/invoices, roles: ROLE_INVOICES }
- { path: ^/registrationbook, roles: ROLE_REGISTRATIONBOOK }
Expand Down
1 change: 1 addition & 0 deletions config/packages/translation.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ framework:
- '%kernel.project_dir%/translations/Users'
- '%kernel.project_dir%/translations/RoomCategory'
- '%kernel.project_dir%/translations/ReservationStatus'
- '%kernel.project_dir%/translations/Housekeeping'
50 changes: 50 additions & 0 deletions migrations/Version20260121120000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20260121120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add housekeeping room day statuses and role.';
}

public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE room_day_statuses (id INT AUTO_INCREMENT NOT NULL, appartment_id INT NOT NULL, assigned_to_id INT DEFAULT NULL, updated_by_id INT DEFAULT NULL, date DATE NOT NULL, hk_status VARCHAR(20) NOT NULL, note LONGTEXT DEFAULT NULL, updated_at DATETIME NOT NULL, UNIQUE INDEX uniq_room_day (appartment_id, date), INDEX IDX_1C76B19393362AA5 (appartment_id), INDEX IDX_1C76B193F91F2105 (assigned_to_id), INDEX IDX_1C76B193896DBBDE (updated_by_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE room_day_statuses ADD CONSTRAINT FK_1C76B19393362AA5 FOREIGN KEY (appartment_id) REFERENCES appartments (id)');
$this->addSql('ALTER TABLE room_day_statuses ADD CONSTRAINT FK_1C76B193F91F2105 FOREIGN KEY (assigned_to_id) REFERENCES users (id)');
$this->addSql('ALTER TABLE room_day_statuses ADD CONSTRAINT FK_1C76B193896DBBDE FOREIGN KEY (updated_by_id) REFERENCES users (id)');
$this->addSql('INSERT INTO roles (id, name, role) VALUES (NULL, "Housekeeping", "ROLE_HOUSEKEEPING")');
$this->addSql("INSERT INTO template_types (name, icon, service, editor_template) SELECT 'TEMPLATE_OPERATIONS_PDF', 'fa-file-pdf', 'OperationsReportService', 'editor_template_operations.json.twig' WHERE NOT EXISTS (SELECT 1 FROM template_types WHERE name = 'TEMPLATE_OPERATIONS_PDF')");
$this->addSql("INSERT INTO template_types (name, icon, service, editor_template) SELECT 'TEMPLATE_REGISTRATION_PDF', 'fa-file-pdf', 'ReservationService', 'editor_template_reservation.json.twig' WHERE NOT EXISTS (SELECT 1 FROM template_types WHERE name = 'TEMPLATE_REGISTRATION_PDF')");

$this->addSql('ALTER TABLE reservation_status ADD code VARCHAR(50) DEFAULT NULL, ADD is_blocking TINYINT(1) NOT NULL DEFAULT 1');
$this->addSql('CREATE UNIQUE INDEX UNIQ_RESERVATION_STATUS_CODE ON reservation_status (code)');
$this->addSql("UPDATE reservation_status SET is_blocking = 1 WHERE is_blocking IS NULL");
$this->addSql("INSERT INTO reservation_status (name, color, contrast_color, code, is_blocking) SELECT 'Storniert / No-Show', '#6c757d', '#ffffff', 'canceled_noshow', 0 WHERE NOT EXISTS (SELECT 1 FROM reservation_status WHERE code = 'canceled_noshow')");
}

public function down(Schema $schema): void
{
$this->addSql('DROP TABLE room_day_statuses');
$this->addSql('DELETE FROM roles WHERE roles.role = "ROLE_HOUSEKEEPING"');
$this->addSql("DELETE FROM template_types WHERE name = 'TEMPLATE_OPERATIONS_PDF'");
$this->addSql("DELETE FROM template_types WHERE name = 'TEMPLATE_REGISTRATION_PDF'");

$this->addSql("DELETE FROM reservation_status WHERE code = 'canceled_noshow'");
$this->addSql('DROP INDEX UNIQ_RESERVATION_STATUS_CODE ON reservation_status');
$this->addSql('ALTER TABLE reservation_status DROP code, DROP is_blocking');

}

public function isTransactional(): bool
{
return false;
}
}
Loading