feat: Permettre la consultation et gestion des droits à l'image des élèves
Les administrateurs et enseignants ont besoin de consulter et gérer les autorisations de droit à l'image des élèves pour respecter la réglementation lors de publications contenant des photos (FR82). Cette fonctionnalité ajoute une page dédiée avec liste filtrable par statut, modification individuelle via dropdown, export CSV avec BOM UTF-8 pour Excel, et préparation du système d'avertissement avant publication (query/handler prêts, intégration à faire quand le module publication existera). Le filtrage par classe (AC2) est bloqué en attente d'une table d'affectation élève↔classe qui n'existe pas encore.
This commit is contained in:
@@ -61,6 +61,11 @@
|
||||
<span class="action-label">Calendrier scolaire</span>
|
||||
<span class="action-hint">Fériés et vacances</span>
|
||||
</a>
|
||||
<a class="action-card" href="/admin/image-rights">
|
||||
<span class="action-icon">📷</span>
|
||||
<span class="action-label">Droit à l'image</span>
|
||||
<span class="action-hint">Autorisations élèves</span>
|
||||
</a>
|
||||
<a class="action-card" href="/admin/pedagogy">
|
||||
<span class="action-icon">🎓</span>
|
||||
<span class="action-label">Pédagogie</span>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
const ADMIN_ROLES = [
|
||||
'ROLE_SUPER_ADMIN',
|
||||
'ROLE_ADMIN',
|
||||
'ROLE_PROF',
|
||||
'ROLE_VIE_SCOLAIRE',
|
||||
'ROLE_SECRETARIAT'
|
||||
];
|
||||
@@ -28,6 +29,7 @@
|
||||
{ href: '/admin/replacements', label: 'Remplacements', isActive: () => isReplacementsActive },
|
||||
{ href: '/admin/academic-year/periods', label: 'Périodes', isActive: () => isPeriodsActive },
|
||||
{ href: '/admin/calendar', label: 'Calendrier', isActive: () => isCalendarActive },
|
||||
{ href: '/admin/image-rights', label: 'Droit à l\'image', isActive: () => isImageRightsActive },
|
||||
{ href: '/admin/pedagogy', label: 'Pédagogie', isActive: () => isPedagogyActive }
|
||||
];
|
||||
|
||||
@@ -80,6 +82,7 @@
|
||||
const isAssignmentsActive = $derived(page.url.pathname.startsWith('/admin/assignments'));
|
||||
const isReplacementsActive = $derived(page.url.pathname.startsWith('/admin/replacements'));
|
||||
const isCalendarActive = $derived(page.url.pathname.startsWith('/admin/calendar'));
|
||||
const isImageRightsActive = $derived(page.url.pathname.startsWith('/admin/image-rights'));
|
||||
const isPedagogyActive = $derived(page.url.pathname.startsWith('/admin/pedagogy'));
|
||||
|
||||
const currentSectionLabel = $derived.by(() => {
|
||||
|
||||
729
frontend/src/routes/admin/image-rights/+page.svelte
Normal file
729
frontend/src/routes/admin/image-rights/+page.svelte
Normal file
@@ -0,0 +1,729 @@
|
||||
<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 SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
interface StudentImageRights {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
imageRightsStatus: string;
|
||||
imageRightsStatusLabel: string;
|
||||
imageRightsUpdatedAt: string | null;
|
||||
className: string | null;
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'authorized', label: 'Autorisé' },
|
||||
{ value: 'refused', label: 'Refusé' },
|
||||
{ value: 'not_specified', label: 'Non renseigné' }
|
||||
];
|
||||
|
||||
// State
|
||||
let students = $state<StudentImageRights[]>([]);
|
||||
let isLoading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let successMessage = $state<string | null>(null);
|
||||
|
||||
// Filters
|
||||
let filterStatus = $state<string>(page.url.searchParams.get('status') ?? '');
|
||||
let searchTerm = $state(page.url.searchParams.get('search') ?? '');
|
||||
|
||||
// Derived groups
|
||||
let filteredStudents = $derived.by(() => {
|
||||
if (!searchTerm) return students;
|
||||
const term = searchTerm.toLowerCase();
|
||||
return students.filter(
|
||||
(s) =>
|
||||
s.firstName.toLowerCase().includes(term) ||
|
||||
s.lastName.toLowerCase().includes(term) ||
|
||||
s.email.toLowerCase().includes(term)
|
||||
);
|
||||
});
|
||||
let authorizedStudents = $derived(
|
||||
filteredStudents.filter((s) => s.imageRightsStatus === 'authorized')
|
||||
);
|
||||
let unauthorizedStudents = $derived(
|
||||
filteredStudents.filter((s) => s.imageRightsStatus !== 'authorized')
|
||||
);
|
||||
|
||||
// Updating state
|
||||
let updatingId = $state<string | null>(null);
|
||||
let isExporting = $state(false);
|
||||
|
||||
let loadAbortController: AbortController | null = null;
|
||||
|
||||
$effect(() => {
|
||||
untrack(() => loadStudents());
|
||||
});
|
||||
|
||||
async function loadStudents() {
|
||||
loadAbortController?.abort();
|
||||
const controller = new AbortController();
|
||||
loadAbortController = controller;
|
||||
|
||||
try {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const params = new URLSearchParams();
|
||||
if (filterStatus) params.set('status', filterStatus);
|
||||
const query = params.toString();
|
||||
const url = `${apiUrl}/students/image-rights${query ? `?${query}` : ''}`;
|
||||
const response = await authenticatedFetch(url, { signal: controller.signal });
|
||||
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors du chargement des droits à l\'image');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
students = Array.isArray(data) ? data : data['member'] ?? data['hydra:member'] ?? [];
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'AbortError') return;
|
||||
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
||||
students = [];
|
||||
} finally {
|
||||
if (!controller.signal.aborted) {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateUrl() {
|
||||
const params = new URLSearchParams();
|
||||
if (filterStatus) params.set('status', filterStatus);
|
||||
if (searchTerm) params.set('search', searchTerm);
|
||||
const query = params.toString();
|
||||
goto(`?${query}`, { replaceState: true, noScroll: true, keepFocus: true });
|
||||
}
|
||||
|
||||
function handleSearch(value: string) {
|
||||
searchTerm = value;
|
||||
updateUrl();
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
updateUrl();
|
||||
loadStudents();
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filterStatus = '';
|
||||
searchTerm = '';
|
||||
updateUrl();
|
||||
loadStudents();
|
||||
}
|
||||
|
||||
async function updateStatus(studentId: string, newStatus: string) {
|
||||
try {
|
||||
updatingId = studentId;
|
||||
error = null;
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/students/${studentId}/image-rights`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/merge-patch+json' },
|
||||
body: JSON.stringify({ imageRightsStatus: newStatus })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `Erreur (${response.status})`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
errorMessage =
|
||||
errorData['hydra:description'] ?? errorData.detail ?? errorMessage;
|
||||
} catch {
|
||||
// Ignore JSON parse errors, use default message
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const updated = await response.json();
|
||||
students = students.map((s) =>
|
||||
s.id === studentId
|
||||
? {
|
||||
...s,
|
||||
imageRightsStatus: updated.imageRightsStatus ?? newStatus,
|
||||
imageRightsStatusLabel:
|
||||
updated.imageRightsStatusLabel ??
|
||||
STATUS_OPTIONS.find((o) => o.value === newStatus)?.label ??
|
||||
newStatus,
|
||||
imageRightsUpdatedAt: updated.imageRightsUpdatedAt ?? new Date().toISOString()
|
||||
}
|
||||
: s
|
||||
);
|
||||
successMessage = 'Statut mis à jour avec succès.';
|
||||
setTimeout(() => (successMessage = null), 3000);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Erreur lors de la mise à jour';
|
||||
} finally {
|
||||
updatingId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function exportCsv() {
|
||||
try {
|
||||
isExporting = true;
|
||||
error = null;
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const params = new URLSearchParams();
|
||||
if (filterStatus) params.set('status', filterStatus);
|
||||
const query = params.toString();
|
||||
const url = `${apiUrl}/students/image-rights/export${query ? `?${query}` : ''}`;
|
||||
const response = await authenticatedFetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors de l\'export');
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const downloadUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = downloadUrl;
|
||||
a.download = 'droits-image.csv';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(downloadUrl);
|
||||
|
||||
successMessage = 'Export CSV téléchargé.';
|
||||
setTimeout(() => (successMessage = null), 3000);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Erreur lors de l\'export';
|
||||
} finally {
|
||||
isExporting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadgeClass(status: string): string {
|
||||
return status === 'authorized'
|
||||
? 'status-active'
|
||||
: status === 'refused'
|
||||
? 'status-suspended'
|
||||
: 'status-pending';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="admin-page">
|
||||
<header class="page-header">
|
||||
<div class="header-content">
|
||||
<h1>Droit à l'image</h1>
|
||||
<p class="subtitle">
|
||||
Consultez et gérez les autorisations de droit à l'image des élèves
|
||||
</p>
|
||||
</div>
|
||||
<button class="btn-primary" onclick={exportCsv} disabled={isExporting}>
|
||||
{isExporting ? 'Export...' : 'Exporter CSV'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-error" role="alert">
|
||||
<span>{error}</span>
|
||||
<button class="alert-close" onclick={() => (error = null)}>×</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if successMessage}
|
||||
<div class="alert alert-success" role="status">
|
||||
<span>{successMessage}</span>
|
||||
<button class="alert-close" onclick={() => (successMessage = null)}>×</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="filters-bar">
|
||||
<div class="filter-group">
|
||||
<SearchInput
|
||||
value={searchTerm}
|
||||
onSearch={handleSearch}
|
||||
placeholder="Rechercher un élève..."
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="filter-status">Statut</label>
|
||||
<select id="filter-status" bind:value={filterStatus}>
|
||||
<option value="">Tous les statuts</option>
|
||||
{#each STATUS_OPTIONS as option}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<button class="btn-secondary btn-sm" onclick={applyFilters}>Filtrer</button>
|
||||
<button class="btn-text btn-sm" onclick={resetFilters}>Réinitialiser</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>Chargement...</p>
|
||||
</div>
|
||||
{:else if students.length === 0}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📷</div>
|
||||
<h2>Aucun élève inscrit</h2>
|
||||
<p>Commencez par créer des comptes élèves pour pouvoir gérer leurs autorisations de droit à l'image.</p>
|
||||
<a class="btn-primary" href="/admin/users">Gérer les utilisateurs</a>
|
||||
</div>
|
||||
{:else if filteredStudents.length === 0}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">🔍</div>
|
||||
<h2>Aucun résultat</h2>
|
||||
<p>Aucun élève ne correspond aux critères de recherche. Essayez de modifier vos filtres.</p>
|
||||
<button class="btn-secondary" onclick={resetFilters}>Réinitialiser les filtres</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="stats-bar">
|
||||
<span class="stat">
|
||||
<span class="stat-count">{authorizedStudents.length}</span> autorisé{authorizedStudents.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
<span class="stat">
|
||||
<span class="stat-count stat-danger">{unauthorizedStudents.length}</span> non autorisé{unauthorizedStudents.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
<span class="stat">
|
||||
<span class="stat-count stat-total">{filteredStudents.length}</span> total
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Élèves autorisés ({authorizedStudents.length})</h2>
|
||||
{#if authorizedStudents.length > 0}
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nom</th>
|
||||
<th>Prénom</th>
|
||||
<th>Classe</th>
|
||||
<th>Statut</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each authorizedStudents as student}
|
||||
<tr>
|
||||
<td data-label="Nom">{student.lastName}</td>
|
||||
<td data-label="Prénom">{student.firstName}</td>
|
||||
<td data-label="Classe">{student.className ?? '—'}</td>
|
||||
<td data-label="Statut">
|
||||
<span class="badge {statusBadgeClass(student.imageRightsStatus)}">
|
||||
{student.imageRightsStatusLabel}
|
||||
</span>
|
||||
</td>
|
||||
<td data-label="Actions">
|
||||
<select
|
||||
value={student.imageRightsStatus}
|
||||
onchange={(e) => updateStatus(student.id, e.currentTarget.value)}
|
||||
disabled={updatingId === student.id}
|
||||
>
|
||||
{#each STATUS_OPTIONS as option}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty-section">Aucun élève autorisé.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Élèves non autorisés ({unauthorizedStudents.length})</h2>
|
||||
{#if unauthorizedStudents.length > 0}
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nom</th>
|
||||
<th>Prénom</th>
|
||||
<th>Classe</th>
|
||||
<th>Statut</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each unauthorizedStudents as student}
|
||||
<tr>
|
||||
<td data-label="Nom">{student.lastName}</td>
|
||||
<td data-label="Prénom">{student.firstName}</td>
|
||||
<td data-label="Classe">{student.className ?? '—'}</td>
|
||||
<td data-label="Statut">
|
||||
<span class="badge {statusBadgeClass(student.imageRightsStatus)}">
|
||||
{student.imageRightsStatusLabel}
|
||||
</span>
|
||||
</td>
|
||||
<td data-label="Actions">
|
||||
<select
|
||||
value={student.imageRightsStatus}
|
||||
onchange={(e) => updateStatus(student.id, e.currentTarget.value)}
|
||||
disabled={updatingId === student.id}
|
||||
>
|
||||
{#each STATUS_OPTIONS as option}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty-section">Tous les élèves sont autorisés.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.admin-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1.5rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1a2e);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary, #666);
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--accent-primary, #4361ee);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--surface-secondary, #f0f0f0);
|
||||
color: var(--text-primary, #333);
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
padding: 0.5rem 1rem;
|
||||
background: none;
|
||||
color: var(--text-secondary, #666);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: #f0fdf4;
|
||||
color: #166534;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
|
||||
.alert-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.filters-bar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #666);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.filter-group select {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.stats-bar {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--surface-secondary, #f8f9fa);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.stat-count {
|
||||
font-weight: 700;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.stat-count.stat-danger {
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.stat-count.stat-total {
|
||||
color: var(--text-primary, #333);
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--text-primary, #1a1a2e);
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
thead {
|
||||
background: var(--surface-secondary, #f8f9fa);
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 0.75rem 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #666);
|
||||
border-bottom: 2px solid var(--border-color, #e5e7eb);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color, #f0f0f0);
|
||||
color: var(--text-primary, #333);
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background: var(--surface-hover, #f9fafb);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.status-suspended {
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
background: #fef9c3;
|
||||
color: #854d0e;
|
||||
}
|
||||
|
||||
td select {
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
td select:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1.5rem;
|
||||
background: var(--surface-elevated, #fff);
|
||||
border: 1px solid var(--border-subtle, #e2e8f0);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1f2937);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary, #64748b);
|
||||
margin: 0 0 1.5rem;
|
||||
max-width: 400px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 3px solid var(--border-color, #e5e7eb);
|
||||
border-top-color: var(--accent-primary, #4361ee);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.empty-section {
|
||||
padding: 1rem;
|
||||
color: var(--text-secondary, #999);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filters-bar {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stats-bar {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
tr {
|
||||
display: block;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
td {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: none;
|
||||
padding: 0.375rem 0.5rem;
|
||||
}
|
||||
|
||||
td::before {
|
||||
content: attr(data-label);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #666);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user