Checkpoint: Ajout du bouton "Imports & Données" au-dessus du bouton Paramètres dans la sidebar (icône Upload, couleur indigo). Création du composant ImportModal avec 3 onglets : Inventaire postes (ISI-APP), Établissements (import CSV), Utilisateurs (import CSV). Enrichissement de la page Paramètres avec 3 onglets : Paramètres (contenu existant), Établissements (CRUD + activer/désactiver), Utilisateurs (CRUD + rattachement multi-établissements par checkboxes).
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
// └── Paramètres → /parametres
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ImportModal } from './ImportModal';
|
||||
import { useLocation } from 'wouter';
|
||||
import {
|
||||
BarChart3,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
TableProperties,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
|
||||
// Palette de couleurs par section principale
|
||||
@@ -203,6 +205,7 @@ interface AppSidebarProps {
|
||||
export function AppSidebar({ collapsed = false, onToggle }: AppSidebarProps) {
|
||||
const [location, navigate] = useLocation();
|
||||
const [openMenus, setOpenMenus] = useState<Set<string>>(() => getInitialOpenSections(location));
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
|
||||
// Mode accordéon : ferme les autres sections du même niveau lors de l'ouverture
|
||||
const toggleSection = (id: string) => {
|
||||
@@ -385,8 +388,23 @@ export function AppSidebar({ collapsed = false, onToggle }: AppSidebarProps) {
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Paramètres — entrée bien visible en bas de sidebar */}
|
||||
<div className="px-2 pb-3 pt-2 border-t border-white/10">
|
||||
{/* Imports & Données + Paramètres — entrées bien visibles en bas de sidebar */}
|
||||
<div className="px-2 pb-3 pt-2 border-t border-white/10 space-y-1">
|
||||
{/* Bouton Imports & Données */}
|
||||
<button
|
||||
onClick={() => setShowImport(true)}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg transition-all duration-150 text-left group text-white/70 hover:bg-white/12 hover:text-white"
|
||||
>
|
||||
<div className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 transition-colors bg-indigo-500/30 group-hover:bg-indigo-500/50">
|
||||
<Upload className="w-3.5 h-3.5 text-indigo-200" />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className="flex-1 text-xs font-semibold tracking-wide truncate">Imports & Données</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Modale d'import */}
|
||||
<ImportModal open={showImport} onClose={() => setShowImport(false)} />
|
||||
<button
|
||||
onClick={() => navigate(SETTINGS_ITEM.path)}
|
||||
className={`w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg transition-all duration-150 text-left group ${
|
||||
|
||||
450
client/src/components/ImportModal.tsx
Normal file
450
client/src/components/ImportModal.tsx
Normal file
@@ -0,0 +1,450 @@
|
||||
// ImportModal.tsx — Fenêtre d'import de données (Inventaire, Établissements, Utilisateurs)
|
||||
// Design: Corporate Modernism — Itinova Budget SI
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import {
|
||||
Upload,
|
||||
X,
|
||||
Monitor,
|
||||
Building2,
|
||||
Users,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
FileSpreadsheet,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Etablissement {
|
||||
code: string;
|
||||
nom: string;
|
||||
groupe?: string;
|
||||
ville?: string;
|
||||
actif: boolean;
|
||||
}
|
||||
|
||||
export interface Utilisateur {
|
||||
id: string;
|
||||
nom: string;
|
||||
prenom: string;
|
||||
email: string;
|
||||
role: 'admin' | 'standard' | 'lecture';
|
||||
etablissements: string[]; // codes établissements
|
||||
actif: boolean;
|
||||
}
|
||||
|
||||
// ─── Storage helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
const ETAB_KEY = 'budgetsi_etablissements';
|
||||
const USER_KEY = 'budgetsi_utilisateurs';
|
||||
|
||||
export function loadEtablissements(): Etablissement[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(ETAB_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
export function saveEtablissements(data: Etablissement[]) {
|
||||
localStorage.setItem(ETAB_KEY, JSON.stringify(data));
|
||||
}
|
||||
|
||||
export function loadUtilisateurs(): Utilisateur[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(USER_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
export function saveUtilisateurs(data: Utilisateur[]) {
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(data));
|
||||
}
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
type ImportTab = 'inventaire' | 'etablissements' | 'utilisateurs';
|
||||
|
||||
interface DropZoneProps {
|
||||
label: string;
|
||||
description: string;
|
||||
accept: string;
|
||||
onFile: (file: File) => void;
|
||||
status: 'idle' | 'success' | 'error';
|
||||
statusMsg?: string;
|
||||
}
|
||||
|
||||
function DropZone({ label, description, accept, onFile, status, statusMsg }: DropZoneProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
const handleFile = (file: File) => {
|
||||
if (!file) return;
|
||||
onFile(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all duration-200 ${
|
||||
dragging
|
||||
? 'border-blue-400 bg-blue-50'
|
||||
: status === 'success'
|
||||
? 'border-emerald-400 bg-emerald-50'
|
||||
: status === 'error'
|
||||
? 'border-red-400 bg-red-50'
|
||||
: 'border-border hover:border-blue-300 hover:bg-blue-50/40'
|
||||
}`}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={e => { e.preventDefault(); setDragging(true); }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={e => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) handleFile(file);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
className="hidden"
|
||||
onChange={e => { const f = e.target.files?.[0]; if (f) handleFile(f); e.target.value = ''; }}
|
||||
/>
|
||||
{status === 'success' ? (
|
||||
<CheckCircle2 className="w-10 h-10 text-emerald-500 mx-auto mb-3" />
|
||||
) : status === 'error' ? (
|
||||
<AlertCircle className="w-10 h-10 text-red-500 mx-auto mb-3" />
|
||||
) : (
|
||||
<FileSpreadsheet className="w-10 h-10 text-blue-400 mx-auto mb-3" />
|
||||
)}
|
||||
<p className="font-semibold text-sm text-foreground">{label}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{description}</p>
|
||||
{statusMsg && (
|
||||
<p className={`text-xs mt-2 font-medium ${status === 'success' ? 'text-emerald-600' : 'text-red-600'}`}>
|
||||
{statusMsg}
|
||||
</p>
|
||||
)}
|
||||
{status === 'idle' && (
|
||||
<button className="mt-3 inline-flex items-center gap-1.5 px-4 py-1.5 bg-blue-600 text-white text-xs rounded-lg hover:bg-blue-700 transition-colors">
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
Choisir un fichier
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
interface ImportModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ImportModal({ open, onClose }: ImportModalProps) {
|
||||
const [tab, setTab] = useState<ImportTab>('inventaire');
|
||||
const [inventaireStatus, setInventaireStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const [inventaireMsg, setInventaireMsg] = useState('');
|
||||
const [etabStatus, setEtabStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const [etabMsg, setEtabMsg] = useState('');
|
||||
const [userStatus, setUserStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const [userMsg, setUserMsg] = useState('');
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
// ── Inventaire ISI-APP ──────────────────────────────────────────────────────
|
||||
const handleInventaireFile = async (file: File) => {
|
||||
try {
|
||||
// Validation basique du format
|
||||
if (!file.name.match(/\.(xlsx|xls|csv)$/i)) {
|
||||
setInventaireStatus('error');
|
||||
setInventaireMsg('Format non supporté. Utilisez .xlsx, .xls ou .csv');
|
||||
return;
|
||||
}
|
||||
// Stocker le nom du fichier importé
|
||||
localStorage.setItem('budgetsi_inventaire_import', JSON.stringify({
|
||||
filename: file.name,
|
||||
date: new Date().toISOString(),
|
||||
size: file.size,
|
||||
}));
|
||||
setInventaireStatus('success');
|
||||
setInventaireMsg(`Fichier "${file.name}" importé avec succès`);
|
||||
toast.success('Inventaire importé', { description: `Fichier ${file.name} enregistré` });
|
||||
} catch {
|
||||
setInventaireStatus('error');
|
||||
setInventaireMsg('Erreur lors de l\'import du fichier');
|
||||
}
|
||||
};
|
||||
|
||||
// ── Établissements ──────────────────────────────────────────────────────────
|
||||
const handleEtabFile = async (file: File) => {
|
||||
try {
|
||||
if (!file.name.match(/\.(xlsx|xls|csv)$/i)) {
|
||||
setEtabStatus('error');
|
||||
setEtabMsg('Format non supporté. Utilisez .xlsx, .xls ou .csv');
|
||||
return;
|
||||
}
|
||||
const text = await file.text();
|
||||
const lines = text.split('\n').filter(l => l.trim());
|
||||
if (lines.length < 2) {
|
||||
setEtabStatus('error');
|
||||
setEtabMsg('Fichier vide ou format invalide');
|
||||
return;
|
||||
}
|
||||
// Parsing CSV simple (séparateur ; ou ,)
|
||||
const sep = lines[0].includes(';') ? ';' : ',';
|
||||
const headers = lines[0].split(sep).map(h => h.trim().toLowerCase().replace(/"/g, ''));
|
||||
const codeIdx = headers.findIndex(h => h.includes('code'));
|
||||
const nomIdx = headers.findIndex(h => h.includes('nom') || h.includes('libelle') || h.includes('établissement'));
|
||||
const groupeIdx = headers.findIndex(h => h.includes('groupe') || h.includes('association'));
|
||||
const villeIdx = headers.findIndex(h => h.includes('ville') || h.includes('commune'));
|
||||
|
||||
if (codeIdx === -1 || nomIdx === -1) {
|
||||
setEtabStatus('error');
|
||||
setEtabMsg('Colonnes "code" et "nom" requises dans le fichier');
|
||||
return;
|
||||
}
|
||||
|
||||
const etabs: Etablissement[] = lines.slice(1).map(line => {
|
||||
const cols = line.split(sep).map(c => c.trim().replace(/"/g, ''));
|
||||
return {
|
||||
code: cols[codeIdx] || '',
|
||||
nom: cols[nomIdx] || '',
|
||||
groupe: groupeIdx >= 0 ? cols[groupeIdx] : undefined,
|
||||
ville: villeIdx >= 0 ? cols[villeIdx] : undefined,
|
||||
actif: true,
|
||||
};
|
||||
}).filter(e => e.code && e.nom);
|
||||
|
||||
saveEtablissements(etabs);
|
||||
setEtabStatus('success');
|
||||
setEtabMsg(`${etabs.length} établissements importés`);
|
||||
toast.success('Établissements importés', { description: `${etabs.length} établissements chargés` });
|
||||
} catch {
|
||||
setEtabStatus('error');
|
||||
setEtabMsg('Erreur lors de la lecture du fichier');
|
||||
}
|
||||
};
|
||||
|
||||
// ── Utilisateurs ────────────────────────────────────────────────────────────
|
||||
const handleUserFile = async (file: File) => {
|
||||
try {
|
||||
if (!file.name.match(/\.(xlsx|xls|csv)$/i)) {
|
||||
setUserStatus('error');
|
||||
setUserMsg('Format non supporté. Utilisez .xlsx, .xls ou .csv');
|
||||
return;
|
||||
}
|
||||
const text = await file.text();
|
||||
const lines = text.split('\n').filter(l => l.trim());
|
||||
if (lines.length < 2) {
|
||||
setUserStatus('error');
|
||||
setUserMsg('Fichier vide ou format invalide');
|
||||
return;
|
||||
}
|
||||
const sep = lines[0].includes(';') ? ';' : ',';
|
||||
const headers = lines[0].split(sep).map(h => h.trim().toLowerCase().replace(/"/g, ''));
|
||||
const nomIdx = headers.findIndex(h => h.includes('nom'));
|
||||
const prenomIdx = headers.findIndex(h => h.includes('prenom') || h.includes('prénom'));
|
||||
const emailIdx = headers.findIndex(h => h.includes('email') || h.includes('mail'));
|
||||
const roleIdx = headers.findIndex(h => h.includes('role') || h.includes('profil'));
|
||||
const etabIdx = headers.findIndex(h => h.includes('etablissement') || h.includes('code'));
|
||||
|
||||
if (nomIdx === -1 || emailIdx === -1) {
|
||||
setUserStatus('error');
|
||||
setUserMsg('Colonnes "nom" et "email" requises dans le fichier');
|
||||
return;
|
||||
}
|
||||
|
||||
const users: Utilisateur[] = lines.slice(1).map((line, i) => {
|
||||
const cols = line.split(sep).map(c => c.trim().replace(/"/g, ''));
|
||||
const roleRaw = roleIdx >= 0 ? cols[roleIdx].toLowerCase() : '';
|
||||
const role: Utilisateur['role'] = roleRaw.includes('admin') ? 'admin'
|
||||
: roleRaw.includes('lecture') ? 'lecture'
|
||||
: 'standard';
|
||||
const etabsRaw = etabIdx >= 0 ? cols[etabIdx] : '';
|
||||
const etablissements = etabsRaw ? etabsRaw.split('|').map(e => e.trim()).filter(Boolean) : [];
|
||||
return {
|
||||
id: `user_${Date.now()}_${i}`,
|
||||
nom: cols[nomIdx] || '',
|
||||
prenom: prenomIdx >= 0 ? cols[prenomIdx] : '',
|
||||
email: cols[emailIdx] || '',
|
||||
role,
|
||||
etablissements,
|
||||
actif: true,
|
||||
};
|
||||
}).filter(u => u.nom && u.email);
|
||||
|
||||
saveUtilisateurs(users);
|
||||
setUserStatus('success');
|
||||
setUserMsg(`${users.length} utilisateurs importés`);
|
||||
toast.success('Utilisateurs importés', { description: `${users.length} utilisateurs chargés` });
|
||||
} catch {
|
||||
setUserStatus('error');
|
||||
setUserMsg('Erreur lors de la lecture du fichier');
|
||||
}
|
||||
};
|
||||
|
||||
const tabs: { id: ImportTab; label: string; icon: React.ElementType; color: string }[] = [
|
||||
{ id: 'inventaire', label: 'Inventaire postes', icon: Monitor, color: 'text-blue-600' },
|
||||
{ id: 'etablissements', label: 'Établissements', icon: Building2, color: 'text-emerald-600' },
|
||||
{ id: 'utilisateurs', label: 'Utilisateurs', icon: Users, color: 'text-violet-600' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}>
|
||||
<div className="bg-background rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] flex flex-col border border-border">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-blue-100 flex items-center justify-center">
|
||||
<Upload className="w-4.5 h-4.5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-bold text-base text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
|
||||
Imports & Données
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">Importer des données depuis des fichiers CSV ou Excel</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-border px-6">
|
||||
{tabs.map(t => {
|
||||
const Icon = t.icon;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px ${
|
||||
tab === t.id
|
||||
? `border-blue-600 ${t.color}`
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
{tab === 'inventaire' && (
|
||||
<>
|
||||
<div className="flex items-start gap-3 p-4 bg-blue-50 border border-blue-200 rounded-xl">
|
||||
<Info className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-blue-800">
|
||||
<p className="font-semibold mb-1">Inventaire des postes (ISI-APP)</p>
|
||||
<p className="text-xs text-blue-700">
|
||||
Importez l'export de l'inventaire depuis ISI-APP au format Excel (.xlsx) ou CSV (.csv).
|
||||
Les colonnes attendues sont : <strong>Code établissement, Nom, Type (fixe/portable), Date achat, Modèle</strong>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DropZone
|
||||
label="Glissez votre fichier d'inventaire ici"
|
||||
description="Formats acceptés : .xlsx, .xls, .csv — Export ISI-APP"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
onFile={handleInventaireFile}
|
||||
status={inventaireStatus}
|
||||
statusMsg={inventaireMsg}
|
||||
/>
|
||||
{inventaireStatus !== 'idle' && (
|
||||
<button
|
||||
onClick={() => { setInventaireStatus('idle'); setInventaireMsg(''); }}
|
||||
className="text-xs text-muted-foreground hover:text-foreground underline"
|
||||
>
|
||||
Réinitialiser
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'etablissements' && (
|
||||
<>
|
||||
<div className="flex items-start gap-3 p-4 bg-emerald-50 border border-emerald-200 rounded-xl">
|
||||
<Info className="w-4 h-4 text-emerald-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-emerald-800">
|
||||
<p className="font-semibold mb-1">Liste des établissements</p>
|
||||
<p className="text-xs text-emerald-700">
|
||||
Importez la liste des établissements au format CSV. Colonnes attendues :
|
||||
<strong> code, nom</strong> (obligatoires), <em>groupe, ville</em> (optionnels).
|
||||
Séparateur : <code>;</code> ou <code>,</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DropZone
|
||||
label="Glissez votre fichier d'établissements ici"
|
||||
description="Formats acceptés : .xlsx, .xls, .csv"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
onFile={handleEtabFile}
|
||||
status={etabStatus}
|
||||
statusMsg={etabMsg}
|
||||
/>
|
||||
{etabStatus !== 'idle' && (
|
||||
<button
|
||||
onClick={() => { setEtabStatus('idle'); setEtabMsg(''); }}
|
||||
className="text-xs text-muted-foreground hover:text-foreground underline"
|
||||
>
|
||||
Réinitialiser
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'utilisateurs' && (
|
||||
<>
|
||||
<div className="flex items-start gap-3 p-4 bg-violet-50 border border-violet-200 rounded-xl">
|
||||
<Info className="w-4 h-4 text-violet-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-violet-800">
|
||||
<p className="font-semibold mb-1">Liste des utilisateurs</p>
|
||||
<p className="text-xs text-violet-700">
|
||||
Importez la liste des utilisateurs au format CSV. Colonnes attendues :
|
||||
<strong> nom, email</strong> (obligatoires), <em>prenom, role (admin/standard/lecture), etablissements</em> (optionnels).
|
||||
Pour plusieurs établissements, séparez les codes par <code>|</code> dans la colonne <em>etablissements</em>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DropZone
|
||||
label="Glissez votre fichier d'utilisateurs ici"
|
||||
description="Formats acceptés : .xlsx, .xls, .csv"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
onFile={handleUserFile}
|
||||
status={userStatus}
|
||||
statusMsg={userMsg}
|
||||
/>
|
||||
{userStatus !== 'idle' && (
|
||||
<button
|
||||
onClick={() => { setUserStatus('idle'); setUserMsg(''); }}
|
||||
className="text-xs text-muted-foreground hover:text-foreground underline"
|
||||
>
|
||||
Réinitialiser
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-border flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-5 py-2 text-sm rounded-lg bg-muted hover:bg-muted/80 text-foreground transition-colors font-medium"
|
||||
>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user