feat: add housekeeping module for household staff management

* Adding flexible reminder options to birthdays

* Fix database migration merge conflict

* Truncate calendar popup descriptions

* Log app version on backend startup

* Add host-mounted data and backup folders

* feat: add housekeeping module

* fix: align housekeeping UI and add task creation

* refactor: rebuild housekeeping experience

* feat: support multiple housekeeping staff

* feat: integrate housekeeping visits with calendar

* feat: refine housekeeping visits and payments

* feat: add housekeeping staff visit logs

* feat: add housekeeping receipts and document folders

* feat: localize housekeeping folders and chores

* feat: refine housekeeping tabs and document folders

* fix: sync housekeeping tab active state

* feat: use configured app name in onboarding and manifest
This commit is contained in:
Rafael Foster
2026-05-08 15:14:51 -03:00
committed by GitHub
parent d19689a1ab
commit 22ec13e559
38 changed files with 7127 additions and 235 deletions
+134
View File
@@ -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`
+88
View File
@@ -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.
+1 -1
View File
@@ -18,7 +18,7 @@
<title>Oikos</title> <title>Oikos</title>
<!-- PWA --> <!-- PWA -->
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" /> <link rel="apple-touch-icon" href="/icons/icon-192.png" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" /> <link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />
+249 -6
View File
@@ -54,7 +54,8 @@
"more": "المزيد", "more": "المزيد",
"documents": "المستندات", "documents": "المستندات",
"kitchen": "المطبخ", "kitchen": "المطبخ",
"search": "بحث" "search": "بحث",
"housekeeping": "Housekeeping"
}, },
"dashboard": { "dashboard": {
"title": "لوحة التحكم", "title": "لوحة التحكم",
@@ -205,7 +206,18 @@
"kanbanArchived": "مؤرشف", "kanbanArchived": "مؤرشف",
"reminderNeedsDueDate": "حدّد تاريخ استحقاق لتفعيل تذكيرات المهمة.", "reminderNeedsDueDate": "حدّد تاريخ استحقاق لتفعيل تذكيرات المهمة.",
"emptyAction": "إنشاء مهمة", "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": { "shopping": {
"title": "التسوق", "title": "التسوق",
@@ -479,7 +491,13 @@
"colorPurple": "بنفسجي", "colorPurple": "بنفسجي",
"colorRed": "أحمر", "colorRed": "أحمر",
"colorSkyBlue": "أزرق سماوي", "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": { "notes": {
"title": "لوحة الملاحظات", "title": "لوحة الملاحظات",
@@ -977,7 +995,72 @@
"addressbookEnabled": "Addressbook enabled", "addressbookEnabled": "Addressbook enabled",
"addressbookDisabled": "Addressbook disabled", "addressbookDisabled": "Addressbook disabled",
"addressbooksRefreshed": "Addressbooks refreshed", "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": { "login": {
"tagline": "تخطيط عائلي. آمن. يحترم الخصوصية. مفتوح المصدر.", "tagline": "تخطيط عائلي. آمن. يحترم الخصوصية. مفتوح المصدر.",
@@ -1120,7 +1203,7 @@
"customWeeks": "Weeks" "customWeeks": "Weeks"
}, },
"onboarding": { "onboarding": {
"step1Title": "Welcome to Oikos", "step1Title": "مرحبًا بك في {{name}}",
"step1Body": "Your personal family planner. Tasks, calendar, shopping and more all in one place.", "step1Body": "Your personal family planner. Tasks, calendar, shopping and more all in one place.",
"step2Title": "التنقل والوحدات", "step2Title": "التنقل والوحدات",
"step2Body": "في الأسفل يمكنك الوصول مباشرة إلى لوحة التحكم والتقويم. بزر ··· تفتح وحدات أخرى مثل المطبخ والملاحظات وجهات الاتصال.", "step2Body": "في الأسفل يمكنك الوصول مباشرة إلى لوحة التحكم والتقويم. بزر ··· تفتح وحدات أخرى مثل المطبخ والملاحظات وجهات الاتصال.",
@@ -1203,7 +1286,18 @@
}, },
"dropzoneTitle": "أفلت الملف هنا أو انقر للاختيار", "dropzoneTitle": "أفلت الملف هنا أو انقر للاختيار",
"dropzoneHint": "اسحب ملفًا إلى هذه المنطقة أو استخدم محدد الملفات.", "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": { "shortcuts": {
"goKitchen": "المطبخ", "goKitchen": "المطبخ",
@@ -1215,5 +1309,154 @@
"help": "اختصارات لوحة المفاتيح", "help": "اختصارات لوحة المفاتيح",
"new": "إنشاء إدخال جديد", "new": "إنشاء إدخال جديد",
"search": "فتح البحث" "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 -"
} }
} }
+172 -11
View File
@@ -54,7 +54,8 @@
"recipes": "Rezepte", "recipes": "Rezepte",
"documents": "Dokumente", "documents": "Dokumente",
"kitchen": "Küche", "kitchen": "Küche",
"search": "Suche" "search": "Suche",
"housekeeping": "Hauspflege"
}, },
"search": { "search": {
"title": "Suche", "title": "Suche",
@@ -499,7 +500,10 @@
"attachmentHint": "Lokales Bild, PDF oder Dokument anhängen. Bilder werden im Ereignis-Popup angezeigt.", "attachmentHint": "Lokales Bild, PDF oder Dokument anhängen. Bilder werden im Ereignis-Popup angezeigt.",
"attachmentFallback": "Anhang", "attachmentFallback": "Anhang",
"attachmentReadError": "Der Anhang konnte nicht gelesen werden.", "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": { "notes": {
"title": "Notizen", "title": "Notizen",
@@ -779,7 +783,7 @@
"tabFamily": "Familie", "tabFamily": "Familie",
"tabApiTokens": "API-Tokens", "tabApiTokens": "API-Tokens",
"tabAccount": "Konto", "tabAccount": "Konto",
"tabBackup": "Backup", "tabBackup": "Backup-Verwaltung",
"tabsAriaLabel": "Einstellungsbereiche", "tabsAriaLabel": "Einstellungsbereiche",
"sectionDesign": "Design", "sectionDesign": "Design",
"sectionAppName": "Anwendungsname", "sectionAppName": "Anwendungsname",
@@ -845,7 +849,7 @@
"disconnectedToast": "{{provider}} getrennt.", "disconnectedToast": "{{provider}} getrennt.",
"googleOnlyAdmin": "Nur Admin kann Google Calendar verbinden.", "googleOnlyAdmin": "Nur Admin kann Google Calendar verbinden.",
"appleOnlyAdmin": "Nur Admin kann Apple Calendar verbinden.", "appleOnlyAdmin": "Nur Admin kann Apple Calendar verbinden.",
"caldavUrlLabel": "CalDAV-Server-URL", "caldavUrlLabel": "CalDAV Server-URL",
"caldavUrlPlaceholder": "https://caldav.icloud.com", "caldavUrlPlaceholder": "https://caldav.icloud.com",
"appleIdLabel": "Apple-ID (E-Mail)", "appleIdLabel": "Apple-ID (E-Mail)",
"applePasswordLabel": "App-spezifisches Passwort", "applePasswordLabel": "App-spezifisches Passwort",
@@ -975,7 +979,6 @@
"memberBirthDateInvalid": "Bitte ein gültiges Geburtstagsdatum im ausgewählten Format verwenden.", "memberBirthDateInvalid": "Bitte ein gültiges Geburtstagsdatum im ausgewählten Format verwenden.",
"memberPhoneMeta": "Telefon: {{value}}", "memberPhoneMeta": "Telefon: {{value}}",
"memberBirthdayMeta": "Geburtstag: {{date}}", "memberBirthdayMeta": "Geburtstag: {{date}}",
"tabBackup": "Backup-Verwaltung",
"sectionBackup": "Backup-Verwaltung", "sectionBackup": "Backup-Verwaltung",
"backupDownloadTitle": "Datenbank-Backup herunterladen", "backupDownloadTitle": "Datenbank-Backup herunterladen",
"backupDownloadHint": "Erstellt ein konsistentes SQLite-Backup aller Anwendungsdaten.", "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.", "caldavEmptyState": "Noch keine CalDAV-Konten verbunden. Füge dein erstes Konto hinzu, um zu starten.",
"caldavNameLabel": "Kontoname", "caldavNameLabel": "Kontoname",
"caldavNamePlaceholder": "z.B. Mein Radicale, iCloud, Nextcloud", "caldavNamePlaceholder": "z.B. Mein Radicale, iCloud, Nextcloud",
"caldavUrlLabel": "CalDAV Server-URL",
"caldavUrlPlaceholder": "https://caldav.icloud.com",
"caldavUrlHint": "Die Basis-URL deines CalDAV-Servers", "caldavUrlHint": "Die Basis-URL deines CalDAV-Servers",
"caldavUsernameLabel": "Benutzername", "caldavUsernameLabel": "Benutzername",
"caldavPasswordLabel": "Passwort", "caldavPasswordLabel": "Passwort",
@@ -1030,7 +1031,6 @@
"calendarsRefreshed": "Kalender aktualisiert", "calendarsRefreshed": "Kalender aktualisiert",
"deleteAccountConfirm": "CalDAV-Konto wirklich löschen? Alle synchronisierten Kalender werden entfernt.", "deleteAccountConfirm": "CalDAV-Konto wirklich löschen? Alle synchronisierten Kalender werden entfernt.",
"lastSync": "Zuletzt synchronisiert", "lastSync": "Zuletzt synchronisiert",
"cardavTitle": "CardDAV Kontakte",
"cardavDescription": "Verbinde mehrere CardDAV-Konten (iCloud, Nextcloud, Radicale, etc.) und synchronisiere deine Kontakte.", "cardavDescription": "Verbinde mehrere CardDAV-Konten (iCloud, Nextcloud, Radicale, etc.) und synchronisiere deine Kontakte.",
"cardavAddAccount": "CardDAV-Konto hinzufügen", "cardavAddAccount": "CardDAV-Konto hinzufügen",
"cardavEmptyState": "Noch keine CardDAV-Konten verbunden. Füge dein erstes Konto hinzu, um Kontakte zu synchronisieren.", "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.", "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.", "helpTooltipCardDAV": "CardDAV ermöglicht die Synchronisation von Kontakten mit iCloud, Nextcloud und anderen CardDAV-Servern.",
"emptyStateAddFirst": "Füge dein erstes Konto hinzu", "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": { "login": {
"tagline": "Familienplanung. Sicher. Datenschutzfreundlich. Open Source.", "tagline": "Familienplanung. Sicher. Datenschutzfreundlich. Open Source.",
@@ -1198,7 +1203,7 @@
"copySuffix": "Kopie" "copySuffix": "Kopie"
}, },
"onboarding": { "onboarding": {
"step1Title": "Willkommen bei Oikos", "step1Title": "Willkommen bei {{name}}",
"step1Body": "Dein persönlicher Familienplaner. Aufgaben, Kalender, Einkauf und mehr alles an einem Ort.", "step1Body": "Dein persönlicher Familienplaner. Aufgaben, Kalender, Einkauf und mehr alles an einem Ort.",
"step2Title": "Navigation & Module", "step2Title": "Navigation & Module",
"step2Body": "Unten erreichst du Dashboard und Kalender direkt. Mit dem ···-Button öffnest du weitere Module wie Küche, Notizen und Kontakte.", "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", "dropzoneTitle": "Datei hier ablegen oder klicken",
"dropzoneHint": "Ziehe eine Datei in diesen Bereich oder nutze die Dateiauswahl.", "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": { "userMultiSelect": {
"nobody": "- Niemand -", "nobody": "- Niemand -",
"moreUsers": "weitere" "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"
}
}
} }
} }
+249 -6
View File
@@ -54,7 +54,8 @@
"more": "Περισσότερα", "more": "Περισσότερα",
"documents": "Έγγραφα", "documents": "Έγγραφα",
"kitchen": "Κουζίνα", "kitchen": "Κουζίνα",
"search": "Αναζήτηση" "search": "Αναζήτηση",
"housekeeping": "Housekeeping"
}, },
"dashboard": { "dashboard": {
"title": "Επισκόπηση", "title": "Επισκόπηση",
@@ -205,7 +206,18 @@
"kanbanArchived": "Αρχειοθετημένο", "kanbanArchived": "Αρχειοθετημένο",
"reminderNeedsDueDate": "Ορίστε ημερομηνία λήξης για να ενεργοποιήσετε τις υπενθυμίσεις.", "reminderNeedsDueDate": "Ορίστε ημερομηνία λήξης για να ενεργοποιήσετε τις υπενθυμίσεις.",
"emptyAction": "Δημιουργία εργασίας", "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": { "shopping": {
"title": "Αγορές", "title": "Αγορές",
@@ -479,7 +491,13 @@
"colorPurple": "Μοβ", "colorPurple": "Μοβ",
"colorRed": "Κόκκινο", "colorRed": "Κόκκινο",
"colorSkyBlue": "Γαλάζιο", "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": { "notes": {
"title": "Σημειώσεις", "title": "Σημειώσεις",
@@ -977,7 +995,72 @@
"addressbookEnabled": "Addressbook enabled", "addressbookEnabled": "Addressbook enabled",
"addressbookDisabled": "Addressbook disabled", "addressbookDisabled": "Addressbook disabled",
"addressbooksRefreshed": "Addressbooks refreshed", "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": { "login": {
"tagline": "Οικογενειακός προγραμματισμός. Ασφαλής. Φιλικός προς την ιδιωτικότητα. Ανοιχτός κώδικας.", "tagline": "Οικογενειακός προγραμματισμός. Ασφαλής. Φιλικός προς την ιδιωτικότητα. Ανοιχτός κώδικας.",
@@ -1120,7 +1203,7 @@
"customWeeks": "Weeks" "customWeeks": "Weeks"
}, },
"onboarding": { "onboarding": {
"step1Title": "Welcome to Oikos", "step1Title": "Καλώς ήρθατε στο {{name}}",
"step1Body": "Your personal family planner. Tasks, calendar, shopping and more all in one place.", "step1Body": "Your personal family planner. Tasks, calendar, shopping and more all in one place.",
"step2Title": "Πλοήγηση & Ενότητες", "step2Title": "Πλοήγηση & Ενότητες",
"step2Body": "Στο κάτω μέρος έχεις άμεση πρόσβαση στον Πίνακα ελέγχου και το Ημερολόγιο. Με το κουμπί ··· ανοίγεις άλλες ενότητες όπως Κουζίνα, Σημειώσεις και Επαφές.", "step2Body": "Στο κάτω μέρος έχεις άμεση πρόσβαση στον Πίνακα ελέγχου και το Ημερολόγιο. Με το κουμπί ··· ανοίγεις άλλες ενότητες όπως Κουζίνα, Σημειώσεις και Επαφές.",
@@ -1203,7 +1286,18 @@
}, },
"dropzoneTitle": "Αφήστε το αρχείο εδώ ή κάντε κλικ για επιλογή", "dropzoneTitle": "Αφήστε το αρχείο εδώ ή κάντε κλικ για επιλογή",
"dropzoneHint": "Σύρετε ένα αρχείο σε αυτήν την περιοχή ή χρησιμοποιήστε τον επιλογέα αρχείων.", "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": { "shortcuts": {
"goKitchen": "Κουζίνα", "goKitchen": "Κουζίνα",
@@ -1215,5 +1309,154 @@
"help": "Συντομεύσεις πληκτρολογίου", "help": "Συντομεύσεις πληκτρολογίου",
"new": "Δημιουργία νέας εγγραφής", "new": "Δημιουργία νέας εγγραφής",
"search": "Άνοιγμα αναζήτησης" "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 -"
} }
} }
+193 -7
View File
@@ -54,7 +54,8 @@
"more": "More", "more": "More",
"documents": "Documents", "documents": "Documents",
"kitchen": "Kitchen", "kitchen": "Kitchen",
"search": "Search" "search": "Search",
"housekeeping": "Housekeeping"
}, },
"dashboard": { "dashboard": {
"title": "Overview", "title": "Overview",
@@ -493,7 +494,10 @@
"colorPurple": "Purple", "colorPurple": "Purple",
"colorRed": "Red", "colorRed": "Red",
"colorSkyBlue": "Sky Blue", "colorSkyBlue": "Sky Blue",
"colorYellow": "Yellow" "colorYellow": "Yellow",
"iconCleaning": "Cleaning",
"attachmentDocumentName": "{{title}} - {{name}}",
"attachmentDocumentDescription": "Attachment uploaded for calendar event \"{{title}}\"."
}, },
"notes": { "notes": {
"title": "Board", "title": "Board",
@@ -994,8 +998,6 @@
"caldavEmptyState": "No CalDAV accounts connected yet. Add your first account to get started.", "caldavEmptyState": "No CalDAV accounts connected yet. Add your first account to get started.",
"caldavNameLabel": "Account Name", "caldavNameLabel": "Account Name",
"caldavNamePlaceholder": "e.g. My Radicale, iCloud, Nextcloud", "caldavNamePlaceholder": "e.g. My Radicale, iCloud, Nextcloud",
"caldavUrlLabel": "CalDAV Server URL",
"caldavUrlPlaceholder": "https://caldav.icloud.com",
"caldavUrlHint": "The base URL of your CalDAV server", "caldavUrlHint": "The base URL of your CalDAV server",
"caldavUsernameLabel": "Username", "caldavUsernameLabel": "Username",
"caldavPasswordLabel": "Password", "caldavPasswordLabel": "Password",
@@ -1034,7 +1036,31 @@
"addressbookEnabled": "Addressbook enabled", "addressbookEnabled": "Addressbook enabled",
"addressbookDisabled": "Addressbook disabled", "addressbookDisabled": "Addressbook disabled",
"addressbooksRefreshed": "Addressbooks refreshed", "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": { "login": {
"tagline": "Family planning. Secure. Privacy-friendly. Open source.", "tagline": "Family planning. Secure. Privacy-friendly. Open source.",
@@ -1177,7 +1203,7 @@
"noResults": "No results found." "noResults": "No results found."
}, },
"onboarding": { "onboarding": {
"step1Title": "Welcome to Oikos", "step1Title": "Welcome to {{name}}",
"step1Body": "Your personal family planner. Tasks, calendar, shopping and more all in one place.", "step1Body": "Your personal family planner. Tasks, calendar, shopping and more all in one place.",
"step2Title": "Navigation & Modules", "step2Title": "Navigation & Modules",
"step2Body": "At the bottom you can directly access Dashboard and Calendar. The ··· button opens additional modules like Kitchen, Notes and Contacts.", "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", "dropzoneTitle": "Drop file here or click to choose",
"dropzoneHint": "Drag a file into this area, or use the file picker.", "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 -"
} }
} }
+249 -6
View File
@@ -54,7 +54,8 @@
"more": "Más", "more": "Más",
"documents": "Documentos", "documents": "Documentos",
"kitchen": "Cocina", "kitchen": "Cocina",
"search": "Buscar" "search": "Buscar",
"housekeeping": "Limpieza"
}, },
"dashboard": { "dashboard": {
"title": "Inicio", "title": "Inicio",
@@ -205,7 +206,18 @@
"kanbanArchived": "Archivado", "kanbanArchived": "Archivado",
"reminderNeedsDueDate": "Establece una fecha de vencimiento para activar los recordatorios de tareas.", "reminderNeedsDueDate": "Establece una fecha de vencimiento para activar los recordatorios de tareas.",
"emptyAction": "Crear tarea", "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": { "shopping": {
"title": "Compras", "title": "Compras",
@@ -479,7 +491,13 @@
"colorPurple": "Morado", "colorPurple": "Morado",
"colorRed": "Rojo", "colorRed": "Rojo",
"colorSkyBlue": "Azul cielo", "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": { "notes": {
"title": "Notas", "title": "Notas",
@@ -977,7 +995,72 @@
"addressbookEnabled": "Addressbook enabled", "addressbookEnabled": "Addressbook enabled",
"addressbookDisabled": "Addressbook disabled", "addressbookDisabled": "Addressbook disabled",
"addressbooksRefreshed": "Addressbooks refreshed", "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": { "login": {
"tagline": "Planificación familiar. Segura. Privada. Código abierto.", "tagline": "Planificación familiar. Segura. Privada. Código abierto.",
@@ -1120,7 +1203,7 @@
"customWeeks": "Weeks" "customWeeks": "Weeks"
}, },
"onboarding": { "onboarding": {
"step1Title": "Welcome to Oikos", "step1Title": "Bienvenido a {{name}}",
"step1Body": "Your personal family planner. Tasks, calendar, shopping and more all in one place.", "step1Body": "Your personal family planner. Tasks, calendar, shopping and more all in one place.",
"step2Title": "Navegación y módulos", "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.", "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", "dropzoneTitle": "Suelta el archivo aquí o haz clic para elegir",
"dropzoneHint": "Arrastra un archivo a esta área o usa el selector de archivos.", "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": { "shortcuts": {
"goKitchen": "Cocina", "goKitchen": "Cocina",
@@ -1215,5 +1309,154 @@
"help": "Atajos de teclado", "help": "Atajos de teclado",
"new": "Crear nueva entrada", "new": "Crear nueva entrada",
"search": "Abrir búsqueda" "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 -"
} }
} }
+249 -6
View File
@@ -54,7 +54,8 @@
"more": "Plus", "more": "Plus",
"documents": "Documents", "documents": "Documents",
"kitchen": "Cuisine", "kitchen": "Cuisine",
"search": "Recherche" "search": "Recherche",
"housekeeping": "Ménage"
}, },
"dashboard": { "dashboard": {
"title": "Accueil", "title": "Accueil",
@@ -205,7 +206,18 @@
"kanbanArchived": "Archivé", "kanbanArchived": "Archivé",
"reminderNeedsDueDate": "Définissez une date d'échéance pour activer les rappels de tâche.", "reminderNeedsDueDate": "Définissez une date d'échéance pour activer les rappels de tâche.",
"emptyAction": "Créer une 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": { "shopping": {
"title": "Courses", "title": "Courses",
@@ -479,7 +491,13 @@
"colorPurple": "Violet", "colorPurple": "Violet",
"colorRed": "Rouge", "colorRed": "Rouge",
"colorSkyBlue": "Bleu ciel", "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": { "notes": {
"title": "Notes", "title": "Notes",
@@ -977,7 +995,72 @@
"addressbookEnabled": "Addressbook enabled", "addressbookEnabled": "Addressbook enabled",
"addressbookDisabled": "Addressbook disabled", "addressbookDisabled": "Addressbook disabled",
"addressbooksRefreshed": "Addressbooks refreshed", "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": { "login": {
"tagline": "Planification familiale. Sécurisée. Respectueuse de la vie privée. Open source.", "tagline": "Planification familiale. Sécurisée. Respectueuse de la vie privée. Open source.",
@@ -1120,7 +1203,7 @@
"customWeeks": "Weeks" "customWeeks": "Weeks"
}, },
"onboarding": { "onboarding": {
"step1Title": "Welcome to Oikos", "step1Title": "Bienvenue dans {{name}}",
"step1Body": "Your personal family planner. Tasks, calendar, shopping and more all in one place.", "step1Body": "Your personal family planner. Tasks, calendar, shopping and more all in one place.",
"step2Title": "Navigation & modules", "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.", "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", "dropzoneTitle": "Déposez le fichier ici ou cliquez pour choisir",
"dropzoneHint": "Glissez un fichier dans cette zone ou utilisez le sélecteur.", "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": { "shortcuts": {
"goKitchen": "Cuisine", "goKitchen": "Cuisine",
@@ -1215,5 +1309,154 @@
"help": "Raccourcis clavier", "help": "Raccourcis clavier",
"new": "Créer une nouvelle entrée", "new": "Créer une nouvelle entrée",
"search": "Ouvrir la recherche" "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": "Aujourdhui",
"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 -"
} }
} }
+249 -6
View File
@@ -54,7 +54,8 @@
"more": "और", "more": "और",
"documents": "दस्तावेज़", "documents": "दस्तावेज़",
"kitchen": "रसोई", "kitchen": "रसोई",
"search": "खोज" "search": "खोज",
"housekeeping": "Housekeeping"
}, },
"dashboard": { "dashboard": {
"title": "डैशबोर्ड", "title": "डैशबोर्ड",
@@ -205,7 +206,18 @@
"kanbanArchived": "संग्रहित", "kanbanArchived": "संग्रहित",
"reminderNeedsDueDate": "कार्य अनुस्मारक सक्षम करने के लिए एक नियत तारीख निर्धारित करें।", "reminderNeedsDueDate": "कार्य अनुस्मारक सक्षम करने के लिए एक नियत तारीख निर्धारित करें।",
"emptyAction": "कार्य बनाएं", "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": { "shopping": {
"title": "खरीदारी", "title": "खरीदारी",
@@ -479,7 +491,13 @@
"colorPurple": "बैंगनी", "colorPurple": "बैंगनी",
"colorRed": "लाल", "colorRed": "लाल",
"colorSkyBlue": "आसमानी नीला", "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": { "notes": {
"title": "नोट बोर्ड", "title": "नोट बोर्ड",
@@ -977,7 +995,72 @@
"addressbookEnabled": "Addressbook enabled", "addressbookEnabled": "Addressbook enabled",
"addressbookDisabled": "Addressbook disabled", "addressbookDisabled": "Addressbook disabled",
"addressbooksRefreshed": "Addressbooks refreshed", "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": { "login": {
"tagline": "पारिवारिक योजना। सुरक्षित। गोपनीयता-अनुकूल। ओपन सोर्स।", "tagline": "पारिवारिक योजना। सुरक्षित। गोपनीयता-अनुकूल। ओपन सोर्स।",
@@ -1120,7 +1203,7 @@
"customWeeks": "Weeks" "customWeeks": "Weeks"
}, },
"onboarding": { "onboarding": {
"step1Title": "Welcome to Oikos", "step1Title": "{{name}} में आपका स्वागत है",
"step1Body": "Your personal family planner. Tasks, calendar, shopping and more all in one place.", "step1Body": "Your personal family planner. Tasks, calendar, shopping and more all in one place.",
"step2Title": "नेविगेशन और मॉड्यूल", "step2Title": "नेविगेशन और मॉड्यूल",
"step2Body": "नीचे से डैशबोर्ड और कैलेंडर तक सीधी पहुँच। ··· बटन से किचन, नोट्स और संपर्क जैसे अन्य मॉड्यूल खोलें।", "step2Body": "नीचे से डैशबोर्ड और कैलेंडर तक सीधी पहुँच। ··· बटन से किचन, नोट्स और संपर्क जैसे अन्य मॉड्यूल खोलें।",
@@ -1203,7 +1286,18 @@
}, },
"dropzoneTitle": "फ़ाइल यहाँ छोड़ें या चुनने के लिए क्लिक करें", "dropzoneTitle": "फ़ाइल यहाँ छोड़ें या चुनने के लिए क्लिक करें",
"dropzoneHint": "फ़ाइल को इस क्षेत्र में खींचें या फ़ाइल पिकर का उपयोग करें।", "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": { "shortcuts": {
"goKitchen": "रसोई", "goKitchen": "रसोई",
@@ -1215,5 +1309,154 @@
"help": "कीबोर्ड शॉर्टकट", "help": "कीबोर्ड शॉर्टकट",
"new": "नई प्रविष्टि बनाएं", "new": "नई प्रविष्टि बनाएं",
"search": "खोज खोलें" "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 -"
} }
} }
+249 -6
View File
@@ -54,7 +54,8 @@
"more": "Altro", "more": "Altro",
"documents": "Documenti", "documents": "Documenti",
"kitchen": "Cucina", "kitchen": "Cucina",
"search": "Cerca" "search": "Cerca",
"housekeeping": "Pulizie"
}, },
"dashboard": { "dashboard": {
"title": "Panoramica", "title": "Panoramica",
@@ -205,7 +206,18 @@
"kanbanArchived": "Archiviato", "kanbanArchived": "Archiviato",
"reminderNeedsDueDate": "Imposta una data di scadenza per abilitare i promemoria delle attività.", "reminderNeedsDueDate": "Imposta una data di scadenza per abilitare i promemoria delle attività.",
"emptyAction": "Crea 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": { "shopping": {
"title": "Spesa", "title": "Spesa",
@@ -479,7 +491,13 @@
"colorPurple": "Viola", "colorPurple": "Viola",
"colorRed": "Rosso", "colorRed": "Rosso",
"colorSkyBlue": "Azzurro", "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 levento calendario \"{{title}}\"."
}, },
"notes": { "notes": {
"title": "Bacheca", "title": "Bacheca",
@@ -977,7 +995,72 @@
"addressbookEnabled": "Addressbook enabled", "addressbookEnabled": "Addressbook enabled",
"addressbookDisabled": "Addressbook disabled", "addressbookDisabled": "Addressbook disabled",
"addressbooksRefreshed": "Addressbooks refreshed", "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": { "login": {
"tagline": "Pianificazione familiare. Sicura. Rispettosa della privacy. Open source.", "tagline": "Pianificazione familiare. Sicura. Rispettosa della privacy. Open source.",
@@ -1120,7 +1203,7 @@
"customWeeks": "Weeks" "customWeeks": "Weeks"
}, },
"onboarding": { "onboarding": {
"step1Title": "Welcome to Oikos", "step1Title": "Benvenuto in {{name}}",
"step1Body": "Your personal family planner. Tasks, calendar, shopping and more all in one place.", "step1Body": "Your personal family planner. Tasks, calendar, shopping and more all in one place.",
"step2Title": "Navigazione e moduli", "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.", "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", "dropzoneTitle": "Rilascia il file qui o fai clic per scegliere",
"dropzoneHint": "Trascina un file in questarea oppure usa il selettore.", "dropzoneHint": "Trascina un file in questarea 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": { "shortcuts": {
"goKitchen": "Cucina", "goKitchen": "Cucina",
@@ -1215,5 +1309,154 @@
"help": "Scorciatoie da tastiera", "help": "Scorciatoie da tastiera",
"new": "Crea nuova voce", "new": "Crea nuova voce",
"search": "Apri ricerca" "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 dellentrata.",
"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 -"
} }
} }
+249 -6
View File
@@ -54,7 +54,8 @@
"more": "もっと見る", "more": "もっと見る",
"documents": "書類", "documents": "書類",
"kitchen": "キッチン", "kitchen": "キッチン",
"search": "検索" "search": "検索",
"housekeeping": "Housekeeping"
}, },
"dashboard": { "dashboard": {
"title": "ダッシュボード", "title": "ダッシュボード",
@@ -205,7 +206,18 @@
"kanbanArchived": "アーカイブ済み", "kanbanArchived": "アーカイブ済み",
"reminderNeedsDueDate": "タスクのリマインダーを有効にするには期日を設定してください。", "reminderNeedsDueDate": "タスクのリマインダーを有効にするには期日を設定してください。",
"emptyAction": "タスクを作成", "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": { "shopping": {
"title": "買い物", "title": "買い物",
@@ -479,7 +491,13 @@
"colorPurple": "紫", "colorPurple": "紫",
"colorRed": "赤", "colorRed": "赤",
"colorSkyBlue": "空色", "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": { "notes": {
"title": "メモボード", "title": "メモボード",
@@ -977,7 +995,72 @@
"addressbookEnabled": "Addressbook enabled", "addressbookEnabled": "Addressbook enabled",
"addressbookDisabled": "Addressbook disabled", "addressbookDisabled": "Addressbook disabled",
"addressbooksRefreshed": "Addressbooks refreshed", "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": { "login": {
"tagline": "家族計画。安全。プライバシー重視。オープンソース。", "tagline": "家族計画。安全。プライバシー重視。オープンソース。",
@@ -1120,7 +1203,7 @@
"customWeeks": "Weeks" "customWeeks": "Weeks"
}, },
"onboarding": { "onboarding": {
"step1Title": "Welcome to Oikos", "step1Title": "{{name}}へようこそ",
"step1Body": "Your personal family planner. Tasks, calendar, shopping and more all in one place.", "step1Body": "Your personal family planner. Tasks, calendar, shopping and more all in one place.",
"step2Title": "ナビゲーションとモジュール", "step2Title": "ナビゲーションとモジュール",
"step2Body": "画面下部からダッシュボードとカレンダーに直接アクセスできます。···ボタンでキッチン、メモ、連絡先などの追加モジュールを開きます。", "step2Body": "画面下部からダッシュボードとカレンダーに直接アクセスできます。···ボタンでキッチン、メモ、連絡先などの追加モジュールを開きます。",
@@ -1203,7 +1286,18 @@
}, },
"dropzoneTitle": "ここにファイルをドロップ、またはクリックして選択", "dropzoneTitle": "ここにファイルをドロップ、またはクリックして選択",
"dropzoneHint": "この領域にファイルをドラッグするか、ファイル選択を使用します。", "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": { "shortcuts": {
"goKitchen": "キッチン", "goKitchen": "キッチン",
@@ -1215,5 +1309,154 @@
"help": "キーボードショートカット", "help": "キーボードショートカット",
"new": "新規エントリを作成", "new": "新規エントリを作成",
"search": "検索を開く" "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 -"
} }
} }
+249 -6
View File
@@ -54,7 +54,8 @@
"more": "Mais", "more": "Mais",
"documents": "Documentos", "documents": "Documentos",
"kitchen": "Cozinha", "kitchen": "Cozinha",
"search": "Pesquisar" "search": "Pesquisar",
"housekeeping": "Faxina"
}, },
"dashboard": { "dashboard": {
"title": "Painel", "title": "Painel",
@@ -205,7 +206,18 @@
"swipedOpenToast": "Marcado como aberto.", "swipedOpenToast": "Marcado como aberto.",
"reminderNeedsDueDate": "Defina uma data de vencimento para habilitar lembretes da tarefa.", "reminderNeedsDueDate": "Defina uma data de vencimento para habilitar lembretes da tarefa.",
"emptyAction": "Criar 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": { "shopping": {
"title": "Compras", "title": "Compras",
@@ -479,7 +491,13 @@
"colorPurple": "Roxo", "colorPurple": "Roxo",
"colorRed": "Vermelho", "colorRed": "Vermelho",
"colorSkyBlue": "Azul céu", "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": { "notes": {
"title": "Quadro de notas", "title": "Quadro de notas",
@@ -977,7 +995,72 @@
"addressbookEnabled": "Addressbook enabled", "addressbookEnabled": "Addressbook enabled",
"addressbookDisabled": "Addressbook disabled", "addressbookDisabled": "Addressbook disabled",
"addressbooksRefreshed": "Addressbooks refreshed", "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": { "login": {
"tagline": "Planejamento familiar. Seguro. Privado. Código aberto.", "tagline": "Planejamento familiar. Seguro. Privado. Código aberto.",
@@ -1120,7 +1203,7 @@
"customWeeks": "Semanas" "customWeeks": "Semanas"
}, },
"onboarding": { "onboarding": {
"step1Title": "Welcome to Oikos", "step1Title": "Bem-vindo ao {{name}}",
"step1Body": "Your personal family planner. Tasks, calendar, shopping and more all in one place.", "step1Body": "Your personal family planner. Tasks, calendar, shopping and more all in one place.",
"step2Title": "Navegação e módulos", "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.", "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", "dropzoneTitle": "Solte o arquivo aqui ou clique para escolher",
"dropzoneHint": "Arraste um arquivo para esta area, ou use o seletor de arquivos.", "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": { "shortcuts": {
"goKitchen": "Cozinha", "goKitchen": "Cozinha",
@@ -1215,5 +1309,154 @@
"help": "Atalhos de teclado", "help": "Atalhos de teclado",
"new": "Criar nova entrada", "new": "Criar nova entrada",
"search": "Abrir pesquisa" "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 -"
} }
} }
+249 -6
View File
@@ -54,7 +54,8 @@
"more": "Ещё", "more": "Ещё",
"documents": "Документы", "documents": "Документы",
"kitchen": "Кухня", "kitchen": "Кухня",
"search": "Поиск" "search": "Поиск",
"housekeeping": "Housekeeping"
}, },
"dashboard": { "dashboard": {
"title": "Обзор", "title": "Обзор",
@@ -205,7 +206,18 @@
"kanbanArchived": "Архивировано", "kanbanArchived": "Архивировано",
"reminderNeedsDueDate": "Установите срок выполнения, чтобы включить напоминания о задаче.", "reminderNeedsDueDate": "Установите срок выполнения, чтобы включить напоминания о задаче.",
"emptyAction": "Создать задачу", "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": { "shopping": {
"title": "Покупки", "title": "Покупки",
@@ -479,7 +491,13 @@
"colorPurple": "Фиолетовый", "colorPurple": "Фиолетовый",
"colorRed": "Красный", "colorRed": "Красный",
"colorSkyBlue": "Небесно-голубой", "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": { "notes": {
"title": "Заметки", "title": "Заметки",
@@ -977,7 +995,72 @@
"addressbookEnabled": "Addressbook enabled", "addressbookEnabled": "Addressbook enabled",
"addressbookDisabled": "Addressbook disabled", "addressbookDisabled": "Addressbook disabled",
"addressbooksRefreshed": "Addressbooks refreshed", "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": { "login": {
"tagline": "Семейное планирование. Безопасно. С уважением к приватности. Открытый исходный код.", "tagline": "Семейное планирование. Безопасно. С уважением к приватности. Открытый исходный код.",
@@ -1120,7 +1203,7 @@
"customWeeks": "Weeks" "customWeeks": "Weeks"
}, },
"onboarding": { "onboarding": {
"step1Title": "Welcome to Oikos", "step1Title": "Добро пожаловать в {{name}}",
"step1Body": "Your personal family planner. Tasks, calendar, shopping and more all in one place.", "step1Body": "Your personal family planner. Tasks, calendar, shopping and more all in one place.",
"step2Title": "Навигация и модули", "step2Title": "Навигация и модули",
"step2Body": "Внизу доступны Панель управления и Календарь напрямую. Кнопка ··· открывает дополнительные модули: Кухня, Заметки и Контакты.", "step2Body": "Внизу доступны Панель управления и Календарь напрямую. Кнопка ··· открывает дополнительные модули: Кухня, Заметки и Контакты.",
@@ -1203,7 +1286,18 @@
}, },
"dropzoneTitle": "Перетащите файл сюда или нажмите для выбора", "dropzoneTitle": "Перетащите файл сюда или нажмите для выбора",
"dropzoneHint": "Перетащите файл в эту область или используйте выбор файла.", "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": { "shortcuts": {
"goKitchen": "Кухня", "goKitchen": "Кухня",
@@ -1215,5 +1309,154 @@
"help": "Горячие клавиши", "help": "Горячие клавиши",
"new": "Создать новую запись", "new": "Создать новую запись",
"search": "Открыть поиск" "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 -"
} }
} }
+249 -6
View File
@@ -54,7 +54,8 @@
"more": "Mer", "more": "Mer",
"documents": "Dokument", "documents": "Dokument",
"kitchen": "Kök", "kitchen": "Kök",
"search": "Sök" "search": "Sök",
"housekeeping": "Städning"
}, },
"dashboard": { "dashboard": {
"title": "Översikt", "title": "Översikt",
@@ -205,7 +206,18 @@
"kanbanArchived": "Arkiverad", "kanbanArchived": "Arkiverad",
"reminderNeedsDueDate": "Ange ett förfallodatum för att aktivera påminnelser för uppgiften.", "reminderNeedsDueDate": "Ange ett förfallodatum för att aktivera påminnelser för uppgiften.",
"emptyAction": "Skapa uppgift", "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": { "shopping": {
"title": "Shopping", "title": "Shopping",
@@ -479,7 +491,13 @@
"colorSkyBlue": "Himmelsblå", "colorSkyBlue": "Himmelsblå",
"colorYellow": "Gul", "colorYellow": "Gul",
"colorGray": "Grå", "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": { "notes": {
"title": "Anteckningar", "title": "Anteckningar",
@@ -977,7 +995,72 @@
"addressbookEnabled": "Addressbook enabled", "addressbookEnabled": "Addressbook enabled",
"addressbookDisabled": "Addressbook disabled", "addressbookDisabled": "Addressbook disabled",
"addressbooksRefreshed": "Addressbooks refreshed", "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": { "login": {
"tagline": "Familjeplanering. Säker. Sekretessvänlig. Öppen källkod.", "tagline": "Familjeplanering. Säker. Sekretessvänlig. Öppen källkod.",
@@ -1120,7 +1203,7 @@
"customWeeks": "Veckor" "customWeeks": "Veckor"
}, },
"onboarding": { "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.", "step1Body": "Din personliga familjeplanerare. Uppgifter, kalender, shopping och mer allt på ett ställe.",
"step2Title": "Navigering och moduler", "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.", "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", "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.", "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": { "shortcuts": {
"goKitchen": "Kök", "goKitchen": "Kök",
@@ -1215,5 +1309,154 @@
"goCal": "Kalender", "goCal": "Kalender",
"goShop": "Inköpslista", "goShop": "Inköpslista",
"goNotes": "Anteckningar" "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 -"
} }
} }
+249 -6
View File
@@ -54,7 +54,8 @@
"more": "Daha Fazla", "more": "Daha Fazla",
"documents": "Belgeler", "documents": "Belgeler",
"kitchen": "Mutfak", "kitchen": "Mutfak",
"search": "Ara" "search": "Ara",
"housekeeping": "Housekeeping"
}, },
"dashboard": { "dashboard": {
"title": "Genel Bakış", "title": "Genel Bakış",
@@ -205,7 +206,18 @@
"kanbanArchived": "Arşivlenmiş", "kanbanArchived": "Arşivlenmiş",
"reminderNeedsDueDate": "Görev hatırlatıcılarını etkinleştirmek için bir son tarih belirleyin.", "reminderNeedsDueDate": "Görev hatırlatıcılarını etkinleştirmek için bir son tarih belirleyin.",
"emptyAction": "Görev oluştur", "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": { "shopping": {
"title": "Alışveriş", "title": "Alışveriş",
@@ -479,7 +491,13 @@
"colorPurple": "Mor", "colorPurple": "Mor",
"colorRed": "Kırmızı", "colorRed": "Kırmızı",
"colorSkyBlue": "Gökyüzü mavisi", "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": { "notes": {
"title": "Notlar", "title": "Notlar",
@@ -977,7 +995,72 @@
"addressbookEnabled": "Addressbook enabled", "addressbookEnabled": "Addressbook enabled",
"addressbookDisabled": "Addressbook disabled", "addressbookDisabled": "Addressbook disabled",
"addressbooksRefreshed": "Addressbooks refreshed", "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": { "login": {
"tagline": "Aile planlaması. Güvenli. Gizlilik dostu. Açık kaynak.", "tagline": "Aile planlaması. Güvenli. Gizlilik dostu. Açık kaynak.",
@@ -1120,7 +1203,7 @@
"customWeeks": "Weeks" "customWeeks": "Weeks"
}, },
"onboarding": { "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.", "step1Body": "Your personal family planner. Tasks, calendar, shopping and more all in one place.",
"step2Title": "Gezinme ve Modüller", "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.", "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", "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.", "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": { "shortcuts": {
"goKitchen": "Mutfak", "goKitchen": "Mutfak",
@@ -1215,5 +1309,154 @@
"help": "Klavye kısayolları", "help": "Klavye kısayolları",
"new": "Yeni giriş oluştur", "new": "Yeni giriş oluştur",
"search": "Aramayı aç" "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 -"
} }
} }
+249 -6
View File
@@ -54,7 +54,8 @@
"more": "Більше", "more": "Більше",
"documents": "Документи", "documents": "Документи",
"kitchen": "Кухня", "kitchen": "Кухня",
"search": "Пошук" "search": "Пошук",
"housekeeping": "Housekeeping"
}, },
"dashboard": { "dashboard": {
"title": "Огляд", "title": "Огляд",
@@ -205,7 +206,18 @@
"kanbanArchived": "Архівовано", "kanbanArchived": "Архівовано",
"reminderNeedsDueDate": "Встановіть дату виконання, щоб увімкнути нагадування про завдання.", "reminderNeedsDueDate": "Встановіть дату виконання, щоб увімкнути нагадування про завдання.",
"emptyAction": "Створити завдання", "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": { "shopping": {
"title": "Покупки", "title": "Покупки",
@@ -479,7 +491,13 @@
"colorPurple": "Фіолетовий", "colorPurple": "Фіолетовий",
"colorRed": "Червоний", "colorRed": "Червоний",
"colorSkyBlue": "Небесно-блакитний", "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": { "notes": {
"title": "Нотатки", "title": "Нотатки",
@@ -977,7 +995,72 @@
"addressbookEnabled": "Addressbook enabled", "addressbookEnabled": "Addressbook enabled",
"addressbookDisabled": "Addressbook disabled", "addressbookDisabled": "Addressbook disabled",
"addressbooksRefreshed": "Addressbooks refreshed", "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": { "login": {
"tagline": "Планування для родини. Безпечно. Конфіденційно. Відкритий код.", "tagline": "Планування для родини. Безпечно. Конфіденційно. Відкритий код.",
@@ -1120,7 +1203,7 @@
"photoOptional": "Необов'язково: можна зберегти без фото профілю." "photoOptional": "Необов'язково: можна зберегти без фото профілю."
}, },
"onboarding": { "onboarding": {
"step1Title": "Welcome to Oikos", "step1Title": "Ласкаво просимо до {{name}}",
"step1Body": "Your personal family planner. Tasks, calendar, shopping and more all in one place.", "step1Body": "Your personal family planner. Tasks, calendar, shopping and more all in one place.",
"step2Title": "Навігація та модулі", "step2Title": "Навігація та модулі",
"step2Body": "Унизу ви маєте прямий доступ до Панелі керування та Календаря. Кнопка ··· відкриває додаткові модулі: Кухня, Нотатки та Контакти.", "step2Body": "Унизу ви маєте прямий доступ до Панелі керування та Календаря. Кнопка ··· відкриває додаткові модулі: Кухня, Нотатки та Контакти.",
@@ -1203,7 +1286,18 @@
}, },
"dropzoneTitle": "Перетягніть файл сюди або натисніть для вибору", "dropzoneTitle": "Перетягніть файл сюди або натисніть для вибору",
"dropzoneHint": "Перетягніть файл у цю область або скористайтеся вибором файлу.", "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": { "shortcuts": {
"goKitchen": "Кухня", "goKitchen": "Кухня",
@@ -1215,5 +1309,154 @@
"help": "Гарячі клавіші", "help": "Гарячі клавіші",
"new": "Створити новий запис", "new": "Створити новий запис",
"search": "Відкрити пошук" "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 -"
} }
} }
+249 -6
View File
@@ -54,7 +54,8 @@
"more": "更多", "more": "更多",
"documents": "文档", "documents": "文档",
"kitchen": "厨房", "kitchen": "厨房",
"search": "搜索" "search": "搜索",
"housekeeping": "Housekeeping"
}, },
"dashboard": { "dashboard": {
"title": "概览", "title": "概览",
@@ -205,7 +206,18 @@
"kanbanArchived": "已归档", "kanbanArchived": "已归档",
"reminderNeedsDueDate": "设置截止日期以启用任务提醒。", "reminderNeedsDueDate": "设置截止日期以启用任务提醒。",
"emptyAction": "创建任务", "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": { "shopping": {
"title": "购物", "title": "购物",
@@ -479,7 +491,13 @@
"colorPurple": "紫色", "colorPurple": "紫色",
"colorRed": "红色", "colorRed": "红色",
"colorSkyBlue": "天蓝色", "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": { "notes": {
"title": "便签板", "title": "便签板",
@@ -977,7 +995,72 @@
"addressbookEnabled": "Addressbook enabled", "addressbookEnabled": "Addressbook enabled",
"addressbookDisabled": "Addressbook disabled", "addressbookDisabled": "Addressbook disabled",
"addressbooksRefreshed": "Addressbooks refreshed", "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": { "login": {
"tagline": "家庭规划。安全。注重隐私。开源。", "tagline": "家庭规划。安全。注重隐私。开源。",
@@ -1120,7 +1203,7 @@
"customWeeks": "Weeks" "customWeeks": "Weeks"
}, },
"onboarding": { "onboarding": {
"step1Title": "Welcome to Oikos", "step1Title": "欢迎使用{{name}}",
"step1Body": "Your personal family planner. Tasks, calendar, shopping and more all in one place.", "step1Body": "Your personal family planner. Tasks, calendar, shopping and more all in one place.",
"step2Title": "导航与模块", "step2Title": "导航与模块",
"step2Body": "底部可直接访问仪表板和日历。点击···按钮可打开厨房、笔记和联系人等更多模块。", "step2Body": "底部可直接访问仪表板和日历。点击···按钮可打开厨房、笔记和联系人等更多模块。",
@@ -1203,7 +1286,18 @@
}, },
"dropzoneTitle": "将文件拖到此处或点击选择", "dropzoneTitle": "将文件拖到此处或点击选择",
"dropzoneHint": "将文件拖入此区域,或使用文件选择器。", "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": { "shortcuts": {
"goKitchen": "厨房", "goKitchen": "厨房",
@@ -1215,5 +1309,154 @@
"help": "键盘快捷键", "help": "键盘快捷键",
"new": "创建新条目", "new": "创建新条目",
"search": "打开搜索" "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 -"
} }
} }
+106 -93
View File
@@ -181,7 +181,7 @@ const EVENT_ICON_CATEGORIES = () => [
{ value: 'building', label: t('calendar.iconBuilding') }, { value: 'building', label: t('calendar.iconBuilding') },
{ value: 'wrench', label: t('calendar.iconRepair') }, { value: 'wrench', label: t('calendar.iconRepair') },
{ value: 'hammer', label: t('calendar.iconMaintenance') }, { 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: 'sofa', label: t('calendar.iconFurniture') },
{ value: 'washing-machine', label: t('calendar.iconLaundry') }, { 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 const HOUR_HEIGHT = 56; // px pro Stunde in Wochen-/Tagesansicht
function renderIconPickerResults(selectedIcon, query = '') {
const q = query.trim().toLowerCase();
if (q) {
const filtered = EVENT_ICON_CATEGORIES()
.flatMap((c) => c.icons)
.filter((icon) => icon.label.toLowerCase().includes(q) || icon.value.includes(q));
if (filtered.length === 0) {
return `<div class="event-icon-picker__no-results">${esc(t('calendar.iconSearchEmpty'))}</div>`;
}
return `
<div class="event-icon-picker__category-icons">
${filtered.map((icon) => iconPickerOptionHtml(icon, selectedIcon)).join('')}
</div>`;
}
return EVENT_ICON_CATEGORIES().map((cat) => `
<div class="event-icon-picker__category">
<div class="event-icon-picker__category-label">${esc(cat.label)}</div>
<div class="event-icon-picker__category-icons">
${cat.icons.map((icon) => iconPickerOptionHtml(icon, selectedIcon)).join('')}
</div>
</div>`).join('');
}
function iconPickerOptionHtml(icon, selectedIcon) {
return `
<button type="button"
class="event-icon-picker__option ${selectedIcon === icon.value ? 'event-icon-picker__option--active' : ''}"
data-icon="${icon.value}"
role="radio"
aria-checked="${selectedIcon === icon.value ? 'true' : 'false'}"
aria-label="${esc(icon.label)}"
title="${esc(icon.label)}">
${eventIconHtml(icon.value, 'event-icon-picker__option-icon')}
</button>`;
}
function openIconPickerDialog(selectedIcon, onSelect, onClose = () => {}) {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay event-icon-dialog';
overlay.setAttribute('aria-modal', 'true');
const panel = document.createElement('div');
panel.className = 'modal-panel modal-panel--md event-icon-dialog__panel';
panel.setAttribute('role', 'dialog');
panel.setAttribute('aria-label', t('calendar.iconLabel'));
panel.insertAdjacentHTML('beforeend', `
<div class="modal-panel__header">
<span class="modal-panel__title">${esc(t('calendar.iconLabel'))}</span>
<button class="modal-panel__close btn--ghost" type="button" aria-label="${esc(t('common.close'))}">
<i data-lucide="x" style="width:16px;height:16px;" aria-hidden="true"></i>
</button>
</div>
<div class="modal-panel__body event-icon-dialog__body">
<input type="search" class="form-input event-icon-picker__search" id="event-icon-dialog-search"
placeholder="${esc(t('calendar.iconSearchPlaceholder'))}" autocomplete="off" aria-label="${esc(t('calendar.iconSearchPlaceholder'))}">
<div class="event-icon-dialog__results" id="event-icon-dialog-results" role="radiogroup" aria-label="${esc(t('calendar.iconLabel'))}">
${renderIconPickerResults(selectedIcon)}
</div>
</div>
`);
function close() {
overlay.remove();
document.removeEventListener('keydown', onKeydown);
onClose();
}
function onKeydown(e) {
if (e.key === 'Escape') close();
}
panel.querySelector('.modal-panel__close')?.addEventListener('click', close);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
panel.querySelector('#event-icon-dialog-search')?.addEventListener('input', (e) => {
const results = panel.querySelector('#event-icon-dialog-results');
results?.replaceChildren();
results?.insertAdjacentHTML('beforeend', renderIconPickerResults(selectedIcon, e.target.value));
if (window.lucide) lucide.createIcons({ el: results });
});
panel.addEventListener('click', (e) => {
const btn = e.target.closest('.event-icon-picker__option');
if (!btn) return;
onSelect(btn.dataset.icon);
close();
});
overlay.appendChild(panel);
document.body.appendChild(overlay);
document.addEventListener('keydown', onKeydown);
panel.querySelector('#event-icon-dialog-search')?.focus();
if (window.lucide) lucide.createIcons({ el: panel });
}
/** /**
* Gibt eine lesbare Textfarbe für eine Hintergrundfarbe zurück. * Gibt eine lesbare Textfarbe für eine Hintergrundfarbe zurück.
* Helle Hintergründe (z.B. Hellgelb, Hellgrün) → dunkles Grau statt Weiß. * 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 iconInput = panel.querySelector('#modal-icon');
const iconTrigger = panel.querySelector('#modal-icon-trigger'); const iconTrigger = panel.querySelector('#modal-icon-trigger');
const iconGrid = panel.querySelector('#modal-icon-grid');
const selectIcon = (icon) => { const selectIcon = (icon) => {
const nextIcon = eventIconName(icon); const nextIcon = eventIconName(icon);
if (iconInput) iconInput.value = nextIcon; if (iconInput) iconInput.value = nextIcon;
@@ -1381,79 +1472,19 @@ function openEventModal({ mode, event = null, date = null, reminder = null }) {
iconTrigger.dataset.icon = nextIcon; iconTrigger.dataset.icon = nextIcon;
iconTrigger.replaceChildren(eventIconElement(nextIcon, 'event-icon-picker__trigger-icon')); 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(); if (window.lucide) lucide.createIcons();
}; };
iconTrigger?.addEventListener('click', () => { iconTrigger?.addEventListener('click', () => {
if (!iconGrid) return; iconTrigger.setAttribute('aria-expanded', 'true');
iconGrid.hidden = !iconGrid.hidden; openIconPickerDialog(iconInput?.value || 'calendar', (icon) => {
iconTrigger.setAttribute('aria-expanded', iconGrid.hidden ? 'false' : 'true'); selectIcon(icon);
}); iconTrigger?.setAttribute('aria-expanded', 'false');
iconGrid?.addEventListener('click', (e) => { iconTrigger?.focus();
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?.setAttribute('aria-expanded', 'false');
iconTrigger?.focus(); iconTrigger?.focus();
}); });
const iconSearch = iconGrid?.querySelector('#modal-icon-search');
iconSearch?.addEventListener('input', () => {
const q = iconSearch.value.trim().toLowerCase();
const resultsEl = iconGrid?.querySelector('#modal-icon-results');
if (!resultsEl) return;
if (!q) {
resultsEl.replaceChildren();
resultsEl.insertAdjacentHTML('afterbegin', EVENT_ICON_CATEGORIES().map((cat) => `
<div class="event-icon-picker__category">
<div class="event-icon-picker__category-label">${esc(cat.label)}</div>
<div class="event-icon-picker__category-icons">
${cat.icons.map((icon) => `
<button type="button" class="event-icon-picker__option ${iconInput?.value === icon.value ? 'event-icon-picker__option--active' : ''}"
data-icon="${icon.value}" role="radio"
aria-checked="${iconInput?.value === icon.value ? 'true' : 'false'}"
aria-label="${esc(icon.label)}" title="${esc(icon.label)}">
${eventIconHtml(icon.value, 'event-icon-picker__option-icon')}
</button>`).join('')}
</div>
</div>`).join(''));
if (window.lucide) lucide.createIcons({ el: resultsEl });
return;
}
const allIcons = EVENT_ICON_CATEGORIES().flatMap((c) => c.icons);
const filtered = allIcons.filter((i) => i.label.toLowerCase().includes(q) || i.value.includes(q));
resultsEl.replaceChildren();
if (filtered.length === 0) {
resultsEl.insertAdjacentHTML('afterbegin', `<div class="event-icon-picker__no-results">${esc(t('calendar.iconSearchEmpty'))}</div>`);
return;
}
resultsEl.insertAdjacentHTML('afterbegin', `
<div class="event-icon-picker__category-icons">
${filtered.map((icon) => `
<button type="button" class="event-icon-picker__option ${iconInput?.value === icon.value ? 'event-icon-picker__option--active' : ''}"
data-icon="${icon.value}" role="radio"
aria-checked="${iconInput?.value === icon.value ? 'true' : 'false'}"
aria-label="${esc(icon.label)}" title="${esc(icon.label)}">
${eventIconHtml(icon.value, 'event-icon-picker__option-icon')}
</button>`).join('')}
</div>`);
if (window.lucide) lucide.createIcons({ el: resultsEl });
});
document.addEventListener('click', function closeIconPicker(e) {
if (!panel.isConnected) {
document.removeEventListener('click', closeIconPicker);
return;
}
if (iconGrid?.hidden || iconGrid?.contains(e.target) || iconTrigger?.contains(e.target)) return;
iconGrid.hidden = true;
iconTrigger?.setAttribute('aria-expanded', 'false');
}); });
const reminderOffset = panel.querySelector('#modal-reminder-offset'); 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 const endTime = isEdit && event.end_datetime && event.end_datetime.length > 10
? localTime(event.end_datetime) : '10:00'; ? localTime(event.end_datetime) : '10:00';
const selectedIcon = eventIconName(isEdit ? event.icon : 'calendar'); const selectedIcon = eventIconName(isEdit ? event.icon : 'calendar');
const iconCats = EVENT_ICON_CATEGORIES();
const iconCategoryButtons = iconCats.map((cat) => `
<div class="event-icon-picker__category">
<div class="event-icon-picker__category-label">${esc(cat.label)}</div>
<div class="event-icon-picker__category-icons">
${cat.icons.map((icon) => `
<button type="button"
class="event-icon-picker__option ${selectedIcon === icon.value ? 'event-icon-picker__option--active' : ''}"
data-icon="${icon.value}"
role="radio"
aria-checked="${selectedIcon === icon.value ? 'true' : 'false'}"
aria-label="${esc(icon.label)}"
title="${esc(icon.label)}">
${eventIconHtml(icon.value, 'event-icon-picker__option-icon')}
</button>`).join('')}
</div>
</div>`).join('');
const selectedUserIds = isEdit const selectedUserIds = isEdit
? (event.assigned_users?.map((u) => u.id) ?? (event.assigned_to ? [event.assigned_to] : [])) ? (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 : '')}"> placeholder="${t('calendar.titlePlaceholder')}" value="${esc(isEdit ? event.title : '')}">
</div> </div>
</div> </div>
<div class="event-icon-picker__grid" id="modal-icon-grid" role="radiogroup" aria-label="${t('calendar.iconLabel')}" hidden>
<input type="search" class="form-input event-icon-picker__search" id="modal-icon-search"
placeholder="${t('calendar.iconSearchPlaceholder')}" autocomplete="off" aria-label="${t('calendar.iconSearchPlaceholder')}">
<div id="modal-icon-results">
${iconCategoryButtons}
</div>
</div>
<div class="form-group"> <div class="form-group">
<label class="toggle"> <label class="toggle">
<input type="checkbox" id="modal-allday" ${isEdit && event.all_day ? 'checked' : ''}> <input type="checkbox" id="modal-allday" ${isEdit && event.all_day ? 'checked' : ''}>
@@ -1812,6 +1818,13 @@ async function saveEvent(overlay, mode, eventId, existingReminder = null, attach
attachment_mime: attachmentPayload.mime, attachment_mime: attachmentPayload.mime,
attachment_size: attachmentPayload.size, attachment_size: attachmentPayload.size,
attachment_data: attachmentPayload.data, attachment_data: attachmentPayload.data,
document_folder_name: t('documents.calendarItemsFolder'),
document_name: attachmentPayload.name
? t('calendar.attachmentDocumentName', { title, name: attachmentPayload.name })
: null,
document_description: attachmentPayload.name
? t('calendar.attachmentDocumentDescription', { title })
: null,
target_caldav_account_id, target_caldav_account_id,
target_caldav_calendar_url, target_caldav_calendar_url,
}; };
+7 -1
View File
@@ -15,10 +15,16 @@ let _fabController = null;
// ── Onboarding ────────────────────────────────────────────────────────────── // ── Onboarding ──────────────────────────────────────────────────────────────
const ONBOARDING_KEY = 'oikos-onboarded'; const ONBOARDING_KEY = 'oikos-onboarded';
const APP_NAME_STORAGE_KEY = 'oikos-app-name';
function getAppName() {
return localStorage.getItem(APP_NAME_STORAGE_KEY) || 'Oikos';
}
function getOnboardingSteps() { function getOnboardingSteps() {
const appName = getAppName();
return [ return [
{ icon: 'home', title: t('onboarding.step1Title'), body: t('onboarding.step1Body') }, { icon: 'home', title: t('onboarding.step1Title', { name: appName }), body: t('onboarding.step1Body') },
{ icon: 'navigation', title: t('onboarding.step2Title'), body: t('onboarding.step2Body') }, { icon: 'navigation', title: t('onboarding.step2Title'), body: t('onboarding.step2Body') },
{ icon: 'plus-circle', title: t('onboarding.step3Title'), body: t('onboarding.step3Body') }, { icon: 'plus-circle', title: t('onboarding.step3Title'), body: t('onboarding.step3Body') },
]; ];
+161 -7
View File
@@ -35,18 +35,22 @@ function categoryLabels() {
} }
let state = { let state = {
allDocuments: [],
documents: [], documents: [],
folders: [],
members: [], members: [],
view: localStorage.getItem('oikos-documents-view') || 'grid', view: localStorage.getItem('oikos-documents-view') || 'grid',
status: 'active', status: 'active',
category: '', category: '',
folderId: '',
query: '', query: '',
}; };
let _container = null; let _container = null;
export async function render(container) { export async function render(container) {
_container = container; _container = container;
container.innerHTML = ` container.replaceChildren();
container.insertAdjacentHTML('beforeend', `
<div class="documents-page"> <div class="documents-page">
<div class="documents-toolbar"> <div class="documents-toolbar">
<h1 class="documents-toolbar__title">${t('documents.title')}</h1> <h1 class="documents-toolbar__title">${t('documents.title')}</h1>
@@ -66,6 +70,10 @@ export async function render(container) {
<i data-lucide="upload" class="icon-base" aria-hidden="true"></i> <i data-lucide="upload" class="icon-base" aria-hidden="true"></i>
${t('documents.addButton')} ${t('documents.addButton')}
</button> </button>
<button class="btn btn--secondary" id="documents-folder-btn">
<i data-lucide="folder-plus" class="icon-base" aria-hidden="true"></i>
${t('documents.addFolderButton')}
</button>
</div> </div>
<div class="documents-filters"> <div class="documents-filters">
<select class="input documents-filter-select" id="documents-status"> <select class="input documents-filter-select" id="documents-status">
@@ -76,18 +84,31 @@ export async function render(container) {
<option value="">${t('documents.allCategories')}</option> <option value="">${t('documents.allCategories')}</option>
${CATEGORIES.map((category) => `<option value="${category}">${categoryLabels()[category]}</option>`).join('')} ${CATEGORIES.map((category) => `<option value="${category}">${categoryLabels()[category]}</option>`).join('')}
</select> </select>
<select class="input documents-filter-select" id="documents-folder">
<option value="">${t('documents.allFolders')}</option>
<option value="__none">${t('documents.noFolder')}</option>
</select>
</div> </div>
<div class="documents-browser-layout">
<aside class="documents-folder-browser" aria-label="${t('documents.folderBrowserTitle')}">
<div class="documents-folder-browser__title">${t('documents.folderBrowserTitle')}</div>
<div class="documents-folder-browser__list" id="documents-folder-browser"></div>
</aside>
<div id="documents-list" class="documents-list documents-list--${state.view}"></div> <div id="documents-list" class="documents-list documents-list--${state.view}"></div>
</div>
<button class="page-fab" id="fab-new-document" aria-label="${t('documents.addButton')}"> <button class="page-fab" id="fab-new-document" aria-label="${t('documents.addButton')}">
<i data-lucide="upload" class="icon-2xl" aria-hidden="true"></i> <i data-lucide="upload" class="icon-2xl" aria-hidden="true"></i>
</button> </button>
</div> </div>
`; `);
if (window.lucide) lucide.createIcons(); if (window.lucide) lucide.createIcons();
await Promise.all([loadMembers(), loadDocuments()]); await Promise.all([loadMembers(), loadFolders()]);
await loadDocuments();
bindPageEvents(); bindPageEvents();
renderFolderOptions();
renderFolderBrowser();
renderDocuments(); renderDocuments();
} }
@@ -101,11 +122,39 @@ async function loadDocuments() {
params.set('status', state.status); params.set('status', state.status);
if (state.category) params.set('category', state.category); if (state.category) params.set('category', state.category);
const res = await api.get(`/documents?${params.toString()}`); const res = await api.get(`/documents?${params.toString()}`);
state.documents = res.data || []; state.allDocuments = res.data || [];
syncFolderDocuments();
}
async function loadFolders() {
const res = await api.get('/documents/folders');
state.folders = res.data || [];
}
function renderFolderOptions() {
const select = _container.querySelector('#documents-folder');
if (!select) return;
select.replaceChildren();
select.insertAdjacentHTML('beforeend', `<option value="">${t('documents.allFolders')}</option>`);
select.insertAdjacentHTML('beforeend', `<option value="__none" ${state.folderId === '__none' ? 'selected' : ''}>${t('documents.noFolder')}</option>`);
state.folders.forEach((folder) => {
select.insertAdjacentHTML('beforeend', `<option value="${folder.id}" ${String(folder.id) === String(state.folderId) ? 'selected' : ''}>${esc(folder.name)}</option>`);
});
}
function syncFolderDocuments() {
if (state.folderId === '__none') {
state.documents = state.allDocuments.filter((doc) => !doc.folder_id);
return;
}
state.documents = state.folderId
? state.allDocuments.filter((doc) => String(doc.folder_id || '') === String(state.folderId))
: state.allDocuments;
} }
function bindPageEvents() { function bindPageEvents() {
_container.querySelector('#documents-add-btn')?.addEventListener('click', () => openDocumentModal()); _container.querySelector('#documents-add-btn')?.addEventListener('click', () => openDocumentModal());
_container.querySelector('#documents-folder-btn')?.addEventListener('click', () => openFolderModal());
_container.querySelector('#fab-new-document')?.addEventListener('click', () => openDocumentModal()); _container.querySelector('#fab-new-document')?.addEventListener('click', () => openDocumentModal());
_container.querySelector('#documents-search')?.addEventListener('input', (e) => { _container.querySelector('#documents-search')?.addEventListener('input', (e) => {
state.query = e.target.value.trim().toLowerCase(); state.query = e.target.value.trim().toLowerCase();
@@ -114,11 +163,19 @@ function bindPageEvents() {
_container.querySelector('#documents-status')?.addEventListener('change', async (e) => { _container.querySelector('#documents-status')?.addEventListener('change', async (e) => {
state.status = e.target.value; state.status = e.target.value;
await loadDocuments(); await loadDocuments();
renderFolderBrowser();
renderDocuments(); renderDocuments();
}); });
_container.querySelector('#documents-category')?.addEventListener('change', async (e) => { _container.querySelector('#documents-category')?.addEventListener('change', async (e) => {
state.category = e.target.value; state.category = e.target.value;
await loadDocuments(); await loadDocuments();
renderFolderBrowser();
renderDocuments();
});
_container.querySelector('#documents-folder')?.addEventListener('change', async (e) => {
state.folderId = e.target.value;
syncFolderDocuments();
renderFolderBrowser();
renderDocuments(); renderDocuments();
}); });
_container.querySelector('.documents-view-toggle')?.addEventListener('click', (e) => { _container.querySelector('.documents-view-toggle')?.addEventListener('click', (e) => {
@@ -132,6 +189,15 @@ function bindPageEvents() {
renderDocuments(); renderDocuments();
}); });
_container.querySelector('#documents-list')?.addEventListener('click', handleDocumentAction); _container.querySelector('#documents-list')?.addEventListener('click', handleDocumentAction);
_container.querySelector('#documents-folder-browser')?.addEventListener('click', (e) => {
const btn = e.target.closest('[data-folder-id]');
if (!btn) return;
state.folderId = btn.dataset.folderId;
syncFolderDocuments();
renderFolderOptions();
renderFolderBrowser();
renderDocuments();
});
} }
function filteredDocuments() { function filteredDocuments() {
@@ -149,25 +215,61 @@ function renderDocuments() {
const docs = filteredDocuments(); const docs = filteredDocuments();
list.className = `documents-list documents-list--${state.view}`; list.className = `documents-list documents-list--${state.view}`;
if (!docs.length) { if (!docs.length) {
list.innerHTML = ` list.replaceChildren();
list.insertAdjacentHTML('beforeend', `
<div class="empty-state"> <div class="empty-state">
<i data-lucide="folder-open" class="empty-state__icon" aria-hidden="true"></i> <i data-lucide="folder-open" class="empty-state__icon" aria-hidden="true"></i>
<div class="empty-state__title">${t('documents.emptyTitle')}</div> <div class="empty-state__title">${t('documents.emptyTitle')}</div>
<div class="empty-state__description">${t('documents.emptyDescription')}</div> <div class="empty-state__description">${t('documents.emptyDescription')}</div>
</div> </div>
`; `);
if (window.lucide) lucide.createIcons(); if (window.lucide) lucide.createIcons();
return; return;
} }
list.innerHTML = docs.map((doc) => state.view === 'list' ? renderListItem(doc) : renderGridCard(doc)).join(''); list.replaceChildren();
list.insertAdjacentHTML('beforeend', docs.map((doc) => state.view === 'list' ? renderListItem(doc) : renderGridCard(doc)).join(''));
if (window.lucide) lucide.createIcons(); if (window.lucide) lucide.createIcons();
stagger(list.querySelectorAll('.document-card, .document-row')); stagger(list.querySelectorAll('.document-card, .document-row'));
} }
function folderCounts() {
const counts = new Map();
counts.set('', state.allDocuments.length);
counts.set('__none', state.allDocuments.filter((doc) => !doc.folder_id).length);
state.folders.forEach((folder) => counts.set(String(folder.id), 0));
state.allDocuments.forEach((doc) => {
if (!doc.folder_id) return;
const key = String(doc.folder_id);
counts.set(key, (counts.get(key) || 0) + 1);
});
return counts;
}
function renderFolderBrowser() {
const browser = _container.querySelector('#documents-folder-browser');
if (!browser) return;
const counts = folderCounts();
const items = [
{ id: '', name: t('documents.allFolders'), icon: 'folders' },
{ id: '__none', name: t('documents.noFolder'), icon: 'folder-x' },
...state.folders.map((folder) => ({ id: String(folder.id), name: folder.name, icon: 'folder' })),
];
browser.replaceChildren();
browser.insertAdjacentHTML('beforeend', items.map((item) => `
<button class="documents-folder-item ${String(state.folderId) === item.id ? 'documents-folder-item--active' : ''}" type="button" data-folder-id="${esc(item.id)}" aria-current="${String(state.folderId) === item.id ? 'true' : 'false'}">
<span class="documents-folder-item__icon"><i data-lucide="${esc(item.icon)}" aria-hidden="true"></i></span>
<span class="documents-folder-item__name">${esc(item.name)}</span>
<span class="documents-folder-item__count">${counts.get(item.id) || 0}</span>
</button>
`).join(''));
if (window.lucide) lucide.createIcons();
}
function renderMeta(doc) { function renderMeta(doc) {
const labels = categoryLabels(); const labels = categoryLabels();
return ` return `
<span><i data-lucide="${CATEGORY_ICONS[doc.category] || 'folder'}" aria-hidden="true"></i>${labels[doc.category] || doc.category}</span> <span><i data-lucide="${CATEGORY_ICONS[doc.category] || 'folder'}" aria-hidden="true"></i>${labels[doc.category] || doc.category}</span>
${doc.folder_name ? `<span><i data-lucide="folder" aria-hidden="true"></i>${esc(doc.folder_name)}</span>` : ''}
<span><i data-lucide="${doc.visibility === 'family' ? 'users' : doc.visibility === 'private' ? 'lock' : 'user-check'}" aria-hidden="true"></i>${t(`documents.visibility.${doc.visibility}`)}</span> <span><i data-lucide="${doc.visibility === 'family' ? 'users' : doc.visibility === 'private' ? 'lock' : 'user-check'}" aria-hidden="true"></i>${t(`documents.visibility.${doc.visibility}`)}</span>
<span>${formatFileSize(doc.file_size)}</span> <span>${formatFileSize(doc.file_size)}</span>
`; `;
@@ -230,6 +332,7 @@ async function handleDocumentAction(e) {
await api.patch(`/documents/${doc.id}/archive`, { archived: doc.status !== 'archived' }); await api.patch(`/documents/${doc.id}/archive`, { archived: doc.status !== 'archived' });
window.oikos?.showToast(doc.status === 'archived' ? t('documents.restoredToast') : t('documents.archivedToast'), 'success'); window.oikos?.showToast(doc.status === 'archived' ? t('documents.restoredToast') : t('documents.archivedToast'), 'success');
await loadDocuments(); await loadDocuments();
renderFolderBrowser();
renderDocuments(); renderDocuments();
} }
if (btn.dataset.action === 'delete') { if (btn.dataset.action === 'delete') {
@@ -237,6 +340,7 @@ async function handleDocumentAction(e) {
await api.delete(`/documents/${doc.id}`); await api.delete(`/documents/${doc.id}`);
window.oikos?.showToast(t('documents.deletedToast'), 'success'); window.oikos?.showToast(t('documents.deletedToast'), 'success');
await loadDocuments(); await loadDocuments();
renderFolderBrowser();
renderDocuments(); renderDocuments();
} }
} }
@@ -269,6 +373,13 @@ function openDocumentModal(doc = null) {
${CATEGORIES.map((category) => `<option value="${category}" ${doc?.category === category ? 'selected' : ''}>${categoryLabels()[category]}</option>`).join('')} ${CATEGORIES.map((category) => `<option value="${category}" ${doc?.category === category ? 'selected' : ''}>${categoryLabels()[category]}</option>`).join('')}
</select> </select>
</div> </div>
<div class="form-group">
<label class="label" for="document-folder">${t('documents.folderLabel')}</label>
<select class="input" id="document-folder">
<option value="">${t('documents.noFolder')}</option>
${state.folders.map((folder) => `<option value="${folder.id}" ${String(doc?.folder_id || '') === String(folder.id) ? 'selected' : ''}>${esc(folder.name)}</option>`).join('')}
</select>
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="label" for="document-description">${t('documents.descriptionLabel')}</label> <label class="label" for="document-description">${t('documents.descriptionLabel')}</label>
@@ -376,6 +487,7 @@ async function saveDocument(event, doc) {
name: form.querySelector('#document-name').value.trim(), name: form.querySelector('#document-name').value.trim(),
description: form.querySelector('#document-description').value.trim() || null, description: form.querySelector('#document-description').value.trim() || null,
category: form.querySelector('#document-category').value, category: form.querySelector('#document-category').value,
folder_id: form.querySelector('#document-folder').value || null,
visibility, visibility,
status: form.querySelector('#document-status').value, status: form.querySelector('#document-status').value,
allowed_member_ids: visibility === 'restricted' 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'); window.oikos?.showToast(doc ? t('documents.savedToast') : t('documents.uploadedToast'), 'success');
closeModal({ force: true }); closeModal({ force: true });
await loadDocuments(); await loadDocuments();
renderFolderBrowser();
renderDocuments(); renderDocuments();
} catch (err) { } catch (err) {
error.textContent = err.message; error.textContent = err.message;
@@ -405,6 +518,47 @@ async function saveDocument(event, doc) {
} }
} }
function openFolderModal() {
openSharedModal({
title: t('documents.newFolderTitle'),
size: 'sm',
content: `
<form id="document-folder-form" class="document-form">
<div class="form-group">
<label class="label" for="document-folder-name">${t('documents.folderNameLabel')}</label>
<input class="input" id="document-folder-name" required maxlength="200" autocomplete="off">
</div>
<div id="document-folder-error" class="login-error" hidden></div>
<div class="modal-panel__footer" style="padding:0;border:none;margin-top:var(--space-5)">
<button type="submit" class="btn btn--primary">${t('documents.createFolderAction')}</button>
</div>
</form>
`,
onSave(panel) {
panel.querySelector('#document-folder-form')?.addEventListener('submit', async (event) => {
event.preventDefault();
const error = panel.querySelector('#document-folder-error');
const input = panel.querySelector('#document-folder-name');
error.hidden = true;
try {
const res = await api.post('/documents/folders', { name: input.value.trim() });
window.oikos?.showToast(t('documents.folderCreatedToast'), 'success');
state.folderId = String(res.data?.id || '');
await loadFolders();
await loadDocuments();
closeModal({ force: true });
renderFolderOptions();
renderFolderBrowser();
renderDocuments();
} catch (err) {
error.textContent = err.message;
error.hidden = false;
}
});
},
});
}
function readFileAsDataUrl(file) { function readFileAsDataUrl(file) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
+877
View File
@@ -0,0 +1,877 @@
/**
* Modul: Housekeeping
* Zweck: Dashboard, chore management, reports, and housekeeping staff
* Abhängigkeiten: /api.js, /i18n.js, /utils/html.js
*/
import { api } from '/api.js';
import { t, formatDate, formatTime } from '/i18n.js';
import { esc } from '/utils/html.js';
import { openModal, closeModal, confirmModal } from '/components/modal.js';
const MAX_FILE_SIZE = 5 * 1024 * 1024;
let state = {
tab: 'dashboard',
dashboard: null,
tasks: [],
reports: [],
visitReport: null,
templates: [],
worker: null,
workers: [],
workerAvatar: undefined,
selectedStaffId: null,
staffLogMonth: new Date().toISOString().slice(0, 7),
staffVisits: [],
};
function money(value) {
return new Intl.NumberFormat(undefined, { style: 'currency', currency: 'BRL' }).format(Number(value || 0));
}
function initials(name = '') {
return name.split(' ').map((part) => part[0]).join('').slice(0, 2).toUpperCase();
}
function urgencyLabel(status) {
if (status === 'overdue') return t('housekeeping.overdue');
if (status === 'today') return t('housekeeping.dueToday');
return t('housekeeping.ok');
}
function scheduleLabel(value) {
const map = {
daily: t('housekeeping.scheduleDaily'),
twice_monthly: t('housekeeping.scheduleTwiceMonthly'),
monthly: t('housekeeping.scheduleMonthly'),
};
return map[value] || map.monthly;
}
function templateLabel(template, field) {
if (!template?.key) return template?.[field] || '';
const key = `housekeeping.taskTemplateData.${template.key}.${field}`;
const translated = t(key);
return translated === key ? template[field] : translated;
}
function visitTextPayload(worker, dateValue, dailyRate, extras) {
const visitDate = dateValue || new Date().toISOString().slice(0, 10);
const total = Number(dailyRate || 0) + Number(extras || 0);
const name = worker?.display_name || t('housekeeping.staff');
return {
event_title: t('housekeeping.calendarVisitTitle', { name }),
payment_title: t('housekeeping.paymentTaskTitle', { name }),
payment_description: t('housekeeping.paymentTaskDescription', {
date: formatDate(visitDate),
amount: money(total),
}),
};
}
function readFileAsDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error(t('documents.fileReadError')));
reader.readAsDataURL(file);
});
}
async function loadStaffVisits(workerId = state.selectedStaffId, monthValue = state.staffLogMonth) {
if (!workerId) {
state.staffVisits = [];
return;
}
const res = await api.get(`/housekeeping/visits?month=${encodeURIComponent(monthValue)}&worker_id=${encodeURIComponent(workerId)}`);
state.staffVisits = res.data?.visits || [];
}
async function loadData() {
const [dashboard, tasks, reports, templates, workers] = await Promise.all([
api.get('/housekeeping/dashboard'),
api.get('/housekeeping/decay-tasks'),
api.get('/housekeeping/visits'),
api.get('/housekeeping/task-templates'),
api.get('/housekeeping/workers'),
]);
state.dashboard = dashboard.data;
state.tasks = tasks.data || [];
state.visitReport = reports.data || { visits: [], totals: {} };
state.reports = state.visitReport.visits || [];
state.templates = templates.data || [];
state.workers = workers.data || [];
state.worker = state.workers[0] || null;
}
function renderTabButton(tab, icon, label) {
const current = state.tab === tab ? ' aria-current="page"' : '';
return `
<button class="housekeeping-tab sub-tab" type="button" data-housekeeping-tab="${esc(tab)}"${current}>
<i class="sub-tab__icon" data-lucide="${esc(icon)}" aria-hidden="true"></i>
<span class="sub-tab__label">${esc(label)}</span>
</button>
`;
}
function renderShell(container) {
container.replaceChildren();
container.insertAdjacentHTML('beforeend', `
<section class="housekeeping-page" aria-labelledby="housekeeping-title">
<header class="housekeeping-toolbar">
<div class="housekeeping-toolbar__title" id="housekeeping-title">${esc(t('housekeeping.title'))}</div>
<nav class="housekeeping-tabs" aria-label="${esc(t('housekeeping.bottomNav'))}">
${renderTabButton('dashboard', 'layout-dashboard', t('housekeeping.dashboard'))}
${renderTabButton('tasks', 'list-checks', t('housekeeping.tasks'))}
${renderTabButton('reports', 'file-text', t('housekeeping.reports'))}
${renderTabButton('staff', 'users-round', t('housekeeping.staff'))}
</nav>
</header>
<div class="housekeeping-content" id="housekeeping-content"></div>
</section>
`);
container.querySelectorAll('[data-housekeeping-tab]').forEach((btn) => {
btn.addEventListener('click', () => {
state.tab = btn.dataset.housekeepingTab;
renderCurrentTab(container);
});
});
renderCurrentTab(container);
}
function renderCurrentTab(container) {
const content = container.querySelector('#housekeeping-content');
if (!content) return;
content.replaceChildren();
container.querySelectorAll('[data-housekeeping-tab]').forEach((btn) => {
const active = btn.dataset.housekeepingTab === state.tab;
btn.classList.toggle('sub-tab--active', active);
if (active) btn.setAttribute('aria-current', 'page');
else btn.removeAttribute('aria-current');
});
if (state.tab === 'tasks') renderTasks(content);
else if (state.tab === 'reports') renderReports(content);
else if (state.tab === 'staff') renderStaff(content);
else renderDashboard(content);
if (window.lucide) window.lucide.createIcons({ el: container });
}
async function toggleSession(container, workerId) {
const worker = state.workers.find((item) => String(item.id) === String(workerId));
const current = worker?.today_session || worker?.current_session;
if (!state.workers.length) {
window.oikos?.showToast(t('housekeeping.checkInDisabled'), 'warning');
return;
}
if (!worker) return;
if (current) return;
try {
const dateValue = new Date().toISOString().slice(0, 10);
await api.post('/housekeeping/work-sessions/check-in', {
worker_id: worker.id,
daily_rate: worker.daily_rate || 0,
extras: 0,
...visitTextPayload(worker, dateValue, worker.daily_rate || 0, 0),
});
window.oikos?.showToast(t('housekeeping.checkedInToast'), 'success');
await loadData();
renderShell(container);
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
}
}
function renderWorkerSummary() {
if (!state.workers.length) {
return `
<section class="housekeeping-card housekeeping-worker-empty">
<i data-lucide="user-plus" aria-hidden="true"></i>
<div>
<h2>${esc(t('housekeeping.noWorkerTitle'))}</h2>
<p>${esc(t('housekeeping.noWorkerHint'))}</p>
</div>
<button class="btn btn--secondary housekeeping-check-small" type="button" disabled>
<i data-lucide="log-in" aria-hidden="true"></i>
<span>${esc(t('housekeeping.checkIn'))}</span>
</button>
</section>
`;
}
const rows = state.workers.map((worker) => {
const checkedIn = !!(worker.today_session || worker.current_session);
const session = worker.today_session || worker.current_session;
return `
<section class="housekeeping-worker-strip">
<div class="housekeeping-avatar" style="background:${esc(worker.avatar_color || '#7C3AED')}">
${worker.avatar_data ? `<img src="${esc(worker.avatar_data)}" alt="${esc(worker.display_name)}">` : esc(initials(worker.display_name))}
</div>
<div>
<strong>${esc(worker.display_name)}</strong>
<span>${esc(checkedIn ? `${t('housekeeping.visitRecordedAt')} ${formatTime(session.check_in)}` : `${money(worker.daily_rate)} · ${scheduleLabel(worker.payment_schedule)}`)}</span>
</div>
<button class="btn ${checkedIn ? 'btn--secondary' : 'btn--primary'} housekeeping-check-small" type="button"
data-worker-check="${worker.id}" ${checkedIn ? 'disabled' : ''}>
<i data-lucide="${checkedIn ? 'check' : 'log-in'}" aria-hidden="true"></i>
<span>${esc(checkedIn ? t('housekeeping.checkedInToday') : t('housekeeping.checkIn'))}</span>
</button>
</section>
`;
}).join('');
return `
<div class="housekeeping-worker-stack">
${rows}
</div>
`;
}
function renderDashboard(content) {
content.replaceChildren();
const data = state.dashboard || {};
const lastVisit = data.last_visit?.check_in ? `${formatDate(data.last_visit.check_in)} · ${formatTime(data.last_visit.check_in)}` : t('housekeeping.noVisits');
const maxPayment = Math.max(1, ...(data.monthly_payments || []).map((row) => row.total));
const bars = (data.monthly_payments || []).map((row) => {
const height = Math.max(8, Math.round((row.total / maxPayment) * 88));
return `
<div class="housekeeping-chart__bar-wrap">
<div class="housekeeping-chart__bar" style="height:${height}px" title="${esc(row.month)} ${esc(money(row.total))}"></div>
<span>${esc(row.month.slice(5))}</span>
</div>
`;
}).join('');
content.insertAdjacentHTML('beforeend', `
${renderWorkerSummary()}
<section class="housekeeping-metrics">
<article class="housekeeping-metric">
<span>${esc(t('housekeeping.visitsThisMonth'))}</span>
<strong>${esc(data.visits_this_month ?? 0)}</strong>
</article>
<article class="housekeeping-metric">
<span>${esc(t('housekeeping.lastVisit'))}</span>
<strong>${esc(lastVisit)}</strong>
</article>
<article class="housekeeping-metric">
<span>${esc(t('housekeeping.pendingChores'))}</span>
<strong>${esc(data.pending_tasks ?? 0)}</strong>
</article>
<article class="housekeeping-metric">
<span>${esc(t('housekeeping.finishedChores'))}</span>
<strong>${esc(data.finished_tasks_this_month ?? 0)}</strong>
</article>
</section>
<section class="housekeeping-card">
<div class="housekeeping-section-heading">
<h2>${esc(t('housekeeping.payments'))}</h2>
<span>${esc(t('housekeeping.pendingPayments'))}: ${esc(money(data.pending_payments || 0))}</span>
</div>
<div class="housekeeping-chart" aria-label="${esc(t('housekeeping.monthlyPayments'))}">
${bars || `<p class="housekeeping-muted">${esc(t('housekeeping.noPaymentData'))}</p>`}
</div>
</section>
`);
content.querySelectorAll('[data-worker-check]').forEach((btn) => {
btn.addEventListener('click', () => toggleSession(document.querySelector('.page-transition') || document.body, btn.dataset.workerCheck));
});
}
async function createTask(payload, content) {
try {
await api.post('/housekeeping/decay-tasks', payload);
window.oikos?.showToast(t('housekeeping.taskCreatedToast'), 'success');
await loadData();
renderTasks(content);
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
}
}
function renderTasks(content) {
content.replaceChildren();
const templateButtons = state.templates.map((template, index) => `
<button class="housekeeping-template" type="button" data-template-index="${index}">
<span>${esc(templateLabel(template, 'name'))}</span>
<small>${esc(templateLabel(template, 'area'))} · ${esc(t('housekeeping.everyDays', { days: template.frequency_days }))}</small>
</button>
`).join('');
const taskRows = state.tasks.map((task) => `
<article class="housekeeping-task housekeeping-task--${esc(task.urgency_status)}">
<button class="housekeeping-task__check" type="button" data-complete-task="${task.id}"
aria-label="${esc(t('housekeeping.completeTask', { name: task.name }))}">
<i data-lucide="check" aria-hidden="true"></i>
</button>
<div class="housekeeping-task__body">
<h2>${esc(task.name)}</h2>
<p>${esc(task.area)} · ${esc(t('housekeeping.everyDays', { days: task.frequency_days }))}</p>
<span>${esc(urgencyLabel(task.urgency_status))}</span>
</div>
</article>
`).join('');
content.insertAdjacentHTML('beforeend', `
<section class="housekeeping-card">
<h2>${esc(t('housekeeping.taskTemplates'))}</h2>
<div class="housekeeping-template-list">${templateButtons}</div>
</section>
<section class="housekeeping-card">
<h2>${esc(t('housekeeping.addCustomTask'))}</h2>
<form id="housekeeping-task-form" class="housekeeping-task-form">
<div class="housekeeping-form-grid housekeeping-form-grid--wide">
<label class="housekeeping-field">
<span>${esc(t('housekeeping.taskName'))}</span>
<input name="name" required maxlength="200" autocomplete="off">
</label>
<label class="housekeeping-field">
<span>${esc(t('housekeeping.taskArea'))}</span>
<input name="area" required maxlength="100" autocomplete="off">
</label>
<label class="housekeeping-field">
<span>${esc(t('housekeeping.taskFrequency'))}</span>
<input name="frequency_days" required inputmode="numeric" type="number" min="1" step="1" value="7">
</label>
</div>
<button class="btn btn--primary housekeeping-form-submit" type="submit">
<i data-lucide="plus" aria-hidden="true"></i>
<span>${esc(t('housekeeping.createTask'))}</span>
</button>
</form>
</section>
<section class="housekeeping-task-list">
${taskRows || `
<div class="housekeeping-empty">
<i data-lucide="list-checks" aria-hidden="true"></i>
<h2>${esc(t('housekeeping.noTasks'))}</h2>
</div>
`}
</section>
`);
content.querySelectorAll('[data-template-index]').forEach((btn) => {
btn.addEventListener('click', () => {
const template = state.templates[Number(btn.dataset.templateIndex)];
if (template) {
createTask({
name: templateLabel(template, 'name'),
area: templateLabel(template, 'area'),
frequency_days: template.frequency_days,
}, content);
}
});
});
content.querySelector('#housekeeping-task-form')?.addEventListener('submit', (event) => {
event.preventDefault();
const form = event.currentTarget;
const fields = form.elements;
const frequencyDays = Number(fields.frequency_days.value);
if (!fields.name.value.trim() || !fields.area.value.trim() || !Number.isInteger(frequencyDays) || frequencyDays < 1) return;
createTask({
name: fields.name.value.trim(),
area: fields.area.value.trim(),
frequency_days: frequencyDays,
}, content);
});
content.querySelectorAll('[data-complete-task]').forEach((btn) => {
btn.addEventListener('click', async () => {
try {
await api.post(`/housekeeping/decay-tasks/${btn.dataset.completeTask}/complete`, {});
window.oikos?.showToast(t('housekeeping.taskDoneToast'), 'success');
await loadData();
renderTasks(content);
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
}
});
});
}
function renderReports(content) {
content.replaceChildren();
const totals = state.visitReport?.totals || {};
const visits = state.reports || [];
const rows = visits.map((visit) => {
const paid = !!visit.paid_at;
return `
<article class="housekeeping-report-item housekeeping-report-item--visit">
<div class="housekeeping-avatar" style="background:${esc(visit.worker_avatar_color || '#7C3AED')}">
${visit.worker_avatar_data ? `<img src="${esc(visit.worker_avatar_data)}" alt="${esc(visit.worker_name || '')}">` : esc(initials(visit.worker_name || 'HK'))}
</div>
<div>
<strong>${esc(visit.worker_name || t('housekeeping.staff'))}</strong>
<span>${esc(formatDate(visit.check_in))} · ${esc(money(visit.total_amount))} · ${esc(paid ? t('housekeeping.paymentPaid') : t('housekeeping.paymentPending'))}</span>
</div>
<button class="btn btn--secondary btn--icon" type="button" data-visit-report="${visit.id}" aria-label="${esc(t('housekeeping.openVisitReport'))}">
<i data-lucide="file-text" aria-hidden="true"></i>
</button>
</article>
`;
}).join('');
content.insertAdjacentHTML('beforeend', `
<section class="housekeeping-card">
<div class="housekeeping-section-heading">
<h2>${esc(t('housekeeping.visitReports'))}</h2>
<span>${esc(state.visitReport?.month || '')}</span>
</div>
<section class="housekeeping-metrics housekeeping-metrics--compact">
<article class="housekeeping-metric">
<span>${esc(t('housekeeping.visitsThisMonth'))}</span>
<strong>${esc(visits.length)}</strong>
</article>
<article class="housekeeping-metric">
<span>${esc(t('housekeeping.pendingPayments'))}</span>
<strong>${esc(money(totals.pending || 0))}</strong>
</article>
<article class="housekeeping-metric">
<span>${esc(t('housekeeping.paymentPaid'))}</span>
<strong>${esc(money(totals.paid || 0))}</strong>
</article>
</section>
</section>
<section class="housekeeping-reports" aria-label="${esc(t('housekeeping.recentReports'))}">
${rows || `<p class="housekeeping-muted">${esc(t('housekeeping.noVisitReports'))}</p>`}
</section>
`);
content.querySelectorAll('[data-visit-report]').forEach((btn) => {
btn.addEventListener('click', () => {
const visit = visits.find((item) => String(item.id) === btn.dataset.visitReport);
if (visit) openVisitReportModal(visit);
});
});
}
function openVisitReportModal(visit) {
const paid = !!visit.paid_at;
openModal({
title: t('housekeeping.visitReportDetails'),
size: 'md',
content: `
<div class="housekeeping-report-modal">
<div class="housekeeping-staff-row">
<div class="housekeeping-avatar" style="background:${esc(visit.worker_avatar_color || '#7C3AED')}">
${visit.worker_avatar_data ? `<img src="${esc(visit.worker_avatar_data)}" alt="${esc(visit.worker_name || '')}">` : esc(initials(visit.worker_name || 'HK'))}
</div>
<div>
<strong>${esc(visit.worker_name || t('housekeeping.staff'))}</strong>
<span>${esc(scheduleLabel(visit.payment_schedule))}</span>
</div>
</div>
<dl class="housekeeping-report-details">
<div><dt>${esc(t('housekeeping.lastVisit'))}</dt><dd>${esc(formatDate(visit.check_in))} · ${esc(formatTime(visit.check_in))}</dd></div>
<div><dt>${esc(t('housekeeping.dailyRate'))}</dt><dd>${esc(money(visit.daily_rate))}</dd></div>
<div><dt>${esc(t('housekeeping.extras'))}</dt><dd>${esc(money(visit.extras))}</dd></div>
<div><dt>${esc(t('housekeeping.totalPayment'))}</dt><dd>${esc(money(visit.total_amount))}</dd></div>
<div><dt>${esc(t('housekeeping.paymentStatus'))}</dt><dd>${esc(paid ? t('housekeeping.paymentPaid') : t('housekeeping.paymentPending'))}</dd></div>
<div><dt>${esc(t('housekeeping.paymentTask'))}</dt><dd>${esc(visit.payment_task_id ? `#${visit.payment_task_id}` : t('housekeeping.notAvailable'))}</dd></div>
<div><dt>${esc(t('housekeeping.calendarEvent'))}</dt><dd>${esc(visit.calendar_event_id ? `#${visit.calendar_event_id}` : t('housekeeping.notAvailable'))}</dd></div>
</dl>
</div>
`,
});
}
function renderStaff(content) {
content.replaceChildren();
const workerRows = state.workers.map((item) => `
<article class="housekeeping-staff-row ${String(state.selectedStaffId || '') === String(item.id) ? 'housekeeping-staff-row--active' : ''}"
data-select-worker="${item.id}" role="button" tabindex="0">
<div class="housekeeping-avatar" style="background:${esc(item.avatar_color || '#7C3AED')}">
${item.avatar_data ? `<img src="${esc(item.avatar_data)}" alt="${esc(item.display_name)}">` : esc(initials(item.display_name))}
</div>
<div>
<strong>${esc(item.display_name)}</strong>
<span>${esc(item.phone || item.email || '')}</span>
</div>
<button class="btn btn--secondary btn--icon" type="button" data-edit-worker="${item.id}" aria-label="${esc(t('common.edit'))}">
<i data-lucide="edit-2" aria-hidden="true"></i>
</button>
</article>
`).join('');
content.insertAdjacentHTML('beforeend', `
<section class="housekeeping-card">
<div class="housekeeping-section-heading">
<h2>${esc(t('housekeeping.staffTitle'))}</h2>
<button class="btn btn--secondary" type="button" id="housekeeping-new-worker">
<i data-lucide="plus" aria-hidden="true"></i>
<span>${esc(t('housekeeping.addWorker'))}</span>
</button>
</div>
<div class="housekeeping-staff-list">
${workerRows || `<p class="housekeeping-muted">${esc(t('housekeeping.noWorkers'))}</p>`}
</div>
</section>
${state.selectedStaffId ? renderStaffVisitLog() : ''}
`);
content.querySelector('#housekeeping-new-worker')?.addEventListener('click', () => {
openStaffModal(null, content);
});
content.querySelectorAll('[data-select-worker]').forEach((row) => {
const select = async () => {
state.selectedStaffId = row.dataset.selectWorker;
try {
await loadStaffVisits();
renderStaff(content);
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
}
};
row.addEventListener('click', (event) => {
if (event.target.closest('[data-edit-worker]')) return;
select();
});
row.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
select();
});
});
content.querySelectorAll('[data-edit-worker]').forEach((btn) => {
btn.addEventListener('click', (event) => {
event.stopPropagation();
const worker = state.workers.find((item) => String(item.id) === btn.dataset.editWorker) || null;
openStaffModal(worker, content);
});
});
content.querySelector('#housekeeping-staff-month')?.addEventListener('change', async (event) => {
state.staffLogMonth = event.currentTarget.value || new Date().toISOString().slice(0, 7);
try {
await loadStaffVisits();
renderStaff(content);
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
}
});
content.querySelectorAll('[data-edit-visit]').forEach((btn) => {
btn.addEventListener('click', () => {
const visit = state.staffVisits.find((item) => String(item.id) === btn.dataset.editVisit);
if (visit) openVisitEditModal(visit, content);
});
});
content.querySelectorAll('[data-pay-visit]').forEach((btn) => {
btn.addEventListener('click', async () => {
const visit = state.staffVisits.find((item) => String(item.id) === btn.dataset.payVisit);
if (!visit) return;
try {
await api.post(`/housekeeping/visits/${visit.id}/pay`, {});
window.oikos?.showToast(t('housekeeping.visitPaidToast'), 'success');
await loadData();
await loadStaffVisits();
renderStaff(content);
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
}
});
});
content.querySelectorAll('[data-delete-visit]').forEach((btn) => {
btn.addEventListener('click', async () => {
const visit = state.staffVisits.find((item) => String(item.id) === btn.dataset.deleteVisit);
if (!visit) return;
if (!await confirmModal(t('housekeeping.deleteVisitConfirm'), { danger: true, confirmLabel: t('common.delete') })) return;
try {
await api.delete(`/housekeeping/visits/${visit.id}`);
window.oikos?.showToast(t('housekeeping.visitDeletedToast'), 'success');
await loadData();
await loadStaffVisits();
renderStaff(content);
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
}
});
});
if (window.lucide) window.lucide.createIcons({ el: content });
}
function renderStaffVisitLog() {
const worker = state.workers.find((item) => String(item.id) === String(state.selectedStaffId));
if (!worker) return '';
const rows = state.staffVisits.map((visit) => {
const paid = !!visit.paid_at;
return `
<article class="housekeeping-staff-log-row">
<div>
<strong>${esc(formatDate(visit.check_in))}</strong>
<span>${esc(money(visit.total_amount))} · ${esc(paid ? t('housekeeping.paymentPaid') : t('housekeeping.paymentPending'))}</span>
</div>
<div class="housekeeping-staff-log-row__actions">
<button class="btn btn--secondary housekeeping-log-action" type="button" data-pay-visit="${visit.id}" ${paid ? 'disabled' : ''}
aria-label="${esc(t('housekeeping.markPaid'))}">
<i data-lucide="badge-dollar-sign" aria-hidden="true"></i>
<span>${esc(paid ? t('housekeeping.paymentPaid') : t('housekeeping.markPaid'))}</span>
</button>
<button class="btn btn--secondary housekeeping-log-action" type="button" data-edit-visit="${visit.id}" aria-label="${esc(t('housekeeping.editVisit'))}">
<i data-lucide="edit-2" aria-hidden="true"></i>
<span>${esc(t('housekeeping.editVisit'))}</span>
</button>
<button class="btn btn--danger-outline housekeeping-log-action" type="button" data-delete-visit="${visit.id}" aria-label="${esc(t('housekeeping.deleteVisit'))}">
<i data-lucide="trash-2" aria-hidden="true"></i>
<span>${esc(t('housekeeping.deleteVisit'))}</span>
</button>
</div>
</article>
`;
}).join('');
return `
<section class="housekeeping-card housekeeping-staff-log">
<div class="housekeeping-section-heading">
<div>
<h2>${esc(t('housekeeping.staffLogTitle', { name: worker.display_name }))}</h2>
<span>${esc(t('housekeeping.staffLogHint'))}</span>
</div>
<label class="housekeeping-field housekeeping-field--inline">
<span>${esc(t('housekeeping.filterMonth'))}</span>
<input id="housekeeping-staff-month" type="month" value="${esc(state.staffLogMonth)}">
</label>
</div>
<div class="housekeeping-staff-log-list">
${rows || `<p class="housekeeping-muted">${esc(t('housekeeping.noVisitReports'))}</p>`}
</div>
</section>
`;
}
function openVisitEditModal(visit, content) {
const worker = state.workers.find((item) => String(item.id) === String(visit.worker_id)) || null;
openModal({
title: t('housekeeping.editVisit'),
size: 'md',
content: `
<form id="housekeeping-visit-form" class="housekeeping-worker-form">
<label class="housekeeping-field">
<span>${esc(t('housekeeping.visitDate'))}</span>
<input name="date" type="date" required value="${esc(visit.check_in.slice(0, 10))}">
</label>
<div class="housekeeping-form-grid">
<label class="housekeeping-field">
<span>${esc(t('housekeeping.dailyRate'))}</span>
<input name="daily_rate" type="number" min="0" step="0.01" inputmode="decimal" value="${esc(visit.daily_rate ?? 0)}">
</label>
<label class="housekeeping-field">
<span>${esc(t('housekeeping.extras'))}</span>
<input name="extras" type="number" min="0" step="0.01" inputmode="decimal" value="${esc(visit.extras ?? 0)}">
</label>
</div>
<label class="document-dropzone" id="housekeeping-receipt-dropzone" for="housekeeping-receipt-file">
<input class="sr-only" id="housekeeping-receipt-file" type="file" accept="image/png,image/jpeg,image/webp,application/pdf,text/plain,text/csv">
<span class="document-dropzone__icon">
<i data-lucide="receipt" aria-hidden="true"></i>
</span>
<span class="document-dropzone__title">${esc(t('housekeeping.receiptUploadTitle'))}</span>
<span class="document-dropzone__hint">${esc(t('housekeeping.receiptUploadHint'))}</span>
<span class="document-dropzone__file" id="housekeeping-receipt-selected" ${visit.receipt_document_name ? '' : 'hidden'}>
${esc(visit.receipt_document_name || '')}
</span>
</label>
<button class="btn btn--primary housekeeping-form-submit" type="submit">
<i data-lucide="save" aria-hidden="true"></i>
<span>${esc(t('common.save'))}</span>
</button>
</form>
`,
onSave: (panel) => {
panel.querySelector('#housekeeping-visit-form')?.addEventListener('submit', async (event) => {
event.preventDefault();
const form = event.currentTarget;
const fields = form.elements;
const dateValue = fields.date.value;
const dailyRate = Number(fields.daily_rate.value || 0);
const extras = Number(fields.extras.value || 0);
let receiptDocumentId = visit.receipt_document_id || null;
try {
const file = panel.querySelector('#housekeeping-receipt-file')?.files?.[0];
if (file) {
if (file.size > MAX_FILE_SIZE) throw new Error(t('documents.fileTooLarge'));
const receipt = await api.post('/documents', {
name: t('housekeeping.receiptDocumentName', {
name: worker?.display_name || t('housekeeping.staff'),
date: formatDate(dateValue),
}),
description: t('housekeeping.receiptDocumentDescription', {
name: worker?.display_name || t('housekeeping.staff'),
date: formatDate(dateValue),
}),
category: 'finance',
visibility: 'family',
status: 'active',
allowed_member_ids: [],
original_name: file.name,
content_data: await readFileAsDataUrl(file),
folder_name: t('documents.housekeepingFolder'),
});
receiptDocumentId = receipt.data?.id || receiptDocumentId;
}
await api.put(`/housekeeping/visits/${visit.id}`, {
date: dateValue,
daily_rate: dailyRate,
extras,
receipt_document_id: receiptDocumentId,
...visitTextPayload(worker, dateValue, dailyRate, extras),
});
window.oikos?.showToast(t('housekeeping.visitSavedToast'), 'success');
await loadData();
state.staffLogMonth = dateValue.slice(0, 7);
await loadStaffVisits();
closeModal({ force: true });
renderStaff(content);
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
}
});
},
});
const panel = document.querySelector('.modal-panel');
const receiptInput = panel?.querySelector('#housekeeping-receipt-file');
const receiptSelected = panel?.querySelector('#housekeeping-receipt-selected');
receiptInput?.addEventListener('change', () => {
const file = receiptInput.files?.[0];
if (!receiptSelected) return;
receiptSelected.hidden = !file && !visit.receipt_document_name;
receiptSelected.textContent = file
? t('documents.selectedFileLabel', { name: file.name })
: (visit.receipt_document_name || '');
});
if (window.lucide) window.lucide.createIcons({ el: panel });
}
function openStaffModal(worker, content) {
const item = worker || {};
state.workerAvatar = item.avatar_data ?? null;
openModal({
title: item.id ? t('housekeeping.editWorker') : t('housekeeping.addWorker'),
size: 'lg',
content: `
<form id="housekeeping-worker-form" class="housekeeping-worker-form">
<input type="hidden" name="id" value="${esc(item.id || '')}">
<div class="housekeeping-profile-editor">
<button class="housekeeping-avatar housekeeping-avatar--lg" type="button" id="housekeeping-avatar-btn"
style="background:${esc(item.avatar_color || '#7C3AED')}" aria-label="${esc(t('housekeeping.profilePicture'))}">
${item.avatar_data ? `<img src="${esc(item.avatar_data)}" alt="${esc(item.display_name || '')}">` : esc(initials(item.display_name || 'HK'))}
</button>
<input class="sr-only" type="file" id="housekeeping-avatar-file" accept="image/png,image/jpeg,image/webp">
<div class="housekeeping-profile-editor__fields">
<label class="housekeeping-field">
<span>${esc(t('housekeeping.workerName'))}</span>
<input name="display_name" required maxlength="128" value="${esc(item.display_name || '')}">
</label>
<label class="housekeeping-field">
<span>${esc(t('housekeeping.workerUsername'))}</span>
<input name="username" maxlength="64" autocomplete="off" value="${esc(item.username || '')}">
</label>
</div>
</div>
<div class="housekeeping-form-grid housekeeping-form-grid--wide">
<label class="housekeeping-field">
<span>${esc(t('housekeeping.workerPhone'))}</span>
<input name="phone" type="tel" autocomplete="tel" value="${esc(item.phone || '')}">
</label>
<label class="housekeeping-field">
<span>${esc(t('housekeeping.workerEmail'))}</span>
<input name="email" type="email" autocomplete="email" value="${esc(item.email || '')}">
</label>
<label class="housekeeping-field">
<span>${esc(t('housekeeping.workerBirthDate'))}</span>
<input name="birth_date" type="date" value="${esc(item.birth_date || '')}">
</label>
<label class="housekeeping-field">
<span>${esc(t('housekeeping.dailyRate'))}</span>
<input name="daily_rate" type="number" min="0" step="0.01" inputmode="decimal" value="${esc(item.daily_rate ?? 0)}">
</label>
<label class="housekeeping-field housekeeping-field--color">
<span>${esc(t('housekeeping.calendarColor'))}</span>
<input name="calendar_color" type="color" value="${esc(item.calendar_color || '#7C3AED')}">
</label>
<label class="housekeeping-field">
<span>${esc(t('housekeeping.paymentSchedule'))}</span>
<select name="payment_schedule">
<option value="daily"${item.payment_schedule === 'daily' ? ' selected' : ''}>${esc(t('housekeeping.scheduleDaily'))}</option>
<option value="twice_monthly"${item.payment_schedule === 'twice_monthly' ? ' selected' : ''}>${esc(t('housekeeping.scheduleTwiceMonthly'))}</option>
<option value="monthly"${!item.payment_schedule || item.payment_schedule === 'monthly' ? ' selected' : ''}>${esc(t('housekeeping.scheduleMonthly'))}</option>
</select>
</label>
<label class="housekeeping-field housekeeping-field--color">
<span>${esc(t('housekeeping.profileColor'))}</span>
<input name="avatar_color" type="color" value="${esc(item.avatar_color || '#7C3AED')}">
</label>
</div>
<label class="housekeeping-field">
<span>${esc(t('housekeeping.workerNotes'))}</span>
<textarea name="notes" rows="3" maxlength="5000">${esc(item.notes || '')}</textarea>
</label>
<button class="btn btn--primary housekeeping-form-submit" type="submit">
<i data-lucide="save" aria-hidden="true"></i>
<span>${esc(t('common.save'))}</span>
</button>
</form>
`,
onSave: (panel) => {
panel.querySelector('#housekeeping-worker-form')?.addEventListener('submit', async (event) => {
event.preventDefault();
const form = event.currentTarget;
const fields = form.elements;
try {
await api.post('/housekeeping/worker', {
id: fields.id.value || null,
display_name: fields.display_name.value.trim(),
username: fields.username.value.trim() || null,
phone: fields.phone.value.trim() || null,
email: fields.email.value.trim() || null,
birth_date: fields.birth_date.value || null,
daily_rate: Number(fields.daily_rate.value || 0),
payment_schedule: fields.payment_schedule.value,
calendar_color: fields.calendar_color.value,
avatar_color: fields.avatar_color.value,
avatar_data: state.workerAvatar,
notes: fields.notes.value.trim() || null,
});
window.oikos?.showToast(t('housekeeping.workerSavedToast'), 'success');
await loadData();
closeModal({ force: true });
renderStaff(content);
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
}
});
},
});
const panel = document.querySelector('.modal-panel');
const avatarFile = panel?.querySelector('#housekeeping-avatar-file');
const avatarButton = panel?.querySelector('#housekeeping-avatar-btn');
avatarButton?.addEventListener('click', () => avatarFile?.click());
avatarFile?.addEventListener('change', () => {
const file = avatarFile.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.addEventListener('load', () => {
state.workerAvatar = String(reader.result || '');
avatarButton.replaceChildren();
avatarButton.insertAdjacentHTML('beforeend', `<img src="${esc(state.workerAvatar)}" alt="">`);
});
reader.readAsDataURL(file);
});
if (window.lucide) window.lucide.createIcons({ el: panel });
}
export async function render(container) {
container.replaceChildren();
container.insertAdjacentHTML('beforeend', `
<section class="housekeeping-page housekeeping-page--loading">
<div class="housekeeping-loading">${esc(t('common.loading'))}</div>
</section>
`);
try {
await loadData();
renderShell(container);
} catch (err) {
container.replaceChildren();
container.insertAdjacentHTML('beforeend', `
<section class="housekeeping-page">
<div class="empty-state">
<div class="empty-state__title">${esc(t('common.errorOccurred'))}</div>
<div class="empty-state__description">${esc(err.message)}</div>
</div>
</section>
`);
}
}
+28 -1
View File
@@ -201,7 +201,7 @@ export async function render(container, { user }) {
let users = []; let users = [];
let googleStatus = { configured: false, connected: false, lastSync: null }; let googleStatus = { configured: false, connected: false, lastSync: null };
let appleStatus = { configured: 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 categories = [];
let icsSubscriptions = []; let icsSubscriptions = [];
let apiTokens = []; let apiTokens = [];
@@ -376,6 +376,20 @@ export async function render(container, { user }) {
</div> </div>
</section> </section>
` : ''} ` : ''}
${user?.role === 'admin' ? `
<section class="settings-section">
<h2 class="settings-section__title">${t('settings.sectionHousekeeping')}</h2>
<div class="settings-card">
<h3 class="settings-card__title">${t('settings.housekeepingPaymentsTitle')}</h3>
<p class="form-hint" style="margin-bottom:var(--space-3)">${t('settings.housekeepingPaymentTasksHint')}</p>
<label class="toggle-row">
<input type="checkbox" id="housekeeping-payment-tasks" ${prefs.housekeeping_payment_tasks ? 'checked' : ''}>
<span>${t('settings.housekeepingPaymentTasksLabel')}</span>
</label>
</div>
</section>
` : ''}
</div> </div>
<!-- Panel: Mahlzeiten --> <!-- Panel: Mahlzeiten -->
@@ -1284,6 +1298,19 @@ function bindEvents(container, user, users, categories, icsSubscriptions, apiTok
}); });
} }
const housekeepingPaymentTasks = container.querySelector('#housekeeping-payment-tasks');
if (housekeepingPaymentTasks) {
housekeepingPaymentTasks.addEventListener('change', async () => {
try {
await api.put('/preferences', { housekeeping_payment_tasks: housekeepingPaymentTasks.checked });
window.oikos?.showToast(t('settings.housekeepingPaymentTasksSaved'), 'success');
} catch (err) {
window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger');
housekeepingPaymentTasks.checked = !housekeepingPaymentTasks.checked;
}
});
}
const appNameForm = container.querySelector('#app-name-form'); const appNameForm = container.querySelector('#app-name-form');
if (appNameForm) { if (appNameForm) {
appNameForm.addEventListener('submit', async (e) => { appNameForm.addEventListener('submit', async (e) => {
+4 -1
View File
@@ -27,6 +27,7 @@ const ROUTES = [
{ path: '/contacts', page: '/pages/contacts.js', requiresAuth: true, module: 'contacts' }, { path: '/contacts', page: '/pages/contacts.js', requiresAuth: true, module: 'contacts' },
{ path: '/budget', page: '/pages/budget.js', requiresAuth: true, module: 'budget' }, { path: '/budget', page: '/pages/budget.js', requiresAuth: true, module: 'budget' },
{ path: '/documents', page: '/pages/documents.js', requiresAuth: true, module: 'documents' }, { 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' }, { 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', 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; const PRIMARY_NAV = 3;
@@ -185,6 +186,7 @@ function routeTitle(path) {
'/contacts': t('nav.contacts'), '/contacts': t('nav.contacts'),
'/budget': t('nav.budget'), '/budget': t('nav.budget'),
'/documents': t('nav.documents'), '/documents': t('nav.documents'),
'/housekeeping': t('nav.housekeeping'),
'/settings': t('nav.settings'), '/settings': t('nav.settings'),
}; };
return map[path] || getAppName(); return map[path] || getAppName();
@@ -1003,6 +1005,7 @@ function navItems() {
{ path: '/contacts', label: t('nav.contacts'), icon: 'book-user', module: 'contacts' }, { path: '/contacts', label: t('nav.contacts'), icon: 'book-user', module: 'contacts' },
{ path: '/budget', label: t('nav.budget'), icon: 'wallet', module: 'budget' }, { path: '/budget', label: t('nav.budget'), icon: 'wallet', module: 'budget' },
{ path: '/documents', label: t('nav.documents'), icon: 'folder-lock', module: 'documents' }, { 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' }, { path: '/settings', label: t('nav.settings'), icon: 'settings', module: 'settings' },
// Kitchen-Gruppe: via Küche-Nav-Button (Bottom-Nav + Sidebar) + kitchen-tabs-bar erreichbar // 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 }, { path: '/meals', label: t('nav.meals'), icon: 'utensils', module: 'meals', kitchenGroup: true },
+15
View File
@@ -691,6 +691,21 @@
overflow-y: auto; 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 { .event-icon-picker__search {
margin-bottom: var(--space-1); margin-bottom: var(--space-1);
background: var(--color-surface); background: var(--color-surface);
+112 -1
View File
@@ -91,10 +91,97 @@
max-width: 240px; 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)); 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 { .documents-list--grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
@@ -335,6 +422,30 @@
max-width: none; 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 { .document-row {
grid-template-columns: auto minmax(0, 1fr); grid-template-columns: auto minmax(0, 1fr);
} }
+678
View File
@@ -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;
}
}
+4
View File
@@ -193,6 +193,8 @@
--module-budget: var(--_module-budget); /* Teal-700 - Finanzen, Stabilität */ --module-budget: var(--_module-budget); /* Teal-700 - Finanzen, Stabilität */
--_module-documents: #1D4ED8; --_module-documents: #1D4ED8;
--module-documents: var(--_module-documents); /* Blue - secure family documents */ --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: #6E7781;
--module-settings: var(--_module-settings); /* Grau - Konfiguration */ --module-settings: var(--_module-settings); /* Grau - Konfiguration */
--_module-reminders: #0E7490; --_module-reminders: #0E7490;
@@ -554,6 +556,7 @@
--_module-contacts: #60A5FA; --_module-contacts: #60A5FA;
--_module-birthdays: #FB7185; --_module-birthdays: #FB7185;
--_module-budget: #2DD4BF; --_module-budget: #2DD4BF;
--_module-housekeeping: #C4B5FD;
--_module-settings: #94A3B8; --_module-settings: #94A3B8;
--_module-reminders: #22D3EE; /* Cyan-400 */ --_module-reminders: #22D3EE; /* Cyan-400 */
@@ -659,6 +662,7 @@
--_module-contacts: #60A5FA; --_module-contacts: #60A5FA;
--_module-birthdays: #FB7185; --_module-birthdays: #FB7185;
--_module-budget: #2DD4BF; /* Teal-400 */ --_module-budget: #2DD4BF; /* Teal-400 */
--_module-housekeeping: #C4B5FD;
--_module-settings: #94A3B8; --_module-settings: #94A3B8;
--_module-reminders: #22D3EE; /* Cyan-400 */ --_module-reminders: #22D3EE; /* Cyan-400 */
+9 -2
View File
@@ -572,7 +572,14 @@ router.get('/me', requireAuth, (req, res) => {
router.get('/users', requireAuth, requireAdmin, (req, res) => { router.get('/users', requireAuth, requireAdmin, (req, res) => {
try { try {
const users = db.get() 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(); .all();
res.json({ data: users.map(publicUser) }); res.json({ data: users.map(publicUser) });
} catch (err) { } 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 };
+151
View File
@@ -1076,6 +1076,15 @@ const MIGRATIONS = [
}, },
{ {
version: 30, 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', description: 'CardDAV multi-account contacts sync',
up: ` up: `
-- ======================================== -- ========================================
@@ -1209,6 +1218,148 @@ const MIGRATIONS = [
SELECT id, assigned_to FROM calendar_events WHERE assigned_to IS NOT NULL; 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);
`,
},
]; ];
/** /**
+37
View File
@@ -36,6 +36,7 @@ import remindersRouter from './routes/reminders.js';
import searchRouter from './routes/search.js'; import searchRouter from './routes/search.js';
import familyRouter from './routes/family.js'; import familyRouter from './routes/family.js';
import backupRouter from './routes/backup.js'; import backupRouter from './routes/backup.js';
import housekeepingRouter from './routes/housekeeping.js';
const log = createLogger('Server'); const log = createLogger('Server');
const logSync = createLogger('Sync'); const logSync = createLogger('Sync');
@@ -175,6 +176,41 @@ app.get('/api/v1/version', (req, res) => {
res.json({ version: APP_VERSION, app_name: appName }); 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) { function sendOpenApi(req, res) {
if (req.query.download === '1') { if (req.query.download === '1') {
res.setHeader('Content-Disposition', 'attachment; filename="openapi.json"'); 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/search', searchRouter);
app.use('/api/v1/family', familyRouter); app.use('/api/v1/family', familyRouter);
app.use('/api/v1/backup', backupRouter); app.use('/api/v1/backup', backupRouter);
app.use('/api/v1/housekeeping', housekeepingRouter);
// -------------------------------------------------------- // --------------------------------------------------------
// Health-Check (für Docker) // Health-Check (für Docker)
+41 -3
View File
@@ -22,6 +22,7 @@ const router = express.Router();
const VALID_SOURCES = ['local', 'google', 'apple', 'ics']; const VALID_SOURCES = ['local', 'google', 'apple', 'ics'];
const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024; const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024;
const DEFAULT_ATTACHMENT_FOLDER = 'Calendar items';
const ATTACHMENT_MIME = new Set([ const ATTACHMENT_MIME = new Set([
'image/png', 'image/png',
'image/jpeg', 'image/jpeg',
@@ -87,6 +88,36 @@ function parseAttachment(dataUrl) {
return { name: null, mime, size: buffer.length, data: base64 }; 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) { function attachmentDataUrl(event) {
if (!event?.attachment_data) return event?.attachment_data ?? null; if (!event?.attachment_data) return event?.attachment_data ?? null;
if (String(event.attachment_data).startsWith('data:')) return event.attachment_data; 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 attachment = req.body.attachment_data ? parseAttachment(req.body.attachment_data) : { mime: null, size: null, data: null };
const eventId = db.get().transaction(() => { const eventId = db.get().transaction(() => {
const documentId = createAttachmentDocument(db.get(), attachment, req.body, userId);
const result = db.get().prepare(` const result = db.get().prepare(`
INSERT INTO calendar_events INSERT INTO calendar_events
(title, description, start_datetime, end_datetime, all_day, (title, description, start_datetime, end_datetime, all_day,
location, color, icon, assigned_to, created_by, recurrence_rule, location, color, icon, assigned_to, created_by, recurrence_rule,
attachment_name, attachment_mime, attachment_size, attachment_data) attachment_name, attachment_mime, attachment_size, attachment_data, attachment_document_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
vTitle.value, vDesc.value, vTitle.value, vDesc.value,
vStart.value, vEnd.value, vStart.value, vEnd.value,
@@ -676,7 +708,8 @@ router.post('/', (req, res) => {
req.body.attachment_name || null, req.body.attachment_name || null,
attachment.mime, attachment.mime,
attachment.size, attachment.size,
attachment.data attachment.data,
documentId
); );
setEventAssignments(db.get(), result.lastInsertRowid, userIds); setEventAssignments(db.get(), result.lastInsertRowid, userIds);
return result.lastInsertRowid; return result.lastInsertRowid;
@@ -747,6 +780,9 @@ router.put('/:id', (req, res) => {
const userModified = event.external_source !== 'local' ? 1 : event.user_modified; const userModified = event.external_source !== 'local' ? 1 : event.user_modified;
db.get().transaction(() => { 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(` db.get().prepare(`
UPDATE calendar_events UPDATE calendar_events
SET title = COALESCE(?, title), SET title = COALESCE(?, title),
@@ -763,6 +799,7 @@ router.put('/:id', (req, res) => {
attachment_mime = ?, attachment_mime = ?,
attachment_size = ?, attachment_size = ?,
attachment_data = ?, attachment_data = ?,
attachment_document_id = ?,
user_modified = ? user_modified = ?
WHERE id = ? WHERE id = ?
`).run( `).run(
@@ -780,6 +817,7 @@ router.put('/:id', (req, res) => {
attachment.mime, attachment.mime,
attachment.size, attachment.size,
attachment.data, attachment.data,
documentId,
userModified, userModified,
id id
); );
+68 -9
View File
@@ -7,7 +7,7 @@
import express from 'express'; import express from 'express';
import * as db from '../db.js'; import * as db from '../db.js';
import { createLogger } from '../logger.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 log = createLogger('Documents');
const router = express.Router(); const router = express.Router();
@@ -70,10 +70,12 @@ function documentSelect() {
return ` return `
SELECT d.id, d.name, d.description, d.category, d.status, d.visibility, 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.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, u.display_name AS creator_name, u.avatar_color AS creator_color,
GROUP_CONCAT(a.user_id) AS allowed_member_ids GROUP_CONCAT(a.user_id) AS allowed_member_ids
FROM family_documents d 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 users u ON u.id = d.created_by
LEFT JOIN family_document_access a ON a.document_id = d.id 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) { 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(` return db.get().prepare(`
SELECT ${columns} SELECT ${columns}
FROM family_documents d FROM family_documents d
@@ -105,6 +107,15 @@ function replaceAccess(documentId, memberIds) {
for (const memberId of memberIds) insert.run(documentId, memberId); 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) => { router.get('/meta/options', (_req, res) => {
res.json({ res.json({
data: { 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) => { router.get('/', (req, res) => {
try { try {
const status = STATUSES.includes(req.query.status) ? req.query.status : 'active'; const status = STATUSES.includes(req.query.status) ? req.query.status : 'active';
const category = CATEGORIES.includes(req.query.category) ? req.query.category : null; 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(` const rows = db.get().prepare(`
${documentSelect()} ${documentSelect()}
WHERE ${canSeeSql('d')} WHERE ${canSeeSql('d')}
AND d.status = @status AND d.status = @status
AND (@category IS NULL OR d.category = @category) AND (@category IS NULL OR d.category = @category)
AND (@folderId IS NULL OR d.folder_id = @folderId)
GROUP BY d.id GROUP BY d.id
ORDER BY d.updated_at DESC ORDER BY d.updated_at DESC
`).all(params); `).all(params);
@@ -143,21 +190,27 @@ router.post('/', (req, res) => {
const vName = str(req.body.name, 'Name', { max: MAX_TITLE }); const vName = str(req.body.name, 'Name', { max: MAX_TITLE });
const vDescription = str(req.body.description, 'Description', { max: MAX_TEXT, required: false }); 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 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 }); 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 category = CATEGORIES.includes(req.body.category) ? req.body.category : 'other';
const visibility = VISIBILITIES.includes(req.body.visibility) ? req.body.visibility : 'family'; 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); const parsed = parseDataUrl(req.body.content_data);
if (parsed.error) return res.status(400).json({ error: parsed.error, code: 400 }); if (parsed.error) return res.status(400).json({ error: parsed.error, code: 400 });
const allowedIds = visibility === 'restricted' ? parseMemberIds(req.body.allowed_member_ids) : []; const allowedIds = visibility === 'restricted' ? parseMemberIds(req.body.allowed_member_ids) : [];
const folderId = vFolderId.value ?? ensureFolder(vFolderName.value, userId(req));
const database = db.get(); const database = db.get();
const result = database.prepare(` const result = database.prepare(`
INSERT INTO family_documents INSERT INTO family_documents
(name, description, category, visibility, original_name, mime_type, file_size, content_data, created_by) (name, description, category, visibility, folder_id, original_name, mime_type, file_size, content_data, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(vName.value, vDescription.value, category, visibility, vOriginalName.value, parsed.mime, parsed.size, parsed.base64, userId(req)); `).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); if (visibility === 'restricted') replaceAccess(result.lastInsertRowid, allowedIds);
const row = database.prepare(` 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 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 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 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(` db.get().prepare(`
UPDATE family_documents UPDATE family_documents
SET name = COALESCE(?, name), SET name = COALESCE(?, name),
description = ?, description = ?,
category = COALESCE(?, category), category = COALESCE(?, category),
visibility = COALESCE(?, visibility), visibility = COALESCE(?, visibility),
status = COALESCE(?, status) status = COALESCE(?, status),
folder_id = ?
WHERE id = ? WHERE id = ?
`).run( `).run(
req.body.name !== undefined ? vName.value : null, req.body.name !== undefined ? vName.value : null,
@@ -201,6 +259,7 @@ router.put('/:id', (req, res) => {
category, category,
visibility, visibility,
status, status,
req.body.folder_id !== undefined ? vFolderId.value : existing.folder_id,
id id
); );
if ((visibility || existing.visibility) === 'restricted') replaceAccess(id, parseMemberIds(req.body.allowed_member_ids)); if ((visibility || existing.visibility) === 'restricted') replaceAccess(id, parseMemberIds(req.body.allowed_member_ids));
+3
View File
@@ -26,6 +26,9 @@ router.get('/members', (req, res) => {
FROM users u FROM users u
LEFT JOIN contacts c ON c.family_user_id = u.id LEFT JOIN contacts c ON c.family_user_id = u.id
LEFT JOIN birthdays b ON b.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 ORDER BY u.display_name COLLATE NOCASE ASC
`).all(); `).all();
res.json({ data: members }); res.json({ data: members });
+956
View File
@@ -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;
+11 -1
View File
@@ -144,6 +144,7 @@ router.get('/', (req, res) => {
app_name: appName, app_name: appName,
dashboard_widgets: dashboardWidgets, dashboard_widgets: dashboardWidgets,
disabled_modules: disabledModules, disabled_modules: disabledModules,
housekeeping_payment_tasks: cfgGet('housekeeping_payment_tasks') === '1',
}, },
}); });
} catch (err) { } catch (err) {
@@ -161,7 +162,7 @@ router.get('/', (req, res) => {
router.put('/', (req, res) => { router.put('/', (req, res) => {
try { 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 (visible_meal_types !== undefined) {
if (!Array.isArray(visible_meal_types)) { if (!Array.isArray(visible_meal_types)) {
@@ -220,6 +221,13 @@ router.put('/', (req, res) => {
cfgSet('disabled_modules', JSON.stringify(unique)); 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 rawMealTypes = cfgGet('visible_meal_types') ?? DEFAULT_MEAL_TYPES;
const savedMealTypes = rawMealTypes.split(',').filter((t) => VALID_MEAL_TYPES.includes(t)); const savedMealTypes = rawMealTypes.split(',').filter((t) => VALID_MEAL_TYPES.includes(t));
const savedCurrency = cfgGet('currency') ?? DEFAULT_CURRENCY; const savedCurrency = cfgGet('currency') ?? DEFAULT_CURRENCY;
@@ -228,6 +236,7 @@ router.put('/', (req, res) => {
const savedAppName = cfgGet('app_name') ?? DEFAULT_APP_NAME; const savedAppName = cfgGet('app_name') ?? DEFAULT_APP_NAME;
const savedWidgets = parseWidgetConfig(cfgGet('dashboard_widgets')); const savedWidgets = parseWidgetConfig(cfgGet('dashboard_widgets'));
const savedDisabledModules = parseDisabledModules(cfgGet('disabled_modules')); const savedDisabledModules = parseDisabledModules(cfgGet('disabled_modules'));
const savedHousekeepingPaymentTasks = cfgGet('housekeeping_payment_tasks') === '1';
res.json({ res.json({
data: { data: {
@@ -238,6 +247,7 @@ router.put('/', (req, res) => {
app_name: savedAppName, app_name: savedAppName,
dashboard_widgets: savedWidgets, dashboard_widgets: savedWidgets,
disabled_modules: savedDisabledModules, disabled_modules: savedDisabledModules,
housekeeping_payment_tasks: savedHousekeepingPaymentTasks,
}, },
}); });
} catch (err) { } catch (err) {
+16
View File
@@ -53,6 +53,19 @@ function setAssignments(d, taskId, userIds) {
for (const uid of userIds) ins.run(taskId, uid); 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). */ /** Alle Subtasks einer Aufgabe laden (eine Ebene tief). */
function loadSubtasks(taskId) { function loadSubtasks(taskId) {
return db.get().prepare(` return db.get().prepare(`
@@ -274,6 +287,7 @@ router.put('/:id', (req, res) => {
status, due_date, due_time, firstUid, status, due_date, due_time, firstUid,
is_recurring ? 1 : 0, recurrence_rule, req.params.id); is_recurring ? 1 : 0, recurrence_rule, req.params.id);
setAssignments(db.get(), task.id, userIds); setAssignments(db.get(), task.id, userIds);
syncHousekeepingPaymentStatus(db.get(), req.params.id, status);
})(); })();
const updated = db.get().prepare(` const updated = db.get().prepare(`
@@ -310,6 +324,8 @@ router.patch('/:id/status', (req, res) => {
if (result.changes === 0) if (result.changes === 0)
return res.status(404).json({ error: 'Task not found.', code: 404 }); 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 // Wiederkehrende Aufgabe: nächste Instanz erstellen wenn erledigt
if (status === 'done') { if (status === 'done') {
const task = db.get().prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id); const task = db.get().prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id);
+1 -2
View File
@@ -57,8 +57,7 @@ function getOffsetMinutes(birthday) {
function birthdayReminderAt(birthDate, offsetMin = 0, from = new Date()) { function birthdayReminderAt(birthDate, offsetMin = 0, from = new Date()) {
const next = nextBirthdayDate(birthDate, from); const next = nextBirthdayDate(birthDate, from);
const baseTime = new Date(`${next}T12:00:00Z`).getTime(); return `${next}T12:00:00Z`;
return new Date(baseTime - (offsetMin || 0) * 60000).toISOString();
} }
function eventTitle(name) { function eventTitle(name) {