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:
@@ -433,8 +433,8 @@ test.describe('Classes Management (Story 2.1)', () => {
|
||||
const classCard = page.locator('.class-card', { hasText: className });
|
||||
await classCard.getByRole('button', { name: /modifier/i }).click();
|
||||
|
||||
// Click breadcrumb to go back
|
||||
await page.getByRole('link', { name: 'Classes' }).click();
|
||||
// Click breadcrumb to go back (scoped to main to avoid matching nav link)
|
||||
await page.getByRole('main').getByRole('link', { name: 'Classes' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/admin\/classes$/);
|
||||
});
|
||||
|
||||
312
frontend/e2e/periods.spec.ts
Normal file
312
frontend/e2e/periods.spec.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { execSync } from 'child_process';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
|
||||
const urlMatch = baseUrl.match(/:(\d+)$/);
|
||||
const PORT = urlMatch ? urlMatch[1] : '4173';
|
||||
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
|
||||
|
||||
const ADMIN_EMAIL = 'e2e-periods-admin@example.com';
|
||||
const ADMIN_PASSWORD = 'PeriodsTest123';
|
||||
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
// Force serial execution — empty state must run first
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Periods Management (Story 2.3)', () => {
|
||||
test.beforeAll(async () => {
|
||||
const projectRoot = join(__dirname, '../..');
|
||||
const composeFile = join(projectRoot, 'compose.yaml');
|
||||
|
||||
try {
|
||||
// Create admin user
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
console.log('Periods E2E test admin user created');
|
||||
|
||||
// Clean up all periods for this tenant
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM academic_periods WHERE tenant_id = '${TENANT_ID}'" 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
console.log('Periods cleaned up for E2E tests');
|
||||
} catch (error) {
|
||||
console.error('Setup error:', error);
|
||||
}
|
||||
});
|
||||
|
||||
async function loginAsAdmin(page: import('@playwright/test').Page) {
|
||||
await page.goto(`${ALPHA_URL}/login`);
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await page.getByRole('button', { name: /se connecter/i }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Empty State
|
||||
// ============================================================================
|
||||
test.describe('Empty State', () => {
|
||||
test('shows empty state when no periods configured', async ({ page }) => {
|
||||
// Clean up right before test to avoid race conditions
|
||||
const projectRoot = join(__dirname, '../..');
|
||||
const composeFile = join(projectRoot, 'compose.yaml');
|
||||
try {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM academic_periods WHERE tenant_id = '${TENANT_ID}'" 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/academic-year/periods`);
|
||||
|
||||
await expect(page.getByRole('heading', { name: /périodes scolaires/i })).toBeVisible();
|
||||
await expect(page.getByText(/aucune période configurée/i)).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: /configurer les périodes/i })
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Year Selector Tabs
|
||||
// ============================================================================
|
||||
test.describe('Year Selector', () => {
|
||||
test('displays three year tabs', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/academic-year/periods`);
|
||||
|
||||
const tabs = page.getByRole('tab');
|
||||
await expect(tabs).toHaveCount(3);
|
||||
});
|
||||
|
||||
test('current year tab is active by default', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/academic-year/periods`);
|
||||
|
||||
const tabs = page.getByRole('tab');
|
||||
// Middle tab (current) should be active
|
||||
await expect(tabs.nth(1)).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
test('can switch between year tabs', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/academic-year/periods`);
|
||||
|
||||
const tabs = page.getByRole('tab');
|
||||
|
||||
// Wait for Svelte hydration and initial load to complete
|
||||
await expect(tabs.nth(1)).toHaveAttribute('aria-selected', 'true', { timeout: 10000 });
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Click next year tab
|
||||
await tabs.nth(2).click();
|
||||
await expect(tabs.nth(2)).toHaveAttribute('aria-selected', 'true', { timeout: 10000 });
|
||||
|
||||
// Wait for load triggered by tab switch
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Click previous year tab
|
||||
await tabs.nth(0).click();
|
||||
await expect(tabs.nth(0)).toHaveAttribute('aria-selected', 'true', { timeout: 10000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Period Configuration
|
||||
// ============================================================================
|
||||
test.describe('Period Configuration', () => {
|
||||
test('can configure trimesters', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/academic-year/periods`);
|
||||
|
||||
// Click "Configurer les périodes" button
|
||||
await page.getByRole('button', { name: /configurer les périodes/i }).click();
|
||||
|
||||
// Modal should open
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Select trimester (should be default)
|
||||
await expect(dialog.locator('#period-type')).toHaveValue('trimester');
|
||||
|
||||
// Verify preview shows 3 trimesters
|
||||
await expect(dialog.getByText(/T1/)).toBeVisible();
|
||||
await expect(dialog.getByText(/T2/)).toBeVisible();
|
||||
await expect(dialog.getByText(/T3/)).toBeVisible();
|
||||
|
||||
// Submit
|
||||
await dialog.getByRole('button', { name: /configurer$/i }).click();
|
||||
|
||||
// Modal should close
|
||||
await expect(dialog).not.toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Period cards should appear
|
||||
await expect(page.getByRole('heading', { name: 'T1' })).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByRole('heading', { name: 'T2' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'T3' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows trimester badge after configuration', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/academic-year/periods`);
|
||||
|
||||
await expect(page.getByText(/trimestres/i)).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('shows dates on each period card', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/academic-year/periods`);
|
||||
|
||||
// Wait for periods to load
|
||||
await expect(page.getByRole('heading', { name: 'T1' })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Each period card should have start and end dates
|
||||
const periodCards = page.locator('.period-card');
|
||||
const count = await periodCards.count();
|
||||
expect(count).toBe(3);
|
||||
|
||||
// Verify date labels exist
|
||||
await expect(page.getByText(/début/i).first()).toBeVisible();
|
||||
await expect(page.getByText(/fin/i).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('configure button no longer visible when periods exist', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/academic-year/periods`);
|
||||
|
||||
// Wait for periods to load
|
||||
await expect(page.getByRole('heading', { name: 'T1' })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Configure button should not be visible
|
||||
await expect(
|
||||
page.getByRole('button', { name: /configurer les périodes/i })
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('can configure semesters on next year', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/academic-year/periods`);
|
||||
|
||||
// Wait for initial load to complete before switching tab
|
||||
const tabs = page.getByRole('tab');
|
||||
await expect(tabs.nth(1)).toHaveAttribute('aria-selected', 'true', { timeout: 10000 });
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Switch to next year tab
|
||||
await tabs.nth(2).click();
|
||||
|
||||
// Should show empty state for next year
|
||||
await expect(page.getByText(/aucune période configurée/i)).toBeVisible({
|
||||
timeout: 10000
|
||||
});
|
||||
|
||||
// Configure semesters for next year
|
||||
await page.getByRole('button', { name: /configurer les périodes/i }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Select semester
|
||||
await dialog.locator('#period-type').selectOption('semester');
|
||||
|
||||
// Verify preview shows 2 semesters
|
||||
await expect(dialog.getByText(/S1/)).toBeVisible();
|
||||
await expect(dialog.getByText(/S2/)).toBeVisible();
|
||||
|
||||
// Submit
|
||||
await dialog.getByRole('button', { name: /configurer$/i }).click();
|
||||
|
||||
// Modal should close and period cards appear
|
||||
await expect(dialog).not.toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByRole('heading', { name: 'S1' })).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByRole('heading', { name: 'S2' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Period Date Modification
|
||||
// ============================================================================
|
||||
test.describe('Period Date Modification', () => {
|
||||
test('each period card has a modify button', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/academic-year/periods`);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'T1' })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const modifyButtons = page.getByRole('button', { name: /modifier les dates/i });
|
||||
await expect(modifyButtons).toHaveCount(3);
|
||||
});
|
||||
|
||||
test('opens edit modal when clicking modify', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/academic-year/periods`);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'T1' })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click modify on first period
|
||||
const modifyButtons = page.getByRole('button', { name: /modifier les dates/i });
|
||||
await modifyButtons.first().click();
|
||||
|
||||
// Edit modal should open
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||
await expect(dialog.getByText(/modifier T1/i)).toBeVisible();
|
||||
|
||||
// Date fields should be present
|
||||
await expect(dialog.locator('#edit-start-date')).toBeVisible();
|
||||
await expect(dialog.locator('#edit-end-date')).toBeVisible();
|
||||
});
|
||||
|
||||
test('can cancel date modification', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/academic-year/periods`);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'T1' })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const modifyButtons = page.getByRole('button', { name: /modifier les dates/i });
|
||||
await modifyButtons.first().click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Cancel
|
||||
await dialog.getByRole('button', { name: /annuler/i }).click();
|
||||
await expect(dialog).not.toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Navigation
|
||||
// ============================================================================
|
||||
test.describe('Navigation', () => {
|
||||
test('can access periods page from admin dashboard', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin`);
|
||||
|
||||
// Click on periods card
|
||||
await page.getByRole('link', { name: /périodes scolaires/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/admin\/academic-year\/periods/);
|
||||
await expect(page.getByRole('heading', { name: /périodes scolaires/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('can access periods page directly', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/academic-year/periods`);
|
||||
|
||||
await expect(page).toHaveURL(/\/admin\/academic-year\/periods/);
|
||||
await expect(page.getByRole('heading', { name: /périodes scolaires/i })).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -41,11 +41,11 @@
|
||||
<span class="action-label">Gérer les matières</span>
|
||||
<span class="action-hint">Créer et gérer</span>
|
||||
</a>
|
||||
<div class="action-card disabled" aria-disabled="true">
|
||||
<a class="action-card" href="/admin/academic-year/periods">
|
||||
<span class="action-icon">📅</span>
|
||||
<span class="action-label">Calendrier scolaire</span>
|
||||
<span class="action-hint">Bientôt disponible</span>
|
||||
</div>
|
||||
<span class="action-label">Périodes scolaires</span>
|
||||
<span class="action-hint">Trimestres et semestres</span>
|
||||
</a>
|
||||
<div class="action-card disabled" aria-disabled="true">
|
||||
<span class="action-icon">📤</span>
|
||||
<span class="action-label">Importer des données</span>
|
||||
|
||||
@@ -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}
|
||||
|
||||
171
frontend/src/routes/admin/+page.svelte
Normal file
171
frontend/src/routes/admin/+page.svelte
Normal 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>
|
||||
958
frontend/src/routes/admin/academic-year/periods/+page.svelte
Normal file
958
frontend/src/routes/admin/academic-year/periods/+page.svelte
Normal 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">📅</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>
|
||||
Reference in New Issue
Block a user