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.
373 lines
8.2 KiB
Svelte
373 lines
8.2 KiB
Svelte
<script lang="ts">
|
|
const DEFAULT_ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png'];
|
|
const DEFAULT_ACCEPT_ATTR = '.pdf,.jpg,.jpeg,.png';
|
|
const DEFAULT_HINT = 'PDF, JPEG ou PNG — 10 Mo max par fichier';
|
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 Mo
|
|
|
|
interface UploadedFile {
|
|
id: string;
|
|
filename: string;
|
|
fileSize: number;
|
|
mimeType: string;
|
|
}
|
|
|
|
let {
|
|
existingFiles = [],
|
|
onUpload,
|
|
onDelete,
|
|
disabled = false,
|
|
acceptedTypes = DEFAULT_ACCEPTED_TYPES,
|
|
acceptAttr = DEFAULT_ACCEPT_ATTR,
|
|
hint = DEFAULT_HINT,
|
|
showDelete = true
|
|
}: {
|
|
existingFiles?: UploadedFile[];
|
|
onUpload: (file: File) => Promise<UploadedFile>;
|
|
onDelete: (fileId: string) => Promise<void>;
|
|
disabled?: boolean;
|
|
acceptedTypes?: string[];
|
|
acceptAttr?: string;
|
|
hint?: string;
|
|
showDelete?: boolean;
|
|
} = $props();
|
|
|
|
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(() => {
|
|
files = existingFiles;
|
|
});
|
|
|
|
function formatFileSize(bytes: number): string {
|
|
if (bytes < 1024) return `${bytes} o`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
|
|
}
|
|
|
|
function getFileIcon(mimeType: string): string {
|
|
if (mimeType === 'application/pdf') return '📄';
|
|
if (mimeType.startsWith('image/')) return '🖼️';
|
|
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}.`;
|
|
}
|
|
if (file.size > MAX_FILE_SIZE) {
|
|
return `Le fichier dépasse la taille maximale de 10 Mo (${formatFileSize(file.size)}).`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function processFiles(fileList: globalThis.FileList) {
|
|
error = null;
|
|
|
|
for (const file of fileList) {
|
|
const validationError = validateFile(file);
|
|
if (validationError) {
|
|
error = validationError;
|
|
continue;
|
|
}
|
|
|
|
pendingFiles = [...pendingFiles, { name: file.name, size: file.size }];
|
|
|
|
try {
|
|
const uploaded = await onUpload(file);
|
|
files = [...files, uploaded];
|
|
} catch {
|
|
error = `Erreur lors de l'envoi de "${file.name}".`;
|
|
} finally {
|
|
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;
|
|
|
|
try {
|
|
await onDelete(fileId);
|
|
files = files.filter((f) => f.id !== fileId);
|
|
} catch {
|
|
error = 'Erreur lors de la suppression du fichier.';
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="file-upload" class:disabled>
|
|
{#if error}
|
|
<p class="upload-error" role="alert">{error}</p>
|
|
{/if}
|
|
|
|
{#if files.length > 0 || pendingFiles.length > 0}
|
|
<ul class="file-list">
|
|
{#each files as file}
|
|
<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
|
|
type="button"
|
|
class="file-remove"
|
|
onclick={() => handleDelete(file.id)}
|
|
title="Supprimer {file.filename}"
|
|
aria-label="Supprimer {file.filename}"
|
|
>
|
|
✕
|
|
</button>
|
|
{/if}
|
|
</li>
|
|
{/each}
|
|
{#each pendingFiles as pending}
|
|
<li class="file-item file-pending">
|
|
<span class="file-icon">⏳</span>
|
|
<span class="file-name">{pending.name}</span>
|
|
<span class="file-size">{formatFileSize(pending.size)}</span>
|
|
<span class="file-uploading">Envoi...</span>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
|
|
{#if !disabled}
|
|
<!-- 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>
|
|
<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"
|
|
/>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.file-upload {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.file-upload.disabled {
|
|
opacity: 0.6;
|
|
}
|
|
|
|
.upload-error {
|
|
margin: 0;
|
|
padding: 0.5rem 0.75rem;
|
|
background: #fee2e2;
|
|
border-radius: 0.375rem;
|
|
color: #991b1b;
|
|
font-size: 0.8125rem;
|
|
}
|
|
|
|
.file-list {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.375rem;
|
|
}
|
|
|
|
.file-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.5rem 0.75rem;
|
|
background: #f9fafb;
|
|
border: 1px solid #e5e7eb;
|
|
border-radius: 0.375rem;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.file-pending {
|
|
opacity: 0.6;
|
|
}
|
|
|
|
.file-icon {
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.file-name {
|
|
flex: 1;
|
|
color: #374151;
|
|
font-weight: 500;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
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;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.file-uploading {
|
|
color: #3b82f6;
|
|
font-size: 0.75rem;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.file-remove {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 1.5rem;
|
|
height: 1.5rem;
|
|
border: none;
|
|
border-radius: 50%;
|
|
background: #e5e7eb;
|
|
color: #6b7280;
|
|
font-size: 0.625rem;
|
|
cursor: pointer;
|
|
flex-shrink: 0;
|
|
transition: background-color 0.15s;
|
|
}
|
|
|
|
.file-remove:hover {
|
|
background: #fecaca;
|
|
color: #dc2626;
|
|
}
|
|
|
|
.drop-zone {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
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;
|
|
}
|
|
|
|
.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 {
|
|
position: absolute;
|
|
width: 0;
|
|
height: 0;
|
|
overflow: hidden;
|
|
opacity: 0;
|
|
}
|
|
|
|
.upload-hint {
|
|
margin: 0;
|
|
font-size: 0.75rem;
|
|
color: #9ca3af;
|
|
}
|
|
</style>
|