diff --git a/docs/DEVELOPMENT_SESSION_2025-12-12_Appointment_Details_Modal.md b/docs/DEVELOPMENT_SESSION_2025-12-12_Appointment_Details_Modal.md new file mode 100644 index 0000000..b0d15fd --- /dev/null +++ b/docs/DEVELOPMENT_SESSION_2025-12-12_Appointment_Details_Modal.md @@ -0,0 +1,264 @@ +# Development Session - 2025-12-12 + +## Session Overview + +**Started**: 18:30 +**Ended**: 22:10 +**Branch**: `fix/automatic-groups-detection` +**Focus**: Appointment Details Modal Enhancement & Tag Filtering + +## Major Accomplishments + +### Phase 1: Bulk Operations Widget (18:30-19:00) + +- **Goal**: Add selection functionality to AdminTable and create reusable bulk operations widget +- **Result**: Complete bulk operations system implemented +- **Code Changes**: + - Created `BulkActionsWidget.vue` - Reusable component for bulk operations + - Added `useBulkAppointmentActions.ts` - Composable for appointment extension + - Enhanced `AdminTable.vue` with checkbox selection support + - Integrated bulk widget into `ExpiringAppointmentsAdmin.vue` + +**Key Features**: + +- Checkbox selection in AdminTable (selectable prop) +- Selection count display in floating widget +- Bulk appointment extension (3-24 months) +- Toast notifications for operation results + +### Phase 2: Appointment Details Modal Overhaul (19:00-21:00) + +- **Goal**: Display all appointment information in structured, readable format +- **Result**: Complete modal redesign with full API integration +- **Code Changes**: + - Added `fetchAppointmentSeries()` API function + - Restructured modal with card-based layout + - Added all missing fields (subtitle, address, link, calendar color) + - Fixed data structure issues (image, description, additionals, exceptions) + +**Key Improvements**: + +- **Card-based Layout**: Separate cards for details, series info, tags, description, image +- **Two-column Grid**: Responsive layout for appointment details +- **Series Information**: Proper display using `repeatId` (DAILY=1, WEEKLY=7, etc.) +- **Collapsible Exceptions**: Show 10, expand to show all (for appointments with many exceptions) +- **Image Display**: Fixed using `imageUrl` from Image object +- **Description**: Fixed using correct `description` field (not deprecated `note`) +- **Additional Dates**: Fixed structure (array of objects with `date` field) +- **Calendar Color**: Visual indicator with colored dot + +### Phase 3: Tag Filtering & Dropdown Issues (21:00-22:10) + +- **Goal**: Fix tag filtering to work client-side without API reloads +- **Result**: Tag filtering works locally, dropdown positioning fixed +- **Code Changes**: + - Modified `useExpiringAppointments` to load all appointments once + - Removed tag parameters from API query + - Fixed `applyFilters()` to work on cached data + - Improved dropdown positioning in `TagMultiSelect.vue` + - Fixed watch initialization order + +**Key Fixes**: + +- Tag filtering now client-side only (no API reload) +- Dropdown appears directly under widget +- Dropdown follows widget on scroll (instead of closing) +- Fixed "Cannot access before initialization" error +- Added debug logging for filter operations + +## Technical Decisions + +### Decision: Bulk Operations Widget Pattern (18:45) + +**Context**: Need reusable pattern for bulk operations across admin panels +**Decision**: Create generic `BulkActionsWidget` with slot-based actions +**Impact**: Can be reused in all admin panels with custom actions + +**Pattern**: + +```vue + + + + + +``` + +### Decision: API Integration for Modal (19:30) + +**Context**: Modal showed incomplete data from table preview +**Decision**: Use `GET /calendars/appointments/{id}/{startDate}` for full data +**Impact**: Modal shows complete appointment series with all details + +**Response Structure**: + +```json +{ + "data": { + "appointment": { + "base": { + /* all series data */ + }, + "calculated": { + /* computed data */ + } + } + } +} +``` + +### Decision: Client-Side Tag Filtering (21:30) + +**Context**: Tag changes triggered API reloads, causing flicker +**Decision**: Load all appointments once, filter locally +**Impact**: Instant filtering, no network requests, better UX + +**Implementation**: + +- Load 9999 days of appointments once +- Apply tag filter in `applyFilters()` function +- Watch for tag changes, re-filter locally + +### Decision: Dropdown Positioning Strategy (22:00) + +**Context**: Dropdown appeared above widget or disconnected +**Decision**: Update position on scroll instead of closing +**Impact**: Better UX, dropdown stays visible and follows widget + +## Challenges & Solutions + +### Challenge 1: Set Reactivity in AdminTable + +**Problem**: Checkbox state not updating when using `Set.add()` and `Set.delete()` +**Solution**: Create new Set instance on each change for Vue reactivity +**Code**: + +```javascript +// Before (not reactive) +selectedItems.value.add(itemId) + +// After (reactive) +const newSet = new Set(selectedItems.value) +newSet.add(itemId) +selectedItems.value = newSet +``` + +### Challenge 2: Appointment Data Structure + +**Problem**: Multiple issues with nested data (image, description, additionals) +**Solution**: Understand API response structure and access correct fields +**Fixes**: + +- Image: `appointment.base.image.imageUrl` (not `image` directly) +- Description: `appointment.base.description` (not deprecated `note`) +- Additionals: Array of `{id, date}` objects (not date strings) +- Series ID: `appointment.base.id` for row-key + +### Challenge 3: Watch Initialization Order + +**Problem**: "Cannot access 'applyFilters' before initialization" error +**Solution**: Remove `{ immediate: true }` from watch, call in `onMounted()` instead +**Reason**: Watch with immediate runs before function declaration + +### Challenge 4: Tag Filtering API Reloads + +**Problem**: Changing tags triggered API requests, causing data reload +**Solution**: Load all data once, filter client-side +**Implementation**: + +- Remove tag parameter from `useExpiringAppointments` +- Filter in `applyFilters()` based on `selectedTagIds` +- Watch only triggers local filtering + +## Performance Improvements + +1. **Reduced API Calls**: Tag filtering no longer triggers API requests +2. **Cached Data**: TanStack Query caches appointments for 30 minutes +3. **Local Filtering**: Instant response to filter changes +4. **Optimized Rendering**: Card-based layout with conditional rendering + +## Code Quality + +- Added comprehensive debug logging for troubleshooting +- Consistent error handling with try-catch blocks +- TypeScript interfaces for type safety +- Reusable components (BulkActionsWidget, TagMultiSelect) +- Performance logging for API calls + +## Testing Notes + +- Tested with "Mädchenjungschar" (29 exceptions, proper display) +- Tested with "Hauskreis Schaak" (21 additional dates, correct structure) +- Tag filtering tested with multiple tags (187, 190) +- Bulk extension tested with various month selections +- Dropdown positioning tested with scroll and resize + +## Next Steps + +- [ ] Consider removing debug logs for production +- [ ] Add bulk operations to other admin panels (Tags, Automatic Groups) +- [ ] Add more bulk actions (delete, duplicate, etc.) +- [ ] Improve exception display for very large lists (pagination?) +- [ ] Add keyboard shortcuts for bulk operations + +## Lessons Learned + +### 1. Vue Reactivity with Collections + +**Problem**: Mutating Sets/Maps doesn't trigger Vue reactivity +**Solution**: Always create new instances when modifying collections +**Application**: Use this pattern for all Set/Map operations in Vue 3 + +### 2. API Response Structure Understanding + +**Problem**: Assumed flat structure, but API returns nested objects +**Solution**: Log full response, understand actual structure before coding +**Application**: Always inspect API responses before implementing UI + +### 3. Watch Timing Issues + +**Problem**: Watches with `immediate: true` can run before dependencies are ready +**Solution**: Use `onMounted()` for initial setup, watches for changes only +**Application**: Separate initialization logic from reactive updates + +### 4. Client-Side vs Server-Side Filtering + +**Problem**: Server-side filtering causes unnecessary API calls +**Solution**: Load all data once, filter client-side for better UX +**Application**: Use client-side filtering for small-medium datasets (<1000 items) + +### 5. Dropdown Positioning in Portals + +**Problem**: Fixed positioning requires careful calculation with scroll offsets +**Solution**: Use `getBoundingClientRect()` + `window.scrollX/Y` for accurate positioning +**Application**: Always account for scroll position when using fixed positioning + +## Files Modified + +- `src/components/common/BulkActionsWidget.vue` (new) +- `src/components/common/AdminTable.vue` (selection support) +- `src/components/common/TagMultiSelect.vue` (positioning fixes) +- `src/components/expiring-appointments/ExpiringAppointmentsAdmin.vue` (major refactor) +- `src/composables/useBulkAppointmentActions.ts` (new) +- `src/composables/useExpiringAppointments.ts` (simplified) +- `src/services/churchtools.ts` (added fetchAppointmentSeries) + +## Commits + +1. `c039afc` - feat: Add bulk operations widget with appointment extension +2. `f3333f6` - feat: Improve appointment details modal with full API integration +3. `b55cf03` - fix: Improve tag filtering and dropdown positioning + +## Session Statistics + +- **Duration**: ~3.5 hours +- **Files Changed**: 7 files +- **Lines Added**: ~850 +- **Lines Removed**: ~600 +- **Commits**: 3 +- **Issues Resolved**: 5 major issues diff --git a/package-lock.json b/package-lock.json index 78291ef..178f8f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "churchtools-dashboard", - "version": "1.0.9", + "version": "1.0.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "churchtools-dashboard", - "version": "1.0.9", + "version": "1.0.10", "dependencies": { "@fortawesome/fontawesome-svg-core": "^7.0.1", "@fortawesome/free-solid-svg-icons": "^7.0.1", @@ -15,6 +15,7 @@ "@tanstack/query-sync-storage-persister": "^5.90.2", "@tanstack/vue-query": "^5.90.2", "@vitejs/plugin-vue": "^6.0.1", + "@vueuse/core": "^14.1.0", "vue": "^3.5.21", "vue-i18n": "^9.14.5" }, @@ -1012,6 +1013,12 @@ "undici-types": "~7.10.0" } }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-vue": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.1.tgz", @@ -1134,6 +1141,44 @@ "integrity": "sha512-+2k1EQpnYuVuu3N7atWyG3/xoFWIVJZq4Mz8XNOdScFI0etES75fbny/oU4lKWk/577P1zmg0ioYvpGEDZ3DLw==", "license": "MIT" }, + "node_modules/@vueuse/core": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.1.0.tgz", + "integrity": "sha512-rgBinKs07hAYyPF834mDTigH7BtPqvZ3Pryuzt1SD/lg5wEcWqvwzXXYGEDb2/cP0Sj5zSvHl3WkmMELr5kfWw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.1.0", + "@vueuse/shared": "14.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.1.0.tgz", + "integrity": "sha512-7hK4g015rWn2PhKcZ99NyT+ZD9sbwm7SGvp7k+k+rKGWnLjS/oQozoIZzWfCewSUeBmnJkIb+CNr7Zc/EyRnnA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.1.0.tgz", + "integrity": "sha512-EcKxtYvn6gx1F8z9J5/rsg3+lTQnvOruQd8fUecW99DCK04BkWD7z5KQ/wTAx+DazyoEE9dJt/zV8OIEQbM6kw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", diff --git a/package.json b/package.json index 93d62bd..dca6faa 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "churchtools-dashboard", "private": true, - "version": "1.0.9", + "version": "1.0.10", "type": "module", "scripts": { "dev": "vite", @@ -40,6 +40,7 @@ "@tanstack/query-sync-storage-persister": "^5.90.2", "@tanstack/vue-query": "^5.90.2", "@vitejs/plugin-vue": "^6.0.1", + "@vueuse/core": "^14.1.0", "vue": "^3.5.21", "vue-i18n": "^9.14.5" } diff --git a/src/components/automatic-groups/AutomaticGroupsAdmin.vue b/src/components/automatic-groups/AutomaticGroupsAdmin.vue index 5c7dc1a..9a34ec7 100644 --- a/src/components/automatic-groups/AutomaticGroupsAdmin.vue +++ b/src/components/automatic-groups/AutomaticGroupsAdmin.vue @@ -1,66 +1,80 @@ - - - - - Suche zurücksetzen - - - - {{ loading ? 'Lädt...' : 'Aktualisieren' }} - - - - - - {{ item.name }} - - - - - {{ getConfigStatusText(item.dynamicGroupStatus) }} - - - - - - {{ getExecutionStatusText(item.executionStatus) }} - - - - - {{ formatDate(item.lastExecution) }} - - - - - Öffnen - - - + + + + {{ selectedCount }} + {{ selectedCount === 1 ? 'Gruppe' : 'Gruppen' }} ausgewählt + + + + + + + Suche zurücksetzen + + + + {{ loading ? 'Lädt...' : 'Aktualisieren' }} + + + + + + {{ item.name }} + + + + + {{ getConfigStatusText(item.dynamicGroupStatus) }} + + + + + + {{ getExecutionStatusText(item.executionStatus) }} + + + + + {{ formatDate(item.lastExecution) }} + + + + + Öffnen + + + + @@ -437,7 +500,8 @@ defineExpose({ .table-container { width: 100%; max-width: 100%; - overflow: auto; + overflow-x: auto; + overflow-y: auto; border-radius: 8px; background: var(--ct-bg-primary, #ffffff); margin-bottom: 0; @@ -485,10 +549,19 @@ defineExpose({ .admin-data-table td { padding: 0.75rem 1rem; border-bottom: 1px solid var(--ct-border-color, #f0f2f5); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; vertical-align: middle; + word-wrap: break-word; + overflow-wrap: break-word; + white-space: normal; + max-width: 300px; /* Default max-width for all cells */ +} + +/* Specific style for description cells */ +.admin-data-table td.description-cell { + white-space: normal; + min-width: 200px; + max-width: 400px; + word-break: break-word; } .admin-data-table th.sortable { @@ -536,6 +609,20 @@ defineExpose({ background-color: var(--ct-bg-secondary, #f8f9fa); } +/* Checkbox column */ +.checkbox-column { + width: 40px; + text-align: center; + padding: 0.75rem 0.5rem; +} + +.ct-checkbox { + cursor: pointer; + width: 18px; + height: 18px; + accent-color: var(--ct-primary, #3498db); +} + /* Buttons */ .ct-btn { padding: 0.75rem 1.5rem; diff --git a/src/components/common/BulkActionsWidget.vue b/src/components/common/BulkActionsWidget.vue new file mode 100644 index 0000000..1e27557 --- /dev/null +++ b/src/components/common/BulkActionsWidget.vue @@ -0,0 +1,210 @@ + + + + + {{ selectedCount }} + {{ itemLabel }} + + + ✕ + + + + + + + Keine Aktionen verfügbar + + + + + + + + diff --git a/src/components/common/TagMultiSelect.vue b/src/components/common/TagMultiSelect.vue new file mode 100644 index 0000000..0170a53 --- /dev/null +++ b/src/components/common/TagMultiSelect.vue @@ -0,0 +1,592 @@ + + + + + Filter nach Tags + + + {{ tag.name }} + + × + + + + + ▼ + + + + + + + + + + + + + {{ tag.name }} + ✓ + + + + Keine passenden Tags gefunden + + + + + + Alle auswählen + + Keine auswählen + + + Auswahl zurücksetzen + + + + + + + + + + diff --git a/src/components/expiring-appointments/ExpiringAppointmentsAdmin.vue b/src/components/expiring-appointments/ExpiringAppointmentsAdmin.vue index ef2702c..c80c78a 100644 --- a/src/components/expiring-appointments/ExpiringAppointmentsAdmin.vue +++ b/src/components/expiring-appointments/ExpiringAppointmentsAdmin.vue @@ -1,21 +1,207 @@ + + + + + Verlängern um: + + 3 Monate + 6 Monate + 12 Monate + 18 Monate + 24 Monate + + + + + {{ isProcessing ? 'Verlängere...' : `${selectedCount} Termine verlängern` }} + + + + + + + + + {{ selectedAppointment.base?.title || 'Termin' }} + × + + + + + + Termindetails + + + Titel: + {{ selectedAppointment.base?.title || '-' }} + + + Untertitel: + {{ selectedAppointment.base.subtitle }} + + + Kalender: + + + {{ selectedAppointment.base?.calendar?.name || '-' }} + + + + Serienbeginn: + {{ formatDate(selectedAppointment.base?.startDate) }} + + + Serienende: + {{ getEffectiveEndDate(selectedAppointment) }} + + + Adresse: + {{ formatAddress(selectedAppointment.base.address) }} + + + Link: + + {{ selectedAppointment.base.link }} + + + + + + + + Serientermin + + + Serie: + + {{ getRepetitionText(selectedAppointment.base) }} + + + + + + + + + Ausnahmen ({{ selectedAppointment.base.exceptions.length }}): + + + {{ showAllExceptions ? 'Weniger anzeigen' : 'Alle anzeigen' }} + + + + + {{ formatDate(exception.date) }} + + + + + + + Weitere Termine: + + + {{ formatDate(additional.date) }} + + + + + + + + Tags + + + {{ tag.name }} + + + + + + + Beschreibung + + + + + + Bild + + + + + + + + @@ -25,13 +211,12 @@ alle 1 - 7 - 14 - 30 - 60 - 90 - 180 - 365 + 14 Tage + 60 Tage + 6 Monate + 12 Monate + 15 Monate + 18 Monate Tagen enden Termine anzeigen @@ -49,6 +234,14 @@ + + + + Alle Status @@ -77,7 +270,7 @@ - {{ item.id || item.base?.id || 'NO_ID' }} + {{ item.seriesId || item.base?.id || 'NO_ID' }} @@ -106,16 +299,38 @@ {{ getEffectiveEndDate(item) }} + + + + + + {{ tag.name }} + + + + - + + + - - Öffnen - + + + Details + + @@ -123,10 +338,20 @@ @@ -441,6 +1000,19 @@ onMounted(() => { .filter-container { min-width: 180px; + margin-bottom: 0.5rem; +} + +/* Style for multi-select dropdown */ +select[multiple] { + min-height: 38px; + padding: 0.25rem; +} + +select[multiple] option { + padding: 0.25rem 0.5rem; + margin: 0.125rem 0; + border-radius: 3px; } .filter-select { @@ -531,6 +1103,348 @@ onMounted(() => { background-color: rgba(52, 152, 219, 0.1); } +/* Modal Styles */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.7); + display: flex; + justify-content: center; + align-items: center; + z-index: 9999; + padding: 20px; + box-sizing: border-box; + overflow-y: auto; +} + +.modal-content { + background: white; + border-radius: 8px; + width: 100%; + max-width: 800px; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25); + color: #333; /* Set default text color */ +} + +.modal-header { + padding: 20px; + border-bottom: 1px solid #e5e7eb; + display: flex; + justify-content: space-between; + align-items: center; + background: #f9fafb; + border-top-left-radius: 8px; + border-top-right-radius: 8px; +} + +.modal-header h3 { + margin: 0; + color: #1f2937; + font-size: 1.25rem; +} + +.modal-body { + padding: 1.5rem; + background: #f9fafb; + max-height: 70vh; + overflow-y: auto; +} + +.appointment-details { + display: flex; + flex-direction: column; + gap: 1rem; +} + +/* Detail Cards */ +.detail-card { + background: white; + border-radius: 8px; + padding: 1.25rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.detail-card h4 { + margin: 0 0 1rem 0; + color: #1f2937; + font-size: 1rem; + font-weight: 600; + border-bottom: 2px solid #e5e7eb; + padding-bottom: 0.5rem; +} + +/* Two-column grid for details */ +.detail-grid-two-col { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; +} + +@media (max-width: 768px) { + .detail-grid-two-col { + grid-template-columns: 1fr; + } +} + +.detail-item { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.detail-label { + font-weight: 600; + color: #6b7280; + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.025em; +} + +.detail-item > span:not(.detail-label) { + color: #1f2937; + font-size: 0.95rem; +} + +/* List items */ +.detail-list { + margin-top: 1rem; +} + +.detail-list .detail-label { + display: block; + margin-bottom: 0.5rem; +} + +/* Date chips */ +.date-chips { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.date-chip { + display: inline-block; + padding: 0.375rem 0.75rem; + background: #f3f4f6; + border-radius: 6px; + font-size: 0.875rem; + color: #374151; + border: 1px solid #e5e7eb; +} + +/* Detail list header */ +.detail-list-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.toggle-btn { + padding: 0.25rem 0.75rem; + background: #f3f4f6; + border: 1px solid #d1d5db; + border-radius: 4px; + font-size: 0.875rem; + color: #374151; + cursor: pointer; + transition: all 0.2s; +} + +.toggle-btn:hover { + background: #e5e7eb; + border-color: #9ca3af; +} + +/* Calendar badge */ +.calendar-badge { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.calendar-color { + width: 16px; + height: 16px; + border-radius: 4px; + border: 1px solid rgba(0, 0, 0, 0.1); + flex-shrink: 0; +} + +/* Appointment link */ +.appointment-link { + color: var(--ct-primary, #3498db); + text-decoration: none; + word-break: break-all; +} + +.appointment-link:hover { + text-decoration: underline; +} + +/* Tags styling */ +.tags-container { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.tag-badge { + display: inline-flex; + align-items: center; + padding: 0.375rem 0.875rem; + background-color: var(--tag-bg) !important; + border-radius: 1rem; + font-size: 0.85rem; + font-weight: 500; + line-height: 1.5; + white-space: nowrap; +} + +/* Description styling */ +.description-content { + color: #374151; + font-size: 0.95rem; + line-height: 1.6; + white-space: pre-wrap; +} + +.description-content :deep(p) { + margin: 0 0 0.75rem 0; +} + +.description-content :deep(p:last-child) { + margin-bottom: 0; +} + +/* Image styling */ +.appointment-image { + width: 100%; + max-width: 600px; + height: auto; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.modal-footer { + padding: 16px 20px; + border-top: 1px solid #e5e7eb; + background: #f9fafb; + display: flex; + justify-content: flex-end; + gap: 12px; + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; +} + +/* Tag styles */ +.tags-container { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.25rem 0; +} + +.tag-badge { + display: inline-flex; + align-items: center; + justify-content: center; + background-color: var(--tag-bg) !important; + height: 24px; + padding: 0 10px; + border-radius: 3px; + font-size: 12px; + font-weight: 500; + line-height: 1; + white-space: nowrap; + background-color: var(--tag-bg, #e0e0e0); + color: var(--tag-text, #333); + border: 1px solid rgba(0, 0, 0, 0.1); + position: relative; + cursor: help; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.tag-badge:hover { + transform: translateY(-1px); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); +} + +.tag-badge[title]:hover::after { + content: attr(title); + position: absolute; + bottom: 100%; + left: 50%; + max-width: 300px; + width: max-content; + transform: translateX(-50%) translateY(-8px); + background-color: #2d3748; + color: #fff; + padding: 0.5rem 0.75rem; + border-radius: 4px; + font-size: 0.8rem; + line-height: 1.4; + white-space: normal; + z-index: 1000; + opacity: 0; + visibility: hidden; + transition: all 0.15s ease-out; + pointer-events: none; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); + word-wrap: break-word; + font-weight: 400; + text-align: left; +} + +.tag-badge[title]:hover::before { + content: ''; + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%) translateY(-3px); + border-width: 5px 5px 0 5px; + border-style: solid; + border-color: #2d3748 transparent transparent transparent; + z-index: 1001; + opacity: 0; + visibility: hidden; + transition: all 0.15s ease-out; +} + +.tag-badge[title]:hover::after, +.tag-badge[title]:hover::before { + opacity: 1; + visibility: visible; + transform: translateX(-50%) translateY(0); +} + +.data-row:hover { + background-color: rgba(0, 0, 0, 0.02); +} + +.data-row.row-selected { + background-color: rgba(52, 152, 219, 0.1); +} + +.data-row.row-selected:hover { + background-color: rgba(52, 152, 219, 0.15); +} + +.no-tags { + color: #9e9e9e; + font-style: italic; + font-size: 0.875rem; + padding: 0.5rem 0; + display: inline-block; +} + .ct-btn-sm { padding: 0.5rem 1rem; font-size: 0.85rem; @@ -569,4 +1483,33 @@ onMounted(() => { transform: rotate(360deg); } } + +/* Bulk actions widget customization */ +.extend-section { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.extend-section label { + font-size: 0.9rem; + font-weight: 500; + color: var(--ct-text-primary, #2c3e50); +} + +.ct-select { + padding: 0.5rem; + border: 1px solid var(--ct-border-color, #e0e0e0); + border-radius: 4px; + font-size: 0.9rem; + background: white; + cursor: pointer; +} + +.ct-select:focus { + outline: none; + border-color: var(--ct-primary, #3498db); + box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.1); +} diff --git a/src/components/tags/TagsAdmin.vue b/src/components/tags/TagsAdmin.vue index 8ab8323..2ce5f17 100644 --- a/src/components/tags/TagsAdmin.vue +++ b/src/components/tags/TagsAdmin.vue @@ -365,7 +365,14 @@ const tableColumns: TableColumn[] = [ width: 150, cellSlot: 'cell-color', }, - { key: 'description', label: 'Beschreibung', sortable: true, resizable: true, width: 250 }, + { + key: 'description', + label: 'Beschreibung', + sortable: true, + resizable: true, + width: 250, + cellClass: 'description-cell', + }, { key: 'actions', label: 'Aktionen', diff --git a/src/composables/useBulkAppointmentActions.ts b/src/composables/useBulkAppointmentActions.ts new file mode 100644 index 0000000..cf7dfd2 --- /dev/null +++ b/src/composables/useBulkAppointmentActions.ts @@ -0,0 +1,95 @@ +import { ref } from 'vue' +import { churchtoolsClient } from '@churchtools/churchtools-client' +import { useToast } from './useToast' + +export function useBulkAppointmentActions() { + const { showSuccess, showError, showWarning } = useToast() + const isProcessing = ref(false) + const processedCount = ref(0) + const errorCount = ref(0) + + /** + * Extend the repeat-until date for multiple appointments + */ + const extendAppointments = async ( + appointmentIds: number[], + extensionMonths: number + ): Promise<{ success: number; failed: number }> => { + isProcessing.value = true + processedCount.value = 0 + errorCount.value = 0 + + const results = { + success: 0, + failed: 0, + } + + for (const appointmentId of appointmentIds) { + try { + // Fetch current appointment data + const response: any = await churchtoolsClient.get(`/appointments/${appointmentId}`) + const appointment = response.appointment || response + + if (!appointment || !appointment.base) { + console.error(`Appointment ${appointmentId} not found or has no base data`) + results.failed++ + errorCount.value++ + continue + } + + const base = appointment.base + + // Calculate new repeat-until date + let newRepeatUntil: string + + if (base.repeatUntil) { + // Extend existing repeatUntil + const currentDate = new Date(base.repeatUntil) + currentDate.setMonth(currentDate.getMonth() + extensionMonths) + newRepeatUntil = currentDate.toISOString().split('T')[0] + } else { + // No repeatUntil set, extend from start date + const startDate = new Date(base.startDate) + startDate.setMonth(startDate.getMonth() + extensionMonths) + newRepeatUntil = startDate.toISOString().split('T')[0] + } + + // Update appointment + await churchtoolsClient.patch(`/appointments/${appointmentId}`, { + repeatUntil: newRepeatUntil, + }) + + results.success++ + processedCount.value++ + } catch (error) { + console.error(`Failed to extend appointment ${appointmentId}:`, error) + results.failed++ + errorCount.value++ + } + } + + isProcessing.value = false + + // Show result toast + if (results.success > 0 && results.failed === 0) { + showSuccess( + `${results.success} ${results.success === 1 ? 'Termin' : 'Termine'} erfolgreich verlängert` + ) + } else if (results.success > 0 && results.failed > 0) { + showWarning( + `${results.success} ${results.success === 1 ? 'Termin' : 'Termine'} verlängert, ${results.failed} fehlgeschlagen` + ) + } else { + showError(`Fehler beim Verlängern der Termine`) + } + + return results + } + + return { + isProcessing, + processedCount, + errorCount, + extendAppointments, + } +} diff --git a/src/composables/useExpiringAppointments.ts b/src/composables/useExpiringAppointments.ts index f694cb8..201b34c 100644 --- a/src/composables/useExpiringAppointments.ts +++ b/src/composables/useExpiringAppointments.ts @@ -6,9 +6,11 @@ export function useExpiringAppointments(daysInAdvance: number = 300000) { if (import.meta.env.DEV) { console.log('📅 Setting up expiring appointments query for', daysInAdvance, 'days') } + + // Load ALL appointments without tag filtering - filtering happens client-side return useQuery({ queryKey: ['expiring-appointments', daysInAdvance], - queryFn: () => findExpiringSeries(daysInAdvance), + queryFn: () => findExpiringSeries(daysInAdvance, []), staleTime: 30 * 60 * 1000, // 30 minutes - appointments don't change often gcTime: 60 * 60 * 1000, // 1 hour cache time refetchInterval: 15 * 60 * 1000, // Background update every 15 minutes diff --git a/src/services/churchtools.ts b/src/services/churchtools.ts index c760df4..5526c87 100644 --- a/src/services/churchtools.ts +++ b/src/services/churchtools.ts @@ -30,22 +30,44 @@ export async function fetchCalendars(): Promise { return response || [] } +/** + * Fetches a single appointment series with all details + */ +export async function fetchAppointmentSeries( + appointmentId: number, + startDate: string +): Promise { + const response = await churchtoolsClient.get( + `/calendars/appointments/${appointmentId}/${startDate}` + ) + return response +} + /** * Fetches appointments for a specific calendar within a date range */ export async function fetchAppointments( calendarIds: number[], startDate: Date, - endDate: Date + endDate: Date, + tagIds: number[] = [] ): Promise { const start = startDate.toISOString().split('T')[0] const end = endDate.toISOString().split('T')[0] - const response = await churchtoolsClient.get('/calendars/appointments', { + const params: Record = { from: start, to: end, 'calendar_ids[]': calendarIds, - }) + include: ['tags'], + } + + // Add tag filter if tags are provided + if (tagIds.length > 0) { + params['tag_ids[]'] = tagIds + } + + const response = await churchtoolsClient.get('/calendars/appointments', params) // Ensure we always return an array of Appointment objects return Array.isArray(response) ? response : [] @@ -69,7 +91,10 @@ export async function identifyCalendars(): Promise<{ /** * Finds all recurring appointment series that are about to end */ -export async function findExpiringSeries(daysInAdvance: number = 60): Promise { +export async function findExpiringSeries( + daysInAdvance: number = 60, + tagIds: number[] = [] +): Promise { const now = new Date() const endDate = new Date() endDate.setDate(now.getDate() + daysInAdvance) @@ -88,14 +113,42 @@ export async function findExpiringSeries(daysInAdvance: number = 60): Promise a.indexOf(v) === i ) // Remove duplicates - // Fetching appointments for calendar IDs - // Fetch appointments - const appointments = await fetchAppointments(allCalendarIds, now, endDate) + // Fetching appointments for calendar IDs with optional tag filtering and include tags + const appointments = await fetchAppointments(allCalendarIds, now, endDate, tagIds) + + // Log the first appointment to debug tags + if (appointments.length > 0) { + console.log('First appointment with tags:', JSON.parse(JSON.stringify(appointments[0]))) + } + + // Process appointments to ensure consistent structure with tags + const processedAppointments = appointments.map((appointment) => { + // Get tags from the root level if they exist + const tags = 'tags' in appointment ? appointment.tags : [] + + // Return the appointment with tags properly set in base.tags + // Handle both AppointmentBase and AppointmentCalculated + if ('base' in appointment) { + return { + ...appointment, + base: { + ...(appointment as any).base, + tags: Array.isArray(tags) ? tags : [], + }, + } + } else { + // For AppointmentBase, add tags directly + return { + ...appointment, + tags: Array.isArray(tags) ? tags : [], + } + } + }) // Find recurring appointments that are ending soon - const expiringSeries = appointments.filter((appointment) => { + const expiringSeries = processedAppointments.filter((appointment) => { // Handle both AppointmentBase and AppointmentCalculated types - const base = 'base' in appointment ? appointment.base : appointment + const base = 'base' in appointment ? (appointment as any).base : appointment // Only consider recurring appointments (must have repeatId) if (!base.repeatId) return false @@ -108,9 +161,9 @@ export async function findExpiringSeries(daysInAdvance: number = 60): Promise 0) { // Find the latest date in additionals const latestAdditional = base.additionals - .map((additional) => new Date(additional.date || additional.date)) - .filter((date) => !isNaN(date.getTime())) - .sort((a, b) => b.getTime() - a.getTime())[0] + .map((additional: any) => new Date(additional.date || additional.startDate)) + .filter((date: Date) => !isNaN(date.getTime())) + .sort((a: Date, b: Date) => b.getTime() - a.getTime())[0] if (latestAdditional) { effectiveEndDate = latestAdditional
Keine Aktionen verfügbar