/**
* 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', `
`);
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(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(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(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(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 `
${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)}
`);
}
}