feat: Provisionner automatiquement un nouvel établissement
Some checks failed
CI / Naming Conventions (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Build Check (push) Has been cancelled

Lorsqu'un super-admin crée un établissement via l'interface, le système
doit automatiquement créer la base tenant, exécuter les migrations,
créer le premier utilisateur admin et envoyer l'invitation — le tout
de manière asynchrone pour ne pas bloquer la réponse HTTP.

Ce mécanisme rend chaque établissement opérationnel dès sa création
sans intervention manuelle sur l'infrastructure.
This commit is contained in:
2026-04-08 13:55:41 +02:00
parent bec211ebf0
commit 713e408773
65 changed files with 5070 additions and 374 deletions

View File

@@ -34,6 +34,7 @@
let files = $state<UploadedFile[]>(existingFiles);
let pendingFiles = $state<{ name: string; size: number }[]>([]);
let error = $state<string | null>(null);
let isDragging = $state(false);
let fileInput: HTMLInputElement;
$effect(() => {
@@ -52,6 +53,15 @@
return '📎';
}
function formatFileType(mimeType: string): string {
if (mimeType === 'application/pdf') return 'PDF';
if (mimeType === 'image/jpeg') return 'JPEG';
if (mimeType === 'image/png') return 'PNG';
if (mimeType.includes('wordprocessingml')) return 'DOCX';
const parts = mimeType.split('/');
return parts[1]?.toUpperCase() ?? mimeType;
}
function validateFile(file: File): string | null {
if (!acceptedTypes.includes(file.type)) {
return `Type de fichier non accepté : ${file.type}.`;
@@ -62,14 +72,10 @@
return null;
}
async function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
const selectedFiles = input.files;
if (!selectedFiles || selectedFiles.length === 0) return;
async function processFiles(fileList: globalThis.FileList) {
error = null;
for (const file of selectedFiles) {
for (const file of fileList) {
const validationError = validateFile(file);
if (validationError) {
error = validationError;
@@ -87,10 +93,37 @@
pendingFiles = pendingFiles.filter((p) => p.name !== file.name);
}
}
}
async function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
const selectedFiles = input.files;
if (!selectedFiles || selectedFiles.length === 0) return;
await processFiles(selectedFiles);
input.value = '';
}
function handleDragOver(event: DragEvent) {
event.preventDefault();
isDragging = true;
}
function handleDragLeave(event: DragEvent) {
const target = event.currentTarget as HTMLElement;
const related = event.relatedTarget as globalThis.Node | null;
if (related && target.contains(related)) return;
isDragging = false;
}
async function handleDrop(event: DragEvent) {
event.preventDefault();
isDragging = false;
const droppedFiles = event.dataTransfer?.files;
if (!droppedFiles || droppedFiles.length === 0) return;
await processFiles(droppedFiles);
}
async function handleDelete(fileId: string) {
error = null;
@@ -114,6 +147,7 @@
<li class="file-item">
<span class="file-icon">{getFileIcon(file.mimeType)}</span>
<span class="file-name">{file.filename}</span>
<span class="file-type">{formatFileType(file.mimeType)}</span>
<span class="file-size">{formatFileSize(file.fileSize)}</span>
{#if !disabled && showDelete}
<button
@@ -140,22 +174,38 @@
{/if}
{#if !disabled}
<button type="button" class="upload-btn" onclick={() => fileInput.click()}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48" />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="drop-zone"
class:drop-zone-active={isDragging}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
onclick={() => fileInput.click()}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
Ajouter un fichier
</button>
<p class="drop-zone-text">
Glissez-déposez vos fichiers ici ou
<button type="button" class="drop-zone-browse" onclick={(e) => { e.stopPropagation(); fileInput.click(); }}>
parcourir
</button>
</p>
<p class="upload-hint">{hint}</p>
</div>
<input
bind:this={fileInput}
type="file"
accept={acceptAttr}
onchange={handleFileSelect}
multiple
class="file-input-hidden"
aria-hidden="true"
tabindex="-1"
/>
<p class="upload-hint">{hint}</p>
{/if}
</div>
@@ -216,6 +266,16 @@
white-space: nowrap;
}
.file-type {
color: #6b7280;
font-size: 0.6875rem;
font-weight: 500;
padding: 0.0625rem 0.375rem;
background: #f3f4f6;
border-radius: 0.25rem;
flex-shrink: 0;
}
.file-size {
color: #9ca3af;
font-size: 0.75rem;
@@ -249,25 +309,51 @@
color: #dc2626;
}
.upload-btn {
display: inline-flex;
.drop-zone {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
border: 1px dashed #d1d5db;
border-radius: 0.375rem;
background: white;
color: #374151;
font-size: 0.875rem;
gap: 0.5rem;
padding: 1.5rem;
border: 2px dashed #d1d5db;
border-radius: 0.5rem;
background: #fafafa;
color: #6b7280;
cursor: pointer;
transition: border-color 0.15s, background-color 0.15s;
align-self: flex-start;
}
.upload-btn:hover {
border-color: #3b82f6;
.drop-zone:hover {
border-color: #93c5fd;
background: #eff6ff;
}
.drop-zone-active {
border-color: #3b82f6;
background: #dbeafe;
}
.drop-zone-text {
margin: 0;
font-size: 0.875rem;
color: #6b7280;
text-align: center;
}
.drop-zone-browse {
display: inline;
padding: 0;
border: none;
background: none;
color: #2563eb;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
text-decoration: underline;
}
.drop-zone-browse:hover {
color: #1d4ed8;
}
.file-input-hidden {