feat: add Juleica number and expiry date with calendar reminder (Closes #160) (#191)

This commit was merged in pull request #191.
This commit is contained in:
2026-04-10 19:43:06 +02:00
parent 4316c5f2b5
commit 27cedd849d
8 changed files with 256 additions and 2 deletions
+8
View File
@@ -54,6 +54,10 @@ use OCP\AppFramework\Db\Entity;
* @method void setDeletedAt(?string $deletedAt) * @method void setDeletedAt(?string $deletedAt)
* @method string|null getEinwilligungDatum() * @method string|null getEinwilligungDatum()
* @method void setEinwilligungDatum(?string $einwilligungDatum) * @method void setEinwilligungDatum(?string $einwilligungDatum)
* @method string|null getJuleicaNummer()
* @method void setJuleicaNummer(?string $juleicaNummer)
* @method string|null getJuleicaAblaufdatum()
* @method void setJuleicaAblaufdatum(?string $juleicaAblaufdatum)
*/ */
class Member extends Entity implements JsonSerializable { class Member extends Entity implements JsonSerializable {
@@ -79,6 +83,8 @@ class Member extends Entity implements JsonSerializable {
protected string $updatedAt = ''; protected string $updatedAt = '';
protected ?string $deletedAt = null; protected ?string $deletedAt = null;
protected ?string $einwilligungDatum = null; protected ?string $einwilligungDatum = null;
protected ?string $juleicaNummer = null;
protected ?string $juleicaAblaufdatum = null;
public function __construct() { public function __construct() {
$this->addType('id', 'integer'); $this->addType('id', 'integer');
@@ -111,6 +117,8 @@ class Member extends Entity implements JsonSerializable {
'updatedAt' => $this->updatedAt, 'updatedAt' => $this->updatedAt,
'deletedAt' => $this->deletedAt, 'deletedAt' => $this->deletedAt,
'einwilligungDatum' => $this->einwilligungDatum, 'einwilligungDatum' => $this->einwilligungDatum,
'juleicaNummer' => $this->juleicaNummer,
'juleicaAblaufdatum' => $this->juleicaAblaufdatum,
]; ];
} }
} }
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
/**
* Migration: Add Juleica fields to mv_members.
*
* Adds juleica_nummer (varchar, nullable) and juleica_ablaufdatum (date, nullable)
* for tracking youth leader card data.
*
* Part of Issue #160.
*/
class Version000016Date20260410000001 extends SimpleMigrationStep {
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
if ($schema->hasTable('mv_members')) {
$table = $schema->getTable('mv_members');
if (!$table->hasColumn('juleica_nummer')) {
$table->addColumn('juleica_nummer', Types::STRING, [
'notnull' => false,
'length' => 100,
'default' => null,
]);
}
if (!$table->hasColumn('juleica_ablaufdatum')) {
$table->addColumn('juleica_ablaufdatum', Types::DATE, [
'notnull' => false,
'default' => null,
]);
}
return $schema;
}
return null;
}
}
+101
View File
@@ -390,4 +390,105 @@ class CalendarSyncService {
->where($qb->expr()->eq('id', $qb->createNamedParameter($memberId, IQueryBuilder::PARAM_INT))); ->where($qb->expr()->eq('id', $qb->createNamedParameter($memberId, IQueryBuilder::PARAM_INT)));
$qb->executeStatement(); $qb->executeStatement();
} }
// ── Juleica reminder sync (Issue #160) ──────────────────────────
/**
* Sync a single member's Juleica expiry reminder.
*
* Creates a one-time reminder 2 months before juleica_ablaufdatum.
* Removes the reminder if the date is cleared.
* Updates the reminder if the date changes.
*
* @param Member $member The member to sync
*/
public function syncJuleicaReminder(Member $member): void {
$this->assertWritePermission();
$juleicaUri = $this->generateJuleicaEventUri($member);
$hasExpiry = $member->getJuleicaAblaufdatum() !== null
&& $member->getJuleicaAblaufdatum() !== '';
$hasEvent = $this->juleicaEventExists($juleicaUri);
if ($hasExpiry && $member->getDeletedAt() === null) {
$vcalendar = $this->buildJuleicaVEvent($member);
$this->storeCalendarEvent($juleicaUri, $vcalendar);
$this->logger->debug('Synced Juleica reminder', [
'memberId' => $member->getId(),
'eventUri' => $juleicaUri,
'app' => 'mitgliederverwaltung',
]);
} elseif ($hasEvent) {
$this->removeCalendarEvent($juleicaUri);
$this->logger->debug('Removed Juleica reminder', [
'memberId' => $member->getId(),
'eventUri' => $juleicaUri,
'app' => 'mitgliederverwaltung',
]);
}
}
/**
* Build a VCalendar event for a member's Juleica expiry reminder.
* The event is placed 2 months before the Ablaufdatum.
*/
private function buildJuleicaVEvent(Member $member): string {
$expiry = new \DateTime($member->getJuleicaAblaufdatum());
$reminderDate = (clone $expiry)->modify('-2 months');
$summary = sprintf(
'Juleica-Erneuerung: %s %s',
$member->getVorname(),
$member->getNachname()
);
$description = sprintf(
'Die Juleica von %s %s laeuft am %s ab. Bitte rechtzeitig die Erneuerung einleiten.',
$member->getVorname(),
$member->getNachname(),
$expiry->format('d.m.Y')
);
if ($member->getJuleicaNummer()) {
$description .= "\nJuleica-Nummer: " . $member->getJuleicaNummer();
}
$vcal = new VCalendar();
$vevent = $vcal->add('VEVENT', [
'SUMMARY' => $summary,
'DESCRIPTION' => $description,
'DTSTART' => $reminderDate,
'TRANSP' => 'TRANSPARENT',
]);
// All-day event (non-recurring)
$vevent->DTSTART['VALUE'] = 'DATE';
return $vcal->serialize();
}
/**
* Generate a unique event URI for a member's Juleica reminder.
*/
private function generateJuleicaEventUri(Member $member): string {
return 'mv-juleica-' . $member->getId() . '.ics';
}
/**
* Check if a Juleica reminder event exists in the calendar staging table.
*/
private function juleicaEventExists(string $eventUri): bool {
$qb = $this->db->getQueryBuilder();
$qb->select('id')
->from('mv_calendar_events')
->where($qb->expr()->eq('event_uri', $qb->createNamedParameter($eventUri)));
$result = $qb->executeQuery();
$exists = $result->fetch() !== false;
$result->closeCursor();
return $exists;
}
} }
+3 -1
View File
@@ -140,7 +140,7 @@ class EntityExportService {
*/ */
public function getColumnHeaders(string $type): array { public function getColumnHeaders(string $type): array {
return match ($type) { return match ($type) {
'mitglieder' => ['ID', 'Vorname', 'Nachname', 'Geburtsdatum', 'Geschlecht', 'Rolle', 'Stufe-ID', 'Stufenname', 'Eintritt', 'Austritt', 'Status', 'Allergien', 'Notizen', 'Zusaetzliche Notizen', 'KV-Typ', 'KV-Name', 'Familien-ID', 'Familienname', 'Eingefrorener Beitragssatz', 'Einwilligungsdatum'], 'mitglieder' => ['ID', 'Vorname', 'Nachname', 'Geburtsdatum', 'Geschlecht', 'Rolle', 'Stufe-ID', 'Stufenname', 'Eintritt', 'Austritt', 'Status', 'Allergien', 'Notizen', 'Zusaetzliche Notizen', 'KV-Typ', 'KV-Name', 'Familien-ID', 'Familienname', 'Eingefrorener Beitragssatz', 'Einwilligungsdatum', 'Juleica-Nummer', 'Juleica-Ablaufdatum'],
'familien' => ['ID', 'Name', 'Kontoinhaber', 'IBAN', 'BIC', 'Kreditinstitut'], 'familien' => ['ID', 'Name', 'Kontoinhaber', 'IBAN', 'BIC', 'Kreditinstitut'],
'adressen' => ['ID', 'Mitglied-ID', 'Mitgliedername', 'Label', 'Strasse', 'PLZ', 'Ort', 'Land', 'Primaer'], 'adressen' => ['ID', 'Mitglied-ID', 'Mitgliedername', 'Label', 'Strasse', 'PLZ', 'Ort', 'Land', 'Primaer'],
'telefonnummern' => ['ID', 'Mitglied-ID', 'Mitgliedername', 'Label', 'Nummer'], 'telefonnummern' => ['ID', 'Mitglied-ID', 'Mitgliedername', 'Label', 'Nummer'],
@@ -228,6 +228,8 @@ class EntityExportService {
$this->resolveFamily($m->getFamilyId()), $this->resolveFamily($m->getFamilyId()),
(string)($m->getFrozenFeeRate() ?? ''), (string)($m->getFrozenFeeRate() ?? ''),
$m->getEinwilligungDatum() ?? '', $m->getEinwilligungDatum() ?? '',
$m->getJuleicaNummer() ?? '',
$m->getJuleicaAblaufdatum() ?? '',
]; ];
} }
return $rows; return $rows;
+6
View File
@@ -447,6 +447,8 @@ class EntityImportService {
if (isset($fields['allergien']) && $fields['allergien'] !== '[verschluesselt]') { if (isset($fields['allergien']) && $fields['allergien'] !== '[verschluesselt]') {
$entity->setAllergienEncrypted($fields['allergien'] ? $this->encryptionService->encrypt($fields['allergien']) : null); $entity->setAllergienEncrypted($fields['allergien'] ? $this->encryptionService->encrypt($fields['allergien']) : null);
} }
if (isset($fields['juleica_nummer'])) $entity->setJuleicaNummer($fields['juleica_nummer'] ?: null);
if (isset($fields['juleica_ablaufdatum'])) $entity->setJuleicaAblaufdatum($fields['juleica_ablaufdatum'] ? $this->normalizeDate($fields['juleica_ablaufdatum']) : null);
$entity->setUpdatedAt($now); $entity->setUpdatedAt($now);
$this->memberMapper->update($entity); $this->memberMapper->update($entity);
$this->auditService->logUpdate($entity->jsonSerialize(), 'member', $id); $this->auditService->logUpdate($entity->jsonSerialize(), 'member', $id);
@@ -527,6 +529,8 @@ class EntityImportService {
['key' => 'familienname', 'label' => 'Familienname', 'required' => false, 'fk_resolve' => 'family'], ['key' => 'familienname', 'label' => 'Familienname', 'required' => false, 'fk_resolve' => 'family'],
['key' => 'frozen_fee_rate', 'label' => 'Eingefrorener Beitragssatz', 'required' => false], ['key' => 'frozen_fee_rate', 'label' => 'Eingefrorener Beitragssatz', 'required' => false],
['key' => 'einwilligung_datum', 'label' => 'Einwilligungsdatum', 'required' => false, 'type' => 'date'], ['key' => 'einwilligung_datum', 'label' => 'Einwilligungsdatum', 'required' => false, 'type' => 'date'],
['key' => 'juleica_nummer', 'label' => 'Juleica-Nummer', 'required' => false],
['key' => 'juleica_ablaufdatum', 'label' => 'Juleica-Ablaufdatum', 'required' => false, 'type' => 'date'],
], ],
'familien' => [ 'familien' => [
['key' => 'id', 'label' => 'ID', 'required' => false], ['key' => 'id', 'label' => 'ID', 'required' => false],
@@ -991,6 +995,8 @@ class EntityImportService {
if (!empty($d['family_id'])) $m->setFamilyId((int)$d['family_id']); if (!empty($d['family_id'])) $m->setFamilyId((int)$d['family_id']);
if (!empty($d['frozen_fee_rate'])) $m->setFrozenFeeRate($d['frozen_fee_rate']); if (!empty($d['frozen_fee_rate'])) $m->setFrozenFeeRate($d['frozen_fee_rate']);
if (!empty($d['einwilligung_datum'])) $m->setEinwilligungDatum($this->normalizeDate($d['einwilligung_datum'])); if (!empty($d['einwilligung_datum'])) $m->setEinwilligungDatum($this->normalizeDate($d['einwilligung_datum']));
if (!empty($d['juleica_nummer'])) $m->setJuleicaNummer($d['juleica_nummer']);
if (!empty($d['juleica_ablaufdatum'])) $m->setJuleicaAblaufdatum($this->normalizeDate($d['juleica_ablaufdatum']));
$m->setCreatedAt($now); $m->setCreatedAt($now);
$m->setUpdatedAt($now); $m->setUpdatedAt($now);
$m = $this->memberMapper->insert($m); $m = $this->memberMapper->insert($m);
+9
View File
@@ -102,6 +102,8 @@ class MemberService {
$member->setFrozenFeeRate($data['frozenFeeRate'] ?? null); $member->setFrozenFeeRate($data['frozenFeeRate'] ?? null);
$member->setCalendarEventUri($data['calendarEventUri'] ?? null); $member->setCalendarEventUri($data['calendarEventUri'] ?? null);
$member->setContactVcardUri($data['contactVcardUri'] ?? null); $member->setContactVcardUri($data['contactVcardUri'] ?? null);
$member->setJuleicaNummer($data['juleicaNummer'] ?? null);
$member->setJuleicaAblaufdatum($data['juleicaAblaufdatum'] ?? null);
$member->setCreatedAt($now); $member->setCreatedAt($now);
$member->setUpdatedAt($now); $member->setUpdatedAt($now);
@@ -352,6 +354,7 @@ class MemberService {
'stufeId', 'eintritt', 'austritt', 'status', 'allergienEncrypted', 'stufeId', 'eintritt', 'austritt', 'status', 'allergienEncrypted',
'notizen', 'zusatzNotizen', 'kvTyp', 'kvName', 'familyId', 'notizen', 'zusatzNotizen', 'kvTyp', 'kvName', 'familyId',
'frozenFeeRate', 'calendarEventUri', 'contactVcardUri', 'einwilligungDatum', 'frozenFeeRate', 'calendarEventUri', 'contactVcardUri', 'einwilligungDatum',
'juleicaNummer', 'juleicaAblaufdatum',
]; ];
$data = array_intersect_key($data, array_flip($editableFields)); $data = array_intersect_key($data, array_flip($editableFields));
@@ -418,6 +421,12 @@ class MemberService {
if (array_key_exists('einwilligungDatum', $data)) { if (array_key_exists('einwilligungDatum', $data)) {
$member->setEinwilligungDatum($data['einwilligungDatum']); $member->setEinwilligungDatum($data['einwilligungDatum']);
} }
if (array_key_exists('juleicaNummer', $data)) {
$member->setJuleicaNummer($data['juleicaNummer']);
}
if (array_key_exists('juleicaAblaufdatum', $data)) {
$member->setJuleicaAblaufdatum($data['juleicaAblaufdatum']);
}
$member->setUpdatedAt((new DateTime())->format('Y-m-d H:i:s')); $member->setUpdatedAt((new DateTime())->format('Y-m-d H:i:s'));
+77 -1
View File
@@ -102,6 +102,33 @@
</template> </template>
</div> </div>
<!-- Juleica section -->
<div class="member-form__section">
<h4>Juleica</h4>
<div class="member-form__grid">
<div class="member-form__field">
<label>Juleica-Nummer</label>
<NcTextField :model-value="member.juleicaNummer || ''"
:disabled="!editing"
placeholder="Kartennummer"
@update:model-value="$emit('update', 'juleicaNummer', $event || null)" />
</div>
<div class="member-form__field">
<label>Ablaufdatum</label>
<NcTextField :model-value="member.juleicaAblaufdatum || ''"
type="date"
:disabled="!editing"
@update:model-value="$emit('update', 'juleicaAblaufdatum', $event || null)" />
<span v-if="!editing && juleicaStatus === 'expired'" class="member-form__juleica-warning member-form__juleica-warning--expired">
abgelaufen
</span>
<span v-else-if="!editing && juleicaStatus === 'expiring'" class="member-form__juleica-warning member-form__juleica-warning--expiring">
laeuft bald ab
</span>
</div>
</div>
</div>
<!-- Full-width text areas --> <!-- Full-width text areas -->
<div class="member-form__field member-form__field--full"> <div class="member-form__field member-form__field--full">
<label>Allergien</label> <label>Allergien</label>
@@ -133,9 +160,10 @@
</template> </template>
<script setup> <script setup>
import { computed } from 'vue'
import { NcTextField, NcSelect } from '@nextcloud/vue' import { NcTextField, NcSelect } from '@nextcloud/vue'
defineProps({ const props = defineProps({
member: { member: {
type: Object, type: Object,
required: true, required: true,
@@ -156,6 +184,24 @@ defineProps({
defineEmits(['update']) defineEmits(['update'])
/**
* Compute Juleica status: 'expired', 'expiring', or null.
* 'expired' = Ablaufdatum is in the past
* 'expiring' = Ablaufdatum is within the next 2 months
*/
const juleicaStatus = computed(() => {
const dateStr = props.member.juleicaAblaufdatum
if (!dateStr) return null
const expiry = new Date(dateStr)
const now = new Date()
now.setHours(0, 0, 0, 0)
if (expiry < now) return 'expired'
const twoMonths = new Date(now)
twoMonths.setMonth(twoMonths.getMonth() + 2)
if (expiry <= twoMonths) return 'expiring'
return null
})
const geschlechtOptions = [ const geschlechtOptions = [
{ value: 'maennlich', label: 'Männlich' }, { value: 'maennlich', label: 'Männlich' },
{ value: 'weiblich', label: 'Weiblich' }, { value: 'weiblich', label: 'Weiblich' },
@@ -233,4 +279,34 @@ const kvTypOptions = [
border-color: var(--color-primary); border-color: var(--color-primary);
outline: none; outline: none;
} }
.member-form__section {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--color-border);
}
.member-form__section h4 {
margin: 0 0 12px 0;
font-size: 1em;
color: var(--color-text-lighter);
}
.member-form__juleica-warning {
font-size: 0.85em;
font-weight: 600;
padding: 2px 8px;
border-radius: var(--border-radius-pill);
display: inline-block;
}
.member-form__juleica-warning--expired {
color: var(--color-error);
background-color: var(--color-error-hover, rgba(204, 0, 0, 0.1));
}
.member-form__juleica-warning--expiring {
color: var(--color-warning);
background-color: var(--color-warning-hover, rgba(204, 153, 0, 0.1));
}
</style> </style>
+2
View File
@@ -363,6 +363,8 @@ async function save() {
familyId: d.familyId, familyId: d.familyId,
frozenFeeRate: d.frozenFeeRate, frozenFeeRate: d.frozenFeeRate,
einwilligungDatum: d.einwilligungDatum, einwilligungDatum: d.einwilligungDatum,
juleicaNummer: d.juleicaNummer,
juleicaAblaufdatum: d.juleicaAblaufdatum,
} }
await store.updateMember(Number(props.id), data) await store.updateMember(Number(props.id), data)
await loadMember() await loadMember()