From f1a29e4111731e24022080a0e134361a58c01081 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Thu, 6 Nov 2025 14:25:15 +0100 Subject: [PATCH] Add last scheduler executions endpoint and member quick update functions Implemented a new endpoint in ApiLogController to retrieve the last execution information for scheduler jobs. Added quick update functions in MemberService and corresponding routes for updating test membership status and marking member forms as handed over. Enhanced the MembersView to support quick actions for managing test memberships and form statuses, improving user experience and operational efficiency. --- backend/controllers/apiLogController.js | 17 ++ backend/controllers/memberController.js | 26 +- backend/routes/apiLogRoutes.js | 3 + backend/routes/memberRoutes.js | 4 +- backend/services/apiLogService.js | 124 +++++++++ backend/services/memberService.js | 52 ++++ frontend/src/views/MembersView.vue | 134 ++++++++- frontend/src/views/TeamManagementView.vue | 323 +++++++++++++++++++++- 8 files changed, 675 insertions(+), 8 deletions(-) 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 + +
+
+
+ 📊 Spielergebnisse: + + Zuletzt: {{ formatJobDate(schedulerJobs.match_results.lastRun) }} + + ({{ schedulerJobs.match_results.fetchedCount }} abgerufen) + + ⚠️ Fehler + +
+
+
+ {{ team.teamName }} + + +
+
+
+
+
@@ -167,6 +200,25 @@
+ +
+ 🔄 Automatische Jobs +
+
+ Zuletzt aktualisiert: + {{ formatJobDate(getTeamJobInfo(teamToEdit).lastRun) }} +
+
+ Status: + ✓ Erfolgreich + ✗ Fehler +
+
+
+ Noch keine automatische Aktualisierung +
+
+
@@ -261,6 +313,20 @@ ✗ Nicht konfiguriert
+ + +
+ 🔄 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; +}