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:
@@ -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`
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": "Aujourd’hui",
|
||||||
|
"ok": "OK",
|
||||||
|
"noTasks": "Aucune tâche de ménage.",
|
||||||
|
"everyDays": "Tous les {{days}} jours",
|
||||||
|
"completeTask": "Terminer {{name}}",
|
||||||
|
"taskDoneToast": "Tâche terminée.",
|
||||||
|
"reportTitle": "Signaler un problème",
|
||||||
|
"problemDescription": "Description du problème",
|
||||||
|
"problemPlaceholder": "Exemple : ampoule grillée",
|
||||||
|
"addPhoto": "Ajouter une photo",
|
||||||
|
"sendReport": "Envoyer",
|
||||||
|
"reportSentToast": "Problème signalé.",
|
||||||
|
"recentReports": "Signalements récents",
|
||||||
|
"addTask": "Ajouter une tâche",
|
||||||
|
"taskName": "Tâche",
|
||||||
|
"taskNamePlaceholder": "Exemple : nettoyer les salles de bain",
|
||||||
|
"taskArea": "Zone",
|
||||||
|
"taskAreaPlaceholder": "Exemple : salle de bain",
|
||||||
|
"taskFrequency": "Fréquence",
|
||||||
|
"createTask": "Créer la tâche",
|
||||||
|
"taskCreatedToast": "Tâche de ménage créée.",
|
||||||
|
"dashboard": "Tableau",
|
||||||
|
"reports": "Reports",
|
||||||
|
"visitsThisMonth": "Visites du mois",
|
||||||
|
"lastVisit": "Dernière visite",
|
||||||
|
"pendingChores": "Tâches en attente",
|
||||||
|
"finishedChores": "Tâches terminées",
|
||||||
|
"payments": "Paiements",
|
||||||
|
"pendingPayments": "Paiements en attente",
|
||||||
|
"monthlyPayments": "Paiements mensuels",
|
||||||
|
"noPaymentData": "Aucune donnée de paiement.",
|
||||||
|
"noVisits": "Aucune visite",
|
||||||
|
"noWorkerTitle": "Aucun profil de ménage",
|
||||||
|
"noWorkerHint": "Créez le profil pour définir les contacts, le tarif et le rythme de paiement.",
|
||||||
|
"taskTemplates": "Tâches suggérées",
|
||||||
|
"addCustomTask": "Ajouter une tâche personnalisée",
|
||||||
|
"noReports": "Aucun signalement.",
|
||||||
|
"profileTitle": "Profil ménage",
|
||||||
|
"profilePicture": "Photo de profil",
|
||||||
|
"workerName": "Nom",
|
||||||
|
"workerUsername": "Identifiant",
|
||||||
|
"workerPhone": "Téléphone",
|
||||||
|
"workerEmail": "E-mail",
|
||||||
|
"workerBirthDate": "Anniversaire",
|
||||||
|
"paymentSchedule": "Rythme de paiement",
|
||||||
|
"scheduleDaily": "À chaque visite",
|
||||||
|
"scheduleTwiceMonthly": "Deux fois par mois",
|
||||||
|
"scheduleMonthly": "Mensuel",
|
||||||
|
"profileColor": "Couleur du profil",
|
||||||
|
"workerNotes": "Notes",
|
||||||
|
"workerSavedToast": "Profil enregistré.",
|
||||||
|
"staff": "Équipe",
|
||||||
|
"staffTitle": "Équipe de ménage",
|
||||||
|
"addWorker": "Ajouter une personne",
|
||||||
|
"editWorker": "Modifier la personne",
|
||||||
|
"noWorkers": "Aucune personne de ménage enregistrée.",
|
||||||
|
"moreWorkers": "+{{count}} de plus",
|
||||||
|
"checkInDisabled": "Ajoutez une personne avant de pointer.",
|
||||||
|
"calendarColor": "Couleur du calendrier",
|
||||||
|
"visitRecordedAt": "Visit recorded at",
|
||||||
|
"checkedInToday": "Recorded today",
|
||||||
|
"visitReports": "Staff visit reports",
|
||||||
|
"noVisitReports": "No staff visits recorded this month.",
|
||||||
|
"openVisitReport": "Open visit report",
|
||||||
|
"visitReportDetails": "Visit report",
|
||||||
|
"paymentPaid": "Paid",
|
||||||
|
"paymentPending": "Pending",
|
||||||
|
"totalPayment": "Total payment",
|
||||||
|
"paymentStatus": "Payment status",
|
||||||
|
"paymentTask": "Payment task",
|
||||||
|
"calendarEvent": "Calendar event",
|
||||||
|
"notAvailable": "Not available",
|
||||||
|
"calendarVisitTitle": "Housekeeping: {{name}}",
|
||||||
|
"paymentTaskTitle": "Pay {{name}} for housekeeping",
|
||||||
|
"paymentTaskDescription": "Housekeeping visit on {{date}}. Amount due: {{amount}}.",
|
||||||
|
"staffLogTitle": "{{name}} visits",
|
||||||
|
"staffLogHint": "Edit visit dates, amounts, and linked records.",
|
||||||
|
"filterMonth": "Month",
|
||||||
|
"editVisit": "Edit visit",
|
||||||
|
"deleteVisit": "Delete visit",
|
||||||
|
"deleteVisitConfirm": "Delete this visit? The linked calendar event and payment task will also be removed.",
|
||||||
|
"visitDeletedToast": "Visit deleted.",
|
||||||
|
"visitSavedToast": "Visit updated.",
|
||||||
|
"visitDate": "Visit date",
|
||||||
|
"markPaid": "Mark paid",
|
||||||
|
"visitPaidToast": "Payment marked as paid.",
|
||||||
|
"receiptUploadTitle": "Upload payment receipt",
|
||||||
|
"receiptUploadHint": "Attach a payment receipt. It will appear in Documents.",
|
||||||
|
"receiptDocumentName": "Receipt - {{name}} - {{date}}",
|
||||||
|
"receiptDocumentDescription": "Payment receipt for {{name}} housekeeping visit on {{date}}.",
|
||||||
|
"taskTemplateData": {
|
||||||
|
"cleanBathrooms": {
|
||||||
|
"name": "Nettoyer les salles de bain",
|
||||||
|
"area": "Salles de bain"
|
||||||
|
},
|
||||||
|
"mopKitchenFloor": {
|
||||||
|
"name": "Laver le sol de la cuisine",
|
||||||
|
"area": "Cuisine"
|
||||||
|
},
|
||||||
|
"dustLivingRoom": {
|
||||||
|
"name": "Dépoussiérer le salon",
|
||||||
|
"area": "Salon"
|
||||||
|
},
|
||||||
|
"changeBedLinens": {
|
||||||
|
"name": "Changer les draps",
|
||||||
|
"area": "Chambres"
|
||||||
|
},
|
||||||
|
"cleanRefrigerator": {
|
||||||
|
"name": "Nettoyer le réfrigérateur",
|
||||||
|
"area": "Cuisine"
|
||||||
|
},
|
||||||
|
"cleanWindows": {
|
||||||
|
"name": "Nettoyer les fenêtres",
|
||||||
|
"area": "Toute la maison"
|
||||||
|
},
|
||||||
|
"deepCleanOven": {
|
||||||
|
"name": "Nettoyage approfondi du four",
|
||||||
|
"area": "Cuisine"
|
||||||
|
},
|
||||||
|
"washOutdoor": {
|
||||||
|
"name": "Laver balcon/patio",
|
||||||
|
"area": "Extérieur"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"userMultiSelect": {
|
||||||
|
"moreUsers": "weitere",
|
||||||
|
"nobody": "- Niemand -"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+249
-6
@@ -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
@@ -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 l’evento 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 quest’area oppure usa il selettore.",
|
"dropzoneHint": "Trascina un file in quest’area oppure usa il selettore.",
|
||||||
"selectedFileLabel": "Selezionato: {{name}}"
|
"selectedFileLabel": "Selezionato: {{name}}",
|
||||||
|
"addFolderButton": "Add folder",
|
||||||
|
"allFolders": "All folders",
|
||||||
|
"folderLabel": "Folder",
|
||||||
|
"noFolder": "No folder",
|
||||||
|
"newFolderTitle": "New folder",
|
||||||
|
"folderNameLabel": "Folder name",
|
||||||
|
"createFolderAction": "Create folder",
|
||||||
|
"folderCreatedToast": "Folder created.",
|
||||||
|
"housekeepingFolder": "Pulizie",
|
||||||
|
"calendarItemsFolder": "Elementi del calendario",
|
||||||
|
"folderBrowserTitle": "Sfoglia cartelle"
|
||||||
},
|
},
|
||||||
"shortcuts": {
|
"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 dell’entrata.",
|
||||||
|
"calendarColor": "Colore calendario",
|
||||||
|
"visitRecordedAt": "Visit recorded at",
|
||||||
|
"checkedInToday": "Recorded today",
|
||||||
|
"visitReports": "Staff visit reports",
|
||||||
|
"noVisitReports": "No staff visits recorded this month.",
|
||||||
|
"openVisitReport": "Open visit report",
|
||||||
|
"visitReportDetails": "Visit report",
|
||||||
|
"paymentPaid": "Paid",
|
||||||
|
"paymentPending": "Pending",
|
||||||
|
"totalPayment": "Total payment",
|
||||||
|
"paymentStatus": "Payment status",
|
||||||
|
"paymentTask": "Payment task",
|
||||||
|
"calendarEvent": "Calendar event",
|
||||||
|
"notAvailable": "Not available",
|
||||||
|
"calendarVisitTitle": "Housekeeping: {{name}}",
|
||||||
|
"paymentTaskTitle": "Pay {{name}} for housekeeping",
|
||||||
|
"paymentTaskDescription": "Housekeeping visit on {{date}}. Amount due: {{amount}}.",
|
||||||
|
"staffLogTitle": "{{name}} visits",
|
||||||
|
"staffLogHint": "Edit visit dates, amounts, and linked records.",
|
||||||
|
"filterMonth": "Month",
|
||||||
|
"editVisit": "Edit visit",
|
||||||
|
"deleteVisit": "Delete visit",
|
||||||
|
"deleteVisitConfirm": "Delete this visit? The linked calendar event and payment task will also be removed.",
|
||||||
|
"visitDeletedToast": "Visit deleted.",
|
||||||
|
"visitSavedToast": "Visit updated.",
|
||||||
|
"visitDate": "Visit date",
|
||||||
|
"markPaid": "Mark paid",
|
||||||
|
"visitPaidToast": "Payment marked as paid.",
|
||||||
|
"receiptUploadTitle": "Upload payment receipt",
|
||||||
|
"receiptUploadHint": "Attach a payment receipt. It will appear in Documents.",
|
||||||
|
"receiptDocumentName": "Receipt - {{name}} - {{date}}",
|
||||||
|
"receiptDocumentDescription": "Payment receipt for {{name}} housekeeping visit on {{date}}.",
|
||||||
|
"taskTemplateData": {
|
||||||
|
"cleanBathrooms": {
|
||||||
|
"name": "Pulire i bagni",
|
||||||
|
"area": "Bagni"
|
||||||
|
},
|
||||||
|
"mopKitchenFloor": {
|
||||||
|
"name": "Lavare il pavimento della cucina",
|
||||||
|
"area": "Cucina"
|
||||||
|
},
|
||||||
|
"dustLivingRoom": {
|
||||||
|
"name": "Spolverare il soggiorno",
|
||||||
|
"area": "Soggiorno"
|
||||||
|
},
|
||||||
|
"changeBedLinens": {
|
||||||
|
"name": "Cambiare la biancheria da letto",
|
||||||
|
"area": "Camere"
|
||||||
|
},
|
||||||
|
"cleanRefrigerator": {
|
||||||
|
"name": "Pulire il frigorifero",
|
||||||
|
"area": "Cucina"
|
||||||
|
},
|
||||||
|
"cleanWindows": {
|
||||||
|
"name": "Pulire le finestre",
|
||||||
|
"area": "Tutta la casa"
|
||||||
|
},
|
||||||
|
"deepCleanOven": {
|
||||||
|
"name": "Pulizia profonda del forno",
|
||||||
|
"area": "Cucina"
|
||||||
|
},
|
||||||
|
"washOutdoor": {
|
||||||
|
"name": "Lavare balcone/patio",
|
||||||
|
"area": "Esterno"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"userMultiSelect": {
|
||||||
|
"moreUsers": "weitere",
|
||||||
|
"nobody": "- Niemand -"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+249
-6
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 -"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+109
-96
@@ -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;
|
iconTrigger?.setAttribute('aria-expanded', 'false');
|
||||||
selectIcon(btn.dataset.icon);
|
iconTrigger?.focus();
|
||||||
iconGrid.hidden = true;
|
});
|
||||||
iconTrigger?.setAttribute('aria-expanded', 'false');
|
|
||||||
iconTrigger?.focus();
|
|
||||||
});
|
|
||||||
|
|
||||||
const iconSearch = iconGrid?.querySelector('#modal-icon-search');
|
|
||||||
iconSearch?.addEventListener('input', () => {
|
|
||||||
const q = iconSearch.value.trim().toLowerCase();
|
|
||||||
const resultsEl = iconGrid?.querySelector('#modal-icon-results');
|
|
||||||
if (!resultsEl) return;
|
|
||||||
if (!q) {
|
|
||||||
resultsEl.replaceChildren();
|
|
||||||
resultsEl.insertAdjacentHTML('afterbegin', EVENT_ICON_CATEGORIES().map((cat) => `
|
|
||||||
<div class="event-icon-picker__category">
|
|
||||||
<div class="event-icon-picker__category-label">${esc(cat.label)}</div>
|
|
||||||
<div class="event-icon-picker__category-icons">
|
|
||||||
${cat.icons.map((icon) => `
|
|
||||||
<button type="button" class="event-icon-picker__option ${iconInput?.value === icon.value ? 'event-icon-picker__option--active' : ''}"
|
|
||||||
data-icon="${icon.value}" role="radio"
|
|
||||||
aria-checked="${iconInput?.value === icon.value ? 'true' : 'false'}"
|
|
||||||
aria-label="${esc(icon.label)}" title="${esc(icon.label)}">
|
|
||||||
${eventIconHtml(icon.value, 'event-icon-picker__option-icon')}
|
|
||||||
</button>`).join('')}
|
|
||||||
</div>
|
|
||||||
</div>`).join(''));
|
|
||||||
if (window.lucide) lucide.createIcons({ el: resultsEl });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const allIcons = EVENT_ICON_CATEGORIES().flatMap((c) => c.icons);
|
|
||||||
const filtered = allIcons.filter((i) => i.label.toLowerCase().includes(q) || i.value.includes(q));
|
|
||||||
resultsEl.replaceChildren();
|
|
||||||
if (filtered.length === 0) {
|
|
||||||
resultsEl.insertAdjacentHTML('afterbegin', `<div class="event-icon-picker__no-results">${esc(t('calendar.iconSearchEmpty'))}</div>`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resultsEl.insertAdjacentHTML('afterbegin', `
|
|
||||||
<div class="event-icon-picker__category-icons">
|
|
||||||
${filtered.map((icon) => `
|
|
||||||
<button type="button" class="event-icon-picker__option ${iconInput?.value === icon.value ? 'event-icon-picker__option--active' : ''}"
|
|
||||||
data-icon="${icon.value}" role="radio"
|
|
||||||
aria-checked="${iconInput?.value === icon.value ? 'true' : 'false'}"
|
|
||||||
aria-label="${esc(icon.label)}" title="${esc(icon.label)}">
|
|
||||||
${eventIconHtml(icon.value, 'event-icon-picker__option-icon')}
|
|
||||||
</button>`).join('')}
|
|
||||||
</div>`);
|
|
||||||
if (window.lucide) lucide.createIcons({ el: resultsEl });
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('click', function closeIconPicker(e) {
|
|
||||||
if (!panel.isConnected) {
|
|
||||||
document.removeEventListener('click', closeIconPicker);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (iconGrid?.hidden || iconGrid?.contains(e.target) || iconTrigger?.contains(e.target)) return;
|
|
||||||
iconGrid.hidden = true;
|
|
||||||
iconTrigger?.setAttribute('aria-expanded', 'false');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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') },
|
||||||
];
|
];
|
||||||
|
|||||||
+162
-8
@@ -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 class="documents-browser-layout">
|
||||||
|
<aside class="documents-folder-browser" aria-label="${t('documents.folderBrowserTitle')}">
|
||||||
|
<div class="documents-folder-browser__title">${t('documents.folderBrowserTitle')}</div>
|
||||||
|
<div class="documents-folder-browser__list" id="documents-folder-browser"></div>
|
||||||
|
</aside>
|
||||||
|
<div id="documents-list" class="documents-list documents-list--${state.view}"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="documents-list" class="documents-list documents-list--${state.view}"></div>
|
|
||||||
<button class="page-fab" id="fab-new-document" aria-label="${t('documents.addButton')}">
|
<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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 },
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user