/** * 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, getLocale } from '/i18n.js'; import { esc } from '/utils/html.js'; import { openModal, closeModal, confirmModal } from '/components/modal.js'; const MAX_FILE_SIZE = 5 * 1024 * 1024; function localDate(d = new Date()) { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } let state = { tab: 'dashboard', dashboard: null, tasks: [], reports: [], visitReport: null, templates: [], worker: null, workers: [], workerAvatar: undefined, selectedStaffId: null, staffLogMonth: localDate().slice(0, 7), staffVisits: [], currency: 'EUR', }; function money(value) { return new Intl.NumberFormat(getLocale(), { style: 'currency', currency: state.currency }).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 || localDate(); 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, prefs] = 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'), api.get('/preferences'), ]); 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; state.currency = prefs.data?.currency ?? 'EUR'; } function renderTabButton(tab, icon, label) { const current = state.tab === tab ? ' aria-current="page"' : ''; return ` `; } function renderShell(container) { container.replaceChildren(); container.insertAdjacentHTML('beforeend', `
${esc(t('housekeeping.title'))}
`); 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; try { if (current) { await api.post('/housekeeping/work-sessions/check-out', { worker_id: worker.id }); window.oikos?.showToast(t('housekeeping.checkedOutToast'), 'success'); } else { await api.post('/housekeeping/work-sessions/check-in', { worker_id: worker.id, daily_rate: worker.daily_rate || 0, extras: 0, ...visitTextPayload(worker, localDate(), 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 `

${esc(t('housekeeping.noWorkerTitle'))}

${esc(t('housekeeping.noWorkerHint'))}

`; } const rows = state.workers.map((worker) => { const checkedIn = !!(worker.today_session || worker.current_session); const session = worker.today_session || worker.current_session; return `
${worker.avatar_data ? `${esc(worker.display_name)}` : esc(initials(worker.display_name))}
${esc(worker.display_name)} ${esc(checkedIn ? `${t('housekeeping.visitRecordedAt')} ${formatTime(session.check_in)}` : `${money(worker.daily_rate)} · ${scheduleLabel(worker.payment_schedule)}`)}
`; }).join(''); return `
${rows}
`; } 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 `
${esc(row.month.slice(5))}
`; }).join(''); content.insertAdjacentHTML('beforeend', ` ${renderWorkerSummary()}
${esc(t('housekeeping.visitsThisMonth'))} ${esc(data.visits_this_month ?? 0)}
${esc(t('housekeeping.lastVisit'))} ${esc(lastVisit)}
${esc(t('housekeeping.pendingChores'))} ${esc(data.pending_tasks ?? 0)}
${esc(t('housekeeping.finishedChores'))} ${esc(data.finished_tasks_this_month ?? 0)}

${esc(t('housekeeping.payments'))}

${esc(t('housekeeping.pendingPayments'))}: ${esc(money(data.pending_payments || 0))}
${bars || `

${esc(t('housekeeping.noPaymentData'))}

`}
`); 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) => ` `).join(''); const taskRows = state.tasks.map((task) => `

${esc(task.name)}

${esc(task.area)} · ${esc(t('housekeeping.everyDays', { days: task.frequency_days }))}

${esc(urgencyLabel(task.urgency_status))}
`).join(''); content.insertAdjacentHTML('beforeend', `

${esc(t('housekeeping.taskTemplates'))}

${templateButtons}

${esc(t('housekeeping.addCustomTask'))}

${taskRows || `

${esc(t('housekeeping.noTasks'))}

`}
`); 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 `
${visit.worker_avatar_data ? `${esc(visit.worker_name || '')}` : esc(initials(visit.worker_name || 'HK'))}
${esc(visit.worker_name || t('housekeeping.staff'))} ${esc(formatDate(visit.check_in))} · ${esc(money(visit.total_amount))} · ${esc(paid ? t('housekeeping.paymentPaid') : t('housekeeping.paymentPending'))}
`; }).join(''); content.insertAdjacentHTML('beforeend', `

${esc(t('housekeeping.visitReports'))}

${esc(state.visitReport?.month || '')}
${esc(t('housekeeping.visitsThisMonth'))} ${esc(visits.length)}
${esc(t('housekeeping.pendingPayments'))} ${esc(money(totals.pending || 0))}
${esc(t('housekeeping.paymentPaid'))} ${esc(money(totals.paid || 0))}
${rows || `

${esc(t('housekeeping.noVisitReports'))}

`}
`); 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: `
${visit.worker_avatar_data ? `${esc(visit.worker_name || '')}` : esc(initials(visit.worker_name || 'HK'))}
${esc(visit.worker_name || t('housekeeping.staff'))} ${esc(scheduleLabel(visit.payment_schedule))}
${esc(t('housekeeping.lastVisit'))}
${esc(formatDate(visit.check_in))} · ${esc(formatTime(visit.check_in))}
${esc(t('housekeeping.dailyRate'))}
${esc(money(visit.daily_rate))}
${esc(t('housekeeping.extras'))}
${esc(money(visit.extras))}
${esc(t('housekeeping.totalPayment'))}
${esc(money(visit.total_amount))}
${esc(t('housekeeping.paymentStatus'))}
${esc(paid ? t('housekeeping.paymentPaid') : t('housekeeping.paymentPending'))}
${esc(t('housekeeping.paymentTask'))}
${esc(visit.payment_task_id ? `#${visit.payment_task_id}` : t('housekeeping.notAvailable'))}
${esc(t('housekeeping.calendarEvent'))}
${esc(visit.calendar_event_id ? `#${visit.calendar_event_id}` : t('housekeeping.notAvailable'))}
`, }); } function renderStaff(content) { content.replaceChildren(); const workerRows = state.workers.map((item) => `
${item.avatar_data ? `${esc(item.display_name)}` : esc(initials(item.display_name))}
${esc(item.display_name)} ${esc(item.phone || item.email || '')}
`).join(''); content.insertAdjacentHTML('beforeend', `

${esc(t('housekeeping.staffTitle'))}

${workerRows || `

${esc(t('housekeeping.noWorkers'))}

`}
${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 || localDate().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 `
${esc(formatDate(visit.check_in))} ${esc(money(visit.total_amount))} · ${esc(paid ? t('housekeeping.paymentPaid') : t('housekeeping.paymentPending'))}
`; }).join(''); return `

${esc(t('housekeeping.staffLogTitle', { name: worker.display_name }))}

${esc(t('housekeeping.staffLogHint'))}
${rows || `

${esc(t('housekeeping.noVisitReports'))}

`}
`; } 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: `
`, 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: `
`, 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', ``); }); reader.readAsDataURL(file); }); if (window.lucide) window.lucide.createIcons({ el: panel }); } export async function render(container) { container.replaceChildren(); container.insertAdjacentHTML('beforeend', `
${esc(t('common.loading'))}
`); try { await loadData(); renderShell(container); } catch (err) { container.replaceChildren(); container.insertAdjacentHTML('beforeend', `
${esc(t('common.errorOccurred'))}
${esc(err.message)}
`); } }