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:
@@ -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
@@ -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'],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user