rapproche encore le tableau de bord de la démo /cockpit : avancement par vérification, mains levées avec action réelle, progression de la séance
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
0573894721
commit
ee079ddffb
6 changed files with 178 additions and 6 deletions
|
|
@ -829,3 +829,22 @@ Le piège de ce chantier n'était pas technique mais une tentation facile : reco
|
||||||
**Suite**
|
**Suite**
|
||||||
|
|
||||||
Chantier « vrai cockpit », étape 2 : Quiz flash (prochaine fonction à construire pour de vrai).
|
Chantier « vrai cockpit », étape 2 : Quiz flash (prochaine fonction à construire pour de vrai).
|
||||||
|
|
||||||
|
## 2026-10-12 — Chantier « vrai cockpit », étape 1 (suite) : plus près de la démo, toujours sans rien inventer
|
||||||
|
|
||||||
|
Adam a demandé d'aller plus loin dans la fidélité à `/cockpit`. Trois ajouts, tous à partir de données déjà réelles :
|
||||||
|
|
||||||
|
**Fait**
|
||||||
|
|
||||||
|
- **Avancement du lab** : une barre par vérification du lab (équivalent réel de la « répartition par étape » de la démo), comptée sur les vraies dernières corrections de la séance — titres et ordre lus dans le `lab.yaml` (`DefinitionLab`), jamais les vérifications bonus (elles ajoutent des points, elles ne font pas partie du parcours normal).
|
||||||
|
- **Panneau « Mains levées »** : la même file que la page de visio (premier arrivé en haut), avec la **vraie action** « Donner la parole » (`seances.visio.parole`) directement depuis le tableau de bord — pas une liste en lecture seule.
|
||||||
|
- **Progression de la séance** (barre latérale) : temps écoulé rapporté à la durée estimée du lab (`Lab::duree_min`) — absente si l'une des deux manque, jamais une estimation inventée.
|
||||||
|
- `VisioController::mainsLevees()` (nouveau, public, à côté de `nombreMainsLevees()`) : la liste complète (nom, depuis), pas seulement le compte.
|
||||||
|
- Le calcul de l'avancement a buté deux fois sur l'analyse statique (Larastan) : `Correction::$details` n'avait pas de type précis (ajouté en `@property-read`), et repasser par une requête SQL séparée pour les corrections (au lieu de réutiliser le chargement déjà fait) perdait le type des modèles pour l'outil. Les deux corrigés à la racine, pas contournés.
|
||||||
|
- 2 nouveaux tests Pest (61 au total) : avancement qui exclut bien les bonus, mains levées avec la vraie action.
|
||||||
|
|
||||||
|
**Vérifié sur le serveur réel** (scénario à deux élèves, corrections différentes, main levée en cache — supprimé ensuite) : avancement affiché par vérification (2/2 puis 1/2), mains levées avec « Yanis » et le lien exact vers `seances.visio.parole`, barre de progression du temps. Classe réelle d'Adam vérifiée intacte après coup (1 classe, 2 élèves).
|
||||||
|
|
||||||
|
**Suite**
|
||||||
|
|
||||||
|
Chantier « vrai cockpit », étape 2 : Quiz flash.
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,13 @@
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Classe;
|
use App\Models\Classe;
|
||||||
|
use App\Models\Correction;
|
||||||
use App\Models\DemandeInscription;
|
use App\Models\DemandeInscription;
|
||||||
use App\Models\Eleve;
|
use App\Models\Eleve;
|
||||||
use App\Models\LabSession;
|
use App\Models\LabSession;
|
||||||
use App\Models\Seance;
|
use App\Models\Seance;
|
||||||
use App\Services\GenerateurCompteEleve;
|
use App\Services\GenerateurCompteEleve;
|
||||||
|
use App\Services\Labs\DefinitionLab;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
@ -48,17 +50,44 @@ class ClasseController extends Controller
|
||||||
->sortByDesc(fn ($s) => $s->statut === Seance::EN_COURS)
|
->sortByDesc(fn ($s) => $s->statut === Seance::EN_COURS)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
// Déjà chargées par le eager loading ci-dessus (labSessions.derniereCorrection) :
|
||||||
|
// repasser par une requête séparée perdait le type précis pour l'analyse statique.
|
||||||
$corrections = $seance
|
$corrections = $seance
|
||||||
? LabSession::query()->whereIn('id', $seance->labSessions->pluck('id'))->whereHas('derniereCorrection')->with('derniereCorrection')->get()->pluck('derniereCorrection')
|
? $seance->labSessions->map(fn (LabSession $ls) => $ls->derniereCorrection)->filter()->values()
|
||||||
: collect();
|
: collect();
|
||||||
|
|
||||||
|
// Répartition par vérification du lab (équivalent réel du « répartition
|
||||||
|
// par étape » de la démo) : l'ordre et les titres viennent du lab.yaml,
|
||||||
|
// le compte de réussite des vraies corrections. Jamais de bonus ici
|
||||||
|
// (ils ajoutent des points, ils ne font pas partie du parcours normal).
|
||||||
|
$avancement = collect();
|
||||||
|
if ($seance && $corrections->isNotEmpty()) {
|
||||||
|
$definition = DefinitionLab::depuis($seance->lab->identifiant);
|
||||||
|
$avancement = collect($definition->verifications)
|
||||||
|
->reject(fn ($v) => $v['bonus'])
|
||||||
|
->map(fn ($v) => [
|
||||||
|
'titre' => $v['titre'],
|
||||||
|
'reussi' => $corrections->filter(fn (Correction $c) => collect($c->details)->firstWhere('id', $v['id'])['reussi'] ?? false)->count(),
|
||||||
|
'total' => $corrections->count(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progression dans le temps de la séance, d'après la durée estimée
|
||||||
|
// du lab (Lab::duree_min) : absente si l'une des deux manque, jamais
|
||||||
|
// une estimation inventée.
|
||||||
|
$progressionSeance = ($seance?->demarree_a && $seance->lab->duree_min)
|
||||||
|
? min(100, (int) round($seance->demarree_a->diffInMinutes(now()) / $seance->lab->duree_min * 100))
|
||||||
|
: null;
|
||||||
|
|
||||||
return view('mes-classes.show', [
|
return view('mes-classes.show', [
|
||||||
'classes' => Auth::guard('web')->user()->classes()->withCount('eleves')->orderBy('nom')->get(),
|
'classes' => Auth::guard('web')->user()->classes()->withCount('eleves')->orderBy('nom')->get(),
|
||||||
'classe' => $classe->load('eleves'),
|
'classe' => $classe->load('eleves'),
|
||||||
'demandes' => $classe->demandesInscription()->where('statut', DemandeInscription::EN_ATTENTE)->oldest()->get(),
|
'demandes' => $classe->demandesInscription()->where('statut', DemandeInscription::EN_ATTENTE)->oldest()->get(),
|
||||||
'seance' => $seance,
|
'seance' => $seance,
|
||||||
|
'avancement' => $avancement,
|
||||||
|
'progressionSeance' => $progressionSeance,
|
||||||
'reussiteMoyenne' => $corrections->isNotEmpty() ? (int) round($corrections->avg(fn ($c) => $c->note_max > 0 ? $c->note / $c->note_max * 100 : 0)) : null,
|
'reussiteMoyenne' => $corrections->isNotEmpty() ? (int) round($corrections->avg(fn ($c) => $c->note_max > 0 ? $c->note / $c->note_max * 100 : 0)) : null,
|
||||||
'mainsLevees' => ($seance && $seance->visioOuverte()) ? VisioController::nombreMainsLevees($seance) : null,
|
'mains' => ($seance && $seance->visioOuverte()) ? VisioController::mainsLevees($seance) : [],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -214,7 +214,26 @@ class VisioController extends Controller
|
||||||
|
|
||||||
public static function nombreMainsLevees(Seance $seance): int
|
public static function nombreMainsLevees(Seance $seance): int
|
||||||
{
|
{
|
||||||
return count((Cache::get(self::cleEtat($seance)) ?? [])['mains'] ?? []);
|
return count(self::mainsLevees($seance));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mains levées, premier arrivé en premier (même tri que etatProf) : nom
|
||||||
|
* déjà minimisé (prénom + initiale), rien de plus que ce qu'affiche
|
||||||
|
* déjà la page de visio.
|
||||||
|
*
|
||||||
|
* @return list<array{eleve_id: int, nom: string, depuis: int}>
|
||||||
|
*/
|
||||||
|
public static function mainsLevees(Seance $seance): array
|
||||||
|
{
|
||||||
|
/** @var array<int, array{nom: string, depuis: int}> $mains */
|
||||||
|
$mains = (Cache::get(self::cleEtat($seance)) ?? [])['mains'] ?? [];
|
||||||
|
|
||||||
|
return collect($mains)
|
||||||
|
->sortBy('depuis')
|
||||||
|
->map(fn ($m, $id) => ['eleve_id' => (int) $id, 'nom' => $m['nom'], 'depuis' => $m['depuis']])
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function cle(Seance $seance): string
|
private function cle(Seance $seance): string
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
* Une passe de vérification d'une session de lab (bouton « Vérifier mon
|
* Une passe de vérification d'une session de lab (bouton « Vérifier mon
|
||||||
* travail »). details : une entrée par vérification du lab.yaml, avec
|
* travail »). details : une entrée par vérification du lab.yaml, avec
|
||||||
* id, titre, points, reussi et message (message_echec du lab si échouée).
|
* id, titre, points, reussi et message (message_echec du lab si échouée).
|
||||||
|
*
|
||||||
|
* @property-read array<int, array{id: string, titre: string, points: int, bonus: bool, reussi: bool, message: ?string}> $details
|
||||||
*/
|
*/
|
||||||
class Correction extends Model
|
class Correction extends Model
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,10 @@
|
||||||
<p class="font-extrabold">Séance en cours</p>
|
<p class="font-extrabold">Séance en cours</p>
|
||||||
<p class="mt-1">{{ $seance->lab->titre }}</p>
|
<p class="mt-1">{{ $seance->lab->titre }}</p>
|
||||||
<p class="mt-2 font-mono text-xs">{{ $seance->statut === \App\Models\Seance::EN_COURS ? 'En cours' : 'Salle d\'attente' }}</p>
|
<p class="mt-2 font-mono text-xs">{{ $seance->statut === \App\Models\Seance::EN_COURS ? 'En cours' : 'Salle d\'attente' }}</p>
|
||||||
|
@if ($progressionSeance !== null)
|
||||||
|
<div class="mt-2 h-2 rounded-lg border border-[#dadce0] bg-white"><div class="h-full rounded-lg bg-black" style="width:{{ $progressionSeance }}%"></div></div>
|
||||||
|
<p class="mt-1 font-mono text-[11px] text-[#5f6368]">{{ $progressionSeance }}% du temps estimé ({{ $seance->lab->duree_min }} min)</p>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</aside>
|
</aside>
|
||||||
|
|
@ -78,15 +82,68 @@
|
||||||
<p class="mt-2 font-mono text-3xl font-semibold leading-none">{{ $reussiteMoyenne }}%</p>
|
<p class="mt-2 font-mono text-3xl font-semibold leading-none">{{ $reussiteMoyenne }}%</p>
|
||||||
</li>
|
</li>
|
||||||
@endif
|
@endif
|
||||||
@if ($mainsLevees !== null)
|
@if ($seance->visioOuverte())
|
||||||
<li class="flex flex-col rounded-lg border border-[#dadce0] bg-[#fce8e6] p-4">
|
<li class="flex flex-col rounded-lg border border-[#dadce0] bg-[#fce8e6] p-4">
|
||||||
<p class="text-sm font-bold">Mains levées (visio)</p>
|
<p class="text-sm font-bold">Mains levées (visio)</p>
|
||||||
<p class="mt-2 font-mono text-3xl font-semibold leading-none">{{ $mainsLevees }}</p>
|
<p class="mt-2 font-mono text-3xl font-semibold leading-none">{{ count($mains) }}</p>
|
||||||
</li>
|
</li>
|
||||||
@endif
|
@endif
|
||||||
@endif
|
@endif
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
@if ($seance && $avancement->isNotEmpty())
|
||||||
|
{{-- Équivalent réel de la « répartition par étape » de la démo :
|
||||||
|
une barre par vérification du lab, comptée sur les vraies
|
||||||
|
dernières corrections (pas de fausse étape numérotée). --}}
|
||||||
|
<section class="mt-6 rounded-lg border border-[#dadce0] bg-white p-5" aria-labelledby="t-avancement">
|
||||||
|
<div class="flex flex-wrap items-baseline justify-between gap-2">
|
||||||
|
<h2 id="t-avancement" class="text-lg font-extrabold">Avancement du lab « {{ $seance->lab->titre }} »</h2>
|
||||||
|
<p class="font-mono text-xs font-semibold text-[#5f6368]">{{ $avancement->count() }} vérifications · {{ $avancement->first()['total'] }} élève(s) corrigé(s)</p>
|
||||||
|
</div>
|
||||||
|
<ul class="mt-4 grid gap-2">
|
||||||
|
@foreach ($avancement as $v)
|
||||||
|
<li class="flex items-center gap-3">
|
||||||
|
<span class="w-40 shrink-0 truncate text-sm font-bold" title="{{ $v['titre'] }}">{{ $v['titre'] }}</span>
|
||||||
|
<span class="h-3 flex-1 rounded-lg border border-[#dadce0] bg-[#f8f9fa]"><span class="block h-full rounded-lg bg-[#3C8A5A]" style="width:{{ $v['total'] ? round($v['reussi'] / $v['total'] * 100) : 0 }}%"></span></span>
|
||||||
|
<span class="w-14 shrink-0 text-right font-mono text-sm font-semibold">{{ $v['reussi'] }}/{{ $v['total'] }}</span>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($seance && $seance->visioOuverte())
|
||||||
|
{{-- Équivalent réel du panneau « Mains levées » de la démo : la
|
||||||
|
même file que la page de visio, avec la même action (donner
|
||||||
|
la parole), juste accessible sans quitter le tableau de bord. --}}
|
||||||
|
<section class="mt-6 rounded-lg border border-[#dadce0] bg-white" aria-labelledby="t-mains">
|
||||||
|
<div class="flex items-center justify-between border-b border-[#e3e5e8] bg-[#fce8e6] p-4">
|
||||||
|
<h2 id="t-mains" class="text-lg font-extrabold">Mains levées</h2>
|
||||||
|
<span class="font-mono text-xs font-semibold">ordre d'arrivée</span>
|
||||||
|
</div>
|
||||||
|
@if (empty($mains))
|
||||||
|
<p class="p-4 font-semibold">Aucune main levée pour l'instant.</p>
|
||||||
|
@else
|
||||||
|
<ol class="divide-y divide-[#e3e5e8]">
|
||||||
|
@foreach ($mains as $i => $main)
|
||||||
|
<li class="flex items-center justify-between gap-3 p-4">
|
||||||
|
<span class="flex items-center gap-3">
|
||||||
|
<span class="font-mono text-sm font-semibold">{{ $i + 1 }}</span>
|
||||||
|
<span class="font-extrabold">{{ $main['nom'] }}</span>
|
||||||
|
@php($attente = max(0, time() - $main['depuis']))
|
||||||
|
<span class="font-mono text-xs text-[#5f6368]">· depuis {{ intdiv($attente, 60) }} min {{ str_pad((string) ($attente % 60), 2, '0', STR_PAD_LEFT) }} s</span>
|
||||||
|
</span>
|
||||||
|
<form action="{{ route('seances.visio.parole', [$seance, $main['eleve_id']]) }}" method="post">
|
||||||
|
@csrf
|
||||||
|
<button type="submit" class="inline-flex h-9 items-center rounded-lg bg-black px-3 text-xs font-extrabold text-white hover:bg-[#ec3013]">Donner la parole</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ol>
|
||||||
|
@endif
|
||||||
|
</section>
|
||||||
|
@endif
|
||||||
|
|
||||||
@if ($seance)
|
@if ($seance)
|
||||||
{{-- Vraies données de la séance en cours : un élève, son état, sa dernière vérification. --}}
|
{{-- Vraies données de la séance en cours : un élève, son état, sa dernière vérification. --}}
|
||||||
<section class="mt-6 rounded-lg border border-[#dadce0] bg-white" aria-labelledby="t-seance-encours">
|
<section class="mt-6 rounded-lg border border-[#dadce0] bg-white" aria-labelledby="t-seance-encours">
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ use App\Models\Eleve;
|
||||||
use App\Models\Lab;
|
use App\Models\Lab;
|
||||||
use App\Models\LabSession;
|
use App\Models\LabSession;
|
||||||
use App\Models\Seance;
|
use App\Models\Seance;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
test('le tableau de bord d’une classe vide affiche les cartes réelles, sans aucune donnée inventée', function () {
|
test('le tableau de bord d’une classe vide affiche les cartes réelles, sans aucune donnée inventée', function () {
|
||||||
$classe = Classe::factory()->create(['nom' => 'Classe A']);
|
$classe = Classe::factory()->create(['nom' => 'Classe A']);
|
||||||
|
|
@ -23,7 +24,7 @@ test('le tableau de bord d’une classe vide affiche les cartes réelles, sans a
|
||||||
|
|
||||||
test('une séance en cours montre les vrais élèves, leur état et leur dernière correction', function () {
|
test('une séance en cours montre les vrais élèves, leur état et leur dernière correction', function () {
|
||||||
$classe = Classe::factory()->create();
|
$classe = Classe::factory()->create();
|
||||||
$lab = Lab::factory()->create(['titre' => 'Lab 08']);
|
$lab = Lab::factory()->create(['identifiant' => '08-deux-lan-routeur', 'titre' => 'Lab 08']);
|
||||||
$seance = Seance::create(['classe_id' => $classe->id, 'lab_id' => $lab->id, 'statut' => Seance::EN_COURS, 'demarree_a' => now()]);
|
$seance = Seance::create(['classe_id' => $classe->id, 'lab_id' => $lab->id, 'statut' => Seance::EN_COURS, 'demarree_a' => now()]);
|
||||||
$eleveActif = Eleve::factory()->create(['classe_id' => $classe->id, 'prenom' => 'Léa']);
|
$eleveActif = Eleve::factory()->create(['classe_id' => $classe->id, 'prenom' => 'Léa']);
|
||||||
$eleveEchec = Eleve::factory()->create(['classe_id' => $classe->id, 'prenom' => 'Yanis']);
|
$eleveEchec = Eleve::factory()->create(['classe_id' => $classe->id, 'prenom' => 'Yanis']);
|
||||||
|
|
@ -46,6 +47,51 @@ test('une séance en cours montre les vrais élèves, leur état et leur derniè
|
||||||
->assertDontSee('Mains levées'); // pas de visio ouverte : la carte n'existe pas
|
->assertDontSee('Mains levées'); // pas de visio ouverte : la carte n'existe pas
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('l’avancement du lab compte les vraies vérifications réussies, jamais les bonus', function () {
|
||||||
|
$classe = Classe::factory()->create();
|
||||||
|
$lab = Lab::factory()->create(['identifiant' => '08-deux-lan-routeur']);
|
||||||
|
$seance = Seance::create(['classe_id' => $classe->id, 'lab_id' => $lab->id, 'statut' => Seance::EN_COURS, 'demarree_a' => now()]);
|
||||||
|
$eleve = Eleve::factory()->create(['classe_id' => $classe->id]);
|
||||||
|
$session = LabSession::create(['seance_id' => $seance->id, 'eleve_id' => $eleve->id, 'lab_id' => $lab->id, 'etat' => LabSession::ACTIF]);
|
||||||
|
Correction::create(['lab_session_id' => $session->id, 'reussi' => false, 'note' => 1, 'note_max' => 7, 'details' => [
|
||||||
|
['id' => 'ip-pc-a', 'titre' => 'Adresse de PC-A', 'points' => 1, 'bonus' => false, 'reussi' => true, 'message' => null],
|
||||||
|
['id' => 'forwarding', 'titre' => 'Routage activé', 'points' => 1, 'bonus' => false, 'reussi' => false, 'message' => 'Regarde net.ipv4.ip_forward.'],
|
||||||
|
['id' => 'bonus-permanent', 'titre' => 'Routage permanent', 'points' => 1, 'bonus' => true, 'reussi' => true, 'message' => null],
|
||||||
|
]]);
|
||||||
|
|
||||||
|
commeProf($this, $classe->prof)
|
||||||
|
->get(route('mes-classes.show', $classe))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Avancement du lab')
|
||||||
|
->assertSee('Adresse de PC-A')
|
||||||
|
->assertSee('1/1') // réussi par le seul élève corrigé
|
||||||
|
->assertSee('Routage activé')
|
||||||
|
->assertSee('0/1') // pas réussi
|
||||||
|
->assertDontSee('Routage permanent'); // bonus : hors du parcours normal
|
||||||
|
});
|
||||||
|
|
||||||
|
test('les mains levées de la visio apparaissent sur le tableau de bord, avec l’action réelle « donner la parole »', function () {
|
||||||
|
$classe = Classe::factory()->create();
|
||||||
|
$lab = Lab::factory()->create(['identifiant' => '08-deux-lan-routeur']);
|
||||||
|
$seance = Seance::create([
|
||||||
|
'classe_id' => $classe->id, 'lab_id' => $lab->id, 'statut' => Seance::EN_COURS, 'demarree_a' => now(),
|
||||||
|
'visio_ouverte_a' => now(), 'visio_salle' => 'syllabus-test', 'visio_chat' => false,
|
||||||
|
]);
|
||||||
|
$eleve = Eleve::factory()->create(['classe_id' => $classe->id, 'prenom' => 'Léa']);
|
||||||
|
Cache::put("visio:{$seance->id}", [
|
||||||
|
'mains' => [$eleve->id => ['nom' => 'Léa', 'depuis' => time() - 90]],
|
||||||
|
'participants' => [], 'presences' => [],
|
||||||
|
], 3600);
|
||||||
|
|
||||||
|
commeProf($this, $classe->prof)
|
||||||
|
->get(route('mes-classes.show', $classe))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Mains levées')
|
||||||
|
->assertSee('Léa')
|
||||||
|
->assertSee('1 min 30 s')
|
||||||
|
->assertSee(route('seances.visio.parole', [$seance, $eleve]), false);
|
||||||
|
});
|
||||||
|
|
||||||
test('un prof ne voit pas la séance en cours d’une autre classe', function () {
|
test('un prof ne voit pas la séance en cours d’une autre classe', function () {
|
||||||
$classe = Classe::factory()->create();
|
$classe = Classe::factory()->create();
|
||||||
$autreClasse = Classe::factory()->create();
|
$autreClasse = Classe::factory()->create();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue