diff --git a/app/Console/Commands/CheckRosterStatus.php b/app/Console/Commands/CheckRosterStatus.php
index cb9bc5fa..dc507afd 100644
--- a/app/Console/Commands/CheckRosterStatus.php
+++ b/app/Console/Commands/CheckRosterStatus.php
@@ -56,138 +56,136 @@ public function handle(): int
}
}
- protected function getRoster(): array
- {
+ protected function checkUser(int $vatsimId): void
+{
+ try {
+ $entry = RosterEntry::firstOrCreate(
+ ['user_id' => $vatsimId],
+ [
+ 'last_session' => null,
+ 'removal_date' => null,
+ ]
+ );
+
try {
- $response = Http::withHeaders([
- 'X-API-KEY' => config('services.vateud.token'),
- 'Accept' => 'application/json',
- 'User-Agent' => 'VATGER Training System',
- ])->get('https://core.vateud.net/api/facility/roster');
+ $lastSession = $this->getLastSession($vatsimId);
- if ($response->successful()) {
- $data = $response->json();
- return $data['data']['controllers'] ?? [];
+ if ($lastSession instanceof Carbon && $lastSession->year > 2000) {
+ $entry->last_session = $lastSession;
+ $entry->save();
}
- } catch (\Exception $e) {
- Log::error('Failed to fetch roster from VatEUD', ['error' => $e->getMessage()]);
+ } catch (\Throwable $e) {
+ Log::warning('Failed fetching last session', [
+ 'vatsim_id' => $vatsimId,
+ 'error' => $e->getMessage(),
+ ]);
}
- return [];
- }
+ if (!$entry->last_session) {
+ return;
+ }
- protected function checkUser(int $vatsimId): void
- {
- try {
- $entry = RosterEntry::firstOrCreate(
- ['user_id' => $vatsimId],
- [
- 'last_session' => Carbon::createFromTimestamp(0),
- 'removal_date' => null
- ]
- );
+ $inactiveDays = $entry->last_session->diffInDays(now());
- if ($entry->last_session && !$entry->last_session->timezone) {
- $entry->last_session = Carbon::parse($entry->last_session)->timezone('UTC');
- $entry->save();
- }
+ $WARNING_THRESHOLD = 330; // ~11 months
+ $REMOVAL_THRESHOLD = 365; // 12 months
+ $GRACE_DAYS = 35;
- if ($entry->last_session && now()->diffInDays($entry->last_session) < (11 * 30)) {
- return;
- }
+ if ($inactiveDays < $WARNING_THRESHOLD) {
- try {
- $lastSession = $this->getLastSession($vatsimId);
- $entry->last_session = $lastSession;
+ if ($entry->removal_date) {
+ Log::info('USER RECOVERED ROSTER ACTIVITY - resetting warning', [
+ 'vatsim_id' => $vatsimId,
+ ]);
+
+ $entry->removal_date = null;
$entry->save();
- } catch (\Exception $e) {
- $this->warn("Error getting last session for {$vatsimId}: " . $e->getMessage());
- return;
}
- if ($entry->last_session->lt(now()->subDays(366))) {
- if ($entry->removal_date && $entry->removal_date->lt(now())) {
- $this->removeFromRoster($vatsimId);
- $entry->delete();
- return;
- }
- }
+ return;
+ }
- if ($entry->last_session->lt(now()->subDays(11 * 30))) {
- try {
- [$isRecentS1, $ratingChangeDate] = $this->checkS1Status($vatsimId);
-
- if ($isRecentS1) {
- $entry->last_session = $ratingChangeDate;
- $entry->removal_date = null;
- $entry->save();
- return;
- }
- } catch (\Exception $e) {
- $this->warn("Error checking rating for {$vatsimId}: " . $e->getMessage());
- }
+ if ($inactiveDays >= $WARNING_THRESHOLD && !$entry->removal_date) {
- if (!$entry->removal_date) {
- $this->sendRemovalWarning($vatsimId);
- $entry->removal_date = now()->addDays(35);
- $entry->save();
- $this->info("Set removal date for {$vatsimId}: " . $entry->removal_date->format('Y-m-d'));
- }
- } else {
- if ($entry->removal_date) {
- $entry->removal_date = null;
- $entry->save();
- }
- }
+ Log::warning('SENDING ROSTER REMOVAL WARNING', [
+ 'vatsim_id' => $vatsimId,
+ ]);
- } catch (\Exception $e) {
- $this->error("Error checking user {$vatsimId}: " . $e->getMessage());
- Log::error('Error in roster check for user', [
+ $this->sendRemovalWarning($vatsimId);
+
+ $entry->removal_date = now()->addDays($GRACE_DAYS);
+ $entry->save();
+
+ return;
+ }
+
+ if (
+ $inactiveDays >= $REMOVAL_THRESHOLD &&
+ $entry->removal_date &&
+ now()->gte($entry->removal_date)
+ ) {
+ Log::warning('REMOVING USER FROM ROSTER', [
'vatsim_id' => $vatsimId,
- 'error' => $e->getMessage()
+ 'inactive_days' => $inactiveDays,
+ 'removal_date' => $entry->removal_date,
]);
+
+ $this->removeFromRoster($vatsimId);
+ $entry->delete();
+
+ return;
}
+ } catch (\Throwable $e) {
+ Log::error('ROSTER CHECK FAILED', [
+ 'vatsim_id' => $vatsimId,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+}
+
+ protected function getRoster(): array
+ {
+ $response = Http::withHeaders([
+ 'X-API-KEY' => config('services.vateud.token'),
+ 'Accept' => 'application/json',
+ ])->get('https://core.vateud.net/api/facility/roster');
+
+ return $response->successful()
+ ? ($response->json()['data']['controllers'] ?? [])
+ : [];
}
protected function getLastSession(int $vatsimId): Carbon
{
- $date = now()->subDays(365);
- $apiUrl = "http://stats.vatsim-germany.org/api/atc/{$vatsimId}/sessions/?start_date={$date->format('Y-m-d')}";
+ $date = now()->subYear();
- try {
- $response = Http::timeout(15)->get($apiUrl);
-
- if (!$response->successful()) {
- throw new \Exception("API request failed with status: " . $response->status());
- }
+ $response = Http::timeout(15)->get(
+ "http://stats.vatsim-germany.org/api/atc/{$vatsimId}/sessions/?start_date={$date->format('Y-m-d')}"
+ );
- $connections = $response->json();
- if (!is_array($connections)) {
- $connections = [];
- }
-
- foreach ($connections as $connection) {
- $callsign = $connection['callsign'] ?? '';
- $prefix = substr($callsign, 0, 2);
-
- if (in_array($prefix, ['ED', 'ET'])) {
- $endTime = $connection['disconnected_at'] ?? null;
- if ($endTime) {
- return Carbon::parse($endTime)->timezone('UTC');
- }
- }
+ if (!$response->successful()) {
+ throw new \Exception('Session API failed');
+ }
+
+ $latest = null;
+
+ foreach ($response->json() as $session) {
+ $prefix = substr($session['callsign'] ?? '', 0, 2);
+
+ if (!in_array($prefix, ['ED', 'ET'])) {
+ continue;
}
- $this->warn("No German connections found for user {$vatsimId}");
- return Carbon::createFromTimestamp(0)->timezone('UTC');
+ if (!empty($session['disconnected_at'])) {
+ $time = Carbon::parse($session['disconnected_at']);
- } catch (\Exception $e) {
- Log::error('Error fetching last session from vatsim-germany.org', [
- 'vatsim_id' => $vatsimId,
- 'error' => $e->getMessage()
- ]);
- throw $e;
+ if (!$latest || $time->gt($latest)) {
+ $latest = $time;
+ }
+ }
}
+
+ return $latest ?? Carbon::createFromTimestamp(0);
}
protected function checkS1Status(int $vatsimId): array
@@ -240,95 +238,60 @@ protected function checkS1Status(int $vatsimId): array
}
protected function sendRemovalWarning(int $vatsimId): void
- {
- $apiKey = config('services.vatger.api_key');
- $apiBaseUrl = config('services.vatger.api_url');
-
- if (!$apiKey) {
- Log::warning('VATGER API key not configured, skipping removal notification');
- return;
- }
-
- $message = "You have not controlled in the past 11 months. " .
- "If you want to stay on the VATSIM Germany roster, " .
- "please log in to the VATSIM network and control at least once in the next 35 days. " .
- "If you do not, your account will be removed from the roster. " .
- "If you believe this is a mistake, please contact the ATD.";
-
- $data = [
- 'title' => 'Removal from VATSIM Germany Roster',
- 'message' => $message,
- 'source_name' => 'VATGER ATD',
- 'via' => 'board.ping',
- ];
-
- try {
- $response = Http::withHeaders([
- 'Authorization' => "Token {$apiKey}",
- ])->post("{$apiBaseUrl}/user/{$vatsimId}/send_notification", $data);
-
- if (!$response->successful()) {
- Log::warning('Failed to send removal notification', [
- 'vatsim_id' => $vatsimId,
- 'status' => $response->status()
- ]);
- }
- } catch (\Exception $e) {
- Log::error('Error sending removal notification', [
+ {
+ $message =
+ "You have not controlled in the past 11 months. " .
+ "To remain on the VATSIM Germany roster, please log in and control within the next 35 days. " .
+ "Otherwise, your account will be removed from the roster. " .
+ "If you believe this is a mistake, please contact the ATD.";
+
+ $result = $this->vatgerService->sendNotification(
+ $vatsimId,
+ 'Removal from VATSIM Germany Roster',
+ $message,
+ 'VATGER ATD',
+ 'https://vatsim-germany.org'
+ );
+
+ if (!$result['success']) {
+ Log::error('Failed to send roster removal warning', [
'vatsim_id' => $vatsimId,
- 'error' => $e->getMessage()
]);
+ } else {
+ ActivityLogger::log(
+ 'roster.notified',
+ $vatsimId,
+ "Notified roster removal for $vatsimId",
+ );
}
}
protected function removeFromRoster(int $vatsimId): void
{
- try {
- $this->info("Removing user {$vatsimId} from roster...");
-
- $user = User::where('vatsim_id', $vatsimId)->first();
-
- $this->vatEudService->removeRosterAndEndorsements($vatsimId);
-
- WaitingListEntry::whereHas('user', function ($query) use ($vatsimId) {
- $query->where('vatsim_id', $vatsimId);
- })->delete();
-
- if ($user) {
- ActivityLogger::log(
- 'roster.removed',
- $user,
- "User {$user->name} (VATSIM ID: {$vatsimId}) removed from roster due to inactivity",
- [
- 'vatsim_id' => $vatsimId,
- 'user_id' => $user->id,
- 'user_name' => $user->name,
- 'reason' => 'inactivity',
- 'removed_by' => 'system',
- ]
- );
- } else {
- ActivityLogger::log(
- 'roster.removed',
- null,
- "VATSIM ID {$vatsimId} removed from roster due to inactivity",
- [
- 'vatsim_id' => $vatsimId,
- 'reason' => 'inactivity',
- 'removed_by' => 'system',
- ]
- );
- }
+ $success = $this->vatEudService->removeRosterAndEndorsements($vatsimId);
- Log::info('User removed from roster', [
- 'vatsim_id' => $vatsimId
- ]);
-
- } catch (\Exception $e) {
- Log::error('Error removing user from roster', [
+ if (!$success) {
+ Log::error('Roster removal failed at VATEUD', [
'vatsim_id' => $vatsimId,
- 'error' => $e->getMessage()
]);
+ return;
}
+
+ WaitingListEntry::whereHas('user', function ($q) use ($vatsimId) {
+ $q->where('vatsim_id', $vatsimId);
+ })->delete();
+
+ ActivityLogger::log(
+ 'roster.removed',
+ null,
+ "User {$vatsimId} removed from roster due to inactivity",
+ [
+ 'vatsim_id' => $vatsimId,
+ 'reason' => 'inactivity',
+ 'removed_by' => 'system',
+ ]
+ );
+
+ Log::warning("ROSTER REMOVAL COMPLETE: {$vatsimId}");
}
}
diff --git a/app/Console/Commands/SyncEndorsementActivities.php b/app/Console/Commands/SyncEndorsementActivities.php
index 68e44330..60772eea 100644
--- a/app/Console/Commands/SyncEndorsementActivities.php
+++ b/app/Console/Commands/SyncEndorsementActivities.php
@@ -156,6 +156,9 @@ protected function updateEndorsementActivity(EndorsementActivity $endorsementAct
}
}
+ $eligibleSince = $this->activityService->calculateEligibleSince($endorsementData);
+ $endorsementActivity->eligible_since = $eligibleSince;
+
$endorsementActivity->save();
} catch (\Exception $e) {
diff --git a/app/Enums/ActivityAction.php b/app/Enums/ActivityAction.php
index 77d02c92..6680948e 100644
--- a/app/Enums/ActivityAction.php
+++ b/app/Enums/ActivityAction.php
@@ -61,6 +61,7 @@ enum ActivityAction: string
case CPT_UPDATED = 'cpt.updated';
case ROSTER_REMOVED = 'roster.removed';
+ case ROSTER_NOTIFIED = 'roster.notified';
case GDPR_DELETION = 'gdpr.deletion';
case API_USER_RETRIEVAL = 'api.user.retrieval';
@@ -129,6 +130,7 @@ public function getLabel(): string
self::CPT_UPDATED => 'CPT Updated',
self::ROSTER_REMOVED => 'Removed from Roster',
+ self::ROSTER_NOTIFIED => 'Notified Roster Removal',
self::GDPR_DELETION => 'GDPR User Deletion',
@@ -180,6 +182,7 @@ public function getColor(): string
self::COURSE_FINISHED,
self::COURSE_UPDATED,
self::COT_UPDATED,
+ self::ROSTER_NOTIFIED,
self::UPDATED => 'warning',
self::CPT_EXAMINER_JOINED,
diff --git a/app/Http/Controllers/EndorsementController.php b/app/Http/Controllers/EndorsementController.php
index 839511ee..ccb7db7b 100644
--- a/app/Http/Controllers/EndorsementController.php
+++ b/app/Http/Controllers/EndorsementController.php
@@ -198,6 +198,7 @@ public function mentorView(Request $request): Response
'activityHours' => $activity->activity_hours,
'status' => $activity->status,
'progress' => $activity->progress,
+ 'eligibleSince' => $activity->eligible_since,
'removalDate' => $activity->removal_date?->format('Y-m-d'),
'removalDays' => $activity->removal_date
? $activity->removal_date->diffInDays(now(), false)
@@ -305,6 +306,7 @@ public function mentorView(Request $request): Response
'userPermissions' => [
'canRemoveForPositions' => $canRemovePositions,
'canRemoveAny' => ($user->is_superuser || $user->is_admin) || (!empty($canRemovePositions) && count($canRemovePositions) > 0),
+ 'isAdmin' => $user->is_superuser || $user->is_admin,
],
]);
}
diff --git a/app/Models/EndorsementActivity.php b/app/Models/EndorsementActivity.php
index 3ec79b10..48d3c441 100644
--- a/app/Models/EndorsementActivity.php
+++ b/app/Models/EndorsementActivity.php
@@ -18,6 +18,7 @@ class EndorsementActivity extends Model
'last_updated',
'last_activity_date',
'removal_date',
+ 'eligible_since',
'removal_notified',
'created_at_vateud',
];
@@ -27,6 +28,7 @@ class EndorsementActivity extends Model
'last_updated' => 'datetime',
'last_activity_date' => 'date',
'removal_date' => 'date',
+ 'eligible_since' => 'datetime',
'removal_notified' => 'boolean',
'created_at_vateud' => 'datetime',
];
diff --git a/app/Services/VatEudService.php b/app/Services/VatEudService.php
index 950591e3..5967e627 100644
--- a/app/Services/VatEudService.php
+++ b/app/Services/VatEudService.php
@@ -243,28 +243,86 @@ public function removeRosterAndEndorsements(int $vatsimId): bool
$rosterResponse = Http::withHeaders($this->headers)
->delete("{$this->baseUrl}/facility/roster/{$vatsimId}");
+ if (!$rosterResponse->successful()) {
+ Log::error('Roster removal failed', [
+ 'vatsim_id' => $vatsimId,
+ 'status' => $rosterResponse->status(),
+ 'body' => $rosterResponse->body(),
+ ]);
+
+ return false;
+ }
+
$tier1 = collect($this->getTier1Endorsements())
->where('user_cid', $vatsimId);
-
- $tier2 = collect($this->getTier2Endorsements())
- ->where('user_cid', $vatsimId);
foreach ($tier1 as $endorsement) {
- Http::withHeaders($this->headers)
- ->delete("{$this->baseUrl}/facility/endorsements/tier-1/{$endorsement['id']}");
+ try {
+ $response = Http::withHeaders($this->headers)
+ ->delete("{$this->baseUrl}/facility/endorsements/tier-1/{$endorsement['id']}");
+
+ if (!$response->successful()) {
+ Log::warning('Failed to delete Tier 1 endorsement', [
+ 'vatsim_id' => $vatsimId,
+ 'endorsement_id' => $endorsement['id'],
+ 'status' => $response->status(),
+ 'body' => $response->body(),
+ ]);
+
+ $success = false;
+ }
+ } catch (\Throwable $e) {
+ Log::error('Exception while deleting Tier 1 endorsement', [
+ 'vatsim_id' => $vatsimId,
+ 'endorsement_id' => $endorsement['id'],
+ 'error' => $e->getMessage(),
+ ]);
+
+ $success = false;
+ }
}
+ $tier2 = collect($this->getTier2Endorsements())
+ ->where('user_cid', $vatsimId);
+
foreach ($tier2 as $endorsement) {
- Http::withHeaders($this->headers)
- ->delete("{$this->baseUrl}/facility/endorsements/tier-2/{$endorsement['id']}");
+ try {
+ $response = Http::withHeaders($this->headers)
+ ->delete("{$this->baseUrl}/facility/endorsements/tier-2/{$endorsement['id']}");
+
+ if (!$response->successful()) {
+ Log::warning('Failed to delete Tier 2 endorsement', [
+ 'vatsim_id' => $vatsimId,
+ 'endorsement_id' => $endorsement['id'],
+ 'status' => $response->status(),
+ 'body' => $response->body(),
+ ]);
+
+ $success = false;
+ }
+ } catch (\Throwable $e) {
+ Log::error('Exception while deleting Tier 2 endorsement', [
+ 'vatsim_id' => $vatsimId,
+ 'endorsement_id' => $endorsement['id'],
+ 'error' => $e->getMessage(),
+ ]);
+
+ $success = false;
+ }
}
+ Log::warning('ROSTER REMOVAL COMPLETED', [
+ 'vatsim_id' => $vatsimId,
+ ]);
+
return true;
+
} catch (\Exception $e) {
Log::error('Error removing roster and endorsements', [
'vatsim_id' => $vatsimId,
'error' => $e->getMessage()
]);
+
return false;
}
}
diff --git a/app/Services/VatsimActivityService.php b/app/Services/VatsimActivityService.php
index c8afef31..380bc6ea 100644
--- a/app/Services/VatsimActivityService.php
+++ b/app/Services/VatsimActivityService.php
@@ -280,4 +280,177 @@ public function getActivityProgress(float $activityMinutes): float
$minRequiredMinutes = config('services.vateud.min_activity_minutes', 180);
return min(($activityMinutes / $minRequiredMinutes) * 100, 100);
}
+
+ protected function getVatsimConnectionsTwoYears(int $vatsimId): array
+ {
+ $cacheKey = "vatsim_activity_2y:{$vatsimId}";
+
+ return Cache::remember($cacheKey, now()->addHours(6), function () use ($vatsimId) {
+ $start = Carbon::now()->subYears(2)->format('Y-m-d');
+ $apiUrl = "http://stats.vatsim-germany.org/api/atc/{$vatsimId}/sessions/?start_date={$start}";
+
+ try {
+ $response = Http::timeout(15)
+ ->retry(2, 1000)
+ ->get($apiUrl);
+
+ if (!$response->successful()) {
+ return [];
+ }
+
+ $data = $response->json();
+ return is_array($data) ? $data : [];
+
+ } catch (\Exception $e) {
+ Log::error('Error fetching 2y VATSIM connections', [
+ 'vatsim_id' => $vatsimId,
+ 'error' => $e->getMessage(),
+ ]);
+ return [];
+ }
+ });
+ }
+
+ protected function extractRelevantSessions(array $endorsement, array $connections): array
+ {
+ $sessions = [];
+ $position = $endorsement['position'];
+ $inTransition = Carbon::now()->lessThan(Carbon::parse($this->transitionEndDate));
+
+ foreach ($connections as $connection) {
+ $callsign = $connection['callsign'] ?? '';
+ $minutes = floatval($connection['minutes_online'] ?? 0);
+ $date = $this->parseConnectionDate($connection);
+
+ if (!$date || $minutes <= 0) {
+ continue;
+ }
+
+ $matches = false;
+
+ if (str_ends_with($position, '_CTR')) {
+ $ctrlPrefix = substr($position, 0, 6);
+
+ if (str_starts_with($callsign, $ctrlPrefix) ||
+ ($position === 'EDWW_W_CTR' && $callsign === 'EDWW_CTR')) {
+ $matches = true;
+ }
+ } else {
+ $parts = explode('_', $position);
+ if (count($parts) >= 2) {
+ $airport = $parts[0];
+ $station = end($parts);
+
+ $ctrStations = $this->ctrTopdown[$airport] ?? [];
+ $legacyCtrStations = $inTransition ? ($this->legacyCtrTopdown[$airport] ?? []) : [];
+ $appStations = $this->appTopdown[$airport] ?? [];
+
+ $matchesSuffix = $this->suffixCondition($airport, $station, $callsign);
+
+ $matchesCtr = false;
+
+ $ctrAllowedStations = ['APP', 'TWR', 'GNDDEL'];
+
+ if (in_array($station, $ctrAllowedStations, true)) {
+ $allCtrStations = array_unique(array_merge($ctrStations, $legacyCtrStations));
+
+ foreach ($allCtrStations as $ctrStation) {
+ if (str_starts_with($callsign, $ctrStation)) {
+ $matchesCtr = true;
+ break;
+ }
+ }
+
+ if (!$matchesCtr && $station !== 'APP') {
+ foreach ($appStations as $appStation) {
+ if (str_starts_with($callsign, $appStation)) {
+ $matchesCtr = true;
+ break;
+ }
+ }
+ }
+ }
+
+ if (!$matchesCtr && $station === 'APP') {
+ foreach ($appStations as $appStation) {
+ if (str_starts_with($callsign, $appStation)) {
+ $matchesCtr = true;
+ break;
+ }
+ }
+ }
+
+ $matches = $matchesCtr || $matchesSuffix;
+ }
+ }
+
+ if ($matches) {
+ $sessions[] = [
+ 'date' => $date,
+ 'minutes' => $minutes,
+ ];
+ }
+ }
+
+ usort($sessions, fn($a, $b) => $a['date']->lt($b['date']) ? -1 : 1);
+
+ return $sessions;
+ }
+
+ public function calculateEligibleSince(array $endorsement): ?Carbon
+ {
+ $connections = $this->getVatsimConnectionsTwoYears($endorsement['user_cid']);
+ $sessions = $this->extractRelevantSessions($endorsement, $connections);
+
+ // No sessions in 2 years -> no value
+ if (empty($sessions)) {
+ return null;
+ }
+
+ $requiredMinutes = config('services.vateud.min_activity_minutes', 180);
+
+ $events = [];
+
+ foreach ($sessions as $session) {
+ $events[] = [
+ 'date' => $session['date']->copy(),
+ 'delta' => $session['minutes'],
+ ];
+
+ $events[] = [
+ 'date' => $session['date']->copy()->addDays(180),
+ 'delta' => -$session['minutes'],
+ ];
+ }
+
+ usort($events, function ($a, $b) {
+ return $a['date']->timestamp <=> $b['date']->timestamp;
+ });
+
+ $runningMinutes = 0;
+ $eligibleSince = null;
+
+ foreach ($events as $event) {
+ $before = $runningMinutes;
+
+ $runningMinutes += $event['delta'];
+
+ if (
+ $before >= $requiredMinutes &&
+ $runningMinutes < $requiredMinutes
+ ) {
+ $eligibleSince = $event['date']->copy();
+ }
+
+ if ($runningMinutes >= $requiredMinutes) {
+ $eligibleSince = null;
+ }
+ }
+
+ if ($runningMinutes < $requiredMinutes) {
+ return $eligibleSince;
+ }
+
+ return null;
+ }
}
diff --git a/database/migrations/2026_05_13_125344_add_eligible_since_to_endorsement_activities.php b/database/migrations/2026_05_13_125344_add_eligible_since_to_endorsement_activities.php
new file mode 100644
index 00000000..9f861982
--- /dev/null
+++ b/database/migrations/2026_05_13_125344_add_eligible_since_to_endorsement_activities.php
@@ -0,0 +1,30 @@
+timestamp('eligible_since')
+ ->nullable()
+ ->after('removal_date');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('endorsement_activities', function (Blueprint $table) {
+ $table->dropColumn('eligible_since');
+ });
+ }
+};
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index ff29950b..dde6d38f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5,9 +5,9 @@
"packages": {
"": {
"dependencies": {
- "@emnapi/core": "1.9.1",
+ "@emnapi/core": "1.10.0",
"@emnapi/runtime": "1.10.0",
- "@headlessui/react": "^2.2.9",
+ "@headlessui/react": "^2.2.10",
"@inertiajs/react": "^2.3.6",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
@@ -44,11 +44,11 @@
"date-fns": "^4.1.0",
"globals": "^17.5.0",
"laravel-vite-plugin": "^3.1.0",
- "lucide-react": "^1.14.0",
+ "lucide-react": "^1.16.0",
"next-themes": "^0.4.6",
"react": "^19.2.5",
"react-day-picker": "^9.13.0",
- "react-dom": "^19.2.3",
+ "react-dom": "^19.2.5",
"recharts": "2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
@@ -246,12 +246,12 @@
"license": "MIT"
},
"node_modules/@emnapi/core": {
- "version": "1.9.1",
- "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
- "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
+ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"license": "MIT",
"dependencies": {
- "@emnapi/wasi-threads": "1.2.0",
+ "@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
@@ -265,9 +265,9 @@
}
},
"node_modules/@emnapi/wasi-threads": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
- "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"license": "MIT",
"dependencies": {
"tslib": "^2.4.0"
@@ -327,9 +327,9 @@
"license": "MIT"
},
"node_modules/@headlessui/react": {
- "version": "2.2.9",
- "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.9.tgz",
- "integrity": "sha512-Mb+Un58gwBn0/yWZfyrCh0TJyurtT+dETj7YHleylHk5od3dv2XqETPGWMyQ5/7sYN7oWdyM1u9MvC0OC8UmzQ==",
+ "version": "2.2.10",
+ "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.10.tgz",
+ "integrity": "sha512-5pVLNK9wlpxTUTy9GpgbX/SdcRh+HBnPktjM2wbiLTH4p+2EPHBO1aoSryUCuKUIItdDWO9ITlhUL8UnUN/oIA==",
"license": "MIT",
"dependencies": {
"@floating-ui/react": "^0.26.16",
@@ -4251,9 +4251,9 @@
}
},
"node_modules/lucide-react": {
- "version": "1.14.0",
- "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz",
- "integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==",
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz",
+ "integrity": "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -4613,15 +4613,15 @@
}
},
"node_modules/react-dom": {
- "version": "19.2.4",
- "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
- "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
+ "version": "19.2.5",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
+ "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
- "react": "^19.2.4"
+ "react": "^19.2.5"
}
},
"node_modules/react-is": {
diff --git a/package.json b/package.json
index a0186738..44f40e7e 100644
--- a/package.json
+++ b/package.json
@@ -15,9 +15,9 @@
"@types/ziggy-js": "^1.8.0"
},
"dependencies": {
- "@emnapi/core": "1.9.1",
+ "@emnapi/core": "1.10.0",
"@emnapi/runtime": "1.10.0",
- "@headlessui/react": "^2.2.9",
+ "@headlessui/react": "^2.2.10",
"@inertiajs/react": "^2.3.6",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
@@ -54,11 +54,11 @@
"date-fns": "^4.1.0",
"globals": "^17.5.0",
"laravel-vite-plugin": "^3.1.0",
- "lucide-react": "^1.14.0",
+ "lucide-react": "^1.16.0",
"next-themes": "^0.4.6",
"react": "^19.2.5",
"react-day-picker": "^9.13.0",
- "react-dom": "^19.2.3",
+ "react-dom": "^19.2.5",
"recharts": "2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
diff --git a/resources/js/pages/endorsements/manage.tsx b/resources/js/pages/endorsements/manage.tsx
index b3d89697..be725a86 100644
--- a/resources/js/pages/endorsements/manage.tsx
+++ b/resources/js/pages/endorsements/manage.tsx
@@ -57,6 +57,7 @@ interface EndorsementData {
activityHours: number
status: "active" | "warning" | "removal"
progress: number
+ eligibleSince: string
removalDate: string | null
removalDays: number
}
@@ -74,6 +75,7 @@ interface PageProps {
userPermissions: {
canRemoveForPositions: string[] | null
canRemoveAny: boolean
+ isAdmin: boolean
}
}
@@ -122,6 +124,8 @@ export default function ManageEndorsements({
const [isProcessing, setIsProcessing] = useState(false)
const [showActiveEndorsements, setShowActiveEndorsements] = useState(false)
+ const { isAdmin } = userPermissions
+
const canRemoveForPosition = (position: string): boolean => {
if (userPermissions.canRemoveForPositions === null) {
return true
@@ -478,6 +482,7 @@ export default function ManageEndorsements({
Controller
Activity
Status
+ {isAdmin && Eligible Since}
Actions
@@ -564,6 +569,20 @@ export default function ManageEndorsements({
)}
+ {isAdmin && (
+
+ {state !== "active"
+ ? (() => {
+ const date = new Date(
+ endorsement.eligibleSince,
+ )
+ return date.getFullYear() === 1970
+ ? "Unknown"
+ : date.toLocaleDateString("de")
+ })()
+ : null}
+
+ )}