feat(mobile): refonte design DSFR complète + partie admin
- écrans auth/dashboard/journal/info/urgence/profil refondus sur le design system - AppBar institutionnel République Française sur tous les écrans - partie admin (utilisateurs, contenus, émotions) avec AdminGuard - entrée Espace administration dans le profil (admins uniquement) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { Stack } from 'expo-router';
|
||||
import AdminGuard from '@/components/AdminGuard';
|
||||
import { Colors } from '@/lib/colors';
|
||||
|
||||
/** Pile de navigation de l'espace d'administration (protégée par AdminGuard). */
|
||||
export default function AdminLayout() {
|
||||
return (
|
||||
<AdminGuard>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: Colors.white },
|
||||
headerTintColor: Colors.primary,
|
||||
headerTitleStyle: { fontWeight: '700', color: Colors.black },
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" options={{ title: 'Administration' }} />
|
||||
<Stack.Screen name="utilisateurs" options={{ title: 'Utilisateurs' }} />
|
||||
<Stack.Screen name="contenus" options={{ title: 'Contenus' }} />
|
||||
<Stack.Screen name="emotions" options={{ title: 'Émotions' }} />
|
||||
</Stack>
|
||||
</AdminGuard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
FlatList,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Badge, Button, Card, EmptyState, Loader, TextField } from '@/components/ui';
|
||||
import { useAdminFeeds, useSaveFeed, useDeleteFeed } from '@/hooks/useAdmin';
|
||||
import { Colors } from '@/lib/colors';
|
||||
import { FontSize, FontWeight, HIT_SLOP, Radius, Spacing } from '@/lib/theme';
|
||||
import type { Feed } from '@/types';
|
||||
|
||||
interface FormState {
|
||||
id?: number;
|
||||
titre: string;
|
||||
contenu: string;
|
||||
ordre: string;
|
||||
est_publie: boolean;
|
||||
}
|
||||
|
||||
const FORM_VIDE: FormState = { titre: '', contenu: '', ordre: '0', est_publie: false };
|
||||
|
||||
function messageErreur(e: unknown, defaut: string): string {
|
||||
const err = e as { response?: { data?: { message?: string } } };
|
||||
return err?.response?.data?.message ?? defaut;
|
||||
}
|
||||
|
||||
export default function AdminContenusScreen() {
|
||||
const { data: feeds, isLoading } = useAdminFeeds();
|
||||
const saveMut = useSaveFeed();
|
||||
const deleteMut = useDeleteFeed();
|
||||
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [form, setForm] = useState<FormState>(FORM_VIDE);
|
||||
|
||||
const ouvrirCreation = () => {
|
||||
setForm(FORM_VIDE);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const ouvrirEdition = (f: Feed) => {
|
||||
setForm({ id: f.id, titre: f.titre, contenu: f.contenu, ordre: String(f.ordre), est_publie: f.est_publie });
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const set = (cle: 'titre' | 'contenu' | 'ordre') => (valeur: string) => setForm((f) => ({ ...f, [cle]: valeur }));
|
||||
|
||||
const soumettre = async () => {
|
||||
if (!form.titre.trim() || !form.contenu.trim()) {
|
||||
Alert.alert('Champs requis', 'Le titre et le contenu sont obligatoires.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await saveMut.mutateAsync({
|
||||
id: form.id,
|
||||
titre: form.titre.trim(),
|
||||
contenu: form.contenu.trim(),
|
||||
ordre: Number.parseInt(form.ordre, 10) || 0,
|
||||
est_publie: form.est_publie,
|
||||
});
|
||||
setModalVisible(false);
|
||||
} catch (e) {
|
||||
Alert.alert('Erreur', messageErreur(e, 'Enregistrement impossible.'));
|
||||
}
|
||||
};
|
||||
|
||||
const supprimer = (f: Feed) => {
|
||||
Alert.alert('Supprimer', `Supprimer « ${f.titre} » ?`, [
|
||||
{ text: 'Annuler', style: 'cancel' },
|
||||
{
|
||||
text: 'Supprimer',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await deleteMut.mutateAsync(f.id);
|
||||
} catch (e) {
|
||||
Alert.alert('Erreur', messageErreur(e, 'Suppression impossible.'));
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const renderItem = ({ item }: { item: Feed }) => (
|
||||
<Card style={styles.card}>
|
||||
<Text style={styles.titre}>{item.titre}</Text>
|
||||
<View style={styles.badges}>
|
||||
<Badge label={item.est_publie ? 'Publié' : 'Brouillon'} tone={item.est_publie ? 'success' : 'warning'} />
|
||||
<Badge label={`Ordre ${item.ordre}`} tone="neutral" />
|
||||
</View>
|
||||
<Text style={styles.extrait} numberOfLines={2}>
|
||||
{item.contenu}
|
||||
</Text>
|
||||
<View style={styles.actions}>
|
||||
<Button title="Modifier" variant="secondary" size="sm" icon="create-outline" onPress={() => ouvrirEdition(item)} />
|
||||
<Button title="Supprimer" variant="danger" size="sm" icon="trash-outline" onPress={() => supprimer(item)} />
|
||||
</View>
|
||||
</Card>
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loader label="Chargement des contenus…" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<FlatList
|
||||
data={feeds ?? []}
|
||||
keyExtractor={(item) => String(item.id)}
|
||||
renderItem={renderItem}
|
||||
contentContainerStyle={styles.list}
|
||||
ListHeaderComponent={
|
||||
<View style={styles.headerAction}>
|
||||
<Button title="Nouveau contenu" icon="add" fullWidth onPress={ouvrirCreation} />
|
||||
</View>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<EmptyState icon="newspaper-outline" title="Aucun contenu" message="Créez un premier article d’information." />
|
||||
}
|
||||
/>
|
||||
|
||||
<Modal visible={modalVisible} animationType="slide" transparent onRequestClose={() => setModalVisible(false)}>
|
||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.modalOverlay}>
|
||||
<View style={styles.modalSheet}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>{form.id ? 'Modifier le contenu' : 'Nouveau contenu'}</Text>
|
||||
<Pressable hitSlop={HIT_SLOP} onPress={() => setModalVisible(false)} accessibilityLabel="Fermer">
|
||||
<Ionicons name="close" size={24} color={Colors.gray[600]} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<ScrollView contentContainerStyle={styles.modalBody} keyboardShouldPersistTaps="handled">
|
||||
<TextField label="Titre" value={form.titre} onChangeText={set('titre')} required autoCapitalize="sentences" />
|
||||
<TextField
|
||||
label="Contenu"
|
||||
value={form.contenu}
|
||||
onChangeText={set('contenu')}
|
||||
required
|
||||
multiline
|
||||
numberOfLines={6}
|
||||
autoCapitalize="sentences"
|
||||
/>
|
||||
<TextField label="Ordre d’affichage" value={form.ordre} onChangeText={set('ordre')} keyboardType="numeric" />
|
||||
|
||||
<View style={styles.switchRow}>
|
||||
<View style={styles.switchText}>
|
||||
<Text style={styles.fieldLabel}>Publié</Text>
|
||||
<Text style={styles.switchHelper}>
|
||||
{form.est_publie ? 'Visible par le public' : 'Brouillon (masqué)'}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={form.est_publie}
|
||||
onValueChange={(v) => setForm((f) => ({ ...f, est_publie: v }))}
|
||||
trackColor={{ true: Colors.primary, false: Colors.gray[300] }}
|
||||
thumbColor={Colors.white}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.modalActions}>
|
||||
<Button title="Annuler" variant="tertiary" onPress={() => setModalVisible(false)} />
|
||||
<Button title="Enregistrer" icon="checkmark" loading={saveMut.isPending} onPress={soumettre} />
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: { flex: 1, backgroundColor: Colors.gray[50] },
|
||||
list: { padding: Spacing.lg, gap: Spacing.md },
|
||||
headerAction: { marginBottom: Spacing.md },
|
||||
card: { padding: Spacing.lg, gap: Spacing.sm },
|
||||
titre: { fontSize: FontSize.md, fontWeight: FontWeight.bold, color: Colors.black },
|
||||
badges: { flexDirection: 'row', gap: Spacing.sm, flexWrap: 'wrap' },
|
||||
extrait: { fontSize: FontSize.sm, color: Colors.gray[500], lineHeight: 20 },
|
||||
actions: { flexDirection: 'row', gap: Spacing.sm, marginTop: Spacing.xs },
|
||||
modalOverlay: { flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(22,22,22,0.4)' },
|
||||
modalSheet: {
|
||||
backgroundColor: Colors.white,
|
||||
borderTopLeftRadius: Radius.md,
|
||||
borderTopRightRadius: Radius.md,
|
||||
maxHeight: '90%',
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: Spacing.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: Colors.gray[200],
|
||||
},
|
||||
modalTitle: { fontSize: FontSize.lg, fontWeight: FontWeight.bold, color: Colors.black },
|
||||
modalBody: { padding: Spacing.lg },
|
||||
fieldLabel: { fontSize: FontSize.sm, fontWeight: FontWeight.medium, color: Colors.black },
|
||||
switchRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: Spacing.sm,
|
||||
marginBottom: Spacing.md,
|
||||
},
|
||||
switchText: { flex: 1, paddingRight: Spacing.md },
|
||||
switchHelper: { fontSize: FontSize.xs, color: Colors.gray[500], marginTop: 2 },
|
||||
modalActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: Spacing.sm, marginTop: Spacing.sm },
|
||||
});
|
||||
@@ -0,0 +1,361 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Button, Card, EmptyState, Loader, SectionTitle, TextField } from '@/components/ui';
|
||||
import { useAdminEmotions, useSaveEmotion, useDeleteEmotion } from '@/hooks/useAdmin';
|
||||
import { Colors } from '@/lib/colors';
|
||||
import { FontSize, FontWeight, HIT_SLOP, Radius, Spacing } from '@/lib/theme';
|
||||
import type { Emotion } from '@/types';
|
||||
|
||||
interface FormState {
|
||||
id?: number;
|
||||
nom: string;
|
||||
couleur: string;
|
||||
icone: string;
|
||||
niveau: number;
|
||||
parent_id: number | null;
|
||||
est_actif: boolean;
|
||||
}
|
||||
|
||||
const FORM_VIDE: FormState = { nom: '', couleur: '#000091', icone: '', niveau: 1, parent_id: null, est_actif: true };
|
||||
|
||||
function messageErreur(e: unknown, defaut: string): string {
|
||||
const err = e as { response?: { data?: { message?: string } } };
|
||||
return err?.response?.data?.message ?? defaut;
|
||||
}
|
||||
|
||||
/** Regroupe une liste d'émotions (plate ou imbriquée) en niveau 1 + leurs enfants. */
|
||||
function organiser(emotions: Emotion[] | undefined): { parent: Emotion; enfants: Emotion[] }[] {
|
||||
if (!emotions) return [];
|
||||
const racines = emotions.filter((e) => e.niveau === 1);
|
||||
return racines.map((parent) => {
|
||||
const depuisEnfants = parent.enfants ?? [];
|
||||
const depuisPlat = emotions.filter((e) => e.niveau === 2 && e.parent_id === parent.id);
|
||||
// On dédoublonne par id au cas où les deux sources coexistent.
|
||||
const map = new Map<number, Emotion>();
|
||||
[...depuisEnfants, ...depuisPlat].forEach((e) => map.set(e.id, e));
|
||||
return { parent, enfants: Array.from(map.values()) };
|
||||
});
|
||||
}
|
||||
|
||||
export default function AdminEmotionsScreen() {
|
||||
const { data: emotions, isLoading } = useAdminEmotions();
|
||||
const saveMut = useSaveEmotion();
|
||||
const deleteMut = useDeleteEmotion();
|
||||
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [form, setForm] = useState<FormState>(FORM_VIDE);
|
||||
|
||||
const groupes = useMemo(() => organiser(emotions), [emotions]);
|
||||
const parents = useMemo(() => (emotions ?? []).filter((e) => e.niveau === 1), [emotions]);
|
||||
|
||||
const ouvrirCreation = () => {
|
||||
setForm(FORM_VIDE);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const ouvrirEdition = (e: Emotion) => {
|
||||
setForm({
|
||||
id: e.id,
|
||||
nom: e.nom,
|
||||
couleur: e.couleur,
|
||||
icone: e.icone ?? '',
|
||||
niveau: e.niveau,
|
||||
parent_id: e.parent_id,
|
||||
est_actif: e.est_actif,
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const soumettre = async () => {
|
||||
if (!form.nom.trim()) {
|
||||
Alert.alert('Champs requis', 'Le nom est obligatoire.');
|
||||
return;
|
||||
}
|
||||
if (!/^#([0-9A-Fa-f]{6})$/.test(form.couleur.trim())) {
|
||||
Alert.alert('Couleur', 'Saisissez une couleur hexadécimale (ex. #000091).');
|
||||
return;
|
||||
}
|
||||
if (form.niveau === 2 && !form.parent_id) {
|
||||
Alert.alert('Émotion parente', 'Choisissez l’émotion parente pour une sous-émotion.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await saveMut.mutateAsync({
|
||||
id: form.id,
|
||||
nom: form.nom.trim(),
|
||||
couleur: form.couleur.trim(),
|
||||
icone: form.icone.trim() || null,
|
||||
niveau: form.niveau,
|
||||
parent_id: form.niveau === 2 ? form.parent_id : null,
|
||||
est_actif: form.est_actif,
|
||||
});
|
||||
setModalVisible(false);
|
||||
} catch (e) {
|
||||
Alert.alert('Erreur', messageErreur(e, 'Enregistrement impossible.'));
|
||||
}
|
||||
};
|
||||
|
||||
const supprimer = (e: Emotion) => {
|
||||
Alert.alert('Supprimer', `Supprimer « ${e.nom} » ?`, [
|
||||
{ text: 'Annuler', style: 'cancel' },
|
||||
{
|
||||
text: 'Supprimer',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await deleteMut.mutateAsync(e.id);
|
||||
} catch (err) {
|
||||
Alert.alert('Erreur', messageErreur(err, 'Suppression impossible.'));
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const renderEmotion = (e: Emotion, estEnfant: boolean) => (
|
||||
<Card key={e.id} style={[styles.emotionCard, estEnfant && styles.emotionEnfant]}>
|
||||
<View style={styles.emotionRow}>
|
||||
<View style={[styles.pastille, { backgroundColor: e.couleur }]}>
|
||||
{e.icone ? <Text style={styles.pastilleIcone}>{e.icone}</Text> : null}
|
||||
</View>
|
||||
<View style={styles.emotionInfo}>
|
||||
<Text style={styles.emotionNom}>{e.nom}</Text>
|
||||
<Text style={styles.emotionMeta}>
|
||||
Niveau {e.niveau} · {e.est_actif ? 'Actif' : 'Inactif'} · {e.couleur}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.actions}>
|
||||
<Button title="Modifier" variant="secondary" size="sm" icon="create-outline" onPress={() => ouvrirEdition(e)} />
|
||||
<Button title="Supprimer" variant="danger" size="sm" icon="trash-outline" onPress={() => supprimer(e)} />
|
||||
</View>
|
||||
</Card>
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loader label="Chargement des émotions…" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<ScrollView contentContainerStyle={styles.list}>
|
||||
<View style={styles.headerAction}>
|
||||
<Button title="Nouvelle émotion" icon="add" fullWidth onPress={ouvrirCreation} />
|
||||
</View>
|
||||
|
||||
{groupes.length === 0 ? (
|
||||
<EmptyState icon="color-palette-outline" title="Aucune émotion" message="Créez la première émotion du référentiel." />
|
||||
) : (
|
||||
groupes.map(({ parent, enfants }) => (
|
||||
<View key={parent.id} style={styles.groupe}>
|
||||
<SectionTitle>{parent.nom}</SectionTitle>
|
||||
{renderEmotion(parent, false)}
|
||||
{enfants.map((enfant) => renderEmotion(enfant, true))}
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<Modal visible={modalVisible} animationType="slide" transparent onRequestClose={() => setModalVisible(false)}>
|
||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.modalOverlay}>
|
||||
<View style={styles.modalSheet}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>{form.id ? 'Modifier l’émotion' : 'Nouvelle émotion'}</Text>
|
||||
<Pressable hitSlop={HIT_SLOP} onPress={() => setModalVisible(false)} accessibilityLabel="Fermer">
|
||||
<Ionicons name="close" size={24} color={Colors.gray[600]} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<ScrollView contentContainerStyle={styles.modalBody} keyboardShouldPersistTaps="handled">
|
||||
<TextField
|
||||
label="Nom"
|
||||
value={form.nom}
|
||||
onChangeText={(v) => setForm((f) => ({ ...f, nom: v }))}
|
||||
required
|
||||
autoCapitalize="words"
|
||||
/>
|
||||
<View style={styles.couleurRow}>
|
||||
<View style={styles.couleurField}>
|
||||
<TextField
|
||||
label="Couleur (hex)"
|
||||
value={form.couleur}
|
||||
onChangeText={(v) => setForm((f) => ({ ...f, couleur: v }))}
|
||||
placeholder="#000091"
|
||||
required
|
||||
/>
|
||||
</View>
|
||||
<View style={[styles.couleurApercu, { backgroundColor: /^#([0-9A-Fa-f]{6})$/.test(form.couleur) ? form.couleur : Colors.gray[200] }]} />
|
||||
</View>
|
||||
<TextField
|
||||
label="Icône (emoji, optionnel)"
|
||||
value={form.icone}
|
||||
onChangeText={(v) => setForm((f) => ({ ...f, icone: v }))}
|
||||
placeholder="😊"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
|
||||
<Text style={styles.fieldLabel}>Niveau</Text>
|
||||
<View style={styles.segment}>
|
||||
<Pressable
|
||||
style={[styles.segmentItem, form.niveau === 1 && styles.segmentItemActive]}
|
||||
onPress={() => setForm((f) => ({ ...f, niveau: 1, parent_id: null }))}
|
||||
>
|
||||
<Text style={[styles.segmentText, form.niveau === 1 && styles.segmentTextActive]}>
|
||||
1 · Émotion
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.segmentItem, form.niveau === 2 && styles.segmentItemActive]}
|
||||
onPress={() => setForm((f) => ({ ...f, niveau: 2 }))}
|
||||
>
|
||||
<Text style={[styles.segmentText, form.niveau === 2 && styles.segmentTextActive]}>
|
||||
2 · Sous-émotion
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{form.niveau === 2 ? (
|
||||
<>
|
||||
<Text style={styles.fieldLabel}>Émotion parente</Text>
|
||||
<View style={styles.parentList}>
|
||||
{parents.length === 0 ? (
|
||||
<Text style={styles.switchHelper}>Aucune émotion de niveau 1 disponible.</Text>
|
||||
) : (
|
||||
parents.map((p) => {
|
||||
const actif = form.parent_id === p.id;
|
||||
return (
|
||||
<Pressable
|
||||
key={p.id}
|
||||
style={[styles.parentChip, actif && styles.parentChipActive]}
|
||||
onPress={() => setForm((f) => ({ ...f, parent_id: p.id }))}
|
||||
>
|
||||
<View style={[styles.parentDot, { backgroundColor: p.couleur }]} />
|
||||
<Text style={[styles.parentChipText, actif && styles.parentChipTextActive]}>{p.nom}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<View style={styles.switchRow}>
|
||||
<View style={styles.switchText}>
|
||||
<Text style={styles.fieldLabel}>Actif</Text>
|
||||
<Text style={styles.switchHelper}>
|
||||
{form.est_actif ? 'Disponible dans le tracker' : 'Masqué du tracker'}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={form.est_actif}
|
||||
onValueChange={(v) => setForm((f) => ({ ...f, est_actif: v }))}
|
||||
trackColor={{ true: Colors.primary, false: Colors.gray[300] }}
|
||||
thumbColor={Colors.white}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.modalActions}>
|
||||
<Button title="Annuler" variant="tertiary" onPress={() => setModalVisible(false)} />
|
||||
<Button title="Enregistrer" icon="checkmark" loading={saveMut.isPending} onPress={soumettre} />
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: { flex: 1, backgroundColor: Colors.gray[50] },
|
||||
list: { padding: Spacing.lg, gap: Spacing.md },
|
||||
headerAction: { marginBottom: Spacing.md },
|
||||
groupe: { gap: Spacing.sm, marginBottom: Spacing.md },
|
||||
emotionCard: { padding: Spacing.md, gap: Spacing.sm },
|
||||
emotionEnfant: { marginLeft: Spacing.xl, borderColor: Colors.gray[100] },
|
||||
emotionRow: { flexDirection: 'row', alignItems: 'center', gap: Spacing.md },
|
||||
pastille: { width: 36, height: 36, borderRadius: Radius.pill, alignItems: 'center', justifyContent: 'center' },
|
||||
pastilleIcone: { fontSize: 18 },
|
||||
emotionInfo: { flex: 1 },
|
||||
emotionNom: { fontSize: FontSize.md, fontWeight: FontWeight.semibold, color: Colors.black },
|
||||
emotionMeta: { fontSize: FontSize.xs, color: Colors.gray[500], marginTop: 2 },
|
||||
actions: { flexDirection: 'row', gap: Spacing.sm },
|
||||
modalOverlay: { flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(22,22,22,0.4)' },
|
||||
modalSheet: {
|
||||
backgroundColor: Colors.white,
|
||||
borderTopLeftRadius: Radius.md,
|
||||
borderTopRightRadius: Radius.md,
|
||||
maxHeight: '92%',
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: Spacing.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: Colors.gray[200],
|
||||
},
|
||||
modalTitle: { fontSize: FontSize.lg, fontWeight: FontWeight.bold, color: Colors.black },
|
||||
modalBody: { padding: Spacing.lg },
|
||||
fieldLabel: { fontSize: FontSize.sm, fontWeight: FontWeight.medium, color: Colors.black, marginBottom: Spacing.xs },
|
||||
couleurRow: { flexDirection: 'row', alignItems: 'flex-start', gap: Spacing.md },
|
||||
couleurField: { flex: 1 },
|
||||
couleurApercu: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: Radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: Colors.gray[200],
|
||||
marginTop: 24,
|
||||
},
|
||||
segment: {
|
||||
flexDirection: 'row',
|
||||
borderWidth: 1,
|
||||
borderColor: Colors.primary,
|
||||
borderRadius: Radius.sm,
|
||||
overflow: 'hidden',
|
||||
marginBottom: Spacing.lg,
|
||||
},
|
||||
segmentItem: { flex: 1, paddingVertical: Spacing.md, alignItems: 'center', backgroundColor: Colors.white },
|
||||
segmentItemActive: { backgroundColor: Colors.primary },
|
||||
segmentText: { fontSize: FontSize.sm, fontWeight: FontWeight.semibold, color: Colors.primary },
|
||||
segmentTextActive: { color: Colors.white },
|
||||
parentList: { flexDirection: 'row', flexWrap: 'wrap', gap: Spacing.sm, marginBottom: Spacing.lg },
|
||||
parentChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: Spacing.xs,
|
||||
paddingVertical: Spacing.sm,
|
||||
paddingHorizontal: Spacing.md,
|
||||
borderRadius: Radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: Colors.gray[300],
|
||||
backgroundColor: Colors.white,
|
||||
minHeight: 44,
|
||||
},
|
||||
parentChipActive: { borderColor: Colors.primary, backgroundColor: '#E3E3FD' },
|
||||
parentDot: { width: 14, height: 14, borderRadius: Radius.pill },
|
||||
parentChipText: { fontSize: FontSize.sm, color: Colors.gray[600] },
|
||||
parentChipTextActive: { color: Colors.primary, fontWeight: FontWeight.semibold },
|
||||
switchRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: Spacing.sm,
|
||||
marginBottom: Spacing.md,
|
||||
},
|
||||
switchText: { flex: 1, paddingRight: Spacing.md },
|
||||
switchHelper: { fontSize: FontSize.xs, color: Colors.gray[500], marginTop: 2 },
|
||||
modalActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: Spacing.sm, marginTop: Spacing.sm },
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Card, SectionTitle } from '@/components/ui';
|
||||
import { useAdminUtilisateurs, useAdminFeeds, useAdminEmotions } from '@/hooks/useAdmin';
|
||||
import { Colors } from '@/lib/colors';
|
||||
import { FontSize, FontWeight, Radius, Spacing } from '@/lib/theme';
|
||||
|
||||
interface HubItem {
|
||||
href: '/admin/utilisateurs' | '/admin/contenus' | '/admin/emotions';
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
titre: string;
|
||||
sousTitre: string;
|
||||
compteur?: number;
|
||||
}
|
||||
|
||||
/** Hub d'administration : accès aux trois sections de gestion. */
|
||||
export default function AdminHubScreen() {
|
||||
const router = useRouter();
|
||||
const { data: utilisateurs } = useAdminUtilisateurs();
|
||||
const { data: feeds } = useAdminFeeds();
|
||||
const { data: emotions } = useAdminEmotions();
|
||||
|
||||
const items: HubItem[] = [
|
||||
{
|
||||
href: '/admin/utilisateurs',
|
||||
icon: 'people-outline',
|
||||
titre: 'Utilisateurs',
|
||||
sousTitre: 'Comptes, rôles et activation',
|
||||
compteur: utilisateurs?.length,
|
||||
},
|
||||
{
|
||||
href: '/admin/contenus',
|
||||
icon: 'newspaper-outline',
|
||||
titre: 'Contenus',
|
||||
sousTitre: 'Articles d’information santé mentale',
|
||||
compteur: feeds?.length,
|
||||
},
|
||||
{
|
||||
href: '/admin/emotions',
|
||||
icon: 'color-palette-outline',
|
||||
titre: 'Émotions',
|
||||
sousTitre: 'Référentiel des émotions et sous-émotions',
|
||||
compteur: emotions?.length,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.screen} contentContainerStyle={styles.content}>
|
||||
<SectionTitle>Espace d’administration</SectionTitle>
|
||||
<Text style={styles.intro}>Gérez les utilisateurs, les contenus et le référentiel des émotions.</Text>
|
||||
|
||||
<View style={styles.list}>
|
||||
{items.map((item) => (
|
||||
<Card key={item.href} onPress={() => router.push(item.href)} style={styles.card}>
|
||||
<View style={styles.row}>
|
||||
<View style={styles.iconCircle}>
|
||||
<Ionicons name={item.icon} size={26} color={Colors.primary} />
|
||||
</View>
|
||||
<View style={styles.cardBody}>
|
||||
<Text style={styles.cardTitle}>{item.titre}</Text>
|
||||
<Text style={styles.cardSubtitle}>{item.sousTitre}</Text>
|
||||
</View>
|
||||
<View style={styles.cardEnd}>
|
||||
{typeof item.compteur === 'number' ? (
|
||||
<Text style={styles.count}>{item.compteur}</Text>
|
||||
) : null}
|
||||
<Ionicons name="chevron-forward" size={20} color={Colors.gray[400]} />
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: { flex: 1, backgroundColor: Colors.gray[50] },
|
||||
content: { padding: Spacing.lg, gap: Spacing.md },
|
||||
intro: { fontSize: FontSize.sm, color: Colors.gray[500], marginBottom: Spacing.sm, lineHeight: 20 },
|
||||
list: { gap: Spacing.md },
|
||||
card: { padding: Spacing.lg },
|
||||
row: { flexDirection: 'row', alignItems: 'center', gap: Spacing.md },
|
||||
iconCircle: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: Radius.md,
|
||||
backgroundColor: '#E3E3FD',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
cardBody: { flex: 1 },
|
||||
cardTitle: { fontSize: FontSize.md, fontWeight: FontWeight.bold, color: Colors.black },
|
||||
cardSubtitle: { fontSize: FontSize.xs, color: Colors.gray[500], marginTop: 2, lineHeight: 18 },
|
||||
cardEnd: { flexDirection: 'row', alignItems: 'center', gap: Spacing.sm },
|
||||
count: {
|
||||
fontSize: FontSize.sm,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.primary,
|
||||
minWidth: 22,
|
||||
textAlign: 'right',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
FlatList,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Badge, Button, Card, EmptyState, Loader, TextField } from '@/components/ui';
|
||||
import {
|
||||
useAdminUtilisateurs,
|
||||
useCreateUtilisateur,
|
||||
useToggleUtilisateurActif,
|
||||
useDeleteUtilisateur,
|
||||
} from '@/hooks/useAdmin';
|
||||
import { Colors } from '@/lib/colors';
|
||||
import { FontSize, FontWeight, HIT_SLOP, Radius, Spacing } from '@/lib/theme';
|
||||
import type { Utilisateur } from '@/types';
|
||||
|
||||
const ROLE_MEMBRE = 2;
|
||||
const ROLE_ADMIN = 3;
|
||||
|
||||
interface FormState {
|
||||
prenom: string;
|
||||
nom: string;
|
||||
email: string;
|
||||
password: string;
|
||||
password_confirmation: string;
|
||||
role_id: number;
|
||||
}
|
||||
|
||||
const FORM_VIDE: FormState = {
|
||||
prenom: '',
|
||||
nom: '',
|
||||
email: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
role_id: ROLE_MEMBRE,
|
||||
};
|
||||
|
||||
/** Extrait le message d'erreur d'une réponse API (422 backend) ou un texte par défaut. */
|
||||
function messageErreur(e: unknown, defaut: string): string {
|
||||
const err = e as { response?: { data?: { message?: string } } };
|
||||
return err?.response?.data?.message ?? defaut;
|
||||
}
|
||||
|
||||
export default function AdminUtilisateursScreen() {
|
||||
const { data: utilisateurs, isLoading } = useAdminUtilisateurs();
|
||||
const createMut = useCreateUtilisateur();
|
||||
const toggleMut = useToggleUtilisateurActif();
|
||||
const deleteMut = useDeleteUtilisateur();
|
||||
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [form, setForm] = useState<FormState>(FORM_VIDE);
|
||||
|
||||
const ouvrirFormulaire = () => {
|
||||
setForm(FORM_VIDE);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const set = (cle: keyof FormState) => (valeur: string) => setForm((f) => ({ ...f, [cle]: valeur }));
|
||||
|
||||
const soumettre = async () => {
|
||||
if (!form.prenom.trim() || !form.nom.trim() || !form.email.trim() || !form.password) {
|
||||
Alert.alert('Champs requis', 'Merci de renseigner tous les champs.');
|
||||
return;
|
||||
}
|
||||
if (form.password !== form.password_confirmation) {
|
||||
Alert.alert('Mots de passe', 'La confirmation ne correspond pas.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await createMut.mutateAsync(form);
|
||||
setModalVisible(false);
|
||||
} catch (e) {
|
||||
Alert.alert('Erreur', messageErreur(e, 'Impossible de créer l’utilisateur.'));
|
||||
}
|
||||
};
|
||||
|
||||
const basculerActif = async (u: Utilisateur) => {
|
||||
try {
|
||||
await toggleMut.mutateAsync(u.id);
|
||||
} catch (e) {
|
||||
Alert.alert('Erreur', messageErreur(e, 'Action impossible.'));
|
||||
}
|
||||
};
|
||||
|
||||
const supprimer = (u: Utilisateur) => {
|
||||
Alert.alert('Supprimer', `Supprimer ${u.prenom} ${u.nom} ?`, [
|
||||
{ text: 'Annuler', style: 'cancel' },
|
||||
{
|
||||
text: 'Supprimer',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await deleteMut.mutateAsync(u.id);
|
||||
} catch (e) {
|
||||
Alert.alert('Erreur', messageErreur(e, 'Suppression impossible.'));
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const renderItem = ({ item }: { item: Utilisateur }) => (
|
||||
<Card style={styles.card}>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={styles.cardHeaderText}>
|
||||
<Text style={styles.nom}>
|
||||
{item.prenom} {item.nom}
|
||||
</Text>
|
||||
<Text style={styles.email}>{item.email}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.badges}>
|
||||
<Badge label={item.role?.nom ?? 'membre'} tone="primary" />
|
||||
<Badge label={item.est_actif ? 'Actif' : 'Inactif'} tone={item.est_actif ? 'success' : 'error'} />
|
||||
</View>
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
title={item.est_actif ? 'Désactiver' : 'Activer'}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={item.est_actif ? 'pause-outline' : 'play-outline'}
|
||||
onPress={() => basculerActif(item)}
|
||||
/>
|
||||
<Button title="Supprimer" variant="danger" size="sm" icon="trash-outline" onPress={() => supprimer(item)} />
|
||||
</View>
|
||||
</Card>
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loader label="Chargement des utilisateurs…" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<FlatList
|
||||
data={utilisateurs ?? []}
|
||||
keyExtractor={(item) => String(item.id)}
|
||||
renderItem={renderItem}
|
||||
contentContainerStyle={styles.list}
|
||||
ListHeaderComponent={
|
||||
<View style={styles.headerAction}>
|
||||
<Button title="Nouvel utilisateur" icon="person-add-outline" fullWidth onPress={ouvrirFormulaire} />
|
||||
</View>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<EmptyState
|
||||
icon="people-outline"
|
||||
title="Aucun utilisateur"
|
||||
message="Créez le premier compte avec le bouton ci-dessus."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Modal visible={modalVisible} animationType="slide" transparent onRequestClose={() => setModalVisible(false)}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
style={styles.modalOverlay}
|
||||
>
|
||||
<View style={styles.modalSheet}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>Nouvel utilisateur</Text>
|
||||
<Pressable hitSlop={HIT_SLOP} onPress={() => setModalVisible(false)} accessibilityLabel="Fermer">
|
||||
<Ionicons name="close" size={24} color={Colors.gray[600]} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<ScrollView contentContainerStyle={styles.modalBody} keyboardShouldPersistTaps="handled">
|
||||
<TextField label="Prénom" value={form.prenom} onChangeText={set('prenom')} required autoCapitalize="words" />
|
||||
<TextField label="Nom" value={form.nom} onChangeText={set('nom')} required autoCapitalize="words" />
|
||||
<TextField
|
||||
label="Email"
|
||||
value={form.email}
|
||||
onChangeText={set('email')}
|
||||
required
|
||||
keyboardType="email-address"
|
||||
/>
|
||||
<TextField label="Mot de passe" value={form.password} onChangeText={set('password')} required secureTextEntry />
|
||||
<TextField
|
||||
label="Confirmation"
|
||||
value={form.password_confirmation}
|
||||
onChangeText={set('password_confirmation')}
|
||||
required
|
||||
secureTextEntry
|
||||
/>
|
||||
|
||||
<Text style={styles.fieldLabel}>Rôle</Text>
|
||||
<View style={styles.segment}>
|
||||
<Pressable
|
||||
style={[styles.segmentItem, form.role_id === ROLE_MEMBRE && styles.segmentItemActive]}
|
||||
onPress={() => setForm((f) => ({ ...f, role_id: ROLE_MEMBRE }))}
|
||||
>
|
||||
<Text style={[styles.segmentText, form.role_id === ROLE_MEMBRE && styles.segmentTextActive]}>
|
||||
Membre
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.segmentItem, form.role_id === ROLE_ADMIN && styles.segmentItemActive]}
|
||||
onPress={() => setForm((f) => ({ ...f, role_id: ROLE_ADMIN }))}
|
||||
>
|
||||
<Text style={[styles.segmentText, form.role_id === ROLE_ADMIN && styles.segmentTextActive]}>
|
||||
Administrateur
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.modalActions}>
|
||||
<Button title="Annuler" variant="tertiary" onPress={() => setModalVisible(false)} />
|
||||
<Button title="Créer" icon="checkmark" loading={createMut.isPending} onPress={soumettre} />
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: { flex: 1, backgroundColor: Colors.gray[50] },
|
||||
list: { padding: Spacing.lg, gap: Spacing.md },
|
||||
headerAction: { marginBottom: Spacing.md },
|
||||
card: { padding: Spacing.lg, gap: Spacing.sm },
|
||||
cardHeader: { flexDirection: 'row', alignItems: 'flex-start' },
|
||||
cardHeaderText: { flex: 1 },
|
||||
nom: { fontSize: FontSize.md, fontWeight: FontWeight.bold, color: Colors.black },
|
||||
email: { fontSize: FontSize.sm, color: Colors.gray[500], marginTop: 2 },
|
||||
badges: { flexDirection: 'row', gap: Spacing.sm, flexWrap: 'wrap' },
|
||||
actions: { flexDirection: 'row', gap: Spacing.sm, marginTop: Spacing.xs },
|
||||
modalOverlay: { flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(22,22,22,0.4)' },
|
||||
modalSheet: {
|
||||
backgroundColor: Colors.white,
|
||||
borderTopLeftRadius: Radius.md,
|
||||
borderTopRightRadius: Radius.md,
|
||||
maxHeight: '90%',
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: Spacing.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: Colors.gray[200],
|
||||
},
|
||||
modalTitle: { fontSize: FontSize.lg, fontWeight: FontWeight.bold, color: Colors.black },
|
||||
modalBody: { padding: Spacing.lg },
|
||||
fieldLabel: { fontSize: FontSize.sm, fontWeight: FontWeight.medium, color: Colors.black, marginBottom: Spacing.xs },
|
||||
segment: {
|
||||
flexDirection: 'row',
|
||||
borderWidth: 1,
|
||||
borderColor: Colors.primary,
|
||||
borderRadius: Radius.sm,
|
||||
overflow: 'hidden',
|
||||
marginBottom: Spacing.lg,
|
||||
},
|
||||
segmentItem: { flex: 1, paddingVertical: Spacing.md, alignItems: 'center', backgroundColor: Colors.white },
|
||||
segmentItemActive: { backgroundColor: Colors.primary },
|
||||
segmentText: { fontSize: FontSize.sm, fontWeight: FontWeight.semibold, color: Colors.primary },
|
||||
segmentTextActive: { color: Colors.white },
|
||||
modalActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: Spacing.sm, marginTop: Spacing.sm },
|
||||
});
|
||||
Reference in New Issue
Block a user