feat: Gestion des périodes scolaires

L'administration d'un établissement nécessite de découper l'année
scolaire en trimestres ou semestres avant de pouvoir saisir les notes
et générer les bulletins.

Ce module permet de configurer les périodes par année scolaire
(current/previous/next résolus en UUID v5 déterministes), de modifier
les dates individuelles avec validation anti-chevauchement, et de
consulter la période en cours avec le décompte des jours restants.

Les dates par défaut de février s'adaptent aux années bissextiles.
Le repository utilise UPSERT transactionnel pour garantir l'intégrité
lors du changement de mode (trimestres ↔ semestres). Les domain events
de Subject sont étendus pour couvrir toutes les mutations (code,
couleur, description) en plus du renommage.
This commit is contained in:
2026-02-06 12:00:29 +01:00
parent 0d5a097c4c
commit f19d0ae3ef
69 changed files with 5201 additions and 121 deletions

View File

@@ -26,6 +26,7 @@
// Determine which admin section is active
const isClassesActive = $derived(page.url.pathname.startsWith('/admin/classes'));
const isSubjectsActive = $derived(page.url.pathname.startsWith('/admin/subjects'));
const isPeriodsActive = $derived(page.url.pathname.startsWith('/admin/academic-year/periods'));
</script>
<div class="admin-layout">
@@ -38,6 +39,7 @@
<a href="/dashboard" class="nav-link">Tableau de bord</a>
<a href="/admin/classes" class="nav-link" class:active={isClassesActive}>Classes</a>
<a href="/admin/subjects" class="nav-link" class:active={isSubjectsActive}>Matières</a>
<a href="/admin/academic-year/periods" class="nav-link" class:active={isPeriodsActive}>Périodes</a>
<button class="nav-button" onclick={goSettings}>Paramètres</button>
<button class="logout-button" onclick={handleLogout} disabled={isLoggingOut}>
{#if isLoggingOut}

View File

@@ -0,0 +1,171 @@
<script lang="ts">
import { getApiBaseUrl } from '$lib/api/config';
import { authenticatedFetch } from '$lib/auth/auth.svelte';
let classCount = $state<number | null>(null);
let subjectCount = $state<number | null>(null);
$effect(() => {
loadStats();
});
async function loadStats() {
const base = getApiBaseUrl();
const [classesRes, subjectsRes] = await Promise.allSettled([
authenticatedFetch(`${base}/classes`),
authenticatedFetch(`${base}/subjects`)
]);
if (classesRes.status === 'fulfilled' && classesRes.value.ok) {
const data = await classesRes.value.json();
classCount = Array.isArray(data) ? data.length : (data['hydra:totalItems'] ?? null);
}
if (subjectsRes.status === 'fulfilled' && subjectsRes.value.ok) {
const data = await subjectsRes.value.json();
subjectCount = Array.isArray(data) ? data.length : (data['hydra:totalItems'] ?? null);
}
}
</script>
<svelte:head>
<title>Administration - Classeo</title>
</svelte:head>
<div class="admin-dashboard">
<header class="page-header">
<h1>Administration</h1>
<p class="subtitle">Configurez votre établissement</p>
</header>
<div class="stats-row">
<div class="stat-card">
<span class="stat-value">{classCount ?? ''}</span>
<span class="stat-label">Classes</span>
</div>
<div class="stat-card">
<span class="stat-value">{subjectCount ?? ''}</span>
<span class="stat-label">Matières</span>
</div>
</div>
<div class="action-cards">
<a class="action-card" href="/admin/classes">
<span class="action-icon">🏫</span>
<span class="action-label">Classes</span>
<span class="action-hint">Créer et gérer les classes</span>
</a>
<a class="action-card" href="/admin/subjects">
<span class="action-icon">📚</span>
<span class="action-label">Matières</span>
<span class="action-hint">Créer et gérer les matières</span>
</a>
<a class="action-card" href="/admin/academic-year/periods">
<span class="action-icon">📅</span>
<span class="action-label">Périodes scolaires</span>
<span class="action-hint">Trimestres ou semestres</span>
</a>
</div>
</div>
<style>
.admin-dashboard {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.page-header h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary, #1f2937);
}
.subtitle {
margin: 0.25rem 0 0;
color: var(--text-secondary, #64748b);
font-size: 0.875rem;
}
.stats-row {
display: flex;
gap: 1rem;
}
.stat-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 1rem 1.5rem;
background: var(--surface-elevated, #fff);
border: 1px solid var(--border-subtle, #e2e8f0);
border-radius: 0.75rem;
min-width: 100px;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary, #1f2937);
}
.stat-label {
font-size: 0.75rem;
color: var(--text-secondary, #64748b);
}
.action-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.action-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1.5rem 1rem;
background: var(--surface-elevated, #fff);
border: 2px solid var(--border-subtle, #e2e8f0);
border-radius: 0.75rem;
text-decoration: none;
color: inherit;
transition: all 0.2s;
}
.action-card:hover {
border-color: var(--accent-primary, #0ea5e9);
background: var(--accent-primary-light, #e0f2fe);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.action-icon {
font-size: 2rem;
}
.action-label {
font-weight: 600;
color: var(--text-primary, #374151);
}
.action-hint {
font-size: 0.75rem;
color: var(--text-secondary, #6b7280);
text-align: center;
}
@media (max-width: 640px) {
.stats-row {
flex-wrap: wrap;
}
.stat-card {
flex: 1;
}
}
</style>

View File

@@ -0,0 +1,958 @@
<script lang="ts">
import { getApiBaseUrl } from '$lib/api/config';
import { authenticatedFetch } from '$lib/auth';
// Types
interface Period {
sequence: number;
label: string;
startDate: string;
endDate: string;
isCurrent: boolean;
daysRemaining: number;
isPast: boolean;
}
interface PeriodsConfig {
type: string;
periods: Period[];
currentPeriod: Period | null;
}
// State
let config = $state<PeriodsConfig | null>(null);
let isLoading = $state(true);
let error = $state<string | null>(null);
let isSubmitting = $state(false);
let showConfigureModal = $state(false);
let showEditModal = $state(false);
let showImpactWarning = $state(false);
// Configure form state
let selectedType = $state<string>('trimester');
// Edit form state
let editingPeriod = $state<Period | null>(null);
let editStartDate = $state('');
let editEndDate = $state('');
// Academic year selector
type YearKey = 'previous' | 'current' | 'next';
const yearOptions: { key: YearKey; offset: number }[] = [
{ key: 'previous', offset: -1 },
{ key: 'current', offset: 0 },
{ key: 'next', offset: 1 }
];
let selectedYear = $state<YearKey>('current');
let academicYearId = $derived(selectedYear);
function baseStartYear(): number {
const now = new Date();
return now.getMonth() >= 8 ? now.getFullYear() : now.getFullYear() - 1;
}
function schoolYearLabel(offset: number): string {
const sy = baseStartYear() + offset;
return `${sy}-${sy + 1}`;
}
let startYear = $derived(
baseStartYear() + ({ previous: -1, current: 0, next: 1 })[selectedYear]
);
// Derived
let hasConfig = $derived(config !== null && config.periods.length > 0);
// Reload when year changes
$effect(() => {
void selectedYear; // Track dependency to re-run on change
loadPeriods();
});
async function loadPeriods() {
try {
isLoading = true;
error = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(
`${apiUrl}/academic-years/${academicYearId}/periods`
);
if (response.status === 404 || response.status === 204) {
config = null;
return;
}
if (!response.ok) {
throw new Error('Erreur lors du chargement des périodes');
}
const data = await response.json();
config = data;
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
config = null;
} finally {
isLoading = false;
}
}
async function handleConfigure() {
try {
isSubmitting = true;
error = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(
`${apiUrl}/academic-years/${academicYearId}/periods`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
periodType: selectedType,
startYear
})
}
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(
errorData['hydra:description'] || errorData.message || 'Erreur lors de la configuration'
);
}
await loadPeriods();
showConfigureModal = false;
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
isSubmitting = false;
}
}
function openEditModal(period: Period) {
editingPeriod = period;
editStartDate = period.startDate;
editEndDate = period.endDate;
showImpactWarning = false;
showEditModal = true;
}
function closeEditModal() {
showEditModal = false;
editingPeriod = null;
}
async function handleUpdatePeriod(confirmImpact = false) {
if (!editingPeriod) return;
try {
isSubmitting = true;
error = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(
`${apiUrl}/academic-years/${academicYearId}/periods/${editingPeriod.sequence}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/merge-patch+json' },
body: JSON.stringify({
startDate: editStartDate,
endDate: editEndDate,
confirmImpact
})
}
);
if (response.status === 409) {
// Période avec notes : afficher l'avertissement
showImpactWarning = true;
return;
}
if (!response.ok) {
const errorData = await response.json();
throw new Error(
errorData['hydra:description'] || errorData.message || 'Erreur lors de la modification'
);
}
await loadPeriods();
closeEditModal();
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
isSubmitting = false;
}
}
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric'
});
}
function typeLabel(type: string): string {
return type === 'trimester' ? 'Trimestres' : 'Semestres';
}
</script>
<svelte:head>
<title>Périodes scolaires - Classeo</title>
</svelte:head>
<div class="periods-page">
<header class="page-header">
<div class="header-content">
<h1>Périodes scolaires</h1>
<p class="subtitle">Configurez le découpage de l'année en trimestres ou semestres</p>
</div>
<div class="header-actions">
<div class="year-selector" role="tablist" aria-label="Année scolaire">
{#each yearOptions as { key, offset } (key)}
<button
role="tab"
class="year-tab"
class:year-tab-active={selectedYear === key}
aria-selected={selectedYear === key}
onclick={() => (selectedYear = key as YearKey)}
>
{schoolYearLabel(offset)}
</button>
{/each}
</div>
</div>
</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 isLoading}
<div class="loading-state">
<div class="spinner"></div>
<p>Chargement des périodes...</p>
</div>
{:else if !hasConfig}
<div class="empty-state">
<span class="empty-icon">&#128197;</span>
<h2>Aucune période configurée</h2>
<p>Choisissez entre trimestres (3 périodes) ou semestres (2 périodes)</p>
<button class="btn-primary" onclick={() => (showConfigureModal = true)}>
Configurer les périodes
</button>
</div>
{:else if config}
<!-- Current period banner -->
{#if config.currentPeriod}
<div class="current-period-banner">
<div class="banner-content">
<span class="banner-label">Période actuelle</span>
<span class="banner-period">{config.currentPeriod.label}</span>
</div>
<div class="banner-countdown">
<span class="countdown-number">{config.currentPeriod.daysRemaining}</span>
<span class="countdown-label">jours restants</span>
</div>
</div>
{/if}
<!-- Period type info -->
<div class="config-info">
<span class="config-badge">{typeLabel(config.type)}</span>
</div>
<!-- Periods list -->
<div class="periods-list">
{#each config.periods as period (period.sequence)}
<div class="period-card" class:period-current={period.isCurrent} class:period-past={period.isPast}>
<div class="period-header">
<h3 class="period-label">{period.label}</h3>
{#if period.isCurrent}
<span class="badge badge-current">En cours</span>
{:else if period.isPast}
<span class="badge badge-past">Terminée</span>
{:else}
<span class="badge badge-future">A venir</span>
{/if}
</div>
<div class="period-dates">
<div class="date-item">
<span class="date-label">Début</span>
<span class="date-value">{formatDate(period.startDate)}</span>
</div>
<div class="date-item">
<span class="date-label">Fin</span>
<span class="date-value">{formatDate(period.endDate)}</span>
</div>
</div>
{#if period.isCurrent}
<div class="period-progress">
<div class="progress-info">
<span>{period.daysRemaining} jours restants</span>
</div>
</div>
{/if}
<div class="period-actions">
<button class="btn-secondary btn-sm" onclick={() => openEditModal(period)}>
Modifier les dates
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Configure Modal -->
{#if showConfigureModal}
<div class="modal-overlay" onclick={() => (showConfigureModal = false)} role="presentation">
<div
class="modal"
onclick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="configure-title"
>
<header class="modal-header">
<h2 id="configure-title">Configurer les périodes</h2>
<button
class="modal-close"
onclick={() => (showConfigureModal = false)}
aria-label="Fermer">x</button
>
</header>
<form
class="modal-body"
onsubmit={(e) => {
e.preventDefault();
handleConfigure();
}}
>
<div class="form-group">
<label for="period-type">Mode de découpage *</label>
<select id="period-type" bind:value={selectedType}>
<option value="trimester">Trimestres (3 périodes)</option>
<option value="semester">Semestres (2 périodes)</option>
</select>
</div>
<p class="form-help" style="margin-bottom: 1rem;">
Année scolaire : <strong>{startYear}-{startYear + 1}</strong>
</p>
<div class="type-preview">
{#if selectedType === 'trimester'}
<p>
<strong>T1 :</strong> 1er sept. {startYear} - 30 nov. {startYear}
</p>
<p>
<strong>T2 :</strong> 1er déc. {startYear} - 28 fév. {startYear + 1}
</p>
<p>
<strong>T3 :</strong> 1er mars {startYear + 1} - 30 juin {startYear + 1}
</p>
{:else}
<p>
<strong>S1 :</strong> 1er sept. {startYear} - 31 jan. {startYear + 1}
</p>
<p>
<strong>S2 :</strong> 1er fév. {startYear + 1} - 30 juin {startYear + 1}
</p>
{/if}
</div>
<div class="modal-actions">
<button
type="button"
class="btn-secondary"
onclick={() => (showConfigureModal = false)}
disabled={isSubmitting}
>
Annuler
</button>
<button type="submit" class="btn-primary" disabled={isSubmitting}>
{#if isSubmitting}
Configuration...
{:else}
Configurer
{/if}
</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Edit Period Modal -->
{#if showEditModal && editingPeriod}
<div class="modal-overlay" onclick={closeEditModal} role="presentation">
<div
class="modal"
onclick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="edit-title"
>
<header class="modal-header">
<h2 id="edit-title">Modifier {editingPeriod.label}</h2>
<button class="modal-close" onclick={closeEditModal} aria-label="Fermer">x</button>
</header>
<form
class="modal-body"
onsubmit={(e) => {
e.preventDefault();
handleUpdatePeriod();
}}
>
{#if showImpactWarning}
<div class="alert alert-warning">
<strong>Attention :</strong> Cette période contient des notes. La modification des dates
peut impacter les bulletins existants.
</div>
{/if}
<div class="form-group">
<label for="edit-start-date">Date de début</label>
<input type="date" id="edit-start-date" bind:value={editStartDate} required />
</div>
<div class="form-group">
<label for="edit-end-date">Date de fin</label>
<input type="date" id="edit-end-date" bind:value={editEndDate} required />
</div>
<div class="modal-actions">
<button
type="button"
class="btn-secondary"
onclick={closeEditModal}
disabled={isSubmitting}
>
Annuler
</button>
{#if showImpactWarning}
<button
type="button"
class="btn-danger"
onclick={() => handleUpdatePeriod(true)}
disabled={isSubmitting}
>
{#if isSubmitting}
Modification...
{:else}
Confirmer la modification
{/if}
</button>
{:else}
<button type="submit" class="btn-primary" disabled={isSubmitting}>
{#if isSubmitting}
Modification...
{:else}
Enregistrer
{/if}
</button>
{/if}
</div>
</form>
</div>
</div>
{/if}
<style>
.periods-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;
}
.header-actions {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.year-selector {
display: flex;
background: #f3f4f6;
border-radius: 0.5rem;
padding: 0.25rem;
}
.year-tab {
padding: 0.5rem 1rem;
border: none;
background: transparent;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
color: #6b7280;
cursor: pointer;
transition: all 0.2s;
}
.year-tab-active {
background: white;
color: #1f2937;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.year-tab:hover:not(.year-tab-active) {
color: #374151;
}
/* Current period banner */
.current-period-banner {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
background: linear-gradient(135deg, #3b82f6, #2563eb);
border-radius: 0.75rem;
color: white;
margin-bottom: 1.5rem;
}
.banner-label {
font-size: 0.875rem;
opacity: 0.9;
}
.banner-period {
display: block;
font-size: 1.5rem;
font-weight: 700;
margin-top: 0.25rem;
}
.banner-countdown {
text-align: right;
}
.countdown-number {
display: block;
font-size: 2rem;
font-weight: 700;
}
.countdown-label {
font-size: 0.75rem;
opacity: 0.9;
}
/* Config info */
.config-info {
margin-bottom: 1rem;
}
.config-badge {
display: inline-block;
padding: 0.375rem 0.75rem;
background: #eff6ff;
color: #3b82f6;
border-radius: 1rem;
font-size: 0.875rem;
font-weight: 500;
}
/* Periods list */
.periods-list {
display: grid;
gap: 1rem;
}
.period-card {
padding: 1.5rem;
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
transition: box-shadow 0.2s;
}
.period-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.period-current {
border-color: #3b82f6;
border-width: 2px;
}
.period-past {
opacity: 0.7;
}
.period-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.period-label {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
}
.badge {
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
}
.badge-current {
background: #dbeafe;
color: #1d4ed8;
}
.badge-past {
background: #f3f4f6;
color: #6b7280;
}
.badge-future {
background: #f0fdf4;
color: #16a34a;
}
.period-dates {
display: flex;
gap: 2rem;
margin-bottom: 1rem;
}
.date-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.date-label {
font-size: 0.75rem;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.date-value {
font-size: 0.9375rem;
color: #1f2937;
font-weight: 500;
}
.period-progress {
margin-bottom: 1rem;
}
.progress-info {
font-size: 0.875rem;
color: #3b82f6;
font-weight: 500;
}
.period-actions {
margin-top: 0.5rem;
}
/* Type preview */
.type-preview {
padding: 1rem;
background: #f9fafb;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.type-preview p {
margin: 0.25rem 0;
font-size: 0.875rem;
color: #4b5563;
}
.form-help {
margin: 0.25rem 0 0;
font-size: 0.8125rem;
color: #6b7280;
}
/* Alert warning */
.alert-warning {
padding: 1rem;
background: #fffbeb;
border: 1px solid #fcd34d;
border-radius: 0.5rem;
color: #92400e;
margin-bottom: 1rem;
}
/* Shared styles */
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-primary:hover:not(:disabled) {
background: #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 {
padding: 0.5rem 1rem;
background: #dc2626;
color: white;
border: none;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
}
.btn-danger:hover:not(:disabled) {
background: #b91c1c;
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
}
.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-icon {
flex-shrink: 0;
width: 1.5rem;
height: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
background: #dc2626;
color: white;
border-radius: 50%;
font-size: 0.75rem;
font-weight: 700;
}
.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-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;
}
/* 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-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-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #374151;
}
.form-group input,
.form-group select {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
transition: border-color 0.2s;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
}
</style>