feat: add housekeeping module for household staff management

* Adding flexible reminder options to birthdays

* Fix database migration merge conflict

* Truncate calendar popup descriptions

* Log app version on backend startup

* Add host-mounted data and backup folders

* feat: add housekeeping module

* fix: align housekeeping UI and add task creation

* refactor: rebuild housekeeping experience

* feat: support multiple housekeeping staff

* feat: integrate housekeeping visits with calendar

* feat: refine housekeeping visits and payments

* feat: add housekeeping staff visit logs

* feat: add housekeeping receipts and document folders

* feat: localize housekeeping folders and chores

* feat: refine housekeeping tabs and document folders

* fix: sync housekeeping tab active state

* feat: use configured app name in onboarding and manifest
This commit is contained in:
Rafael Foster
2026-05-08 15:14:51 -03:00
committed by GitHub
parent d19689a1ab
commit 22ec13e559
38 changed files with 7127 additions and 235 deletions
+109 -96
View File
@@ -181,7 +181,7 @@ const EVENT_ICON_CATEGORIES = () => [
{ value: 'building', label: t('calendar.iconBuilding') },
{ value: 'wrench', label: t('calendar.iconRepair') },
{ value: 'hammer', label: t('calendar.iconMaintenance') },
{ value: 'paintbrush', label: t('calendar.iconDecoration') },
{ value: 'paintbrush', label: t('calendar.iconCleaning') },
{ value: 'sofa', label: t('calendar.iconFurniture') },
{ value: 'washing-machine', label: t('calendar.iconLaundry') },
],
@@ -210,6 +210,98 @@ const CALENDAR_VIEW_STORAGE_KEY = 'oikos-calendar-view';
const HOUR_HEIGHT = 56; // px pro Stunde in Wochen-/Tagesansicht
function renderIconPickerResults(selectedIcon, query = '') {
const q = query.trim().toLowerCase();
if (q) {
const filtered = EVENT_ICON_CATEGORIES()
.flatMap((c) => c.icons)
.filter((icon) => icon.label.toLowerCase().includes(q) || icon.value.includes(q));
if (filtered.length === 0) {
return `<div class="event-icon-picker__no-results">${esc(t('calendar.iconSearchEmpty'))}</div>`;
}
return `
<div class="event-icon-picker__category-icons">
${filtered.map((icon) => iconPickerOptionHtml(icon, selectedIcon)).join('')}
</div>`;
}
return EVENT_ICON_CATEGORIES().map((cat) => `
<div class="event-icon-picker__category">
<div class="event-icon-picker__category-label">${esc(cat.label)}</div>
<div class="event-icon-picker__category-icons">
${cat.icons.map((icon) => iconPickerOptionHtml(icon, selectedIcon)).join('')}
</div>
</div>`).join('');
}
function iconPickerOptionHtml(icon, selectedIcon) {
return `
<button type="button"
class="event-icon-picker__option ${selectedIcon === icon.value ? 'event-icon-picker__option--active' : ''}"
data-icon="${icon.value}"
role="radio"
aria-checked="${selectedIcon === icon.value ? 'true' : 'false'}"
aria-label="${esc(icon.label)}"
title="${esc(icon.label)}">
${eventIconHtml(icon.value, 'event-icon-picker__option-icon')}
</button>`;
}
function openIconPickerDialog(selectedIcon, onSelect, onClose = () => {}) {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay event-icon-dialog';
overlay.setAttribute('aria-modal', 'true');
const panel = document.createElement('div');
panel.className = 'modal-panel modal-panel--md event-icon-dialog__panel';
panel.setAttribute('role', 'dialog');
panel.setAttribute('aria-label', t('calendar.iconLabel'));
panel.insertAdjacentHTML('beforeend', `
<div class="modal-panel__header">
<span class="modal-panel__title">${esc(t('calendar.iconLabel'))}</span>
<button class="modal-panel__close btn--ghost" type="button" aria-label="${esc(t('common.close'))}">
<i data-lucide="x" style="width:16px;height:16px;" aria-hidden="true"></i>
</button>
</div>
<div class="modal-panel__body event-icon-dialog__body">
<input type="search" class="form-input event-icon-picker__search" id="event-icon-dialog-search"
placeholder="${esc(t('calendar.iconSearchPlaceholder'))}" autocomplete="off" aria-label="${esc(t('calendar.iconSearchPlaceholder'))}">
<div class="event-icon-dialog__results" id="event-icon-dialog-results" role="radiogroup" aria-label="${esc(t('calendar.iconLabel'))}">
${renderIconPickerResults(selectedIcon)}
</div>
</div>
`);
function close() {
overlay.remove();
document.removeEventListener('keydown', onKeydown);
onClose();
}
function onKeydown(e) {
if (e.key === 'Escape') close();
}
panel.querySelector('.modal-panel__close')?.addEventListener('click', close);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
panel.querySelector('#event-icon-dialog-search')?.addEventListener('input', (e) => {
const results = panel.querySelector('#event-icon-dialog-results');
results?.replaceChildren();
results?.insertAdjacentHTML('beforeend', renderIconPickerResults(selectedIcon, e.target.value));
if (window.lucide) lucide.createIcons({ el: results });
});
panel.addEventListener('click', (e) => {
const btn = e.target.closest('.event-icon-picker__option');
if (!btn) return;
onSelect(btn.dataset.icon);
close();
});
overlay.appendChild(panel);
document.body.appendChild(overlay);
document.addEventListener('keydown', onKeydown);
panel.querySelector('#event-icon-dialog-search')?.focus();
if (window.lucide) lucide.createIcons({ el: panel });
}
/**
* Gibt eine lesbare Textfarbe für eine Hintergrundfarbe zurück.
* Helle Hintergründe (z.B. Hellgelb, Hellgrün) → dunkles Grau statt Weiß.
@@ -1373,7 +1465,6 @@ function openEventModal({ mode, event = null, date = null, reminder = null }) {
const iconInput = panel.querySelector('#modal-icon');
const iconTrigger = panel.querySelector('#modal-icon-trigger');
const iconGrid = panel.querySelector('#modal-icon-grid');
const selectIcon = (icon) => {
const nextIcon = eventIconName(icon);
if (iconInput) iconInput.value = nextIcon;
@@ -1381,79 +1472,19 @@ function openEventModal({ mode, event = null, date = null, reminder = null }) {
iconTrigger.dataset.icon = nextIcon;
iconTrigger.replaceChildren(eventIconElement(nextIcon, 'event-icon-picker__trigger-icon'));
}
iconGrid?.querySelectorAll('.event-icon-picker__option').forEach((btn) => {
const active = btn.dataset.icon === nextIcon;
btn.classList.toggle('event-icon-picker__option--active', active);
btn.setAttribute('aria-checked', active ? 'true' : 'false');
});
if (window.lucide) lucide.createIcons();
};
iconTrigger?.addEventListener('click', () => {
if (!iconGrid) return;
iconGrid.hidden = !iconGrid.hidden;
iconTrigger.setAttribute('aria-expanded', iconGrid.hidden ? 'false' : 'true');
});
iconGrid?.addEventListener('click', (e) => {
const btn = e.target.closest('.event-icon-picker__option');
if (!btn) return;
selectIcon(btn.dataset.icon);
iconGrid.hidden = true;
iconTrigger?.setAttribute('aria-expanded', 'false');
iconTrigger?.focus();
});
const iconSearch = iconGrid?.querySelector('#modal-icon-search');
iconSearch?.addEventListener('input', () => {
const q = iconSearch.value.trim().toLowerCase();
const resultsEl = iconGrid?.querySelector('#modal-icon-results');
if (!resultsEl) return;
if (!q) {
resultsEl.replaceChildren();
resultsEl.insertAdjacentHTML('afterbegin', EVENT_ICON_CATEGORIES().map((cat) => `
<div class="event-icon-picker__category">
<div class="event-icon-picker__category-label">${esc(cat.label)}</div>
<div class="event-icon-picker__category-icons">
${cat.icons.map((icon) => `
<button type="button" class="event-icon-picker__option ${iconInput?.value === icon.value ? 'event-icon-picker__option--active' : ''}"
data-icon="${icon.value}" role="radio"
aria-checked="${iconInput?.value === icon.value ? 'true' : 'false'}"
aria-label="${esc(icon.label)}" title="${esc(icon.label)}">
${eventIconHtml(icon.value, 'event-icon-picker__option-icon')}
</button>`).join('')}
</div>
</div>`).join(''));
if (window.lucide) lucide.createIcons({ el: resultsEl });
return;
}
const allIcons = EVENT_ICON_CATEGORIES().flatMap((c) => c.icons);
const filtered = allIcons.filter((i) => i.label.toLowerCase().includes(q) || i.value.includes(q));
resultsEl.replaceChildren();
if (filtered.length === 0) {
resultsEl.insertAdjacentHTML('afterbegin', `<div class="event-icon-picker__no-results">${esc(t('calendar.iconSearchEmpty'))}</div>`);
return;
}
resultsEl.insertAdjacentHTML('afterbegin', `
<div class="event-icon-picker__category-icons">
${filtered.map((icon) => `
<button type="button" class="event-icon-picker__option ${iconInput?.value === icon.value ? 'event-icon-picker__option--active' : ''}"
data-icon="${icon.value}" role="radio"
aria-checked="${iconInput?.value === icon.value ? 'true' : 'false'}"
aria-label="${esc(icon.label)}" title="${esc(icon.label)}">
${eventIconHtml(icon.value, 'event-icon-picker__option-icon')}
</button>`).join('')}
</div>`);
if (window.lucide) lucide.createIcons({ el: resultsEl });
});
document.addEventListener('click', function closeIconPicker(e) {
if (!panel.isConnected) {
document.removeEventListener('click', closeIconPicker);
return;
}
if (iconGrid?.hidden || iconGrid?.contains(e.target) || iconTrigger?.contains(e.target)) return;
iconGrid.hidden = true;
iconTrigger?.setAttribute('aria-expanded', 'false');
iconTrigger.setAttribute('aria-expanded', 'true');
openIconPickerDialog(iconInput?.value || 'calendar', (icon) => {
selectIcon(icon);
iconTrigger?.setAttribute('aria-expanded', 'false');
iconTrigger?.focus();
}, () => {
iconTrigger?.setAttribute('aria-expanded', 'false');
iconTrigger?.focus();
});
});
const reminderOffset = panel.querySelector('#modal-reminder-offset');
@@ -1550,23 +1581,6 @@ function buildEventModalContent({ mode, event, date, reminder = null }) {
const endTime = isEdit && event.end_datetime && event.end_datetime.length > 10
? localTime(event.end_datetime) : '10:00';
const selectedIcon = eventIconName(isEdit ? event.icon : 'calendar');
const iconCats = EVENT_ICON_CATEGORIES();
const iconCategoryButtons = iconCats.map((cat) => `
<div class="event-icon-picker__category">
<div class="event-icon-picker__category-label">${esc(cat.label)}</div>
<div class="event-icon-picker__category-icons">
${cat.icons.map((icon) => `
<button type="button"
class="event-icon-picker__option ${selectedIcon === icon.value ? 'event-icon-picker__option--active' : ''}"
data-icon="${icon.value}"
role="radio"
aria-checked="${selectedIcon === icon.value ? 'true' : 'false'}"
aria-label="${esc(icon.label)}"
title="${esc(icon.label)}">
${eventIconHtml(icon.value, 'event-icon-picker__option-icon')}
</button>`).join('')}
</div>
</div>`).join('');
const selectedUserIds = isEdit
? (event.assigned_users?.map((u) => u.id) ?? (event.assigned_to ? [event.assigned_to] : []))
@@ -1593,14 +1607,6 @@ function buildEventModalContent({ mode, event, date, reminder = null }) {
placeholder="${t('calendar.titlePlaceholder')}" value="${esc(isEdit ? event.title : '')}">
</div>
</div>
<div class="event-icon-picker__grid" id="modal-icon-grid" role="radiogroup" aria-label="${t('calendar.iconLabel')}" hidden>
<input type="search" class="form-input event-icon-picker__search" id="modal-icon-search"
placeholder="${t('calendar.iconSearchPlaceholder')}" autocomplete="off" aria-label="${t('calendar.iconSearchPlaceholder')}">
<div id="modal-icon-results">
${iconCategoryButtons}
</div>
</div>
<div class="form-group">
<label class="toggle">
<input type="checkbox" id="modal-allday" ${isEdit && event.all_day ? 'checked' : ''}>
@@ -1812,6 +1818,13 @@ async function saveEvent(overlay, mode, eventId, existingReminder = null, attach
attachment_mime: attachmentPayload.mime,
attachment_size: attachmentPayload.size,
attachment_data: attachmentPayload.data,
document_folder_name: t('documents.calendarItemsFolder'),
document_name: attachmentPayload.name
? t('calendar.attachmentDocumentName', { title, name: attachmentPayload.name })
: null,
document_description: attachmentPayload.name
? t('calendar.attachmentDocumentDescription', { title })
: null,
target_caldav_account_id,
target_caldav_calendar_url,
};