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.
This commit is contained in:
Torsten Schulz (local)
2025-11-06 14:25:15 +01:00
parent c9d82827ff
commit f1a29e4111
8 changed files with 675 additions and 8 deletions

View File

@@ -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();

View File

@@ -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 };
export { getClubMembers, getWaitingApprovals, setClubMembers, uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis, rotateMemberImage, transferMembers, quickUpdateTestMembership, quickUpdateMemberFormHandedOver };

View File

@@ -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);

View File

@@ -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;

View File

@@ -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();

View File

@@ -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();

View File

@@ -121,7 +121,8 @@
<th>Geburtsdatum</th>
<th>Telefon-Nr.</th>
<th>Email-Adresse</th>
<th></th>
<th v-if="hasTestMembers">Trainingsteilnahmen</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
@@ -137,6 +138,7 @@
<td>
<span class="gender-symbol" :class="['gender-' + (member.gender || 'unknown'), { 'is-inactive': !member.active }]" :title="labelGender(member.gender)">{{ genderSymbol(member.gender) }}</span>
<span class="gender-name" :class="['gender-' + (member.gender || 'unknown'), { 'is-inactive': !member.active }]">
<span v-if="member.testMembership && member.trainingParticipations >= 5" class="warning-icon" title="5 oder mehr Trainingsteilnahmen"></span>
{{ member.lastName }}, {{ member.firstName }}
<span v-if="!member.active && showInactiveMembers" class="inactive-badge">inaktiv</span>
</span>
@@ -154,9 +156,21 @@
<td>{{ getFormattedBirthdate(member.birthDate) }}</td>
<td>{{ member.phone }}</td>
<td>{{ member.email }}</td>
<td v-if="hasTestMembers">
<span v-if="member.testMembership">{{ member.trainingParticipations || 0 }}</span>
<span v-else>-</span>
</td>
<td>
<button @click.stop="openNotesModal(member)">Notizen</button>
<button @click.stop="openActivitiesModal(member)" class="btn-activities">Übungen</button>
<div class="action-buttons-row">
<button v-if="member.testMembership" @click.stop="quickRemoveTestMembership(member)" class="btn-quick-action" title="Keine Testmitgliedschaft mehr">
Keine Testmitgliedschaft
</button>
<button v-if="!member.memberFormHandedOver" @click.stop="quickMarkFormHandedOver(member)" class="btn-quick-action" title="Mitgliedsformular ausgehändigt">
Formular ausgehändigt
</button>
<button @click.stop="openNotesModal(member)">Notizen</button>
<button @click.stop="openActivitiesModal(member)" class="btn-activities">Übungen</button>
</div>
</td>
</tr>
</template>
@@ -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;
}
</style>

View File

@@ -8,6 +8,39 @@
:show-current-season="true"
/>
<!-- Automatische Jobs Info -->
<div v-if="schedulerJobs.rating_updates || schedulerJobs.match_results" class="scheduler-jobs-info">
<div class="job-info" v-if="schedulerJobs.rating_updates?.lastRun">
<span class="job-label">🔄 Rating-Updates:</span>
<span class="job-details">
Zuletzt: {{ formatJobDate(schedulerJobs.rating_updates.lastRun) }}
<span v-if="schedulerJobs.rating_updates.updatedCount !== null" class="job-count">
({{ schedulerJobs.rating_updates.updatedCount }} aktualisiert)
</span>
<span v-if="!schedulerJobs.rating_updates.success" class="job-error"> Fehler</span>
</span>
</div>
<div class="job-info" v-if="schedulerJobs.match_results?.lastRun">
<div class="job-header">
<span class="job-label">📊 Spielergebnisse:</span>
<span class="job-details">
Zuletzt: {{ formatJobDate(schedulerJobs.match_results.lastRun) }}
<span v-if="schedulerJobs.match_results.fetchedCount !== null" class="job-count">
({{ schedulerJobs.match_results.fetchedCount }} abgerufen)
</span>
<span v-if="!schedulerJobs.match_results.success" class="job-error"> Fehler</span>
</span>
</div>
<div v-if="schedulerJobs.match_results.teamDetails && schedulerJobs.match_results.teamDetails.length > 0" class="team-details">
<div v-for="team in getFilteredTeamDetails(schedulerJobs.match_results.teamDetails)" :key="team.clubTeamId" class="team-detail-item">
<span class="team-name">{{ team.teamName }}</span>
<span v-if="team.success" class="team-status success"></span>
<span v-else class="team-status error"></span>
</div>
</div>
</div>
</div>
<div class="newteam">
<div class="toggle-new-team">
<span @click="toggleNewTeam">
@@ -167,6 +200,25 @@
</table>
</div>
<!-- Automatische Jobs Info für dieses Team -->
<div v-if="getTeamJobInfo(teamToEdit)" class="team-job-info compact">
<span class="section-title">🔄 Automatische Jobs</span>
<div v-if="getTeamJobInfo(teamToEdit).lastRun" class="team-job-details">
<div class="team-job-item">
<span class="team-job-label">Zuletzt aktualisiert:</span>
<span class="team-job-value">{{ formatJobDate(getTeamJobInfo(teamToEdit).lastRun) }}</span>
</div>
<div v-if="getTeamJobInfo(teamToEdit).success !== null" class="team-job-item">
<span class="team-job-label">Status:</span>
<span v-if="getTeamJobInfo(teamToEdit).success" class="team-job-status success"> Erfolgreich</span>
<span v-else class="team-job-status error"> Fehler</span>
</div>
</div>
<div v-else class="team-job-details">
<span class="team-job-no-data">Noch keine automatische Aktualisierung</span>
</div>
</div>
<!-- MyTischtennis URL Konfiguration -->
<div class="mytischtennis-config compact">
<div class="mytischtennis-header-compact">
@@ -261,6 +313,20 @@
Nicht konfiguriert
</span>
</div>
<!-- Automatische Jobs Info für dieses Team -->
<div class="info-row team-job-status-row">
<span class="label">🔄 Automatische Jobs:</span>
<span class="value">
<span v-if="getTeamJobInfo(team) && getTeamJobInfo(team).lastRun" class="team-job-status-value">
{{ formatJobDate(getTeamJobInfo(team).lastRun) }}
<span v-if="getTeamJobInfo(team).success !== null" class="team-job-status-icon" :class="getTeamJobInfo(team).success ? 'success' : 'error'">
{{ getTeamJobInfo(team).success ? '' : '' }}
</span>
</span>
<span v-else class="team-job-no-data">Nie</span>
</span>
</div>
</div>
<!-- PDF-Dokumente Icons -->
@@ -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;
}
</style>