diff --git a/backend/controllers/apiLogController.js b/backend/controllers/apiLogController.js
index 02a021f..af81323 100644
--- a/backend/controllers/apiLogController.js
+++ b/backend/controllers/apiLogController.js
@@ -62,6 +62,23 @@ class ApiLogController {
next(error);
}
}
+
+ /**
+ * GET /api/logs/scheduler/last-executions
+ * Get last execution info for scheduler jobs
+ */
+ async getLastSchedulerExecutions(req, res, next) {
+ try {
+ const { clubId } = req.query;
+ const results = await apiLogService.getLastSchedulerExecutions(clubId ? parseInt(clubId) : null);
+ res.json({
+ success: true,
+ data: results
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
}
export default new ApiLogController();
diff --git a/backend/controllers/memberController.js b/backend/controllers/memberController.js
index 2c10110..c651565 100644
--- a/backend/controllers/memberController.js
+++ b/backend/controllers/memberController.js
@@ -100,6 +100,30 @@ const rotateMemberImage = async (req, res) => {
}
};
+const quickUpdateTestMembership = async (req, res) => {
+ try {
+ const { clubId, memberId } = req.params;
+ const { authcode: userToken } = req.headers;
+ const result = await MemberService.quickUpdateTestMembership(userToken, clubId, memberId);
+ res.status(result.status).json(result.response);
+ } catch (error) {
+ console.error('[quickUpdateTestMembership] - Error:', error);
+ res.status(500).json({ error: 'Failed to update test membership' });
+ }
+};
+
+const quickUpdateMemberFormHandedOver = async (req, res) => {
+ try {
+ const { clubId, memberId } = req.params;
+ const { authcode: userToken } = req.headers;
+ const result = await MemberService.quickUpdateMemberFormHandedOver(userToken, clubId, memberId);
+ res.status(result.status).json(result.response);
+ } catch (error) {
+ console.error('[quickUpdateMemberFormHandedOver] - Error:', error);
+ res.status(500).json({ error: 'Failed to update member form status' });
+ }
+};
+
const transferMembers = async (req, res) => {
try {
const { id: clubId } = req.params;
@@ -132,4 +156,4 @@ const transferMembers = async (req, res) => {
}
};
-export { getClubMembers, getWaitingApprovals, setClubMembers, uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis, rotateMemberImage, transferMembers };
\ No newline at end of file
+export { getClubMembers, getWaitingApprovals, setClubMembers, uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis, rotateMemberImage, transferMembers, quickUpdateTestMembership, quickUpdateMemberFormHandedOver };
\ No newline at end of file
diff --git a/backend/routes/apiLogRoutes.js b/backend/routes/apiLogRoutes.js
index 0bff6a3..6f958ce 100644
--- a/backend/routes/apiLogRoutes.js
+++ b/backend/routes/apiLogRoutes.js
@@ -11,6 +11,9 @@ router.use(authenticate);
// Get logs - requires permissions or admin
router.get('/', apiLogController.getLogs);
+// Get last scheduler executions
+router.get('/scheduler/last-executions', apiLogController.getLastSchedulerExecutions);
+
// Get single log by ID
router.get('/:id', apiLogController.getLogById);
diff --git a/backend/routes/memberRoutes.js b/backend/routes/memberRoutes.js
index 86a6a00..5549d6f 100644
--- a/backend/routes/memberRoutes.js
+++ b/backend/routes/memberRoutes.js
@@ -1,4 +1,4 @@
-import { getClubMembers, getWaitingApprovals, setClubMembers, uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis, rotateMemberImage, transferMembers } from '../controllers/memberController.js';
+import { getClubMembers, getWaitingApprovals, setClubMembers, uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis, rotateMemberImage, transferMembers, quickUpdateTestMembership, quickUpdateMemberFormHandedOver } from '../controllers/memberController.js';
import express from 'express';
import { authenticate } from '../middleware/authMiddleware.js';
import { authorize } from '../middleware/authorizationMiddleware.js';
@@ -17,5 +17,7 @@ router.get('/notapproved/:id', authenticate, authorize('members', 'read'), getWa
router.post('/update-ratings/:id', authenticate, authorize('mytischtennis', 'write'), updateRatingsFromMyTischtennis);
router.post('/rotate-image/:clubId/:memberId', authenticate, authorize('members', 'write'), rotateMemberImage);
router.post('/transfer/:id', authenticate, authorize('members', 'write'), transferMembers);
+router.post('/quick-update-test-membership/:clubId/:memberId', authenticate, authorize('members', 'write'), quickUpdateTestMembership);
+router.post('/quick-update-member-form/:clubId/:memberId', authenticate, authorize('members', 'write'), quickUpdateMemberFormHandedOver);
export default router;
diff --git a/backend/services/apiLogService.js b/backend/services/apiLogService.js
index 6cae02b..d736d56 100644
--- a/backend/services/apiLogService.js
+++ b/backend/services/apiLogService.js
@@ -184,6 +184,130 @@ class ApiLogService {
throw error;
}
}
+
+ /**
+ * Get last execution info for scheduler jobs
+ */
+ async getLastSchedulerExecutions(clubId = null) {
+ try {
+ const jobTypes = ['rating_updates', 'match_results'];
+ const results = {};
+
+ for (const jobType of jobTypes) {
+ const lastExecution = await ApiLog.findOne({
+ where: {
+ logType: 'scheduler',
+ schedulerJobType: jobType
+ },
+ order: [['createdAt', 'DESC']],
+ attributes: ['id', 'createdAt', 'statusCode', 'responseBody', 'executionTime', 'errorMessage']
+ });
+
+ if (lastExecution) {
+ let parsedResponse = null;
+ let updatedCount = null;
+ let fetchedCount = null;
+ let teamDetails = [];
+
+ // Parse responseBody to extract counts
+ try {
+ if (lastExecution.responseBody) {
+ parsedResponse = JSON.parse(lastExecution.responseBody);
+ // Extract counts from response
+ if (parsedResponse.totalUpdated !== undefined) {
+ updatedCount = parsedResponse.totalUpdated;
+ } else if (parsedResponse.updatedCount !== undefined) {
+ updatedCount = parsedResponse.updatedCount;
+ }
+ if (parsedResponse.totalFetched !== undefined) {
+ fetchedCount = parsedResponse.totalFetched;
+ } else if (parsedResponse.fetchedCount !== undefined) {
+ fetchedCount = parsedResponse.fetchedCount;
+ }
+ }
+ } catch (e) {
+ // Ignore parsing errors
+ }
+
+ // For match_results, try to get team-specific details from API logs
+ if (jobType === 'match_results') {
+ try {
+ // Find all API logs created around the same time as the scheduler execution
+ // (within 5 minutes before and after)
+ const timeWindowStart = new Date(lastExecution.createdAt);
+ timeWindowStart.setMinutes(timeWindowStart.getMinutes() - 5);
+ const timeWindowEnd = new Date(lastExecution.createdAt);
+ timeWindowEnd.setMinutes(timeWindowEnd.getMinutes() + 5);
+
+ const teamLogs = await ApiLog.findAll({
+ where: {
+ logType: 'api_request',
+ schedulerJobType: 'mytischtennis_fetch',
+ createdAt: {
+ [Op.between]: [timeWindowStart, timeWindowEnd]
+ }
+ },
+ order: [['createdAt', 'DESC']],
+ attributes: ['id', 'requestBody', 'statusCode', 'createdAt']
+ });
+
+ // Extract team information from requestBody
+ const teamMap = new Map();
+ for (const log of teamLogs) {
+ try {
+ if (log.requestBody) {
+ const requestData = JSON.parse(log.requestBody);
+ if (requestData.clubTeamId && requestData.teamName) {
+ const teamId = requestData.clubTeamId;
+ if (!teamMap.has(teamId)) {
+ teamMap.set(teamId, {
+ clubTeamId: teamId,
+ teamName: requestData.teamName,
+ success: log.statusCode === 200,
+ lastRun: log.createdAt
+ });
+ }
+ }
+ }
+ } catch (e) {
+ // Ignore parsing errors for individual logs
+ }
+ }
+
+ teamDetails = Array.from(teamMap.values());
+ } catch (e) {
+ console.error('Error extracting team details:', e);
+ }
+ }
+
+ results[jobType] = {
+ lastRun: lastExecution.createdAt,
+ success: lastExecution.statusCode === 200,
+ executionTime: lastExecution.executionTime,
+ updatedCount: updatedCount,
+ fetchedCount: fetchedCount,
+ errorMessage: lastExecution.errorMessage,
+ teamDetails: teamDetails
+ };
+ } else {
+ results[jobType] = {
+ lastRun: null,
+ success: null,
+ executionTime: null,
+ updatedCount: null,
+ fetchedCount: null,
+ errorMessage: null,
+ teamDetails: []
+ };
+ }
+ }
+
+ return results;
+ } catch (error) {
+ console.error('Error getting last scheduler executions:', error);
+ throw error;
+ }
+ }
}
export default new ApiLogService();
diff --git a/backend/services/memberService.js b/backend/services/memberService.js
index 913846b..3bc2e62 100644
--- a/backend/services/memberService.js
+++ b/backend/services/memberService.js
@@ -1,6 +1,8 @@
import UserClub from "../models/UserClub.js";
import { checkAccess, getUserByToken, hasUserClubAccess } from "../utils/userUtils.js";
import Member from "../models/Member.js";
+import Participant from "../models/Participant.js";
+import DiaryDate from "../models/DiaryDates.js";
import path from 'path';
import fs from 'fs';
import sharp from 'sharp';
@@ -492,6 +494,56 @@ class MemberService {
};
}
}
+
+ async quickUpdateTestMembership(userToken, clubId, memberId) {
+ try {
+ await checkAccess(userToken, clubId);
+ const member = await Member.findOne({ where: { id: memberId, clubId: clubId } });
+ if (!member) {
+ return { status: 404, response: { error: 'Member not found in this club' } };
+ }
+
+ if (!member.testMembership) {
+ return { status: 400, response: { error: 'Member is not a test member' } };
+ }
+
+ member.testMembership = false;
+ await member.save();
+
+ return {
+ status: 200,
+ response: { success: true, message: 'Testmitgliedschaft entfernt' }
+ };
+ } catch (error) {
+ console.error('[quickUpdateTestMembership] - Error:', error);
+ return { status: 500, response: { error: 'Failed to update test membership' } };
+ }
+ }
+
+ async quickUpdateMemberFormHandedOver(userToken, clubId, memberId) {
+ try {
+ await checkAccess(userToken, clubId);
+ const member = await Member.findOne({ where: { id: memberId, clubId: clubId } });
+ if (!member) {
+ return { status: 404, response: { error: 'Member not found in this club' } };
+ }
+
+ if (member.memberFormHandedOver) {
+ return { status: 400, response: { error: 'Member form already handed over' } };
+ }
+
+ member.memberFormHandedOver = true;
+ await member.save();
+
+ return {
+ status: 200,
+ response: { success: true, message: 'Mitgliedsformular als ausgehändigt markiert' }
+ };
+ } catch (error) {
+ console.error('[quickUpdateMemberFormHandedOver] - Error:', error);
+ return { status: 500, response: { error: 'Failed to update member form status' } };
+ }
+ }
}
export default new MemberService();
\ No newline at end of file
diff --git a/frontend/src/views/MembersView.vue b/frontend/src/views/MembersView.vue
index d0c2d2c..3a29d84 100644
--- a/frontend/src/views/MembersView.vue
+++ b/frontend/src/views/MembersView.vue
@@ -121,7 +121,8 @@
Geburtsdatum |
Telefon-Nr. |
Email-Adresse |
- |
+ Trainingsteilnahmen |
+ Aktionen |
@@ -137,6 +138,7 @@
{{ genderSymbol(member.gender) }}
+ ⚠️
{{ member.lastName }}, {{ member.firstName }}
inaktiv
@@ -154,9 +156,21 @@
| {{ getFormattedBirthdate(member.birthDate) }} |
{{ member.phone }} |
{{ member.email }} |
+
+ {{ member.trainingParticipations || 0 }}
+ -
+ |
-
-
+
+
+
+
+
+
|
@@ -286,6 +300,10 @@ export default {
return true;
});
+ },
+
+ hasTestMembers() {
+ return this.members.some(member => member.testMembership);
}
},
data() {
@@ -396,6 +414,87 @@ export default {
this.members.forEach(member => {
this.loadMemberImage(member);
});
+
+ // Lade Trainingsteilnahmen für alle Testmitglieder auf einmal über /training-stats
+ await this.loadTrainingParticipations();
+ },
+
+ async loadTrainingParticipations() {
+ try {
+ const response = await apiClient.get(`/training-stats/${this.currentClub}`);
+ const trainingStats = response.data.members || [];
+
+ console.log('[loadTrainingParticipations] Training Stats geladen:', trainingStats.length, 'Mitglieder');
+
+ // Erstelle eine Map für schnellen Zugriff: memberId -> participationTotal
+ // Verwende parseInt, um sicherzustellen, dass IDs als Zahlen verglichen werden
+ const participationMap = new Map();
+ trainingStats.forEach(stat => {
+ const memberId = parseInt(stat.id, 10);
+ participationMap.set(memberId, stat.participationTotal || 0);
+ console.log(`[loadTrainingParticipations] Map gesetzt: ID ${memberId} -> ${stat.participationTotal || 0}`);
+ });
+
+ // Setze Trainingsteilnahmen für alle Testmitglieder
+ const testMembers = this.members.filter(m => m.testMembership);
+ console.log('[loadTrainingParticipations] Testmitglieder gefunden:', testMembers.length);
+
+ testMembers.forEach(member => {
+ const memberId = parseInt(member.id, 10);
+ const count = participationMap.get(memberId);
+ console.log(`[loadTrainingParticipations] Mitglied ${memberId} (${member.firstName} ${member.lastName}): count=${count}`);
+ // Nur setzen, wenn ein Wert gefunden wurde (nicht undefined)
+ if (count !== undefined) {
+ this.$set(member, 'trainingParticipations', count);
+ console.log(`[loadTrainingParticipations] Trainingsteilnahmen gesetzt: ${count}`);
+ } else {
+ // Fallback: 0 setzen, wenn kein Wert gefunden wurde
+ this.$set(member, 'trainingParticipations', 0);
+ console.log(`[loadTrainingParticipations] Kein Wert gefunden, setze 0`);
+ }
+ });
+ } catch (error) {
+ console.error('Fehler beim Laden der Trainingsteilnahmen:', error);
+ // Bei Fehler setze 0 für alle Testmitglieder
+ this.members.forEach(member => {
+ if (member.testMembership) {
+ this.$set(member, 'trainingParticipations', 0);
+ }
+ });
+ }
+ },
+
+ async quickRemoveTestMembership(member) {
+ try {
+ const response = await apiClient.post(`/clubmembers/quick-update-test-membership/${this.currentClub}/${member.id}`);
+ if (response.data.success) {
+ member.testMembership = false;
+ member.trainingParticipations = undefined; // Entferne die Anzeige
+ this.showInfo('Erfolg', response.data.message || 'Testmitgliedschaft entfernt', '', 'success');
+ } else {
+ this.showInfo('Fehler', response.data.error || 'Fehler beim Entfernen der Testmitgliedschaft', '', 'error');
+ }
+ } catch (error) {
+ console.error('Fehler beim Entfernen der Testmitgliedschaft:', error);
+ const errorMessage = error.response?.data?.error || error.message || 'Fehler beim Entfernen der Testmitgliedschaft';
+ this.showInfo('Fehler', errorMessage, '', 'error');
+ }
+ },
+
+ async quickMarkFormHandedOver(member) {
+ try {
+ const response = await apiClient.post(`/clubmembers/quick-update-member-form/${this.currentClub}/${member.id}`);
+ if (response.data.success) {
+ member.memberFormHandedOver = true;
+ this.showInfo('Erfolg', response.data.message || 'Mitgliedsformular als ausgehändigt markiert', '', 'success');
+ } else {
+ this.showInfo('Fehler', response.data.error || 'Fehler beim Markieren des Formulars', '', 'error');
+ }
+ } catch (error) {
+ console.error('Fehler beim Markieren des Formulars:', error);
+ const errorMessage = error.response?.data?.error || error.message || 'Fehler beim Markieren des Formulars';
+ this.showInfo('Fehler', errorMessage, '', 'error');
+ }
},
toggleNewMember() {
this.memberFormIsOpen = !this.memberFormIsOpen;
@@ -1172,4 +1271,33 @@ table td {
.btn-transfer:hover {
background-color: #138496;
}
+
+.action-buttons-row {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ align-items: center;
+}
+
+.btn-quick-action {
+ background-color: #ffc107;
+ color: #000;
+ border: none;
+ padding: 0.4rem 0.8rem;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 0.85em;
+ font-weight: 500;
+ transition: background-color 0.2s ease;
+ white-space: nowrap;
+}
+
+.btn-quick-action:hover {
+ background-color: #e0a800;
+}
+
+.warning-icon {
+ margin-right: 0.25rem;
+ font-size: 1.1em;
+}
diff --git a/frontend/src/views/TeamManagementView.vue b/frontend/src/views/TeamManagementView.vue
index 1283023..0a69c22 100644
--- a/frontend/src/views/TeamManagementView.vue
+++ b/frontend/src/views/TeamManagementView.vue
@@ -8,6 +8,39 @@
:show-current-season="true"
/>
+
+
+
+ 🔄 Rating-Updates:
+
+ Zuletzt: {{ formatJobDate(schedulerJobs.rating_updates.lastRun) }}
+
+ ({{ schedulerJobs.rating_updates.updatedCount }} aktualisiert)
+
+ ⚠️ Fehler
+
+
+
+
+
+
+ {{ team.teamName }}
+ ✓
+ ✗
+
+
+
+
+
@@ -167,6 +200,25 @@
+
+
+
🔄 Automatische Jobs
+
+
+ Zuletzt aktualisiert:
+ {{ formatJobDate(getTeamJobInfo(teamToEdit).lastRun) }}
+
+
+ Status:
+ ✓ Erfolgreich
+ ✗ Fehler
+
+
+
+ Noch keine automatische Aktualisierung
+
+
+
+
+
+
+ 🔄 Automatische Jobs:
+
+
+ {{ formatJobDate(getTeamJobInfo(team).lastRun) }}
+
+ {{ getTeamJobInfo(team).success ? '✓' : '✗' }}
+
+
+ Nie
+
+
@@ -401,6 +467,12 @@ export default {
const loadingStats = ref(false);
const memberById = ref({});
+ // Scheduler Jobs Info
+ const schedulerJobs = ref({
+ rating_updates: null,
+ match_results: null
+ });
+
// Computed
const selectedClub = computed(() => store.state.currentClub);
const authToken = computed(() => store.state.token);
@@ -442,6 +514,9 @@ export default {
// Lade alle Team-Dokumente nach dem Laden der Teams
await loadAllTeamDocuments();
+
+ // Aktualisiere Job-Informationen, damit Team-Filterung korrekt funktioniert
+ await loadSchedulerJobsInfo();
} catch (error) {
console.error('Fehler beim Laden der Club-Teams:', error);
}
@@ -494,12 +569,14 @@ export default {
}
};
- const editTeam = (team) => {
+ const editTeam = async (team) => {
teamToEdit.value = team;
newTeamName.value = team.name;
newLeagueId.value = team.leagueId || '';
teamFormIsOpen.value = true;
- loadTeamDocuments();
+ await loadTeamDocuments();
+ // Aktualisiere Job-Informationen, damit Team-spezifische Daten aktuell sind
+ await loadSchedulerJobsInfo();
};
const deleteTeam = async (team) => {
@@ -568,6 +645,31 @@ export default {
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
+
+ const formatJobDate = (dateString) => {
+ if (!dateString) return 'Nie';
+ const date = new Date(dateString);
+ const now = new Date();
+ const diffMs = now - date;
+ const diffMins = Math.floor(diffMs / 60000);
+ const diffHours = Math.floor(diffMs / 3600000);
+ const diffDays = Math.floor(diffMs / 86400000);
+
+ if (diffMins < 1) return 'gerade eben';
+ if (diffMins < 60) return `vor ${diffMins} Min.`;
+ if (diffHours < 24) return `vor ${diffHours} Std.`;
+ if (diffDays === 1) return 'gestern';
+ if (diffDays < 7) return `vor ${diffDays} Tagen`;
+
+ // Format: DD.MM.YYYY HH:MM
+ return date.toLocaleDateString('de-DE', {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit'
+ });
+ };
const loadTeamDocuments = async () => {
if (!teamToEdit.value) return;
@@ -739,10 +841,54 @@ export default {
loadLeagues();
};
+ // Load scheduler jobs info
+ const loadSchedulerJobsInfo = async () => {
+ try {
+ const clubIdParam = selectedClub.value ? `?clubId=${selectedClub.value}` : '';
+ const response = await apiClient.get(`/logs/scheduler/last-executions${clubIdParam}`);
+ if (response.data.success) {
+ schedulerJobs.value = response.data.data;
+ }
+ } catch (error) {
+ console.error('Fehler beim Laden der Job-Informationen:', error);
+ }
+ };
+
+ // Filter team details to only show teams from current club
+ const getFilteredTeamDetails = (teamDetails) => {
+ if (!teamDetails || !selectedClub.value) return teamDetails;
+ // Filter by checking if team exists in current club's teams
+ return teamDetails.filter(team => {
+ return teams.value.some(t => t.id === team.clubTeamId);
+ });
+ };
+
+ // Get job info for a specific team
+ const getTeamJobInfo = (team) => {
+ if (!team || !team.id || !schedulerJobs.value.match_results?.teamDetails) {
+ return null;
+ }
+
+ const teamDetail = schedulerJobs.value.match_results.teamDetails.find(
+ td => td.clubTeamId === team.id
+ );
+
+ if (!teamDetail) {
+ return null;
+ }
+
+ return {
+ lastRun: teamDetail.lastRun,
+ success: teamDetail.success
+ };
+ };
+
// Lifecycle
onMounted(() => {
// Lade Ligen beim ersten Laden der Seite (ohne Saison-Filter)
loadLeagues();
+ // Lade Job-Informationen
+ loadSchedulerJobsInfo();
});
// PDF-Dialog Funktionen
@@ -1188,7 +1334,12 @@ export default {
getMyTischtennisStatus,
fetchTeamDataManually,
refreshPlayerStats
- ,memberById
+ ,memberById,
+ schedulerJobs,
+ formatJobDate,
+ loadSchedulerJobsInfo,
+ getFilteredTeamDetails,
+ getTeamJobInfo
};
}
};
@@ -2259,4 +2410,170 @@ export default {
color: #2e7d32;
font-size: 0.9rem;
}
+
+/* Scheduler Jobs Info */
+.scheduler-jobs-info {
+ margin-bottom: 1rem;
+ padding: 0.75rem;
+ background: var(--background-light);
+ border-radius: var(--border-radius);
+ border: 1px solid var(--border-color);
+ font-size: 0.875rem;
+}
+
+.job-info {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-bottom: 0.5rem;
+}
+
+.job-info:last-child {
+ margin-bottom: 0;
+}
+
+.job-label {
+ font-weight: 600;
+ color: var(--text-color);
+ min-width: 150px;
+}
+
+.job-details {
+ color: var(--text-muted);
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.job-count {
+ color: var(--primary-color);
+ font-weight: 500;
+}
+
+.job-error {
+ color: #dc3545;
+ font-weight: 500;
+}
+
+.job-header {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.team-details {
+ margin-top: 0.5rem;
+ padding-top: 0.5rem;
+ border-top: 1px solid var(--border-color);
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.team-detail-item {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.8rem;
+ padding: 0.25rem 0.5rem;
+ background: white;
+ border-radius: 3px;
+}
+
+.team-name {
+ flex: 1;
+ color: var(--text-color);
+}
+
+.team-status {
+ font-weight: bold;
+ font-size: 0.9rem;
+}
+
+.team-status.success {
+ color: #28a745;
+}
+
+.team-status.error {
+ color: #dc3545;
+}
+
+/* Team Job Info */
+.team-job-info.compact {
+ margin-top: 0.5rem;
+ padding: 0.5rem 0.75rem;
+ background: var(--background-light);
+ border-radius: 4px;
+ border: 1px solid var(--border-color);
+}
+
+.team-job-details {
+ margin-top: 0.5rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.team-job-item {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.85rem;
+}
+
+.team-job-label {
+ font-weight: 600;
+ color: var(--text-muted);
+ min-width: 120px;
+}
+
+.team-job-value {
+ color: var(--text-color);
+}
+
+.team-job-status {
+ font-weight: 500;
+}
+
+.team-job-status.success {
+ color: #28a745;
+}
+
+.team-job-status.error {
+ color: #dc3545;
+}
+
+.team-job-no-data {
+ color: var(--text-muted);
+ font-size: 0.85rem;
+ font-style: italic;
+}
+
+.team-job-status-row {
+ margin-top: 0.3rem;
+ padding-top: 0.3rem;
+ border-top: 1px solid var(--border-color);
+}
+
+.team-job-status-value {
+ display: flex;
+ align-items: center;
+ gap: 0.3rem;
+ font-size: 0.8rem;
+}
+
+.team-job-status-icon {
+ font-weight: bold;
+ font-size: 0.9rem;
+}
+
+.team-job-status-icon.success {
+ color: #28a745;
+}
+
+.team-job-status-icon.error {
+ color: #dc3545;
+}