feat: add calendar sync button with CalDAV backend integration

Adds a "Kalender-Synchronisation" section to Settings that lets users
trigger a full sync of member birthdays and Juleica reminders to the
Nextcloud CalDAV backend. The calendar is created automatically if it
doesn't exist. Users can also check/enable the Calendar UI app.

- CalendarSyncService: add CalDavBackend integration (ensureCalendarExists,
  fullSyncCalDav, getCalendarStatus, enableCalendarApp)
- CalendarController: new REST endpoints (status, sync, enable-app)
- calendar.js Pinia store: frontend state management
- Settings.vue: calendar sync UI with status display and sync button
- CLAUDE.md: add workflow rule for commit+push after each feature

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
shahondin1624
2026-04-11 16:18:29 +02:00
parent c51439179f
commit ea8451710f
6 changed files with 634 additions and 11 deletions
+4
View File
@@ -70,6 +70,10 @@ This is a common problem. Nextcloud aggressively caches JS assets. Escalation pa
Then run `make redeploy` followed by `occ upgrade` inside the container Then run `make redeploy` followed by `occ upgrade` inside the container
4. **Full rebuild** — nuclear option when nothing else works: `make clean && make deploy`. This wipes all Docker volumes (DB included), rebuilds JS, starts fresh containers, reinstalls Nextcloud, and re-enables the app from scratch. You lose all data but guarantee a clean state. 4. **Full rebuild** — nuclear option when nothing else works: `make clean && make deploy`. This wipes all Docker volumes (DB included), rebuilds JS, starts fresh containers, reinstalls Nextcloud, and re-enables the app from scratch. You lose all data but guarantee a clean state.
## Workflow
- Each completed feature, bugfix, or enhancement should be committed and pushed immediately after completion.
## Gitea ## Gitea
- Repo: `shahondin1624/Mitgliederverwaltung` on `git.shahondin1624.de` - Repo: `shahondin1624/Mitgliederverwaltung` on `git.shahondin1624.de`
+7 -1
View File
@@ -172,11 +172,17 @@ return [
['name' => 'backup#checkUpdate', 'url' => '/api/v1/update/check', 'verb' => 'GET'], ['name' => 'backup#checkUpdate', 'url' => '/api/v1/update/check', 'verb' => 'GET'],
['name' => 'backup#installUpdate', 'url' => '/api/v1/update/install', 'verb' => 'POST'], ['name' => 'backup#installUpdate', 'url' => '/api/v1/update/install', 'verb' => 'POST'],
// ── Calendar sync ──────────────────────────────────────────────
['name' => 'calendar#status', 'url' => '/api/v1/calendar/status', 'verb' => 'GET'],
['name' => 'calendar#sync', 'url' => '/api/v1/calendar/sync', 'verb' => 'POST'],
['name' => 'calendar#enableApp', 'url' => '/api/v1/calendar/enable-app', 'verb' => 'POST'],
// ── Files integration (NC Files browser) ──────────────────── // ── Files integration (NC Files browser) ────────────────────
['name' => 'file#getSettings', 'url' => '/api/v1/files/settings', 'verb' => 'GET'], ['name' => 'file#getSettings', 'url' => '/api/v1/files/settings', 'verb' => 'GET'],
['name' => 'file#updateSettings', 'url' => '/api/v1/files/settings', 'verb' => 'PUT'], ['name' => 'file#updateSettings', 'url' => '/api/v1/files/settings', 'verb' => 'PUT'],
['name' => 'file#memberFiles', 'url' => '/api/v1/members/{memberId}/files', 'verb' => 'GET'], ['name' => 'file#memberFiles', 'url' => '/api/v1/members/{memberId}/files', 'verb' => 'GET'],
['name' => 'file#ensureFolder', 'url' => '/api/v1/members/{memberId}/files/ensure-folder', 'verb' => 'POST'], ['name' => 'file#ensureFolder', 'url' => '/api/v1/members/{memberId}/files/ensure-folder', 'verb' => 'POST'],
['name' => 'file#lagerFiles', 'url' => '/api/v1/lager/{lagerId}/files/browse', 'verb' => 'GET'], ['name' => 'file#lagerFiles', 'url' => '/api/v1/lager/{lagerId}/files/browse', 'verb' => 'GET'],
['name' => 'file#ensureLagerFolder', 'url' => '/api/v1/lager/{lagerId}/files/ensure-folder', 'verb' => 'POST'],
], ],
]; ];
+100
View File
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Controller;
use OCA\Mitgliederverwaltung\Service\CalendarSyncService;
use OCP\AppFramework\ApiController;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse;
use OCP\IRequest;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;
/**
* REST API controller for calendar sync operations.
*
* Provides endpoints to check calendar status, trigger a full sync
* of member birthdays and Juleica reminders to the Nextcloud CalDAV
* backend, and enable the Calendar UI app.
*/
class CalendarController extends ApiController {
use ApiControllerTrait;
private CalendarSyncService $calendarSyncService;
private IUserSession $userSession;
private LoggerInterface $logger;
public function __construct(
string $appName,
IRequest $request,
CalendarSyncService $calendarSyncService,
IUserSession $userSession,
LoggerInterface $logger
) {
parent::__construct($appName, $request);
$this->calendarSyncService = $calendarSyncService;
$this->userSession = $userSession;
$this->logger = $logger;
}
/**
* Get calendar sync status.
*
* GET /api/v1/calendar/status
*
* @return JSONResponse Status information about the calendar setup
*/
public function status(): JSONResponse {
return $this->handleAction(function () {
$userId = $this->userSession->getUser()->getUID();
$status = $this->calendarSyncService->getCalendarStatus($userId);
return new JSONResponse($status);
}, 'Calendar status');
}
/**
* Trigger a full calendar sync to the CalDAV backend.
*
* POST /api/v1/calendar/sync
*
* Creates the calendar if it doesn't exist, then syncs all member
* birthdays and Juleica reminders as real CalDAV events.
*
* @return JSONResponse Sync statistics (created, updated, deleted, juleica)
*/
public function sync(): JSONResponse {
return $this->handleAction(function () {
$userId = $this->userSession->getUser()->getUID();
$stats = $this->calendarSyncService->fullSyncCalDav($userId);
return new JSONResponse($stats);
}, 'Calendar sync');
}
/**
* Enable the Nextcloud Calendar UI app.
*
* POST /api/v1/calendar/enable-app
*
* @return JSONResponse Result of the enable operation
*/
public function enableApp(): JSONResponse {
return $this->handleAction(function () {
if ($this->calendarSyncService->isCalendarAppEnabled()) {
return new JSONResponse(['enabled' => true, 'message' => 'Calendar-App ist bereits aktiviert']);
}
$success = $this->calendarSyncService->enableCalendarApp();
if ($success) {
return new JSONResponse(['enabled' => true, 'message' => 'Calendar-App wurde aktiviert']);
}
return new JSONResponse(
['enabled' => false, 'error' => 'Calendar-App konnte nicht aktiviert werden. Bitte installieren Sie die App ueber den Nextcloud App Store.'],
Http::STATUS_UNPROCESSABLE_ENTITY
);
}, 'Enable calendar app');
}
}
+218 -9
View File
@@ -4,8 +4,10 @@ declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Service; namespace OCA\Mitgliederverwaltung\Service;
use OCA\DAV\CalDAV\CalDavBackend;
use OCA\Mitgliederverwaltung\Db\Member; use OCA\Mitgliederverwaltung\Db\Member;
use OCA\Mitgliederverwaltung\Db\MemberMapper; use OCA\Mitgliederverwaltung\Db\MemberMapper;
use OCP\App\IAppManager;
use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection; use OCP\IDBConnection;
use OCP\IUserSession; use OCP\IUserSession;
@@ -18,9 +20,9 @@ use Sabre\VObject\Component\VCalendar;
* Creates/manages the "Pfadfinder Geburtstage" calendar with recurring yearly * Creates/manages the "Pfadfinder Geburtstage" calendar with recurring yearly
* all-day events for each active member (excluding Erziehungsberechtigte). * all-day events for each active member (excluding Erziehungsberechtigte).
* *
* Note: Uses a custom staging table (mv_calendar_events) because * Uses CalDavBackend for direct CalDAV writes (user-triggered sync) and
* OCP\Calendar\IManager does not provide write methods. All sync * a staging table (mv_calendar_events) for background job processing.
* operations are guarded by PermissionService to enforce Nextcloud * All sync operations are guarded by PermissionService to enforce Nextcloud
* access control. See Issue #170. * access control. See Issue #170.
* *
* Part of Issue #50. * Part of Issue #50.
@@ -35,19 +37,25 @@ class CalendarSyncService {
private PermissionService $permissionService; private PermissionService $permissionService;
private IUserSession $userSession; private IUserSession $userSession;
private LoggerInterface $logger; private LoggerInterface $logger;
private CalDavBackend $caldavBackend;
private IAppManager $appManager;
public function __construct( public function __construct(
MemberMapper $memberMapper, MemberMapper $memberMapper,
IDBConnection $db, IDBConnection $db,
PermissionService $permissionService, PermissionService $permissionService,
IUserSession $userSession, IUserSession $userSession,
LoggerInterface $logger LoggerInterface $logger,
CalDavBackend $caldavBackend,
IAppManager $appManager
) { ) {
$this->memberMapper = $memberMapper; $this->memberMapper = $memberMapper;
$this->db = $db; $this->db = $db;
$this->permissionService = $permissionService; $this->permissionService = $permissionService;
$this->userSession = $userSession; $this->userSession = $userSession;
$this->logger = $logger; $this->logger = $logger;
$this->caldavBackend = $caldavBackend;
$this->appManager = $appManager;
} }
/** /**
@@ -82,8 +90,213 @@ class CalendarSyncService {
} }
} }
// ── CalDAV backend operations ───────────────────────────────────
/** /**
* Sync a single member's birthday event. * Get the status of calendar sync for a user.
*
* @return array{calendarAppEnabled: bool, calendarExists: bool, calendarId: ?int, eventCount: int}
*/
public function getCalendarStatus(string $userId): array {
$calendarAppEnabled = $this->appManager->isEnabledForUser('calendar');
$calendarId = $this->findCalendarId($userId);
$eventCount = 0;
if ($calendarId !== null) {
$objects = $this->caldavBackend->getCalendarObjects($calendarId);
$eventCount = count($objects);
}
// Count members that should have events
$members = $this->memberMapper->findAll();
$eligibleCount = 0;
foreach ($members as $member) {
if ($this->shouldHaveEvent($member)) {
$eligibleCount++;
}
}
return [
'calendarAppEnabled' => $calendarAppEnabled,
'calendarExists' => $calendarId !== null,
'calendarId' => $calendarId,
'eventCount' => $eventCount,
'eligibleMembers' => $eligibleCount,
];
}
/**
* Check if the Calendar UI app is enabled.
*/
public function isCalendarAppEnabled(): bool {
return $this->appManager->isEnabledForUser('calendar');
}
/**
* Enable the Calendar UI app.
*
* @return bool true if successfully enabled, false if the app is not installed
*/
public function enableCalendarApp(): bool {
try {
$this->appManager->enableApp('calendar');
return true;
} catch (\Exception $e) {
$this->logger->warning('Failed to enable calendar app', [
'exception' => $e,
'app' => 'mitgliederverwaltung',
]);
return false;
}
}
/**
* Find the calendar ID for our dedicated calendar.
*/
private function findCalendarId(string $userId): ?int {
$principalUri = 'principals/users/' . $userId;
$calendars = $this->caldavBackend->getCalendarsForUser($principalUri);
foreach ($calendars as $calendar) {
if ($calendar['uri'] === self::CALENDAR_URI) {
return (int)$calendar['id'];
}
}
return null;
}
/**
* Ensure the dedicated calendar exists, creating it if needed.
*
* @return int The calendar ID
*/
public function ensureCalendarExists(string $userId): int {
$calendarId = $this->findCalendarId($userId);
if ($calendarId !== null) {
return $calendarId;
}
$principalUri = 'principals/users/' . $userId;
$calendarId = $this->caldavBackend->createCalendar($principalUri, self::CALENDAR_URI, [
'{DAV:}displayname' => self::CALENDAR_NAME,
'{urn:ietf:params:xml:ns:caldav}calendar-description' =>
'Automatisch verwaltete Geburtstage und Erinnerungen der Pfadfinder-Mitglieder',
'{http://apple.com/ns/ical/}calendar-color' => '#4CAF50FF',
]);
$this->logger->info('Created calendar for user', [
'userId' => $userId,
'calendarId' => $calendarId,
'app' => 'mitgliederverwaltung',
]);
return (int)$calendarId;
}
/**
* Full sync to the real CalDAV backend for a specific user.
*
* Creates the calendar if it doesn't exist, then creates/updates/deletes
* all birthday events and Juleica reminders.
*
* @return array{created: int, updated: int, deleted: int, juleica: int}
*/
public function fullSyncCalDav(string $userId): array {
$this->assertWritePermission();
$stats = ['created' => 0, 'updated' => 0, 'deleted' => 0, 'juleica' => 0];
$calendarId = $this->ensureCalendarExists($userId);
$members = $this->memberMapper->findAll();
// Track valid event URIs to clean up orphans later
$validUris = [];
foreach ($members as $member) {
$shouldHaveEvent = $this->shouldHaveEvent($member);
$eventUri = $this->generateEventUri($member);
$hasEvent = $this->caldavObjectExists($calendarId, $eventUri);
if ($shouldHaveEvent && !$hasEvent) {
$vcalendar = $this->buildVEvent($member);
$this->caldavBackend->createCalendarObject($calendarId, $eventUri, $vcalendar);
$this->updateMemberCalendarUri($member->getId(), $eventUri);
$this->storeCalendarEvent($eventUri, $vcalendar);
$stats['created']++;
$validUris[] = $eventUri;
} elseif ($shouldHaveEvent && $hasEvent) {
$vcalendar = $this->buildVEvent($member);
$this->caldavBackend->updateCalendarObject($calendarId, $eventUri, $vcalendar);
$this->storeCalendarEvent($eventUri, $vcalendar);
$stats['updated']++;
$validUris[] = $eventUri;
} elseif (!$shouldHaveEvent && $hasEvent) {
$this->caldavBackend->deleteCalendarObject($calendarId, $eventUri);
$this->updateMemberCalendarUri($member->getId(), null);
$this->removeCalendarEvent($eventUri);
$stats['deleted']++;
}
// Juleica reminder
$hasJuleica = $member->getJuleicaAblaufdatum() !== null
&& $member->getJuleicaAblaufdatum() !== ''
&& $member->getDeletedAt() === null;
$juleicaUri = $this->generateJuleicaEventUri($member);
$juleicaExists = $this->caldavObjectExists($calendarId, $juleicaUri);
if ($hasJuleica) {
$juleicaData = $this->buildJuleicaVEvent($member);
if ($juleicaExists) {
$this->caldavBackend->updateCalendarObject($calendarId, $juleicaUri, $juleicaData);
} else {
$this->caldavBackend->createCalendarObject($calendarId, $juleicaUri, $juleicaData);
}
$this->storeCalendarEvent($juleicaUri, $juleicaData);
$stats['juleica']++;
$validUris[] = $juleicaUri;
} elseif ($juleicaExists) {
$this->caldavBackend->deleteCalendarObject($calendarId, $juleicaUri);
$this->removeCalendarEvent($juleicaUri);
}
}
// Clean up orphaned events (events with mv- prefix that don't match any member)
$allCalObjects = $this->caldavBackend->getCalendarObjects($calendarId);
foreach ($allCalObjects as $obj) {
$uri = $obj['uri'];
if (str_starts_with($uri, 'mv-') && !in_array($uri, $validUris, true)) {
$this->caldavBackend->deleteCalendarObject($calendarId, $uri);
$this->removeCalendarEvent($uri);
$stats['deleted']++;
}
}
$this->logger->info('Calendar CalDAV full sync completed', [
'userId' => $userId,
'created' => $stats['created'],
'updated' => $stats['updated'],
'deleted' => $stats['deleted'],
'juleica' => $stats['juleica'],
'app' => 'mitgliederverwaltung',
]);
return $stats;
}
/**
* Check if a CalDAV object exists in the calendar.
*/
private function caldavObjectExists(int $calendarId, string $objectUri): bool {
$obj = $this->caldavBackend->getCalendarObject($calendarId, $objectUri);
return $obj !== null;
}
// ── Staging table operations (for background jobs) ──────────────
/**
* Sync a single member's birthday event (staging table).
* *
* Creates, updates, or deletes the calendar event based on member state: * Creates, updates, or deletes the calendar event based on member state:
* - Active member with role != Erziehungsberechtigter => create/update event * - Active member with role != Erziehungsberechtigter => create/update event
@@ -296,8 +509,6 @@ class CalendarSyncService {
*/ */
private function buildVEvent(Member $member): string { private function buildVEvent(Member $member): string {
$birthYear = substr($member->getGeburtsdatum(), 0, 4); $birthYear = substr($member->getGeburtsdatum(), 0, 4);
$birthMonth = substr($member->getGeburtsdatum(), 5, 2);
$birthDay = substr($member->getGeburtsdatum(), 8, 2);
$summary = sprintf( $summary = sprintf(
'Geburtstag: %s %s (*%s)', 'Geburtstag: %s %s (*%s)',
@@ -306,8 +517,6 @@ class CalendarSyncService {
$birthYear $birthYear
); );
$dtstart = $birthMonth . $birthDay;
$vcal = new VCalendar(); $vcal = new VCalendar();
$vevent = $vcal->add('VEVENT', [ $vevent = $vcal->add('VEVENT', [
'SUMMARY' => $summary, 'SUMMARY' => $summary,
+76
View File
@@ -0,0 +1,76 @@
/**
* Pinia store for calendar sync state management.
*/
import { defineStore } from 'pinia'
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
export const useCalendarStore = defineStore('calendar', {
state: () => ({
status: null,
syncing: false,
enablingApp: false,
error: null,
success: null,
}),
actions: {
async fetchStatus() {
this.error = null
try {
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/calendar/status')
const response = await axios.get(url)
this.status = response.data
} catch (err) {
this.error = err.response?.data?.error || 'Fehler beim Laden des Kalender-Status'
}
},
async triggerSync() {
this.syncing = true
this.error = null
this.success = null
try {
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/calendar/sync')
const response = await axios.post(url)
const stats = response.data
const parts = []
if (stats.created > 0) parts.push(`${stats.created} erstellt`)
if (stats.updated > 0) parts.push(`${stats.updated} aktualisiert`)
if (stats.deleted > 0) parts.push(`${stats.deleted} geloescht`)
if (stats.juleica > 0) parts.push(`${stats.juleica} Juleica-Erinnerungen`)
this.success = parts.length > 0
? `Synchronisation abgeschlossen: ${parts.join(', ')}`
: 'Synchronisation abgeschlossen — keine Aenderungen'
await this.fetchStatus()
} catch (err) {
this.error = err.response?.data?.error || 'Fehler bei der Kalender-Synchronisation'
} finally {
this.syncing = false
}
},
async enableCalendarApp() {
this.enablingApp = true
this.error = null
try {
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/calendar/enable-app')
const response = await axios.post(url)
this.success = response.data.message
await this.fetchStatus()
} catch (err) {
this.error = err.response?.data?.error || 'Fehler beim Aktivieren der Calendar-App'
} finally {
this.enablingApp = false
}
},
clearError() {
this.error = null
},
clearSuccess() {
this.success = null
},
},
})
+229 -1
View File
@@ -108,6 +108,119 @@
</div> </div>
</div> </div>
<!-- File Integration Section -->
<div class="settings__section">
<h3>Datei-Integration</h3>
<p class="settings__description">
Konfigurieren Sie, wo Mitglieder- und Lager-Ordner in Nextcloud Files angelegt werden.
</p>
<div class="settings__file-fields">
<div class="settings__file-field">
<label>Basispfad Mitglieder</label>
<NcTextField :model-value="filesStore.settings.basePath"
placeholder="/Verein/Mitglieder/"
@update:model-value="updateFileSetting('basePath', $event)" />
</div>
<div class="settings__file-field">
<label>Ordner-Muster</label>
<NcTextField :model-value="filesStore.settings.subfolderPattern"
placeholder="{Nachname}_{Vorname}"
@update:model-value="updateFileSetting('subfolderPattern', $event)" />
<span class="settings__file-hint">Platzhalter: {Vorname}, {Nachname}, {Id}</span>
</div>
<div class="settings__file-field">
<label>Basispfad Lager</label>
<NcTextField :model-value="filesStore.settings.lagerBasePath"
placeholder="/Verein/Lager/"
@update:model-value="updateFileSetting('lagerBasePath', $event)" />
</div>
<div class="settings__file-field">
<label class="settings__toggle-label">
<input type="checkbox"
:checked="filesStore.settings.autoCreate"
@change="updateFileSetting('autoCreate', $event.target.checked)">
Ordner automatisch erstellen
</label>
</div>
</div>
<div v-if="fileSaveSuccess" class="settings__file-success">
Einstellungen gespeichert
</div>
</div>
<!-- Calendar Sync Section -->
<div class="settings__section">
<h3>Kalender-Synchronisation</h3>
<p class="settings__description">
Synchronisieren Sie Geburtstage und Juleica-Erinnerungen in den Nextcloud-Kalender.
</p>
<!-- Error -->
<div v-if="calendarStore.error" class="settings__error">
{{ calendarStore.error }}
<NcButton @click="calendarStore.clearError()">Schliessen</NcButton>
</div>
<!-- Success -->
<div v-if="calendarStore.success" class="settings__calendar-success">
{{ calendarStore.success }}
<NcButton @click="calendarStore.clearSuccess()">Schliessen</NcButton>
</div>
<!-- Status -->
<div v-if="calendarStore.status" class="settings__calendar-status">
<div class="settings__calendar-status-row">
<span class="settings__calendar-label">Calendar-App:</span>
<span v-if="calendarStore.status.calendarAppEnabled"
class="settings__calendar-badge settings__calendar-badge--ok">
Aktiviert
</span>
<span v-else class="settings__calendar-badge settings__calendar-badge--warn">
Nicht aktiviert
</span>
<NcButton v-if="!calendarStore.status.calendarAppEnabled"
:disabled="calendarStore.enablingApp"
@click="calendarStore.enableCalendarApp()">
<NcLoadingIcon v-if="calendarStore.enablingApp" :size="16" />
<template v-else>Aktivieren</template>
</NcButton>
</div>
<div class="settings__calendar-status-row">
<span class="settings__calendar-label">Kalender:</span>
<span v-if="calendarStore.status.calendarExists"
class="settings__calendar-badge settings__calendar-badge--ok">
Vorhanden ({{ calendarStore.status.eventCount }} Eintraege)
</span>
<span v-else class="settings__calendar-badge settings__calendar-badge--info">
Wird bei erster Synchronisation erstellt
</span>
</div>
<div class="settings__calendar-status-row">
<span class="settings__calendar-label">Berechtigte Mitglieder:</span>
<span>{{ calendarStore.status.eligibleMembers }}</span>
</div>
</div>
<NcLoadingIcon v-else-if="!calendarStore.error" :size="24" class="settings__loading" />
<!-- Sync button -->
<div class="settings__calendar-actions">
<NcButton type="primary"
:disabled="calendarStore.syncing"
@click="calendarStore.triggerSync()">
<NcLoadingIcon v-if="calendarStore.syncing" :size="20" />
<template v-else>Jetzt synchronisieren</template>
</NcButton>
</div>
</div>
<!-- Fee Rules Section --> <!-- Fee Rules Section -->
<div class="settings__section"> <div class="settings__section">
<FeeRuleEditor /> <FeeRuleEditor />
@@ -121,19 +234,27 @@
</template> </template>
<script setup> <script setup>
import { onMounted } from 'vue' import { onMounted, ref } from 'vue'
import { NcButton, NcTextField, NcLoadingIcon } from '@nextcloud/vue' import { NcButton, NcTextField, NcLoadingIcon } from '@nextcloud/vue'
import { useStufenStore } from '../stores/stufen.js' import { useStufenStore } from '../stores/stufen.js'
import { useFilesStore } from '../stores/files.js'
import { useCalendarStore } from '../stores/calendar.js'
import PermissionSettings from '../components/PermissionSettings.vue' import PermissionSettings from '../components/PermissionSettings.vue'
import FeeRuleEditor from '../components/FeeRuleEditor.vue' import FeeRuleEditor from '../components/FeeRuleEditor.vue'
import Plus from 'vue-material-design-icons/Plus.vue' import Plus from 'vue-material-design-icons/Plus.vue'
const stufenStore = useStufenStore() const stufenStore = useStufenStore()
const filesStore = useFilesStore()
const calendarStore = useCalendarStore()
let saveTimeout = null let saveTimeout = null
let fileSaveTimeout = null
const fileSaveSuccess = ref(false)
onMounted(() => { onMounted(() => {
stufenStore.fetchStufen() stufenStore.fetchStufen()
filesStore.fetchSettings()
calendarStore.fetchStatus()
}) })
/** /**
@@ -204,6 +325,25 @@ async function confirmDeleteStufe(stufe) {
} }
} }
} }
/**
* Update a file setting with debounced save.
*/
function updateFileSetting(field, value) {
filesStore.settings[field] = value
fileSaveSuccess.value = false
clearTimeout(fileSaveTimeout)
fileSaveTimeout = setTimeout(async () => {
try {
await filesStore.updateSettings({ [field]: value })
fileSaveSuccess.value = true
setTimeout(() => { fileSaveSuccess.value = false }, 2000)
} catch {
// Error displayed by store
}
}, 500)
}
</script> </script>
<style scoped> <style scoped>
@@ -346,4 +486,92 @@ async function confirmDeleteStufe(stufe) {
.settings__add-stufe { .settings__add-stufe {
margin-top: 16px; margin-top: 16px;
} }
.settings__file-fields {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 500px;
}
.settings__file-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.settings__file-field > label {
font-size: 0.85em;
color: var(--color-text-lighter);
font-weight: 500;
}
.settings__file-hint {
font-size: 0.8em;
color: var(--color-text-lighter);
}
.settings__file-success {
margin-top: 8px;
color: var(--color-success);
font-size: 0.9em;
}
.settings__calendar-success {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: var(--color-success);
color: white;
border-radius: var(--border-radius);
margin-bottom: 12px;
}
.settings__calendar-status {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 16px;
background: var(--color-background-dark);
border-radius: var(--border-radius);
margin-bottom: 16px;
}
.settings__calendar-status-row {
display: flex;
align-items: center;
gap: 8px;
}
.settings__calendar-label {
font-weight: 500;
min-width: 180px;
}
.settings__calendar-badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--border-radius-pill);
font-size: 0.85em;
}
.settings__calendar-badge--ok {
background: var(--color-success);
color: white;
}
.settings__calendar-badge--warn {
background: var(--color-warning);
color: white;
}
.settings__calendar-badge--info {
background: var(--color-primary-element-light);
color: var(--color-primary-element-light-text);
}
.settings__calendar-actions {
margin-top: 8px;
}
</style> </style>