diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..c236988 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,134 @@ +# Housekeeping Design + +## Goal + +Housekeeping adds a simplified mobile/PWA module for the cleaner workflow in Oikos. It keeps the existing private-network, authenticated-session security model and exposes no public endpoints. + +## User Experience + +The `/housekeeping` route is a focused module that follows the same toolbar, tab, card, and chip patterns used by the rest of Oikos: + +- **Dashboard**: staff-specific check-in/check-out actions, visits this month, last visit, pending/finished chores, pending payments, and a compact monthly payment chart. +- **Tasks**: suggested chore templates, custom chore creation, urgency-sorted recurring tasks, and one-tap completion. +- **Reports**: camera upload plus text description for maintenance occurrences. +- **Staff**: one or more housekeeper people, contact data, profile pictures, daily rates, and payment schedules. + +Accessibility constraints: + +- Primary actions are at least 44px high. +- Check-in/out is a compact top-toolbar action, matching the small action pattern used elsewhere in the app. +- Status is communicated by text and color, not color alone. +- Inputs and buttons have explicit labels or accessible names. +- Icons use the locally bundled Lucide runtime; no external CDN is introduced. + +## Data Model + +### `housekeeping_work_sessions` + +Stores point/finance records: + +- `id` +- `worker_id` +- `check_in` +- `check_out` +- `daily_rate` +- `extras` +- `calendar_event_id` +- `created_by` +- `created_at` +- `updated_at` + +Monthly amount is calculated as `SUM(daily_rate + extras)` for sessions whose `check_in` belongs to the requested month. +Each check-in creates a linked local calendar event for the selected staff member. Check-out updates that event end time. + +### `housekeeping_decay_tasks` + +Stores dynamic recurring cleaning tasks: + +- `id` +- `name` +- `area` +- `frequency_days` +- `last_completed` +- `created_by` +- `created_at` +- `updated_at` + +Urgency is computed at read time: + +```text +urgency = (now - last_completed) / frequency_days +``` + +Status mapping: + +- `overdue`: due date is before today. +- `today`: due date is today. +- `ok`: due date is in the future. + +Rows with no `last_completed` are treated as overdue. + +### `housekeeping_supply_requests` + +Stores quick supply requests and links each request to an Oikos shopping item: + +- `id` +- `name` +- `quantity` +- `shopping_item_id` +- `created_by` +- `created_at` + +The supply request transaction always appends an item to the main `shopping_items` table. If no shopping list exists, the backend creates a private authenticated list named `Housekeeping`. + +### `housekeeping_maintenance_log` + +Stores maintenance occurrences: + +- `id` +- `description` +- `photo_url` +- `created_by` +- `created_at` +- `updated_at` + +`photo_url` accepts self-contained `data:image/png|jpeg|webp;base64,...` values only, keeping uploaded camera photos inside the authenticated Oikos database boundary. + +### `housekeeping_workers` + +Stores housekeeper-specific employment/payment settings while keeping the person unified with Oikos user/contact/birthday data: + +- `id` +- `user_id` +- `daily_rate` +- `payment_schedule` +- `calendar_color` +- `notes` +- `created_at` +- `updated_at` + +The linked `users` row is excluded from normal Family Management and Family APIs through the `housekeeping_workers` association, but remains synchronized with contacts and birthdays. +Multiple housekeepers can be registered; each has its own linked `users` row. +`calendar_color` controls the default color used for housekeeping visit events. Visit events use the cleaning icon (`sparkles`). + +## REST API + +All endpoints are mounted under `/api/v1/housekeeping` and inherit the existing `requireAuth` and CSRF middleware. + +- `GET /summary?month=YYYY-MM` +- `GET /dashboard` +- `GET /task-templates` +- `GET /worker` +- `GET /workers` +- `POST /worker` +- `GET /work-sessions?month=YYYY-MM` +- `POST /work-sessions/check-in` +- `POST /work-sessions/check-out` +- `GET /decay-tasks` +- `POST /decay-tasks` +- `PATCH /decay-tasks/:taskId` +- `POST /decay-tasks/:taskId/complete` +- `DELETE /decay-tasks/:taskId` +- `POST /supply-requests` +- `GET /maintenance-log` +- `POST /maintenance-log` diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md new file mode 100644 index 0000000..21f87b2 --- /dev/null +++ b/IMPLEMENTATION.md @@ -0,0 +1,88 @@ +# Housekeeping Implementation + +## Backend + +The module is implemented in `server/routes/housekeeping.js` and registered in `server/index.js` under: + +```text +/api/v1/housekeeping +``` + +The registration happens after the global authenticated `/api/v1` middleware, so the module follows the existing Oikos security model: + +- Session or API-token authentication required. +- CSRF required for state-changing session requests. +- API rate limiting inherited from `/api/`. +- No unauthenticated housekeeping route. + +Database schema is migration `33` in `server/db.js`. The migration creates: + +- `housekeeping_work_sessions` +- `housekeeping_decay_tasks` +- `housekeeping_supply_requests` +- `housekeeping_maintenance_log` + +Migration `34` adds: + +- `housekeeping_workers` +- `housekeeping_work_sessions.paid_at` + +Migration `35` adds per-staff visit tracking: + +- `housekeeping_workers.calendar_color` +- `housekeeping_work_sessions.worker_id` +- `housekeeping_work_sessions.calendar_event_id` + +Each staff profile links to `users.id`. These users are hidden from the normal family list by filtering rows associated with `housekeeping_workers`, while contact and birthday sync remains shared with the existing family-member artifact flow. + +The quick supply endpoint uses a SQLite transaction: + +1. Resolve the first existing shopping list, or create `Housekeeping`. +2. Insert a `shopping_items` row. +3. Insert a `housekeeping_supply_requests` row linked to the shopping item. + +If any step fails, the transaction rolls back. + +## Frontend + +The SPA route `/housekeeping` is registered in `public/router.js` and loads: + +- `public/pages/housekeeping.js` +- `public/styles/housekeeping.css` + +The page uses the existing API wrapper in `public/api.js`, so CSRF tokens and auth expiry behavior remain centralized. The UI now follows the standard Oikos module layout: sticky toolbar, horizontal tab chips, and regular cards. +Check-in/check-out actions live beside each staff member on the Dashboard and are disabled until at least one staff member exists. + +Calendar integration creates a local calendar event at check-in, assigns it to the staff user, uses the staff calendar color, and updates the end time on check-out. Calendar event icon selection now opens a dedicated icon picker dialog instead of expanding inline inside the event modal. + +The UI intentionally avoids `innerHTML`; rendering uses `replaceChildren()` and `insertAdjacentHTML()` with escaped dynamic values. + +## Localization + +The module adds: + +- `nav.housekeeping` +- `housekeeping.*` + +to every JSON locale under `public/locales`. + +Portuguese is the primary text for the cleaner-facing target workflow, with localized strings for the most common existing languages and English fallback text for remaining locales. + +## Validation + +Backend validation covers: + +- Required strings and max lengths. +- Positive integer `frequency_days`. +- Non-negative `daily_rate` and `extras`. +- `YYYY-MM` month filters. +- Maintenance photos limited to PNG, JPEG, or WebP data URLs under 6 MB. + +## Manual Use + +1. Navigate to `/housekeeping`. +2. Use the check-in/check-out button next to the staff member on the Dashboard. +3. Review the Dashboard metrics and payment chart. +4. On **Tasks**, choose suggested chores or create a custom recurring chore. +5. On **Reports**, take/upload a photo and submit a maintenance description. +6. On **Staff**, create or update one or more housekeepers, contacts, birthdays, rates, and payment schedules. diff --git a/public/index.html b/public/index.html index c48f7dd..c6a0601 100644 --- a/public/index.html +++ b/public/index.html @@ -18,7 +18,7 @@ Oikos - + diff --git a/public/locales/ar.json b/public/locales/ar.json index f170b5b..fb0a355 100644 --- a/public/locales/ar.json +++ b/public/locales/ar.json @@ -54,7 +54,8 @@ "more": "المزيد", "documents": "المستندات", "kitchen": "المطبخ", - "search": "بحث" + "search": "بحث", + "housekeeping": "Housekeeping" }, "dashboard": { "title": "لوحة التحكم", @@ -205,7 +206,18 @@ "kanbanArchived": "مؤرشف", "reminderNeedsDueDate": "حدّد تاريخ استحقاق لتفعيل تذكيرات المهمة.", "emptyAction": "إنشاء مهمة", - "navLabelOverdue": "المهام، {{count}} متأخرة" + "navLabelOverdue": "المهام، {{count}} متأخرة", + "bulkArchive": "Archive", + "bulkArchived": "Tasks archived.", + "bulkDelete": "Delete", + "bulkDeleteConfirm": "Delete {{count}} tasks permanently?", + "bulkDeleted": "Tasks deleted.", + "bulkMarkDone": "Mark done", + "bulkMarkOpen": "Mark open", + "bulkSelect": "Bulk select", + "bulkSelectedCount": "{{count}} selected", + "bulkStatusChanged": "Status changed.", + "selectTask": "Select task" }, "shopping": { "title": "التسوق", @@ -479,7 +491,13 @@ "colorPurple": "بنفسجي", "colorRed": "أحمر", "colorSkyBlue": "أزرق سماوي", - "colorYellow": "أصفر" + "colorYellow": "أصفر", + "iconCleaning": "Cleaning", + "caldavTargetHint": "Choose a CalDAV calendar to sync this event.", + "caldavTargetLabel": "Sync to CalDAV", + "caldavTargetLocal": "Store locally only", + "attachmentDocumentName": "{{title}} - {{name}}", + "attachmentDocumentDescription": "مرفق تم رفعه لحدث التقويم \"{{title}}\"." }, "notes": { "title": "لوحة الملاحظات", @@ -977,7 +995,72 @@ "addressbookEnabled": "Addressbook enabled", "addressbookDisabled": "Addressbook disabled", "addressbooksRefreshed": "Addressbooks refreshed", - "deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link." + "deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link.", + "sectionHousekeeping": "Housekeeping", + "housekeepingPaymentsTitle": "Payment tasks", + "housekeepingPaymentTasksLabel": "Create a payment task on each housekeeper check-in", + "housekeepingPaymentTasksHint": "When enabled, each check-in creates a task for paying the staff member. Completing that task marks the visit payment as paid.", + "housekeepingPaymentTasksSaved": "Housekeeping payment setting saved.", + "backupSchedulerDisabled": "Disabled", + "backupSchedulerEnabled": "Enabled", + "backupSchedulerHint": "Scheduled backups are created automatically and old backups are rotated.", + "backupSchedulerKeep": "Retention", + "backupSchedulerKeepCount": "{{count}} backups", + "backupSchedulerLastBackup": "Last backup", + "backupSchedulerLastFail": "{{date}} (failed)", + "backupSchedulerLastSuccess": "{{date}} (successful)", + "backupSchedulerNever": "No backup created yet", + "backupSchedulerSchedule": "Schedule", + "backupSchedulerStatus": "Status", + "backupSchedulerTitle": "Automatic Backups", + "backupSchedulerTrigger": "Create backup now", + "backupSchedulerTriggeredToast": "Backup created successfully.", + "backupSchedulerTriggering": "Creating backup...", + "breadcrumbLabel": "Pfad", + "caldavAccountAdded": "CalDAV account added successfully", + "caldavAccountDeleted": "CalDAV account removed", + "caldavAddAccount": "Add CalDAV Account", + "caldavCalendarsToggle": "Show/hide calendars", + "caldavConnectionFailed": "Connection to CalDAV server failed", + "caldavDescription": "Connect multiple CalDAV accounts (iCloud, Nextcloud, Radicale, Baikal, etc.) and choose which calendars to sync.", + "caldavEmptyState": "No CalDAV accounts connected yet. Add your first account to get started.", + "caldavNameLabel": "Account Name", + "caldavNamePlaceholder": "e.g. My Radicale, iCloud, Nextcloud", + "caldavPasswordHint": "For iCloud: Use app-specific password from appleid.apple.com", + "caldavPasswordLabel": "Password", + "caldavRefreshCalendars": "Refresh calendars", + "caldavSyncFailed": "CalDAV sync failed", + "caldavSyncSuccess": "CalDAV sync successful", + "caldavTitle": "CalDAV Calendars", + "caldavUrlHint": "The base URL of your CalDAV server", + "caldavUsernameLabel": "Username", + "calendarDisabled": "Calendar disabled", + "calendarEnabled": "Calendar enabled", + "calendarsRefreshed": "Calendars refreshed", + "deleteAccountConfirm": "Really delete CalDAV account? All synced calendars will be removed.", + "emptyStateAddFirst": "Füge dein erstes Konto hinzu", + "emptyStateNoAccounts": "Noch keine Konten verbunden", + "helpTooltipCalDAV": "CalDAV ermöglicht die Synchronisation von Kalendern mit iCloud, Nextcloud und anderen CalDAV-Servern.", + "helpTooltipCardDAV": "CardDAV ermöglicht die Synchronisation von Kontakten mit iCloud, Nextcloud und anderen CardDAV-Servern.", + "lastSync": "Last synced", + "modulesHint": "Disabled modules disappear from the navigation. Data is preserved and reappears once a module is re-enabled.", + "modulesSaved": "Module visibility saved.", + "modulesTitle": "Active modules", + "navigationLabel": "Einstellungsnavigation", + "sectionAdmin": "Administration", + "sectionCloudServices": "Cloud-Dienste", + "sectionModules": "Modules", + "sectionModulesNav": "Module", + "sectionOpenStandards": "CalDAV & CardDAV", + "sectionPersonal": "Persönlich", + "sectionSync": "Synchronisation", + "statusError": "Fehler", + "statusNeverSynced": "Noch nie synchronisiert", + "statusSynced": "Synchronisiert", + "statusSyncing": "Synchronisiert…", + "syncedAgo": "vor {{time}}", + "tabSyncCalendar": "Kalender", + "tabSyncContacts": "Kontakte" }, "login": { "tagline": "تخطيط عائلي. آمن. يحترم الخصوصية. مفتوح المصدر.", @@ -1120,7 +1203,7 @@ "customWeeks": "Weeks" }, "onboarding": { - "step1Title": "Welcome to Oikos", + "step1Title": "مرحبًا بك في {{name}}", "step1Body": "Your personal family planner. Tasks, calendar, shopping and more – all in one place.", "step2Title": "التنقل والوحدات", "step2Body": "في الأسفل يمكنك الوصول مباشرة إلى لوحة التحكم والتقويم. بزر ··· تفتح وحدات أخرى مثل المطبخ والملاحظات وجهات الاتصال.", @@ -1203,7 +1286,18 @@ }, "dropzoneTitle": "أفلت الملف هنا أو انقر للاختيار", "dropzoneHint": "اسحب ملفًا إلى هذه المنطقة أو استخدم محدد الملفات.", - "selectedFileLabel": "المحدد: {{name}}" + "selectedFileLabel": "المحدد: {{name}}", + "addFolderButton": "Add folder", + "allFolders": "All folders", + "folderLabel": "Folder", + "noFolder": "No folder", + "newFolderTitle": "New folder", + "folderNameLabel": "Folder name", + "createFolderAction": "Create folder", + "folderCreatedToast": "Folder created.", + "housekeepingFolder": "التنظيف المنزلي", + "calendarItemsFolder": "عناصر التقويم", + "folderBrowserTitle": "تصفح المجلدات" }, "shortcuts": { "goKitchen": "المطبخ", @@ -1215,5 +1309,154 @@ "help": "اختصارات لوحة المفاتيح", "new": "إنشاء إدخال جديد", "search": "فتح البحث" + }, + "housekeeping": { + "title": "Cleaner workspace", + "bottomNav": "Housekeeping navigation", + "home": "Home", + "tasks": "Tasks", + "report": "Report", + "notCheckedIn": "Not checked in", + "checkedInAt": "Checked in at", + "monthTotal": "Current month · {{count}} sessions", + "dailyRate": "Daily rate", + "extras": "Extras", + "checkIn": "Check in", + "checkOut": "Check out", + "quickSupply": "Missing product", + "supplyName": "Product name", + "supplyPlaceholder": "What is missing?", + "checkedInToast": "Check-in recorded.", + "checkedOutToast": "Check-out recorded.", + "supplyAddedToast": "Added to the shopping list.", + "overdue": "Overdue", + "dueToday": "Due today", + "ok": "OK", + "noTasks": "No housekeeping tasks yet.", + "everyDays": "Every {{days}} days", + "completeTask": "Complete {{name}}", + "taskDoneToast": "Task completed.", + "reportTitle": "Report a problem", + "problemDescription": "Problem description", + "problemPlaceholder": "Example: burnt-out light bulb", + "addPhoto": "Add photo", + "sendReport": "Send report", + "reportSentToast": "Problem reported.", + "recentReports": "Recent reports", + "addTask": "Add task", + "taskName": "Task", + "taskNamePlaceholder": "Example: Clean bathrooms", + "taskArea": "Area", + "taskAreaPlaceholder": "Example: Bathroom", + "taskFrequency": "Frequency", + "createTask": "Create task", + "taskCreatedToast": "Housekeeping task created.", + "dashboard": "Dashboard", + "reports": "Reports", + "visitsThisMonth": "Visits this month", + "lastVisit": "Last visit", + "pendingChores": "Pending chores", + "finishedChores": "Finished chores", + "payments": "Payments", + "pendingPayments": "Pending payments", + "monthlyPayments": "Monthly payments", + "noPaymentData": "No payment data yet.", + "noVisits": "No visits yet", + "noWorkerTitle": "No housekeeper profile", + "noWorkerHint": "Create the worker profile to define contacts, rate, and payment schedule.", + "taskTemplates": "Suggested chores", + "addCustomTask": "Add custom chore", + "noReports": "No reports yet.", + "profileTitle": "Housekeeper profile", + "profilePicture": "Housekeeper profile picture", + "workerName": "Name", + "workerUsername": "Username", + "workerPhone": "Phone", + "workerEmail": "Email", + "workerBirthDate": "Birthday", + "paymentSchedule": "Payment schedule", + "scheduleDaily": "Every visit", + "scheduleTwiceMonthly": "Twice a month", + "scheduleMonthly": "Monthly", + "profileColor": "Profile color", + "workerNotes": "Notes", + "workerSavedToast": "Housekeeper profile saved.", + "staff": "Staff", + "staffTitle": "Housekeeping staff", + "addWorker": "Add housekeeper", + "editWorker": "Edit housekeeper", + "noWorkers": "No housekeepers registered yet.", + "moreWorkers": "+{{count}} more", + "checkInDisabled": "Add a housekeeper before checking in.", + "calendarColor": "Calendar color", + "visitRecordedAt": "Visit recorded at", + "checkedInToday": "Recorded today", + "visitReports": "Staff visit reports", + "noVisitReports": "No staff visits recorded this month.", + "openVisitReport": "Open visit report", + "visitReportDetails": "Visit report", + "paymentPaid": "Paid", + "paymentPending": "Pending", + "totalPayment": "Total payment", + "paymentStatus": "Payment status", + "paymentTask": "Payment task", + "calendarEvent": "Calendar event", + "notAvailable": "Not available", + "calendarVisitTitle": "Housekeeping: {{name}}", + "paymentTaskTitle": "Pay {{name}} for housekeeping", + "paymentTaskDescription": "Housekeeping visit on {{date}}. Amount due: {{amount}}.", + "staffLogTitle": "{{name}} visits", + "staffLogHint": "Edit visit dates, amounts, and linked records.", + "filterMonth": "Month", + "editVisit": "Edit visit", + "deleteVisit": "Delete visit", + "deleteVisitConfirm": "Delete this visit? The linked calendar event and payment task will also be removed.", + "visitDeletedToast": "Visit deleted.", + "visitSavedToast": "Visit updated.", + "visitDate": "Visit date", + "markPaid": "Mark paid", + "visitPaidToast": "Payment marked as paid.", + "receiptUploadTitle": "Upload payment receipt", + "receiptUploadHint": "Attach a payment receipt. It will appear in Documents.", + "receiptDocumentName": "Receipt - {{name}} - {{date}}", + "receiptDocumentDescription": "Payment receipt for {{name}} housekeeping visit on {{date}}.", + "taskTemplateData": { + "cleanBathrooms": { + "name": "تنظيف الحمامات", + "area": "الحمامات" + }, + "mopKitchenFloor": { + "name": "مسح أرضية المطبخ", + "area": "المطبخ" + }, + "dustLivingRoom": { + "name": "إزالة الغبار من غرفة المعيشة", + "area": "غرفة المعيشة" + }, + "changeBedLinens": { + "name": "تغيير مفروشات السرير", + "area": "غرف النوم" + }, + "cleanRefrigerator": { + "name": "تنظيف الثلاجة", + "area": "المطبخ" + }, + "cleanWindows": { + "name": "تنظيف النوافذ", + "area": "المنزل كله" + }, + "deepCleanOven": { + "name": "تنظيف الفرن بعمق", + "area": "المطبخ" + }, + "washOutdoor": { + "name": "غسل الشرفة/الفناء", + "area": "الخارج" + } + } + }, + "userMultiSelect": { + "moreUsers": "weitere", + "nobody": "- Niemand -" } -} \ No newline at end of file +} diff --git a/public/locales/de.json b/public/locales/de.json index 161aedb..a83729f 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -54,7 +54,8 @@ "recipes": "Rezepte", "documents": "Dokumente", "kitchen": "Küche", - "search": "Suche" + "search": "Suche", + "housekeeping": "Hauspflege" }, "search": { "title": "Suche", @@ -499,7 +500,10 @@ "attachmentHint": "Lokales Bild, PDF oder Dokument anhängen. Bilder werden im Ereignis-Popup angezeigt.", "attachmentFallback": "Anhang", "attachmentReadError": "Der Anhang konnte nicht gelesen werden.", - "attachmentTooLarge": "Der Anhang darf höchstens 5 MB groß sein." + "attachmentTooLarge": "Der Anhang darf höchstens 5 MB groß sein.", + "iconCleaning": "Reinigung", + "attachmentDocumentName": "{{title}} - {{name}}", + "attachmentDocumentDescription": "Anhang für Kalendereintrag \"{{title}}\" hochgeladen." }, "notes": { "title": "Notizen", @@ -779,7 +783,7 @@ "tabFamily": "Familie", "tabApiTokens": "API-Tokens", "tabAccount": "Konto", - "tabBackup": "Backup", + "tabBackup": "Backup-Verwaltung", "tabsAriaLabel": "Einstellungsbereiche", "sectionDesign": "Design", "sectionAppName": "Anwendungsname", @@ -845,7 +849,7 @@ "disconnectedToast": "{{provider}} getrennt.", "googleOnlyAdmin": "Nur Admin kann Google Calendar verbinden.", "appleOnlyAdmin": "Nur Admin kann Apple Calendar verbinden.", - "caldavUrlLabel": "CalDAV-Server-URL", + "caldavUrlLabel": "CalDAV Server-URL", "caldavUrlPlaceholder": "https://caldav.icloud.com", "appleIdLabel": "Apple-ID (E-Mail)", "applePasswordLabel": "App-spezifisches Passwort", @@ -975,7 +979,6 @@ "memberBirthDateInvalid": "Bitte ein gültiges Geburtstagsdatum im ausgewählten Format verwenden.", "memberPhoneMeta": "Telefon: {{value}}", "memberBirthdayMeta": "Geburtstag: {{date}}", - "tabBackup": "Backup-Verwaltung", "sectionBackup": "Backup-Verwaltung", "backupDownloadTitle": "Datenbank-Backup herunterladen", "backupDownloadHint": "Erstellt ein konsistentes SQLite-Backup aller Anwendungsdaten.", @@ -1012,8 +1015,6 @@ "caldavEmptyState": "Noch keine CalDAV-Konten verbunden. Füge dein erstes Konto hinzu, um zu starten.", "caldavNameLabel": "Kontoname", "caldavNamePlaceholder": "z.B. Mein Radicale, iCloud, Nextcloud", - "caldavUrlLabel": "CalDAV Server-URL", - "caldavUrlPlaceholder": "https://caldav.icloud.com", "caldavUrlHint": "Die Basis-URL deines CalDAV-Servers", "caldavUsernameLabel": "Benutzername", "caldavPasswordLabel": "Passwort", @@ -1030,7 +1031,6 @@ "calendarsRefreshed": "Kalender aktualisiert", "deleteAccountConfirm": "CalDAV-Konto wirklich löschen? Alle synchronisierten Kalender werden entfernt.", "lastSync": "Zuletzt synchronisiert", - "cardavTitle": "CardDAV Kontakte", "cardavDescription": "Verbinde mehrere CardDAV-Konten (iCloud, Nextcloud, Radicale, etc.) und synchronisiere deine Kontakte.", "cardavAddAccount": "CardDAV-Konto hinzufügen", "cardavEmptyState": "Noch keine CardDAV-Konten verbunden. Füge dein erstes Konto hinzu, um Kontakte zu synchronisieren.", @@ -1061,7 +1061,12 @@ "helpTooltipCalDAV": "CalDAV ermöglicht die Synchronisation von Kalendern mit iCloud, Nextcloud und anderen CalDAV-Servern.", "helpTooltipCardDAV": "CardDAV ermöglicht die Synchronisation von Kontakten mit iCloud, Nextcloud und anderen CardDAV-Servern.", "emptyStateAddFirst": "Füge dein erstes Konto hinzu", - "emptyStateNoAccounts": "Noch keine Konten verbunden" + "emptyStateNoAccounts": "Noch keine Konten verbunden", + "sectionHousekeeping": "Housekeeping", + "housekeepingPaymentsTitle": "Payment tasks", + "housekeepingPaymentTasksLabel": "Create a payment task on each housekeeper check-in", + "housekeepingPaymentTasksHint": "When enabled, each check-in creates a task for paying the staff member. Completing that task marks the visit payment as paid.", + "housekeepingPaymentTasksSaved": "Housekeeping payment setting saved." }, "login": { "tagline": "Familienplanung. Sicher. Datenschutzfreundlich. Open Source.", @@ -1198,7 +1203,7 @@ "copySuffix": "Kopie" }, "onboarding": { - "step1Title": "Willkommen bei Oikos", + "step1Title": "Willkommen bei {{name}}", "step1Body": "Dein persönlicher Familienplaner. Aufgaben, Kalender, Einkauf und mehr – alles an einem Ort.", "step2Title": "Navigation & Module", "step2Body": "Unten erreichst du Dashboard und Kalender direkt. Mit dem ···-Button öffnest du weitere Module wie Küche, Notizen und Kontakte.", @@ -1292,10 +1297,166 @@ }, "dropzoneTitle": "Datei hier ablegen oder klicken", "dropzoneHint": "Ziehe eine Datei in diesen Bereich oder nutze die Dateiauswahl.", - "selectedFileLabel": "Ausgewählt: {{name}}" + "selectedFileLabel": "Ausgewählt: {{name}}", + "addFolderButton": "Add folder", + "allFolders": "All folders", + "folderLabel": "Folder", + "noFolder": "No folder", + "newFolderTitle": "New folder", + "folderNameLabel": "Folder name", + "createFolderAction": "Create folder", + "folderCreatedToast": "Folder created.", + "housekeepingFolder": "Hausreinigung", + "calendarItemsFolder": "Kalendereinträge", + "folderBrowserTitle": "Ordner durchsuchen" }, "userMultiSelect": { "nobody": "- Niemand -", "moreUsers": "weitere" + }, + "housekeeping": { + "title": "Reinigungsbereich", + "bottomNav": "Hauspflege-Navigation", + "home": "Start", + "tasks": "Aufgaben", + "report": "Melden", + "notCheckedIn": "Nicht eingecheckt", + "checkedInAt": "Eingecheckt um", + "monthTotal": "Aktueller Monat · {{count}} Einsätze", + "dailyRate": "Tagessatz", + "extras": "Extras", + "checkIn": "Einchecken", + "checkOut": "Auschecken", + "quickSupply": "Produkt fehlt", + "supplyName": "Produktname", + "supplyPlaceholder": "Was fehlt?", + "checkedInToast": "Check-in gespeichert.", + "checkedOutToast": "Check-out gespeichert.", + "supplyAddedToast": "Zur Einkaufsliste hinzugefügt.", + "overdue": "Überfällig", + "dueToday": "Heute fällig", + "ok": "OK", + "noTasks": "Noch keine Hauspflege-Aufgaben.", + "everyDays": "Alle {{days}} Tage", + "completeTask": "{{name}} erledigen", + "taskDoneToast": "Aufgabe erledigt.", + "reportTitle": "Problem melden", + "problemDescription": "Problembeschreibung", + "problemPlaceholder": "Beispiel: Glühbirne defekt", + "addPhoto": "Foto hinzufügen", + "sendReport": "Meldung senden", + "reportSentToast": "Problem gemeldet.", + "recentReports": "Letzte Meldungen", + "addTask": "Aufgabe hinzufügen", + "taskName": "Aufgabe", + "taskNamePlaceholder": "Beispiel: Badezimmer reinigen", + "taskArea": "Bereich", + "taskAreaPlaceholder": "Beispiel: Bad", + "taskFrequency": "Häufigkeit", + "createTask": "Aufgabe erstellen", + "taskCreatedToast": "Hauspflege-Aufgabe erstellt.", + "dashboard": "Dashboard", + "reports": "Reports", + "visitsThisMonth": "Besuche im Monat", + "lastVisit": "Letzter Besuch", + "pendingChores": "Offene Aufgaben", + "finishedChores": "Erledigte Aufgaben", + "payments": "Zahlungen", + "pendingPayments": "Offene Zahlungen", + "monthlyPayments": "Monatliche Zahlungen", + "noPaymentData": "Noch keine Zahlungsdaten.", + "noVisits": "Noch keine Besuche", + "noWorkerTitle": "Kein Hauspflege-Profil", + "noWorkerHint": "Profil anlegen, um Kontakte, Tagessatz und Zahlungsrhythmus festzulegen.", + "taskTemplates": "Vorgeschlagene Aufgaben", + "addCustomTask": "Eigene Aufgabe hinzufügen", + "noReports": "Noch keine Meldungen.", + "profileTitle": "Hauspflege-Profil", + "profilePicture": "Profilbild", + "workerName": "Name", + "workerUsername": "Benutzername", + "workerPhone": "Telefon", + "workerEmail": "E-Mail", + "workerBirthDate": "Geburtstag", + "paymentSchedule": "Zahlungsrhythmus", + "scheduleDaily": "Pro Besuch", + "scheduleTwiceMonthly": "Zweimal monatlich", + "scheduleMonthly": "Monatlich", + "profileColor": "Profilfarbe", + "workerNotes": "Notizen", + "workerSavedToast": "Hauspflege-Profil gespeichert.", + "staff": "Personal", + "staffTitle": "Hauspflege-Personal", + "addWorker": "Hauspflege hinzufügen", + "editWorker": "Hauspflege bearbeiten", + "noWorkers": "Noch keine Hauspflege-Person registriert.", + "moreWorkers": "+{{count}} weitere", + "checkInDisabled": "Lege zuerst eine Hauspflege-Person an.", + "calendarColor": "Kalenderfarbe", + "visitRecordedAt": "Visit recorded at", + "checkedInToday": "Recorded today", + "visitReports": "Staff visit reports", + "noVisitReports": "No staff visits recorded this month.", + "openVisitReport": "Open visit report", + "visitReportDetails": "Visit report", + "paymentPaid": "Paid", + "paymentPending": "Pending", + "totalPayment": "Total payment", + "paymentStatus": "Payment status", + "paymentTask": "Payment task", + "calendarEvent": "Calendar event", + "notAvailable": "Not available", + "calendarVisitTitle": "Housekeeping: {{name}}", + "paymentTaskTitle": "Pay {{name}} for housekeeping", + "paymentTaskDescription": "Housekeeping visit on {{date}}. Amount due: {{amount}}.", + "staffLogTitle": "{{name}} visits", + "staffLogHint": "Edit visit dates, amounts, and linked records.", + "filterMonth": "Month", + "editVisit": "Edit visit", + "deleteVisit": "Delete visit", + "deleteVisitConfirm": "Delete this visit? The linked calendar event and payment task will also be removed.", + "visitDeletedToast": "Visit deleted.", + "visitSavedToast": "Visit updated.", + "visitDate": "Visit date", + "markPaid": "Mark paid", + "visitPaidToast": "Payment marked as paid.", + "receiptUploadTitle": "Upload payment receipt", + "receiptUploadHint": "Attach a payment receipt. It will appear in Documents.", + "receiptDocumentName": "Receipt - {{name}} - {{date}}", + "receiptDocumentDescription": "Payment receipt for {{name}} housekeeping visit on {{date}}.", + "taskTemplateData": { + "cleanBathrooms": { + "name": "Bäder reinigen", + "area": "Bäder" + }, + "mopKitchenFloor": { + "name": "Küchenboden wischen", + "area": "Küche" + }, + "dustLivingRoom": { + "name": "Wohnzimmer abstauben", + "area": "Wohnzimmer" + }, + "changeBedLinens": { + "name": "Bettwäsche wechseln", + "area": "Schlafzimmer" + }, + "cleanRefrigerator": { + "name": "Kühlschrank reinigen", + "area": "Küche" + }, + "cleanWindows": { + "name": "Fenster putzen", + "area": "Ganzes Haus" + }, + "deepCleanOven": { + "name": "Backofen gründlich reinigen", + "area": "Küche" + }, + "washOutdoor": { + "name": "Balkon/Terrasse waschen", + "area": "Außenbereich" + } + } } } diff --git a/public/locales/el.json b/public/locales/el.json index 64da05e..8e2a1ce 100644 --- a/public/locales/el.json +++ b/public/locales/el.json @@ -54,7 +54,8 @@ "more": "Περισσότερα", "documents": "Έγγραφα", "kitchen": "Κουζίνα", - "search": "Αναζήτηση" + "search": "Αναζήτηση", + "housekeeping": "Housekeeping" }, "dashboard": { "title": "Επισκόπηση", @@ -205,7 +206,18 @@ "kanbanArchived": "Αρχειοθετημένο", "reminderNeedsDueDate": "Ορίστε ημερομηνία λήξης για να ενεργοποιήσετε τις υπενθυμίσεις.", "emptyAction": "Δημιουργία εργασίας", - "navLabelOverdue": "Εργασίες, {{count}} εκπρόθεσμες" + "navLabelOverdue": "Εργασίες, {{count}} εκπρόθεσμες", + "bulkArchive": "Archive", + "bulkArchived": "Tasks archived.", + "bulkDelete": "Delete", + "bulkDeleteConfirm": "Delete {{count}} tasks permanently?", + "bulkDeleted": "Tasks deleted.", + "bulkMarkDone": "Mark done", + "bulkMarkOpen": "Mark open", + "bulkSelect": "Bulk select", + "bulkSelectedCount": "{{count}} selected", + "bulkStatusChanged": "Status changed.", + "selectTask": "Select task" }, "shopping": { "title": "Αγορές", @@ -479,7 +491,13 @@ "colorPurple": "Μοβ", "colorRed": "Κόκκινο", "colorSkyBlue": "Γαλάζιο", - "colorYellow": "Κίτρινο" + "colorYellow": "Κίτρινο", + "iconCleaning": "Cleaning", + "caldavTargetHint": "Choose a CalDAV calendar to sync this event.", + "caldavTargetLabel": "Sync to CalDAV", + "caldavTargetLocal": "Store locally only", + "attachmentDocumentName": "{{title}} - {{name}}", + "attachmentDocumentDescription": "Συνημμένο που ανέβηκε για το συμβάν ημερολογίου \"{{title}}\"." }, "notes": { "title": "Σημειώσεις", @@ -977,7 +995,72 @@ "addressbookEnabled": "Addressbook enabled", "addressbookDisabled": "Addressbook disabled", "addressbooksRefreshed": "Addressbooks refreshed", - "deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link." + "deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link.", + "sectionHousekeeping": "Housekeeping", + "housekeepingPaymentsTitle": "Payment tasks", + "housekeepingPaymentTasksLabel": "Create a payment task on each housekeeper check-in", + "housekeepingPaymentTasksHint": "When enabled, each check-in creates a task for paying the staff member. Completing that task marks the visit payment as paid.", + "housekeepingPaymentTasksSaved": "Housekeeping payment setting saved.", + "backupSchedulerDisabled": "Disabled", + "backupSchedulerEnabled": "Enabled", + "backupSchedulerHint": "Scheduled backups are created automatically and old backups are rotated.", + "backupSchedulerKeep": "Retention", + "backupSchedulerKeepCount": "{{count}} backups", + "backupSchedulerLastBackup": "Last backup", + "backupSchedulerLastFail": "{{date}} (failed)", + "backupSchedulerLastSuccess": "{{date}} (successful)", + "backupSchedulerNever": "No backup created yet", + "backupSchedulerSchedule": "Schedule", + "backupSchedulerStatus": "Status", + "backupSchedulerTitle": "Automatic Backups", + "backupSchedulerTrigger": "Create backup now", + "backupSchedulerTriggeredToast": "Backup created successfully.", + "backupSchedulerTriggering": "Creating backup...", + "breadcrumbLabel": "Pfad", + "caldavAccountAdded": "CalDAV account added successfully", + "caldavAccountDeleted": "CalDAV account removed", + "caldavAddAccount": "Add CalDAV Account", + "caldavCalendarsToggle": "Show/hide calendars", + "caldavConnectionFailed": "Connection to CalDAV server failed", + "caldavDescription": "Connect multiple CalDAV accounts (iCloud, Nextcloud, Radicale, Baikal, etc.) and choose which calendars to sync.", + "caldavEmptyState": "No CalDAV accounts connected yet. Add your first account to get started.", + "caldavNameLabel": "Account Name", + "caldavNamePlaceholder": "e.g. My Radicale, iCloud, Nextcloud", + "caldavPasswordHint": "For iCloud: Use app-specific password from appleid.apple.com", + "caldavPasswordLabel": "Password", + "caldavRefreshCalendars": "Refresh calendars", + "caldavSyncFailed": "CalDAV sync failed", + "caldavSyncSuccess": "CalDAV sync successful", + "caldavTitle": "CalDAV Calendars", + "caldavUrlHint": "The base URL of your CalDAV server", + "caldavUsernameLabel": "Username", + "calendarDisabled": "Calendar disabled", + "calendarEnabled": "Calendar enabled", + "calendarsRefreshed": "Calendars refreshed", + "deleteAccountConfirm": "Really delete CalDAV account? All synced calendars will be removed.", + "emptyStateAddFirst": "Füge dein erstes Konto hinzu", + "emptyStateNoAccounts": "Noch keine Konten verbunden", + "helpTooltipCalDAV": "CalDAV ermöglicht die Synchronisation von Kalendern mit iCloud, Nextcloud und anderen CalDAV-Servern.", + "helpTooltipCardDAV": "CardDAV ermöglicht die Synchronisation von Kontakten mit iCloud, Nextcloud und anderen CardDAV-Servern.", + "lastSync": "Last synced", + "modulesHint": "Disabled modules disappear from the navigation. Data is preserved and reappears once a module is re-enabled.", + "modulesSaved": "Module visibility saved.", + "modulesTitle": "Active modules", + "navigationLabel": "Einstellungsnavigation", + "sectionAdmin": "Administration", + "sectionCloudServices": "Cloud-Dienste", + "sectionModules": "Modules", + "sectionModulesNav": "Module", + "sectionOpenStandards": "CalDAV & CardDAV", + "sectionPersonal": "Persönlich", + "sectionSync": "Synchronisation", + "statusError": "Fehler", + "statusNeverSynced": "Noch nie synchronisiert", + "statusSynced": "Synchronisiert", + "statusSyncing": "Synchronisiert…", + "syncedAgo": "vor {{time}}", + "tabSyncCalendar": "Kalender", + "tabSyncContacts": "Kontakte" }, "login": { "tagline": "Οικογενειακός προγραμματισμός. Ασφαλής. Φιλικός προς την ιδιωτικότητα. Ανοιχτός κώδικας.", @@ -1120,7 +1203,7 @@ "customWeeks": "Weeks" }, "onboarding": { - "step1Title": "Welcome to Oikos", + "step1Title": "Καλώς ήρθατε στο {{name}}", "step1Body": "Your personal family planner. Tasks, calendar, shopping and more – all in one place.", "step2Title": "Πλοήγηση & Ενότητες", "step2Body": "Στο κάτω μέρος έχεις άμεση πρόσβαση στον Πίνακα ελέγχου και το Ημερολόγιο. Με το κουμπί ··· ανοίγεις άλλες ενότητες όπως Κουζίνα, Σημειώσεις και Επαφές.", @@ -1203,7 +1286,18 @@ }, "dropzoneTitle": "Αφήστε το αρχείο εδώ ή κάντε κλικ για επιλογή", "dropzoneHint": "Σύρετε ένα αρχείο σε αυτήν την περιοχή ή χρησιμοποιήστε τον επιλογέα αρχείων.", - "selectedFileLabel": "Επιλέχθηκε: {{name}}" + "selectedFileLabel": "Επιλέχθηκε: {{name}}", + "addFolderButton": "Add folder", + "allFolders": "All folders", + "folderLabel": "Folder", + "noFolder": "No folder", + "newFolderTitle": "New folder", + "folderNameLabel": "Folder name", + "createFolderAction": "Create folder", + "folderCreatedToast": "Folder created.", + "housekeepingFolder": "Καθαριότητα", + "calendarItemsFolder": "Στοιχεία ημερολογίου", + "folderBrowserTitle": "Περιήγηση φακέλων" }, "shortcuts": { "goKitchen": "Κουζίνα", @@ -1215,5 +1309,154 @@ "help": "Συντομεύσεις πληκτρολογίου", "new": "Δημιουργία νέας εγγραφής", "search": "Άνοιγμα αναζήτησης" + }, + "housekeeping": { + "title": "Cleaner workspace", + "bottomNav": "Housekeeping navigation", + "home": "Home", + "tasks": "Tasks", + "report": "Report", + "notCheckedIn": "Not checked in", + "checkedInAt": "Checked in at", + "monthTotal": "Current month · {{count}} sessions", + "dailyRate": "Daily rate", + "extras": "Extras", + "checkIn": "Check in", + "checkOut": "Check out", + "quickSupply": "Missing product", + "supplyName": "Product name", + "supplyPlaceholder": "What is missing?", + "checkedInToast": "Check-in recorded.", + "checkedOutToast": "Check-out recorded.", + "supplyAddedToast": "Added to the shopping list.", + "overdue": "Overdue", + "dueToday": "Due today", + "ok": "OK", + "noTasks": "No housekeeping tasks yet.", + "everyDays": "Every {{days}} days", + "completeTask": "Complete {{name}}", + "taskDoneToast": "Task completed.", + "reportTitle": "Report a problem", + "problemDescription": "Problem description", + "problemPlaceholder": "Example: burnt-out light bulb", + "addPhoto": "Add photo", + "sendReport": "Send report", + "reportSentToast": "Problem reported.", + "recentReports": "Recent reports", + "addTask": "Add task", + "taskName": "Task", + "taskNamePlaceholder": "Example: Clean bathrooms", + "taskArea": "Area", + "taskAreaPlaceholder": "Example: Bathroom", + "taskFrequency": "Frequency", + "createTask": "Create task", + "taskCreatedToast": "Housekeeping task created.", + "dashboard": "Dashboard", + "reports": "Reports", + "visitsThisMonth": "Visits this month", + "lastVisit": "Last visit", + "pendingChores": "Pending chores", + "finishedChores": "Finished chores", + "payments": "Payments", + "pendingPayments": "Pending payments", + "monthlyPayments": "Monthly payments", + "noPaymentData": "No payment data yet.", + "noVisits": "No visits yet", + "noWorkerTitle": "No housekeeper profile", + "noWorkerHint": "Create the worker profile to define contacts, rate, and payment schedule.", + "taskTemplates": "Suggested chores", + "addCustomTask": "Add custom chore", + "noReports": "No reports yet.", + "profileTitle": "Housekeeper profile", + "profilePicture": "Housekeeper profile picture", + "workerName": "Name", + "workerUsername": "Username", + "workerPhone": "Phone", + "workerEmail": "Email", + "workerBirthDate": "Birthday", + "paymentSchedule": "Payment schedule", + "scheduleDaily": "Every visit", + "scheduleTwiceMonthly": "Twice a month", + "scheduleMonthly": "Monthly", + "profileColor": "Profile color", + "workerNotes": "Notes", + "workerSavedToast": "Housekeeper profile saved.", + "staff": "Staff", + "staffTitle": "Housekeeping staff", + "addWorker": "Add housekeeper", + "editWorker": "Edit housekeeper", + "noWorkers": "No housekeepers registered yet.", + "moreWorkers": "+{{count}} more", + "checkInDisabled": "Add a housekeeper before checking in.", + "calendarColor": "Calendar color", + "visitRecordedAt": "Visit recorded at", + "checkedInToday": "Recorded today", + "visitReports": "Staff visit reports", + "noVisitReports": "No staff visits recorded this month.", + "openVisitReport": "Open visit report", + "visitReportDetails": "Visit report", + "paymentPaid": "Paid", + "paymentPending": "Pending", + "totalPayment": "Total payment", + "paymentStatus": "Payment status", + "paymentTask": "Payment task", + "calendarEvent": "Calendar event", + "notAvailable": "Not available", + "calendarVisitTitle": "Housekeeping: {{name}}", + "paymentTaskTitle": "Pay {{name}} for housekeeping", + "paymentTaskDescription": "Housekeeping visit on {{date}}. Amount due: {{amount}}.", + "staffLogTitle": "{{name}} visits", + "staffLogHint": "Edit visit dates, amounts, and linked records.", + "filterMonth": "Month", + "editVisit": "Edit visit", + "deleteVisit": "Delete visit", + "deleteVisitConfirm": "Delete this visit? The linked calendar event and payment task will also be removed.", + "visitDeletedToast": "Visit deleted.", + "visitSavedToast": "Visit updated.", + "visitDate": "Visit date", + "markPaid": "Mark paid", + "visitPaidToast": "Payment marked as paid.", + "receiptUploadTitle": "Upload payment receipt", + "receiptUploadHint": "Attach a payment receipt. It will appear in Documents.", + "receiptDocumentName": "Receipt - {{name}} - {{date}}", + "receiptDocumentDescription": "Payment receipt for {{name}} housekeeping visit on {{date}}.", + "taskTemplateData": { + "cleanBathrooms": { + "name": "Καθαρισμός μπάνιων", + "area": "Μπάνια" + }, + "mopKitchenFloor": { + "name": "Σφουγγάρισμα δαπέδου κουζίνας", + "area": "Κουζίνα" + }, + "dustLivingRoom": { + "name": "Ξεσκόνισμα σαλονιού", + "area": "Σαλόνι" + }, + "changeBedLinens": { + "name": "Αλλαγή σεντονιών", + "area": "Υπνοδωμάτια" + }, + "cleanRefrigerator": { + "name": "Καθαρισμός ψυγείου", + "area": "Κουζίνα" + }, + "cleanWindows": { + "name": "Καθαρισμός παραθύρων", + "area": "Όλο το σπίτι" + }, + "deepCleanOven": { + "name": "Βαθύς καθαρισμός φούρνου", + "area": "Κουζίνα" + }, + "washOutdoor": { + "name": "Πλύσιμο μπαλκονιού/αυλής", + "area": "Εξωτερικός χώρος" + } + } + }, + "userMultiSelect": { + "moreUsers": "weitere", + "nobody": "- Niemand -" } -} \ No newline at end of file +} diff --git a/public/locales/en.json b/public/locales/en.json index ade2cff..ee436b7 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -54,7 +54,8 @@ "more": "More", "documents": "Documents", "kitchen": "Kitchen", - "search": "Search" + "search": "Search", + "housekeeping": "Housekeeping" }, "dashboard": { "title": "Overview", @@ -493,7 +494,10 @@ "colorPurple": "Purple", "colorRed": "Red", "colorSkyBlue": "Sky Blue", - "colorYellow": "Yellow" + "colorYellow": "Yellow", + "iconCleaning": "Cleaning", + "attachmentDocumentName": "{{title}} - {{name}}", + "attachmentDocumentDescription": "Attachment uploaded for calendar event \"{{title}}\"." }, "notes": { "title": "Board", @@ -994,8 +998,6 @@ "caldavEmptyState": "No CalDAV accounts connected yet. Add your first account to get started.", "caldavNameLabel": "Account Name", "caldavNamePlaceholder": "e.g. My Radicale, iCloud, Nextcloud", - "caldavUrlLabel": "CalDAV Server URL", - "caldavUrlPlaceholder": "https://caldav.icloud.com", "caldavUrlHint": "The base URL of your CalDAV server", "caldavUsernameLabel": "Username", "caldavPasswordLabel": "Password", @@ -1034,7 +1036,31 @@ "addressbookEnabled": "Addressbook enabled", "addressbookDisabled": "Addressbook disabled", "addressbooksRefreshed": "Addressbooks refreshed", - "deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link." + "deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link.", + "sectionHousekeeping": "Housekeeping", + "housekeepingPaymentsTitle": "Payment tasks", + "housekeepingPaymentTasksLabel": "Create a payment task on each housekeeper check-in", + "housekeepingPaymentTasksHint": "When enabled, each check-in creates a task for paying the staff member. Completing that task marks the visit payment as paid.", + "housekeepingPaymentTasksSaved": "Housekeeping payment setting saved.", + "breadcrumbLabel": "Pfad", + "emptyStateAddFirst": "Füge dein erstes Konto hinzu", + "emptyStateNoAccounts": "Noch keine Konten verbunden", + "helpTooltipCalDAV": "CalDAV ermöglicht die Synchronisation von Kalendern mit iCloud, Nextcloud und anderen CalDAV-Servern.", + "helpTooltipCardDAV": "CardDAV ermöglicht die Synchronisation von Kontakten mit iCloud, Nextcloud und anderen CardDAV-Servern.", + "navigationLabel": "Einstellungsnavigation", + "sectionAdmin": "Administration", + "sectionCloudServices": "Cloud-Dienste", + "sectionModulesNav": "Module", + "sectionOpenStandards": "CalDAV & CardDAV", + "sectionPersonal": "Persönlich", + "sectionSync": "Synchronisation", + "statusError": "Fehler", + "statusNeverSynced": "Noch nie synchronisiert", + "statusSynced": "Synchronisiert", + "statusSyncing": "Synchronisiert…", + "syncedAgo": "vor {{time}}", + "tabSyncCalendar": "Kalender", + "tabSyncContacts": "Kontakte" }, "login": { "tagline": "Family planning. Secure. Privacy-friendly. Open source.", @@ -1177,7 +1203,7 @@ "noResults": "No results found." }, "onboarding": { - "step1Title": "Welcome to Oikos", + "step1Title": "Welcome to {{name}}", "step1Body": "Your personal family planner. Tasks, calendar, shopping and more – all in one place.", "step2Title": "Navigation & Modules", "step2Body": "At the bottom you can directly access Dashboard and Calendar. The ··· button opens additional modules like Kitchen, Notes and Contacts.", @@ -1271,6 +1297,166 @@ }, "dropzoneTitle": "Drop file here or click to choose", "dropzoneHint": "Drag a file into this area, or use the file picker.", - "selectedFileLabel": "Selected: {{name}}" + "selectedFileLabel": "Selected: {{name}}", + "addFolderButton": "Add folder", + "allFolders": "All folders", + "folderLabel": "Folder", + "noFolder": "No folder", + "newFolderTitle": "New folder", + "folderNameLabel": "Folder name", + "createFolderAction": "Create folder", + "folderCreatedToast": "Folder created.", + "housekeepingFolder": "HouseKeeping", + "calendarItemsFolder": "Calendar items", + "folderBrowserTitle": "Browse folders" + }, + "housekeeping": { + "title": "Cleaner workspace", + "bottomNav": "Housekeeping navigation", + "home": "Home", + "tasks": "Tasks", + "report": "Report", + "notCheckedIn": "Not checked in", + "checkedInAt": "Checked in at", + "monthTotal": "Current month · {{count}} sessions", + "dailyRate": "Daily rate", + "extras": "Extras", + "checkIn": "Check in", + "checkOut": "Check out", + "quickSupply": "Missing product", + "supplyName": "Product name", + "supplyPlaceholder": "What is missing?", + "checkedInToast": "Check-in recorded.", + "checkedOutToast": "Check-out recorded.", + "supplyAddedToast": "Added to the shopping list.", + "overdue": "Overdue", + "dueToday": "Due today", + "ok": "OK", + "noTasks": "No housekeeping tasks yet.", + "everyDays": "Every {{days}} days", + "completeTask": "Complete {{name}}", + "taskDoneToast": "Task completed.", + "reportTitle": "Report a problem", + "problemDescription": "Problem description", + "problemPlaceholder": "Example: burnt-out light bulb", + "addPhoto": "Add photo", + "sendReport": "Send report", + "reportSentToast": "Problem reported.", + "recentReports": "Recent reports", + "addTask": "Add task", + "taskName": "Task", + "taskNamePlaceholder": "Example: Clean bathrooms", + "taskArea": "Area", + "taskAreaPlaceholder": "Example: Bathroom", + "taskFrequency": "Frequency", + "createTask": "Create task", + "taskCreatedToast": "Housekeeping task created.", + "dashboard": "Dashboard", + "reports": "Reports", + "visitsThisMonth": "Visits this month", + "lastVisit": "Last visit", + "pendingChores": "Pending chores", + "finishedChores": "Finished chores", + "payments": "Payments", + "pendingPayments": "Pending payments", + "monthlyPayments": "Monthly payments", + "noPaymentData": "No payment data yet.", + "noVisits": "No visits yet", + "noWorkerTitle": "No housekeeper profile", + "noWorkerHint": "Create the worker profile to define contacts, rate, and payment schedule.", + "taskTemplates": "Suggested chores", + "addCustomTask": "Add custom chore", + "noReports": "No reports yet.", + "profileTitle": "Housekeeper profile", + "profilePicture": "Housekeeper profile picture", + "workerName": "Name", + "workerUsername": "Username", + "workerPhone": "Phone", + "workerEmail": "Email", + "workerBirthDate": "Birthday", + "paymentSchedule": "Payment schedule", + "scheduleDaily": "Every visit", + "scheduleTwiceMonthly": "Twice a month", + "scheduleMonthly": "Monthly", + "profileColor": "Profile color", + "workerNotes": "Notes", + "workerSavedToast": "Housekeeper profile saved.", + "staff": "Staff", + "staffTitle": "Housekeeping staff", + "addWorker": "Add housekeeper", + "editWorker": "Edit housekeeper", + "noWorkers": "No housekeepers registered yet.", + "moreWorkers": "+{{count}} more", + "checkInDisabled": "Add a housekeeper before checking in.", + "calendarColor": "Calendar color", + "visitRecordedAt": "Visit recorded at", + "checkedInToday": "Recorded today", + "visitReports": "Staff visit reports", + "noVisitReports": "No staff visits recorded this month.", + "openVisitReport": "Open visit report", + "visitReportDetails": "Visit report", + "paymentPaid": "Paid", + "paymentPending": "Pending", + "totalPayment": "Total payment", + "paymentStatus": "Payment status", + "paymentTask": "Payment task", + "calendarEvent": "Calendar event", + "notAvailable": "Not available", + "calendarVisitTitle": "Housekeeping: {{name}}", + "paymentTaskTitle": "Pay {{name}} for housekeeping", + "paymentTaskDescription": "Housekeeping visit on {{date}}. Amount due: {{amount}}.", + "staffLogTitle": "{{name}} visits", + "staffLogHint": "Edit visit dates, amounts, and linked records.", + "filterMonth": "Month", + "editVisit": "Edit visit", + "deleteVisit": "Delete visit", + "deleteVisitConfirm": "Delete this visit? The linked calendar event and payment task will also be removed.", + "visitDeletedToast": "Visit deleted.", + "visitSavedToast": "Visit updated.", + "visitDate": "Visit date", + "markPaid": "Mark paid", + "visitPaidToast": "Payment marked as paid.", + "receiptUploadTitle": "Upload payment receipt", + "receiptUploadHint": "Attach a payment receipt. It will appear in Documents.", + "receiptDocumentName": "Receipt - {{name}} - {{date}}", + "receiptDocumentDescription": "Payment receipt for {{name}} housekeeping visit on {{date}}.", + "taskTemplateData": { + "cleanBathrooms": { + "name": "Clean bathrooms", + "area": "Bathrooms" + }, + "mopKitchenFloor": { + "name": "Mop kitchen floor", + "area": "Kitchen" + }, + "dustLivingRoom": { + "name": "Dust living room", + "area": "Living room" + }, + "changeBedLinens": { + "name": "Change bed linens", + "area": "Bedrooms" + }, + "cleanRefrigerator": { + "name": "Clean refrigerator", + "area": "Kitchen" + }, + "cleanWindows": { + "name": "Clean windows", + "area": "Whole house" + }, + "deepCleanOven": { + "name": "Deep clean oven", + "area": "Kitchen" + }, + "washOutdoor": { + "name": "Wash balcony/patio", + "area": "Outdoor" + } + } + }, + "userMultiSelect": { + "moreUsers": "weitere", + "nobody": "- Niemand -" } } diff --git a/public/locales/es.json b/public/locales/es.json index 267c851..47f8f88 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -54,7 +54,8 @@ "more": "Más", "documents": "Documentos", "kitchen": "Cocina", - "search": "Buscar" + "search": "Buscar", + "housekeeping": "Limpieza" }, "dashboard": { "title": "Inicio", @@ -205,7 +206,18 @@ "kanbanArchived": "Archivado", "reminderNeedsDueDate": "Establece una fecha de vencimiento para activar los recordatorios de tareas.", "emptyAction": "Crear tarea", - "navLabelOverdue": "Tareas, {{count}} vencidas" + "navLabelOverdue": "Tareas, {{count}} vencidas", + "bulkArchive": "Archive", + "bulkArchived": "Tasks archived.", + "bulkDelete": "Delete", + "bulkDeleteConfirm": "Delete {{count}} tasks permanently?", + "bulkDeleted": "Tasks deleted.", + "bulkMarkDone": "Mark done", + "bulkMarkOpen": "Mark open", + "bulkSelect": "Bulk select", + "bulkSelectedCount": "{{count}} selected", + "bulkStatusChanged": "Status changed.", + "selectTask": "Select task" }, "shopping": { "title": "Compras", @@ -479,7 +491,13 @@ "colorPurple": "Morado", "colorRed": "Rojo", "colorSkyBlue": "Azul cielo", - "colorYellow": "Amarillo" + "colorYellow": "Amarillo", + "iconCleaning": "Limpieza", + "caldavTargetHint": "Choose a CalDAV calendar to sync this event.", + "caldavTargetLabel": "Sync to CalDAV", + "caldavTargetLocal": "Store locally only", + "attachmentDocumentName": "{{title}} - {{name}}", + "attachmentDocumentDescription": "Archivo adjunto subido para el evento de calendario \"{{title}}\"." }, "notes": { "title": "Notas", @@ -977,7 +995,72 @@ "addressbookEnabled": "Addressbook enabled", "addressbookDisabled": "Addressbook disabled", "addressbooksRefreshed": "Addressbooks refreshed", - "deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link." + "deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link.", + "sectionHousekeeping": "Housekeeping", + "housekeepingPaymentsTitle": "Payment tasks", + "housekeepingPaymentTasksLabel": "Create a payment task on each housekeeper check-in", + "housekeepingPaymentTasksHint": "When enabled, each check-in creates a task for paying the staff member. Completing that task marks the visit payment as paid.", + "housekeepingPaymentTasksSaved": "Housekeeping payment setting saved.", + "backupSchedulerDisabled": "Disabled", + "backupSchedulerEnabled": "Enabled", + "backupSchedulerHint": "Scheduled backups are created automatically and old backups are rotated.", + "backupSchedulerKeep": "Retention", + "backupSchedulerKeepCount": "{{count}} backups", + "backupSchedulerLastBackup": "Last backup", + "backupSchedulerLastFail": "{{date}} (failed)", + "backupSchedulerLastSuccess": "{{date}} (successful)", + "backupSchedulerNever": "No backup created yet", + "backupSchedulerSchedule": "Schedule", + "backupSchedulerStatus": "Status", + "backupSchedulerTitle": "Automatic Backups", + "backupSchedulerTrigger": "Create backup now", + "backupSchedulerTriggeredToast": "Backup created successfully.", + "backupSchedulerTriggering": "Creating backup...", + "breadcrumbLabel": "Pfad", + "caldavAccountAdded": "CalDAV account added successfully", + "caldavAccountDeleted": "CalDAV account removed", + "caldavAddAccount": "Add CalDAV Account", + "caldavCalendarsToggle": "Show/hide calendars", + "caldavConnectionFailed": "Connection to CalDAV server failed", + "caldavDescription": "Connect multiple CalDAV accounts (iCloud, Nextcloud, Radicale, Baikal, etc.) and choose which calendars to sync.", + "caldavEmptyState": "No CalDAV accounts connected yet. Add your first account to get started.", + "caldavNameLabel": "Account Name", + "caldavNamePlaceholder": "e.g. My Radicale, iCloud, Nextcloud", + "caldavPasswordHint": "For iCloud: Use app-specific password from appleid.apple.com", + "caldavPasswordLabel": "Password", + "caldavRefreshCalendars": "Refresh calendars", + "caldavSyncFailed": "CalDAV sync failed", + "caldavSyncSuccess": "CalDAV sync successful", + "caldavTitle": "CalDAV Calendars", + "caldavUrlHint": "The base URL of your CalDAV server", + "caldavUsernameLabel": "Username", + "calendarDisabled": "Calendar disabled", + "calendarEnabled": "Calendar enabled", + "calendarsRefreshed": "Calendars refreshed", + "deleteAccountConfirm": "Really delete CalDAV account? All synced calendars will be removed.", + "emptyStateAddFirst": "Füge dein erstes Konto hinzu", + "emptyStateNoAccounts": "Noch keine Konten verbunden", + "helpTooltipCalDAV": "CalDAV ermöglicht die Synchronisation von Kalendern mit iCloud, Nextcloud und anderen CalDAV-Servern.", + "helpTooltipCardDAV": "CardDAV ermöglicht die Synchronisation von Kontakten mit iCloud, Nextcloud und anderen CardDAV-Servern.", + "lastSync": "Last synced", + "modulesHint": "Disabled modules disappear from the navigation. Data is preserved and reappears once a module is re-enabled.", + "modulesSaved": "Module visibility saved.", + "modulesTitle": "Active modules", + "navigationLabel": "Einstellungsnavigation", + "sectionAdmin": "Administration", + "sectionCloudServices": "Cloud-Dienste", + "sectionModules": "Modules", + "sectionModulesNav": "Module", + "sectionOpenStandards": "CalDAV & CardDAV", + "sectionPersonal": "Persönlich", + "sectionSync": "Synchronisation", + "statusError": "Fehler", + "statusNeverSynced": "Noch nie synchronisiert", + "statusSynced": "Synchronisiert", + "statusSyncing": "Synchronisiert…", + "syncedAgo": "vor {{time}}", + "tabSyncCalendar": "Kalender", + "tabSyncContacts": "Kontakte" }, "login": { "tagline": "Planificación familiar. Segura. Privada. Código abierto.", @@ -1120,7 +1203,7 @@ "customWeeks": "Weeks" }, "onboarding": { - "step1Title": "Welcome to Oikos", + "step1Title": "Bienvenido a {{name}}", "step1Body": "Your personal family planner. Tasks, calendar, shopping and more – all in one place.", "step2Title": "Navegación y módulos", "step2Body": "En la parte inferior accedes directamente al Panel y el Calendario. Con el botón ··· abres más módulos como Cocina, Notas y Contactos.", @@ -1203,7 +1286,18 @@ }, "dropzoneTitle": "Suelta el archivo aquí o haz clic para elegir", "dropzoneHint": "Arrastra un archivo a esta área o usa el selector de archivos.", - "selectedFileLabel": "Seleccionado: {{name}}" + "selectedFileLabel": "Seleccionado: {{name}}", + "addFolderButton": "Add folder", + "allFolders": "All folders", + "folderLabel": "Folder", + "noFolder": "No folder", + "newFolderTitle": "New folder", + "folderNameLabel": "Folder name", + "createFolderAction": "Create folder", + "folderCreatedToast": "Folder created.", + "housekeepingFolder": "Limpieza", + "calendarItemsFolder": "Elementos del calendario", + "folderBrowserTitle": "Explorar carpetas" }, "shortcuts": { "goKitchen": "Cocina", @@ -1215,5 +1309,154 @@ "help": "Atajos de teclado", "new": "Crear nueva entrada", "search": "Abrir búsqueda" + }, + "housekeeping": { + "title": "Área de limpieza", + "bottomNav": "Navegación de limpieza", + "home": "Inicio", + "tasks": "Tareas", + "report": "Reportar", + "notCheckedIn": "Sin entrada", + "checkedInAt": "Entrada a las", + "monthTotal": "Mes actual · {{count}} jornadas", + "dailyRate": "Tarifa diaria", + "extras": "Extras", + "checkIn": "Entrada", + "checkOut": "Salida", + "quickSupply": "Falta producto", + "supplyName": "Producto", + "supplyPlaceholder": "¿Qué falta?", + "checkedInToast": "Entrada registrada.", + "checkedOutToast": "Salida registrada.", + "supplyAddedToast": "Añadido a la lista de compras.", + "overdue": "Atrasada", + "dueToday": "Hoy", + "ok": "OK", + "noTasks": "No hay tareas de limpieza.", + "everyDays": "Cada {{days}} días", + "completeTask": "Completar {{name}}", + "taskDoneToast": "Tarea completada.", + "reportTitle": "Reportar problema", + "problemDescription": "Descripción del problema", + "problemPlaceholder": "Ejemplo: bombilla fundida", + "addPhoto": "Agregar foto", + "sendReport": "Enviar reporte", + "reportSentToast": "Problema reportado.", + "recentReports": "Reportes recientes", + "addTask": "Agregar tarea", + "taskName": "Tarea", + "taskNamePlaceholder": "Ejemplo: limpiar baños", + "taskArea": "Área", + "taskAreaPlaceholder": "Ejemplo: baño", + "taskFrequency": "Frecuencia", + "createTask": "Crear tarea", + "taskCreatedToast": "Tarea de limpieza creada.", + "dashboard": "Panel", + "reports": "Reports", + "visitsThisMonth": "Visitas del mes", + "lastVisit": "Última visita", + "pendingChores": "Tareas pendientes", + "finishedChores": "Tareas completadas", + "payments": "Pagos", + "pendingPayments": "Pagos pendientes", + "monthlyPayments": "Pagos mensuales", + "noPaymentData": "Aún no hay datos de pago.", + "noVisits": "Sin visitas todavía", + "noWorkerTitle": "Sin perfil de limpieza", + "noWorkerHint": "Crea el perfil para definir contactos, tarifa y calendario de pago.", + "taskTemplates": "Tareas sugeridas", + "addCustomTask": "Agregar tarea personalizada", + "noReports": "Aún no hay reportes.", + "profileTitle": "Perfil de limpieza", + "profilePicture": "Foto de perfil", + "workerName": "Nombre", + "workerUsername": "Usuario", + "workerPhone": "Teléfono", + "workerEmail": "Email", + "workerBirthDate": "Cumpleaños", + "paymentSchedule": "Calendario de pago", + "scheduleDaily": "Cada visita", + "scheduleTwiceMonthly": "Dos veces al mes", + "scheduleMonthly": "Mensual", + "profileColor": "Color de perfil", + "workerNotes": "Notas", + "workerSavedToast": "Perfil guardado.", + "staff": "Personal", + "staffTitle": "Personal de limpieza", + "addWorker": "Agregar persona", + "editWorker": "Editar persona", + "noWorkers": "No hay personal de limpieza registrado.", + "moreWorkers": "+{{count}} más", + "checkInDisabled": "Agrega una persona antes de registrar entrada.", + "calendarColor": "Color de calendario", + "visitRecordedAt": "Visit recorded at", + "checkedInToday": "Recorded today", + "visitReports": "Staff visit reports", + "noVisitReports": "No staff visits recorded this month.", + "openVisitReport": "Open visit report", + "visitReportDetails": "Visit report", + "paymentPaid": "Paid", + "paymentPending": "Pending", + "totalPayment": "Total payment", + "paymentStatus": "Payment status", + "paymentTask": "Payment task", + "calendarEvent": "Calendar event", + "notAvailable": "Not available", + "calendarVisitTitle": "Housekeeping: {{name}}", + "paymentTaskTitle": "Pay {{name}} for housekeeping", + "paymentTaskDescription": "Housekeeping visit on {{date}}. Amount due: {{amount}}.", + "staffLogTitle": "{{name}} visits", + "staffLogHint": "Edit visit dates, amounts, and linked records.", + "filterMonth": "Month", + "editVisit": "Edit visit", + "deleteVisit": "Delete visit", + "deleteVisitConfirm": "Delete this visit? The linked calendar event and payment task will also be removed.", + "visitDeletedToast": "Visit deleted.", + "visitSavedToast": "Visit updated.", + "visitDate": "Visit date", + "markPaid": "Mark paid", + "visitPaidToast": "Payment marked as paid.", + "receiptUploadTitle": "Upload payment receipt", + "receiptUploadHint": "Attach a payment receipt. It will appear in Documents.", + "receiptDocumentName": "Receipt - {{name}} - {{date}}", + "receiptDocumentDescription": "Payment receipt for {{name}} housekeeping visit on {{date}}.", + "taskTemplateData": { + "cleanBathrooms": { + "name": "Limpiar baños", + "area": "Baños" + }, + "mopKitchenFloor": { + "name": "Fregar el suelo de la cocina", + "area": "Cocina" + }, + "dustLivingRoom": { + "name": "Quitar el polvo de la sala", + "area": "Sala" + }, + "changeBedLinens": { + "name": "Cambiar la ropa de cama", + "area": "Dormitorios" + }, + "cleanRefrigerator": { + "name": "Limpiar el refrigerador", + "area": "Cocina" + }, + "cleanWindows": { + "name": "Limpiar ventanas", + "area": "Toda la casa" + }, + "deepCleanOven": { + "name": "Limpieza profunda del horno", + "area": "Cocina" + }, + "washOutdoor": { + "name": "Lavar balcón/patio", + "area": "Exterior" + } + } + }, + "userMultiSelect": { + "moreUsers": "weitere", + "nobody": "- Niemand -" } -} \ No newline at end of file +} diff --git a/public/locales/fr.json b/public/locales/fr.json index a54ade8..9765285 100644 --- a/public/locales/fr.json +++ b/public/locales/fr.json @@ -54,7 +54,8 @@ "more": "Plus", "documents": "Documents", "kitchen": "Cuisine", - "search": "Recherche" + "search": "Recherche", + "housekeeping": "Ménage" }, "dashboard": { "title": "Accueil", @@ -205,7 +206,18 @@ "kanbanArchived": "Archivé", "reminderNeedsDueDate": "Définissez une date d'échéance pour activer les rappels de tâche.", "emptyAction": "Créer une tâche", - "navLabelOverdue": "Tâches, {{count}} en retard" + "navLabelOverdue": "Tâches, {{count}} en retard", + "bulkArchive": "Archive", + "bulkArchived": "Tasks archived.", + "bulkDelete": "Delete", + "bulkDeleteConfirm": "Delete {{count}} tasks permanently?", + "bulkDeleted": "Tasks deleted.", + "bulkMarkDone": "Mark done", + "bulkMarkOpen": "Mark open", + "bulkSelect": "Bulk select", + "bulkSelectedCount": "{{count}} selected", + "bulkStatusChanged": "Status changed.", + "selectTask": "Select task" }, "shopping": { "title": "Courses", @@ -479,7 +491,13 @@ "colorPurple": "Violet", "colorRed": "Rouge", "colorSkyBlue": "Bleu ciel", - "colorYellow": "Jaune" + "colorYellow": "Jaune", + "iconCleaning": "Ménage", + "caldavTargetHint": "Choose a CalDAV calendar to sync this event.", + "caldavTargetLabel": "Sync to CalDAV", + "caldavTargetLocal": "Store locally only", + "attachmentDocumentName": "{{title}} - {{name}}", + "attachmentDocumentDescription": "Pièce jointe téléversée pour l’événement \"{{title}}\"." }, "notes": { "title": "Notes", @@ -977,7 +995,72 @@ "addressbookEnabled": "Addressbook enabled", "addressbookDisabled": "Addressbook disabled", "addressbooksRefreshed": "Addressbooks refreshed", - "deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link." + "deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link.", + "sectionHousekeeping": "Housekeeping", + "housekeepingPaymentsTitle": "Payment tasks", + "housekeepingPaymentTasksLabel": "Create a payment task on each housekeeper check-in", + "housekeepingPaymentTasksHint": "When enabled, each check-in creates a task for paying the staff member. Completing that task marks the visit payment as paid.", + "housekeepingPaymentTasksSaved": "Housekeeping payment setting saved.", + "backupSchedulerDisabled": "Disabled", + "backupSchedulerEnabled": "Enabled", + "backupSchedulerHint": "Scheduled backups are created automatically and old backups are rotated.", + "backupSchedulerKeep": "Retention", + "backupSchedulerKeepCount": "{{count}} backups", + "backupSchedulerLastBackup": "Last backup", + "backupSchedulerLastFail": "{{date}} (failed)", + "backupSchedulerLastSuccess": "{{date}} (successful)", + "backupSchedulerNever": "No backup created yet", + "backupSchedulerSchedule": "Schedule", + "backupSchedulerStatus": "Status", + "backupSchedulerTitle": "Automatic Backups", + "backupSchedulerTrigger": "Create backup now", + "backupSchedulerTriggeredToast": "Backup created successfully.", + "backupSchedulerTriggering": "Creating backup...", + "breadcrumbLabel": "Pfad", + "caldavAccountAdded": "CalDAV account added successfully", + "caldavAccountDeleted": "CalDAV account removed", + "caldavAddAccount": "Add CalDAV Account", + "caldavCalendarsToggle": "Show/hide calendars", + "caldavConnectionFailed": "Connection to CalDAV server failed", + "caldavDescription": "Connect multiple CalDAV accounts (iCloud, Nextcloud, Radicale, Baikal, etc.) and choose which calendars to sync.", + "caldavEmptyState": "No CalDAV accounts connected yet. Add your first account to get started.", + "caldavNameLabel": "Account Name", + "caldavNamePlaceholder": "e.g. My Radicale, iCloud, Nextcloud", + "caldavPasswordHint": "For iCloud: Use app-specific password from appleid.apple.com", + "caldavPasswordLabel": "Password", + "caldavRefreshCalendars": "Refresh calendars", + "caldavSyncFailed": "CalDAV sync failed", + "caldavSyncSuccess": "CalDAV sync successful", + "caldavTitle": "CalDAV Calendars", + "caldavUrlHint": "The base URL of your CalDAV server", + "caldavUsernameLabel": "Username", + "calendarDisabled": "Calendar disabled", + "calendarEnabled": "Calendar enabled", + "calendarsRefreshed": "Calendars refreshed", + "deleteAccountConfirm": "Really delete CalDAV account? All synced calendars will be removed.", + "emptyStateAddFirst": "Füge dein erstes Konto hinzu", + "emptyStateNoAccounts": "Noch keine Konten verbunden", + "helpTooltipCalDAV": "CalDAV ermöglicht die Synchronisation von Kalendern mit iCloud, Nextcloud und anderen CalDAV-Servern.", + "helpTooltipCardDAV": "CardDAV ermöglicht die Synchronisation von Kontakten mit iCloud, Nextcloud und anderen CardDAV-Servern.", + "lastSync": "Last synced", + "modulesHint": "Disabled modules disappear from the navigation. Data is preserved and reappears once a module is re-enabled.", + "modulesSaved": "Module visibility saved.", + "modulesTitle": "Active modules", + "navigationLabel": "Einstellungsnavigation", + "sectionAdmin": "Administration", + "sectionCloudServices": "Cloud-Dienste", + "sectionModules": "Modules", + "sectionModulesNav": "Module", + "sectionOpenStandards": "CalDAV & CardDAV", + "sectionPersonal": "Persönlich", + "sectionSync": "Synchronisation", + "statusError": "Fehler", + "statusNeverSynced": "Noch nie synchronisiert", + "statusSynced": "Synchronisiert", + "statusSyncing": "Synchronisiert…", + "syncedAgo": "vor {{time}}", + "tabSyncCalendar": "Kalender", + "tabSyncContacts": "Kontakte" }, "login": { "tagline": "Planification familiale. Sécurisée. Respectueuse de la vie privée. Open source.", @@ -1120,7 +1203,7 @@ "customWeeks": "Weeks" }, "onboarding": { - "step1Title": "Welcome to Oikos", + "step1Title": "Bienvenue dans {{name}}", "step1Body": "Your personal family planner. Tasks, calendar, shopping and more – all in one place.", "step2Title": "Navigation & modules", "step2Body": "En bas, accédez directement au Tableau de bord et au Calendrier. Le bouton ··· ouvre d'autres modules comme Cuisine, Notes et Contacts.", @@ -1203,7 +1286,18 @@ }, "dropzoneTitle": "Déposez le fichier ici ou cliquez pour choisir", "dropzoneHint": "Glissez un fichier dans cette zone ou utilisez le sélecteur.", - "selectedFileLabel": "Sélectionné : {{name}}" + "selectedFileLabel": "Sélectionné : {{name}}", + "addFolderButton": "Add folder", + "allFolders": "All folders", + "folderLabel": "Folder", + "noFolder": "No folder", + "newFolderTitle": "New folder", + "folderNameLabel": "Folder name", + "createFolderAction": "Create folder", + "folderCreatedToast": "Folder created.", + "housekeepingFolder": "Ménage", + "calendarItemsFolder": "Éléments du calendrier", + "folderBrowserTitle": "Parcourir les dossiers" }, "shortcuts": { "goKitchen": "Cuisine", @@ -1215,5 +1309,154 @@ "help": "Raccourcis clavier", "new": "Créer une nouvelle entrée", "search": "Ouvrir la recherche" + }, + "housekeeping": { + "title": "Espace ménage", + "bottomNav": "Navigation ménage", + "home": "Accueil", + "tasks": "Tâches", + "report": "Signaler", + "notCheckedIn": "Pas pointé", + "checkedInAt": "Pointé à", + "monthTotal": "Mois en cours · {{count}} sessions", + "dailyRate": "Tarif journalier", + "extras": "Extras", + "checkIn": "Arrivée", + "checkOut": "Départ", + "quickSupply": "Produit manquant", + "supplyName": "Produit", + "supplyPlaceholder": "Que manque-t-il ?", + "checkedInToast": "Arrivée enregistrée.", + "checkedOutToast": "Départ enregistré.", + "supplyAddedToast": "Ajouté à la liste de courses.", + "overdue": "En retard", + "dueToday": "Aujourd’hui", + "ok": "OK", + "noTasks": "Aucune tâche de ménage.", + "everyDays": "Tous les {{days}} jours", + "completeTask": "Terminer {{name}}", + "taskDoneToast": "Tâche terminée.", + "reportTitle": "Signaler un problème", + "problemDescription": "Description du problème", + "problemPlaceholder": "Exemple : ampoule grillée", + "addPhoto": "Ajouter une photo", + "sendReport": "Envoyer", + "reportSentToast": "Problème signalé.", + "recentReports": "Signalements récents", + "addTask": "Ajouter une tâche", + "taskName": "Tâche", + "taskNamePlaceholder": "Exemple : nettoyer les salles de bain", + "taskArea": "Zone", + "taskAreaPlaceholder": "Exemple : salle de bain", + "taskFrequency": "Fréquence", + "createTask": "Créer la tâche", + "taskCreatedToast": "Tâche de ménage créée.", + "dashboard": "Tableau", + "reports": "Reports", + "visitsThisMonth": "Visites du mois", + "lastVisit": "Dernière visite", + "pendingChores": "Tâches en attente", + "finishedChores": "Tâches terminées", + "payments": "Paiements", + "pendingPayments": "Paiements en attente", + "monthlyPayments": "Paiements mensuels", + "noPaymentData": "Aucune donnée de paiement.", + "noVisits": "Aucune visite", + "noWorkerTitle": "Aucun profil de ménage", + "noWorkerHint": "Créez le profil pour définir les contacts, le tarif et le rythme de paiement.", + "taskTemplates": "Tâches suggérées", + "addCustomTask": "Ajouter une tâche personnalisée", + "noReports": "Aucun signalement.", + "profileTitle": "Profil ménage", + "profilePicture": "Photo de profil", + "workerName": "Nom", + "workerUsername": "Identifiant", + "workerPhone": "Téléphone", + "workerEmail": "E-mail", + "workerBirthDate": "Anniversaire", + "paymentSchedule": "Rythme de paiement", + "scheduleDaily": "À chaque visite", + "scheduleTwiceMonthly": "Deux fois par mois", + "scheduleMonthly": "Mensuel", + "profileColor": "Couleur du profil", + "workerNotes": "Notes", + "workerSavedToast": "Profil enregistré.", + "staff": "Équipe", + "staffTitle": "Équipe de ménage", + "addWorker": "Ajouter une personne", + "editWorker": "Modifier la personne", + "noWorkers": "Aucune personne de ménage enregistrée.", + "moreWorkers": "+{{count}} de plus", + "checkInDisabled": "Ajoutez une personne avant de pointer.", + "calendarColor": "Couleur du calendrier", + "visitRecordedAt": "Visit recorded at", + "checkedInToday": "Recorded today", + "visitReports": "Staff visit reports", + "noVisitReports": "No staff visits recorded this month.", + "openVisitReport": "Open visit report", + "visitReportDetails": "Visit report", + "paymentPaid": "Paid", + "paymentPending": "Pending", + "totalPayment": "Total payment", + "paymentStatus": "Payment status", + "paymentTask": "Payment task", + "calendarEvent": "Calendar event", + "notAvailable": "Not available", + "calendarVisitTitle": "Housekeeping: {{name}}", + "paymentTaskTitle": "Pay {{name}} for housekeeping", + "paymentTaskDescription": "Housekeeping visit on {{date}}. Amount due: {{amount}}.", + "staffLogTitle": "{{name}} visits", + "staffLogHint": "Edit visit dates, amounts, and linked records.", + "filterMonth": "Month", + "editVisit": "Edit visit", + "deleteVisit": "Delete visit", + "deleteVisitConfirm": "Delete this visit? The linked calendar event and payment task will also be removed.", + "visitDeletedToast": "Visit deleted.", + "visitSavedToast": "Visit updated.", + "visitDate": "Visit date", + "markPaid": "Mark paid", + "visitPaidToast": "Payment marked as paid.", + "receiptUploadTitle": "Upload payment receipt", + "receiptUploadHint": "Attach a payment receipt. It will appear in Documents.", + "receiptDocumentName": "Receipt - {{name}} - {{date}}", + "receiptDocumentDescription": "Payment receipt for {{name}} housekeeping visit on {{date}}.", + "taskTemplateData": { + "cleanBathrooms": { + "name": "Nettoyer les salles de bain", + "area": "Salles de bain" + }, + "mopKitchenFloor": { + "name": "Laver le sol de la cuisine", + "area": "Cuisine" + }, + "dustLivingRoom": { + "name": "Dépoussiérer le salon", + "area": "Salon" + }, + "changeBedLinens": { + "name": "Changer les draps", + "area": "Chambres" + }, + "cleanRefrigerator": { + "name": "Nettoyer le réfrigérateur", + "area": "Cuisine" + }, + "cleanWindows": { + "name": "Nettoyer les fenêtres", + "area": "Toute la maison" + }, + "deepCleanOven": { + "name": "Nettoyage approfondi du four", + "area": "Cuisine" + }, + "washOutdoor": { + "name": "Laver balcon/patio", + "area": "Extérieur" + } + } + }, + "userMultiSelect": { + "moreUsers": "weitere", + "nobody": "- Niemand -" } -} \ No newline at end of file +} diff --git a/public/locales/hi.json b/public/locales/hi.json index 331cd74..935e3cb 100644 --- a/public/locales/hi.json +++ b/public/locales/hi.json @@ -54,7 +54,8 @@ "more": "और", "documents": "दस्तावेज़", "kitchen": "रसोई", - "search": "खोज" + "search": "खोज", + "housekeeping": "Housekeeping" }, "dashboard": { "title": "डैशबोर्ड", @@ -205,7 +206,18 @@ "kanbanArchived": "संग्रहित", "reminderNeedsDueDate": "कार्य अनुस्मारक सक्षम करने के लिए एक नियत तारीख निर्धारित करें।", "emptyAction": "कार्य बनाएं", - "navLabelOverdue": "कार्य, {{count}} अतिदेय" + "navLabelOverdue": "कार्य, {{count}} अतिदेय", + "bulkArchive": "Archive", + "bulkArchived": "Tasks archived.", + "bulkDelete": "Delete", + "bulkDeleteConfirm": "Delete {{count}} tasks permanently?", + "bulkDeleted": "Tasks deleted.", + "bulkMarkDone": "Mark done", + "bulkMarkOpen": "Mark open", + "bulkSelect": "Bulk select", + "bulkSelectedCount": "{{count}} selected", + "bulkStatusChanged": "Status changed.", + "selectTask": "Select task" }, "shopping": { "title": "खरीदारी", @@ -479,7 +491,13 @@ "colorPurple": "बैंगनी", "colorRed": "लाल", "colorSkyBlue": "आसमानी नीला", - "colorYellow": "पीला" + "colorYellow": "पीला", + "iconCleaning": "Cleaning", + "caldavTargetHint": "Choose a CalDAV calendar to sync this event.", + "caldavTargetLabel": "Sync to CalDAV", + "caldavTargetLocal": "Store locally only", + "attachmentDocumentName": "{{title}} - {{name}}", + "attachmentDocumentDescription": "कैलेंडर इवेंट \"{{title}}\" के लिए अपलोड किया गया अटैचमेंट।" }, "notes": { "title": "नोट बोर्ड", @@ -977,7 +995,72 @@ "addressbookEnabled": "Addressbook enabled", "addressbookDisabled": "Addressbook disabled", "addressbooksRefreshed": "Addressbooks refreshed", - "deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link." + "deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link.", + "sectionHousekeeping": "Housekeeping", + "housekeepingPaymentsTitle": "Payment tasks", + "housekeepingPaymentTasksLabel": "Create a payment task on each housekeeper check-in", + "housekeepingPaymentTasksHint": "When enabled, each check-in creates a task for paying the staff member. Completing that task marks the visit payment as paid.", + "housekeepingPaymentTasksSaved": "Housekeeping payment setting saved.", + "backupSchedulerDisabled": "Disabled", + "backupSchedulerEnabled": "Enabled", + "backupSchedulerHint": "Scheduled backups are created automatically and old backups are rotated.", + "backupSchedulerKeep": "Retention", + "backupSchedulerKeepCount": "{{count}} backups", + "backupSchedulerLastBackup": "Last backup", + "backupSchedulerLastFail": "{{date}} (failed)", + "backupSchedulerLastSuccess": "{{date}} (successful)", + "backupSchedulerNever": "No backup created yet", + "backupSchedulerSchedule": "Schedule", + "backupSchedulerStatus": "Status", + "backupSchedulerTitle": "Automatic Backups", + "backupSchedulerTrigger": "Create backup now", + "backupSchedulerTriggeredToast": "Backup created successfully.", + "backupSchedulerTriggering": "Creating backup...", + "breadcrumbLabel": "Pfad", + "caldavAccountAdded": "CalDAV account added successfully", + "caldavAccountDeleted": "CalDAV account removed", + "caldavAddAccount": "Add CalDAV Account", + "caldavCalendarsToggle": "Show/hide calendars", + "caldavConnectionFailed": "Connection to CalDAV server failed", + "caldavDescription": "Connect multiple CalDAV accounts (iCloud, Nextcloud, Radicale, Baikal, etc.) and choose which calendars to sync.", + "caldavEmptyState": "No CalDAV accounts connected yet. Add your first account to get started.", + "caldavNameLabel": "Account Name", + "caldavNamePlaceholder": "e.g. My Radicale, iCloud, Nextcloud", + "caldavPasswordHint": "For iCloud: Use app-specific password from appleid.apple.com", + "caldavPasswordLabel": "Password", + "caldavRefreshCalendars": "Refresh calendars", + "caldavSyncFailed": "CalDAV sync failed", + "caldavSyncSuccess": "CalDAV sync successful", + "caldavTitle": "CalDAV Calendars", + "caldavUrlHint": "The base URL of your CalDAV server", + "caldavUsernameLabel": "Username", + "calendarDisabled": "Calendar disabled", + "calendarEnabled": "Calendar enabled", + "calendarsRefreshed": "Calendars refreshed", + "deleteAccountConfirm": "Really delete CalDAV account? All synced calendars will be removed.", + "emptyStateAddFirst": "Füge dein erstes Konto hinzu", + "emptyStateNoAccounts": "Noch keine Konten verbunden", + "helpTooltipCalDAV": "CalDAV ermöglicht die Synchronisation von Kalendern mit iCloud, Nextcloud und anderen CalDAV-Servern.", + "helpTooltipCardDAV": "CardDAV ermöglicht die Synchronisation von Kontakten mit iCloud, Nextcloud und anderen CardDAV-Servern.", + "lastSync": "Last synced", + "modulesHint": "Disabled modules disappear from the navigation. Data is preserved and reappears once a module is re-enabled.", + "modulesSaved": "Module visibility saved.", + "modulesTitle": "Active modules", + "navigationLabel": "Einstellungsnavigation", + "sectionAdmin": "Administration", + "sectionCloudServices": "Cloud-Dienste", + "sectionModules": "Modules", + "sectionModulesNav": "Module", + "sectionOpenStandards": "CalDAV & CardDAV", + "sectionPersonal": "Persönlich", + "sectionSync": "Synchronisation", + "statusError": "Fehler", + "statusNeverSynced": "Noch nie synchronisiert", + "statusSynced": "Synchronisiert", + "statusSyncing": "Synchronisiert…", + "syncedAgo": "vor {{time}}", + "tabSyncCalendar": "Kalender", + "tabSyncContacts": "Kontakte" }, "login": { "tagline": "पारिवारिक योजना। सुरक्षित। गोपनीयता-अनुकूल। ओपन सोर्स।", @@ -1120,7 +1203,7 @@ "customWeeks": "Weeks" }, "onboarding": { - "step1Title": "Welcome to Oikos", + "step1Title": "{{name}} में आपका स्वागत है", "step1Body": "Your personal family planner. Tasks, calendar, shopping and more – all in one place.", "step2Title": "नेविगेशन और मॉड्यूल", "step2Body": "नीचे से डैशबोर्ड और कैलेंडर तक सीधी पहुँच। ··· बटन से किचन, नोट्स और संपर्क जैसे अन्य मॉड्यूल खोलें।", @@ -1203,7 +1286,18 @@ }, "dropzoneTitle": "फ़ाइल यहाँ छोड़ें या चुनने के लिए क्लिक करें", "dropzoneHint": "फ़ाइल को इस क्षेत्र में खींचें या फ़ाइल पिकर का उपयोग करें।", - "selectedFileLabel": "चयनित: {{name}}" + "selectedFileLabel": "चयनित: {{name}}", + "addFolderButton": "Add folder", + "allFolders": "All folders", + "folderLabel": "Folder", + "noFolder": "No folder", + "newFolderTitle": "New folder", + "folderNameLabel": "Folder name", + "createFolderAction": "Create folder", + "folderCreatedToast": "Folder created.", + "housekeepingFolder": "हाउसकीपिंग", + "calendarItemsFolder": "कैलेंडर आइटम", + "folderBrowserTitle": "फ़ोल्डर ब्राउज़ करें" }, "shortcuts": { "goKitchen": "रसोई", @@ -1215,5 +1309,154 @@ "help": "कीबोर्ड शॉर्टकट", "new": "नई प्रविष्टि बनाएं", "search": "खोज खोलें" + }, + "housekeeping": { + "title": "Cleaner workspace", + "bottomNav": "Housekeeping navigation", + "home": "Home", + "tasks": "Tasks", + "report": "Report", + "notCheckedIn": "Not checked in", + "checkedInAt": "Checked in at", + "monthTotal": "Current month · {{count}} sessions", + "dailyRate": "Daily rate", + "extras": "Extras", + "checkIn": "Check in", + "checkOut": "Check out", + "quickSupply": "Missing product", + "supplyName": "Product name", + "supplyPlaceholder": "What is missing?", + "checkedInToast": "Check-in recorded.", + "checkedOutToast": "Check-out recorded.", + "supplyAddedToast": "Added to the shopping list.", + "overdue": "Overdue", + "dueToday": "Due today", + "ok": "OK", + "noTasks": "No housekeeping tasks yet.", + "everyDays": "Every {{days}} days", + "completeTask": "Complete {{name}}", + "taskDoneToast": "Task completed.", + "reportTitle": "Report a problem", + "problemDescription": "Problem description", + "problemPlaceholder": "Example: burnt-out light bulb", + "addPhoto": "Add photo", + "sendReport": "Send report", + "reportSentToast": "Problem reported.", + "recentReports": "Recent reports", + "addTask": "Add task", + "taskName": "Task", + "taskNamePlaceholder": "Example: Clean bathrooms", + "taskArea": "Area", + "taskAreaPlaceholder": "Example: Bathroom", + "taskFrequency": "Frequency", + "createTask": "Create task", + "taskCreatedToast": "Housekeeping task created.", + "dashboard": "Dashboard", + "reports": "Reports", + "visitsThisMonth": "Visits this month", + "lastVisit": "Last visit", + "pendingChores": "Pending chores", + "finishedChores": "Finished chores", + "payments": "Payments", + "pendingPayments": "Pending payments", + "monthlyPayments": "Monthly payments", + "noPaymentData": "No payment data yet.", + "noVisits": "No visits yet", + "noWorkerTitle": "No housekeeper profile", + "noWorkerHint": "Create the worker profile to define contacts, rate, and payment schedule.", + "taskTemplates": "Suggested chores", + "addCustomTask": "Add custom chore", + "noReports": "No reports yet.", + "profileTitle": "Housekeeper profile", + "profilePicture": "Housekeeper profile picture", + "workerName": "Name", + "workerUsername": "Username", + "workerPhone": "Phone", + "workerEmail": "Email", + "workerBirthDate": "Birthday", + "paymentSchedule": "Payment schedule", + "scheduleDaily": "Every visit", + "scheduleTwiceMonthly": "Twice a month", + "scheduleMonthly": "Monthly", + "profileColor": "Profile color", + "workerNotes": "Notes", + "workerSavedToast": "Housekeeper profile saved.", + "staff": "Staff", + "staffTitle": "Housekeeping staff", + "addWorker": "Add housekeeper", + "editWorker": "Edit housekeeper", + "noWorkers": "No housekeepers registered yet.", + "moreWorkers": "+{{count}} more", + "checkInDisabled": "Add a housekeeper before checking in.", + "calendarColor": "Calendar color", + "visitRecordedAt": "Visit recorded at", + "checkedInToday": "Recorded today", + "visitReports": "Staff visit reports", + "noVisitReports": "No staff visits recorded this month.", + "openVisitReport": "Open visit report", + "visitReportDetails": "Visit report", + "paymentPaid": "Paid", + "paymentPending": "Pending", + "totalPayment": "Total payment", + "paymentStatus": "Payment status", + "paymentTask": "Payment task", + "calendarEvent": "Calendar event", + "notAvailable": "Not available", + "calendarVisitTitle": "Housekeeping: {{name}}", + "paymentTaskTitle": "Pay {{name}} for housekeeping", + "paymentTaskDescription": "Housekeeping visit on {{date}}. Amount due: {{amount}}.", + "staffLogTitle": "{{name}} visits", + "staffLogHint": "Edit visit dates, amounts, and linked records.", + "filterMonth": "Month", + "editVisit": "Edit visit", + "deleteVisit": "Delete visit", + "deleteVisitConfirm": "Delete this visit? The linked calendar event and payment task will also be removed.", + "visitDeletedToast": "Visit deleted.", + "visitSavedToast": "Visit updated.", + "visitDate": "Visit date", + "markPaid": "Mark paid", + "visitPaidToast": "Payment marked as paid.", + "receiptUploadTitle": "Upload payment receipt", + "receiptUploadHint": "Attach a payment receipt. It will appear in Documents.", + "receiptDocumentName": "Receipt - {{name}} - {{date}}", + "receiptDocumentDescription": "Payment receipt for {{name}} housekeeping visit on {{date}}.", + "taskTemplateData": { + "cleanBathrooms": { + "name": "बाथरूम साफ करें", + "area": "बाथरूम" + }, + "mopKitchenFloor": { + "name": "रसोई का फर्श पोंछें", + "area": "रसोई" + }, + "dustLivingRoom": { + "name": "लिविंग रूम की धूल साफ करें", + "area": "लिविंग रूम" + }, + "changeBedLinens": { + "name": "बिस्तर की चादरें बदलें", + "area": "बेडरूम" + }, + "cleanRefrigerator": { + "name": "फ्रिज साफ करें", + "area": "रसोई" + }, + "cleanWindows": { + "name": "खिड़कियाँ साफ करें", + "area": "पूरा घर" + }, + "deepCleanOven": { + "name": "ओवन की गहरी सफाई करें", + "area": "रसोई" + }, + "washOutdoor": { + "name": "बालकनी/आँगन धोएँ", + "area": "बाहरी क्षेत्र" + } + } + }, + "userMultiSelect": { + "moreUsers": "weitere", + "nobody": "- Niemand -" } -} \ No newline at end of file +} diff --git a/public/locales/it.json b/public/locales/it.json index 1717950..cfd90f4 100644 --- a/public/locales/it.json +++ b/public/locales/it.json @@ -54,7 +54,8 @@ "more": "Altro", "documents": "Documenti", "kitchen": "Cucina", - "search": "Cerca" + "search": "Cerca", + "housekeeping": "Pulizie" }, "dashboard": { "title": "Panoramica", @@ -205,7 +206,18 @@ "kanbanArchived": "Archiviato", "reminderNeedsDueDate": "Imposta una data di scadenza per abilitare i promemoria delle attività.", "emptyAction": "Crea attività", - "navLabelOverdue": "Attività, {{count}} in ritardo" + "navLabelOverdue": "Attività, {{count}} in ritardo", + "bulkArchive": "Archive", + "bulkArchived": "Tasks archived.", + "bulkDelete": "Delete", + "bulkDeleteConfirm": "Delete {{count}} tasks permanently?", + "bulkDeleted": "Tasks deleted.", + "bulkMarkDone": "Mark done", + "bulkMarkOpen": "Mark open", + "bulkSelect": "Bulk select", + "bulkSelectedCount": "{{count}} selected", + "bulkStatusChanged": "Status changed.", + "selectTask": "Select task" }, "shopping": { "title": "Spesa", @@ -479,7 +491,13 @@ "colorPurple": "Viola", "colorRed": "Rosso", "colorSkyBlue": "Azzurro", - "colorYellow": "Giallo" + "colorYellow": "Giallo", + "iconCleaning": "Pulizie", + "caldavTargetHint": "Choose a CalDAV calendar to sync this event.", + "caldavTargetLabel": "Sync to CalDAV", + "caldavTargetLocal": "Store locally only", + "attachmentDocumentName": "{{title}} - {{name}}", + "attachmentDocumentDescription": "Allegato caricato per l’evento calendario \"{{title}}\"." }, "notes": { "title": "Bacheca", @@ -977,7 +995,72 @@ "addressbookEnabled": "Addressbook enabled", "addressbookDisabled": "Addressbook disabled", "addressbooksRefreshed": "Addressbooks refreshed", - "deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link." + "deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link.", + "sectionHousekeeping": "Housekeeping", + "housekeepingPaymentsTitle": "Payment tasks", + "housekeepingPaymentTasksLabel": "Create a payment task on each housekeeper check-in", + "housekeepingPaymentTasksHint": "When enabled, each check-in creates a task for paying the staff member. Completing that task marks the visit payment as paid.", + "housekeepingPaymentTasksSaved": "Housekeeping payment setting saved.", + "backupSchedulerDisabled": "Disabled", + "backupSchedulerEnabled": "Enabled", + "backupSchedulerHint": "Scheduled backups are created automatically and old backups are rotated.", + "backupSchedulerKeep": "Retention", + "backupSchedulerKeepCount": "{{count}} backups", + "backupSchedulerLastBackup": "Last backup", + "backupSchedulerLastFail": "{{date}} (failed)", + "backupSchedulerLastSuccess": "{{date}} (successful)", + "backupSchedulerNever": "No backup created yet", + "backupSchedulerSchedule": "Schedule", + "backupSchedulerStatus": "Status", + "backupSchedulerTitle": "Automatic Backups", + "backupSchedulerTrigger": "Create backup now", + "backupSchedulerTriggeredToast": "Backup created successfully.", + "backupSchedulerTriggering": "Creating backup...", + "breadcrumbLabel": "Pfad", + "caldavAccountAdded": "CalDAV account added successfully", + "caldavAccountDeleted": "CalDAV account removed", + "caldavAddAccount": "Add CalDAV Account", + "caldavCalendarsToggle": "Show/hide calendars", + "caldavConnectionFailed": "Connection to CalDAV server failed", + "caldavDescription": "Connect multiple CalDAV accounts (iCloud, Nextcloud, Radicale, Baikal, etc.) and choose which calendars to sync.", + "caldavEmptyState": "No CalDAV accounts connected yet. Add your first account to get started.", + "caldavNameLabel": "Account Name", + "caldavNamePlaceholder": "e.g. My Radicale, iCloud, Nextcloud", + "caldavPasswordHint": "For iCloud: Use app-specific password from appleid.apple.com", + "caldavPasswordLabel": "Password", + "caldavRefreshCalendars": "Refresh calendars", + "caldavSyncFailed": "CalDAV sync failed", + "caldavSyncSuccess": "CalDAV sync successful", + "caldavTitle": "CalDAV Calendars", + "caldavUrlHint": "The base URL of your CalDAV server", + "caldavUsernameLabel": "Username", + "calendarDisabled": "Calendar disabled", + "calendarEnabled": "Calendar enabled", + "calendarsRefreshed": "Calendars refreshed", + "deleteAccountConfirm": "Really delete CalDAV account? All synced calendars will be removed.", + "emptyStateAddFirst": "Füge dein erstes Konto hinzu", + "emptyStateNoAccounts": "Noch keine Konten verbunden", + "helpTooltipCalDAV": "CalDAV ermöglicht die Synchronisation von Kalendern mit iCloud, Nextcloud und anderen CalDAV-Servern.", + "helpTooltipCardDAV": "CardDAV ermöglicht die Synchronisation von Kontakten mit iCloud, Nextcloud und anderen CardDAV-Servern.", + "lastSync": "Last synced", + "modulesHint": "Disabled modules disappear from the navigation. Data is preserved and reappears once a module is re-enabled.", + "modulesSaved": "Module visibility saved.", + "modulesTitle": "Active modules", + "navigationLabel": "Einstellungsnavigation", + "sectionAdmin": "Administration", + "sectionCloudServices": "Cloud-Dienste", + "sectionModules": "Modules", + "sectionModulesNav": "Module", + "sectionOpenStandards": "CalDAV & CardDAV", + "sectionPersonal": "Persönlich", + "sectionSync": "Synchronisation", + "statusError": "Fehler", + "statusNeverSynced": "Noch nie synchronisiert", + "statusSynced": "Synchronisiert", + "statusSyncing": "Synchronisiert…", + "syncedAgo": "vor {{time}}", + "tabSyncCalendar": "Kalender", + "tabSyncContacts": "Kontakte" }, "login": { "tagline": "Pianificazione familiare. Sicura. Rispettosa della privacy. Open source.", @@ -1120,7 +1203,7 @@ "customWeeks": "Weeks" }, "onboarding": { - "step1Title": "Welcome to Oikos", + "step1Title": "Benvenuto in {{name}}", "step1Body": "Your personal family planner. Tasks, calendar, shopping and more – all in one place.", "step2Title": "Navigazione e moduli", "step2Body": "In basso accedi direttamente alla Dashboard e al Calendario. Con il pulsante ··· apri altri moduli come Cucina, Note e Contatti.", @@ -1203,7 +1286,18 @@ }, "dropzoneTitle": "Rilascia il file qui o fai clic per scegliere", "dropzoneHint": "Trascina un file in quest’area oppure usa il selettore.", - "selectedFileLabel": "Selezionato: {{name}}" + "selectedFileLabel": "Selezionato: {{name}}", + "addFolderButton": "Add folder", + "allFolders": "All folders", + "folderLabel": "Folder", + "noFolder": "No folder", + "newFolderTitle": "New folder", + "folderNameLabel": "Folder name", + "createFolderAction": "Create folder", + "folderCreatedToast": "Folder created.", + "housekeepingFolder": "Pulizie", + "calendarItemsFolder": "Elementi del calendario", + "folderBrowserTitle": "Sfoglia cartelle" }, "shortcuts": { "goKitchen": "Cucina", @@ -1215,5 +1309,154 @@ "help": "Scorciatoie da tastiera", "new": "Crea nuova voce", "search": "Apri ricerca" + }, + "housekeeping": { + "title": "Area pulizie", + "bottomNav": "Navigazione pulizie", + "home": "Home", + "tasks": "Attività", + "report": "Segnala", + "notCheckedIn": "Non registrata", + "checkedInAt": "Entrata alle", + "monthTotal": "Mese corrente · {{count}} sessioni", + "dailyRate": "Tariffa giornaliera", + "extras": "Extra", + "checkIn": "Entrata", + "checkOut": "Uscita", + "quickSupply": "Prodotto mancante", + "supplyName": "Prodotto", + "supplyPlaceholder": "Cosa manca?", + "checkedInToast": "Entrata registrata.", + "checkedOutToast": "Uscita registrata.", + "supplyAddedToast": "Aggiunto alla lista della spesa.", + "overdue": "In ritardo", + "dueToday": "Oggi", + "ok": "OK", + "noTasks": "Nessuna attività di pulizia.", + "everyDays": "Ogni {{days}} giorni", + "completeTask": "Completa {{name}}", + "taskDoneToast": "Attività completata.", + "reportTitle": "Segnala problema", + "problemDescription": "Descrizione del problema", + "problemPlaceholder": "Esempio: lampadina bruciata", + "addPhoto": "Aggiungi foto", + "sendReport": "Invia", + "reportSentToast": "Problema segnalato.", + "recentReports": "Segnalazioni recenti", + "addTask": "Aggiungi attività", + "taskName": "Attività", + "taskNamePlaceholder": "Esempio: pulire i bagni", + "taskArea": "Area", + "taskAreaPlaceholder": "Esempio: bagno", + "taskFrequency": "Frequenza", + "createTask": "Crea attività", + "taskCreatedToast": "Attività di pulizia creata.", + "dashboard": "Dashboard", + "reports": "Reports", + "visitsThisMonth": "Visite del mese", + "lastVisit": "Ultima visita", + "pendingChores": "Attività aperte", + "finishedChores": "Attività completate", + "payments": "Pagamenti", + "pendingPayments": "Pagamenti in sospeso", + "monthlyPayments": "Pagamenti mensili", + "noPaymentData": "Nessun dato di pagamento.", + "noVisits": "Nessuna visita", + "noWorkerTitle": "Nessun profilo pulizie", + "noWorkerHint": "Crea il profilo per definire contatti, tariffa e calendario pagamenti.", + "taskTemplates": "Attività suggerite", + "addCustomTask": "Aggiungi attività personalizzata", + "noReports": "Nessuna segnalazione.", + "profileTitle": "Profilo pulizie", + "profilePicture": "Foto profilo", + "workerName": "Nome", + "workerUsername": "Nome utente", + "workerPhone": "Telefono", + "workerEmail": "E-mail", + "workerBirthDate": "Compleanno", + "paymentSchedule": "Calendario pagamenti", + "scheduleDaily": "Ogni visita", + "scheduleTwiceMonthly": "Due volte al mese", + "scheduleMonthly": "Mensile", + "profileColor": "Colore profilo", + "workerNotes": "Note", + "workerSavedToast": "Profilo salvato.", + "staff": "Staff", + "staffTitle": "Staff pulizie", + "addWorker": "Aggiungi persona", + "editWorker": "Modifica persona", + "noWorkers": "Nessuna persona registrata.", + "moreWorkers": "+{{count}} altre", + "checkInDisabled": "Aggiungi una persona prima dell’entrata.", + "calendarColor": "Colore calendario", + "visitRecordedAt": "Visit recorded at", + "checkedInToday": "Recorded today", + "visitReports": "Staff visit reports", + "noVisitReports": "No staff visits recorded this month.", + "openVisitReport": "Open visit report", + "visitReportDetails": "Visit report", + "paymentPaid": "Paid", + "paymentPending": "Pending", + "totalPayment": "Total payment", + "paymentStatus": "Payment status", + "paymentTask": "Payment task", + "calendarEvent": "Calendar event", + "notAvailable": "Not available", + "calendarVisitTitle": "Housekeeping: {{name}}", + "paymentTaskTitle": "Pay {{name}} for housekeeping", + "paymentTaskDescription": "Housekeeping visit on {{date}}. Amount due: {{amount}}.", + "staffLogTitle": "{{name}} visits", + "staffLogHint": "Edit visit dates, amounts, and linked records.", + "filterMonth": "Month", + "editVisit": "Edit visit", + "deleteVisit": "Delete visit", + "deleteVisitConfirm": "Delete this visit? The linked calendar event and payment task will also be removed.", + "visitDeletedToast": "Visit deleted.", + "visitSavedToast": "Visit updated.", + "visitDate": "Visit date", + "markPaid": "Mark paid", + "visitPaidToast": "Payment marked as paid.", + "receiptUploadTitle": "Upload payment receipt", + "receiptUploadHint": "Attach a payment receipt. It will appear in Documents.", + "receiptDocumentName": "Receipt - {{name}} - {{date}}", + "receiptDocumentDescription": "Payment receipt for {{name}} housekeeping visit on {{date}}.", + "taskTemplateData": { + "cleanBathrooms": { + "name": "Pulire i bagni", + "area": "Bagni" + }, + "mopKitchenFloor": { + "name": "Lavare il pavimento della cucina", + "area": "Cucina" + }, + "dustLivingRoom": { + "name": "Spolverare il soggiorno", + "area": "Soggiorno" + }, + "changeBedLinens": { + "name": "Cambiare la biancheria da letto", + "area": "Camere" + }, + "cleanRefrigerator": { + "name": "Pulire il frigorifero", + "area": "Cucina" + }, + "cleanWindows": { + "name": "Pulire le finestre", + "area": "Tutta la casa" + }, + "deepCleanOven": { + "name": "Pulizia profonda del forno", + "area": "Cucina" + }, + "washOutdoor": { + "name": "Lavare balcone/patio", + "area": "Esterno" + } + } + }, + "userMultiSelect": { + "moreUsers": "weitere", + "nobody": "- Niemand -" } -} \ No newline at end of file +} diff --git a/public/locales/ja.json b/public/locales/ja.json index e343ec1..1c7a0e6 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -54,7 +54,8 @@ "more": "もっと見る", "documents": "書類", "kitchen": "キッチン", - "search": "検索" + "search": "検索", + "housekeeping": "Housekeeping" }, "dashboard": { "title": "ダッシュボード", @@ -205,7 +206,18 @@ "kanbanArchived": "アーカイブ済み", "reminderNeedsDueDate": "タスクのリマインダーを有効にするには期日を設定してください。", "emptyAction": "タスクを作成", - "navLabelOverdue": "タスク、{{count}}件期限超過" + "navLabelOverdue": "タスク、{{count}}件期限超過", + "bulkArchive": "Archive", + "bulkArchived": "Tasks archived.", + "bulkDelete": "Delete", + "bulkDeleteConfirm": "Delete {{count}} tasks permanently?", + "bulkDeleted": "Tasks deleted.", + "bulkMarkDone": "Mark done", + "bulkMarkOpen": "Mark open", + "bulkSelect": "Bulk select", + "bulkSelectedCount": "{{count}} selected", + "bulkStatusChanged": "Status changed.", + "selectTask": "Select task" }, "shopping": { "title": "買い物", @@ -479,7 +491,13 @@ "colorPurple": "紫", "colorRed": "赤", "colorSkyBlue": "空色", - "colorYellow": "黄" + "colorYellow": "黄", + "iconCleaning": "Cleaning", + "caldavTargetHint": "Choose a CalDAV calendar to sync this event.", + "caldavTargetLabel": "Sync to CalDAV", + "caldavTargetLocal": "Store locally only", + "attachmentDocumentName": "{{title}} - {{name}}", + "attachmentDocumentDescription": "カレンダー予定「{{title}}」にアップロードされた添付ファイル。" }, "notes": { "title": "メモボード", @@ -977,7 +995,72 @@ "addressbookEnabled": "Addressbook enabled", "addressbookDisabled": "Addressbook disabled", "addressbooksRefreshed": "Addressbooks refreshed", - "deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link." + "deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link.", + "sectionHousekeeping": "Housekeeping", + "housekeepingPaymentsTitle": "Payment tasks", + "housekeepingPaymentTasksLabel": "Create a payment task on each housekeeper check-in", + "housekeepingPaymentTasksHint": "When enabled, each check-in creates a task for paying the staff member. Completing that task marks the visit payment as paid.", + "housekeepingPaymentTasksSaved": "Housekeeping payment setting saved.", + "backupSchedulerDisabled": "Disabled", + "backupSchedulerEnabled": "Enabled", + "backupSchedulerHint": "Scheduled backups are created automatically and old backups are rotated.", + "backupSchedulerKeep": "Retention", + "backupSchedulerKeepCount": "{{count}} backups", + "backupSchedulerLastBackup": "Last backup", + "backupSchedulerLastFail": "{{date}} (failed)", + "backupSchedulerLastSuccess": "{{date}} (successful)", + "backupSchedulerNever": "No backup created yet", + "backupSchedulerSchedule": "Schedule", + "backupSchedulerStatus": "Status", + "backupSchedulerTitle": "Automatic Backups", + "backupSchedulerTrigger": "Create backup now", + "backupSchedulerTriggeredToast": "Backup created successfully.", + "backupSchedulerTriggering": "Creating backup...", + "breadcrumbLabel": "Pfad", + "caldavAccountAdded": "CalDAV account added successfully", + "caldavAccountDeleted": "CalDAV account removed", + "caldavAddAccount": "Add CalDAV Account", + "caldavCalendarsToggle": "Show/hide calendars", + "caldavConnectionFailed": "Connection to CalDAV server failed", + "caldavDescription": "Connect multiple CalDAV accounts (iCloud, Nextcloud, Radicale, Baikal, etc.) and choose which calendars to sync.", + "caldavEmptyState": "No CalDAV accounts connected yet. Add your first account to get started.", + "caldavNameLabel": "Account Name", + "caldavNamePlaceholder": "e.g. My Radicale, iCloud, Nextcloud", + "caldavPasswordHint": "For iCloud: Use app-specific password from appleid.apple.com", + "caldavPasswordLabel": "Password", + "caldavRefreshCalendars": "Refresh calendars", + "caldavSyncFailed": "CalDAV sync failed", + "caldavSyncSuccess": "CalDAV sync successful", + "caldavTitle": "CalDAV Calendars", + "caldavUrlHint": "The base URL of your CalDAV server", + "caldavUsernameLabel": "Username", + "calendarDisabled": "Calendar disabled", + "calendarEnabled": "Calendar enabled", + "calendarsRefreshed": "Calendars refreshed", + "deleteAccountConfirm": "Really delete CalDAV account? All synced calendars will be removed.", + "emptyStateAddFirst": "Füge dein erstes Konto hinzu", + "emptyStateNoAccounts": "Noch keine Konten verbunden", + "helpTooltipCalDAV": "CalDAV ermöglicht die Synchronisation von Kalendern mit iCloud, Nextcloud und anderen CalDAV-Servern.", + "helpTooltipCardDAV": "CardDAV ermöglicht die Synchronisation von Kontakten mit iCloud, Nextcloud und anderen CardDAV-Servern.", + "lastSync": "Last synced", + "modulesHint": "Disabled modules disappear from the navigation. Data is preserved and reappears once a module is re-enabled.", + "modulesSaved": "Module visibility saved.", + "modulesTitle": "Active modules", + "navigationLabel": "Einstellungsnavigation", + "sectionAdmin": "Administration", + "sectionCloudServices": "Cloud-Dienste", + "sectionModules": "Modules", + "sectionModulesNav": "Module", + "sectionOpenStandards": "CalDAV & CardDAV", + "sectionPersonal": "Persönlich", + "sectionSync": "Synchronisation", + "statusError": "Fehler", + "statusNeverSynced": "Noch nie synchronisiert", + "statusSynced": "Synchronisiert", + "statusSyncing": "Synchronisiert…", + "syncedAgo": "vor {{time}}", + "tabSyncCalendar": "Kalender", + "tabSyncContacts": "Kontakte" }, "login": { "tagline": "家族計画。安全。プライバシー重視。オープンソース。", @@ -1120,7 +1203,7 @@ "customWeeks": "Weeks" }, "onboarding": { - "step1Title": "Welcome to Oikos", + "step1Title": "{{name}}へようこそ", "step1Body": "Your personal family planner. Tasks, calendar, shopping and more – all in one place.", "step2Title": "ナビゲーションとモジュール", "step2Body": "画面下部からダッシュボードとカレンダーに直接アクセスできます。···ボタンでキッチン、メモ、連絡先などの追加モジュールを開きます。", @@ -1203,7 +1286,18 @@ }, "dropzoneTitle": "ここにファイルをドロップ、またはクリックして選択", "dropzoneHint": "この領域にファイルをドラッグするか、ファイル選択を使用します。", - "selectedFileLabel": "選択済み: {{name}}" + "selectedFileLabel": "選択済み: {{name}}", + "addFolderButton": "Add folder", + "allFolders": "All folders", + "folderLabel": "Folder", + "noFolder": "No folder", + "newFolderTitle": "New folder", + "folderNameLabel": "Folder name", + "createFolderAction": "Create folder", + "folderCreatedToast": "Folder created.", + "housekeepingFolder": "ハウスキーピング", + "calendarItemsFolder": "カレンダー項目", + "folderBrowserTitle": "フォルダーを参照" }, "shortcuts": { "goKitchen": "キッチン", @@ -1215,5 +1309,154 @@ "help": "キーボードショートカット", "new": "新規エントリを作成", "search": "検索を開く" + }, + "housekeeping": { + "title": "Cleaner workspace", + "bottomNav": "Housekeeping navigation", + "home": "Home", + "tasks": "Tasks", + "report": "Report", + "notCheckedIn": "Not checked in", + "checkedInAt": "Checked in at", + "monthTotal": "Current month · {{count}} sessions", + "dailyRate": "Daily rate", + "extras": "Extras", + "checkIn": "Check in", + "checkOut": "Check out", + "quickSupply": "Missing product", + "supplyName": "Product name", + "supplyPlaceholder": "What is missing?", + "checkedInToast": "Check-in recorded.", + "checkedOutToast": "Check-out recorded.", + "supplyAddedToast": "Added to the shopping list.", + "overdue": "Overdue", + "dueToday": "Due today", + "ok": "OK", + "noTasks": "No housekeeping tasks yet.", + "everyDays": "Every {{days}} days", + "completeTask": "Complete {{name}}", + "taskDoneToast": "Task completed.", + "reportTitle": "Report a problem", + "problemDescription": "Problem description", + "problemPlaceholder": "Example: burnt-out light bulb", + "addPhoto": "Add photo", + "sendReport": "Send report", + "reportSentToast": "Problem reported.", + "recentReports": "Recent reports", + "addTask": "Add task", + "taskName": "Task", + "taskNamePlaceholder": "Example: Clean bathrooms", + "taskArea": "Area", + "taskAreaPlaceholder": "Example: Bathroom", + "taskFrequency": "Frequency", + "createTask": "Create task", + "taskCreatedToast": "Housekeeping task created.", + "dashboard": "Dashboard", + "reports": "Reports", + "visitsThisMonth": "Visits this month", + "lastVisit": "Last visit", + "pendingChores": "Pending chores", + "finishedChores": "Finished chores", + "payments": "Payments", + "pendingPayments": "Pending payments", + "monthlyPayments": "Monthly payments", + "noPaymentData": "No payment data yet.", + "noVisits": "No visits yet", + "noWorkerTitle": "No housekeeper profile", + "noWorkerHint": "Create the worker profile to define contacts, rate, and payment schedule.", + "taskTemplates": "Suggested chores", + "addCustomTask": "Add custom chore", + "noReports": "No reports yet.", + "profileTitle": "Housekeeper profile", + "profilePicture": "Housekeeper profile picture", + "workerName": "Name", + "workerUsername": "Username", + "workerPhone": "Phone", + "workerEmail": "Email", + "workerBirthDate": "Birthday", + "paymentSchedule": "Payment schedule", + "scheduleDaily": "Every visit", + "scheduleTwiceMonthly": "Twice a month", + "scheduleMonthly": "Monthly", + "profileColor": "Profile color", + "workerNotes": "Notes", + "workerSavedToast": "Housekeeper profile saved.", + "staff": "Staff", + "staffTitle": "Housekeeping staff", + "addWorker": "Add housekeeper", + "editWorker": "Edit housekeeper", + "noWorkers": "No housekeepers registered yet.", + "moreWorkers": "+{{count}} more", + "checkInDisabled": "Add a housekeeper before checking in.", + "calendarColor": "Calendar color", + "visitRecordedAt": "Visit recorded at", + "checkedInToday": "Recorded today", + "visitReports": "Staff visit reports", + "noVisitReports": "No staff visits recorded this month.", + "openVisitReport": "Open visit report", + "visitReportDetails": "Visit report", + "paymentPaid": "Paid", + "paymentPending": "Pending", + "totalPayment": "Total payment", + "paymentStatus": "Payment status", + "paymentTask": "Payment task", + "calendarEvent": "Calendar event", + "notAvailable": "Not available", + "calendarVisitTitle": "Housekeeping: {{name}}", + "paymentTaskTitle": "Pay {{name}} for housekeeping", + "paymentTaskDescription": "Housekeeping visit on {{date}}. Amount due: {{amount}}.", + "staffLogTitle": "{{name}} visits", + "staffLogHint": "Edit visit dates, amounts, and linked records.", + "filterMonth": "Month", + "editVisit": "Edit visit", + "deleteVisit": "Delete visit", + "deleteVisitConfirm": "Delete this visit? The linked calendar event and payment task will also be removed.", + "visitDeletedToast": "Visit deleted.", + "visitSavedToast": "Visit updated.", + "visitDate": "Visit date", + "markPaid": "Mark paid", + "visitPaidToast": "Payment marked as paid.", + "receiptUploadTitle": "Upload payment receipt", + "receiptUploadHint": "Attach a payment receipt. It will appear in Documents.", + "receiptDocumentName": "Receipt - {{name}} - {{date}}", + "receiptDocumentDescription": "Payment receipt for {{name}} housekeeping visit on {{date}}.", + "taskTemplateData": { + "cleanBathrooms": { + "name": "浴室を掃除", + "area": "浴室" + }, + "mopKitchenFloor": { + "name": "キッチンの床を拭く", + "area": "キッチン" + }, + "dustLivingRoom": { + "name": "リビングのほこり取り", + "area": "リビング" + }, + "changeBedLinens": { + "name": "寝具を交換", + "area": "寝室" + }, + "cleanRefrigerator": { + "name": "冷蔵庫を掃除", + "area": "キッチン" + }, + "cleanWindows": { + "name": "窓を掃除", + "area": "家全体" + }, + "deepCleanOven": { + "name": "オーブンを徹底掃除", + "area": "キッチン" + }, + "washOutdoor": { + "name": "バルコニー/パティオを洗う", + "area": "屋外" + } + } + }, + "userMultiSelect": { + "moreUsers": "weitere", + "nobody": "- Niemand -" } -} \ No newline at end of file +} diff --git a/public/locales/pt.json b/public/locales/pt.json index b12c0d2..e347d15 100644 --- a/public/locales/pt.json +++ b/public/locales/pt.json @@ -54,7 +54,8 @@ "more": "Mais", "documents": "Documentos", "kitchen": "Cozinha", - "search": "Pesquisar" + "search": "Pesquisar", + "housekeeping": "Faxina" }, "dashboard": { "title": "Painel", @@ -205,7 +206,18 @@ "swipedOpenToast": "Marcado como aberto.", "reminderNeedsDueDate": "Defina uma data de vencimento para habilitar lembretes da tarefa.", "emptyAction": "Criar tarefa", - "navLabelOverdue": "Tarefas, {{count}} atrasadas" + "navLabelOverdue": "Tarefas, {{count}} atrasadas", + "bulkArchive": "Archive", + "bulkArchived": "Tasks archived.", + "bulkDelete": "Delete", + "bulkDeleteConfirm": "Delete {{count}} tasks permanently?", + "bulkDeleted": "Tasks deleted.", + "bulkMarkDone": "Mark done", + "bulkMarkOpen": "Mark open", + "bulkSelect": "Bulk select", + "bulkSelectedCount": "{{count}} selected", + "bulkStatusChanged": "Status changed.", + "selectTask": "Select task" }, "shopping": { "title": "Compras", @@ -479,7 +491,13 @@ "colorPurple": "Roxo", "colorRed": "Vermelho", "colorSkyBlue": "Azul céu", - "colorYellow": "Amarelo" + "colorYellow": "Amarelo", + "iconCleaning": "Faxina", + "caldavTargetHint": "Choose a CalDAV calendar to sync this event.", + "caldavTargetLabel": "Sync to CalDAV", + "caldavTargetLocal": "Store locally only", + "attachmentDocumentName": "{{title}} - {{name}}", + "attachmentDocumentDescription": "Anexo enviado para o evento \"{{title}}\" do calendário." }, "notes": { "title": "Quadro de notas", @@ -977,7 +995,72 @@ "addressbookEnabled": "Addressbook enabled", "addressbookDisabled": "Addressbook disabled", "addressbooksRefreshed": "Addressbooks refreshed", - "deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link." + "deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link.", + "sectionHousekeeping": "Faxina", + "housekeepingPaymentsTitle": "Tarefas de pagamento", + "housekeepingPaymentTasksLabel": "Criar uma tarefa de pagamento a cada entrada da faxineira", + "housekeepingPaymentTasksHint": "Quando ativo, cada entrada cria uma tarefa para pagar a pessoa da equipe. Ao concluir essa tarefa, o pagamento da visita é marcado como pago.", + "housekeepingPaymentTasksSaved": "Configuração de pagamento da faxina salva.", + "backupSchedulerDisabled": "Disabled", + "backupSchedulerEnabled": "Enabled", + "backupSchedulerHint": "Scheduled backups are created automatically and old backups are rotated.", + "backupSchedulerKeep": "Retention", + "backupSchedulerKeepCount": "{{count}} backups", + "backupSchedulerLastBackup": "Last backup", + "backupSchedulerLastFail": "{{date}} (failed)", + "backupSchedulerLastSuccess": "{{date}} (successful)", + "backupSchedulerNever": "No backup created yet", + "backupSchedulerSchedule": "Schedule", + "backupSchedulerStatus": "Status", + "backupSchedulerTitle": "Automatic Backups", + "backupSchedulerTrigger": "Create backup now", + "backupSchedulerTriggeredToast": "Backup created successfully.", + "backupSchedulerTriggering": "Creating backup...", + "breadcrumbLabel": "Pfad", + "caldavAccountAdded": "CalDAV account added successfully", + "caldavAccountDeleted": "CalDAV account removed", + "caldavAddAccount": "Add CalDAV Account", + "caldavCalendarsToggle": "Show/hide calendars", + "caldavConnectionFailed": "Connection to CalDAV server failed", + "caldavDescription": "Connect multiple CalDAV accounts (iCloud, Nextcloud, Radicale, Baikal, etc.) and choose which calendars to sync.", + "caldavEmptyState": "No CalDAV accounts connected yet. Add your first account to get started.", + "caldavNameLabel": "Account Name", + "caldavNamePlaceholder": "e.g. My Radicale, iCloud, Nextcloud", + "caldavPasswordHint": "For iCloud: Use app-specific password from appleid.apple.com", + "caldavPasswordLabel": "Password", + "caldavRefreshCalendars": "Refresh calendars", + "caldavSyncFailed": "CalDAV sync failed", + "caldavSyncSuccess": "CalDAV sync successful", + "caldavTitle": "CalDAV Calendars", + "caldavUrlHint": "The base URL of your CalDAV server", + "caldavUsernameLabel": "Username", + "calendarDisabled": "Calendar disabled", + "calendarEnabled": "Calendar enabled", + "calendarsRefreshed": "Calendars refreshed", + "deleteAccountConfirm": "Really delete CalDAV account? All synced calendars will be removed.", + "emptyStateAddFirst": "Füge dein erstes Konto hinzu", + "emptyStateNoAccounts": "Noch keine Konten verbunden", + "helpTooltipCalDAV": "CalDAV ermöglicht die Synchronisation von Kalendern mit iCloud, Nextcloud und anderen CalDAV-Servern.", + "helpTooltipCardDAV": "CardDAV ermöglicht die Synchronisation von Kontakten mit iCloud, Nextcloud und anderen CardDAV-Servern.", + "lastSync": "Last synced", + "modulesHint": "Disabled modules disappear from the navigation. Data is preserved and reappears once a module is re-enabled.", + "modulesSaved": "Module visibility saved.", + "modulesTitle": "Active modules", + "navigationLabel": "Einstellungsnavigation", + "sectionAdmin": "Administration", + "sectionCloudServices": "Cloud-Dienste", + "sectionModules": "Modules", + "sectionModulesNav": "Module", + "sectionOpenStandards": "CalDAV & CardDAV", + "sectionPersonal": "Persönlich", + "sectionSync": "Synchronisation", + "statusError": "Fehler", + "statusNeverSynced": "Noch nie synchronisiert", + "statusSynced": "Synchronisiert", + "statusSyncing": "Synchronisiert…", + "syncedAgo": "vor {{time}}", + "tabSyncCalendar": "Kalender", + "tabSyncContacts": "Kontakte" }, "login": { "tagline": "Planejamento familiar. Seguro. Privado. Código aberto.", @@ -1120,7 +1203,7 @@ "customWeeks": "Semanas" }, "onboarding": { - "step1Title": "Welcome to Oikos", + "step1Title": "Bem-vindo ao {{name}}", "step1Body": "Your personal family planner. Tasks, calendar, shopping and more – all in one place.", "step2Title": "Navegação e módulos", "step2Body": "Na parte inferior acessa diretamente o Painel e o Calendário. Com o botão ··· abre módulos adicionais como Cozinha, Notas e Contactos.", @@ -1203,7 +1286,18 @@ }, "dropzoneTitle": "Solte o arquivo aqui ou clique para escolher", "dropzoneHint": "Arraste um arquivo para esta area, ou use o seletor de arquivos.", - "selectedFileLabel": "Selecionado: {{name}}" + "selectedFileLabel": "Selecionado: {{name}}", + "addFolderButton": "Adicionar pasta", + "allFolders": "Todas as pastas", + "folderLabel": "Pasta", + "noFolder": "Sem pasta", + "newFolderTitle": "Nova pasta", + "folderNameLabel": "Nome da pasta", + "createFolderAction": "Criar pasta", + "folderCreatedToast": "Pasta criada.", + "housekeepingFolder": "Faxina", + "calendarItemsFolder": "Itens do calendário", + "folderBrowserTitle": "Navegar por pastas" }, "shortcuts": { "goKitchen": "Cozinha", @@ -1215,5 +1309,154 @@ "help": "Atalhos de teclado", "new": "Criar nova entrada", "search": "Abrir pesquisa" + }, + "housekeeping": { + "title": "Área da faxineira", + "bottomNav": "Navegação da faxina", + "home": "Início", + "tasks": "Tarefas", + "report": "Reportar", + "notCheckedIn": "Ponto não iniciado", + "checkedInAt": "Entrada às", + "monthTotal": "Mês atual · {{count}} diárias", + "dailyRate": "Valor da diária", + "extras": "Extras", + "checkIn": "Entrar", + "checkOut": "Sair", + "quickSupply": "Faltou produto", + "supplyName": "Nome do produto", + "supplyPlaceholder": "O que faltou?", + "checkedInToast": "Entrada registrada.", + "checkedOutToast": "Saída registrada.", + "supplyAddedToast": "Adicionado à lista de compras.", + "overdue": "Atrasada", + "dueToday": "Hoje", + "ok": "Em dia", + "noTasks": "Nenhuma tarefa de faxina cadastrada.", + "everyDays": "A cada {{days}} dias", + "completeTask": "Concluir {{name}}", + "taskDoneToast": "Tarefa concluída.", + "reportTitle": "Reportar problema", + "problemDescription": "Descrição do problema", + "problemPlaceholder": "Ex: lâmpada queimada", + "addPhoto": "Adicionar foto", + "sendReport": "Enviar ocorrência", + "reportSentToast": "Problema reportado.", + "recentReports": "Ocorrências recentes", + "addTask": "Adicionar tarefa", + "taskName": "Tarefa", + "taskNamePlaceholder": "Ex: Limpar banheiros", + "taskArea": "Área", + "taskAreaPlaceholder": "Ex: Banheiro", + "taskFrequency": "Frequência", + "createTask": "Criar tarefa", + "taskCreatedToast": "Tarefa de faxina criada.", + "dashboard": "Painel", + "reports": "Relatórios", + "visitsThisMonth": "Visitas no mês", + "lastVisit": "Última visita", + "pendingChores": "Tarefas pendentes", + "finishedChores": "Tarefas concluídas", + "payments": "Pagamentos", + "pendingPayments": "Pagamentos pendentes", + "monthlyPayments": "Pagamentos mensais", + "noPaymentData": "Ainda não há dados de pagamento.", + "noVisits": "Nenhuma visita ainda", + "noWorkerTitle": "Nenhum perfil de faxineira", + "noWorkerHint": "Crie o perfil para definir contatos, diária e agenda de pagamento.", + "taskTemplates": "Tarefas sugeridas", + "addCustomTask": "Adicionar tarefa personalizada", + "noReports": "Nenhuma ocorrência ainda.", + "profileTitle": "Perfil da faxineira", + "profilePicture": "Foto de perfil da faxineira", + "workerName": "Nome", + "workerUsername": "Usuário", + "workerPhone": "Telefone", + "workerEmail": "E-mail", + "workerBirthDate": "Aniversário", + "paymentSchedule": "Agenda de pagamento", + "scheduleDaily": "A cada visita", + "scheduleTwiceMonthly": "Duas vezes por mês", + "scheduleMonthly": "Mensal", + "profileColor": "Cor do perfil", + "workerNotes": "Observações", + "workerSavedToast": "Perfil da faxineira salvo.", + "staff": "Equipe", + "staffTitle": "Equipe de faxina", + "addWorker": "Adicionar faxineira", + "editWorker": "Editar faxineira", + "noWorkers": "Nenhuma faxineira cadastrada.", + "moreWorkers": "+{{count}} a mais", + "checkInDisabled": "Cadastre uma faxineira antes de fazer check-in.", + "calendarColor": "Cor no calendário", + "visitRecordedAt": "Visita registrada às", + "checkedInToday": "Registrada hoje", + "visitReports": "Relatórios de visitas da equipe", + "noVisitReports": "Nenhuma visita da equipe registrada neste mês.", + "openVisitReport": "Abrir relatório da visita", + "visitReportDetails": "Relatório da visita", + "paymentPaid": "Pago", + "paymentPending": "Pendente", + "totalPayment": "Pagamento total", + "paymentStatus": "Status do pagamento", + "paymentTask": "Tarefa de pagamento", + "calendarEvent": "Evento do calendário", + "notAvailable": "Não disponível", + "calendarVisitTitle": "Faxina: {{name}}", + "paymentTaskTitle": "Pagar {{name}} pela faxina", + "paymentTaskDescription": "Visita de faxina em {{date}}. Valor devido: {{amount}}.", + "staffLogTitle": "Visitas de {{name}}", + "staffLogHint": "Edite datas, valores e registros vinculados.", + "filterMonth": "Mês", + "editVisit": "Editar visita", + "deleteVisit": "Excluir visita", + "deleteVisitConfirm": "Excluir esta visita? O evento do calendário e a tarefa de pagamento vinculados também serão removidos.", + "visitDeletedToast": "Visita excluída.", + "visitSavedToast": "Visita atualizada.", + "visitDate": "Data da visita", + "markPaid": "Marcar pago", + "visitPaidToast": "Pagamento marcado como pago.", + "receiptUploadTitle": "Enviar comprovante de pagamento", + "receiptUploadHint": "Anexe um comprovante de pagamento. Ele aparecerá em Documentos.", + "receiptDocumentName": "Comprovante - {{name}} - {{date}}", + "receiptDocumentDescription": "Comprovante de pagamento da visita de faxina de {{name}} em {{date}}.", + "taskTemplateData": { + "cleanBathrooms": { + "name": "Limpar banheiros", + "area": "Banheiros" + }, + "mopKitchenFloor": { + "name": "Passar pano no piso da cozinha", + "area": "Cozinha" + }, + "dustLivingRoom": { + "name": "Tirar pó da sala", + "area": "Sala" + }, + "changeBedLinens": { + "name": "Trocar roupa de cama", + "area": "Quartos" + }, + "cleanRefrigerator": { + "name": "Limpar geladeira", + "area": "Cozinha" + }, + "cleanWindows": { + "name": "Limpar janelas", + "area": "Casa toda" + }, + "deepCleanOven": { + "name": "Limpeza profunda do forno", + "area": "Cozinha" + }, + "washOutdoor": { + "name": "Lavar varanda/pátio", + "area": "Área externa" + } + } + }, + "userMultiSelect": { + "moreUsers": "weitere", + "nobody": "- Niemand -" } -} \ No newline at end of file +} diff --git a/public/locales/ru.json b/public/locales/ru.json index 10fc587..30a6543 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -54,7 +54,8 @@ "more": "Ещё", "documents": "Документы", "kitchen": "Кухня", - "search": "Поиск" + "search": "Поиск", + "housekeeping": "Housekeeping" }, "dashboard": { "title": "Обзор", @@ -205,7 +206,18 @@ "kanbanArchived": "Архивировано", "reminderNeedsDueDate": "Установите срок выполнения, чтобы включить напоминания о задаче.", "emptyAction": "Создать задачу", - "navLabelOverdue": "Задачи, {{count}} просрочено" + "navLabelOverdue": "Задачи, {{count}} просрочено", + "bulkArchive": "Archive", + "bulkArchived": "Tasks archived.", + "bulkDelete": "Delete", + "bulkDeleteConfirm": "Delete {{count}} tasks permanently?", + "bulkDeleted": "Tasks deleted.", + "bulkMarkDone": "Mark done", + "bulkMarkOpen": "Mark open", + "bulkSelect": "Bulk select", + "bulkSelectedCount": "{{count}} selected", + "bulkStatusChanged": "Status changed.", + "selectTask": "Select task" }, "shopping": { "title": "Покупки", @@ -479,7 +491,13 @@ "colorPurple": "Фиолетовый", "colorRed": "Красный", "colorSkyBlue": "Небесно-голубой", - "colorYellow": "Жёлтый" + "colorYellow": "Жёлтый", + "iconCleaning": "Cleaning", + "caldavTargetHint": "Choose a CalDAV calendar to sync this event.", + "caldavTargetLabel": "Sync to CalDAV", + "caldavTargetLocal": "Store locally only", + "attachmentDocumentName": "{{title}} - {{name}}", + "attachmentDocumentDescription": "Вложение загружено для события календаря «{{title}}»." }, "notes": { "title": "Заметки", @@ -977,7 +995,72 @@ "addressbookEnabled": "Addressbook enabled", "addressbookDisabled": "Addressbook disabled", "addressbooksRefreshed": "Addressbooks refreshed", - "deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link." + "deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link.", + "sectionHousekeeping": "Housekeeping", + "housekeepingPaymentsTitle": "Payment tasks", + "housekeepingPaymentTasksLabel": "Create a payment task on each housekeeper check-in", + "housekeepingPaymentTasksHint": "When enabled, each check-in creates a task for paying the staff member. Completing that task marks the visit payment as paid.", + "housekeepingPaymentTasksSaved": "Housekeeping payment setting saved.", + "backupSchedulerDisabled": "Disabled", + "backupSchedulerEnabled": "Enabled", + "backupSchedulerHint": "Scheduled backups are created automatically and old backups are rotated.", + "backupSchedulerKeep": "Retention", + "backupSchedulerKeepCount": "{{count}} backups", + "backupSchedulerLastBackup": "Last backup", + "backupSchedulerLastFail": "{{date}} (failed)", + "backupSchedulerLastSuccess": "{{date}} (successful)", + "backupSchedulerNever": "No backup created yet", + "backupSchedulerSchedule": "Schedule", + "backupSchedulerStatus": "Status", + "backupSchedulerTitle": "Automatic Backups", + "backupSchedulerTrigger": "Create backup now", + "backupSchedulerTriggeredToast": "Backup created successfully.", + "backupSchedulerTriggering": "Creating backup...", + "breadcrumbLabel": "Pfad", + "caldavAccountAdded": "CalDAV account added successfully", + "caldavAccountDeleted": "CalDAV account removed", + "caldavAddAccount": "Add CalDAV Account", + "caldavCalendarsToggle": "Show/hide calendars", + "caldavConnectionFailed": "Connection to CalDAV server failed", + "caldavDescription": "Connect multiple CalDAV accounts (iCloud, Nextcloud, Radicale, Baikal, etc.) and choose which calendars to sync.", + "caldavEmptyState": "No CalDAV accounts connected yet. Add your first account to get started.", + "caldavNameLabel": "Account Name", + "caldavNamePlaceholder": "e.g. My Radicale, iCloud, Nextcloud", + "caldavPasswordHint": "For iCloud: Use app-specific password from appleid.apple.com", + "caldavPasswordLabel": "Password", + "caldavRefreshCalendars": "Refresh calendars", + "caldavSyncFailed": "CalDAV sync failed", + "caldavSyncSuccess": "CalDAV sync successful", + "caldavTitle": "CalDAV Calendars", + "caldavUrlHint": "The base URL of your CalDAV server", + "caldavUsernameLabel": "Username", + "calendarDisabled": "Calendar disabled", + "calendarEnabled": "Calendar enabled", + "calendarsRefreshed": "Calendars refreshed", + "deleteAccountConfirm": "Really delete CalDAV account? All synced calendars will be removed.", + "emptyStateAddFirst": "Füge dein erstes Konto hinzu", + "emptyStateNoAccounts": "Noch keine Konten verbunden", + "helpTooltipCalDAV": "CalDAV ermöglicht die Synchronisation von Kalendern mit iCloud, Nextcloud und anderen CalDAV-Servern.", + "helpTooltipCardDAV": "CardDAV ermöglicht die Synchronisation von Kontakten mit iCloud, Nextcloud und anderen CardDAV-Servern.", + "lastSync": "Last synced", + "modulesHint": "Disabled modules disappear from the navigation. Data is preserved and reappears once a module is re-enabled.", + "modulesSaved": "Module visibility saved.", + "modulesTitle": "Active modules", + "navigationLabel": "Einstellungsnavigation", + "sectionAdmin": "Administration", + "sectionCloudServices": "Cloud-Dienste", + "sectionModules": "Modules", + "sectionModulesNav": "Module", + "sectionOpenStandards": "CalDAV & CardDAV", + "sectionPersonal": "Persönlich", + "sectionSync": "Synchronisation", + "statusError": "Fehler", + "statusNeverSynced": "Noch nie synchronisiert", + "statusSynced": "Synchronisiert", + "statusSyncing": "Synchronisiert…", + "syncedAgo": "vor {{time}}", + "tabSyncCalendar": "Kalender", + "tabSyncContacts": "Kontakte" }, "login": { "tagline": "Семейное планирование. Безопасно. С уважением к приватности. Открытый исходный код.", @@ -1120,7 +1203,7 @@ "customWeeks": "Weeks" }, "onboarding": { - "step1Title": "Welcome to Oikos", + "step1Title": "Добро пожаловать в {{name}}", "step1Body": "Your personal family planner. Tasks, calendar, shopping and more – all in one place.", "step2Title": "Навигация и модули", "step2Body": "Внизу доступны Панель управления и Календарь напрямую. Кнопка ··· открывает дополнительные модули: Кухня, Заметки и Контакты.", @@ -1203,7 +1286,18 @@ }, "dropzoneTitle": "Перетащите файл сюда или нажмите для выбора", "dropzoneHint": "Перетащите файл в эту область или используйте выбор файла.", - "selectedFileLabel": "Выбрано: {{name}}" + "selectedFileLabel": "Выбрано: {{name}}", + "addFolderButton": "Add folder", + "allFolders": "All folders", + "folderLabel": "Folder", + "noFolder": "No folder", + "newFolderTitle": "New folder", + "folderNameLabel": "Folder name", + "createFolderAction": "Create folder", + "folderCreatedToast": "Folder created.", + "housekeepingFolder": "Уборка", + "calendarItemsFolder": "Элементы календаря", + "folderBrowserTitle": "Просмотр папок" }, "shortcuts": { "goKitchen": "Кухня", @@ -1215,5 +1309,154 @@ "help": "Горячие клавиши", "new": "Создать новую запись", "search": "Открыть поиск" + }, + "housekeeping": { + "title": "Cleaner workspace", + "bottomNav": "Housekeeping navigation", + "home": "Home", + "tasks": "Tasks", + "report": "Report", + "notCheckedIn": "Not checked in", + "checkedInAt": "Checked in at", + "monthTotal": "Current month · {{count}} sessions", + "dailyRate": "Daily rate", + "extras": "Extras", + "checkIn": "Check in", + "checkOut": "Check out", + "quickSupply": "Missing product", + "supplyName": "Product name", + "supplyPlaceholder": "What is missing?", + "checkedInToast": "Check-in recorded.", + "checkedOutToast": "Check-out recorded.", + "supplyAddedToast": "Added to the shopping list.", + "overdue": "Overdue", + "dueToday": "Due today", + "ok": "OK", + "noTasks": "No housekeeping tasks yet.", + "everyDays": "Every {{days}} days", + "completeTask": "Complete {{name}}", + "taskDoneToast": "Task completed.", + "reportTitle": "Report a problem", + "problemDescription": "Problem description", + "problemPlaceholder": "Example: burnt-out light bulb", + "addPhoto": "Add photo", + "sendReport": "Send report", + "reportSentToast": "Problem reported.", + "recentReports": "Recent reports", + "addTask": "Add task", + "taskName": "Task", + "taskNamePlaceholder": "Example: Clean bathrooms", + "taskArea": "Area", + "taskAreaPlaceholder": "Example: Bathroom", + "taskFrequency": "Frequency", + "createTask": "Create task", + "taskCreatedToast": "Housekeeping task created.", + "dashboard": "Dashboard", + "reports": "Reports", + "visitsThisMonth": "Visits this month", + "lastVisit": "Last visit", + "pendingChores": "Pending chores", + "finishedChores": "Finished chores", + "payments": "Payments", + "pendingPayments": "Pending payments", + "monthlyPayments": "Monthly payments", + "noPaymentData": "No payment data yet.", + "noVisits": "No visits yet", + "noWorkerTitle": "No housekeeper profile", + "noWorkerHint": "Create the worker profile to define contacts, rate, and payment schedule.", + "taskTemplates": "Suggested chores", + "addCustomTask": "Add custom chore", + "noReports": "No reports yet.", + "profileTitle": "Housekeeper profile", + "profilePicture": "Housekeeper profile picture", + "workerName": "Name", + "workerUsername": "Username", + "workerPhone": "Phone", + "workerEmail": "Email", + "workerBirthDate": "Birthday", + "paymentSchedule": "Payment schedule", + "scheduleDaily": "Every visit", + "scheduleTwiceMonthly": "Twice a month", + "scheduleMonthly": "Monthly", + "profileColor": "Profile color", + "workerNotes": "Notes", + "workerSavedToast": "Housekeeper profile saved.", + "staff": "Staff", + "staffTitle": "Housekeeping staff", + "addWorker": "Add housekeeper", + "editWorker": "Edit housekeeper", + "noWorkers": "No housekeepers registered yet.", + "moreWorkers": "+{{count}} more", + "checkInDisabled": "Add a housekeeper before checking in.", + "calendarColor": "Calendar color", + "visitRecordedAt": "Visit recorded at", + "checkedInToday": "Recorded today", + "visitReports": "Staff visit reports", + "noVisitReports": "No staff visits recorded this month.", + "openVisitReport": "Open visit report", + "visitReportDetails": "Visit report", + "paymentPaid": "Paid", + "paymentPending": "Pending", + "totalPayment": "Total payment", + "paymentStatus": "Payment status", + "paymentTask": "Payment task", + "calendarEvent": "Calendar event", + "notAvailable": "Not available", + "calendarVisitTitle": "Housekeeping: {{name}}", + "paymentTaskTitle": "Pay {{name}} for housekeeping", + "paymentTaskDescription": "Housekeeping visit on {{date}}. Amount due: {{amount}}.", + "staffLogTitle": "{{name}} visits", + "staffLogHint": "Edit visit dates, amounts, and linked records.", + "filterMonth": "Month", + "editVisit": "Edit visit", + "deleteVisit": "Delete visit", + "deleteVisitConfirm": "Delete this visit? The linked calendar event and payment task will also be removed.", + "visitDeletedToast": "Visit deleted.", + "visitSavedToast": "Visit updated.", + "visitDate": "Visit date", + "markPaid": "Mark paid", + "visitPaidToast": "Payment marked as paid.", + "receiptUploadTitle": "Upload payment receipt", + "receiptUploadHint": "Attach a payment receipt. It will appear in Documents.", + "receiptDocumentName": "Receipt - {{name}} - {{date}}", + "receiptDocumentDescription": "Payment receipt for {{name}} housekeeping visit on {{date}}.", + "taskTemplateData": { + "cleanBathrooms": { + "name": "Убрать ванные комнаты", + "area": "Ванные комнаты" + }, + "mopKitchenFloor": { + "name": "Вымыть пол на кухне", + "area": "Кухня" + }, + "dustLivingRoom": { + "name": "Вытереть пыль в гостиной", + "area": "Гостиная" + }, + "changeBedLinens": { + "name": "Сменить постельное белье", + "area": "Спальни" + }, + "cleanRefrigerator": { + "name": "Почистить холодильник", + "area": "Кухня" + }, + "cleanWindows": { + "name": "Помыть окна", + "area": "Весь дом" + }, + "deepCleanOven": { + "name": "Глубоко очистить духовку", + "area": "Кухня" + }, + "washOutdoor": { + "name": "Помыть балкон/патио", + "area": "Улица" + } + } + }, + "userMultiSelect": { + "moreUsers": "weitere", + "nobody": "- Niemand -" } -} \ No newline at end of file +} diff --git a/public/locales/sv.json b/public/locales/sv.json index 3e4f571..da6afd7 100644 --- a/public/locales/sv.json +++ b/public/locales/sv.json @@ -54,7 +54,8 @@ "more": "Mer", "documents": "Dokument", "kitchen": "Kök", - "search": "Sök" + "search": "Sök", + "housekeeping": "Städning" }, "dashboard": { "title": "Översikt", @@ -205,7 +206,18 @@ "kanbanArchived": "Arkiverad", "reminderNeedsDueDate": "Ange ett förfallodatum för att aktivera påminnelser för uppgiften.", "emptyAction": "Skapa uppgift", - "navLabelOverdue": "Uppgifter, {{count}} försenade" + "navLabelOverdue": "Uppgifter, {{count}} försenade", + "bulkArchive": "Archive", + "bulkArchived": "Tasks archived.", + "bulkDelete": "Delete", + "bulkDeleteConfirm": "Delete {{count}} tasks permanently?", + "bulkDeleted": "Tasks deleted.", + "bulkMarkDone": "Mark done", + "bulkMarkOpen": "Mark open", + "bulkSelect": "Bulk select", + "bulkSelectedCount": "{{count}} selected", + "bulkStatusChanged": "Status changed.", + "selectTask": "Select task" }, "shopping": { "title": "Shopping", @@ -479,7 +491,13 @@ "colorSkyBlue": "Himmelsblå", "colorYellow": "Gul", "colorGray": "Grå", - "colorCyan": "Cyan" + "colorCyan": "Cyan", + "iconCleaning": "Städning", + "caldavTargetHint": "Choose a CalDAV calendar to sync this event.", + "caldavTargetLabel": "Sync to CalDAV", + "caldavTargetLocal": "Store locally only", + "attachmentDocumentName": "{{title}} - {{name}}", + "attachmentDocumentDescription": "Bilaga uppladdad för kalenderhändelsen \"{{title}}\"." }, "notes": { "title": "Anteckningar", @@ -977,7 +995,72 @@ "addressbookEnabled": "Addressbook enabled", "addressbookDisabled": "Addressbook disabled", "addressbooksRefreshed": "Addressbooks refreshed", - "deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link." + "deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link.", + "sectionHousekeeping": "Housekeeping", + "housekeepingPaymentsTitle": "Payment tasks", + "housekeepingPaymentTasksLabel": "Create a payment task on each housekeeper check-in", + "housekeepingPaymentTasksHint": "When enabled, each check-in creates a task for paying the staff member. Completing that task marks the visit payment as paid.", + "housekeepingPaymentTasksSaved": "Housekeeping payment setting saved.", + "backupSchedulerDisabled": "Disabled", + "backupSchedulerEnabled": "Enabled", + "backupSchedulerHint": "Scheduled backups are created automatically and old backups are rotated.", + "backupSchedulerKeep": "Retention", + "backupSchedulerKeepCount": "{{count}} backups", + "backupSchedulerLastBackup": "Last backup", + "backupSchedulerLastFail": "{{date}} (failed)", + "backupSchedulerLastSuccess": "{{date}} (successful)", + "backupSchedulerNever": "No backup created yet", + "backupSchedulerSchedule": "Schedule", + "backupSchedulerStatus": "Status", + "backupSchedulerTitle": "Automatic Backups", + "backupSchedulerTrigger": "Create backup now", + "backupSchedulerTriggeredToast": "Backup created successfully.", + "backupSchedulerTriggering": "Creating backup...", + "breadcrumbLabel": "Pfad", + "caldavAccountAdded": "CalDAV account added successfully", + "caldavAccountDeleted": "CalDAV account removed", + "caldavAddAccount": "Add CalDAV Account", + "caldavCalendarsToggle": "Show/hide calendars", + "caldavConnectionFailed": "Connection to CalDAV server failed", + "caldavDescription": "Connect multiple CalDAV accounts (iCloud, Nextcloud, Radicale, Baikal, etc.) and choose which calendars to sync.", + "caldavEmptyState": "No CalDAV accounts connected yet. Add your first account to get started.", + "caldavNameLabel": "Account Name", + "caldavNamePlaceholder": "e.g. My Radicale, iCloud, Nextcloud", + "caldavPasswordHint": "For iCloud: Use app-specific password from appleid.apple.com", + "caldavPasswordLabel": "Password", + "caldavRefreshCalendars": "Refresh calendars", + "caldavSyncFailed": "CalDAV sync failed", + "caldavSyncSuccess": "CalDAV sync successful", + "caldavTitle": "CalDAV Calendars", + "caldavUrlHint": "The base URL of your CalDAV server", + "caldavUsernameLabel": "Username", + "calendarDisabled": "Calendar disabled", + "calendarEnabled": "Calendar enabled", + "calendarsRefreshed": "Calendars refreshed", + "deleteAccountConfirm": "Really delete CalDAV account? All synced calendars will be removed.", + "emptyStateAddFirst": "Füge dein erstes Konto hinzu", + "emptyStateNoAccounts": "Noch keine Konten verbunden", + "helpTooltipCalDAV": "CalDAV ermöglicht die Synchronisation von Kalendern mit iCloud, Nextcloud und anderen CalDAV-Servern.", + "helpTooltipCardDAV": "CardDAV ermöglicht die Synchronisation von Kontakten mit iCloud, Nextcloud und anderen CardDAV-Servern.", + "lastSync": "Last synced", + "modulesHint": "Disabled modules disappear from the navigation. Data is preserved and reappears once a module is re-enabled.", + "modulesSaved": "Module visibility saved.", + "modulesTitle": "Active modules", + "navigationLabel": "Einstellungsnavigation", + "sectionAdmin": "Administration", + "sectionCloudServices": "Cloud-Dienste", + "sectionModules": "Modules", + "sectionModulesNav": "Module", + "sectionOpenStandards": "CalDAV & CardDAV", + "sectionPersonal": "Persönlich", + "sectionSync": "Synchronisation", + "statusError": "Fehler", + "statusNeverSynced": "Noch nie synchronisiert", + "statusSynced": "Synchronisiert", + "statusSyncing": "Synchronisiert…", + "syncedAgo": "vor {{time}}", + "tabSyncCalendar": "Kalender", + "tabSyncContacts": "Kontakte" }, "login": { "tagline": "Familjeplanering. Säker. Sekretessvänlig. Öppen källkod.", @@ -1120,7 +1203,7 @@ "customWeeks": "Veckor" }, "onboarding": { - "step1Title": "Välkommen till Oikos", + "step1Title": "Välkommen till {{name}}", "step1Body": "Din personliga familjeplanerare. Uppgifter, kalender, shopping och mer – allt på ett ställe.", "step2Title": "Navigering och moduler", "step2Body": "Nere på skärmen når du direkt Översikt och Kalender. Med ···-knappen öppnar du fler moduler som Kök, Anteckningar och Kontakter.", @@ -1203,7 +1286,18 @@ }, "dropzoneTitle": "Släpp filen här eller klicka för att välja", "dropzoneHint": "Dra en fil till området eller använd filväljaren.", - "selectedFileLabel": "Vald: {{name}}" + "selectedFileLabel": "Vald: {{name}}", + "addFolderButton": "Add folder", + "allFolders": "All folders", + "folderLabel": "Folder", + "noFolder": "No folder", + "newFolderTitle": "New folder", + "folderNameLabel": "Folder name", + "createFolderAction": "Create folder", + "folderCreatedToast": "Folder created.", + "housekeepingFolder": "Städning", + "calendarItemsFolder": "Kalenderobjekt", + "folderBrowserTitle": "Bläddra bland mappar" }, "shortcuts": { "goKitchen": "Kök", @@ -1215,5 +1309,154 @@ "goCal": "Kalender", "goShop": "Inköpslista", "goNotes": "Anteckningar" + }, + "housekeeping": { + "title": "Städyta", + "bottomNav": "Städnavigation", + "home": "Hem", + "tasks": "Uppgifter", + "report": "Rapportera", + "notCheckedIn": "Inte incheckad", + "checkedInAt": "Incheckad kl.", + "monthTotal": "Aktuell månad · {{count}} pass", + "dailyRate": "Dagspris", + "extras": "Extra", + "checkIn": "Checka in", + "checkOut": "Checka ut", + "quickSupply": "Produkt saknas", + "supplyName": "Produkt", + "supplyPlaceholder": "Vad saknas?", + "checkedInToast": "Incheckning sparad.", + "checkedOutToast": "Utcheckning sparad.", + "supplyAddedToast": "Tillagd i inköpslistan.", + "overdue": "Försenad", + "dueToday": "Idag", + "ok": "OK", + "noTasks": "Inga städuppgifter ännu.", + "everyDays": "Var {{days}} dag", + "completeTask": "Slutför {{name}}", + "taskDoneToast": "Uppgift slutförd.", + "reportTitle": "Rapportera problem", + "problemDescription": "Problembeskrivning", + "problemPlaceholder": "Exempel: trasig lampa", + "addPhoto": "Lägg till foto", + "sendReport": "Skicka", + "reportSentToast": "Problem rapporterat.", + "recentReports": "Senaste rapporter", + "addTask": "Lägg till uppgift", + "taskName": "Uppgift", + "taskNamePlaceholder": "Exempel: städa badrum", + "taskArea": "Område", + "taskAreaPlaceholder": "Exempel: badrum", + "taskFrequency": "Frekvens", + "createTask": "Skapa uppgift", + "taskCreatedToast": "Städuppgift skapad.", + "dashboard": "Översikt", + "reports": "Reports", + "visitsThisMonth": "Besök denna månad", + "lastVisit": "Senaste besök", + "pendingChores": "Väntande uppgifter", + "finishedChores": "Klarmarkerade uppgifter", + "payments": "Betalningar", + "pendingPayments": "Väntande betalningar", + "monthlyPayments": "Månadsbetalningar", + "noPaymentData": "Inga betalningsdata ännu.", + "noVisits": "Inga besök ännu", + "noWorkerTitle": "Ingen städprofil", + "noWorkerHint": "Skapa profilen för kontakt, dagspris och betalningsschema.", + "taskTemplates": "Föreslagna uppgifter", + "addCustomTask": "Lägg till egen uppgift", + "noReports": "Inga rapporter ännu.", + "profileTitle": "Städprofil", + "profilePicture": "Profilbild", + "workerName": "Namn", + "workerUsername": "Användarnamn", + "workerPhone": "Telefon", + "workerEmail": "E-post", + "workerBirthDate": "Födelsedag", + "paymentSchedule": "Betalningsschema", + "scheduleDaily": "Varje besök", + "scheduleTwiceMonthly": "Två gånger per månad", + "scheduleMonthly": "Månadsvis", + "profileColor": "Profilfärg", + "workerNotes": "Anteckningar", + "workerSavedToast": "Städprofil sparad.", + "staff": "Personal", + "staffTitle": "Städpersonal", + "addWorker": "Lägg till person", + "editWorker": "Redigera person", + "noWorkers": "Ingen städpersonal registrerad.", + "moreWorkers": "+{{count}} fler", + "checkInDisabled": "Lägg till personal innan incheckning.", + "calendarColor": "Kalenderfärg", + "visitRecordedAt": "Visit recorded at", + "checkedInToday": "Recorded today", + "visitReports": "Staff visit reports", + "noVisitReports": "No staff visits recorded this month.", + "openVisitReport": "Open visit report", + "visitReportDetails": "Visit report", + "paymentPaid": "Paid", + "paymentPending": "Pending", + "totalPayment": "Total payment", + "paymentStatus": "Payment status", + "paymentTask": "Payment task", + "calendarEvent": "Calendar event", + "notAvailable": "Not available", + "calendarVisitTitle": "Housekeeping: {{name}}", + "paymentTaskTitle": "Pay {{name}} for housekeeping", + "paymentTaskDescription": "Housekeeping visit on {{date}}. Amount due: {{amount}}.", + "staffLogTitle": "{{name}} visits", + "staffLogHint": "Edit visit dates, amounts, and linked records.", + "filterMonth": "Month", + "editVisit": "Edit visit", + "deleteVisit": "Delete visit", + "deleteVisitConfirm": "Delete this visit? The linked calendar event and payment task will also be removed.", + "visitDeletedToast": "Visit deleted.", + "visitSavedToast": "Visit updated.", + "visitDate": "Visit date", + "markPaid": "Mark paid", + "visitPaidToast": "Payment marked as paid.", + "receiptUploadTitle": "Upload payment receipt", + "receiptUploadHint": "Attach a payment receipt. It will appear in Documents.", + "receiptDocumentName": "Receipt - {{name}} - {{date}}", + "receiptDocumentDescription": "Payment receipt for {{name}} housekeeping visit on {{date}}.", + "taskTemplateData": { + "cleanBathrooms": { + "name": "Städa badrum", + "area": "Badrum" + }, + "mopKitchenFloor": { + "name": "Moppa köksgolvet", + "area": "Kök" + }, + "dustLivingRoom": { + "name": "Damma vardagsrummet", + "area": "Vardagsrum" + }, + "changeBedLinens": { + "name": "Byt sängkläder", + "area": "Sovrum" + }, + "cleanRefrigerator": { + "name": "Rengör kylskåpet", + "area": "Kök" + }, + "cleanWindows": { + "name": "Putsa fönster", + "area": "Hela huset" + }, + "deepCleanOven": { + "name": "Djuprengör ugnen", + "area": "Kök" + }, + "washOutdoor": { + "name": "Tvätta balkong/uteplats", + "area": "Utomhus" + } + } + }, + "userMultiSelect": { + "moreUsers": "weitere", + "nobody": "- Niemand -" } -} \ No newline at end of file +} diff --git a/public/locales/tr.json b/public/locales/tr.json index f23f427..0750b50 100644 --- a/public/locales/tr.json +++ b/public/locales/tr.json @@ -54,7 +54,8 @@ "more": "Daha Fazla", "documents": "Belgeler", "kitchen": "Mutfak", - "search": "Ara" + "search": "Ara", + "housekeeping": "Housekeeping" }, "dashboard": { "title": "Genel Bakış", @@ -205,7 +206,18 @@ "kanbanArchived": "Arşivlenmiş", "reminderNeedsDueDate": "Görev hatırlatıcılarını etkinleştirmek için bir son tarih belirleyin.", "emptyAction": "Görev oluştur", - "navLabelOverdue": "Görevler, {{count}} gecikmiş" + "navLabelOverdue": "Görevler, {{count}} gecikmiş", + "bulkArchive": "Archive", + "bulkArchived": "Tasks archived.", + "bulkDelete": "Delete", + "bulkDeleteConfirm": "Delete {{count}} tasks permanently?", + "bulkDeleted": "Tasks deleted.", + "bulkMarkDone": "Mark done", + "bulkMarkOpen": "Mark open", + "bulkSelect": "Bulk select", + "bulkSelectedCount": "{{count}} selected", + "bulkStatusChanged": "Status changed.", + "selectTask": "Select task" }, "shopping": { "title": "Alışveriş", @@ -479,7 +491,13 @@ "colorPurple": "Mor", "colorRed": "Kırmızı", "colorSkyBlue": "Gökyüzü mavisi", - "colorYellow": "Sarı" + "colorYellow": "Sarı", + "iconCleaning": "Cleaning", + "caldavTargetHint": "Choose a CalDAV calendar to sync this event.", + "caldavTargetLabel": "Sync to CalDAV", + "caldavTargetLocal": "Store locally only", + "attachmentDocumentName": "{{title}} - {{name}}", + "attachmentDocumentDescription": "\"{{title}}\" takvim etkinliği için ek yüklendi." }, "notes": { "title": "Notlar", @@ -977,7 +995,72 @@ "addressbookEnabled": "Addressbook enabled", "addressbookDisabled": "Addressbook disabled", "addressbooksRefreshed": "Addressbooks refreshed", - "deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link." + "deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link.", + "sectionHousekeeping": "Housekeeping", + "housekeepingPaymentsTitle": "Payment tasks", + "housekeepingPaymentTasksLabel": "Create a payment task on each housekeeper check-in", + "housekeepingPaymentTasksHint": "When enabled, each check-in creates a task for paying the staff member. Completing that task marks the visit payment as paid.", + "housekeepingPaymentTasksSaved": "Housekeeping payment setting saved.", + "backupSchedulerDisabled": "Disabled", + "backupSchedulerEnabled": "Enabled", + "backupSchedulerHint": "Scheduled backups are created automatically and old backups are rotated.", + "backupSchedulerKeep": "Retention", + "backupSchedulerKeepCount": "{{count}} backups", + "backupSchedulerLastBackup": "Last backup", + "backupSchedulerLastFail": "{{date}} (failed)", + "backupSchedulerLastSuccess": "{{date}} (successful)", + "backupSchedulerNever": "No backup created yet", + "backupSchedulerSchedule": "Schedule", + "backupSchedulerStatus": "Status", + "backupSchedulerTitle": "Automatic Backups", + "backupSchedulerTrigger": "Create backup now", + "backupSchedulerTriggeredToast": "Backup created successfully.", + "backupSchedulerTriggering": "Creating backup...", + "breadcrumbLabel": "Pfad", + "caldavAccountAdded": "CalDAV account added successfully", + "caldavAccountDeleted": "CalDAV account removed", + "caldavAddAccount": "Add CalDAV Account", + "caldavCalendarsToggle": "Show/hide calendars", + "caldavConnectionFailed": "Connection to CalDAV server failed", + "caldavDescription": "Connect multiple CalDAV accounts (iCloud, Nextcloud, Radicale, Baikal, etc.) and choose which calendars to sync.", + "caldavEmptyState": "No CalDAV accounts connected yet. Add your first account to get started.", + "caldavNameLabel": "Account Name", + "caldavNamePlaceholder": "e.g. My Radicale, iCloud, Nextcloud", + "caldavPasswordHint": "For iCloud: Use app-specific password from appleid.apple.com", + "caldavPasswordLabel": "Password", + "caldavRefreshCalendars": "Refresh calendars", + "caldavSyncFailed": "CalDAV sync failed", + "caldavSyncSuccess": "CalDAV sync successful", + "caldavTitle": "CalDAV Calendars", + "caldavUrlHint": "The base URL of your CalDAV server", + "caldavUsernameLabel": "Username", + "calendarDisabled": "Calendar disabled", + "calendarEnabled": "Calendar enabled", + "calendarsRefreshed": "Calendars refreshed", + "deleteAccountConfirm": "Really delete CalDAV account? All synced calendars will be removed.", + "emptyStateAddFirst": "Füge dein erstes Konto hinzu", + "emptyStateNoAccounts": "Noch keine Konten verbunden", + "helpTooltipCalDAV": "CalDAV ermöglicht die Synchronisation von Kalendern mit iCloud, Nextcloud und anderen CalDAV-Servern.", + "helpTooltipCardDAV": "CardDAV ermöglicht die Synchronisation von Kontakten mit iCloud, Nextcloud und anderen CardDAV-Servern.", + "lastSync": "Last synced", + "modulesHint": "Disabled modules disappear from the navigation. Data is preserved and reappears once a module is re-enabled.", + "modulesSaved": "Module visibility saved.", + "modulesTitle": "Active modules", + "navigationLabel": "Einstellungsnavigation", + "sectionAdmin": "Administration", + "sectionCloudServices": "Cloud-Dienste", + "sectionModules": "Modules", + "sectionModulesNav": "Module", + "sectionOpenStandards": "CalDAV & CardDAV", + "sectionPersonal": "Persönlich", + "sectionSync": "Synchronisation", + "statusError": "Fehler", + "statusNeverSynced": "Noch nie synchronisiert", + "statusSynced": "Synchronisiert", + "statusSyncing": "Synchronisiert…", + "syncedAgo": "vor {{time}}", + "tabSyncCalendar": "Kalender", + "tabSyncContacts": "Kontakte" }, "login": { "tagline": "Aile planlaması. Güvenli. Gizlilik dostu. Açık kaynak.", @@ -1120,7 +1203,7 @@ "customWeeks": "Weeks" }, "onboarding": { - "step1Title": "Welcome to Oikos", + "step1Title": "{{name}} uygulamasına hoş geldiniz", "step1Body": "Your personal family planner. Tasks, calendar, shopping and more – all in one place.", "step2Title": "Gezinme ve Modüller", "step2Body": "Aşağıda Gösterge Paneli ve Takvim'e doğrudan erişebilirsiniz. ···-düğmesiyle Mutfak, Notlar ve Kişiler gibi ek modülleri açabilirsiniz.", @@ -1203,7 +1286,18 @@ }, "dropzoneTitle": "Dosyayı buraya bırakın veya seçmek için tıklayın", "dropzoneHint": "Bir dosyayı bu alana sürükleyin veya dosya seçiciyi kullanın.", - "selectedFileLabel": "Seçildi: {{name}}" + "selectedFileLabel": "Seçildi: {{name}}", + "addFolderButton": "Add folder", + "allFolders": "All folders", + "folderLabel": "Folder", + "noFolder": "No folder", + "newFolderTitle": "New folder", + "folderNameLabel": "Folder name", + "createFolderAction": "Create folder", + "folderCreatedToast": "Folder created.", + "housekeepingFolder": "Ev temizliği", + "calendarItemsFolder": "Takvim öğeleri", + "folderBrowserTitle": "Klasörlere göz at" }, "shortcuts": { "goKitchen": "Mutfak", @@ -1215,5 +1309,154 @@ "help": "Klavye kısayolları", "new": "Yeni giriş oluştur", "search": "Aramayı aç" + }, + "housekeeping": { + "title": "Cleaner workspace", + "bottomNav": "Housekeeping navigation", + "home": "Home", + "tasks": "Tasks", + "report": "Report", + "notCheckedIn": "Not checked in", + "checkedInAt": "Checked in at", + "monthTotal": "Current month · {{count}} sessions", + "dailyRate": "Daily rate", + "extras": "Extras", + "checkIn": "Check in", + "checkOut": "Check out", + "quickSupply": "Missing product", + "supplyName": "Product name", + "supplyPlaceholder": "What is missing?", + "checkedInToast": "Check-in recorded.", + "checkedOutToast": "Check-out recorded.", + "supplyAddedToast": "Added to the shopping list.", + "overdue": "Overdue", + "dueToday": "Due today", + "ok": "OK", + "noTasks": "No housekeeping tasks yet.", + "everyDays": "Every {{days}} days", + "completeTask": "Complete {{name}}", + "taskDoneToast": "Task completed.", + "reportTitle": "Report a problem", + "problemDescription": "Problem description", + "problemPlaceholder": "Example: burnt-out light bulb", + "addPhoto": "Add photo", + "sendReport": "Send report", + "reportSentToast": "Problem reported.", + "recentReports": "Recent reports", + "addTask": "Add task", + "taskName": "Task", + "taskNamePlaceholder": "Example: Clean bathrooms", + "taskArea": "Area", + "taskAreaPlaceholder": "Example: Bathroom", + "taskFrequency": "Frequency", + "createTask": "Create task", + "taskCreatedToast": "Housekeeping task created.", + "dashboard": "Dashboard", + "reports": "Reports", + "visitsThisMonth": "Visits this month", + "lastVisit": "Last visit", + "pendingChores": "Pending chores", + "finishedChores": "Finished chores", + "payments": "Payments", + "pendingPayments": "Pending payments", + "monthlyPayments": "Monthly payments", + "noPaymentData": "No payment data yet.", + "noVisits": "No visits yet", + "noWorkerTitle": "No housekeeper profile", + "noWorkerHint": "Create the worker profile to define contacts, rate, and payment schedule.", + "taskTemplates": "Suggested chores", + "addCustomTask": "Add custom chore", + "noReports": "No reports yet.", + "profileTitle": "Housekeeper profile", + "profilePicture": "Housekeeper profile picture", + "workerName": "Name", + "workerUsername": "Username", + "workerPhone": "Phone", + "workerEmail": "Email", + "workerBirthDate": "Birthday", + "paymentSchedule": "Payment schedule", + "scheduleDaily": "Every visit", + "scheduleTwiceMonthly": "Twice a month", + "scheduleMonthly": "Monthly", + "profileColor": "Profile color", + "workerNotes": "Notes", + "workerSavedToast": "Housekeeper profile saved.", + "staff": "Staff", + "staffTitle": "Housekeeping staff", + "addWorker": "Add housekeeper", + "editWorker": "Edit housekeeper", + "noWorkers": "No housekeepers registered yet.", + "moreWorkers": "+{{count}} more", + "checkInDisabled": "Add a housekeeper before checking in.", + "calendarColor": "Calendar color", + "visitRecordedAt": "Visit recorded at", + "checkedInToday": "Recorded today", + "visitReports": "Staff visit reports", + "noVisitReports": "No staff visits recorded this month.", + "openVisitReport": "Open visit report", + "visitReportDetails": "Visit report", + "paymentPaid": "Paid", + "paymentPending": "Pending", + "totalPayment": "Total payment", + "paymentStatus": "Payment status", + "paymentTask": "Payment task", + "calendarEvent": "Calendar event", + "notAvailable": "Not available", + "calendarVisitTitle": "Housekeeping: {{name}}", + "paymentTaskTitle": "Pay {{name}} for housekeeping", + "paymentTaskDescription": "Housekeeping visit on {{date}}. Amount due: {{amount}}.", + "staffLogTitle": "{{name}} visits", + "staffLogHint": "Edit visit dates, amounts, and linked records.", + "filterMonth": "Month", + "editVisit": "Edit visit", + "deleteVisit": "Delete visit", + "deleteVisitConfirm": "Delete this visit? The linked calendar event and payment task will also be removed.", + "visitDeletedToast": "Visit deleted.", + "visitSavedToast": "Visit updated.", + "visitDate": "Visit date", + "markPaid": "Mark paid", + "visitPaidToast": "Payment marked as paid.", + "receiptUploadTitle": "Upload payment receipt", + "receiptUploadHint": "Attach a payment receipt. It will appear in Documents.", + "receiptDocumentName": "Receipt - {{name}} - {{date}}", + "receiptDocumentDescription": "Payment receipt for {{name}} housekeeping visit on {{date}}.", + "taskTemplateData": { + "cleanBathrooms": { + "name": "Banyoları temizle", + "area": "Banyolar" + }, + "mopKitchenFloor": { + "name": "Mutfak zeminini sil", + "area": "Mutfak" + }, + "dustLivingRoom": { + "name": "Oturma odasının tozunu al", + "area": "Oturma odası" + }, + "changeBedLinens": { + "name": "Nevresimleri değiştir", + "area": "Yatak odaları" + }, + "cleanRefrigerator": { + "name": "Buzdolabını temizle", + "area": "Mutfak" + }, + "cleanWindows": { + "name": "Camları temizle", + "area": "Tüm ev" + }, + "deepCleanOven": { + "name": "Fırını derinlemesine temizle", + "area": "Mutfak" + }, + "washOutdoor": { + "name": "Balkon/verandayı yıka", + "area": "Dış alan" + } + } + }, + "userMultiSelect": { + "moreUsers": "weitere", + "nobody": "- Niemand -" } -} \ No newline at end of file +} diff --git a/public/locales/uk.json b/public/locales/uk.json index a3c1471..015686c 100644 --- a/public/locales/uk.json +++ b/public/locales/uk.json @@ -54,7 +54,8 @@ "more": "Більше", "documents": "Документи", "kitchen": "Кухня", - "search": "Пошук" + "search": "Пошук", + "housekeeping": "Housekeeping" }, "dashboard": { "title": "Огляд", @@ -205,7 +206,18 @@ "kanbanArchived": "Архівовано", "reminderNeedsDueDate": "Встановіть дату виконання, щоб увімкнути нагадування про завдання.", "emptyAction": "Створити завдання", - "navLabelOverdue": "Завдання, {{count}} прострочено" + "navLabelOverdue": "Завдання, {{count}} прострочено", + "bulkArchive": "Archive", + "bulkArchived": "Tasks archived.", + "bulkDelete": "Delete", + "bulkDeleteConfirm": "Delete {{count}} tasks permanently?", + "bulkDeleted": "Tasks deleted.", + "bulkMarkDone": "Mark done", + "bulkMarkOpen": "Mark open", + "bulkSelect": "Bulk select", + "bulkSelectedCount": "{{count}} selected", + "bulkStatusChanged": "Status changed.", + "selectTask": "Select task" }, "shopping": { "title": "Покупки", @@ -479,7 +491,13 @@ "colorPurple": "Фіолетовий", "colorRed": "Червоний", "colorSkyBlue": "Небесно-блакитний", - "colorYellow": "Жовтий" + "colorYellow": "Жовтий", + "iconCleaning": "Cleaning", + "caldavTargetHint": "Choose a CalDAV calendar to sync this event.", + "caldavTargetLabel": "Sync to CalDAV", + "caldavTargetLocal": "Store locally only", + "attachmentDocumentName": "{{title}} - {{name}}", + "attachmentDocumentDescription": "Вкладення завантажено для події календаря «{{title}}»." }, "notes": { "title": "Нотатки", @@ -977,7 +995,72 @@ "addressbookEnabled": "Addressbook enabled", "addressbookDisabled": "Addressbook disabled", "addressbooksRefreshed": "Addressbooks refreshed", - "deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link." + "deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link.", + "sectionHousekeeping": "Housekeeping", + "housekeepingPaymentsTitle": "Payment tasks", + "housekeepingPaymentTasksLabel": "Create a payment task on each housekeeper check-in", + "housekeepingPaymentTasksHint": "When enabled, each check-in creates a task for paying the staff member. Completing that task marks the visit payment as paid.", + "housekeepingPaymentTasksSaved": "Housekeeping payment setting saved.", + "backupSchedulerDisabled": "Disabled", + "backupSchedulerEnabled": "Enabled", + "backupSchedulerHint": "Scheduled backups are created automatically and old backups are rotated.", + "backupSchedulerKeep": "Retention", + "backupSchedulerKeepCount": "{{count}} backups", + "backupSchedulerLastBackup": "Last backup", + "backupSchedulerLastFail": "{{date}} (failed)", + "backupSchedulerLastSuccess": "{{date}} (successful)", + "backupSchedulerNever": "No backup created yet", + "backupSchedulerSchedule": "Schedule", + "backupSchedulerStatus": "Status", + "backupSchedulerTitle": "Automatic Backups", + "backupSchedulerTrigger": "Create backup now", + "backupSchedulerTriggeredToast": "Backup created successfully.", + "backupSchedulerTriggering": "Creating backup...", + "breadcrumbLabel": "Pfad", + "caldavAccountAdded": "CalDAV account added successfully", + "caldavAccountDeleted": "CalDAV account removed", + "caldavAddAccount": "Add CalDAV Account", + "caldavCalendarsToggle": "Show/hide calendars", + "caldavConnectionFailed": "Connection to CalDAV server failed", + "caldavDescription": "Connect multiple CalDAV accounts (iCloud, Nextcloud, Radicale, Baikal, etc.) and choose which calendars to sync.", + "caldavEmptyState": "No CalDAV accounts connected yet. Add your first account to get started.", + "caldavNameLabel": "Account Name", + "caldavNamePlaceholder": "e.g. My Radicale, iCloud, Nextcloud", + "caldavPasswordHint": "For iCloud: Use app-specific password from appleid.apple.com", + "caldavPasswordLabel": "Password", + "caldavRefreshCalendars": "Refresh calendars", + "caldavSyncFailed": "CalDAV sync failed", + "caldavSyncSuccess": "CalDAV sync successful", + "caldavTitle": "CalDAV Calendars", + "caldavUrlHint": "The base URL of your CalDAV server", + "caldavUsernameLabel": "Username", + "calendarDisabled": "Calendar disabled", + "calendarEnabled": "Calendar enabled", + "calendarsRefreshed": "Calendars refreshed", + "deleteAccountConfirm": "Really delete CalDAV account? All synced calendars will be removed.", + "emptyStateAddFirst": "Füge dein erstes Konto hinzu", + "emptyStateNoAccounts": "Noch keine Konten verbunden", + "helpTooltipCalDAV": "CalDAV ermöglicht die Synchronisation von Kalendern mit iCloud, Nextcloud und anderen CalDAV-Servern.", + "helpTooltipCardDAV": "CardDAV ermöglicht die Synchronisation von Kontakten mit iCloud, Nextcloud und anderen CardDAV-Servern.", + "lastSync": "Last synced", + "modulesHint": "Disabled modules disappear from the navigation. Data is preserved and reappears once a module is re-enabled.", + "modulesSaved": "Module visibility saved.", + "modulesTitle": "Active modules", + "navigationLabel": "Einstellungsnavigation", + "sectionAdmin": "Administration", + "sectionCloudServices": "Cloud-Dienste", + "sectionModules": "Modules", + "sectionModulesNav": "Module", + "sectionOpenStandards": "CalDAV & CardDAV", + "sectionPersonal": "Persönlich", + "sectionSync": "Synchronisation", + "statusError": "Fehler", + "statusNeverSynced": "Noch nie synchronisiert", + "statusSynced": "Synchronisiert", + "statusSyncing": "Synchronisiert…", + "syncedAgo": "vor {{time}}", + "tabSyncCalendar": "Kalender", + "tabSyncContacts": "Kontakte" }, "login": { "tagline": "Планування для родини. Безпечно. Конфіденційно. Відкритий код.", @@ -1120,7 +1203,7 @@ "photoOptional": "Необов'язково: можна зберегти без фото профілю." }, "onboarding": { - "step1Title": "Welcome to Oikos", + "step1Title": "Ласкаво просимо до {{name}}", "step1Body": "Your personal family planner. Tasks, calendar, shopping and more – all in one place.", "step2Title": "Навігація та модулі", "step2Body": "Унизу ви маєте прямий доступ до Панелі керування та Календаря. Кнопка ··· відкриває додаткові модулі: Кухня, Нотатки та Контакти.", @@ -1203,7 +1286,18 @@ }, "dropzoneTitle": "Перетягніть файл сюди або натисніть для вибору", "dropzoneHint": "Перетягніть файл у цю область або скористайтеся вибором файлу.", - "selectedFileLabel": "Вибрано: {{name}}" + "selectedFileLabel": "Вибрано: {{name}}", + "addFolderButton": "Add folder", + "allFolders": "All folders", + "folderLabel": "Folder", + "noFolder": "No folder", + "newFolderTitle": "New folder", + "folderNameLabel": "Folder name", + "createFolderAction": "Create folder", + "folderCreatedToast": "Folder created.", + "housekeepingFolder": "Прибирання", + "calendarItemsFolder": "Елементи календаря", + "folderBrowserTitle": "Перегляд папок" }, "shortcuts": { "goKitchen": "Кухня", @@ -1215,5 +1309,154 @@ "help": "Гарячі клавіші", "new": "Створити новий запис", "search": "Відкрити пошук" + }, + "housekeeping": { + "title": "Cleaner workspace", + "bottomNav": "Housekeeping navigation", + "home": "Home", + "tasks": "Tasks", + "report": "Report", + "notCheckedIn": "Not checked in", + "checkedInAt": "Checked in at", + "monthTotal": "Current month · {{count}} sessions", + "dailyRate": "Daily rate", + "extras": "Extras", + "checkIn": "Check in", + "checkOut": "Check out", + "quickSupply": "Missing product", + "supplyName": "Product name", + "supplyPlaceholder": "What is missing?", + "checkedInToast": "Check-in recorded.", + "checkedOutToast": "Check-out recorded.", + "supplyAddedToast": "Added to the shopping list.", + "overdue": "Overdue", + "dueToday": "Due today", + "ok": "OK", + "noTasks": "No housekeeping tasks yet.", + "everyDays": "Every {{days}} days", + "completeTask": "Complete {{name}}", + "taskDoneToast": "Task completed.", + "reportTitle": "Report a problem", + "problemDescription": "Problem description", + "problemPlaceholder": "Example: burnt-out light bulb", + "addPhoto": "Add photo", + "sendReport": "Send report", + "reportSentToast": "Problem reported.", + "recentReports": "Recent reports", + "addTask": "Add task", + "taskName": "Task", + "taskNamePlaceholder": "Example: Clean bathrooms", + "taskArea": "Area", + "taskAreaPlaceholder": "Example: Bathroom", + "taskFrequency": "Frequency", + "createTask": "Create task", + "taskCreatedToast": "Housekeeping task created.", + "dashboard": "Dashboard", + "reports": "Reports", + "visitsThisMonth": "Visits this month", + "lastVisit": "Last visit", + "pendingChores": "Pending chores", + "finishedChores": "Finished chores", + "payments": "Payments", + "pendingPayments": "Pending payments", + "monthlyPayments": "Monthly payments", + "noPaymentData": "No payment data yet.", + "noVisits": "No visits yet", + "noWorkerTitle": "No housekeeper profile", + "noWorkerHint": "Create the worker profile to define contacts, rate, and payment schedule.", + "taskTemplates": "Suggested chores", + "addCustomTask": "Add custom chore", + "noReports": "No reports yet.", + "profileTitle": "Housekeeper profile", + "profilePicture": "Housekeeper profile picture", + "workerName": "Name", + "workerUsername": "Username", + "workerPhone": "Phone", + "workerEmail": "Email", + "workerBirthDate": "Birthday", + "paymentSchedule": "Payment schedule", + "scheduleDaily": "Every visit", + "scheduleTwiceMonthly": "Twice a month", + "scheduleMonthly": "Monthly", + "profileColor": "Profile color", + "workerNotes": "Notes", + "workerSavedToast": "Housekeeper profile saved.", + "staff": "Staff", + "staffTitle": "Housekeeping staff", + "addWorker": "Add housekeeper", + "editWorker": "Edit housekeeper", + "noWorkers": "No housekeepers registered yet.", + "moreWorkers": "+{{count}} more", + "checkInDisabled": "Add a housekeeper before checking in.", + "calendarColor": "Calendar color", + "visitRecordedAt": "Visit recorded at", + "checkedInToday": "Recorded today", + "visitReports": "Staff visit reports", + "noVisitReports": "No staff visits recorded this month.", + "openVisitReport": "Open visit report", + "visitReportDetails": "Visit report", + "paymentPaid": "Paid", + "paymentPending": "Pending", + "totalPayment": "Total payment", + "paymentStatus": "Payment status", + "paymentTask": "Payment task", + "calendarEvent": "Calendar event", + "notAvailable": "Not available", + "calendarVisitTitle": "Housekeeping: {{name}}", + "paymentTaskTitle": "Pay {{name}} for housekeeping", + "paymentTaskDescription": "Housekeeping visit on {{date}}. Amount due: {{amount}}.", + "staffLogTitle": "{{name}} visits", + "staffLogHint": "Edit visit dates, amounts, and linked records.", + "filterMonth": "Month", + "editVisit": "Edit visit", + "deleteVisit": "Delete visit", + "deleteVisitConfirm": "Delete this visit? The linked calendar event and payment task will also be removed.", + "visitDeletedToast": "Visit deleted.", + "visitSavedToast": "Visit updated.", + "visitDate": "Visit date", + "markPaid": "Mark paid", + "visitPaidToast": "Payment marked as paid.", + "receiptUploadTitle": "Upload payment receipt", + "receiptUploadHint": "Attach a payment receipt. It will appear in Documents.", + "receiptDocumentName": "Receipt - {{name}} - {{date}}", + "receiptDocumentDescription": "Payment receipt for {{name}} housekeeping visit on {{date}}.", + "taskTemplateData": { + "cleanBathrooms": { + "name": "Прибрати ванні кімнати", + "area": "Ванні кімнати" + }, + "mopKitchenFloor": { + "name": "Помити підлогу на кухні", + "area": "Кухня" + }, + "dustLivingRoom": { + "name": "Витерти пил у вітальні", + "area": "Вітальня" + }, + "changeBedLinens": { + "name": "Змінити постільну білизну", + "area": "Спальні" + }, + "cleanRefrigerator": { + "name": "Почистити холодильник", + "area": "Кухня" + }, + "cleanWindows": { + "name": "Помити вікна", + "area": "Увесь дім" + }, + "deepCleanOven": { + "name": "Глибоко очистити духовку", + "area": "Кухня" + }, + "washOutdoor": { + "name": "Помити балкон/патіо", + "area": "Надворі" + } + } + }, + "userMultiSelect": { + "moreUsers": "weitere", + "nobody": "- Niemand -" } -} \ No newline at end of file +} diff --git a/public/locales/zh.json b/public/locales/zh.json index 5e629f9..2ce9ffb 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -54,7 +54,8 @@ "more": "更多", "documents": "文档", "kitchen": "厨房", - "search": "搜索" + "search": "搜索", + "housekeeping": "Housekeeping" }, "dashboard": { "title": "概览", @@ -205,7 +206,18 @@ "kanbanArchived": "已归档", "reminderNeedsDueDate": "设置截止日期以启用任务提醒。", "emptyAction": "创建任务", - "navLabelOverdue": "任务,{{count}} 项已逾期" + "navLabelOverdue": "任务,{{count}} 项已逾期", + "bulkArchive": "Archive", + "bulkArchived": "Tasks archived.", + "bulkDelete": "Delete", + "bulkDeleteConfirm": "Delete {{count}} tasks permanently?", + "bulkDeleted": "Tasks deleted.", + "bulkMarkDone": "Mark done", + "bulkMarkOpen": "Mark open", + "bulkSelect": "Bulk select", + "bulkSelectedCount": "{{count}} selected", + "bulkStatusChanged": "Status changed.", + "selectTask": "Select task" }, "shopping": { "title": "购物", @@ -479,7 +491,13 @@ "colorPurple": "紫色", "colorRed": "红色", "colorSkyBlue": "天蓝色", - "colorYellow": "黄色" + "colorYellow": "黄色", + "iconCleaning": "Cleaning", + "caldavTargetHint": "Choose a CalDAV calendar to sync this event.", + "caldavTargetLabel": "Sync to CalDAV", + "caldavTargetLocal": "Store locally only", + "attachmentDocumentName": "{{title}} - {{name}}", + "attachmentDocumentDescription": "为日历事件“{{title}}”上传的附件。" }, "notes": { "title": "便签板", @@ -977,7 +995,72 @@ "addressbookEnabled": "Addressbook enabled", "addressbookDisabled": "Addressbook disabled", "addressbooksRefreshed": "Addressbooks refreshed", - "deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link." + "deleteCardDAVAccountConfirm": "Really delete CardDAV account? All synced contacts will remain but lose their CardDAV link.", + "sectionHousekeeping": "Housekeeping", + "housekeepingPaymentsTitle": "Payment tasks", + "housekeepingPaymentTasksLabel": "Create a payment task on each housekeeper check-in", + "housekeepingPaymentTasksHint": "When enabled, each check-in creates a task for paying the staff member. Completing that task marks the visit payment as paid.", + "housekeepingPaymentTasksSaved": "Housekeeping payment setting saved.", + "backupSchedulerDisabled": "Disabled", + "backupSchedulerEnabled": "Enabled", + "backupSchedulerHint": "Scheduled backups are created automatically and old backups are rotated.", + "backupSchedulerKeep": "Retention", + "backupSchedulerKeepCount": "{{count}} backups", + "backupSchedulerLastBackup": "Last backup", + "backupSchedulerLastFail": "{{date}} (failed)", + "backupSchedulerLastSuccess": "{{date}} (successful)", + "backupSchedulerNever": "No backup created yet", + "backupSchedulerSchedule": "Schedule", + "backupSchedulerStatus": "Status", + "backupSchedulerTitle": "Automatic Backups", + "backupSchedulerTrigger": "Create backup now", + "backupSchedulerTriggeredToast": "Backup created successfully.", + "backupSchedulerTriggering": "Creating backup...", + "breadcrumbLabel": "Pfad", + "caldavAccountAdded": "CalDAV account added successfully", + "caldavAccountDeleted": "CalDAV account removed", + "caldavAddAccount": "Add CalDAV Account", + "caldavCalendarsToggle": "Show/hide calendars", + "caldavConnectionFailed": "Connection to CalDAV server failed", + "caldavDescription": "Connect multiple CalDAV accounts (iCloud, Nextcloud, Radicale, Baikal, etc.) and choose which calendars to sync.", + "caldavEmptyState": "No CalDAV accounts connected yet. Add your first account to get started.", + "caldavNameLabel": "Account Name", + "caldavNamePlaceholder": "e.g. My Radicale, iCloud, Nextcloud", + "caldavPasswordHint": "For iCloud: Use app-specific password from appleid.apple.com", + "caldavPasswordLabel": "Password", + "caldavRefreshCalendars": "Refresh calendars", + "caldavSyncFailed": "CalDAV sync failed", + "caldavSyncSuccess": "CalDAV sync successful", + "caldavTitle": "CalDAV Calendars", + "caldavUrlHint": "The base URL of your CalDAV server", + "caldavUsernameLabel": "Username", + "calendarDisabled": "Calendar disabled", + "calendarEnabled": "Calendar enabled", + "calendarsRefreshed": "Calendars refreshed", + "deleteAccountConfirm": "Really delete CalDAV account? All synced calendars will be removed.", + "emptyStateAddFirst": "Füge dein erstes Konto hinzu", + "emptyStateNoAccounts": "Noch keine Konten verbunden", + "helpTooltipCalDAV": "CalDAV ermöglicht die Synchronisation von Kalendern mit iCloud, Nextcloud und anderen CalDAV-Servern.", + "helpTooltipCardDAV": "CardDAV ermöglicht die Synchronisation von Kontakten mit iCloud, Nextcloud und anderen CardDAV-Servern.", + "lastSync": "Last synced", + "modulesHint": "Disabled modules disappear from the navigation. Data is preserved and reappears once a module is re-enabled.", + "modulesSaved": "Module visibility saved.", + "modulesTitle": "Active modules", + "navigationLabel": "Einstellungsnavigation", + "sectionAdmin": "Administration", + "sectionCloudServices": "Cloud-Dienste", + "sectionModules": "Modules", + "sectionModulesNav": "Module", + "sectionOpenStandards": "CalDAV & CardDAV", + "sectionPersonal": "Persönlich", + "sectionSync": "Synchronisation", + "statusError": "Fehler", + "statusNeverSynced": "Noch nie synchronisiert", + "statusSynced": "Synchronisiert", + "statusSyncing": "Synchronisiert…", + "syncedAgo": "vor {{time}}", + "tabSyncCalendar": "Kalender", + "tabSyncContacts": "Kontakte" }, "login": { "tagline": "家庭规划。安全。注重隐私。开源。", @@ -1120,7 +1203,7 @@ "customWeeks": "Weeks" }, "onboarding": { - "step1Title": "Welcome to Oikos", + "step1Title": "欢迎使用{{name}}", "step1Body": "Your personal family planner. Tasks, calendar, shopping and more – all in one place.", "step2Title": "导航与模块", "step2Body": "底部可直接访问仪表板和日历。点击···按钮可打开厨房、笔记和联系人等更多模块。", @@ -1203,7 +1286,18 @@ }, "dropzoneTitle": "将文件拖到此处或点击选择", "dropzoneHint": "将文件拖入此区域,或使用文件选择器。", - "selectedFileLabel": "已选择:{{name}}" + "selectedFileLabel": "已选择:{{name}}", + "addFolderButton": "Add folder", + "allFolders": "All folders", + "folderLabel": "Folder", + "noFolder": "No folder", + "newFolderTitle": "New folder", + "folderNameLabel": "Folder name", + "createFolderAction": "Create folder", + "folderCreatedToast": "Folder created.", + "housekeepingFolder": "家政清洁", + "calendarItemsFolder": "日历项目", + "folderBrowserTitle": "浏览文件夹" }, "shortcuts": { "goKitchen": "厨房", @@ -1215,5 +1309,154 @@ "help": "键盘快捷键", "new": "创建新条目", "search": "打开搜索" + }, + "housekeeping": { + "title": "Cleaner workspace", + "bottomNav": "Housekeeping navigation", + "home": "Home", + "tasks": "Tasks", + "report": "Report", + "notCheckedIn": "Not checked in", + "checkedInAt": "Checked in at", + "monthTotal": "Current month · {{count}} sessions", + "dailyRate": "Daily rate", + "extras": "Extras", + "checkIn": "Check in", + "checkOut": "Check out", + "quickSupply": "Missing product", + "supplyName": "Product name", + "supplyPlaceholder": "What is missing?", + "checkedInToast": "Check-in recorded.", + "checkedOutToast": "Check-out recorded.", + "supplyAddedToast": "Added to the shopping list.", + "overdue": "Overdue", + "dueToday": "Due today", + "ok": "OK", + "noTasks": "No housekeeping tasks yet.", + "everyDays": "Every {{days}} days", + "completeTask": "Complete {{name}}", + "taskDoneToast": "Task completed.", + "reportTitle": "Report a problem", + "problemDescription": "Problem description", + "problemPlaceholder": "Example: burnt-out light bulb", + "addPhoto": "Add photo", + "sendReport": "Send report", + "reportSentToast": "Problem reported.", + "recentReports": "Recent reports", + "addTask": "Add task", + "taskName": "Task", + "taskNamePlaceholder": "Example: Clean bathrooms", + "taskArea": "Area", + "taskAreaPlaceholder": "Example: Bathroom", + "taskFrequency": "Frequency", + "createTask": "Create task", + "taskCreatedToast": "Housekeeping task created.", + "dashboard": "Dashboard", + "reports": "Reports", + "visitsThisMonth": "Visits this month", + "lastVisit": "Last visit", + "pendingChores": "Pending chores", + "finishedChores": "Finished chores", + "payments": "Payments", + "pendingPayments": "Pending payments", + "monthlyPayments": "Monthly payments", + "noPaymentData": "No payment data yet.", + "noVisits": "No visits yet", + "noWorkerTitle": "No housekeeper profile", + "noWorkerHint": "Create the worker profile to define contacts, rate, and payment schedule.", + "taskTemplates": "Suggested chores", + "addCustomTask": "Add custom chore", + "noReports": "No reports yet.", + "profileTitle": "Housekeeper profile", + "profilePicture": "Housekeeper profile picture", + "workerName": "Name", + "workerUsername": "Username", + "workerPhone": "Phone", + "workerEmail": "Email", + "workerBirthDate": "Birthday", + "paymentSchedule": "Payment schedule", + "scheduleDaily": "Every visit", + "scheduleTwiceMonthly": "Twice a month", + "scheduleMonthly": "Monthly", + "profileColor": "Profile color", + "workerNotes": "Notes", + "workerSavedToast": "Housekeeper profile saved.", + "staff": "Staff", + "staffTitle": "Housekeeping staff", + "addWorker": "Add housekeeper", + "editWorker": "Edit housekeeper", + "noWorkers": "No housekeepers registered yet.", + "moreWorkers": "+{{count}} more", + "checkInDisabled": "Add a housekeeper before checking in.", + "calendarColor": "Calendar color", + "visitRecordedAt": "Visit recorded at", + "checkedInToday": "Recorded today", + "visitReports": "Staff visit reports", + "noVisitReports": "No staff visits recorded this month.", + "openVisitReport": "Open visit report", + "visitReportDetails": "Visit report", + "paymentPaid": "Paid", + "paymentPending": "Pending", + "totalPayment": "Total payment", + "paymentStatus": "Payment status", + "paymentTask": "Payment task", + "calendarEvent": "Calendar event", + "notAvailable": "Not available", + "calendarVisitTitle": "Housekeeping: {{name}}", + "paymentTaskTitle": "Pay {{name}} for housekeeping", + "paymentTaskDescription": "Housekeeping visit on {{date}}. Amount due: {{amount}}.", + "staffLogTitle": "{{name}} visits", + "staffLogHint": "Edit visit dates, amounts, and linked records.", + "filterMonth": "Month", + "editVisit": "Edit visit", + "deleteVisit": "Delete visit", + "deleteVisitConfirm": "Delete this visit? The linked calendar event and payment task will also be removed.", + "visitDeletedToast": "Visit deleted.", + "visitSavedToast": "Visit updated.", + "visitDate": "Visit date", + "markPaid": "Mark paid", + "visitPaidToast": "Payment marked as paid.", + "receiptUploadTitle": "Upload payment receipt", + "receiptUploadHint": "Attach a payment receipt. It will appear in Documents.", + "receiptDocumentName": "Receipt - {{name}} - {{date}}", + "receiptDocumentDescription": "Payment receipt for {{name}} housekeeping visit on {{date}}.", + "taskTemplateData": { + "cleanBathrooms": { + "name": "清洁浴室", + "area": "浴室" + }, + "mopKitchenFloor": { + "name": "拖厨房地板", + "area": "厨房" + }, + "dustLivingRoom": { + "name": "客厅除尘", + "area": "客厅" + }, + "changeBedLinens": { + "name": "更换床上用品", + "area": "卧室" + }, + "cleanRefrigerator": { + "name": "清洁冰箱", + "area": "厨房" + }, + "cleanWindows": { + "name": "擦窗户", + "area": "全屋" + }, + "deepCleanOven": { + "name": "深度清洁烤箱", + "area": "厨房" + }, + "washOutdoor": { + "name": "清洗阳台/露台", + "area": "户外" + } + } + }, + "userMultiSelect": { + "moreUsers": "weitere", + "nobody": "- Niemand -" } -} \ No newline at end of file +} diff --git a/public/pages/calendar.js b/public/pages/calendar.js index fffc6b0..2ec6790 100644 --- a/public/pages/calendar.js +++ b/public/pages/calendar.js @@ -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 `
${esc(t('calendar.iconSearchEmpty'))}
`; + } + return ` +
+ ${filtered.map((icon) => iconPickerOptionHtml(icon, selectedIcon)).join('')} +
`; + } + return EVENT_ICON_CATEGORIES().map((cat) => ` +
+
${esc(cat.label)}
+
+ ${cat.icons.map((icon) => iconPickerOptionHtml(icon, selectedIcon)).join('')} +
+
`).join(''); +} + +function iconPickerOptionHtml(icon, selectedIcon) { + return ` + `; +} + +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', ` + + + `); + + 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) => ` -
-
${esc(cat.label)}
-
- ${cat.icons.map((icon) => ` - `).join('')} -
-
`).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', `
${esc(t('calendar.iconSearchEmpty'))}
`); - return; - } - resultsEl.insertAdjacentHTML('afterbegin', ` -
- ${filtered.map((icon) => ` - `).join('')} -
`); - 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) => ` -
-
${esc(cat.label)}
-
- ${cat.icons.map((icon) => ` - `).join('')} -
-
`).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 : '')}"> - -
+
+ + +
@@ -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: ` +
+
+ + +
+ + +
+ `, + 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(); diff --git a/public/pages/housekeeping.js b/public/pages/housekeeping.js new file mode 100644 index 0000000..0e7d6d8 --- /dev/null +++ b/public/pages/housekeeping.js @@ -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 ` + + `; +} + +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; + 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 ` +
+ +
+

${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 || 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 ` +
+
+ ${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)}
+
+
+ `); + } +} diff --git a/public/pages/settings.js b/public/pages/settings.js index 4864b82..1499f0b 100644 --- a/public/pages/settings.js +++ b/public/pages/settings.js @@ -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 }) {
` : ''} + + ${user?.role === 'admin' ? ` +
+

${t('settings.sectionHousekeeping')}

+
+

${t('settings.housekeepingPaymentsTitle')}

+

${t('settings.housekeepingPaymentTasksHint')}

+ +
+
+ ` : ''} @@ -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) => { diff --git a/public/router.js b/public/router.js index 55149fd..3dc30fd 100644 --- a/public/router.js +++ b/public/router.js @@ -27,6 +27,7 @@ const ROUTES = [ { path: '/contacts', page: '/pages/contacts.js', requiresAuth: true, module: 'contacts' }, { path: '/budget', page: '/pages/budget.js', requiresAuth: true, module: 'budget' }, { path: '/documents', page: '/pages/documents.js', requiresAuth: true, module: 'documents' }, + { path: '/housekeeping', page: '/pages/housekeeping.js', requiresAuth: true, module: 'housekeeping' }, { path: '/settings', page: '/pages/settings.js', requiresAuth: true, module: 'settings' }, ]; @@ -131,7 +132,7 @@ let _pendingLoginRedirect = false; // -------------------------------------------------------- const ROUTE_ORDER = ['/', '/calendar', '/tasks', '/meals', '/recipes', '/shopping', - '/birthdays', '/notes', '/contacts', '/budget', '/documents', '/settings']; + '/birthdays', '/notes', '/contacts', '/budget', '/documents', '/housekeeping', '/settings']; const PRIMARY_NAV = 3; @@ -185,6 +186,7 @@ function routeTitle(path) { '/contacts': t('nav.contacts'), '/budget': t('nav.budget'), '/documents': t('nav.documents'), + '/housekeeping': t('nav.housekeeping'), '/settings': t('nav.settings'), }; return map[path] || getAppName(); @@ -1003,6 +1005,7 @@ function navItems() { { path: '/contacts', label: t('nav.contacts'), icon: 'book-user', module: 'contacts' }, { path: '/budget', label: t('nav.budget'), icon: 'wallet', module: 'budget' }, { path: '/documents', label: t('nav.documents'), icon: 'folder-lock', module: 'documents' }, + { path: '/housekeeping', label: t('nav.housekeeping'), icon: 'paintbrush', module: 'housekeeping' }, { path: '/settings', label: t('nav.settings'), icon: 'settings', module: 'settings' }, // Kitchen-Gruppe: via Küche-Nav-Button (Bottom-Nav + Sidebar) + kitchen-tabs-bar erreichbar { path: '/meals', label: t('nav.meals'), icon: 'utensils', module: 'meals', kitchenGroup: true }, diff --git a/public/styles/calendar.css b/public/styles/calendar.css index 7a1db30..614065e 100644 --- a/public/styles/calendar.css +++ b/public/styles/calendar.css @@ -691,6 +691,21 @@ overflow-y: auto; } +.event-icon-dialog__body { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.event-icon-dialog__results { + display: flex; + flex-direction: column; + gap: var(--space-3); + max-height: min(60vh, 520px); + overflow-y: auto; + padding-right: var(--space-1); +} + .event-icon-picker__search { margin-bottom: var(--space-1); background: var(--color-surface); diff --git a/public/styles/documents.css b/public/styles/documents.css index 045a88d..1da649e 100644 --- a/public/styles/documents.css +++ b/public/styles/documents.css @@ -91,10 +91,97 @@ max-width: 240px; } -.documents-list { +.documents-browser-layout { + display: grid; + grid-template-columns: minmax(180px, 240px) minmax(0, 1fr); + align-items: start; + gap: var(--space-4); padding: 0 var(--space-4) calc(var(--nav-bottom-height) + var(--space-8)); } +.documents-folder-browser { + position: sticky; + top: var(--space-4); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); + background: var(--color-surface); + box-shadow: var(--shadow-sm); + overflow: hidden; +} + +.documents-folder-browser__title { + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border-subtle); + color: var(--color-text-secondary); + font-size: var(--text-xs); + font-weight: var(--font-weight-semibold); + text-transform: uppercase; +} + +.documents-folder-browser__list { + display: flex; + flex-direction: column; + gap: var(--space-1); + padding: var(--space-2); +} + +.documents-folder-item { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: var(--space-2); + min-height: var(--target-base); + padding: 0 var(--space-2); + border-radius: var(--radius-sm); + color: var(--color-text-secondary); + text-align: left; +} + +.documents-folder-item:hover, +.documents-folder-item--active { + background: color-mix(in srgb, var(--module-accent) 12%, transparent); + color: var(--module-accent); +} + +.documents-folder-item__icon { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.documents-folder-item__icon svg { + width: 17px; + height: 17px; +} + +.documents-folder-item__name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--text-sm); + font-weight: var(--font-weight-medium); +} + +.documents-folder-item__count { + min-width: 24px; + padding: 2px var(--space-2); + border-radius: var(--radius-full); + background: var(--color-surface-2); + color: var(--color-text-tertiary); + font-size: var(--text-xs); + text-align: center; +} + +.documents-folder-item--active .documents-folder-item__count { + background: color-mix(in srgb, var(--module-accent) 16%, var(--color-surface)); + color: var(--module-accent); +} + +.documents-list { + min-width: 0; +} + .documents-list--grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); @@ -335,6 +422,30 @@ max-width: none; } + .documents-browser-layout { + display: flex; + flex-direction: column; + } + + .documents-folder-browser { + position: static; + width: 100%; + } + + .documents-folder-browser__list { + flex-direction: row; + overflow-x: auto; + scrollbar-width: none; + } + + .documents-folder-browser__list::-webkit-scrollbar { + display: none; + } + + .documents-folder-item { + min-width: 160px; + } + .document-row { grid-template-columns: auto minmax(0, 1fr); } diff --git a/public/styles/housekeeping.css b/public/styles/housekeeping.css new file mode 100644 index 0000000..6707e0b --- /dev/null +++ b/public/styles/housekeeping.css @@ -0,0 +1,678 @@ +.housekeeping-page { + --module-accent: var(--module-housekeeping); + max-width: var(--content-max-width); + min-height: 100%; + margin: 0 auto; + display: flex; + flex-direction: column; +} + +.housekeeping-toolbar { + display: grid; + grid-template-columns: minmax(0, auto) minmax(0, 1fr); + align-items: center; + justify-content: space-between; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + border-top: 3px solid var(--module-accent); + border-bottom: 1px solid var(--color-border); + background-color: color-mix(in srgb, var(--color-surface) 90%, transparent); + backdrop-filter: var(--blur-sm); + -webkit-backdrop-filter: var(--blur-sm); + position: sticky; + top: 0; + z-index: var(--z-sticky); +} + +.housekeeping-toolbar__title { + font-size: var(--text-lg); + font-weight: var(--font-weight-bold); + min-width: 0; +} + +.housekeeping-check-small { + min-height: var(--target-lg); + display: inline-flex; + align-items: center; + gap: var(--space-2); + white-space: nowrap; +} + +.housekeeping-check-small:disabled { + background: var(--color-surface-3); + color: var(--color-text-disabled); + border-color: var(--color-border); + cursor: not-allowed; +} + +.housekeeping-tabs { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-1); + height: var(--kitchen-tabs-height, var(--sub-tabs-height)); + padding: 0; + background: transparent; + border-bottom: 0; + overflow-x: auto; + scrollbar-width: none; +} + +.housekeeping-tabs::-webkit-scrollbar { display: none; } + +.housekeeping-tab { + --active-module-accent: var(--module-accent); + flex-shrink: 0; +} + +.housekeeping-tab[aria-current="page"] { + background-color: color-mix(in srgb, var(--module-accent) 14%, transparent); + color: var(--module-accent); +} + +.housekeeping-content { + display: flex; + flex-direction: column; + gap: var(--space-4); + padding: var(--space-4); + padding-bottom: calc(var(--space-16) + env(safe-area-inset-bottom)); +} + +.housekeeping-card, +.housekeeping-worker-strip, +.housekeeping-metric, +.housekeeping-task, +.housekeeping-report-item { + background: var(--color-surface); + border: 1.5px solid var(--color-border); + border-radius: var(--radius-md); +} + +.housekeeping-card { + border-top: 3px solid var(--module-accent); + padding: var(--space-4); +} + +.housekeeping-card h2 { + margin: 0 0 var(--space-3); + font-size: var(--text-lg); + font-weight: var(--font-weight-bold); + letter-spacing: 0; +} + +.housekeeping-worker-strip { + display: grid; + grid-template-columns: 48px minmax(0, 1fr) auto; + align-items: center; + gap: var(--space-3); + padding: var(--space-3); +} + +.housekeeping-worker-stack, +.housekeeping-staff-list { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.housekeeping-worker-strip div:last-child { + display: grid; + gap: 2px; + min-width: 0; +} + +.housekeeping-worker-strip span, +.housekeeping-muted { + color: var(--color-text-secondary); +} + +.housekeeping-avatar { + width: 48px; + height: 48px; + border-radius: var(--radius-full); + color: var(--color-text-on-accent); + display: grid; + place-items: center; + font-weight: var(--font-weight-bold); + overflow: hidden; + border: 1px solid var(--color-border); + flex: 0 0 auto; +} + +.housekeeping-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.housekeeping-avatar--lg { + width: 96px; + height: 96px; + border: 0; + font-size: var(--text-xl); +} + +.housekeeping-worker-empty { + display: flex; + gap: var(--space-3); + align-items: center; +} + +.housekeeping-worker-empty svg { + width: 32px; + height: 32px; + color: var(--module-accent); +} + +.housekeeping-worker-empty p { + margin: 0; + color: var(--color-text-secondary); +} + +.housekeeping-metrics { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); +} + +.housekeeping-metrics--compact { + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-top: var(--space-3); +} + +.housekeeping-metric { + min-height: 104px; + padding: var(--space-3); + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.housekeeping-metric span, +.housekeeping-section-heading span { + color: var(--color-text-secondary); + font-size: var(--text-sm); +} + +.housekeeping-metric strong { + font-size: var(--text-xl); + line-height: 1.15; + letter-spacing: 0; +} + +.housekeeping-section-heading { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: var(--space-3); + margin-bottom: var(--space-3); +} + +.housekeeping-section-heading h2 { + margin: 0; +} + +.housekeeping-chart { + min-height: 126px; + display: flex; + align-items: end; + gap: var(--space-3); + overflow-x: auto; + padding-top: var(--space-3); +} + +.housekeeping-chart__bar-wrap { + min-width: 44px; + display: grid; + justify-items: center; + gap: var(--space-2); + color: var(--color-text-secondary); + font-size: var(--text-xs); +} + +.housekeeping-chart__bar { + width: 24px; + border-radius: var(--radius-full) var(--radius-full) 0 0; + background: var(--module-accent); +} + +.housekeeping-field { + display: grid; + gap: var(--space-2); + font-weight: var(--font-weight-semibold); +} + +.housekeeping-field input, +.housekeeping-field textarea, +.housekeeping-field select { + width: 100%; + min-height: var(--target-lg); + border: 1.5px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text-primary); + font: inherit; + font-size: var(--text-base); + padding: 0 var(--space-3); +} + +.housekeeping-field textarea { + min-height: 120px; + padding: var(--space-3); + resize: vertical; +} + +.housekeeping-field--inline { + min-width: 180px; +} + +.housekeeping-field--color input { + padding: var(--space-1); +} + +.housekeeping-task-form, +.housekeeping-report-form, +.housekeeping-worker-form { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.housekeeping-form-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 132px; + gap: var(--space-3); + align-items: end; +} + +.housekeeping-form-grid--wide { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.housekeeping-form-submit { + min-height: var(--target-lg); + align-self: flex-start; + display: inline-flex; + align-items: center; + gap: var(--space-2); +} + +.housekeeping-template-list { + display: flex; + gap: var(--space-2); + overflow-x: auto; + padding-bottom: var(--space-1); +} + +.housekeeping-template { + min-width: 180px; + border: 1.5px dashed var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface); + color: var(--color-text-primary); + padding: var(--space-3); + text-align: left; + display: grid; + gap: var(--space-1); +} + +.housekeeping-template small { + color: var(--color-text-secondary); +} + +.housekeeping-task-list, +.housekeeping-reports { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.housekeeping-task { + display: grid; + grid-template-columns: 56px 1fr; + gap: var(--space-3); + align-items: center; + min-height: 88px; + padding: var(--space-3); + border-left: 6px solid var(--color-success); +} + +.housekeeping-task--today { + border-left-color: var(--color-warning); +} + +.housekeeping-task--overdue { + border-left-color: var(--color-danger); +} + +.housekeeping-task__check { + width: 52px; + height: 52px; + border: 2px solid var(--module-accent); + border-radius: var(--radius-full); + background: color-mix(in srgb, var(--module-accent) 10%, var(--color-surface)); + color: var(--module-accent); + display: grid; + place-items: center; +} + +.housekeeping-task__body h2 { + margin: 0; + font-size: var(--text-base); + letter-spacing: 0; +} + +.housekeeping-task__body p { + margin: var(--space-1) 0; + color: var(--color-text-secondary); +} + +.housekeeping-task__body span { + font-weight: var(--font-weight-bold); +} + +.housekeeping-empty, +.housekeeping-loading { + min-height: 180px; + display: grid; + place-items: center; + text-align: center; + color: var(--color-text-secondary); +} + +.housekeeping-empty svg { + width: 44px; + height: 44px; + color: var(--module-accent); +} + +.housekeeping-photo { + min-height: 72px; + border: 1.5px dashed color-mix(in srgb, var(--module-accent) 55%, var(--color-border)); + border-radius: var(--radius-md); + color: var(--module-accent); + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + font-weight: var(--font-weight-semibold); +} + +.housekeeping-photo input { + position: absolute; + inline-size: 1px; + block-size: 1px; + opacity: 0; +} + +.housekeeping-photo-preview { + width: 100%; + max-height: 260px; + object-fit: cover; + border-radius: var(--radius-md); + border: 1px solid var(--color-border); +} + +.housekeeping-report-item { + display: grid; + grid-template-columns: 56px 1fr; + gap: var(--space-3); + align-items: center; + padding: var(--space-3); +} + +.housekeeping-report-item--visit { + grid-template-columns: 48px minmax(0, 1fr) auto; +} + +.housekeeping-report-item--visit .housekeeping-avatar img { + width: 100%; + height: 100%; + border-radius: inherit; + background: transparent; +} + +.housekeeping-report-item img, +.housekeeping-report-item > svg { + width: 56px; + height: 56px; + border-radius: var(--radius-sm); + object-fit: cover; + color: var(--module-accent); + background: color-mix(in srgb, var(--module-accent) 10%, var(--color-surface)); +} + +.housekeeping-report-item div { + display: grid; + gap: 3px; +} + +.housekeeping-report-item span { + color: var(--color-text-secondary); + font-size: var(--text-sm); +} + +.housekeeping-report-modal { + display: grid; + gap: var(--space-4); +} + +.housekeeping-report-details { + display: grid; + gap: var(--space-3); + margin: 0; +} + +.housekeeping-report-details div { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-3); + padding-bottom: var(--space-2); + border-bottom: 1px solid var(--color-border); +} + +.housekeeping-report-details dt { + color: var(--color-text-secondary); + font-size: var(--text-sm); +} + +.housekeeping-report-details dd { + margin: 0; + font-weight: var(--font-weight-semibold); + text-align: right; +} + +.housekeeping-staff-row { + display: grid; + grid-template-columns: 48px 1fr auto; + gap: var(--space-3); + align-items: center; + padding: var(--space-3); + border: 1.5px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface); +} + +.housekeeping-staff-row[role="button"] { + cursor: pointer; +} + +.housekeeping-staff-row[role="button"]:hover, +.housekeeping-staff-row[role="button"]:focus-visible, +.housekeeping-staff-row--active { + border-color: var(--module-accent); + background: color-mix(in srgb, var(--module-accent) 8%, var(--color-surface)); +} + +.housekeeping-staff-row div:nth-child(2) { + display: grid; + gap: 2px; + min-width: 0; +} + +.housekeeping-staff-row span { + color: var(--color-text-secondary); + font-size: var(--text-sm); +} + +.housekeeping-profile-editor { + display: grid; + grid-template-columns: auto 1fr; + gap: var(--space-4); + align-items: center; +} + +.housekeeping-profile-editor__fields { + display: grid; + gap: var(--space-3); +} + +.housekeeping-staff-log { + margin-top: var(--space-3); +} + +.housekeeping-staff-log-list { + display: grid; + gap: var(--space-2); +} + +.housekeeping-staff-log-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: var(--space-3); + align-items: center; + padding: var(--space-3); + border: 1.5px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface); +} + +.housekeeping-staff-log-row > div:first-child { + display: grid; + gap: 2px; + min-width: 0; +} + +.housekeeping-staff-log-row span { + color: var(--color-text-secondary); + font-size: var(--text-sm); +} + +.housekeeping-staff-log-row__actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.housekeeping-log-action { + min-height: var(--target-sm); + padding-inline: var(--space-3); + gap: var(--space-2); +} + +.housekeeping-log-action svg { + width: 17px; + height: 17px; +} + +.modal-panel .document-dropzone { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-2); + min-height: 132px; + padding: var(--space-4); + border: 1.5px dashed color-mix(in srgb, var(--module-accent) 48%, var(--color-border)); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--module-accent) 7%, var(--color-surface)); + color: var(--color-text-secondary); + text-align: center; + cursor: pointer; +} + +.modal-panel .document-dropzone__icon { + width: 42px; + height: 42px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-md); + color: var(--module-accent); + background: var(--color-surface); + box-shadow: var(--shadow-sm); +} + +.modal-panel .document-dropzone__title { + font-size: var(--text-sm); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); +} + +.modal-panel .document-dropzone__hint, +.modal-panel .document-dropzone__file { + max-width: 100%; + font-size: var(--text-xs); + overflow-wrap: anywhere; +} + +.modal-panel .document-dropzone__file { + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-sm); + color: var(--module-accent); + background: color-mix(in srgb, var(--module-accent) 14%, transparent); + font-weight: var(--font-weight-semibold); +} + +@media (max-width: 720px) { + .housekeeping-toolbar { + grid-template-columns: 1fr; + align-items: stretch; + } + + .housekeeping-tabs { + justify-content: flex-start; + } + + .housekeeping-metrics, + .housekeeping-metrics--compact, + .housekeeping-form-grid, + .housekeeping-form-grid--wide, + .housekeeping-profile-editor { + grid-template-columns: 1fr; + } + + .housekeeping-avatar--lg { + width: 84px; + height: 84px; + } + + .housekeeping-worker-strip { + grid-template-columns: 48px 1fr; + } + + .housekeeping-worker-strip .housekeeping-check-small { + grid-column: 1 / -1; + justify-content: center; + } + + .housekeeping-section-heading { + align-items: stretch; + flex-direction: column; + } + + .housekeeping-staff-log-row { + grid-template-columns: 1fr; + } + + .housekeeping-staff-log-row__actions { + justify-content: flex-end; + } +} + +@media (max-width: 420px) { + .housekeeping-check-small span { + display: none; + } +} diff --git a/public/styles/tokens.css b/public/styles/tokens.css index 54e9cc7..9db3c49 100644 --- a/public/styles/tokens.css +++ b/public/styles/tokens.css @@ -193,6 +193,8 @@ --module-budget: var(--_module-budget); /* Teal-700 - Finanzen, Stabilität */ --_module-documents: #1D4ED8; --module-documents: var(--_module-documents); /* Blue - secure family documents */ + --_module-housekeeping: #7C3AED; + --module-housekeeping: var(--_module-housekeeping); /* Violet - focused service workflow */ --_module-settings: #6E7781; --module-settings: var(--_module-settings); /* Grau - Konfiguration */ --_module-reminders: #0E7490; @@ -554,6 +556,7 @@ --_module-contacts: #60A5FA; --_module-birthdays: #FB7185; --_module-budget: #2DD4BF; + --_module-housekeeping: #C4B5FD; --_module-settings: #94A3B8; --_module-reminders: #22D3EE; /* Cyan-400 */ @@ -659,6 +662,7 @@ --_module-contacts: #60A5FA; --_module-birthdays: #FB7185; --_module-budget: #2DD4BF; /* Teal-400 */ + --_module-housekeeping: #C4B5FD; --_module-settings: #94A3B8; --_module-reminders: #22D3EE; /* Cyan-400 */ diff --git a/server/auth.js b/server/auth.js index 6d3c276..1b48265 100644 --- a/server/auth.js +++ b/server/auth.js @@ -572,7 +572,14 @@ router.get('/me', requireAuth, (req, res) => { router.get('/users', requireAuth, requireAdmin, (req, res) => { try { const users = db.get() - .prepare(`SELECT ${USER_PUBLIC_COLUMNS} FROM users ORDER BY display_name`) + .prepare(` + SELECT ${USER_PUBLIC_COLUMNS} + FROM users + WHERE NOT EXISTS ( + SELECT 1 FROM housekeeping_workers hw WHERE hw.user_id = users.id + ) + ORDER BY display_name + `) .all(); res.json({ data: users.map(publicUser) }); } catch (err) { @@ -952,4 +959,4 @@ router.delete('/users/:id', requireAuth, requireAdmin, csrfMiddleware, (req, res } }); -export { router, sessionMiddleware, requireAuth, requireAdmin }; +export { router, sessionMiddleware, requireAuth, requireAdmin, syncFamilyMemberArtifacts, normalizeAvatarData }; diff --git a/server/db.js b/server/db.js index 009f96e..f980a17 100644 --- a/server/db.js +++ b/server/db.js @@ -1076,6 +1076,15 @@ const MIGRATIONS = [ }, { version: 30, + description: 'Advanced reminder options for birthdays', + up: ` + ALTER TABLE birthdays ADD COLUMN reminder_offset TEXT; + ALTER TABLE birthdays ADD COLUMN reminder_custom_amount INTEGER; + ALTER TABLE birthdays ADD COLUMN reminder_custom_unit TEXT; + `, + }, + { + version: 31, description: 'CardDAV multi-account contacts sync', up: ` -- ======================================== @@ -1209,6 +1218,148 @@ const MIGRATIONS = [ SELECT id, assigned_to FROM calendar_events WHERE assigned_to IS NOT NULL; `, }, + { + version: 33, + description: 'Housekeeping work sessions, decay tasks, supply requests, and maintenance log', + up: ` + CREATE TABLE IF NOT EXISTS housekeeping_work_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + check_in TEXT NOT NULL, + check_out TEXT, + daily_rate REAL NOT NULL DEFAULT 0 CHECK(daily_rate >= 0), + extras REAL NOT NULL DEFAULT 0 CHECK(extras >= 0), + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ); + + CREATE TABLE IF NOT EXISTS housekeeping_decay_tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + area TEXT NOT NULL, + frequency_days INTEGER NOT NULL CHECK(frequency_days > 0), + last_completed TEXT, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ); + + CREATE TABLE IF NOT EXISTS housekeeping_supply_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + quantity TEXT, + shopping_item_id INTEGER REFERENCES shopping_items(id) ON DELETE SET NULL, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ); + + CREATE TABLE IF NOT EXISTS housekeeping_maintenance_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + description TEXT NOT NULL, + photo_url TEXT, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ); + + CREATE TRIGGER IF NOT EXISTS trg_housekeeping_work_sessions_updated_at + AFTER UPDATE ON housekeeping_work_sessions FOR EACH ROW + BEGIN UPDATE housekeeping_work_sessions SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END; + + CREATE TRIGGER IF NOT EXISTS trg_housekeeping_decay_tasks_updated_at + AFTER UPDATE ON housekeeping_decay_tasks FOR EACH ROW + BEGIN UPDATE housekeeping_decay_tasks SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END; + + CREATE TRIGGER IF NOT EXISTS trg_housekeeping_maintenance_log_updated_at + AFTER UPDATE ON housekeeping_maintenance_log FOR EACH ROW + BEGIN UPDATE housekeeping_maintenance_log SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END; + + CREATE INDEX IF NOT EXISTS idx_housekeeping_sessions_check_in ON housekeeping_work_sessions(check_in); + CREATE INDEX IF NOT EXISTS idx_housekeeping_sessions_open ON housekeeping_work_sessions(check_out); + CREATE INDEX IF NOT EXISTS idx_housekeeping_decay_area ON housekeeping_decay_tasks(area); + CREATE INDEX IF NOT EXISTS idx_housekeeping_decay_completed ON housekeeping_decay_tasks(last_completed); + CREATE INDEX IF NOT EXISTS idx_housekeeping_supply_created ON housekeeping_supply_requests(created_at); + CREATE INDEX IF NOT EXISTS idx_housekeeping_maintenance_created ON housekeeping_maintenance_log(created_at); + `, + }, + { + version: 34, + description: 'Housekeeping worker profile and payment tracking', + up: ` + CREATE TABLE IF NOT EXISTS housekeeping_workers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + daily_rate REAL NOT NULL DEFAULT 0 CHECK(daily_rate >= 0), + payment_schedule TEXT NOT NULL DEFAULT 'monthly' + CHECK(payment_schedule IN ('daily', 'twice_monthly', 'monthly')), + notes TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ); + + ALTER TABLE housekeeping_work_sessions ADD COLUMN paid_at TEXT; + + CREATE TRIGGER IF NOT EXISTS trg_housekeeping_workers_updated_at + AFTER UPDATE ON housekeeping_workers FOR EACH ROW + BEGIN UPDATE housekeeping_workers SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END; + + CREATE INDEX IF NOT EXISTS idx_housekeeping_workers_user ON housekeeping_workers(user_id); + CREATE INDEX IF NOT EXISTS idx_housekeeping_sessions_paid ON housekeeping_work_sessions(paid_at); + `, + }, + { + version: 35, + description: 'Housekeeping per-worker sessions and calendar linkage', + up: ` + ALTER TABLE housekeeping_workers ADD COLUMN calendar_color TEXT NOT NULL DEFAULT '#7C3AED'; + ALTER TABLE housekeeping_work_sessions ADD COLUMN worker_id INTEGER REFERENCES housekeeping_workers(id) ON DELETE SET NULL; + ALTER TABLE housekeeping_work_sessions ADD COLUMN calendar_event_id INTEGER REFERENCES calendar_events(id) ON DELETE SET NULL; + + CREATE INDEX IF NOT EXISTS idx_housekeeping_sessions_worker ON housekeeping_work_sessions(worker_id); + CREATE INDEX IF NOT EXISTS idx_housekeeping_sessions_calendar ON housekeeping_work_sessions(calendar_event_id); + `, + }, + { + version: 36, + description: 'Housekeeping payment task linkage', + up: ` + ALTER TABLE housekeeping_work_sessions ADD COLUMN payment_task_id INTEGER REFERENCES tasks(id) ON DELETE SET NULL; + + CREATE INDEX IF NOT EXISTS idx_housekeeping_sessions_payment_task ON housekeeping_work_sessions(payment_task_id); + `, + }, + { + version: 37, + description: 'Document folders and housekeeping receipt linkage', + up: ` + CREATE TABLE IF NOT EXISTS family_document_folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ); + + CREATE TRIGGER IF NOT EXISTS trg_family_document_folders_updated_at + AFTER UPDATE ON family_document_folders FOR EACH ROW + BEGIN UPDATE family_document_folders SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END; + + ALTER TABLE family_documents ADD COLUMN folder_id INTEGER REFERENCES family_document_folders(id) ON DELETE SET NULL; + ALTER TABLE housekeeping_work_sessions ADD COLUMN receipt_document_id INTEGER REFERENCES family_documents(id) ON DELETE SET NULL; + + CREATE INDEX IF NOT EXISTS idx_family_documents_folder ON family_documents(folder_id); + CREATE INDEX IF NOT EXISTS idx_housekeeping_sessions_receipt ON housekeeping_work_sessions(receipt_document_id); + `, + }, + { + version: 38, + description: 'Calendar attachment document linkage', + up: ` + ALTER TABLE calendar_events ADD COLUMN attachment_document_id INTEGER REFERENCES family_documents(id) ON DELETE SET NULL; + + CREATE INDEX IF NOT EXISTS idx_calendar_attachment_document ON calendar_events(attachment_document_id); + `, + }, ]; /** diff --git a/server/index.js b/server/index.js index 320bb2e..442ea51 100644 --- a/server/index.js +++ b/server/index.js @@ -36,6 +36,7 @@ import remindersRouter from './routes/reminders.js'; import searchRouter from './routes/search.js'; import familyRouter from './routes/family.js'; import backupRouter from './routes/backup.js'; +import housekeepingRouter from './routes/housekeeping.js'; const log = createLogger('Server'); const logSync = createLogger('Sync'); @@ -175,6 +176,41 @@ app.get('/api/v1/version', (req, res) => { res.json({ version: APP_VERSION, app_name: appName }); }); +app.get('/manifest.webmanifest', (req, res) => { + let appName = DEFAULT_APP_NAME; + try { + const row = db.get().prepare('SELECT value FROM sync_config WHERE key = ?').get('app_name'); + if (row?.value) appName = row.value; + } catch { + // fall back to default + } + + res.type('application/manifest+json'); + res.setHeader('Cache-Control', 'no-cache, must-revalidate'); + res.json({ + name: `${appName} Familienplaner`, + short_name: appName, + description: 'Selbstgehosteter Familienplaner', + id: '/', + start_url: '/', + scope: '/', + display: 'standalone', + display_override: ['standalone', 'minimal-ui'], + orientation: 'portrait-primary', + theme_color: '#007AFF', + background_color: '#F5F5F7', + lang: 'de-DE', + categories: ['productivity', 'lifestyle'], + icons: [ + { src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' }, + { src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' }, + { src: '/icons/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' }, + { src: '/icons/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, + ], + screenshots: [], + }); +}); + function sendOpenApi(req, res) { if (req.query.download === '1') { res.setHeader('Content-Disposition', 'attachment; filename="openapi.json"'); @@ -206,6 +242,7 @@ app.use('/api/v1/reminders', remindersRouter); app.use('/api/v1/search', searchRouter); app.use('/api/v1/family', familyRouter); app.use('/api/v1/backup', backupRouter); +app.use('/api/v1/housekeeping', housekeepingRouter); // -------------------------------------------------------- // Health-Check (für Docker) diff --git a/server/routes/calendar.js b/server/routes/calendar.js index 9e92836..90f28db 100644 --- a/server/routes/calendar.js +++ b/server/routes/calendar.js @@ -22,6 +22,7 @@ const router = express.Router(); const VALID_SOURCES = ['local', 'google', 'apple', 'ics']; const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024; +const DEFAULT_ATTACHMENT_FOLDER = 'Calendar items'; const ATTACHMENT_MIME = new Set([ 'image/png', 'image/jpeg', @@ -87,6 +88,36 @@ function parseAttachment(dataUrl) { return { name: null, mime, size: buffer.length, data: base64 }; } +function ensureDocumentFolder(database, name, actorId) { + const folderName = typeof name === 'string' ? name.trim() : ''; + if (!folderName) return null; + const existing = database.prepare('SELECT id FROM family_document_folders WHERE name = ? COLLATE NOCASE').get(folderName); + if (existing) return existing.id; + const result = database.prepare('INSERT INTO family_document_folders (name, created_by) VALUES (?, ?)').run(folderName, actorId); + return result.lastInsertRowid; +} + +function createAttachmentDocument(database, attachment, body, actorId) { + if (!attachment?.data) return null; + const originalName = String(body.attachment_name || 'Attachment').trim() || 'Attachment'; + const folderId = ensureDocumentFolder(database, body.document_folder_name || DEFAULT_ATTACHMENT_FOLDER, actorId); + const result = database.prepare(` + INSERT INTO family_documents + (name, description, category, visibility, folder_id, original_name, mime_type, file_size, content_data, created_by) + VALUES (?, ?, 'other', 'family', ?, ?, ?, ?, ?, ?) + `).run( + body.document_name || originalName.replace(/\.[^.]+$/, ''), + body.document_description || null, + folderId, + originalName, + attachment.mime, + attachment.size, + attachment.data, + actorId, + ); + return result.lastInsertRowid; +} + function attachmentDataUrl(event) { if (!event?.attachment_data) return event?.attachment_data ?? null; if (String(event.attachment_data).startsWith('data:')) return event.attachment_data; @@ -661,12 +692,13 @@ router.post('/', (req, res) => { const attachment = req.body.attachment_data ? parseAttachment(req.body.attachment_data) : { mime: null, size: null, data: null }; const eventId = db.get().transaction(() => { + const documentId = createAttachmentDocument(db.get(), attachment, req.body, userId); const result = db.get().prepare(` INSERT INTO calendar_events (title, description, start_datetime, end_datetime, all_day, location, color, icon, assigned_to, created_by, recurrence_rule, - attachment_name, attachment_mime, attachment_size, attachment_data) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + attachment_name, attachment_mime, attachment_size, attachment_data, attachment_document_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( vTitle.value, vDesc.value, vStart.value, vEnd.value, @@ -676,7 +708,8 @@ router.post('/', (req, res) => { req.body.attachment_name || null, attachment.mime, attachment.size, - attachment.data + attachment.data, + documentId ); setEventAssignments(db.get(), result.lastInsertRowid, userIds); return result.lastInsertRowid; @@ -747,6 +780,9 @@ router.put('/:id', (req, res) => { const userModified = event.external_source !== 'local' ? 1 : event.user_modified; db.get().transaction(() => { + const documentId = req.body.attachment_data + ? createAttachmentDocument(db.get(), attachment, req.body, event.created_by) + : event.attachment_document_id; db.get().prepare(` UPDATE calendar_events SET title = COALESCE(?, title), @@ -763,6 +799,7 @@ router.put('/:id', (req, res) => { attachment_mime = ?, attachment_size = ?, attachment_data = ?, + attachment_document_id = ?, user_modified = ? WHERE id = ? `).run( @@ -780,6 +817,7 @@ router.put('/:id', (req, res) => { attachment.mime, attachment.size, attachment.data, + documentId, userModified, id ); diff --git a/server/routes/documents.js b/server/routes/documents.js index 02a5252..76a5040 100644 --- a/server/routes/documents.js +++ b/server/routes/documents.js @@ -7,7 +7,7 @@ import express from 'express'; import * as db from '../db.js'; import { createLogger } from '../logger.js'; -import { str, collectErrors, MAX_TEXT, MAX_TITLE } from '../middleware/validate.js'; +import { str, collectErrors, id as validateId, MAX_TEXT, MAX_TITLE } from '../middleware/validate.js'; const log = createLogger('Documents'); const router = express.Router(); @@ -70,10 +70,12 @@ function documentSelect() { return ` SELECT d.id, d.name, d.description, d.category, d.status, d.visibility, d.original_name, d.mime_type, d.file_size, d.storage_provider, - d.storage_key, d.created_by, d.created_at, d.updated_at, + d.storage_key, d.folder_id, d.created_by, d.created_at, d.updated_at, + f.name AS folder_name, u.display_name AS creator_name, u.avatar_color AS creator_color, GROUP_CONCAT(a.user_id) AS allowed_member_ids FROM family_documents d + LEFT JOIN family_document_folders f ON f.id = d.folder_id LEFT JOIN users u ON u.id = d.created_by LEFT JOIN family_document_access a ON a.document_id = d.id `; @@ -90,7 +92,7 @@ function normalizeDocument(row) { } function getVisibleDocument(id, req, includeContent = false) { - const columns = includeContent ? 'd.*' : 'd.id, d.created_by, d.visibility, d.description'; + const columns = includeContent ? 'd.*' : 'd.id, d.created_by, d.visibility, d.description, d.folder_id'; return db.get().prepare(` SELECT ${columns} FROM family_documents d @@ -105,6 +107,15 @@ function replaceAccess(documentId, memberIds) { for (const memberId of memberIds) insert.run(documentId, memberId); } +function ensureFolder(name, actorId) { + const folderName = typeof name === 'string' ? name.trim() : ''; + if (!folderName) return null; + const existing = db.get().prepare('SELECT id FROM family_document_folders WHERE name = ? COLLATE NOCASE').get(folderName); + if (existing) return existing.id; + const result = db.get().prepare('INSERT INTO family_document_folders (name, created_by) VALUES (?, ?)').run(folderName, actorId); + return result.lastInsertRowid; +} + router.get('/meta/options', (_req, res) => { res.json({ data: { @@ -118,16 +129,52 @@ router.get('/meta/options', (_req, res) => { }); }); +router.get('/folders', (_req, res) => { + try { + const rows = db.get().prepare(` + SELECT id, name, created_by, created_at, updated_at + FROM family_document_folders + ORDER BY name COLLATE NOCASE ASC + `).all(); + res.json({ data: rows }); + } catch (err) { + log.error('GET /folders error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + +router.post('/folders', (req, res) => { + try { + const vName = str(req.body.name, 'Name', { max: MAX_TITLE }); + if (vName.error) return res.status(400).json({ error: vName.error, code: 400 }); + const result = db.get().prepare('INSERT INTO family_document_folders (name, created_by) VALUES (?, ?)') + .run(vName.value, userId(req)); + const row = db.get().prepare('SELECT id, name, created_by, created_at, updated_at FROM family_document_folders WHERE id = ?') + .get(result.lastInsertRowid); + res.status(201).json({ data: row }); + } catch (err) { + if (err.message?.includes('UNIQUE constraint')) { + return res.status(409).json({ error: 'Folder already exists.', code: 409 }); + } + log.error('POST /folders error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + router.get('/', (req, res) => { try { const status = STATUSES.includes(req.query.status) ? req.query.status : 'active'; const category = CATEGORIES.includes(req.query.category) ? req.query.category : null; - const params = { userId: userId(req), status, category }; + const folderId = req.query.folder_id !== undefined && req.query.folder_id !== '' + ? Number(req.query.folder_id) + : null; + const params = { userId: userId(req), status, category, folderId }; const rows = db.get().prepare(` ${documentSelect()} WHERE ${canSeeSql('d')} AND d.status = @status AND (@category IS NULL OR d.category = @category) + AND (@folderId IS NULL OR d.folder_id = @folderId) GROUP BY d.id ORDER BY d.updated_at DESC `).all(params); @@ -143,21 +190,27 @@ router.post('/', (req, res) => { const vName = str(req.body.name, 'Name', { max: MAX_TITLE }); const vDescription = str(req.body.description, 'Description', { max: MAX_TEXT, required: false }); const vOriginalName = str(req.body.original_name, 'Original filename', { max: MAX_TITLE }); - const errors = collectErrors([vName, vDescription, vOriginalName]); + const vFolderName = str(req.body.folder_name, 'Folder name', { max: MAX_TITLE, required: false }); + const errors = collectErrors([vName, vDescription, vOriginalName, vFolderName]); if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); const category = CATEGORIES.includes(req.body.category) ? req.body.category : 'other'; const visibility = VISIBILITIES.includes(req.body.visibility) ? req.body.visibility : 'family'; + const vFolderId = req.body.folder_id !== undefined && req.body.folder_id !== null && req.body.folder_id !== '' + ? validateId(req.body.folder_id, 'folder_id') + : { value: null, error: null }; + if (vFolderId.error) return res.status(400).json({ error: vFolderId.error, code: 400 }); const parsed = parseDataUrl(req.body.content_data); if (parsed.error) return res.status(400).json({ error: parsed.error, code: 400 }); const allowedIds = visibility === 'restricted' ? parseMemberIds(req.body.allowed_member_ids) : []; + const folderId = vFolderId.value ?? ensureFolder(vFolderName.value, userId(req)); const database = db.get(); const result = database.prepare(` INSERT INTO family_documents - (name, description, category, visibility, original_name, mime_type, file_size, content_data, created_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run(vName.value, vDescription.value, category, visibility, vOriginalName.value, parsed.mime, parsed.size, parsed.base64, userId(req)); + (name, description, category, visibility, folder_id, original_name, mime_type, file_size, content_data, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(vName.value, vDescription.value, category, visibility, folderId, vOriginalName.value, parsed.mime, parsed.size, parsed.base64, userId(req)); if (visibility === 'restricted') replaceAccess(result.lastInsertRowid, allowedIds); const row = database.prepare(` @@ -187,13 +240,18 @@ router.put('/:id', (req, res) => { const category = req.body.category !== undefined && CATEGORIES.includes(req.body.category) ? req.body.category : null; const visibility = req.body.visibility !== undefined && VISIBILITIES.includes(req.body.visibility) ? req.body.visibility : null; const status = req.body.status !== undefined && STATUSES.includes(req.body.status) ? req.body.status : null; + const vFolderId = req.body.folder_id !== undefined && req.body.folder_id !== null && req.body.folder_id !== '' + ? validateId(req.body.folder_id, 'folder_id') + : { value: null, error: null }; + if (vFolderId.error) return res.status(400).json({ error: vFolderId.error, code: 400 }); db.get().prepare(` UPDATE family_documents SET name = COALESCE(?, name), description = ?, category = COALESCE(?, category), visibility = COALESCE(?, visibility), - status = COALESCE(?, status) + status = COALESCE(?, status), + folder_id = ? WHERE id = ? `).run( req.body.name !== undefined ? vName.value : null, @@ -201,6 +259,7 @@ router.put('/:id', (req, res) => { category, visibility, status, + req.body.folder_id !== undefined ? vFolderId.value : existing.folder_id, id ); if ((visibility || existing.visibility) === 'restricted') replaceAccess(id, parseMemberIds(req.body.allowed_member_ids)); diff --git a/server/routes/family.js b/server/routes/family.js index ef1a2ac..b783afd 100644 --- a/server/routes/family.js +++ b/server/routes/family.js @@ -26,6 +26,9 @@ router.get('/members', (req, res) => { FROM users u LEFT JOIN contacts c ON c.family_user_id = u.id LEFT JOIN birthdays b ON b.family_user_id = u.id + WHERE NOT EXISTS ( + SELECT 1 FROM housekeeping_workers hw WHERE hw.user_id = u.id + ) ORDER BY u.display_name COLLATE NOCASE ASC `).all(); res.json({ data: members }); diff --git a/server/routes/housekeeping.js b/server/routes/housekeeping.js new file mode 100644 index 0000000..07d3309 --- /dev/null +++ b/server/routes/housekeeping.js @@ -0,0 +1,956 @@ +/** + * Modul: Housekeeping + * Zweck: REST-API fuer Ponto/Financeiro, tarefas dinamicas, insumos e ocorrencias + * Abhängigkeiten: express, server/db.js + */ + +import express from 'express'; +import bcrypt from 'bcrypt'; +import crypto from 'node:crypto'; +import { createLogger } from '../logger.js'; +import * as db from '../db.js'; +import { normalizeAvatarData, syncFamilyMemberArtifacts } from '../auth.js'; +import { collectErrors, color, date, datetime, month, num, oneOf, str, id as validateId, MAX_SHORT, MAX_TEXT, MAX_TITLE } from '../middleware/validate.js'; + +const log = createLogger('Housekeeping'); +const router = express.Router(); + +const MAX_PHOTO_DATA_LENGTH = 6 * 1024 * 1024; +const IMAGE_DATA_RE = /^data:image\/(?:png|jpeg|webp);base64,[a-z0-9+/=]+$/i; +const PAYMENT_SCHEDULES = ['daily', 'twice_monthly', 'monthly']; +const DEFAULT_CALENDAR_COLOR = '#7C3AED'; +const HOUSEKEEPING_EVENT_ICON = 'paintbrush'; +const PAYMENT_TASKS_PREF = 'housekeeping_payment_tasks'; + +const TASK_TEMPLATES = [ + { key: 'cleanBathrooms', name: 'Clean bathrooms', area: 'Bathrooms', frequency_days: 7 }, + { key: 'mopKitchenFloor', name: 'Mop kitchen floor', area: 'Kitchen', frequency_days: 7 }, + { key: 'dustLivingRoom', name: 'Dust living room', area: 'Living room', frequency_days: 14 }, + { key: 'changeBedLinens', name: 'Change bed linens', area: 'Bedrooms', frequency_days: 14 }, + { key: 'cleanRefrigerator', name: 'Clean refrigerator', area: 'Kitchen', frequency_days: 30 }, + { key: 'cleanWindows', name: 'Clean windows', area: 'Whole house', frequency_days: 30 }, + { key: 'deepCleanOven', name: 'Deep clean oven', area: 'Kitchen', frequency_days: 60 }, + { key: 'washOutdoor', name: 'Wash balcony/patio', area: 'Outdoor', frequency_days: 30 }, +]; + +function userId(req) { + return req.authUserId || req.session.userId; +} + +function nowIso() { + return new Date().toISOString(); +} + +function currentMonth() { + return nowIso().slice(0, 7); +} + +function publicSession(row) { + if (!row) return null; + return { + id: row.id, + worker_id: row.worker_id ?? null, + calendar_event_id: row.calendar_event_id ?? null, + payment_task_id: row.payment_task_id ?? null, + receipt_document_id: row.receipt_document_id ?? null, + check_in: row.check_in, + check_out: row.check_out, + daily_rate: Number(row.daily_rate || 0), + extras: Number(row.extras || 0), + paid_at: row.paid_at ?? null, + created_at: row.created_at, + updated_at: row.updated_at, + }; +} + +function publicWorker(row) { + if (!row) return null; + const todaySession = loadTodaySession(row.id); + return { + id: row.id, + user_id: row.user_id, + username: row.username, + display_name: row.display_name, + avatar_color: row.avatar_color, + avatar_data: row.avatar_data ?? null, + phone: row.phone ?? null, + email: row.email ?? null, + birth_date: row.birth_date ?? null, + daily_rate: Number(row.daily_rate || 0), + payment_schedule: row.payment_schedule, + calendar_color: row.calendar_color || DEFAULT_CALENDAR_COLOR, + current_session: publicSession(todaySession), + today_session: publicSession(todaySession), + notes: row.notes ?? null, + created_at: row.created_at, + updated_at: row.updated_at, + }; +} + +function taskUrgency(row, now = new Date()) { + const frequencyDays = Math.max(1, Number(row.frequency_days || 1)); + const completed = row.last_completed ? new Date(row.last_completed) : null; + if (!completed || Number.isNaN(completed.getTime())) { + return { urgency: Number.MAX_SAFE_INTEGER, status: 'overdue', due_date: null }; + } + + const due = new Date(completed); + due.setDate(due.getDate() + frequencyDays); + + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const dueDay = new Date(due.getFullYear(), due.getMonth(), due.getDate()); + const elapsedDays = Math.max(0, (now.getTime() - completed.getTime()) / 86_400_000); + const urgency = elapsedDays / frequencyDays; + + let status = 'ok'; + if (today.getTime() > dueDay.getTime()) status = 'overdue'; + else if (today.getTime() === dueDay.getTime()) status = 'today'; + + return { urgency, status, due_date: due.toISOString() }; +} + +function publicDecayTask(row) { + const computed = taskUrgency(row); + return { + id: row.id, + name: row.name, + area: row.area, + frequency_days: row.frequency_days, + last_completed: row.last_completed, + urgency: computed.urgency === Number.MAX_SAFE_INTEGER ? null : Number(computed.urgency.toFixed(3)), + urgency_status: computed.status, + due_date: computed.due_date, + created_at: row.created_at, + updated_at: row.updated_at, + }; +} + +function validatePhotoUrl(value) { + if (value === undefined || value === null || value === '') return { value: null, error: null }; + if (typeof value !== 'string') return { value: null, error: 'Photo must be a data URL string.' }; + const trimmed = value.trim(); + if (trimmed.length > MAX_PHOTO_DATA_LENGTH) return { value: null, error: 'Photo is too large.' }; + if (!IMAGE_DATA_RE.test(trimmed)) return { value: null, error: 'Photo must be PNG, JPEG, or WebP.' }; + return { value: trimmed, error: null }; +} + +function loadOpenSession(workerId = null) { + if (workerId) { + return db.get().prepare(` + SELECT * FROM housekeeping_work_sessions + WHERE check_out IS NULL AND worker_id = ? + ORDER BY check_in DESC + LIMIT 1 + `).get(workerId); + } + return db.get().prepare(` + SELECT * FROM housekeeping_work_sessions + WHERE check_out IS NULL + ORDER BY check_in DESC + LIMIT 1 + `).get(); +} + +function loadTodaySession(workerId) { + return db.get().prepare(` + SELECT * FROM housekeeping_work_sessions + WHERE worker_id = ? AND substr(check_in, 1, 10) = substr(strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), 1, 10) + ORDER BY check_in DESC + LIMIT 1 + `).get(workerId); +} + +function housekeepingPaymentTasksEnabled(database = db.get()) { + const row = database.prepare('SELECT value FROM sync_config WHERE key = ?').get(PAYMENT_TASKS_PREF); + return row?.value === '1'; +} + +function defaultDailyRate() { + const worker = loadWorker(); + if (worker) return Number(worker.daily_rate || 0); + const row = db.get().prepare(` + SELECT daily_rate FROM housekeeping_work_sessions + ORDER BY check_in DESC + LIMIT 1 + `).get(); + return Number(row?.daily_rate || 0); +} + +function loadWorker() { + return loadWorkers()[0] ?? null; +} + +function loadWorkers() { + return db.get().prepare(` + SELECT hw.*, + u.username, + u.display_name, + u.avatar_color, + u.avatar_data, + c.phone, + c.email, + b.birth_date + FROM housekeeping_workers hw + JOIN users u ON u.id = hw.user_id + LEFT JOIN contacts c ON c.family_user_id = u.id + LEFT JOIN birthdays b ON b.family_user_id = u.id + ORDER BY u.display_name COLLATE NOCASE ASC + `).all(); +} + +function createVisitCalendarEvent(database, worker, checkIn, actorId, title = null) { + const visitDate = checkIn.slice(0, 10); + const result = database.prepare(` + INSERT INTO calendar_events + (title, start_datetime, end_datetime, all_day, color, icon, assigned_to, created_by, external_source) + VALUES (?, ?, NULL, 1, ?, ?, ?, ?, 'local') + `).run( + title || `Housekeeping: ${worker.display_name}`, + visitDate, + worker.calendar_color || DEFAULT_CALENDAR_COLOR, + HOUSEKEEPING_EVENT_ICON, + worker.user_id, + actorId, + ); + database.prepare('INSERT OR IGNORE INTO event_assignments (event_id, user_id) VALUES (?, ?)') + .run(result.lastInsertRowid, worker.user_id); + return result.lastInsertRowid; +} + +function createPaymentTask(database, worker, checkIn, amount, actorId, title = null, description = null) { + const visitDate = checkIn.slice(0, 10); + const result = database.prepare(` + INSERT INTO tasks (title, description, due_date, priority, category, status, created_by) + VALUES (?, ?, ?, 'medium', 'household', 'open', ?) + `).run( + title || `Pay ${worker.display_name} for housekeeping`, + description || `Housekeeping visit on ${visitDate}. Amount due: ${amount.toFixed(2)}.`, + visitDate, + actorId, + ); + return result.lastInsertRowid; +} + +function updateVisitLinks(database, session, worker, checkIn, dailyRate, extras, eventTitle = null, paymentTitle = null, paymentDescription = null) { + const visitDate = checkIn.slice(0, 10); + if (session.calendar_event_id) { + database.prepare(` + UPDATE calendar_events + SET title = COALESCE(?, title), + start_datetime = ?, + end_datetime = NULL, + all_day = 1, + color = ?, + icon = ? + WHERE id = ? + `).run( + eventTitle, + visitDate, + worker?.calendar_color || DEFAULT_CALENDAR_COLOR, + HOUSEKEEPING_EVENT_ICON, + session.calendar_event_id, + ); + } + if (session.payment_task_id) { + const totalAmount = Number(dailyRate || 0) + Number(extras || 0); + database.prepare(` + UPDATE tasks + SET title = COALESCE(?, title), + description = COALESCE(?, description), + due_date = ? + WHERE id = ? + `).run( + paymentTitle, + paymentDescription || `Housekeeping visit on ${visitDate}. Amount due: ${totalAmount.toFixed(2)}.`, + visitDate, + session.payment_task_id, + ); + } +} + +function deleteVisitLinks(database, session) { + if (session.calendar_event_id) database.prepare('DELETE FROM calendar_events WHERE id = ?').run(session.calendar_event_id); + if (session.payment_task_id) database.prepare('DELETE FROM tasks WHERE id = ?').run(session.payment_task_id); +} + +function reconcilePaymentTasks(database = db.get()) { + database.prepare(` + UPDATE housekeeping_work_sessions + SET paid_at = COALESCE(paid_at, strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + WHERE payment_task_id IS NOT NULL + AND paid_at IS NULL + AND EXISTS ( + SELECT 1 FROM tasks + WHERE tasks.id = housekeeping_work_sessions.payment_task_id + AND tasks.status = 'done' + ) + `).run(); +} + +function loadWorkerById(workerId) { + return db.get().prepare(` + SELECT hw.*, + u.username, + u.display_name, + u.avatar_color, + u.avatar_data, + c.phone, + c.email, + b.birth_date + FROM housekeeping_workers hw + JOIN users u ON u.id = hw.user_id + LEFT JOIN contacts c ON c.family_user_id = u.id + LEFT JOIN birthdays b ON b.family_user_id = u.id + WHERE hw.id = ? + `).get(workerId); +} + +function monthlySummary(monthValue = currentMonth()) { + const row = db.get().prepare(` + SELECT + COUNT(*) AS session_count, + COALESCE(SUM(daily_rate), 0) AS daily_total, + COALESCE(SUM(extras), 0) AS extras_total, + COALESCE(SUM(daily_rate + extras), 0) AS total_amount + FROM housekeeping_work_sessions + WHERE substr(check_in, 1, 7) = ? + `).get(monthValue); + + return { + month: monthValue, + session_count: row.session_count, + daily_total: Number(row.daily_total || 0), + extras_total: Number(row.extras_total || 0), + total_amount: Number(row.total_amount || 0), + }; +} + +function housekeepingDashboard() { + reconcilePaymentTasks(); + const monthValue = currentMonth(); + const workers = loadWorkers().map(publicWorker); + const worker = workers[0] ?? null; + const summary = monthlySummary(monthValue); + const lastVisit = db.get().prepare(` + SELECT * FROM housekeeping_work_sessions + ORDER BY check_in DESC + LIMIT 1 + `).get(); + const payment = db.get().prepare(` + SELECT + COALESCE(SUM(CASE WHEN paid_at IS NULL THEN daily_rate + extras ELSE 0 END), 0) AS pending, + COALESCE(SUM(CASE WHEN paid_at IS NOT NULL THEN daily_rate + extras ELSE 0 END), 0) AS paid + FROM housekeeping_work_sessions + WHERE substr(check_in, 1, 7) = ? + `).get(monthValue); + const taskRows = db.get().prepare('SELECT * FROM housekeeping_decay_tasks').all(); + const tasks = taskRows.map(publicDecayTask); + const chart = db.get().prepare(` + SELECT substr(check_in, 1, 7) AS month, + COALESCE(SUM(daily_rate + extras), 0) AS total, + COALESCE(SUM(CASE WHEN paid_at IS NULL THEN daily_rate + extras ELSE 0 END), 0) AS pending + FROM housekeeping_work_sessions + WHERE check_in >= strftime('%Y-%m-01T00:00:00Z', 'now', '-5 months') + GROUP BY substr(check_in, 1, 7) + ORDER BY month ASC + `).all().map((row) => ({ + month: row.month, + total: Number(row.total || 0), + pending: Number(row.pending || 0), + })); + + return { + worker, + workers, + current_session: null, + visits_this_month: summary.session_count, + last_visit: publicSession(lastVisit), + pending_tasks: tasks.filter((task) => task.urgency_status !== 'ok').length, + finished_tasks_this_month: taskRows.filter((task) => task.last_completed?.slice(0, 7) === monthValue).length, + pending_payments: Number(payment.pending || 0), + paid_this_month: Number(payment.paid || 0), + monthly_payments: chart, + }; +} + +function assertAdmin(req, res) { + if (req.authRole === 'admin') return true; + res.status(403).json({ error: 'Permission denied.', code: 403 }); + return false; +} + +async function createWorkerUser({ username, displayName, avatarColor, avatarData, actorUserId }) { + const finalUsername = username || `housekeeper_${Date.now()}`; + const password = crypto.randomBytes(24).toString('base64url'); + const hash = await bcrypt.hash(password, 12); + const result = db.get().prepare(` + INSERT INTO users (username, display_name, password_hash, avatar_color, avatar_data, role, family_role) + VALUES (?, ?, ?, ?, ?, 'member', 'other') + `).run(finalUsername, displayName, hash, avatarColor || '#7C3AED', avatarData ?? null); + syncFamilyMemberArtifacts(db.get(), result.lastInsertRowid, { + displayName, + avatarData: avatarData ?? null, + actorUserId, + }); + return result.lastInsertRowid; +} + +function defaultShoppingCategory() { + const preferred = db.get() + .prepare("SELECT name FROM shopping_categories WHERE name = 'Haushalt' COLLATE NOCASE LIMIT 1") + .get(); + if (preferred) return preferred.name; + const fallback = db.get() + .prepare("SELECT name FROM shopping_categories WHERE name = 'Sonstiges' COLLATE NOCASE LIMIT 1") + .get(); + return fallback?.name || 'Sonstiges'; +} + +function defaultShoppingList(actorId) { + const existing = db.get().prepare(` + SELECT id FROM shopping_lists + ORDER BY created_at ASC, id ASC + LIMIT 1 + `).get(); + if (existing) return existing.id; + + const result = db.get() + .prepare('INSERT INTO shopping_lists (name, created_by) VALUES (?, ?)') + .run('Housekeeping', actorId); + return result.lastInsertRowid; +} + +router.get('/dashboard', (_req, res) => { + try { + res.json({ data: housekeepingDashboard() }); + } catch (err) { + log.error('GET /dashboard error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + +router.get('/task-templates', (_req, res) => { + res.json({ data: TASK_TEMPLATES }); +}); + +router.get('/worker', (_req, res) => { + try { + res.json({ data: publicWorker(loadWorker()) }); + } catch (err) { + log.error('GET /worker error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + +router.get('/workers', (_req, res) => { + try { + res.json({ data: loadWorkers().map(publicWorker) }); + } catch (err) { + log.error('GET /workers error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + +router.post('/worker', async (req, res) => { + if (!assertAdmin(req, res)) return; + try { + const vWorkerId = req.body.id !== undefined && req.body.id !== null && req.body.id !== '' + ? validateId(req.body.id, 'id') + : { value: null, error: null }; + if (vWorkerId.error) return res.status(400).json({ error: vWorkerId.error, code: 400 }); + const existing = vWorkerId.value ? loadWorkerById(vWorkerId.value) : null; + if (vWorkerId.value && !existing) return res.status(404).json({ error: 'Housekeeper not found.', code: 404 }); + + const vDisplayName = str(req.body.display_name, 'display_name', { max: 128 }); + const vUsername = str(req.body.username, 'username', { max: 64, required: false }); + const vPhone = str(req.body.phone, 'phone', { max: MAX_SHORT, required: false }); + const vEmail = str(req.body.email, 'email', { max: MAX_TITLE, required: false }); + const vBirthDate = date(req.body.birth_date, 'birth_date'); + const vDailyRate = num(req.body.daily_rate, 'daily_rate', { required: true }); + const vSchedule = oneOf(req.body.payment_schedule || 'monthly', PAYMENT_SCHEDULES, 'payment_schedule'); + const vCalendarColor = color(req.body.calendar_color || DEFAULT_CALENDAR_COLOR, 'calendar_color'); + const vNotes = str(req.body.notes, 'notes', { max: MAX_TEXT, required: false }); + const errors = collectErrors([vDisplayName, vUsername, vPhone, vEmail, vBirthDate, vDailyRate, vSchedule, vCalendarColor, vNotes]); + if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); + if (vUsername.value && !/^[a-zA-Z0-9._-]{3,64}$/.test(vUsername.value)) { + return res.status(400).json({ error: 'Username must be 3-64 characters long and may only contain letters, numbers, dots, hyphens, and underscores.', code: 400 }); + } + if (vDailyRate.value < 0) { + return res.status(400).json({ error: 'daily_rate must be greater than or equal to zero.', code: 400 }); + } + const avatarColor = String(req.body.avatar_color || '#7C3AED').trim(); + const avatarData = req.body.avatar_data !== undefined + ? normalizeAvatarData(req.body.avatar_data) + : existing?.avatar_data ?? null; + if (avatarData?.error) { + return res.status(400).json({ error: avatarData.error, code: 400 }); + } + + const actorId = userId(req); + const targetUserId = existing ? existing.user_id : await createWorkerUser({ + username: vUsername.value, + displayName: vDisplayName.value, + avatarColor, + avatarData, + actorUserId: actorId, + }); + + db.get().transaction(() => { + db.get().prepare(` + UPDATE users + SET username = ?, display_name = ?, avatar_color = ?, avatar_data = ? + WHERE id = ? + `).run( + vUsername.value || existing?.username || `housekeeper_${targetUserId}`, + vDisplayName.value, + avatarColor || '#7C3AED', + avatarData ?? null, + targetUserId, + ); + db.get().prepare(` + INSERT INTO housekeeping_workers (user_id, daily_rate, payment_schedule, calendar_color, notes) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + daily_rate = excluded.daily_rate, + payment_schedule = excluded.payment_schedule, + calendar_color = excluded.calendar_color, + notes = excluded.notes + `).run(targetUserId, vDailyRate.value, vSchedule.value, vCalendarColor.value, vNotes.value); + syncFamilyMemberArtifacts(db.get(), targetUserId, { + displayName: vDisplayName.value, + phone: vPhone.value, + email: vEmail.value, + birthDate: vBirthDate.value, + avatarData: avatarData ?? null, + actorUserId: actorId, + }); + })(); + + const saved = existing ? loadWorkerById(existing.id) : loadWorkers().find((worker) => worker.user_id === targetUserId); + res.status(existing ? 200 : 201).json({ data: publicWorker(saved) }); + } catch (err) { + if (err.message?.includes('UNIQUE constraint')) { + return res.status(409).json({ error: 'Username is already taken.', code: 409 }); + } + log.error('POST /worker error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + +router.get('/summary', (req, res) => { + try { + const vMonth = month(req.query.month, 'month'); + if (vMonth.error) return res.status(400).json({ error: vMonth.error, code: 400 }); + res.json({ + data: { + current_session: publicSession(loadOpenSession()), + default_daily_rate: defaultDailyRate(), + summary: monthlySummary(vMonth.value || currentMonth()), + }, + }); + } catch (err) { + log.error('GET /summary error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + +router.get('/work-sessions', (req, res) => { + try { + reconcilePaymentTasks(); + const vMonth = month(req.query.month, 'month'); + if (vMonth.error) return res.status(400).json({ error: vMonth.error, code: 400 }); + const rows = db.get().prepare(` + SELECT * FROM housekeeping_work_sessions + WHERE substr(check_in, 1, 7) = ? + ORDER BY check_in DESC + `).all(vMonth.value || currentMonth()); + res.json({ data: rows.map(publicSession) }); + } catch (err) { + log.error('GET /work-sessions error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + +router.get('/visits', (req, res) => { + try { + reconcilePaymentTasks(); + const vMonth = month(req.query.month, 'month'); + if (vMonth.error) return res.status(400).json({ error: vMonth.error, code: 400 }); + const vWorkerId = req.query.worker_id !== undefined && req.query.worker_id !== '' + ? validateId(req.query.worker_id, 'worker_id') + : { value: null, error: null }; + if (vWorkerId.error) return res.status(400).json({ error: vWorkerId.error, code: 400 }); + const selectedMonth = vMonth.value || currentMonth(); + const rows = db.get().prepare(` + SELECT hws.*, + hw.payment_schedule, + u.display_name AS worker_name, + u.avatar_color AS worker_avatar_color, + u.avatar_data AS worker_avatar_data, + t.status AS payment_task_status, + t.title AS payment_task_title, + fd.name AS receipt_document_name + FROM housekeeping_work_sessions hws + LEFT JOIN housekeeping_workers hw ON hw.id = hws.worker_id + LEFT JOIN users u ON u.id = hw.user_id + LEFT JOIN tasks t ON t.id = hws.payment_task_id + LEFT JOIN family_documents fd ON fd.id = hws.receipt_document_id + WHERE substr(hws.check_in, 1, 7) = ? + AND (? IS NULL OR hws.worker_id = ?) + ORDER BY hws.check_in DESC + `).all(selectedMonth, vWorkerId.value, vWorkerId.value); + const visits = rows.map((row) => ({ + ...publicSession(row), + worker_name: row.worker_name ?? null, + worker_avatar_color: row.worker_avatar_color ?? DEFAULT_CALENDAR_COLOR, + worker_avatar_data: row.worker_avatar_data ?? null, + payment_schedule: row.payment_schedule ?? 'monthly', + payment_task_status: row.payment_task_status ?? null, + payment_task_title: row.payment_task_title ?? null, + receipt_document_name: row.receipt_document_name ?? null, + total_amount: Number(row.daily_rate || 0) + Number(row.extras || 0), + })); + const totals = visits.reduce((acc, visit) => { + acc.total += visit.total_amount; + if (visit.paid_at) acc.paid += visit.total_amount; + else acc.pending += visit.total_amount; + return acc; + }, { total: 0, paid: 0, pending: 0 }); + res.json({ data: { month: selectedMonth, visits, totals } }); + } catch (err) { + log.error('GET /visits error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + +router.post('/work-sessions/check-in', (req, res) => { + try { + if (loadWorkers().length === 0) { + return res.status(400).json({ error: 'Add a housekeeper before checking in.', code: 400 }); + } + const vWorkerId = validateId(req.body.worker_id, 'worker_id'); + if (vWorkerId.error) return res.status(400).json({ error: vWorkerId.error, code: 400 }); + const worker = loadWorkerById(vWorkerId.value); + if (!worker) return res.status(404).json({ error: 'Housekeeper not found.', code: 404 }); + if (loadTodaySession(worker.id)) return res.status(409).json({ error: 'A visit is already recorded today for this housekeeper.', code: 409 }); + + const vDailyRate = num(req.body.daily_rate, 'daily_rate', { required: true }); + const vExtras = num(req.body.extras, 'extras'); + const vEventTitle = str(req.body.event_title, 'event_title', { max: MAX_TITLE, required: false }); + const vPaymentTitle = str(req.body.payment_title, 'payment_title', { max: MAX_TITLE, required: false }); + const vPaymentDescription = str(req.body.payment_description, 'payment_description', { max: MAX_TEXT, required: false }); + const errors = collectErrors([vDailyRate, vExtras, vEventTitle, vPaymentTitle, vPaymentDescription]); + if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); + if (vDailyRate.value < 0 || (vExtras.value ?? 0) < 0) { + return res.status(400).json({ error: 'Amounts must be greater than or equal to zero.', code: 400 }); + } + + const actorId = userId(req); + const checkIn = nowIso(); + const result = db.get().transaction(() => { + const eventId = createVisitCalendarEvent(db.get(), worker, checkIn, actorId, vEventTitle.value); + const totalAmount = Number(vDailyRate.value || 0) + Number(vExtras.value || 0); + const taskId = housekeepingPaymentTasksEnabled(db.get()) + ? createPaymentTask(db.get(), worker, checkIn, totalAmount, actorId, vPaymentTitle.value, vPaymentDescription.value) + : null; + return db.get().prepare(` + INSERT INTO housekeeping_work_sessions (worker_id, check_in, check_out, daily_rate, extras, calendar_event_id, payment_task_id, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run(worker.id, checkIn, checkIn, vDailyRate.value, vExtras.value ?? 0, eventId, taskId, actorId); + })(); + const row = db.get().prepare('SELECT * FROM housekeeping_work_sessions WHERE id = ?').get(result.lastInsertRowid); + res.status(201).json({ data: publicSession(row), summary: monthlySummary() }); + } catch (err) { + log.error('POST /work-sessions/check-in error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + +router.put('/visits/:id', (req, res) => { + try { + const vId = validateId(req.params.id, 'id'); + if (vId.error) return res.status(400).json({ error: vId.error, code: 400 }); + const existing = db.get().prepare('SELECT * FROM housekeeping_work_sessions WHERE id = ?').get(vId.value); + if (!existing) return res.status(404).json({ error: 'Visit not found.', code: 404 }); + + const vDate = date(req.body.date, 'date', true); + const vDailyRate = num(req.body.daily_rate, 'daily_rate', { required: true }); + const vExtras = num(req.body.extras, 'extras'); + const vEventTitle = str(req.body.event_title, 'event_title', { max: MAX_TITLE, required: false }); + const vPaymentTitle = str(req.body.payment_title, 'payment_title', { max: MAX_TITLE, required: false }); + const vPaymentDescription = str(req.body.payment_description, 'payment_description', { max: MAX_TEXT, required: false }); + const vReceiptId = req.body.receipt_document_id !== undefined && req.body.receipt_document_id !== null && req.body.receipt_document_id !== '' + ? validateId(req.body.receipt_document_id, 'receipt_document_id') + : { value: null, error: null }; + const errors = collectErrors([vDate, vDailyRate, vExtras, vEventTitle, vPaymentTitle, vPaymentDescription, vReceiptId]); + if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); + if (vDailyRate.value < 0 || (vExtras.value ?? 0) < 0) { + return res.status(400).json({ error: 'Amounts must be greater than or equal to zero.', code: 400 }); + } + + const originalTime = existing.check_in?.slice(11) || '09:00:00.000Z'; + const checkIn = `${vDate.value}T${originalTime}`; + const worker = existing.worker_id ? loadWorkerById(existing.worker_id) : null; + db.get().transaction(() => { + db.get().prepare(` + UPDATE housekeeping_work_sessions + SET check_in = ?, check_out = ?, daily_rate = ?, extras = ?, receipt_document_id = ? + WHERE id = ? + `).run( + checkIn, + checkIn, + vDailyRate.value, + vExtras.value ?? 0, + req.body.receipt_document_id !== undefined ? vReceiptId.value : existing.receipt_document_id, + existing.id, + ); + updateVisitLinks( + db.get(), + existing, + worker, + checkIn, + vDailyRate.value, + vExtras.value ?? 0, + vEventTitle.value, + vPaymentTitle.value, + vPaymentDescription.value, + ); + })(); + const row = db.get().prepare('SELECT * FROM housekeeping_work_sessions WHERE id = ?').get(existing.id); + res.json({ data: publicSession(row), summary: monthlySummary(row.check_in.slice(0, 7)) }); + } catch (err) { + log.error('PUT /visits/:id error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + +router.post('/visits/:id/pay', (req, res) => { + try { + const vId = validateId(req.params.id, 'id'); + if (vId.error) return res.status(400).json({ error: vId.error, code: 400 }); + const existing = db.get().prepare('SELECT * FROM housekeeping_work_sessions WHERE id = ?').get(vId.value); + if (!existing) return res.status(404).json({ error: 'Visit not found.', code: 404 }); + const paidAt = nowIso(); + db.get().transaction(() => { + db.get().prepare('UPDATE housekeeping_work_sessions SET paid_at = ? WHERE id = ?').run(paidAt, existing.id); + if (existing.payment_task_id) { + db.get().prepare('UPDATE tasks SET status = ? WHERE id = ?').run('done', existing.payment_task_id); + } + })(); + const row = db.get().prepare('SELECT * FROM housekeeping_work_sessions WHERE id = ?').get(existing.id); + res.json({ data: publicSession(row), summary: monthlySummary(row.check_in.slice(0, 7)) }); + } catch (err) { + log.error('POST /visits/:id/pay error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + +router.delete('/visits/:id', (req, res) => { + try { + const vId = validateId(req.params.id, 'id'); + if (vId.error) return res.status(400).json({ error: vId.error, code: 400 }); + const existing = db.get().prepare('SELECT * FROM housekeeping_work_sessions WHERE id = ?').get(vId.value); + if (!existing) return res.status(404).json({ error: 'Visit not found.', code: 404 }); + db.get().transaction(() => { + deleteVisitLinks(db.get(), existing); + db.get().prepare('DELETE FROM housekeeping_work_sessions WHERE id = ?').run(existing.id); + })(); + res.json({ ok: true, summary: monthlySummary() }); + } catch (err) { + log.error('DELETE /visits/:id error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + +router.post('/work-sessions/check-out', (req, res) => { + try { + const vWorkerId = validateId(req.body.worker_id, 'worker_id'); + if (vWorkerId.error) return res.status(400).json({ error: vWorkerId.error, code: 400 }); + const session = loadOpenSession(vWorkerId.value); + if (!session) return res.status(404).json({ error: 'No open work session found.', code: 404 }); + + const vExtras = num(req.body.extras, 'extras'); + if (vExtras.error) return res.status(400).json({ error: vExtras.error, code: 400 }); + if ((vExtras.value ?? session.extras) < 0) { + return res.status(400).json({ error: 'Extras must be greater than or equal to zero.', code: 400 }); + } + + const checkOut = nowIso(); + db.get().transaction(() => { + db.get().prepare(` + UPDATE housekeeping_work_sessions + SET check_out = ?, extras = ? + WHERE id = ? + `).run(checkOut, vExtras.value ?? session.extras, session.id); + })(); + const row = db.get().prepare('SELECT * FROM housekeeping_work_sessions WHERE id = ?').get(session.id); + res.json({ data: publicSession(row), summary: monthlySummary(row.check_in.slice(0, 7)) }); + } catch (err) { + log.error('POST /work-sessions/check-out error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + +router.get('/decay-tasks', (_req, res) => { + try { + const rows = db.get().prepare('SELECT * FROM housekeeping_decay_tasks ORDER BY area COLLATE NOCASE, name COLLATE NOCASE').all(); + const tasks = rows + .map(publicDecayTask) + .sort((a, b) => { + const rank = { overdue: 0, today: 1, ok: 2 }; + const rankDiff = rank[a.urgency_status] - rank[b.urgency_status]; + if (rankDiff !== 0) return rankDiff; + return (b.urgency ?? 9999) - (a.urgency ?? 9999); + }); + res.json({ data: tasks }); + } catch (err) { + log.error('GET /decay-tasks error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + +router.post('/decay-tasks', (req, res) => { + try { + const vName = str(req.body.name, 'name', { max: MAX_TITLE }); + const vArea = str(req.body.area, 'area', { max: MAX_SHORT }); + const vFrequency = num(req.body.frequency_days, 'frequency_days', { required: true }); + const vCompleted = datetime(req.body.last_completed, 'last_completed'); + const errors = collectErrors([vName, vArea, vFrequency, vCompleted]); + if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); + if (!Number.isInteger(vFrequency.value) || vFrequency.value < 1) { + return res.status(400).json({ error: 'frequency_days must be a positive integer.', code: 400 }); + } + + const result = db.get().prepare(` + INSERT INTO housekeeping_decay_tasks (name, area, frequency_days, last_completed, created_by) + VALUES (?, ?, ?, ?, ?) + `).run(vName.value, vArea.value, vFrequency.value, vCompleted.value, userId(req)); + const row = db.get().prepare('SELECT * FROM housekeeping_decay_tasks WHERE id = ?').get(result.lastInsertRowid); + res.status(201).json({ data: publicDecayTask(row) }); + } catch (err) { + log.error('POST /decay-tasks error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + +router.patch('/decay-tasks/:taskId', (req, res) => { + try { + const vId = validateId(req.params.taskId, 'taskId'); + if (vId.error) return res.status(400).json({ error: vId.error, code: 400 }); + const existing = db.get().prepare('SELECT * FROM housekeeping_decay_tasks WHERE id = ?').get(vId.value); + if (!existing) return res.status(404).json({ error: 'Task not found.', code: 404 }); + + const vName = req.body.name !== undefined ? str(req.body.name, 'name', { max: MAX_TITLE }) : { value: existing.name, error: null }; + const vArea = req.body.area !== undefined ? str(req.body.area, 'area', { max: MAX_SHORT }) : { value: existing.area, error: null }; + const vFrequency = req.body.frequency_days !== undefined ? num(req.body.frequency_days, 'frequency_days', { required: true }) : { value: existing.frequency_days, error: null }; + const vCompleted = req.body.last_completed !== undefined ? datetime(req.body.last_completed, 'last_completed') : { value: existing.last_completed, error: null }; + const errors = collectErrors([vName, vArea, vFrequency, vCompleted]); + if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); + if (!Number.isInteger(Number(vFrequency.value)) || Number(vFrequency.value) < 1) { + return res.status(400).json({ error: 'frequency_days must be a positive integer.', code: 400 }); + } + + db.get().prepare(` + UPDATE housekeeping_decay_tasks + SET name = ?, area = ?, frequency_days = ?, last_completed = ? + WHERE id = ? + `).run(vName.value, vArea.value, Number(vFrequency.value), vCompleted.value, vId.value); + const row = db.get().prepare('SELECT * FROM housekeeping_decay_tasks WHERE id = ?').get(vId.value); + res.json({ data: publicDecayTask(row) }); + } catch (err) { + log.error('PATCH /decay-tasks/:taskId error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + +router.post('/decay-tasks/:taskId/complete', (req, res) => { + try { + const vId = validateId(req.params.taskId, 'taskId'); + if (vId.error) return res.status(400).json({ error: vId.error, code: 400 }); + const existing = db.get().prepare('SELECT * FROM housekeeping_decay_tasks WHERE id = ?').get(vId.value); + if (!existing) return res.status(404).json({ error: 'Task not found.', code: 404 }); + + db.get().prepare('UPDATE housekeeping_decay_tasks SET last_completed = ? WHERE id = ?').run(nowIso(), vId.value); + const row = db.get().prepare('SELECT * FROM housekeeping_decay_tasks WHERE id = ?').get(vId.value); + res.json({ data: publicDecayTask(row) }); + } catch (err) { + log.error('POST /decay-tasks/:taskId/complete error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + +router.delete('/decay-tasks/:taskId', (req, res) => { + try { + const vId = validateId(req.params.taskId, 'taskId'); + if (vId.error) return res.status(400).json({ error: vId.error, code: 400 }); + const result = db.get().prepare('DELETE FROM housekeeping_decay_tasks WHERE id = ?').run(vId.value); + if (result.changes === 0) return res.status(404).json({ error: 'Task not found.', code: 404 }); + res.json({ ok: true }); + } catch (err) { + log.error('DELETE /decay-tasks/:taskId error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + +router.post('/supply-requests', (req, res) => { + try { + const vName = str(req.body.name, 'name', { max: MAX_TITLE }); + const vQuantity = str(req.body.quantity, 'quantity', { max: MAX_SHORT, required: false }); + const errors = collectErrors([vName, vQuantity]); + if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); + + const actorId = userId(req); + const result = db.get().transaction(() => { + const listId = defaultShoppingList(actorId); + const item = db.get().prepare(` + INSERT INTO shopping_items (list_id, name, quantity, category) + VALUES (?, ?, ?, ?) + `).run(listId, vName.value, vQuantity.value, defaultShoppingCategory()); + const request = db.get().prepare(` + INSERT INTO housekeeping_supply_requests (name, quantity, shopping_item_id, created_by) + VALUES (?, ?, ?, ?) + `).run(vName.value, vQuantity.value, item.lastInsertRowid, actorId); + return { + requestId: request.lastInsertRowid, + shoppingItemId: item.lastInsertRowid, + }; + })(); + + const row = db.get().prepare('SELECT * FROM housekeeping_supply_requests WHERE id = ?').get(result.requestId); + res.status(201).json({ data: row, shopping_item_id: result.shoppingItemId }); + } catch (err) { + log.error('POST /supply-requests error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + +router.get('/maintenance-log', (_req, res) => { + try { + const rows = db.get().prepare('SELECT * FROM housekeeping_maintenance_log ORDER BY created_at DESC, id DESC').all(); + res.json({ data: rows }); + } catch (err) { + log.error('GET /maintenance-log error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + +router.post('/maintenance-log', (req, res) => { + try { + const vDescription = str(req.body.description, 'description', { max: MAX_TEXT }); + const vPhoto = validatePhotoUrl(req.body.photo_url); + const errors = collectErrors([vDescription, vPhoto]); + if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); + + const result = db.get().prepare(` + INSERT INTO housekeeping_maintenance_log (description, photo_url, created_by) + VALUES (?, ?, ?) + `).run(vDescription.value, vPhoto.value, userId(req)); + const row = db.get().prepare('SELECT * FROM housekeeping_maintenance_log WHERE id = ?').get(result.lastInsertRowid); + res.status(201).json({ data: row }); + } catch (err) { + log.error('POST /maintenance-log error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + +export default router; diff --git a/server/routes/preferences.js b/server/routes/preferences.js index 3ae7ba2..1770c4d 100644 --- a/server/routes/preferences.js +++ b/server/routes/preferences.js @@ -144,6 +144,7 @@ router.get('/', (req, res) => { app_name: appName, dashboard_widgets: dashboardWidgets, disabled_modules: disabledModules, + housekeeping_payment_tasks: cfgGet('housekeeping_payment_tasks') === '1', }, }); } catch (err) { @@ -161,7 +162,7 @@ router.get('/', (req, res) => { router.put('/', (req, res) => { try { - const { visible_meal_types, currency, date_format, time_format, app_name, dashboard_widgets, disabled_modules } = req.body; + const { visible_meal_types, currency, date_format, time_format, app_name, dashboard_widgets, disabled_modules, housekeeping_payment_tasks } = req.body; if (visible_meal_types !== undefined) { if (!Array.isArray(visible_meal_types)) { @@ -220,6 +221,13 @@ router.put('/', (req, res) => { cfgSet('disabled_modules', JSON.stringify(unique)); } + if (housekeeping_payment_tasks !== undefined) { + if (typeof housekeeping_payment_tasks !== 'boolean') { + return res.status(400).json({ error: 'housekeeping_payment_tasks must be a boolean', code: 400 }); + } + cfgSet('housekeeping_payment_tasks', housekeeping_payment_tasks ? '1' : '0'); + } + const rawMealTypes = cfgGet('visible_meal_types') ?? DEFAULT_MEAL_TYPES; const savedMealTypes = rawMealTypes.split(',').filter((t) => VALID_MEAL_TYPES.includes(t)); const savedCurrency = cfgGet('currency') ?? DEFAULT_CURRENCY; @@ -228,6 +236,7 @@ router.put('/', (req, res) => { const savedAppName = cfgGet('app_name') ?? DEFAULT_APP_NAME; const savedWidgets = parseWidgetConfig(cfgGet('dashboard_widgets')); const savedDisabledModules = parseDisabledModules(cfgGet('disabled_modules')); + const savedHousekeepingPaymentTasks = cfgGet('housekeeping_payment_tasks') === '1'; res.json({ data: { @@ -238,6 +247,7 @@ router.put('/', (req, res) => { app_name: savedAppName, dashboard_widgets: savedWidgets, disabled_modules: savedDisabledModules, + housekeeping_payment_tasks: savedHousekeepingPaymentTasks, }, }); } catch (err) { diff --git a/server/routes/tasks.js b/server/routes/tasks.js index 624b792..feae1e8 100644 --- a/server/routes/tasks.js +++ b/server/routes/tasks.js @@ -53,6 +53,19 @@ function setAssignments(d, taskId, userIds) { for (const uid of userIds) ins.run(taskId, uid); } +function syncHousekeepingPaymentStatus(d, taskId, status) { + const table = d.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'housekeeping_work_sessions'").get(); + if (!table) return; + d.prepare(` + UPDATE housekeeping_work_sessions + SET paid_at = CASE + WHEN ? = 'done' THEN COALESCE(paid_at, strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ELSE NULL + END + WHERE payment_task_id = ? + `).run(status, taskId); +} + /** Alle Subtasks einer Aufgabe laden (eine Ebene tief). */ function loadSubtasks(taskId) { return db.get().prepare(` @@ -274,6 +287,7 @@ router.put('/:id', (req, res) => { status, due_date, due_time, firstUid, is_recurring ? 1 : 0, recurrence_rule, req.params.id); setAssignments(db.get(), task.id, userIds); + syncHousekeepingPaymentStatus(db.get(), req.params.id, status); })(); const updated = db.get().prepare(` @@ -310,6 +324,8 @@ router.patch('/:id/status', (req, res) => { if (result.changes === 0) return res.status(404).json({ error: 'Task not found.', code: 404 }); + syncHousekeepingPaymentStatus(db.get(), req.params.id, status); + // Wiederkehrende Aufgabe: nächste Instanz erstellen wenn erledigt if (status === 'done') { const task = db.get().prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id); diff --git a/server/services/birthdays.js b/server/services/birthdays.js index 175201e..d9362f4 100644 --- a/server/services/birthdays.js +++ b/server/services/birthdays.js @@ -57,8 +57,7 @@ function getOffsetMinutes(birthday) { function birthdayReminderAt(birthDate, offsetMin = 0, from = new Date()) { const next = nextBirthdayDate(birthDate, from); - const baseTime = new Date(`${next}T12:00:00Z`).getTime(); - return new Date(baseTime - (offsetMin || 0) * 60000).toISOString(); + return `${next}T12:00:00Z`; } function eventTitle(name) {