feat(r6): mobile ouvert à tous les rôles — maquette + socle R6.1

R4 avait délibérément fermé le mobile au Technicien. Le référent demande
l'ouverture à tous les rôles avec les mêmes droits qu'au web — nouvelle
release R6 (R4 est close, R4.1/4.2/4.3 déjà pris par le socle mobile
technicien d'origine, pas de réouverture ni de collision de numérotation).

Maquette (docs/02-design/maquettes/maquette-mobile-tous-roles.html, 7
écrans) validée par le référent : D1 barre d'onglets adaptative par rôle
(Technicien/Technicien limité inchangés + onglet Menu ajouté) ; D2 le Menu
reprend à l'identique les 4 groupes du web, même matrice de permissions ;
D3 groupe sans lien visible masqué en entier ; D4 le mobile porte les
actions courantes par famille, pas les flux de gestion les plus denses ;
D5 aucune logique de permission propre au mobile.

R6.1 — socle : usePermissions() mobile calqué sur le web ; barre d'onglets
adaptative (ongletsVisibles par rôle, href:null masque sans retirer du
navigateur) ; Accueil (dashboard réel pour Administrateur/Gestionnaire/
Dispatcher/Vue seule, aucun chiffre inventé) ; OT (liste complète,
viewOther déjà géré serveur, réutilise la fiche OT R4 telle quelle) ; Menu
(groupes filtrés, vide masqué) ; Demandes (PanneauDemandes — un seul
composant pour tous les rôles : création/suivi Demandeur, approbation/
rejet Gestionnaire/Dispatcher/Administrateur, lecture Vue seule).
Familles pas encore portées : écran « à venir » plutôt qu'un lien mort.

Typecheck propre, 17 tests Jest, lint 5/5 paquets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
pr-daaif
2026-08-02 11:19:44 +01:00
parent 3e9e708116
commit ab0f260d0f
17 changed files with 1857 additions and 13 deletions

View File

@@ -1,12 +1,19 @@
import { Tabs } from 'expo-router';
import { Text, type ColorValue } from 'react-native';
import { useAssets, useReferenceValues } from '@/api/exploitation';
import { useMe } from '@/auth/session';
import { ongletsVisibles } from '@/auth/roles';
import { usePermissions } from '@/auth/use-permissions';
import { useFile } from '@/file/store';
import { useTokens } from '@/theme/tokens';
/** La tabbar de la maquette R4. Les onglets à venir restent visibles
* (périmètre annoncé, même patron que la sidebar web) mais mènent à un
* écran « disponible en R4.x ». */
/** Barre d'onglets ADAPTATIVE (maquette « mobile ouvert à tous les rôles »,
* D1) : le Technicien/Technicien limité gardent exactement leurs 4 onglets
* terrain R4, inchangés. Les autres rôles reçoivent Accueil/OT/Menu (ou une
* variante plus étroite — Demandeur, Vue seule — selon leurs droits,
* `ongletsVisibles`). Tous les écrans sont toujours déclarés : `href: null`
* masque un onglet de la barre sans le retirer du navigateur (le Menu peut
* toujours y pousser directement). */
function Pic({ glyphe, couleur }: { glyphe: string; couleur: ColorValue }) {
return <Text style={{ fontSize: 17, color: couleur, lineHeight: 20 }}>{glyphe}</Text>;
@@ -16,10 +23,19 @@ export default function CoquilleTabs() {
const t = useTokens();
const file = useFile();
const conflits = file.some((s) => s.statut === 'CONFLIT');
const { data: me } = useMe();
const { can } = usePermissions();
const visibles = ongletsVisibles(me?.role.name);
// D1 « lecture locale » : le parc (scan D4) et les référentiels du bilan
// se préchargent dès l'entrée — le sous-sol n'attend pas qu'on y pense.
useAssets();
useReferenceValues();
// Le Demandeur n'a pas ASSETS.view (seulement /assets/options) : inutile
// d'appeler /assets pour lui, l'API répondrait 403.
const peutAssets = can('ASSETS', 'view');
useAssets({ enabled: peutAssets });
useReferenceValues({ enabled: peutAssets });
const cacher = (nom: string) => (visibles.has(nom) ? undefined : null);
return (
<Tabs
screenOptions={{
@@ -34,6 +50,7 @@ export default function CoquilleTabs() {
<Tabs.Screen
name="journee"
options={{
href: cacher('journee'),
title: 'Ma journée',
tabBarIcon: ({ color }) => <Pic glyphe="☰" couleur={color} />,
}}
@@ -41,6 +58,7 @@ export default function CoquilleTabs() {
<Tabs.Screen
name="scanner"
options={{
href: cacher('scanner'),
title: 'Scanner',
tabBarIcon: ({ color }) => <Pic glyphe="▣" couleur={color} />,
}}
@@ -48,6 +66,7 @@ export default function CoquilleTabs() {
<Tabs.Screen
name="preventif"
options={{
href: cacher('preventif'),
title: 'Préventif',
tabBarIcon: ({ color }) => <Pic glyphe="✓" couleur={color} />,
}}
@@ -55,6 +74,7 @@ export default function CoquilleTabs() {
<Tabs.Screen
name="synchro"
options={{
href: cacher('synchro'),
title: 'Synchro',
tabBarIcon: ({ color }) => <Pic glyphe="⇅" couleur={color} />,
tabBarBadge: file.length || undefined,
@@ -66,6 +86,38 @@ export default function CoquilleTabs() {
},
}}
/>
<Tabs.Screen
name="accueil"
options={{
href: cacher('accueil'),
title: 'Accueil',
tabBarIcon: ({ color }) => <Pic glyphe="⌂" couleur={color} />,
}}
/>
<Tabs.Screen
name="ot"
options={{
href: cacher('ot'),
title: 'OT',
tabBarIcon: ({ color }) => <Pic glyphe="☰" couleur={color} />,
}}
/>
<Tabs.Screen
name="nouvelle-demande"
options={{
href: cacher('nouvelle-demande'),
title: 'Nouvelle demande',
tabBarIcon: ({ color }) => <Pic glyphe="+" couleur={color} />,
}}
/>
<Tabs.Screen
name="menu"
options={{
href: cacher('menu'),
title: 'Menu',
tabBarIcon: ({ color }) => <Pic glyphe="☷" couleur={color} />,
}}
/>
</Tabs>
);
}

View File

@@ -0,0 +1,179 @@
import { router } from 'expo-router';
import { Pressable, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRequests, useWorkOrders } from '@/api/exploitation';
import { useMe } from '@/auth/session';
import { usePermissions } from '@/auth/use-permissions';
import { EnteteTabs } from '@/composants/ui';
import { PanneauDemandes } from '@/composants/panneau-demandes';
import { useTokens } from '@/theme/tokens';
/** Accueil — maquette « mobile ouvert à tous les rôles », écran 2 : le
* Demandeur y voit directement ses demandes (son seul geste) ; les rôles
* de gestion (Administrateur/Gestionnaire/Dispatcher/Vue seule) y trouvent
* leurs signaux réels (urgence, OT en cours, demandes à traiter) — aucun
* chiffre inventé : seuls WORK_ORDERS/REQUESTS sont déjà câblés sur mobile,
* le reste (stock, préventif du mois…) attend son propre écran (Menu). */
export default function PageAccueil() {
const { data: me } = useMe();
if (me?.role.name === 'Demandeur') {
return (
<SafeAreaView style={{ flex: 1 }} edges={['top']}>
<View style={{ flex: 1, padding: 14, gap: 10 }}>
<EnteteTabs />
<PanneauDemandes />
</View>
</SafeAreaView>
);
}
return <TableauDeBordGestion />;
}
function TableauDeBordGestion() {
const t = useTokens();
const { can } = usePermissions();
const peutOT = can('WORK_ORDERS', 'view');
const peutDemandes = can('REQUESTS', 'view');
const { data: workOrders } = useWorkOrders();
const { data: requests } = useRequests();
const ots = workOrders ?? [];
const urgences = ots.filter((o) => o.priority === 'PERSON_TRAPPED' && o.status !== 'DONE' && o.status !== 'CANCELLED');
const enCours = ots.filter((o) => o.status === 'OPEN' || o.status === 'IN_PROGRESS').length;
const aTraiter = (requests ?? []).filter((r) => r.status === 'RECEIVED').length;
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ padding: 14, gap: 10, flex: 1 }}>
<EnteteTabs />
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
Accueil
</Text>
{urgences.length ? (
<Pressable
accessibilityRole="button"
onPress={() => router.push(`/ot/${urgences[0]!.id}`)}
style={{
backgroundColor: t.prioBloqueFond,
borderRadius: 10,
padding: 9,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
}}
>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: t.prioBloque }} />
<Text style={{ color: t.prioBloque, fontFamily: 'Manrope_800ExtraBold', fontSize: 12 }}>
{urgences.length === 1
? `1 personne bloquée — ${urgences[0]!.assetReference} · ${urgences[0]!.siteName}`
: `${urgences.length} personnes bloquées`}
</Text>
</Pressable>
) : null}
{peutOT || peutDemandes ? (
<View style={{ flexDirection: 'row', gap: 8 }}>
{peutOT ? (
<View
style={{
flex: 1,
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 10,
padding: 10,
}}
>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.primaire }}>
{enCours}
</Text>
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 10.5, color: t.encre2 }}>
OT en cours
</Text>
</View>
) : null}
{peutDemandes ? (
<View
style={{
flex: 1,
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 10,
padding: 10,
}}
>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
{aTraiter}
</Text>
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 10.5, color: t.encre2 }}>
Demandes reçues
</Text>
</View>
) : null}
</View>
) : null}
{peutDemandes && aTraiter > 0 ? (
<Pressable
accessibilityRole="button"
onPress={() => router.push('/demandes')}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 10,
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 10,
padding: 11,
}}
>
<View
style={{
width: 28,
height: 28,
borderRadius: 8,
backgroundColor: t.primaireDoux,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text style={{ fontSize: 14, color: t.primaire }}></Text>
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 12.5, color: t.encre }}>
Demandes en attente
</Text>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 11, color: t.encre2 }}>
à approuver ou rejeter
</Text>
</View>
<Text
style={{
fontFamily: 'Manrope_800ExtraBold',
fontSize: 10.5,
color: t.stAttente,
backgroundColor: t.stAttenteFond,
paddingHorizontal: 7,
paddingVertical: 2,
borderRadius: 999,
overflow: 'hidden',
}}
>
{aTraiter}
</Text>
<Text style={{ color: t.encre3, fontSize: 13 }}></Text>
</Pressable>
) : null}
{!peutOT && !peutDemandes ? (
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', textAlign: 'center', padding: 24 }}>
Rien à afficher ici pour votre rôle voir le Menu.
</Text>
) : null}
</View>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,139 @@
import { router } from 'expo-router';
import { Pressable, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import type { ObjectCategory, PermissionRight } from '@siop/shared';
import { useMe } from '@/auth/session';
import { estRoleTerrain } from '@/auth/roles';
import { usePermissions } from '@/auth/use-permissions';
import { EnteteTabs } from '@/composants/ui';
import { useTokens, type Tokens } from '@/theme/tokens';
/** Menu — maquette « mobile ouvert à tous les rôles », écran 3 : reprend à
* l'identique les 4 groupes et l'ordre de la sidebar web (Exploitation /
* Parc / Ressources / Pilotage, coquille.tsx), filtrés par LA MÊME matrice
* (D2 — aucune règle de droit nouvelle). Un groupe sans aucun lien visible
* est masqué en entier (D3) : l'écran est petit, un intitulé vide n'aide
* personne. Ce que « Ma journée »/« Préventif » couvrent déjà pour le
* Technicien n'est pas dupliqué ici. */
interface LienMenu {
libelle: string;
permission: [ObjectCategory, PermissionRight];
ico: string;
cacheEnTerrain?: boolean; // déjà couvert par un onglet terrain dédié
route?: string;
aVenir?: string; // detail affiché sur l'écran « à venir »
}
const GROUPES: { titre: string; liens: LienMenu[] }[] = [
{
titre: 'Exploitation',
liens: [
{ libelle: 'Ordres de travail', ico: '☰', permission: ['WORK_ORDERS', 'view'], cacheEnTerrain: true, route: '/(tabs)/ot' },
{ libelle: 'Demandes', ico: '✎', permission: ['REQUESTS', 'view'], route: '/demandes' },
{ libelle: 'Préventif', ico: '✓', permission: ['WORK_ORDERS', 'view'], cacheEnTerrain: true, aVenir: 'Les grilles préventives pour les rôles de gestion arrivent dans une prochaine étape — le Technicien les a déjà dans son onglet dédié.' },
],
},
{
titre: 'Parc',
liens: [
{ libelle: 'Ascenseurs', ico: '▣', permission: ['ASSETS', 'view'], aVenir: 'La liste du parc en mobilité arrive dans une prochaine étape — en attendant, le Scanner et les fiches OT y mènent déjà.' },
{ libelle: 'Sites', ico: '◫', permission: ['LOCATIONS', 'view'], aVenir: 'La liste des sites en mobilité arrive dans une prochaine étape.' },
{ libelle: 'Catégories', ico: '▤', permission: ['SETTINGS', 'view'], aVenir: 'Ladministration des catégories reste sur le grand écran pour linstant.' },
],
},
{
titre: 'Ressources',
liens: [
{ libelle: 'Stock & achats', ico: '◔', permission: ['PARTS', 'view'], aVenir: 'Le stock et les bons de commande en mobilité arrivent dans une prochaine étape.' },
{ libelle: 'Tiers', ico: '⇄', permission: ['PURCHASE_ORDERS', 'view'], aVenir: 'Fournisseurs et clients restent sur le grand écran pour linstant.' },
{ libelle: 'Fichiers', ico: '▧', permission: ['ASSETS', 'view'], aVenir: 'La bibliothèque documentaire reste sur le grand écran pour linstant.' },
],
},
{
titre: 'Pilotage',
liens: [
{ libelle: 'Statistiques', ico: '◈', permission: ['ANALYTICS', 'view'], aVenir: 'Les statistiques en mobilité arrivent dans une prochaine étape.' },
{ libelle: 'Assistant', ico: '✦', permission: ['WORK_ORDERS', 'view'], aVenir: 'Lassistant IA reste sur le grand écran et la clôture mobile pour linstant.' },
{ libelle: 'Personnes & équipes', ico: '◎', permission: ['PEOPLE_TEAMS', 'view'], aVenir: 'La gestion des personnes et équipes en mobilité arrive dans une prochaine étape.' },
],
},
];
export default function PageMenu() {
const t = useTokens();
const { data: me } = useMe();
const { can } = usePermissions();
const terrain = estRoleTerrain(me?.role.name);
const groupesVisibles = GROUPES.map((g) => ({
...g,
liens: g.liens.filter(
({ permission, cacheEnTerrain }) => can(...permission) && !(cacheEnTerrain && terrain),
),
})).filter((g) => g.liens.length > 0);
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ padding: 14, gap: 14, flex: 1 }}>
<EnteteTabs />
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>Menu</Text>
{groupesVisibles.length === 0 ? (
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', textAlign: 'center', padding: 24 }}>
Rien d'autre accessible à votre rôle ici.
</Text>
) : null}
{groupesVisibles.map((g) => (
<View key={g.titre} style={{ gap: 6 }}>
<Text
style={{
fontFamily: 'Manrope_800ExtraBold',
fontSize: 10.5,
letterSpacing: 0.8,
textTransform: 'uppercase',
color: t.encre3,
}}
>
{g.titre}
</Text>
{g.liens.map((lien) => (
<LigneMenu key={lien.libelle} lien={lien} t={t} />
))}
</View>
))}
</View>
</SafeAreaView>
);
}
function LigneMenu({ lien, t }: { lien: LienMenu; t: Tokens }) {
const aller = () => {
if (lien.route) {
router.push(lien.route as never);
} else {
router.push({ pathname: '/a-venir', params: { titre: lien.libelle, detail: lien.aVenir ?? '' } });
}
};
return (
<Pressable
accessibilityRole="button"
onPress={aller}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 10,
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 10,
padding: 11,
}}
>
<Text style={{ width: 22, textAlign: 'center', fontSize: 14, color: t.primaire }}>{lien.ico}</Text>
<Text style={{ flex: 1, fontFamily: 'Manrope_700Bold', fontSize: 12.5, color: t.encre }}>
{lien.libelle}
</Text>
<Text style={{ color: t.encre3, fontSize: 13 }}></Text>
</Pressable>
);
}

View File

@@ -0,0 +1,24 @@
import { router } from 'expo-router';
import { ScrollView, Text } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { FormulaireDemande } from '@/composants/formulaire-demande';
import { EnteteTabs } from '@/composants/ui';
import { useTokens } from '@/theme/tokens';
/** Onglet du Demandeur (maquette « mobile ouvert à tous les rôles »,
* écran 5) : le geste qu'il fait le plus mérite son propre onglet plutôt
* qu'un bouton caché dans l'Accueil. */
export default function PageNouvelleDemande() {
const t = useTokens();
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
<EnteteTabs />
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
Nouvelle demande
</Text>
<FormulaireDemande surSucces={() => router.replace('/(tabs)/accueil')} />
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,154 @@
import { router } from 'expo-router';
import { FlatList, Pressable, RefreshControl, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import {
WORK_ORDER_PRIORITY_LABELS,
WORK_ORDER_STATUS_LABELS,
WORK_ORDER_TYPE_LABELS,
type WorkOrderPriority,
type WorkOrderStatus,
} from '@siop/shared';
import { useWorkOrders } from '@/api/exploitation';
import { EnteteTabs } from '@/composants/ui';
import { triJournee } from '@/lib/journee';
import { useTokens, type Tokens } from '@/theme/tokens';
/** Ordres de travail — maquette « mobile ouvert à tous les rôles », écran 4 :
* à la différence de « Ma journée » (Technicien, ses OT uniquement),
* Administrateur/Gestionnaire/Dispatcher ont `viewOther` — l'API renvoie
* déjà TOUS les OT (ADR-003, rien à filtrer ici). Même tri priorité puis
* échéance que « Ma journée » ; ouvrir un OT réutilise la fiche R4 telle
* quelle (elle affiche déjà les actions permises via `allowedTransitions`). */
const STYLE_STATUT: Record<WorkOrderStatus, (t: Tokens) => [string, string]> = {
OPEN: (t) => [t.stOuvert, t.stOuvertFond],
IN_PROGRESS: (t) => [t.stEncours, t.stEncoursFond],
ON_HOLD: (t) => [t.stAttente, t.stAttenteFond],
DONE: (t) => [t.stTermine, t.stTermineFond],
CANCELLED: (t) => [t.stAnnule, t.stAnnuleFond],
};
const STRIE_PRIORITE: Record<WorkOrderPriority, (t: Tokens) => string> = {
PERSON_TRAPPED: (t) => t.prioBloque,
HIGH: (t) => t.prioHaute,
MEDIUM: (t) => t.prioMoyenne,
LOW: (t) => t.prioBasse,
NONE: () => 'transparent',
};
export default function PageOT() {
const t = useTokens();
const { data: workOrders, refetch, isFetching } = useWorkOrders();
const ots = triJournee(workOrders ?? []);
const urgences = ots.filter((o) => o.priority === 'PERSON_TRAPPED');
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ padding: 14, gap: 10, flex: 1 }}>
<EnteteTabs />
<View style={{ flexDirection: 'row', alignItems: 'baseline' }}>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
Ordres de travail
</Text>
<Text style={{ marginLeft: 'auto', fontFamily: 'Manrope_600SemiBold', fontSize: 11, color: t.encre3 }}>
{ots.length}
</Text>
</View>
{urgences.length ? (
<View
style={{
backgroundColor: t.prioBloqueFond,
borderRadius: 10,
padding: 9,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
}}
>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: t.prioBloque }} />
<Text style={{ color: t.prioBloque, fontFamily: 'Manrope_800ExtraBold', fontSize: 12 }}>
{urgences.length === 1
? `1 personne bloquée — ${urgences[0]!.assetReference} · ${urgences[0]!.siteName}`
: `${urgences.length} personnes bloquées`}
</Text>
</View>
) : null}
<FlatList
data={ots}
keyExtractor={(o) => o.id}
refreshControl={
<RefreshControl refreshing={isFetching} onRefresh={() => void refetch()} tintColor={t.primaire} />
}
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
ListEmptyComponent={
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
Aucun OT tirez pour rafraîchir.
</Text>
}
renderItem={({ item: o }) => {
const [enc, fond] = STYLE_STATUT[o.status](t);
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={`${o.reference}${o.title}`}
onPress={() => router.push(`/ot/${o.id}`)}
style={{
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 12,
borderLeftWidth: 4,
borderLeftColor: STRIE_PRIORITE[o.priority](t),
padding: 11,
gap: 2,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
letterSpacing: 0.6,
textTransform: 'uppercase',
color: t.encre2,
flex: 1,
}}
>
{o.reference} · {WORK_ORDER_TYPE_LABELS[o.type]}
</Text>
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
color: enc,
backgroundColor: fond,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
overflow: 'hidden',
}}
>
{WORK_ORDER_STATUS_LABELS[o.status]}
</Text>
</View>
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 14, color: t.encre }}>
{o.title}
</Text>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
Asc. {o.assetReference} {o.siteName}
{o.dueDate
? ` · échéance ${new Intl.DateTimeFormat('fr-FR', { day: 'numeric', month: 'short' }).format(new Date(o.dueDate))}`
: ''}
{o.priority !== 'NONE' && o.priority !== 'PERSON_TRAPPED'
? ` · ${WORK_ORDER_PRIORITY_LABELS[o.priority]}`
: ''}
</Text>
</Pressable>
);
}}
/>
</View>
</SafeAreaView>
);
}