Files
Classeo/frontend/src/routes/admin/replacements/+page.svelte
Mathias STRASSER 1db8a7a0b2
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled
fix: Corriger les tests E2E après l'introduction du cache-aside paginé
Le commit 23dd717 a introduit un cache Redis (paginated_queries.cache)
pour les requêtes paginées. Les tests E2E qui modifient les données via
SQL direct (beforeAll, cleanup) contournent la couche applicative et ne
déclenchent pas l'invalidation du cache, provoquant des données obsolètes.

De plus, plusieurs problèmes d'isolation entre tests ont été découverts :
- Les tests classes.spec.ts supprimaient les données d'autres specs via
  DELETE FROM school_classes sans nettoyer les FK dépendantes
- Les tests user-blocking utilisaient des emails partagés entre les
  projets Playwright (chromium/firefox/webkit) exécutés en parallèle,
  causant des race conditions sur l'état du compte utilisateur
- Le handler NotifyTeachersPedagogicalDayHandler s'exécutait de manière
  synchrone, bloquant la réponse HTTP pendant l'envoi des emails
- La sélection d'un enseignant remplaçant effaçait l'autre dropdown car
  {#if} supprimait l'option sélectionnée du DOM

Corrections appliquées :
- Ajout de cache:pool:clear après chaque modification SQL directe
- Nettoyage des FK dépendantes avant les DELETE (classes, subjects)
- Emails uniques par projet navigateur pour éviter les race conditions
- Routage de JourneePedagogiqueAjoutee vers le transport async
- Remplacement de {#if} par disabled sur les selects de remplacement
- Recherche par nom sur la page classes pour gérer la pagination
- Patterns toPass() pour la fiabilité Firefox sur les color pickers
2026-03-01 23:33:42 +01:00

1275 lines
29 KiB
Svelte

<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { getApiBaseUrl } from '$lib/api/config';
import { authenticatedFetch } from '$lib/auth';
import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte';
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
import { untrack } from 'svelte';
// Types
interface TeacherReplacement {
id: string;
replacedTeacherId: string;
replacementTeacherId: string;
startDate: string;
endDate: string;
status: string;
classes: Array<{ classId: string; subjectId: string }>;
reason: string | null;
createdAt: string | null;
endedAt: string | null;
}
interface User {
id: string;
firstName: string;
lastName: string;
email: string;
roles: string[];
}
interface SchoolClass {
id: string;
name: string;
level: string | null;
status: string;
}
interface Subject {
id: string;
name: string;
code: string;
color: string | null;
status: string;
}
interface ClassSubjectPair {
classId: string;
subjectId: string;
}
// State
let replacements = $state<TeacherReplacement[]>([]);
let teachers = $state<User[]>([]);
let classes = $state<SchoolClass[]>([]);
let subjects = $state<Subject[]>([]);
let isLoading = $state(true);
let error = $state<string | null>(null);
let successMessage = $state<string | null>(null);
// Pagination & Search
let currentPage = $state(Number(page.url.searchParams.get('page')) || 1);
let searchTerm = $state(page.url.searchParams.get('search') ?? '');
let totalItems = $state(0);
const itemsPerPage = 30;
let totalPages = $derived(Math.ceil(totalItems / itemsPerPage));
// Create modal
let showCreateModal = $state(false);
let selectedReplacedTeacherId = $state('');
let selectedReplacementTeacherId = $state('');
let selectedStartDate = $state('');
let selectedEndDate = $state('');
let selectedReason = $state('');
let classPairs = $state<ClassSubjectPair[]>([{ classId: '', subjectId: '' }]);
let isSubmitting = $state(false);
// Delete (end) state
let showEndModal = $state(false);
let replacementToEnd = $state<TeacherReplacement | null>(null);
let isEnding = $state(false);
// Validation
let isFormValid = $derived(
selectedReplacedTeacherId !== '' &&
selectedReplacementTeacherId !== '' &&
selectedReplacedTeacherId !== selectedReplacementTeacherId &&
selectedStartDate !== '' &&
selectedEndDate !== '' &&
selectedEndDate >= selectedStartDate &&
classPairs.some((p) => p.classId !== '' && p.subjectId !== '')
);
// Load everything on mount
$effect(() => {
untrack(() => loadAll());
});
async function loadAll() {
try {
isLoading = true;
error = null;
const apiUrl = getApiBaseUrl();
const [teachersRes, classesRes, subjectsRes] = await Promise.all([
authenticatedFetch(`${apiUrl}/users?role=ROLE_PROF&itemsPerPage=100`),
authenticatedFetch(`${apiUrl}/classes?itemsPerPage=100`),
authenticatedFetch(`${apiUrl}/subjects?itemsPerPage=100`)
]);
if (!teachersRes.ok) throw new Error('Erreur lors du chargement des enseignants');
if (!classesRes.ok) throw new Error('Erreur lors du chargement des classes');
if (!subjectsRes.ok) throw new Error('Erreur lors du chargement des matières');
const [teachersData, classesData, subjectsData] = await Promise.all([
teachersRes.json(),
classesRes.json(),
subjectsRes.json()
]);
teachers = extractCollection(teachersData);
classes = extractCollection(classesData);
subjects = extractCollection(subjectsData);
await loadReplacements();
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
isLoading = false;
}
}
let replacementsAbortController: AbortController | null = null;
async function loadReplacements() {
replacementsAbortController?.abort();
const controller = new AbortController();
replacementsAbortController = controller;
const apiUrl = getApiBaseUrl();
const params = new URLSearchParams();
params.set('page', String(currentPage));
params.set('itemsPerPage', String(itemsPerPage));
if (searchTerm) params.set('search', searchTerm);
const response = await authenticatedFetch(
`${apiUrl}/teacher-replacements?${params.toString()}`,
{ signal: controller.signal }
);
if (controller.signal.aborted) return;
if (!response.ok) {
throw new Error('Erreur lors du chargement des remplacements');
}
const data = await response.json();
replacements = data['hydra:member'] ?? data['member'] ?? [];
totalItems = data['hydra:totalItems'] ?? data['totalItems'] ?? replacements.length;
}
function extractCollection<T>(data: Record<string, unknown>): T[] {
const hydra = data['hydra:member'];
if (Array.isArray(hydra)) return hydra as T[];
const member = data['member'];
if (Array.isArray(member)) return member as T[];
if (Array.isArray(data)) return data as T[];
return [];
}
function getTeacherName(teacherId: string): string {
const teacher = teachers.find((t) => t.id === teacherId);
return teacher ? `${teacher.firstName} ${teacher.lastName}` : teacherId;
}
function getClassName(classId: string): string {
const cls = classes.find((c) => c.id === classId);
return cls?.name ?? classId;
}
function getSubjectName(subjectId: string): string {
const subject = subjects.find((s) => s.id === subjectId);
return subject?.name ?? subjectId;
}
function getSubjectColor(subjectId: string): string | null {
const subject = subjects.find((s) => s.id === subjectId);
return subject?.color ?? null;
}
function daysRemaining(endDate: string): number {
const end = new Date(endDate);
const now = new Date();
now.setHours(0, 0, 0, 0);
end.setHours(0, 0, 0, 0);
return Math.ceil((end.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
}
function updateUrl() {
const params = new URLSearchParams();
if (currentPage > 1) params.set('page', String(currentPage));
if (searchTerm) params.set('search', searchTerm);
const query = params.toString();
goto(`?${query}`, { replaceState: true, noScroll: true, keepFocus: true });
}
function handleSearch(value: string) {
searchTerm = value;
currentPage = 1;
updateUrl();
reloadReplacements();
}
function handlePageChange(newPage: number) {
currentPage = newPage;
updateUrl();
reloadReplacements();
}
async function reloadReplacements() {
try {
isLoading = true;
error = null;
await loadReplacements();
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
isLoading = false;
}
}
function openCreateModal() {
showCreateModal = true;
selectedReplacedTeacherId = '';
selectedReplacementTeacherId = '';
selectedStartDate = '';
selectedEndDate = '';
selectedReason = '';
classPairs = [{ classId: '', subjectId: '' }];
error = null;
}
function closeCreateModal() {
showCreateModal = false;
}
function addClassPair() {
classPairs = [...classPairs, { classId: '', subjectId: '' }];
}
function removeClassPair(index: number) {
classPairs = classPairs.filter((_, i) => i !== index);
}
async function handleCreate() {
if (!isFormValid) return;
const validPairs = classPairs.filter((p) => p.classId !== '' && p.subjectId !== '');
try {
isSubmitting = true;
error = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/teacher-replacements`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
replacedTeacherId: selectedReplacedTeacherId,
replacementTeacherId: selectedReplacementTeacherId,
startDate: selectedStartDate,
endDate: selectedEndDate,
classes: validPairs,
reason: selectedReason || null
})
});
if (!response.ok) {
const data = await response.json().catch(() => null);
const message =
data?.['hydra:description'] ??
data?.message ??
data?.detail ??
`Erreur (${response.status})`;
throw new Error(message);
}
successMessage = 'Remplacement créé avec succès';
closeCreateModal();
await reloadReplacements();
globalThis.setTimeout(() => {
successMessage = null;
}, 3000);
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur lors de la création';
} finally {
isSubmitting = false;
}
}
function openEndModal(replacement: TeacherReplacement) {
replacementToEnd = replacement;
showEndModal = true;
}
function closeEndModal() {
showEndModal = false;
replacementToEnd = null;
}
async function handleEnd() {
if (!replacementToEnd) return;
try {
isEnding = true;
error = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(
`${apiUrl}/teacher-replacements/${replacementToEnd.id}`,
{ method: 'DELETE' }
);
if (!response.ok && response.status !== 204) {
const data = await response.json().catch(() => null);
const message =
data?.['hydra:description'] ??
data?.message ??
data?.detail ??
`Erreur (${response.status})`;
throw new Error(message);
}
replacements = replacements.filter((r) => r.id !== replacementToEnd!.id);
totalItems = Math.max(0, totalItems - 1);
if (replacements.length === 0 && currentPage > 1) {
currentPage -= 1;
updateUrl();
reloadReplacements();
}
successMessage = 'Remplacement terminé avec succès';
closeEndModal();
globalThis.setTimeout(() => {
successMessage = null;
}, 3000);
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur lors de la terminaison';
} finally {
isEnding = false;
}
}
</script>
<svelte:head>
<title>Remplacements enseignants - Classeo</title>
</svelte:head>
<div class="replacements-page">
<header class="page-header">
<div class="header-content">
<h1>Remplacements enseignants</h1>
<p class="subtitle">Désignez des remplaçants pour les enseignants absents</p>
</div>
<button class="btn-primary" onclick={openCreateModal}>
<span class="btn-icon">+</span>
Nouveau remplacement
</button>
</header>
{#if error}
<div class="alert alert-error">
<span class="alert-icon">!</span>
{error}
<button class="alert-close" onclick={() => (error = null)}>x</button>
</div>
{/if}
{#if successMessage}
<div class="alert alert-success">
{successMessage}
</div>
{/if}
<SearchInput
value={searchTerm}
onSearch={handleSearch}
placeholder="Rechercher par enseignant..."
/>
{#if isLoading}
<div class="loading-state" aria-live="polite" role="status">
<div class="spinner"></div>
<p>Chargement des remplacements...</p>
</div>
{:else if replacements.length === 0}
<div class="empty-state">
<span class="empty-icon">&#x1F504;</span>
{#if searchTerm}
<h2>Aucun résultat</h2>
<p>Aucun remplacement ne correspond à votre recherche</p>
<button class="btn-secondary" onclick={() => handleSearch('')}>Effacer la recherche</button>
{:else}
<h2>Aucun remplacement actif</h2>
<p>Désignez un remplaçant pour un enseignant absent</p>
<button class="btn-primary" onclick={openCreateModal}>Nouveau remplacement</button>
{/if}
</div>
{:else}
<div class="table-container">
<table class="replacements-table">
<thead>
<tr>
<th>Enseignant remplacé</th>
<th>Remplaçant</th>
<th>Classes / Matières</th>
<th>Période</th>
<th>Statut</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each replacements as replacement (replacement.id)}
{@const days = daysRemaining(replacement.endDate)}
<tr>
<td data-label="Remplacé" class="teacher-cell">
<span class="teacher-name">{getTeacherName(replacement.replacedTeacherId)}</span>
</td>
<td data-label="Remplaçant" class="teacher-cell">
<span class="teacher-name">{getTeacherName(replacement.replacementTeacherId)}</span>
</td>
<td data-label="Classes / Matières" class="classes-cell">
{#each replacement.classes as pair}
{@const color = getSubjectColor(pair.subjectId)}
<span class="class-pair">
<span class="class-name">{getClassName(pair.classId)}</span>
{#if color}
<span
class="subject-badge"
style="background-color: {color}; color: white"
>
{getSubjectName(pair.subjectId)}
</span>
{:else}
<span class="subject-badge subject-badge-default">
{getSubjectName(pair.subjectId)}
</span>
{/if}
</span>
{/each}
</td>
<td data-label="Période" class="date-cell">
<div class="date-range">
{new Date(replacement.startDate).toLocaleDateString('fr-FR')}
&rarr;
{new Date(replacement.endDate).toLocaleDateString('fr-FR')}
</div>
{#if replacement.status === 'active'}
<span
class="countdown"
class:countdown-warning={days <= 3}
class:countdown-urgent={days <= 1}
>
{#if days > 1}
{days} jours restants
{:else if days === 1}
1 jour restant
{:else if days === 0}
Dernier jour
{:else}
Expiré
{/if}
</span>
{/if}
</td>
<td data-label="Statut">
{#if replacement.status === 'active'}
<span class="status-badge status-active">Actif</span>
{:else}
<span class="status-badge status-ended">Terminé</span>
{/if}
</td>
<td data-label="Actions" class="actions-cell">
{#if replacement.status === 'active'}
<button
class="btn-remove"
onclick={() => openEndModal(replacement)}
>
Terminer
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<Pagination {currentPage} {totalPages} onPageChange={handlePageChange} />
{/if}
</div>
<!-- Create Modal -->
{#if showCreateModal}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={closeCreateModal} role="presentation">
<div
class="modal modal-large"
role="dialog"
aria-modal="true"
aria-labelledby="create-modal-title"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') closeCreateModal(); }}
>
<header class="modal-header">
<h2 id="create-modal-title">Nouveau remplacement</h2>
<button class="modal-close" onclick={closeCreateModal} aria-label="Fermer">x</button>
</header>
<form
class="modal-body"
onsubmit={(e) => {
e.preventDefault();
handleCreate();
}}
>
<div class="form-row">
<div class="form-group">
<label for="replaced-teacher">Enseignant remplacé *</label>
<select id="replaced-teacher" bind:value={selectedReplacedTeacherId} required>
<option value="">-- Sélectionner --</option>
{#each teachers as teacher (teacher.id)}
<option value={teacher.id} disabled={teacher.id === selectedReplacementTeacherId}>
{teacher.firstName} {teacher.lastName}
</option>
{/each}
</select>
</div>
<div class="form-group">
<label for="replacement-teacher">Remplaçant *</label>
<select id="replacement-teacher" bind:value={selectedReplacementTeacherId} required>
<option value="">-- Sélectionner --</option>
{#each teachers as teacher (teacher.id)}
<option value={teacher.id} disabled={teacher.id === selectedReplacedTeacherId}>
{teacher.firstName} {teacher.lastName}
</option>
{/each}
</select>
</div>
</div>
{#if selectedReplacedTeacherId && selectedReplacementTeacherId && selectedReplacedTeacherId === selectedReplacementTeacherId}
<div class="form-error">
L'enseignant remplacé et le remplaçant doivent être différents.
</div>
{/if}
<div class="form-row">
<div class="form-group">
<label for="start-date">Date de début *</label>
<input type="date" id="start-date" bind:value={selectedStartDate} required />
</div>
<div class="form-group">
<label for="end-date">Date de fin *</label>
<input type="date" id="end-date" bind:value={selectedEndDate} min={selectedStartDate} required />
</div>
</div>
{#if selectedStartDate && selectedEndDate && selectedEndDate < selectedStartDate}
<div class="form-error">
La date de fin doit être postérieure à la date de début.
</div>
{/if}
<div class="form-group" role="group" aria-labelledby="classes-label">
<span id="classes-label" class="form-label">Classes et matières concernées *</span>
{#each classPairs as pair, index}
<div class="class-pair-row">
<select bind:value={pair.classId} required>
<option value="">-- Classe --</option>
{#each classes as cls (cls.id)}
<option value={cls.id}>{cls.name}{cls.level ? ` (${cls.level})` : ''}</option>
{/each}
</select>
<select bind:value={pair.subjectId} required>
<option value="">-- Matière --</option>
{#each subjects as subject (subject.id)}
<option value={subject.id}>{subject.name} ({subject.code})</option>
{/each}
</select>
{#if classPairs.length > 1}
<button type="button" class="btn-remove-pair" onclick={() => removeClassPair(index)} aria-label="Retirer cette classe/matière">
x
</button>
{/if}
</div>
{/each}
<button type="button" class="btn-add-pair" onclick={addClassPair}>
+ Ajouter une classe/matière
</button>
</div>
<div class="form-group">
<label for="reason">Motif (optionnel)</label>
<input type="text" id="reason" bind:value={selectedReason} placeholder="Ex: Congé maladie" />
</div>
<div class="form-hint">
Le remplaçant pourra saisir de nouvelles notes et consulter les notes existantes en lecture seule.
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick={closeCreateModal} disabled={isSubmitting}>
Annuler
</button>
<button
type="submit"
class="btn-primary"
disabled={isSubmitting || !isFormValid}
>
{#if isSubmitting}
Création...
{:else}
Désigner le remplaçant
{/if}
</button>
</div>
</form>
</div>
</div>
{/if}
<!-- End Replacement Confirmation Modal -->
{#if showEndModal && replacementToEnd}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={closeEndModal} role="presentation">
<div
class="modal modal-confirm"
role="alertdialog"
aria-modal="true"
aria-labelledby="end-modal-title"
aria-describedby="end-modal-description"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') closeEndModal(); }}
>
<header class="modal-header modal-header-danger">
<h2 id="end-modal-title">Terminer le remplacement</h2>
<button class="modal-close" onclick={closeEndModal} aria-label="Fermer">x</button>
</header>
<div class="modal-body">
<p id="end-modal-description">
Terminer le remplacement de <strong>{getTeacherName(replacementToEnd.replacedTeacherId)}</strong>
par <strong>{getTeacherName(replacementToEnd.replacementTeacherId)}</strong> ?
</p>
<p class="delete-warning">
Le remplaçant perdra immédiatement l'accès aux classes concernées.
</p>
</div>
<div class="modal-actions modal-actions-padded">
<button type="button" class="btn-secondary" onclick={closeEndModal} disabled={isEnding}>
Annuler
</button>
<button type="button" class="btn-danger" onclick={handleEnd} disabled={isEnding}>
{#if isEnding}
Terminaison...
{:else}
Terminer
{/if}
</button>
</div>
</div>
</div>
{/if}
<style>
.replacements-page {
padding: 1.5rem;
max-width: 1200px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.header-content h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.subtitle {
margin: 0.25rem 0 0;
color: #6b7280;
font-size: 0.875rem;
}
/* Buttons */
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: var(--btn-primary-bg, #3b82f6);
color: white;
border: none;
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-primary:hover:not(:disabled) {
background: var(--btn-primary-hover-bg, #2563eb);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
padding: 0.5rem 1rem;
background: white;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-secondary:hover:not(:disabled) {
background: #f3f4f6;
}
.btn-danger {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: #dc2626;
color: white;
border: none;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.btn-danger:hover:not(:disabled) {
background: #b91c1c;
}
.btn-danger:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-remove {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
border-radius: 0.25rem;
cursor: pointer;
transition: background 0.15s;
}
.btn-remove:hover {
background: #fee2e2;
}
.btn-icon {
font-size: 1.25rem;
line-height: 1;
}
/* Alerts */
.alert {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.alert-error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
}
.alert-success {
background: #f0fdf4;
border: 1px solid #bbf7d0;
color: #16a34a;
}
.alert-icon {
flex-shrink: 0;
font-weight: bold;
}
.alert-close {
margin-left: auto;
padding: 0.25rem 0.5rem;
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
opacity: 0.6;
}
.alert-close:hover {
opacity: 1;
}
/* Loading & Empty */
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
text-align: center;
background: white;
border-radius: 0.75rem;
border: 2px dashed #e5e7eb;
}
.spinner {
width: 2rem;
height: 2rem;
border: 3px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.empty-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.empty-state h2 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
color: #1f2937;
}
.empty-state p {
margin: 0 0 1.5rem;
color: #6b7280;
}
/* Table */
.table-container {
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
overflow-x: auto;
}
.replacements-table {
width: 100%;
border-collapse: collapse;
}
.replacements-table th {
text-align: left;
padding: 0.75rem 1rem;
font-size: 0.75rem;
font-weight: 600;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid #e5e7eb;
background: #f9fafb;
}
.replacements-table td {
padding: 0.75rem 1rem;
font-size: 0.875rem;
color: #374151;
border-bottom: 1px solid #f3f4f6;
vertical-align: top;
}
.replacements-table tr:last-child td {
border-bottom: none;
}
.replacements-table tr:hover td {
background: #f9fafb;
}
.teacher-cell {
white-space: nowrap;
}
.teacher-name {
font-weight: 500;
color: #1f2937;
}
.classes-cell {
min-width: 200px;
}
.class-pair {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin: 0.125rem 0.25rem 0.125rem 0;
}
.class-name {
font-size: 0.8125rem;
color: #4b5563;
}
.subject-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: 500;
}
.subject-badge-default {
background: #f3f4f6;
color: #374151;
}
.date-cell {
white-space: nowrap;
}
.date-range {
color: #374151;
font-size: 0.8125rem;
}
.countdown {
display: block;
margin-top: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
color: #16a34a;
}
.countdown-warning {
color: #d97706;
}
.countdown-urgent {
color: #dc2626;
}
.status-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.status-active {
background: #f0fdf4;
color: #16a34a;
}
.status-ended {
background: #f3f4f6;
color: #6b7280;
}
.actions-cell {
white-space: nowrap;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 100;
}
.modal {
background: white;
border-radius: 0.75rem;
width: 100%;
max-width: 28rem;
max-height: 90vh;
overflow: auto;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
.modal-large {
max-width: 36rem;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
}
.modal-close {
padding: 0.25rem 0.5rem;
background: none;
border: none;
font-size: 1.5rem;
line-height: 1;
color: #6b7280;
cursor: pointer;
}
.modal-close:hover {
color: #1f2937;
}
.modal-body {
padding: 1.5rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label,
.form-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #374151;
}
.form-group select,
.form-group input {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
transition: border-color 0.2s;
background: white;
}
.form-group select:focus,
.form-group input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.form-error {
padding: 0.5rem 0.75rem;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 0.375rem;
color: #dc2626;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.class-pair-row {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 0.5rem;
margin-bottom: 0.5rem;
align-items: center;
}
.class-pair-row select {
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
background: white;
}
.class-pair-row select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.btn-remove-pair {
padding: 0.375rem 0.5rem;
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
line-height: 1;
}
.btn-remove-pair:hover {
background: #fee2e2;
}
.btn-add-pair {
padding: 0.5rem 0.75rem;
background: none;
border: 1px dashed #d1d5db;
border-radius: 0.375rem;
color: #6b7280;
cursor: pointer;
font-size: 0.875rem;
width: 100%;
transition: all 0.2s;
}
.btn-add-pair:hover {
border-color: #3b82f6;
color: #3b82f6;
background: #eff6ff;
}
.form-hint {
padding: 0.75rem 1rem;
background: #eff6ff;
border-radius: 0.375rem;
font-size: 0.875rem;
color: #3b82f6;
margin-bottom: 1.25rem;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
}
.modal-actions-padded {
padding: 1rem 1.5rem;
}
/* End confirmation modal */
.modal-confirm {
max-width: 24rem;
}
.modal-header-danger {
background: #fef2f2;
border-bottom-color: #fecaca;
}
.modal-header-danger h2 {
color: #dc2626;
}
.delete-warning {
margin: 0.75rem 0 0;
font-size: 0.875rem;
color: #6b7280;
}
@media (max-width: 640px) {
.form-row {
grid-template-columns: 1fr;
}
.class-pair-row {
grid-template-columns: 1fr;
}
}
@media (max-width: 767px) {
.table-container {
background: transparent;
border: none;
}
.replacements-table thead {
display: none;
}
.replacements-table tbody tr {
display: block;
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
margin-bottom: 0.75rem;
padding: 0.25rem 0;
}
.replacements-table tr:hover td {
background: transparent;
}
.replacements-table td {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 0.5rem 1rem;
border-bottom: none;
text-align: right;
}
.replacements-table td::before {
content: attr(data-label);
font-weight: 600;
font-size: 0.75rem;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
text-align: left;
margin-right: 1rem;
flex-shrink: 0;
}
.teacher-cell {
white-space: normal;
}
.classes-cell {
min-width: 0;
}
.date-cell {
white-space: normal;
}
.actions-cell {
justify-content: flex-end;
padding-top: 0.75rem;
border-top: 1px solid #f3f4f6;
}
}
</style>