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,
};
+7 -1
View File
@@ -15,10 +15,16 @@ let _fabController = null;
// ── Onboarding ──────────────────────────────────────────────────────────────
const ONBOARDING_KEY = 'oikos-onboarded';
const APP_NAME_STORAGE_KEY = 'oikos-app-name';
function getAppName() {
return localStorage.getItem(APP_NAME_STORAGE_KEY) || 'Oikos';
}
function getOnboardingSteps() {
const appName = getAppName();
return [
{ icon: 'home', title: t('onboarding.step1Title'), body: t('onboarding.step1Body') },
{ icon: 'home', title: t('onboarding.step1Title', { name: appName }), body: t('onboarding.step1Body') },
{ icon: 'navigation', title: t('onboarding.step2Title'), body: t('onboarding.step2Body') },
{ icon: 'plus-circle', title: t('onboarding.step3Title'), body: t('onboarding.step3Body') },
];
+162 -8
View File
@@ -35,18 +35,22 @@ function categoryLabels() {
}
let state = {
allDocuments: [],
documents: [],
folders: [],
members: [],
view: localStorage.getItem('oikos-documents-view') || 'grid',
status: 'active',
category: '',
folderId: '',
query: '',
};
let _container = null;
export async function render(container) {
_container = container;
container.innerHTML = `
container.replaceChildren();
container.insertAdjacentHTML('beforeend', `
<div class="documents-page">
<div class="documents-toolbar">
<h1 class="documents-toolbar__title">${t('documents.title')}</h1>
@@ -66,6 +70,10 @@ export async function render(container) {
<i data-lucide="upload" class="icon-base" aria-hidden="true"></i>
${t('documents.addButton')}
</button>
<button class="btn btn--secondary" id="documents-folder-btn">
<i data-lucide="folder-plus" class="icon-base" aria-hidden="true"></i>
${t('documents.addFolderButton')}
</button>
</div>
<div class="documents-filters">
<select class="input documents-filter-select" id="documents-status">
@@ -76,18 +84,31 @@ export async function render(container) {
<option value="">${t('documents.allCategories')}</option>
${CATEGORIES.map((category) => `<option value="${category}">${categoryLabels()[category]}</option>`).join('')}
</select>
<select class="input documents-filter-select" id="documents-folder">
<option value="">${t('documents.allFolders')}</option>
<option value="__none">${t('documents.noFolder')}</option>
</select>
</div>
<div class="documents-browser-layout">
<aside class="documents-folder-browser" aria-label="${t('documents.folderBrowserTitle')}">
<div class="documents-folder-browser__title">${t('documents.folderBrowserTitle')}</div>
<div class="documents-folder-browser__list" id="documents-folder-browser"></div>
</aside>
<div id="documents-list" class="documents-list documents-list--${state.view}"></div>
</div>
<div id="documents-list" class="documents-list documents-list--${state.view}"></div>
<button class="page-fab" id="fab-new-document" aria-label="${t('documents.addButton')}">
<i data-lucide="upload" class="icon-2xl" aria-hidden="true"></i>
</button>
</div>
`;
`);
if (window.lucide) lucide.createIcons();
await Promise.all([loadMembers(), loadDocuments()]);
await Promise.all([loadMembers(), loadFolders()]);
await loadDocuments();
bindPageEvents();
renderFolderOptions();
renderFolderBrowser();
renderDocuments();
}
@@ -101,11 +122,39 @@ async function loadDocuments() {
params.set('status', state.status);
if (state.category) params.set('category', state.category);
const res = await api.get(`/documents?${params.toString()}`);
state.documents = res.data || [];
state.allDocuments = res.data || [];
syncFolderDocuments();
}
async function loadFolders() {
const res = await api.get('/documents/folders');
state.folders = res.data || [];
}
function renderFolderOptions() {
const select = _container.querySelector('#documents-folder');
if (!select) return;
select.replaceChildren();
select.insertAdjacentHTML('beforeend', `<option value="">${t('documents.allFolders')}</option>`);
select.insertAdjacentHTML('beforeend', `<option value="__none" ${state.folderId === '__none' ? 'selected' : ''}>${t('documents.noFolder')}</option>`);
state.folders.forEach((folder) => {
select.insertAdjacentHTML('beforeend', `<option value="${folder.id}" ${String(folder.id) === String(state.folderId) ? 'selected' : ''}>${esc(folder.name)}</option>`);
});
}
function syncFolderDocuments() {
if (state.folderId === '__none') {
state.documents = state.allDocuments.filter((doc) => !doc.folder_id);
return;
}
state.documents = state.folderId
? state.allDocuments.filter((doc) => String(doc.folder_id || '') === String(state.folderId))
: state.allDocuments;
}
function bindPageEvents() {
_container.querySelector('#documents-add-btn')?.addEventListener('click', () => openDocumentModal());
_container.querySelector('#documents-folder-btn')?.addEventListener('click', () => openFolderModal());
_container.querySelector('#fab-new-document')?.addEventListener('click', () => openDocumentModal());
_container.querySelector('#documents-search')?.addEventListener('input', (e) => {
state.query = e.target.value.trim().toLowerCase();
@@ -114,11 +163,19 @@ function bindPageEvents() {
_container.querySelector('#documents-status')?.addEventListener('change', async (e) => {
state.status = e.target.value;
await loadDocuments();
renderFolderBrowser();
renderDocuments();
});
_container.querySelector('#documents-category')?.addEventListener('change', async (e) => {
state.category = e.target.value;
await loadDocuments();
renderFolderBrowser();
renderDocuments();
});
_container.querySelector('#documents-folder')?.addEventListener('change', async (e) => {
state.folderId = e.target.value;
syncFolderDocuments();
renderFolderBrowser();
renderDocuments();
});
_container.querySelector('.documents-view-toggle')?.addEventListener('click', (e) => {
@@ -132,6 +189,15 @@ function bindPageEvents() {
renderDocuments();
});
_container.querySelector('#documents-list')?.addEventListener('click', handleDocumentAction);
_container.querySelector('#documents-folder-browser')?.addEventListener('click', (e) => {
const btn = e.target.closest('[data-folder-id]');
if (!btn) return;
state.folderId = btn.dataset.folderId;
syncFolderDocuments();
renderFolderOptions();
renderFolderBrowser();
renderDocuments();
});
}
function filteredDocuments() {
@@ -149,25 +215,61 @@ function renderDocuments() {
const docs = filteredDocuments();
list.className = `documents-list documents-list--${state.view}`;
if (!docs.length) {
list.innerHTML = `
list.replaceChildren();
list.insertAdjacentHTML('beforeend', `
<div class="empty-state">
<i data-lucide="folder-open" class="empty-state__icon" aria-hidden="true"></i>
<div class="empty-state__title">${t('documents.emptyTitle')}</div>
<div class="empty-state__description">${t('documents.emptyDescription')}</div>
</div>
`;
`);
if (window.lucide) lucide.createIcons();
return;
}
list.innerHTML = docs.map((doc) => state.view === 'list' ? renderListItem(doc) : renderGridCard(doc)).join('');
list.replaceChildren();
list.insertAdjacentHTML('beforeend', docs.map((doc) => state.view === 'list' ? renderListItem(doc) : renderGridCard(doc)).join(''));
if (window.lucide) lucide.createIcons();
stagger(list.querySelectorAll('.document-card, .document-row'));
}
function folderCounts() {
const counts = new Map();
counts.set('', state.allDocuments.length);
counts.set('__none', state.allDocuments.filter((doc) => !doc.folder_id).length);
state.folders.forEach((folder) => counts.set(String(folder.id), 0));
state.allDocuments.forEach((doc) => {
if (!doc.folder_id) return;
const key = String(doc.folder_id);
counts.set(key, (counts.get(key) || 0) + 1);
});
return counts;
}
function renderFolderBrowser() {
const browser = _container.querySelector('#documents-folder-browser');
if (!browser) return;
const counts = folderCounts();
const items = [
{ id: '', name: t('documents.allFolders'), icon: 'folders' },
{ id: '__none', name: t('documents.noFolder'), icon: 'folder-x' },
...state.folders.map((folder) => ({ id: String(folder.id), name: folder.name, icon: 'folder' })),
];
browser.replaceChildren();
browser.insertAdjacentHTML('beforeend', items.map((item) => `
<button class="documents-folder-item ${String(state.folderId) === item.id ? 'documents-folder-item--active' : ''}" type="button" data-folder-id="${esc(item.id)}" aria-current="${String(state.folderId) === item.id ? 'true' : 'false'}">
<span class="documents-folder-item__icon"><i data-lucide="${esc(item.icon)}" aria-hidden="true"></i></span>
<span class="documents-folder-item__name">${esc(item.name)}</span>
<span class="documents-folder-item__count">${counts.get(item.id) || 0}</span>
</button>
`).join(''));
if (window.lucide) lucide.createIcons();
}
function renderMeta(doc) {
const labels = categoryLabels();
return `
<span><i data-lucide="${CATEGORY_ICONS[doc.category] || 'folder'}" aria-hidden="true"></i>${labels[doc.category] || doc.category}</span>
${doc.folder_name ? `<span><i data-lucide="folder" aria-hidden="true"></i>${esc(doc.folder_name)}</span>` : ''}
<span><i data-lucide="${doc.visibility === 'family' ? 'users' : doc.visibility === 'private' ? 'lock' : 'user-check'}" aria-hidden="true"></i>${t(`documents.visibility.${doc.visibility}`)}</span>
<span>${formatFileSize(doc.file_size)}</span>
`;
@@ -230,6 +332,7 @@ async function handleDocumentAction(e) {
await api.patch(`/documents/${doc.id}/archive`, { archived: doc.status !== 'archived' });
window.oikos?.showToast(doc.status === 'archived' ? t('documents.restoredToast') : t('documents.archivedToast'), 'success');
await loadDocuments();
renderFolderBrowser();
renderDocuments();
}
if (btn.dataset.action === 'delete') {
@@ -237,6 +340,7 @@ async function handleDocumentAction(e) {
await api.delete(`/documents/${doc.id}`);
window.oikos?.showToast(t('documents.deletedToast'), 'success');
await loadDocuments();
renderFolderBrowser();
renderDocuments();
}
}
@@ -269,6 +373,13 @@ function openDocumentModal(doc = null) {
${CATEGORIES.map((category) => `<option value="${category}" ${doc?.category === category ? 'selected' : ''}>${categoryLabels()[category]}</option>`).join('')}
</select>
</div>
<div class="form-group">
<label class="label" for="document-folder">${t('documents.folderLabel')}</label>
<select class="input" id="document-folder">
<option value="">${t('documents.noFolder')}</option>
${state.folders.map((folder) => `<option value="${folder.id}" ${String(doc?.folder_id || '') === String(folder.id) ? 'selected' : ''}>${esc(folder.name)}</option>`).join('')}
</select>
</div>
</div>
<div class="form-group">
<label class="label" for="document-description">${t('documents.descriptionLabel')}</label>
@@ -376,6 +487,7 @@ async function saveDocument(event, doc) {
name: form.querySelector('#document-name').value.trim(),
description: form.querySelector('#document-description').value.trim() || null,
category: form.querySelector('#document-category').value,
folder_id: form.querySelector('#document-folder').value || null,
visibility,
status: form.querySelector('#document-status').value,
allowed_member_ids: visibility === 'restricted'
@@ -396,6 +508,7 @@ async function saveDocument(event, doc) {
window.oikos?.showToast(doc ? t('documents.savedToast') : t('documents.uploadedToast'), 'success');
closeModal({ force: true });
await loadDocuments();
renderFolderBrowser();
renderDocuments();
} catch (err) {
error.textContent = err.message;
@@ -405,6 +518,47 @@ async function saveDocument(event, doc) {
}
}
function openFolderModal() {
openSharedModal({
title: t('documents.newFolderTitle'),
size: 'sm',
content: `
<form id="document-folder-form" class="document-form">
<div class="form-group">
<label class="label" for="document-folder-name">${t('documents.folderNameLabel')}</label>
<input class="input" id="document-folder-name" required maxlength="200" autocomplete="off">
</div>
<div id="document-folder-error" class="login-error" hidden></div>
<div class="modal-panel__footer" style="padding:0;border:none;margin-top:var(--space-5)">
<button type="submit" class="btn btn--primary">${t('documents.createFolderAction')}</button>
</div>
</form>
`,
onSave(panel) {
panel.querySelector('#document-folder-form')?.addEventListener('submit', async (event) => {
event.preventDefault();
const error = panel.querySelector('#document-folder-error');
const input = panel.querySelector('#document-folder-name');
error.hidden = true;
try {
const res = await api.post('/documents/folders', { name: input.value.trim() });
window.oikos?.showToast(t('documents.folderCreatedToast'), 'success');
state.folderId = String(res.data?.id || '');
await loadFolders();
await loadDocuments();
closeModal({ force: true });
renderFolderOptions();
renderFolderBrowser();
renderDocuments();
} catch (err) {
error.textContent = err.message;
error.hidden = false;
}
});
},
});
}
function readFileAsDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
+877
View File
@@ -0,0 +1,877 @@
/**
* Modul: Housekeeping
* Zweck: Dashboard, chore management, reports, and housekeeping staff
* Abhängigkeiten: /api.js, /i18n.js, /utils/html.js
*/
import { api } from '/api.js';
import { t, formatDate, formatTime } from '/i18n.js';
import { esc } from '/utils/html.js';
import { openModal, closeModal, confirmModal } from '/components/modal.js';
const MAX_FILE_SIZE = 5 * 1024 * 1024;
let state = {
tab: 'dashboard',
dashboard: null,
tasks: [],
reports: [],
visitReport: null,
templates: [],
worker: null,
workers: [],
workerAvatar: undefined,
selectedStaffId: null,
staffLogMonth: new Date().toISOString().slice(0, 7),
staffVisits: [],
};
function money(value) {
return new Intl.NumberFormat(undefined, { style: 'currency', currency: 'BRL' }).format(Number(value || 0));
}
function initials(name = '') {
return name.split(' ').map((part) => part[0]).join('').slice(0, 2).toUpperCase();
}
function urgencyLabel(status) {
if (status === 'overdue') return t('housekeeping.overdue');
if (status === 'today') return t('housekeeping.dueToday');
return t('housekeeping.ok');
}
function scheduleLabel(value) {
const map = {
daily: t('housekeeping.scheduleDaily'),
twice_monthly: t('housekeeping.scheduleTwiceMonthly'),
monthly: t('housekeeping.scheduleMonthly'),
};
return map[value] || map.monthly;
}
function templateLabel(template, field) {
if (!template?.key) return template?.[field] || '';
const key = `housekeeping.taskTemplateData.${template.key}.${field}`;
const translated = t(key);
return translated === key ? template[field] : translated;
}
function visitTextPayload(worker, dateValue, dailyRate, extras) {
const visitDate = dateValue || new Date().toISOString().slice(0, 10);
const total = Number(dailyRate || 0) + Number(extras || 0);
const name = worker?.display_name || t('housekeeping.staff');
return {
event_title: t('housekeeping.calendarVisitTitle', { name }),
payment_title: t('housekeeping.paymentTaskTitle', { name }),
payment_description: t('housekeeping.paymentTaskDescription', {
date: formatDate(visitDate),
amount: money(total),
}),
};
}
function readFileAsDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error(t('documents.fileReadError')));
reader.readAsDataURL(file);
});
}
async function loadStaffVisits(workerId = state.selectedStaffId, monthValue = state.staffLogMonth) {
if (!workerId) {
state.staffVisits = [];
return;
}
const res = await api.get(`/housekeeping/visits?month=${encodeURIComponent(monthValue)}&worker_id=${encodeURIComponent(workerId)}`);
state.staffVisits = res.data?.visits || [];
}
async function loadData() {
const [dashboard, tasks, reports, templates, workers] = await Promise.all([
api.get('/housekeeping/dashboard'),
api.get('/housekeeping/decay-tasks'),
api.get('/housekeeping/visits'),
api.get('/housekeeping/task-templates'),
api.get('/housekeeping/workers'),
]);
state.dashboard = dashboard.data;
state.tasks = tasks.data || [];
state.visitReport = reports.data || { visits: [], totals: {} };
state.reports = state.visitReport.visits || [];
state.templates = templates.data || [];
state.workers = workers.data || [];
state.worker = state.workers[0] || null;
}
function renderTabButton(tab, icon, label) {
const current = state.tab === tab ? ' aria-current="page"' : '';
return `
<button class="housekeeping-tab sub-tab" type="button" data-housekeeping-tab="${esc(tab)}"${current}>
<i class="sub-tab__icon" data-lucide="${esc(icon)}" aria-hidden="true"></i>
<span class="sub-tab__label">${esc(label)}</span>
</button>
`;
}
function renderShell(container) {
container.replaceChildren();
container.insertAdjacentHTML('beforeend', `
<section class="housekeeping-page" aria-labelledby="housekeeping-title">
<header class="housekeeping-toolbar">
<div class="housekeeping-toolbar__title" id="housekeeping-title">${esc(t('housekeeping.title'))}</div>
<nav class="housekeeping-tabs" aria-label="${esc(t('housekeeping.bottomNav'))}">
${renderTabButton('dashboard', 'layout-dashboard', t('housekeeping.dashboard'))}
${renderTabButton('tasks', 'list-checks', t('housekeeping.tasks'))}
${renderTabButton('reports', 'file-text', t('housekeeping.reports'))}
${renderTabButton('staff', 'users-round', t('housekeeping.staff'))}
</nav>
</header>
<div class="housekeeping-content" id="housekeeping-content"></div>
</section>
`);
container.querySelectorAll('[data-housekeeping-tab]').forEach((btn) => {
btn.addEventListener('click', () => {
state.tab = btn.dataset.housekeepingTab;
renderCurrentTab(container);
});
});
renderCurrentTab(container);
}
function renderCurrentTab(container) {
const content = container.querySelector('#housekeeping-content');
if (!content) return;
content.replaceChildren();
container.querySelectorAll('[data-housekeeping-tab]').forEach((btn) => {
const active = btn.dataset.housekeepingTab === state.tab;
btn.classList.toggle('sub-tab--active', active);
if (active) btn.setAttribute('aria-current', 'page');
else btn.removeAttribute('aria-current');
});
if (state.tab === 'tasks') renderTasks(content);
else if (state.tab === 'reports') renderReports(content);
else if (state.tab === 'staff') renderStaff(content);
else renderDashboard(content);
if (window.lucide) window.lucide.createIcons({ el: container });
}
async function toggleSession(container, workerId) {
const worker = state.workers.find((item) => String(item.id) === String(workerId));
const current = worker?.today_session || worker?.current_session;
if (!state.workers.length) {
window.oikos?.showToast(t('housekeeping.checkInDisabled'), 'warning');
return;
}
if (!worker) return;
if (current) return;
try {
const dateValue = new Date().toISOString().slice(0, 10);
await api.post('/housekeeping/work-sessions/check-in', {
worker_id: worker.id,
daily_rate: worker.daily_rate || 0,
extras: 0,
...visitTextPayload(worker, dateValue, worker.daily_rate || 0, 0),
});
window.oikos?.showToast(t('housekeeping.checkedInToast'), 'success');
await loadData();
renderShell(container);
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
}
}
function renderWorkerSummary() {
if (!state.workers.length) {
return `
<section class="housekeeping-card housekeeping-worker-empty">
<i data-lucide="user-plus" aria-hidden="true"></i>
<div>
<h2>${esc(t('housekeeping.noWorkerTitle'))}</h2>
<p>${esc(t('housekeeping.noWorkerHint'))}</p>
</div>
<button class="btn btn--secondary housekeeping-check-small" type="button" disabled>
<i data-lucide="log-in" aria-hidden="true"></i>
<span>${esc(t('housekeeping.checkIn'))}</span>
</button>
</section>
`;
}
const rows = state.workers.map((worker) => {
const checkedIn = !!(worker.today_session || worker.current_session);
const session = worker.today_session || worker.current_session;
return `
<section class="housekeeping-worker-strip">
<div class="housekeeping-avatar" style="background:${esc(worker.avatar_color || '#7C3AED')}">
${worker.avatar_data ? `<img src="${esc(worker.avatar_data)}" alt="${esc(worker.display_name)}">` : esc(initials(worker.display_name))}
</div>
<div>
<strong>${esc(worker.display_name)}</strong>
<span>${esc(checkedIn ? `${t('housekeeping.visitRecordedAt')} ${formatTime(session.check_in)}` : `${money(worker.daily_rate)} · ${scheduleLabel(worker.payment_schedule)}`)}</span>
</div>
<button class="btn ${checkedIn ? 'btn--secondary' : 'btn--primary'} housekeeping-check-small" type="button"
data-worker-check="${worker.id}" ${checkedIn ? 'disabled' : ''}>
<i data-lucide="${checkedIn ? 'check' : 'log-in'}" aria-hidden="true"></i>
<span>${esc(checkedIn ? t('housekeeping.checkedInToday') : t('housekeeping.checkIn'))}</span>
</button>
</section>
`;
}).join('');
return `
<div class="housekeeping-worker-stack">
${rows}
</div>
`;
}
function renderDashboard(content) {
content.replaceChildren();
const data = state.dashboard || {};
const lastVisit = data.last_visit?.check_in ? `${formatDate(data.last_visit.check_in)} · ${formatTime(data.last_visit.check_in)}` : t('housekeeping.noVisits');
const maxPayment = Math.max(1, ...(data.monthly_payments || []).map((row) => row.total));
const bars = (data.monthly_payments || []).map((row) => {
const height = Math.max(8, Math.round((row.total / maxPayment) * 88));
return `
<div class="housekeeping-chart__bar-wrap">
<div class="housekeeping-chart__bar" style="height:${height}px" title="${esc(row.month)} ${esc(money(row.total))}"></div>
<span>${esc(row.month.slice(5))}</span>
</div>
`;
}).join('');
content.insertAdjacentHTML('beforeend', `
${renderWorkerSummary()}
<section class="housekeeping-metrics">
<article class="housekeeping-metric">
<span>${esc(t('housekeeping.visitsThisMonth'))}</span>
<strong>${esc(data.visits_this_month ?? 0)}</strong>
</article>
<article class="housekeeping-metric">
<span>${esc(t('housekeeping.lastVisit'))}</span>
<strong>${esc(lastVisit)}</strong>
</article>
<article class="housekeeping-metric">
<span>${esc(t('housekeeping.pendingChores'))}</span>
<strong>${esc(data.pending_tasks ?? 0)}</strong>
</article>
<article class="housekeeping-metric">
<span>${esc(t('housekeeping.finishedChores'))}</span>
<strong>${esc(data.finished_tasks_this_month ?? 0)}</strong>
</article>
</section>
<section class="housekeeping-card">
<div class="housekeeping-section-heading">
<h2>${esc(t('housekeeping.payments'))}</h2>
<span>${esc(t('housekeeping.pendingPayments'))}: ${esc(money(data.pending_payments || 0))}</span>
</div>
<div class="housekeeping-chart" aria-label="${esc(t('housekeeping.monthlyPayments'))}">
${bars || `<p class="housekeeping-muted">${esc(t('housekeeping.noPaymentData'))}</p>`}
</div>
</section>
`);
content.querySelectorAll('[data-worker-check]').forEach((btn) => {
btn.addEventListener('click', () => toggleSession(document.querySelector('.page-transition') || document.body, btn.dataset.workerCheck));
});
}
async function createTask(payload, content) {
try {
await api.post('/housekeeping/decay-tasks', payload);
window.oikos?.showToast(t('housekeeping.taskCreatedToast'), 'success');
await loadData();
renderTasks(content);
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
}
}
function renderTasks(content) {
content.replaceChildren();
const templateButtons = state.templates.map((template, index) => `
<button class="housekeeping-template" type="button" data-template-index="${index}">
<span>${esc(templateLabel(template, 'name'))}</span>
<small>${esc(templateLabel(template, 'area'))} · ${esc(t('housekeeping.everyDays', { days: template.frequency_days }))}</small>
</button>
`).join('');
const taskRows = state.tasks.map((task) => `
<article class="housekeeping-task housekeeping-task--${esc(task.urgency_status)}">
<button class="housekeeping-task__check" type="button" data-complete-task="${task.id}"
aria-label="${esc(t('housekeeping.completeTask', { name: task.name }))}">
<i data-lucide="check" aria-hidden="true"></i>
</button>
<div class="housekeeping-task__body">
<h2>${esc(task.name)}</h2>
<p>${esc(task.area)} · ${esc(t('housekeeping.everyDays', { days: task.frequency_days }))}</p>
<span>${esc(urgencyLabel(task.urgency_status))}</span>
</div>
</article>
`).join('');
content.insertAdjacentHTML('beforeend', `
<section class="housekeeping-card">
<h2>${esc(t('housekeeping.taskTemplates'))}</h2>
<div class="housekeeping-template-list">${templateButtons}</div>
</section>
<section class="housekeeping-card">
<h2>${esc(t('housekeeping.addCustomTask'))}</h2>
<form id="housekeeping-task-form" class="housekeeping-task-form">
<div class="housekeeping-form-grid housekeeping-form-grid--wide">
<label class="housekeeping-field">
<span>${esc(t('housekeeping.taskName'))}</span>
<input name="name" required maxlength="200" autocomplete="off">
</label>
<label class="housekeeping-field">
<span>${esc(t('housekeeping.taskArea'))}</span>
<input name="area" required maxlength="100" autocomplete="off">
</label>
<label class="housekeeping-field">
<span>${esc(t('housekeeping.taskFrequency'))}</span>
<input name="frequency_days" required inputmode="numeric" type="number" min="1" step="1" value="7">
</label>
</div>
<button class="btn btn--primary housekeeping-form-submit" type="submit">
<i data-lucide="plus" aria-hidden="true"></i>
<span>${esc(t('housekeeping.createTask'))}</span>
</button>
</form>
</section>
<section class="housekeeping-task-list">
${taskRows || `
<div class="housekeeping-empty">
<i data-lucide="list-checks" aria-hidden="true"></i>
<h2>${esc(t('housekeeping.noTasks'))}</h2>
</div>
`}
</section>
`);
content.querySelectorAll('[data-template-index]').forEach((btn) => {
btn.addEventListener('click', () => {
const template = state.templates[Number(btn.dataset.templateIndex)];
if (template) {
createTask({
name: templateLabel(template, 'name'),
area: templateLabel(template, 'area'),
frequency_days: template.frequency_days,
}, content);
}
});
});
content.querySelector('#housekeeping-task-form')?.addEventListener('submit', (event) => {
event.preventDefault();
const form = event.currentTarget;
const fields = form.elements;
const frequencyDays = Number(fields.frequency_days.value);
if (!fields.name.value.trim() || !fields.area.value.trim() || !Number.isInteger(frequencyDays) || frequencyDays < 1) return;
createTask({
name: fields.name.value.trim(),
area: fields.area.value.trim(),
frequency_days: frequencyDays,
}, content);
});
content.querySelectorAll('[data-complete-task]').forEach((btn) => {
btn.addEventListener('click', async () => {
try {
await api.post(`/housekeeping/decay-tasks/${btn.dataset.completeTask}/complete`, {});
window.oikos?.showToast(t('housekeeping.taskDoneToast'), 'success');
await loadData();
renderTasks(content);
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
}
});
});
}
function renderReports(content) {
content.replaceChildren();
const totals = state.visitReport?.totals || {};
const visits = state.reports || [];
const rows = visits.map((visit) => {
const paid = !!visit.paid_at;
return `
<article class="housekeeping-report-item housekeeping-report-item--visit">
<div class="housekeeping-avatar" style="background:${esc(visit.worker_avatar_color || '#7C3AED')}">
${visit.worker_avatar_data ? `<img src="${esc(visit.worker_avatar_data)}" alt="${esc(visit.worker_name || '')}">` : esc(initials(visit.worker_name || 'HK'))}
</div>
<div>
<strong>${esc(visit.worker_name || t('housekeeping.staff'))}</strong>
<span>${esc(formatDate(visit.check_in))} · ${esc(money(visit.total_amount))} · ${esc(paid ? t('housekeeping.paymentPaid') : t('housekeeping.paymentPending'))}</span>
</div>
<button class="btn btn--secondary btn--icon" type="button" data-visit-report="${visit.id}" aria-label="${esc(t('housekeeping.openVisitReport'))}">
<i data-lucide="file-text" aria-hidden="true"></i>
</button>
</article>
`;
}).join('');
content.insertAdjacentHTML('beforeend', `
<section class="housekeeping-card">
<div class="housekeeping-section-heading">
<h2>${esc(t('housekeeping.visitReports'))}</h2>
<span>${esc(state.visitReport?.month || '')}</span>
</div>
<section class="housekeeping-metrics housekeeping-metrics--compact">
<article class="housekeeping-metric">
<span>${esc(t('housekeeping.visitsThisMonth'))}</span>
<strong>${esc(visits.length)}</strong>
</article>
<article class="housekeeping-metric">
<span>${esc(t('housekeeping.pendingPayments'))}</span>
<strong>${esc(money(totals.pending || 0))}</strong>
</article>
<article class="housekeeping-metric">
<span>${esc(t('housekeeping.paymentPaid'))}</span>
<strong>${esc(money(totals.paid || 0))}</strong>
</article>
</section>
</section>
<section class="housekeeping-reports" aria-label="${esc(t('housekeeping.recentReports'))}">
${rows || `<p class="housekeeping-muted">${esc(t('housekeeping.noVisitReports'))}</p>`}
</section>
`);
content.querySelectorAll('[data-visit-report]').forEach((btn) => {
btn.addEventListener('click', () => {
const visit = visits.find((item) => String(item.id) === btn.dataset.visitReport);
if (visit) openVisitReportModal(visit);
});
});
}
function openVisitReportModal(visit) {
const paid = !!visit.paid_at;
openModal({
title: t('housekeeping.visitReportDetails'),
size: 'md',
content: `
<div class="housekeeping-report-modal">
<div class="housekeeping-staff-row">
<div class="housekeeping-avatar" style="background:${esc(visit.worker_avatar_color || '#7C3AED')}">
${visit.worker_avatar_data ? `<img src="${esc(visit.worker_avatar_data)}" alt="${esc(visit.worker_name || '')}">` : esc(initials(visit.worker_name || 'HK'))}
</div>
<div>
<strong>${esc(visit.worker_name || t('housekeeping.staff'))}</strong>
<span>${esc(scheduleLabel(visit.payment_schedule))}</span>
</div>
</div>
<dl class="housekeeping-report-details">
<div><dt>${esc(t('housekeeping.lastVisit'))}</dt><dd>${esc(formatDate(visit.check_in))} · ${esc(formatTime(visit.check_in))}</dd></div>
<div><dt>${esc(t('housekeeping.dailyRate'))}</dt><dd>${esc(money(visit.daily_rate))}</dd></div>
<div><dt>${esc(t('housekeeping.extras'))}</dt><dd>${esc(money(visit.extras))}</dd></div>
<div><dt>${esc(t('housekeeping.totalPayment'))}</dt><dd>${esc(money(visit.total_amount))}</dd></div>
<div><dt>${esc(t('housekeeping.paymentStatus'))}</dt><dd>${esc(paid ? t('housekeeping.paymentPaid') : t('housekeeping.paymentPending'))}</dd></div>
<div><dt>${esc(t('housekeeping.paymentTask'))}</dt><dd>${esc(visit.payment_task_id ? `#${visit.payment_task_id}` : t('housekeeping.notAvailable'))}</dd></div>
<div><dt>${esc(t('housekeeping.calendarEvent'))}</dt><dd>${esc(visit.calendar_event_id ? `#${visit.calendar_event_id}` : t('housekeeping.notAvailable'))}</dd></div>
</dl>
</div>
`,
});
}
function renderStaff(content) {
content.replaceChildren();
const workerRows = state.workers.map((item) => `
<article class="housekeeping-staff-row ${String(state.selectedStaffId || '') === String(item.id) ? 'housekeeping-staff-row--active' : ''}"
data-select-worker="${item.id}" role="button" tabindex="0">
<div class="housekeeping-avatar" style="background:${esc(item.avatar_color || '#7C3AED')}">
${item.avatar_data ? `<img src="${esc(item.avatar_data)}" alt="${esc(item.display_name)}">` : esc(initials(item.display_name))}
</div>
<div>
<strong>${esc(item.display_name)}</strong>
<span>${esc(item.phone || item.email || '')}</span>
</div>
<button class="btn btn--secondary btn--icon" type="button" data-edit-worker="${item.id}" aria-label="${esc(t('common.edit'))}">
<i data-lucide="edit-2" aria-hidden="true"></i>
</button>
</article>
`).join('');
content.insertAdjacentHTML('beforeend', `
<section class="housekeeping-card">
<div class="housekeeping-section-heading">
<h2>${esc(t('housekeeping.staffTitle'))}</h2>
<button class="btn btn--secondary" type="button" id="housekeeping-new-worker">
<i data-lucide="plus" aria-hidden="true"></i>
<span>${esc(t('housekeeping.addWorker'))}</span>
</button>
</div>
<div class="housekeeping-staff-list">
${workerRows || `<p class="housekeeping-muted">${esc(t('housekeeping.noWorkers'))}</p>`}
</div>
</section>
${state.selectedStaffId ? renderStaffVisitLog() : ''}
`);
content.querySelector('#housekeeping-new-worker')?.addEventListener('click', () => {
openStaffModal(null, content);
});
content.querySelectorAll('[data-select-worker]').forEach((row) => {
const select = async () => {
state.selectedStaffId = row.dataset.selectWorker;
try {
await loadStaffVisits();
renderStaff(content);
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
}
};
row.addEventListener('click', (event) => {
if (event.target.closest('[data-edit-worker]')) return;
select();
});
row.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
select();
});
});
content.querySelectorAll('[data-edit-worker]').forEach((btn) => {
btn.addEventListener('click', (event) => {
event.stopPropagation();
const worker = state.workers.find((item) => String(item.id) === btn.dataset.editWorker) || null;
openStaffModal(worker, content);
});
});
content.querySelector('#housekeeping-staff-month')?.addEventListener('change', async (event) => {
state.staffLogMonth = event.currentTarget.value || new Date().toISOString().slice(0, 7);
try {
await loadStaffVisits();
renderStaff(content);
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
}
});
content.querySelectorAll('[data-edit-visit]').forEach((btn) => {
btn.addEventListener('click', () => {
const visit = state.staffVisits.find((item) => String(item.id) === btn.dataset.editVisit);
if (visit) openVisitEditModal(visit, content);
});
});
content.querySelectorAll('[data-pay-visit]').forEach((btn) => {
btn.addEventListener('click', async () => {
const visit = state.staffVisits.find((item) => String(item.id) === btn.dataset.payVisit);
if (!visit) return;
try {
await api.post(`/housekeeping/visits/${visit.id}/pay`, {});
window.oikos?.showToast(t('housekeeping.visitPaidToast'), 'success');
await loadData();
await loadStaffVisits();
renderStaff(content);
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
}
});
});
content.querySelectorAll('[data-delete-visit]').forEach((btn) => {
btn.addEventListener('click', async () => {
const visit = state.staffVisits.find((item) => String(item.id) === btn.dataset.deleteVisit);
if (!visit) return;
if (!await confirmModal(t('housekeeping.deleteVisitConfirm'), { danger: true, confirmLabel: t('common.delete') })) return;
try {
await api.delete(`/housekeeping/visits/${visit.id}`);
window.oikos?.showToast(t('housekeeping.visitDeletedToast'), 'success');
await loadData();
await loadStaffVisits();
renderStaff(content);
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
}
});
});
if (window.lucide) window.lucide.createIcons({ el: content });
}
function renderStaffVisitLog() {
const worker = state.workers.find((item) => String(item.id) === String(state.selectedStaffId));
if (!worker) return '';
const rows = state.staffVisits.map((visit) => {
const paid = !!visit.paid_at;
return `
<article class="housekeeping-staff-log-row">
<div>
<strong>${esc(formatDate(visit.check_in))}</strong>
<span>${esc(money(visit.total_amount))} · ${esc(paid ? t('housekeeping.paymentPaid') : t('housekeeping.paymentPending'))}</span>
</div>
<div class="housekeeping-staff-log-row__actions">
<button class="btn btn--secondary housekeeping-log-action" type="button" data-pay-visit="${visit.id}" ${paid ? 'disabled' : ''}
aria-label="${esc(t('housekeeping.markPaid'))}">
<i data-lucide="badge-dollar-sign" aria-hidden="true"></i>
<span>${esc(paid ? t('housekeeping.paymentPaid') : t('housekeeping.markPaid'))}</span>
</button>
<button class="btn btn--secondary housekeeping-log-action" type="button" data-edit-visit="${visit.id}" aria-label="${esc(t('housekeeping.editVisit'))}">
<i data-lucide="edit-2" aria-hidden="true"></i>
<span>${esc(t('housekeeping.editVisit'))}</span>
</button>
<button class="btn btn--danger-outline housekeeping-log-action" type="button" data-delete-visit="${visit.id}" aria-label="${esc(t('housekeeping.deleteVisit'))}">
<i data-lucide="trash-2" aria-hidden="true"></i>
<span>${esc(t('housekeeping.deleteVisit'))}</span>
</button>
</div>
</article>
`;
}).join('');
return `
<section class="housekeeping-card housekeeping-staff-log">
<div class="housekeeping-section-heading">
<div>
<h2>${esc(t('housekeeping.staffLogTitle', { name: worker.display_name }))}</h2>
<span>${esc(t('housekeeping.staffLogHint'))}</span>
</div>
<label class="housekeeping-field housekeeping-field--inline">
<span>${esc(t('housekeeping.filterMonth'))}</span>
<input id="housekeeping-staff-month" type="month" value="${esc(state.staffLogMonth)}">
</label>
</div>
<div class="housekeeping-staff-log-list">
${rows || `<p class="housekeeping-muted">${esc(t('housekeeping.noVisitReports'))}</p>`}
</div>
</section>
`;
}
function openVisitEditModal(visit, content) {
const worker = state.workers.find((item) => String(item.id) === String(visit.worker_id)) || null;
openModal({
title: t('housekeeping.editVisit'),
size: 'md',
content: `
<form id="housekeeping-visit-form" class="housekeeping-worker-form">
<label class="housekeeping-field">
<span>${esc(t('housekeeping.visitDate'))}</span>
<input name="date" type="date" required value="${esc(visit.check_in.slice(0, 10))}">
</label>
<div class="housekeeping-form-grid">
<label class="housekeeping-field">
<span>${esc(t('housekeeping.dailyRate'))}</span>
<input name="daily_rate" type="number" min="0" step="0.01" inputmode="decimal" value="${esc(visit.daily_rate ?? 0)}">
</label>
<label class="housekeeping-field">
<span>${esc(t('housekeeping.extras'))}</span>
<input name="extras" type="number" min="0" step="0.01" inputmode="decimal" value="${esc(visit.extras ?? 0)}">
</label>
</div>
<label class="document-dropzone" id="housekeeping-receipt-dropzone" for="housekeeping-receipt-file">
<input class="sr-only" id="housekeeping-receipt-file" type="file" accept="image/png,image/jpeg,image/webp,application/pdf,text/plain,text/csv">
<span class="document-dropzone__icon">
<i data-lucide="receipt" aria-hidden="true"></i>
</span>
<span class="document-dropzone__title">${esc(t('housekeeping.receiptUploadTitle'))}</span>
<span class="document-dropzone__hint">${esc(t('housekeeping.receiptUploadHint'))}</span>
<span class="document-dropzone__file" id="housekeeping-receipt-selected" ${visit.receipt_document_name ? '' : 'hidden'}>
${esc(visit.receipt_document_name || '')}
</span>
</label>
<button class="btn btn--primary housekeeping-form-submit" type="submit">
<i data-lucide="save" aria-hidden="true"></i>
<span>${esc(t('common.save'))}</span>
</button>
</form>
`,
onSave: (panel) => {
panel.querySelector('#housekeeping-visit-form')?.addEventListener('submit', async (event) => {
event.preventDefault();
const form = event.currentTarget;
const fields = form.elements;
const dateValue = fields.date.value;
const dailyRate = Number(fields.daily_rate.value || 0);
const extras = Number(fields.extras.value || 0);
let receiptDocumentId = visit.receipt_document_id || null;
try {
const file = panel.querySelector('#housekeeping-receipt-file')?.files?.[0];
if (file) {
if (file.size > MAX_FILE_SIZE) throw new Error(t('documents.fileTooLarge'));
const receipt = await api.post('/documents', {
name: t('housekeeping.receiptDocumentName', {
name: worker?.display_name || t('housekeeping.staff'),
date: formatDate(dateValue),
}),
description: t('housekeeping.receiptDocumentDescription', {
name: worker?.display_name || t('housekeeping.staff'),
date: formatDate(dateValue),
}),
category: 'finance',
visibility: 'family',
status: 'active',
allowed_member_ids: [],
original_name: file.name,
content_data: await readFileAsDataUrl(file),
folder_name: t('documents.housekeepingFolder'),
});
receiptDocumentId = receipt.data?.id || receiptDocumentId;
}
await api.put(`/housekeeping/visits/${visit.id}`, {
date: dateValue,
daily_rate: dailyRate,
extras,
receipt_document_id: receiptDocumentId,
...visitTextPayload(worker, dateValue, dailyRate, extras),
});
window.oikos?.showToast(t('housekeeping.visitSavedToast'), 'success');
await loadData();
state.staffLogMonth = dateValue.slice(0, 7);
await loadStaffVisits();
closeModal({ force: true });
renderStaff(content);
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
}
});
},
});
const panel = document.querySelector('.modal-panel');
const receiptInput = panel?.querySelector('#housekeeping-receipt-file');
const receiptSelected = panel?.querySelector('#housekeeping-receipt-selected');
receiptInput?.addEventListener('change', () => {
const file = receiptInput.files?.[0];
if (!receiptSelected) return;
receiptSelected.hidden = !file && !visit.receipt_document_name;
receiptSelected.textContent = file
? t('documents.selectedFileLabel', { name: file.name })
: (visit.receipt_document_name || '');
});
if (window.lucide) window.lucide.createIcons({ el: panel });
}
function openStaffModal(worker, content) {
const item = worker || {};
state.workerAvatar = item.avatar_data ?? null;
openModal({
title: item.id ? t('housekeeping.editWorker') : t('housekeeping.addWorker'),
size: 'lg',
content: `
<form id="housekeeping-worker-form" class="housekeeping-worker-form">
<input type="hidden" name="id" value="${esc(item.id || '')}">
<div class="housekeeping-profile-editor">
<button class="housekeeping-avatar housekeeping-avatar--lg" type="button" id="housekeeping-avatar-btn"
style="background:${esc(item.avatar_color || '#7C3AED')}" aria-label="${esc(t('housekeeping.profilePicture'))}">
${item.avatar_data ? `<img src="${esc(item.avatar_data)}" alt="${esc(item.display_name || '')}">` : esc(initials(item.display_name || 'HK'))}
</button>
<input class="sr-only" type="file" id="housekeeping-avatar-file" accept="image/png,image/jpeg,image/webp">
<div class="housekeeping-profile-editor__fields">
<label class="housekeeping-field">
<span>${esc(t('housekeeping.workerName'))}</span>
<input name="display_name" required maxlength="128" value="${esc(item.display_name || '')}">
</label>
<label class="housekeeping-field">
<span>${esc(t('housekeeping.workerUsername'))}</span>
<input name="username" maxlength="64" autocomplete="off" value="${esc(item.username || '')}">
</label>
</div>
</div>
<div class="housekeeping-form-grid housekeeping-form-grid--wide">
<label class="housekeeping-field">
<span>${esc(t('housekeeping.workerPhone'))}</span>
<input name="phone" type="tel" autocomplete="tel" value="${esc(item.phone || '')}">
</label>
<label class="housekeeping-field">
<span>${esc(t('housekeeping.workerEmail'))}</span>
<input name="email" type="email" autocomplete="email" value="${esc(item.email || '')}">
</label>
<label class="housekeeping-field">
<span>${esc(t('housekeeping.workerBirthDate'))}</span>
<input name="birth_date" type="date" value="${esc(item.birth_date || '')}">
</label>
<label class="housekeeping-field">
<span>${esc(t('housekeeping.dailyRate'))}</span>
<input name="daily_rate" type="number" min="0" step="0.01" inputmode="decimal" value="${esc(item.daily_rate ?? 0)}">
</label>
<label class="housekeeping-field housekeeping-field--color">
<span>${esc(t('housekeeping.calendarColor'))}</span>
<input name="calendar_color" type="color" value="${esc(item.calendar_color || '#7C3AED')}">
</label>
<label class="housekeeping-field">
<span>${esc(t('housekeeping.paymentSchedule'))}</span>
<select name="payment_schedule">
<option value="daily"${item.payment_schedule === 'daily' ? ' selected' : ''}>${esc(t('housekeeping.scheduleDaily'))}</option>
<option value="twice_monthly"${item.payment_schedule === 'twice_monthly' ? ' selected' : ''}>${esc(t('housekeeping.scheduleTwiceMonthly'))}</option>
<option value="monthly"${!item.payment_schedule || item.payment_schedule === 'monthly' ? ' selected' : ''}>${esc(t('housekeeping.scheduleMonthly'))}</option>
</select>
</label>
<label class="housekeeping-field housekeeping-field--color">
<span>${esc(t('housekeeping.profileColor'))}</span>
<input name="avatar_color" type="color" value="${esc(item.avatar_color || '#7C3AED')}">
</label>
</div>
<label class="housekeeping-field">
<span>${esc(t('housekeeping.workerNotes'))}</span>
<textarea name="notes" rows="3" maxlength="5000">${esc(item.notes || '')}</textarea>
</label>
<button class="btn btn--primary housekeeping-form-submit" type="submit">
<i data-lucide="save" aria-hidden="true"></i>
<span>${esc(t('common.save'))}</span>
</button>
</form>
`,
onSave: (panel) => {
panel.querySelector('#housekeeping-worker-form')?.addEventListener('submit', async (event) => {
event.preventDefault();
const form = event.currentTarget;
const fields = form.elements;
try {
await api.post('/housekeeping/worker', {
id: fields.id.value || null,
display_name: fields.display_name.value.trim(),
username: fields.username.value.trim() || null,
phone: fields.phone.value.trim() || null,
email: fields.email.value.trim() || null,
birth_date: fields.birth_date.value || null,
daily_rate: Number(fields.daily_rate.value || 0),
payment_schedule: fields.payment_schedule.value,
calendar_color: fields.calendar_color.value,
avatar_color: fields.avatar_color.value,
avatar_data: state.workerAvatar,
notes: fields.notes.value.trim() || null,
});
window.oikos?.showToast(t('housekeeping.workerSavedToast'), 'success');
await loadData();
closeModal({ force: true });
renderStaff(content);
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
}
});
},
});
const panel = document.querySelector('.modal-panel');
const avatarFile = panel?.querySelector('#housekeeping-avatar-file');
const avatarButton = panel?.querySelector('#housekeeping-avatar-btn');
avatarButton?.addEventListener('click', () => avatarFile?.click());
avatarFile?.addEventListener('change', () => {
const file = avatarFile.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.addEventListener('load', () => {
state.workerAvatar = String(reader.result || '');
avatarButton.replaceChildren();
avatarButton.insertAdjacentHTML('beforeend', `<img src="${esc(state.workerAvatar)}" alt="">`);
});
reader.readAsDataURL(file);
});
if (window.lucide) window.lucide.createIcons({ el: panel });
}
export async function render(container) {
container.replaceChildren();
container.insertAdjacentHTML('beforeend', `
<section class="housekeeping-page housekeeping-page--loading">
<div class="housekeeping-loading">${esc(t('common.loading'))}</div>
</section>
`);
try {
await loadData();
renderShell(container);
} catch (err) {
container.replaceChildren();
container.insertAdjacentHTML('beforeend', `
<section class="housekeeping-page">
<div class="empty-state">
<div class="empty-state__title">${esc(t('common.errorOccurred'))}</div>
<div class="empty-state__description">${esc(err.message)}</div>
</div>
</section>
`);
}
}
+28 -1
View File
@@ -201,7 +201,7 @@ export async function render(container, { user }) {
let users = [];
let googleStatus = { configured: false, connected: false, lastSync: null };
let appleStatus = { configured: false, lastSync: null };
let prefs = { visible_meal_types: ['breakfast', 'lunch', 'dinner', 'snack'], currency: 'EUR', date_format: 'mdy', time_format: '24h', app_name: DEFAULT_APP_NAME, disabled_modules: [] };
let prefs = { visible_meal_types: ['breakfast', 'lunch', 'dinner', 'snack'], currency: 'EUR', date_format: 'mdy', time_format: '24h', app_name: DEFAULT_APP_NAME, disabled_modules: [], housekeeping_payment_tasks: false };
let categories = [];
let icsSubscriptions = [];
let apiTokens = [];
@@ -376,6 +376,20 @@ export async function render(container, { user }) {
</div>
</section>
` : ''}
${user?.role === 'admin' ? `
<section class="settings-section">
<h2 class="settings-section__title">${t('settings.sectionHousekeeping')}</h2>
<div class="settings-card">
<h3 class="settings-card__title">${t('settings.housekeepingPaymentsTitle')}</h3>
<p class="form-hint" style="margin-bottom:var(--space-3)">${t('settings.housekeepingPaymentTasksHint')}</p>
<label class="toggle-row">
<input type="checkbox" id="housekeeping-payment-tasks" ${prefs.housekeeping_payment_tasks ? 'checked' : ''}>
<span>${t('settings.housekeepingPaymentTasksLabel')}</span>
</label>
</div>
</section>
` : ''}
</div>
<!-- Panel: Mahlzeiten -->
@@ -1284,6 +1298,19 @@ function bindEvents(container, user, users, categories, icsSubscriptions, apiTok
});
}
const housekeepingPaymentTasks = container.querySelector('#housekeeping-payment-tasks');
if (housekeepingPaymentTasks) {
housekeepingPaymentTasks.addEventListener('change', async () => {
try {
await api.put('/preferences', { housekeeping_payment_tasks: housekeepingPaymentTasks.checked });
window.oikos?.showToast(t('settings.housekeepingPaymentTasksSaved'), 'success');
} catch (err) {
window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger');
housekeepingPaymentTasks.checked = !housekeepingPaymentTasks.checked;
}
});
}
const appNameForm = container.querySelector('#app-name-form');
if (appNameForm) {
appNameForm.addEventListener('submit', async (e) => {