UI/Mdt/MdtView.razor

The MDT (Mobile Data Terminal) panel used by the police tablet, station window and vehicle computer. Draws the screen picked by MdtState.Tab (home, 911 calls, plate reader, citizen files, records, vehicles, warrants, dispatch history, evidence, penal code, roster), sends all reads and writes to PoliceService requests, and navigates between tabs through the PoliceTablet shell.

@using Sandbox;
@using Sandbox.UI;
@namespace HorizonRP
@inherits Panel

@*
	Le moteur du MDT de l'UCPD (Mobile Data Terminal), le même pour les trois habillages de
	PoliceTablet : une fenêtre du poste du commissariat, la tablette durcie à pied (F7), l'ordinateur
	de bord d'un véhicule de police (F7 assis). Il dessine l'écran demandé par son état (MdtState :
	State.Tab) — accueil, appels 911, lecteur de plaques, citoyens, casiers et rapports, véhicules,
	avis de recherche, historique 911, scellés, code pénal, effectifs — et passe par l'habillage
	(Tablet.Navigate) pour aller d'un écran à un autre : la tablette change d'onglet, le poste
	ouvre ou met au premier plan la bonne fenêtre.

	Rien n'est décidé ici : toute lecture et toute écriture est une demande à l'hôte
	(PoliceService.Tablet.cs, .Dispatch.cs, .Evidence.cs), qui revérifie le service et le grade, et
	répond à l'agent seul. Les boutons cachés selon le grade ne sont qu'un confort.
*@

<root class="mdt-view shell-@(State?.Shell)">
	@if ( State is not null && Me is not null )
	{
		@if ( State.Tab == "accueil" )
		{
			@Home()
		}
		else if ( State.Tab == "appels" )
		{
			@CallsScreen()
		}
		else if ( State.Tab == "lecteur" )
		{
			@ReaderScreen()
		}
		else if ( State.Tab == "citoyens" || State.Tab == "casiers" )
		{
			<div class="split">
				<div class="split-list">
					<div class="search">
						<i>search</i>
						<TextEntry class="field grow" Placeholder="Nom, téléphone, SteamID…" [email protected] OnTextEdited=@((string v) => { State.SearchQuery = v; }) onsubmit=@(() => Search())></TextEntry>
						<div class="btn primary" onclick=@(() => Search())>Rechercher</div>
					</div>
					<div class="list">
						@if ( State.SearchedFor is null )
						{
							<div class="hint">Tapez un nom RP, un numéro de téléphone (« 123-4567 ») ou un SteamID.</div>
						}
						else if ( Found is null )
						{
							<div class="hint">@(SearchError ?? "Recherche…")</div>
						}
						else if ( !Found.Any() )
						{
							<div class="hint">@($"Personne ne répond à « {(State.SearchedFor)} ».")</div>
						}
						else
						{
							@foreach ( var person in Found )
							{
								<div class="person @(State.FileId.ToString() == person.Steam ? "active" : "")" onclick=@(() => OpenCitizen( person.Steam ))>
									<div class="avatar">@Initial( person.RpName )</div>
									<div class="row-main">
										<div class="row-title">@person.RpName</div>
										<div class="row-sub">@($"{(person.Phone ?? "–")} · {(JobName( person.Job ))}")</div>
										<div class="tags">
											@if ( person.Wanted )
											{
												<div class="tag red">Recherché</div>
											}
											@if ( person.UnpaidTotal > 0 )
											{
												<div class="tag amber">@($"Impayé {(Format.Money( person.UnpaidTotal ))}")</div>
											}
											@if ( person.Online )
											{
												<div class="tag green">En ville</div>
											}
										</div>
									</div>
								</div>
							}
						}
					</div>
				</div>
				<div class="split-detail">
					@CitizenFile( State.Tab == "casiers" )
				</div>
			</div>
		}
		else if ( State.Tab == "vehicules" )
		{
			<div class="split">
				<div class="split-list">
					<div class="search">
						<i>directions_car</i>
						<TextEntry class="field grow plate-field" Placeholder="Plaque (ex. 4HZR123)" [email protected] OnTextEdited=@((string v) => { State.PlateQuery = v; }) onsubmit=@(() => SearchPlate( State.PlateQuery ))></TextEntry>
						<div class="btn primary" onclick=@(() => SearchPlate( State.PlateQuery ))>Interroger</div>
					</div>
					<div class="section-label">@($"Véhicules signalés ({(Flags.Count)})")</div>
					<div class="list">
						@if ( !Flags.Any() )
						{
							<div class="hint">Aucun véhicule signalé.</div>
						}
						@foreach ( var flag in Flags )
						{
							<div class="person @(State.PlateFor == flag.Plate ? "active" : "")" onclick=@(() => SearchPlate( flag.Plate ))>
								<div class="mini-plate">@flag.Plate</div>
								<div class="row-main">
									<div class="row-sub red">@flag.Reason</div>
								</div>
							</div>
						}
					</div>
				</div>
				<div class="split-detail">
					@PlateFile()
				</div>
			</div>
		}
		else if ( State.Tab == "avis" )
		{
			@WarrantsScreen()
		}
		else if ( State.Tab == "historique" )
		{
			@HistoryScreen()
		}
		else if ( State.Tab == "scelles" )
		{
			@EvidenceScreen()
		}
		else if ( State.Tab == "code" )
		{
			@PenalScreen()
		}
		else if ( State.Tab == "effectifs" )
		{
			@RosterScreen()
		}
	}
</root>

@code
{
	/// <summary>L'habillage qui porte cet écran (navigation, lecteur de plaques).</summary>
	public PoliceTablet Tablet { get; set; }

	public MdtState State { get; set; }

	RoleplayPlayer Me => RoleplayPlayer.Local;
	int MyRank => Me?.PoliceGrade ?? -1;
	PoliceRules.Grade MyGrade => PoliceRules.GradeOf( MyRank );
	PoliceService Service => PoliceService.Current;

	List<RoleplayPlayer> OnDuty => RoleplayPlayer.All.Where( x => x.IsCop ).OrderByDescending( x => x.PoliceGrade ).ThenBy( x => x.RpName ).ToList();
	IReadOnlyList<PoliceService.PlateFlag> Flags => Service?.Flags ?? (IReadOnlyList<PoliceService.PlateFlag>)Array.Empty<PoliceService.PlateFlag>();
	IReadOnlyList<PoliceService.PenalEntry> Penal => Service?.PenalCode ?? (IReadOnlyList<PoliceService.PenalEntry>)Array.Empty<PoliceService.PenalEntry>();
	IEnumerable<DispatchCall> Calls => Service?.Dispatch ?? (IReadOnlyList<DispatchCall>)Array.Empty<DispatchCall>();
	IEnumerable<DispatchCall> PoliceCalls => Calls.Where( x => x.Service == "police" );
	IEnumerable<DispatchCall> PendingCalls => PoliceCalls.Where( x => x.Status == "attente" );
	List<MdtOfficerRow> Roster => PoliceTabletClient.Roster ?? new();

	/// <summary>Les appels que l'agent a pris et qui sont encore ouverts : son intervention en cours.</summary>
	IEnumerable<DispatchCall> MyCalls => PoliceCalls.Where( x => x.Open && x.TakenBySteam.Contains( Me.SteamId ) );

	// ------------------------------------------------------------------ cycle

	int refreshed = -1;
	string refreshedTab;
	int seenReadSerial = -1;
	int seenEvidenceSerial;
	MdtState lastState;

	public override void Tick()
	{
		base.Tick();
		if ( State is null || Me is null || !Me.IsCop )
			return;

		// Le même panneau peut recevoir l'état d'une autre fenêtre (fermeture d'une fenêtre voisine).
		if ( !ReferenceEquals( lastState, State ) )
		{
			lastState = State;
			refreshed = -1;
		}

		// Un écran qui s'affiche (onglet changé, habillage rouvert) reprend des nouvelles du fichier.
		if ( refreshed != State.RefreshSerial || refreshedTab != State.Tab )
		{
			refreshed = State.RefreshSerial;
			refreshedTab = State.Tab;
			State.Asked.Clear();
			Refresh();
		}

		// Un scellé vient de changer : les pages du registre se redemandent tout de suite.
		if ( seenEvidenceSerial != PoliceTabletClient.EvidenceSerial )
		{
			seenEvidenceSerial = PoliceTabletClient.EvidenceSerial;
			foreach ( var key in State.Asked.Keys.Where( k => k.StartsWith( "e:" ) || k.StartsWith( "f:" ) ).ToList() )
				State.Asked.Remove( key );
		}

		// Les pages qui manquent (premier affichage, scellé modifié) se redemandent d'elles-mêmes.
		if ( State.Tab == "historique" && PoliceTabletClient.HistoryFor != HistoryKey && State.ShouldAsk( "h:" + HistoryKey, 6 ) )
			AskHistory();
		if ( State.Tab == "scelles" && !PoliceTabletClient.Evidence.ContainsKey( EvidenceListKey ) && State.ShouldAsk( "e:" + EvidenceListKey, 6 ) )
			PoliceService.MdtRequestEvidence( State.EvStatus, "", Clip( State.EvApplied ), State.EvPage );
		if ( State.Tab is "citoyens" or "casiers" && State.FileId != 0 && !PoliceTabletClient.Evidence.ContainsKey( FileEvidenceKey ) && State.ShouldAsk( "f:" + FileEvidenceKey, 8 ) )
			PoliceService.MdtRequestEvidence( "", State.FileId.ToString(), "", 1 );

		// Le lecteur de plaques a lu (trace de l'hôte) : la fiche du véhicule s'ouvre.
		if ( seenReadSerial != PoliceTabletClient.PlateReadSerial )
		{
			if ( seenReadSerial >= 0 && State.Tab == "lecteur" && !string.IsNullOrEmpty( PoliceTabletClient.PlateRead ) )
				State.PlateFor = PoliceTabletClient.PlateRead;
			seenReadSerial = PoliceTabletClient.PlateReadSerial;
		}

		UpdatePhoto();
	}

	/// <summary>L'écran reprend des nouvelles du fichier (l'hôte demande à l'API).</summary>
	void Refresh()
	{
		switch ( State.Tab )
		{
			case "accueil":
			case "avis":
				PoliceService.MdtRequestWarrants();
				break;
			case "effectifs":
				PoliceService.MdtRequestRoster();
				break;
			case "citoyens":
			case "casiers":
				if ( State.FileId != 0 )
				{
					PoliceService.MdtRequestCitizen( State.FileId );
					PoliceService.MdtRequestEvidence( "", State.FileId.ToString(), "", 1 );
					State.Asked["f:" + FileEvidenceKey] = 0;
				}
				break;
			case "vehicules":
			case "lecteur":
				if ( !string.IsNullOrEmpty( State.PlateFor ) )
					PoliceService.MdtRequestPlate( State.PlateFor );
				break;
			case "historique":
				AskHistory();
				State.Asked["h:" + HistoryKey] = 0;
				break;
			case "scelles":
				PoliceService.MdtRequestEvidence( State.EvStatus, "", Clip( State.EvApplied ), State.EvPage );
				State.Asked["e:" + EvidenceListKey] = 0;
				break;
		}
	}

	void Go( string tab, Action<MdtState> setup = null )
	{
		Sounds.Play( "tap", 0.25f );
		if ( Tablet is not null )
			Tablet.Navigate( State, tab, setup );
		else
		{
			setup?.Invoke( State );
			State.Tab = tab;
		}
	}

	/// <summary>Un second clic confirme une action qu'on ne défait pas (clore, effacer, annuler, renvoyer).</summary>
	void Ask( string key, Action action )
	{
		if ( State.Confirm != key )
		{
			State.Confirm = key;
			return;
		}
		State.Confirm = null;
		action();
	}

	// ------------------------------------------------------------------ accueil

	RenderFragment Home() => @<text>
		<div class="scroll home">
			<div class="agent-card">
				<div class="agent-insignia"><i>@MyGrade.Insignia</i></div>
				<div class="agent-main">
					<div class="agent-hello">@($"Bonjour, {(MyGrade.Name)} {(Me.RpName)}")</div>
					<div class="agent-rank">@($"{(MyGrade.Name)} · {(MyGrade.English)}")</div>
					<div class="agent-ids">
						<div class="id-chip"><i>badge</i>@($"Matricule {(Badge( Me.PoliceBadge ))}")</div>
						<div class="id-chip"><i>settings_input_antenna</i>@($"Indicatif {(Callsign( Me.PoliceCallsign ))}")</div>
						<div class="id-chip on"><i>radio_button_checked</i>En service</div>
					</div>
				</div>
				<div class="agent-dept">@PoliceRules.Department</div>
			</div>

			<div class="card">
				<div class="card-title"><i>settings_input_antenna</i>Statut de votre unité<div class="card-note">Vu de toutes les unités, sur l'accueil et les appels</div></div>
				@StatusPicker()
			</div>

			@foreach ( var call in MyCalls.Take( 2 ) )
			{
				<div class="mission">
					<div class="mission-icon"><i>emergency</i></div>
					<div class="row-main">
						<div class="row-title">@($"Votre intervention · 911 n°{(call.Id)} · {(call.CallerName)}")</div>
						<div class="row-sub">@($"« {(call.Message)} » · {(call.Location ?? DistanceOf( call ))}")</div>
					</div>
					<div class="btn" onclick=@(() => Route( call ))><i>navigation</i>GPS</div>
					<div class="btn primary" onclick=@(() => Go( "appels", s => s.CallId = call.Id ))>Ouvrir</div>
				</div>
			}

			<div class="stats">
				<div class="stat"><div class="stat-value">@OnDuty.Count</div><div class="stat-label">Unités en service</div></div>
				<div class="stat @(PendingCalls.Any() ? "hot" : "")"><div class="stat-value">@PendingCalls.Count()</div><div class="stat-label">Appels 911 en attente</div></div>
				<div class="stat"><div class="stat-value">@(PoliceTabletClient.Warrants?.Count( x => x.Active ).ToString() ?? "–")</div><div class="stat-label">Avis de recherche actifs</div></div>
				<div class="stat"><div class="stat-value">@Flags.Count</div><div class="stat-label">Véhicules signalés</div></div>
			</div>

			<div class="grid2">
				<div class="card">
					<div class="card-title"><i>groups</i>Unités en service</div>
					@foreach ( var cop in OnDuty )
					{
						var car = cop.GetComponentInParent<Vehicle>();
						var st = PoliceService.UnitStateOf( cop );
						<div class="row">
							<div class="row-icon blue"><i>@PoliceRules.GradeOf( cop.PoliceGrade ).Insignia</i></div>
							<div class="row-main">
								<div class="row-title">@($"{(PoliceRules.GradeOf( cop.PoliceGrade ).Name)} {(cop.RpName)}")</div>
								<div class="row-sub">@(car.IsValid() ? $"{(car.Info?.Name ?? "Véhicule")} · {(car.Plate)}" : "À pied")</div>
							</div>
							<div class="unit-chip" style="@UnitStyle( st )"><i>@st.Icon</i>@st.Name</div>
							<div class="callsign">@Callsign( cop.PoliceCallsign )</div>
						</div>
					}
				</div>
				<div class="col">
					<div class="card">
						<div class="card-title"><i>person_search</i>Avis de recherche récents<div class="card-link" onclick=@(() => Go( "avis" ))>Tout voir</div></div>
						@if ( PoliceTabletClient.Warrants is null )
						{
							<div class="hint">@(PoliceTabletClient.WarrantsMissing ? "La liste des avis n'est pas disponible : ouvrez la fiche d'un citoyen." : "Chargement…")</div>
						}
						else if ( !PoliceTabletClient.Warrants.Any( x => x.Active ) )
						{
							<div class="hint">Aucun avis de recherche actif.</div>
						}
						else
						{
							@foreach ( var w in PoliceTabletClient.Warrants.Where( x => x.Active ).Take( 5 ) )
							{
								<div class="row link" onclick=@(() => Go( "avis", s => { s.WarrantId = w.Id; s.Confirm = null; } ))>
									<div class="row-icon red"><i>@(w.IsDangerous ? "warning" : "person_search")</i></div>
									<div class="row-main">
										<div class="row-title">@WarrantName( w )</div>
										<div class="row-sub">@w.Reason</div>
									</div>
									<div class="row-time">@Stamp( w.CreatedAt )</div>
								</div>
							}
						}
					</div>
					<div class="card">
						<div class="card-title"><i>emergency</i>Appels 911 en attente<div class="card-link" onclick=@(() => Go( "appels" ))>Tout voir</div></div>
						@if ( !PendingCalls.Any() )
						{
							<div class="hint">Aucun appel en attente.</div>
						}
						@foreach ( var call in PendingCalls.Take( 4 ) )
						{
							<div class="row link" onclick=@(() => Go( "appels", s => s.CallId = call.Id ))>
								<div class="row-icon amber"><i>call</i></div>
								<div class="row-main">
									<div class="row-title">@($"n°{(call.Id)} · {(call.CallerName)}")</div>
									<div class="row-sub">@call.Message</div>
								</div>
								<div class="row-time">@DistanceOf( call )</div>
							</div>
						}
					</div>
				</div>
			</div>
		</div>
	</text>;

	/// <summary>Les quatre statuts radio, l'actif en couleur.</summary>
	RenderFragment StatusPicker() => @<text>
		<div class="status-picker">
			@foreach ( var s in PoliceService.UnitStates )
			{
				var on = PoliceService.UnitStateOf( Me ).Id == s.Id;
				<div class="status-btn @(on ? "on" : "")" style="@(on ? UnitStyle( s ) : $"border-color: {s.Color};")" onclick=@(() => SetStatus( s.Id ))>
					<i>@s.Icon</i>
					<div class="status-text">
						<div class="status-name">@s.Name</div>
						<div class="status-code">@s.Code</div>
					</div>
				</div>
			}
		</div>
	</text>;

	void SetStatus( string id )
	{
		Sounds.Play( "tap", 0.25f );
		PoliceService.RequestUnitStatus( id );
	}

	static string UnitStyle( PoliceService.UnitState s ) => $"background-color: {s.Color}; border-color: {s.Color};";

	// ------------------------------------------------------------------ appels 911

	DispatchCall SelectedCall => PoliceCalls.FirstOrDefault( x => x.Id == State.CallId ) ?? PoliceCalls.FirstOrDefault( x => x.Open );

	RenderFragment CallsScreen() => @<text>
		<div class="split">
			<div class="split-list wide">
				<div class="list-head">
					<div class="section-label">@($"{(PoliceCalls.Count( x => x.Open ))} appel(s) en cours")</div>
					<div class="btn small" onclick=@(() => Go( "historique" ))><i>history</i>Historique</div>
				</div>
				<div class="list">
					@if ( !PoliceCalls.Any() )
					{
						<div class="empty"><i>phone_in_talk</i><div class="empty-text">Aucun appel au 911 pour l'instant.</div></div>
					}
					@foreach ( var call in PoliceCalls )
					{
						<div class="call-row @call.Status @(SelectedCall == call ? "active" : "")" onclick=@(() => { State.CallId = call.Id; State.Confirm = null; })>
							<div class="call-side">
								<div class="call-id">@($"n°{(call.Id)}")</div>
								<div class="call-time">@call.At</div>
							</div>
							<div class="row-main">
								<div class="row-title">@call.CallerName</div>
								<div class="row-sub">@call.Message</div>
								<div class="tags">
									<div class="status-tag @call.Status">@StatusName( call.Status )</div>
									@if ( call.HasPosition )
									{
										<div class="tag">@DistanceOf( call )</div>
									}
								</div>
							</div>
						</div>
					}
				</div>
			</div>
			<div class="split-detail">
				@if ( SelectedCall is { } c )
				{
					var mine = c.TakenBySteam.Contains( Me.SteamId );
					<div class="scroll">
						<div class="call-head-card @c.Status">
							<div class="call-big-id">@($"911 · n°{(c.Id)}")</div>
							<div class="row-main">
								<div class="call-who">@($"{(c.CallerName)} · {(c.Number)}")</div>
								<div class="row-sub">@($"Reçu à {(c.At)} (heure de la ville) · il y a {(c.AgeMinutes)} min")</div>
							</div>
							<div class="status-tag big @c.Status">@StatusName( c.Status )</div>
						</div>
						<div class="card">
							<div class="card-title"><i>record_voice_over</i>Message de l'appelant</div>
							<div class="text big">@($"« {(c.Message)} »")</div>
							<div class="meta">@($"Lieu : {(c.Location ?? (c.HasPosition ? DistanceOf( c ) : "position non transmise"))}")</div>
						</div>
						@if ( c.HasPosition )
						{
							<div class="card">
								<div class="card-title"><i>map</i>Localisation<div class="card-note">@DistanceOf( c )</div></div>
								@MiniMap( c.Position )
							</div>
						}
						<div class="card">
							<div class="card-title"><i>local_police</i>Unités engagées</div>
							@if ( c.TakenBySteam.Count == 0 )
							{
								<div class="hint">Aucune unité n'a encore pris l'appel.</div>
							}
							@for ( var i = 0; i < c.TakenBySteam.Count; i++ )
							{
								var steam = c.TakenBySteam[i];
								var label = i < c.TakenBy.Count ? c.TakenBy[i] : "Unité";
								var unit = RoleplayPlayer.All.FirstOrDefault( x => x.SteamId == steam );
								var st = unit is not null && unit.IsCop ? PoliceService.UnitStateOf( unit ) : null;
								<div class="row">
									<div class="row-icon blue"><i>directions_car</i></div>
									<div class="row-main">
										<div class="row-title">@label</div>
										<div class="row-sub">@(unit is null ? "Hors ligne" : $"{(PoliceRules.GradeOf( unit.PoliceGrade ).Name)} {(unit.RpName)}")</div>
									</div>
									@if ( st is not null )
									{
										<div class="unit-chip" style="@UnitStyle( st )"><i>@st.Icon</i>@st.Name</div>
									}
								</div>
							}
							@if ( !c.Open && c.ClosedBy is not null )
							{
								<div class="meta">@($"Clos par {(c.ClosedBy)}")</div>
							}
						</div>
						@if ( c.Open )
						{
							<div class="actions">
								@if ( !mine )
								{
									<div class="btn primary" onclick=@(() => TakeCall( c ))><i>directions_car</i>Prendre l'appel</div>
								}
								@if ( c.HasPosition )
								{
									<div class="btn" onclick=@(() => Route( c ))><i>navigation</i>Itinéraire GPS</div>
								}
								@if ( mine )
								{
									@StatusPicker()
								}
								<div class="btn danger" onclick=@(() => Ask( $"appel:{c.Id}", () => PoliceService.RequestCloseCall( c.Id ) ))><i>task_alt</i>@(State.Confirm == $"appel:{c.Id}" ? "Confirmer la clôture" : "Clore l'appel")</div>
							</div>
						}
					</div>
				}
				else
				{
					<div class="empty"><i>emergency</i><div class="empty-text">Choisissez un appel dans la liste.</div></div>
				}
			</div>
		</div>
	</text>;

	void TakeCall( DispatchCall call )
	{
		Sounds.Play( "tap", 0.25f );
		PoliceService.RequestTakeCall( call.Id );
		Route( call );
	}

	void Route( DispatchCall call )
	{
		if ( !call.HasPosition )
		{
			HudNotifications.Push( $"Appel n°{call.Id} : l'appelant n'a pas transmis sa position.", "info" );
			return;
		}
		Gps.SetTarget( call.Position, $"911 n°{call.Id}" );
		HudNotifications.Push( $"GPS : itinéraire vers l'appel n°{call.Id} ({DistanceOf( call )}).", "info" );
	}

	string DistanceOf( DispatchCall call ) => call.HasPosition ? DistanceTo( call.Position ) : "—";

	string DistanceTo( Vector3 at ) =>
		Me is null ? "—" : Gps.FormatDistance( Me.WorldPosition.WithZ( 0 ).Distance( at.WithZ( 0 ) ) * 0.0254f );

	static string StatusName( string status ) => status switch
	{
		"attente" or "ouvert" => "En attente",
		"pris" => "Pris en charge",
		"clos" => "Clos",
		_ => status ?? "—",
	};

	// ------------------------------------------------------------------ la carte

	/// <summary>Le zoom de la petite carte (unités d'interface par pixel de l'image de la ville).</summary>
	const float MapZoom = 0.9f;
	const float MapWidth = 520f;
	const float MapHeight = 230f;

	/// <summary>Une vignette de la carte de la ville (celle du GPS du téléphone), centrée sur un lieu : le lieu, l'agent, l'itinéraire.</summary>
	RenderFragment MiniMap( Vector3 at ) => @<text>
		<div class="mini-map">
			<div class="map-image" style="@MapImageStyle( at )">
				@if ( Gps.Target is { } target && target.WithZ( 0 ).Distance( at.WithZ( 0 ) ) < 50 )
				{
					@foreach ( var seg in RouteSegments( at ) )
					{
						<div class="route-seg" style="@seg"></div>
					}
				}
				<div class="map-call" style="@PinStyle( at, at, 30 )"><i>location_on</i></div>
				@if ( Me is not null )
				{
					<div class="map-me" style="@PinStyle( Me.WorldPosition, at, 22 )"><i>navigation</i></div>
				}
			</div>
		</div>
	</text>;

	static string F( float v ) => v.ToString( "0.##", System.Globalization.CultureInfo.InvariantCulture );

	/// <summary>L'image de la ville placée pour que <paramref name="center"/> tombe au milieu du cadre.</summary>
	static string MapImageStyle( Vector3 center )
	{
		var c = GpsMap.ToImage( center ) * MapZoom;
		var size = GpsMap.ImageSize * MapZoom;
		return $"left: {F( MapWidth / 2 - c.x )}px; top: {F( MapHeight / 2 - c.y )}px; width: {F( size )}px; height: {F( size )}px;";
	}

	static string PinStyle( Vector3 world, Vector3 center, float size )
	{
		var p = GpsMap.ToImage( world ) * MapZoom;
		return $"left: {F( p.x - size / 2 )}px; top: {F( p.y - size )}px; width: {F( size )}px; height: {F( size )}px;";
	}

	static IEnumerable<string> RouteSegments( Vector3 center )
	{
		var route = Gps.Route;
		for ( var k = 0; k < route.Count - 1; k++ )
		{
			var a = GpsMap.ToImage( route[k] ) * MapZoom;
			var b = GpsMap.ToImage( route[k + 1] ) * MapZoom;
			var mid = (a + b) / 2;
			var length = (b - a).Length;
			var angle = MathF.Atan2( b.y - a.y, b.x - a.x ) * 180f / MathF.PI;
			yield return $"left: {F( mid.x - length / 2 )}px; top: {F( mid.y - 2.5f )}px; width: {F( length )}px; transform: rotate( {F( angle )}deg );";
		}
	}

	// ------------------------------------------------------------------ lecteur de plaques

	RenderFragment ReaderScreen() => @<text>
		<div class="scroll">
			<div class="readers">
				@Reader( "Lecteur avant", Tablet?.FrontRead, false )
				@Reader( "Lecteur arrière", Tablet?.RearRead, true )
			</div>
			@if ( Tablet?.Reads is { Count: > 0 } reads )
			{
				<div class="card">
					<div class="card-title"><i>history</i>Dernières lectures</div>
					<div class="read-log">
						@foreach ( var r in reads )
						{
							<div class="read-item @(r.Flagged ? "hot" : "")" onclick=@(() => SearchPlate( r.Plate ))>
								<div class="mini-plate">@r.Plate</div>
								<div class="row-main">
									<div class="row-sub">@($"{(r.Model)} · {(r.At)}{(r.Flagged ? $" · {r.Reason}" : "")}")</div>
								</div>
							</div>
						}
					</div>
				</div>
			}
			@if ( !string.IsNullOrEmpty( State.PlateFor ) )
			{
				@PlateFile()
			}
		</div>
	</text>;

	RenderFragment Reader( string title, PoliceTablet.PlateReading reading, bool rear ) => @<text>
		<div class="reader @(reading?.Flagged == true ? "hot" : "")">
			<div class="reader-head">
				<i>@(rear ? "south" : "north")</i>
				<div class="reader-title">@title</div>
				<div class="reader-led @(reading is null ? "" : "on")"></div>
			</div>
			@if ( reading is null )
			{
				<div class="reader-empty">Aucun véhicule dans l'axe</div>
			}
			else
			{
				<div class="plate small">
					<div class="plate-state">New Horizon</div>
					<div class="plate-number">@reading.Plate</div>
				</div>
				<div class="reader-model">@reading.Model</div>
				<div class="reader-speed">@($"{(reading.Kmh):0} km/h")</div>
				@if ( reading.Flagged )
				{
					<div class="reader-alert"><i>report</i>@($"SIGNALÉ · {(reading.Reason)}")</div>
				}
			}
			<div class="btn primary" onclick=@(() => ReadPlate( rear ))><i>manage_search</i>Interroger le fichier</div>
		</div>
	</text>;

	void ReadPlate( bool rear )
	{
		Sounds.Play( "tap", 0.25f );
		PoliceService.MdtReadPlateAhead( rear );
	}

	// ------------------------------------------------------------------ citoyens et casiers

	List<Backend.MdtPerson> Found => State.SearchedFor is null ? null : PoliceTabletClient.Searches.GetValueOrDefault( State.SearchedFor );
	string SearchError => State.SearchedFor is null ? null : PoliceTabletClient.SearchErrors.GetValueOrDefault( State.SearchedFor );
	Backend.MdtCitizenResponse File => PoliceTabletClient.Files.GetValueOrDefault( State.FileId );
	string FileError => PoliceTabletClient.FileErrors.GetValueOrDefault( State.FileId );

	/// <summary>Un texte tel que l'hôte le range (MdtClip) : la réponse se retrouve sous la même clé.</summary>
	static string Clip( string text )
	{
		text = (text ?? "").Trim();
		return text.Length > 64 ? text[..64] : text;
	}

	void Search()
	{
		var q = Clip( State.SearchQuery );
		if ( q.Length == 0 )
			return;
		State.SearchedFor = q;
		PoliceTabletClient.Searches.Remove( q );
		PoliceTabletClient.SearchErrors.Remove( q );
		PoliceService.MdtRequestSearch( q );
	}

	/// <summary>Ouvre la fiche d'un citoyen dans l'écran courant (citoyens ou casiers), sinon dans celui des citoyens.</summary>
	void OpenCitizen( string steamId )
	{
		if ( !ulong.TryParse( steamId, out var id ) || id == 0 )
			return;
		if ( State.Tab is "citoyens" or "casiers" )
		{
			Sounds.Play( "tap", 0.25f );
			ShowFile( State, id );
			return;
		}
		Go( "citoyens", s => ShowFile( s, id ) );
	}

	static void ShowFile( MdtState s, ulong id )
	{
		if ( id != s.FileId )
		{
			s.Mode = null;
			s.Confirm = null;
		}
		s.FileId = id;
		PoliceService.MdtRequestCitizen( id );
		PoliceService.MdtRequestEvidence( "", id.ToString(), "", 1 );
		s.Asked["f:" + PoliceService.EvidenceKey( "", id.ToString(), "", 1 )] = 0;
	}

	void StartForm( string mode )
	{
		Sounds.Play( "tap", 0.25f );
		State.Mode = State.Mode == mode ? null : mode;
		State.DraftTitle = "";
		State.DraftNotes = "";
		State.DraftLocation = "";
		State.DraftDangerous = false;
		State.DraftCharges.Clear();
		State.ChargeQuery = "";
	}

	void SubmitForm()
	{
		var charges = string.Join( ",", State.DraftCharges );
		if ( State.Mode == "avis" )
		{
			if ( string.IsNullOrWhiteSpace( State.DraftNotes ) )
			{
				HudNotifications.Push( "Un avis de recherche a besoin d'un motif.", "erreur" );
				return;
			}
			PoliceService.MdtIssueWarrant( State.FileId, State.DraftNotes, charges, State.DraftDangerous );
		}
		else
		{
			if ( string.IsNullOrWhiteSpace( State.DraftTitle ) )
			{
				HudNotifications.Push( "Donnez un titre au rapport.", "erreur" );
				return;
			}
			PoliceService.MdtWriteRecord( State.FileId, State.Mode, charges, State.DraftTitle, State.DraftNotes, State.DraftLocation );
		}
		Sounds.Play( "envoye", 0.4f );
		State.Mode = null;
	}

	/// <summary>Les lignes du code pénal proposées dans le formulaire, d'après la saisie.</summary>
	IEnumerable<PoliceService.PenalEntry> ChargeSuggestions =>
		string.IsNullOrWhiteSpace( State.ChargeQuery ) ? Enumerable.Empty<PoliceService.PenalEntry>()
			: Penal.Where( x => !State.DraftCharges.Contains( x.Id ) && Matches( x, State.ChargeQuery ) ).Take( 6 );

	static bool Matches( PoliceService.PenalEntry entry, string query )
	{
		var q = query.Trim();
		return (entry.Id ?? "").Contains( q, StringComparison.OrdinalIgnoreCase )
			|| (entry.Title ?? "").Contains( q, StringComparison.OrdinalIgnoreCase )
			|| (entry.Category ?? "").Contains( q, StringComparison.OrdinalIgnoreCase );
	}

	/// <summary>Le citoyen est en ville : son temps de prison se lit en direct, sinon celui de la fiche.</summary>
	int JailLeft( Backend.MdtCitizenResponse file )
	{
		var online = RoleplayPlayer.All.FirstOrDefault( x => x.SteamId == State.FileId );
		if ( online is not null )
			return online.JailSecondsLeft;
		return file?.Jail?.SecondsLeft ?? 0;
	}

	string FileEvidenceKey => PoliceService.EvidenceKey( "", State.FileId.ToString(), "", 1 );

	/// <summary>
	/// La fiche d'un citoyen. <paramref name="casier"/> : l'appli « Casiers et rapports », qui met
	/// le casier et la rédaction en tête ; sinon la fiche complète du MDT (identité d'abord).
	/// </summary>
	RenderFragment CitizenFile( bool casier ) => @<text>
		@if ( State.FileId == 0 )
		{
			<div class="empty"><i>@(casier ? "folder_shared" : "badge")</i><div class="empty-text">@(casier ? "Recherchez un citoyen pour ouvrir son casier et y verser un rapport." : "Recherchez un citoyen puis ouvrez sa fiche.")</div></div>
		}
		else if ( File is null )
		{
			<div class="empty"><i>@(FileError is null ? "hourglass_empty" : "error")</i><div class="empty-text">@(FileError ?? "Ouverture de la fiche…")</div></div>
		}
		else
		{
			var f = File;
			var activeWarrants = (f.Warrants ?? new()).Where( x => x.Active ).ToList();
			var jail = JailLeft( f );
			<div class="scroll file">
				@foreach ( var w in activeWarrants )
				{
					<div class="wanted-banner @(w.IsDangerous ? "danger" : "")">
						<i>@(w.IsDangerous ? "warning" : "person_search")</i>
						<div class="wb-main">
							<div class="wb-title">@($"AVIS DE RECHERCHE ACTIF N°{(w.Id)}{(w.IsDangerous ? " · INDIVIDU DANGEREUX" : "")}")</div>
							<div class="wb-sub">@w.Reason</div>
						</div>
						@if ( MyRank >= PoliceRules.RankCloseWarrant )
						{
							<div class="btn white" onclick=@(() => Ask( $"avis:{w.Id}", () => PoliceService.MdtCloseWarrant( w.Id, State.FileId ) ))>@(State.Confirm == $"avis:{w.Id}" ? "Confirmer" : "Clore")</div>
						}
					</div>
				}
				@if ( jail > 0 )
				{
					<div class="jail-banner"><i>lock</i>@($"En détention · {(Clock( jail ))} de peine restante")</div>
				}

				@Identity( f, casier )

				<div class="actions">
					<div class="btn @(State.Mode == "rapport" ? "primary" : "")" onclick=@(() => StartForm( "rapport" ))><i>description</i>Rédiger un rapport</div>
					<div class="btn @(State.Mode == "avertissement" ? "primary" : "")" onclick=@(() => StartForm( "avertissement" ))><i>report</i>Avertissement</div>
					@if ( MyRank >= PoliceRules.RankWarrant )
					{
						<div class="btn @(State.Mode == "avis" ? "primary" : "danger")" onclick=@(() => StartForm( "avis" ))><i>person_search</i>Avis de recherche</div>
					}
					@if ( casier )
					{
						<div class="btn" onclick=@(() => Go( "citoyens", s => ShowFile( s, State.FileId ) ))><i>badge</i>Fiche complète</div>
					}
					else
					{
						<div class="btn" onclick=@(() => Go( "casiers", s => ShowFile( s, State.FileId ) ))><i>folder_shared</i>Casier</div>
					}
					<div class="btn" onclick=@(() => ShowFile( State, State.FileId ))><i>refresh</i></div>
				</div>

				@if ( State.Mode is not null )
				{
					@ReportForm()
				}

				@if ( casier )
				{
					@Records( f )
					@ClosedWarrants( f )
					<div class="grid2">
						@Fines( f )
						@FileEvidence()
					</div>
				}
				else
				{
					@ClosedWarrants( f )
					<div class="grid2">
						@CarsOf( f )
						@Fines( f )
					</div>
					@FileEvidence()
					@Records( f )
				}
			</div>
		}
	</text>;

	RenderFragment Identity( Backend.MdtCitizenResponse f, bool compact ) => @<text>
		@{
			var c = f.Citizen;
			var unpaid = (f.Fines ?? new()).Where( x => x.Unpaid ).ToList();
		}
		<div class="identity @(compact ? "compact" : "")">
			<div class="photo"><ScenePanel @ref="PhotoPanel" class="photo-3d"></ScenePanel><div class="photo-plate">@Documents.CitizenId( State.FileId )</div></div>
			<div class="id-fields">
				<div class="id-name">@(c?.RpName ?? "Inconnu")</div>
				<div class="id-grid">
					<div class="cell"><div class="id-label">Date de naissance</div><div class="value">@Format.Date( c?.BirthDate )</div></div>
					<div class="cell"><div class="id-label">Sexe</div><div class="value">@(c?.Sex ?? "—")</div></div>
					<div class="cell"><div class="id-label">Téléphone</div><div class="value">@(c?.Phone ?? "—")</div></div>
					<div class="cell"><div class="id-label">Métier</div><div class="value">@JobName( c?.Job )</div></div>
					<div class="cell"><div class="id-label">N° de citoyen</div><div class="value mono">@Documents.CitizenId( State.FileId )</div></div>
					<div class="cell"><div class="id-label">SteamID</div><div class="value mono">@State.FileId</div></div>
				</div>
				<div class="tags">
					@if ( RoleplayPlayer.All.Any( x => x.SteamId == State.FileId ) )
					{
						<div class="tag green">En ville</div>
					}
					@if ( unpaid.Any() )
					{
						<div class="tag amber">@($"Amendes impayées : {(Format.Money( unpaid.Sum( x => x.Amount ) ))}")</div>
					}
					@if ( (f.Records ?? new()).Any( x => x.Kind == "arrestation" ) )
					{
						<div class="tag red">@($"{(f.Records.Count( x => x.Kind == "arrestation" ))} arrestation(s)")</div>
					}
				</div>
			</div>
		</div>
	</text>;

	RenderFragment ReportForm() => @<text>
		<div class="card form">
			<div class="card-title"><i>@(State.Mode == "avis" ? "person_search" : State.Mode == "rapport" ? "description" : "report")</i>@(State.Mode == "avis" ? "Émettre un avis de recherche" : State.Mode == "rapport" ? "Nouveau rapport" : "Nouvel avertissement")</div>
			@if ( State.Mode != "avis" )
			{
				<div class="field-label">Titre</div>
				<TextEntry class="field" Placeholder="ex. Contrôle routier, Rixe devant le bar…" [email protected] OnTextEdited=@((string v) => { State.DraftTitle = v; })></TextEntry>
				<div class="field-label">Lieu</div>
				<TextEntry class="field" Placeholder="ex. Carrefour de Vinewood" [email protected] OnTextEdited=@((string v) => { State.DraftLocation = v; })></TextEntry>
			}
			<div class="field-label">@(State.Mode == "avis" ? "Motif" : "Récit des faits")</div>
			<TextEntry class="field tall" Multiline=@true Placeholder=@(State.Mode == "avis" ? "Pourquoi cette personne est recherchée…" : "Ce qui s'est passé, les témoins, les suites…") [email protected] OnTextEdited=@((string v) => { State.DraftNotes = v; })></TextEntry>
			<div class="field-label">Charges (code pénal)</div>
			@ChargeChips( State.DraftCharges, removable: true )
			<TextEntry class="field" Placeholder="Ajouter une charge : vol, PC-211…" [email protected] OnTextEdited=@((string v) => { State.ChargeQuery = v; })></TextEntry>
			@foreach ( var s in ChargeSuggestions )
			{
				<div class="suggestion" onclick=@(() => { State.DraftCharges.Add( s.Id ); State.ChargeQuery = ""; })>
					<div class="penal-id">@s.Id</div>
					<div class="penal-title">@s.Title</div>
					<div class="class-pill" style="@ClassStyle( s.Class )">@ClassName( s.Class )</div>
				</div>
			}
			@if ( State.Mode == "avis" )
			{
				<div class="toggle-row" onclick=@(() => State.DraftDangerous = !State.DraftDangerous)>
					<div class="switch @(State.DraftDangerous ? "on" : "")"><div class="knob"></div></div>
					<span>Individu dangereux (armé, violent)</span>
				</div>
			}
			<div class="actions">
				<div class="btn primary" onclick=@(() => SubmitForm())><i>send</i>@(State.Mode == "avis" ? "Diffuser l'avis" : "Verser au dossier")</div>
				<div class="btn" onclick=@(() => State.Mode = null)>Annuler</div>
			</div>
		</div>
	</text>;

	RenderFragment ClosedWarrants( Backend.MdtCitizenResponse f ) => @<text>
		@if ( (f.Warrants ?? new()).Any( x => !x.Active ) )
		{
			<div class="card">
				<div class="card-title"><i>history</i>Avis de recherche clos</div>
				@foreach ( var w in f.Warrants.Where( x => !x.Active ) )
				{
					<div class="record">
						<div class="kind-pill">@($"Avis n°{(w.Id)}")</div>
						<div class="record-main">
							<div class="record-title">@w.Reason</div>
							<div class="meta">@($"Émis le {(Stamp( w.CreatedAt ))} par {(w.IssuedBy ?? "–")} · clos le {(Stamp( w.ClosedAt ))} par {(w.ClosedBy ?? "–")}")</div>
						</div>
					</div>
				}
			</div>
		}
	</text>;

	RenderFragment CarsOf( Backend.MdtCitizenResponse f ) => @<text>
		<div class="card">
			<div class="card-title"><i>directions_car</i>Véhicules</div>
			@if ( !(f.Vehicles ?? new()).Any() )
			{
				<div class="hint">Aucun véhicule immatriculé.</div>
			}
			@foreach ( var v in f.Vehicles ?? new() )
			{
				<div class="row link" onclick=@(() => Go( "vehicules", s => QueryPlate( s, v.Plate ) ))>
					<div class="mini-plate">@v.Plate</div>
					<div class="row-main">
						<div class="row-title">@VehicleName( v.Model )</div>
						<div class="row-sub">@ColorName( v.Color )</div>
					</div>
					@if ( v.IsFlagged )
					{
						<div class="tag red">@FlagReason( v )</div>
					}
				</div>
			}
		</div>
	</text>;

	RenderFragment Fines( Backend.MdtCitizenResponse f ) => @<text>
		@{
			var unpaid = (f.Fines ?? new()).Where( x => x.Unpaid ).ToList();
		}
		<div class="card">
			<div class="card-title"><i>receipt_long</i>Amendes<div class="card-total">@($"Impayé : {(Format.Money( unpaid.Sum( x => x.Amount ) ))}")</div></div>
			@if ( !(f.Fines ?? new()).Any() )
			{
				<div class="hint">Aucune amende.</div>
			}
			@foreach ( var fine in f.Fines ?? new() )
			{
				<div class="row">
					<div class="row-main">
						<div class="row-title">@($"n°{(fine.Id)} · {(fine.Title ?? "Amende")}")</div>
						<div class="row-sub">@($"Dressée le {(Stamp( fine.IssuedAt ))}{(fine.Status == "payee" ? $" · payée le {Stamp( fine.PaidAt )}" : "")}")</div>
					</div>
					<div class="fine-amount">@Format.Money( fine.Amount )</div>
					<div class="status-tag @fine.Status">@FineStatus( fine.Status )</div>
					@if ( fine.Unpaid && MyRank >= PoliceRules.RankCancelFine )
					{
						<div class="btn small danger" onclick=@(() => Ask( $"amende:{fine.Id}", () => PoliceService.MdtCancelFine( fine.Id, State.FileId ) ))>@(State.Confirm == $"amende:{fine.Id}" ? "Confirmer" : "Annuler")</div>
					}
				</div>
			}
		</div>
	</text>;

	RenderFragment Records( Backend.MdtCitizenResponse f ) => @<text>
		<div class="card">
			<div class="card-title"><i>folder_shared</i>@($"Casier judiciaire ({((f.Records ?? new()).Count)})")</div>
			@if ( !(f.Records ?? new()).Any() )
			{
				<div class="hint">Casier vierge.</div>
			}
			@foreach ( var r in f.Records ?? new() )
			{
				<div class="record">
					<div class="kind-pill @r.Kind">@KindName( r.Kind )</div>
					<div class="record-main">
						<div class="record-title">@(string.IsNullOrWhiteSpace( r.Title ) ? KindName( r.Kind ) : r.Title)</div>
						@ChargeChips( r.ChargeIds )
						@if ( r.FineTotal > 0 || r.JailMinutes > 0 )
						{
							<div class="record-sanction">@Sanction( r )</div>
						}
						@if ( !string.IsNullOrWhiteSpace( r.Notes ) )
						{
							<div class="text">@r.Notes</div>
						}
						<div class="meta">@($"{(Stamp( r.CreatedAt ))} · {(r.OfficerName ?? "–")}{(string.IsNullOrWhiteSpace( r.Location ) ? "" : $" · {r.Location}")}")</div>
					</div>
					@if ( MyRank >= PoliceRules.RankDeleteRecord )
					{
						<div class="btn small danger" onclick=@(() => Ask( $"ligne:{r.Id}", () => PoliceService.MdtDeleteRecord( r.Id, State.FileId ) ))><i>delete</i>@(State.Confirm == $"ligne:{r.Id}" ? "Confirmer" : "")</div>
					}
				</div>
			}
		</div>
	</text>;

	/// <summary>Les scellés de la personne (ce qui lui a été saisi), sur sa fiche.</summary>
	RenderFragment FileEvidence() => @<text>
		@{
			var page = PoliceTabletClient.Evidence.GetValueOrDefault( FileEvidenceKey );
		}
		<div class="card">
			<div class="card-title"><i>inventory_2</i>Scellés<div class="card-link" onclick=@(() => Go( "scelles", s => { s.EvStatus = ""; s.EvQuery = File?.Citizen?.RpName ?? ""; s.EvApplied = s.EvQuery; s.EvPage = 1; } ))>Registre</div></div>
			@if ( page is null )
			{
				<div class="hint">Lecture du registre…</div>
			}
			else if ( !page.Ok )
			{
				<div class="hint">@page.Error</div>
			}
			else if ( !(page.Evidence ?? new()).Any() )
			{
				<div class="hint">Aucun objet saisi.</div>
			}
			else
			{
				@foreach ( var e in page.Evidence.Take( 8 ) )
				{
					<div class="row link" onclick=@(() => Go( "scelles", s => { s.EvStatus = ""; s.EvId = e.Id; } ))>
						<div class="seal-pill">@SealOf( e )</div>
						<div class="row-main">
							<div class="row-title">@EvidenceName( e )</div>
							<div class="row-sub">@($"Saisi le {(Stamp( e.CreatedAt ))} · {(e.By ?? "–")}")</div>
						</div>
						<div class="status-tag ev-@(e.Outcome)">@EvidenceStatus( e )</div>
					</div>
				}
			}
		</div>
	</text>;

	/// <summary>Les charges d'une ligne : l'article, l'intitulé, la classe en couleur.</summary>
	RenderFragment ChargeChips( List<string> ids, bool removable = false ) => @<text>
		@if ( ids is { Count: > 0 } )
		{
			<div class="charges">
				@foreach ( var id in ids.ToList() )
				{
					var entry = Penal.FirstOrDefault( x => x.Id == id );
					<div class="charge" style="@ClassStyle( entry?.Class, border: true )">
						<b>@id</b>
						<span>@(entry?.Title ?? "Article inconnu")</span>
						@if ( removable )
						{
							<i onclick=@(() => State.DraftCharges.Remove( id ))>close</i>
						}
					</div>
				}
			</div>
		}
	</text>;

	// La photo d'identité, d'après l'apparence enregistrée (comme la carte d'identité, IdCard)

	ScenePanel PhotoPanel { get; set; }
	readonly Preview3D photo = new() { TurnSpeed = 0, HeadDistance = 20f, HeadHeight = 67f, FaceOnly = true };

	void UpdatePhoto()
	{
		if ( State.Tab is not ("citoyens" or "casiers") || File?.Citizen is not { } citizen || PhotoPanel is null )
			return;
		photo.Attach( PhotoPanel );
		photo.ShowCharacter( citizen.AppearanceJson, citizen.Sex, headOnly: true );
		photo.Tick();
	}

	// ------------------------------------------------------------------ véhicules

	static string[] FlagReasons => new[] { "Volé", "Recherché", "Impliqué dans un délit", "Délit de fuite", "Défaut d'assurance" };

	void SearchPlate( string plate )
	{
		var clean = PoliceService.MdtPlateOf( plate );
		if ( clean is null )
		{
			HudNotifications.Push( "Plaque invalide.", "erreur" );
			return;
		}
		QueryPlate( State, clean );
	}

	static void QueryPlate( MdtState s, string plate )
	{
		var clean = PoliceService.MdtPlateOf( plate );
		if ( clean is null )
			return;
		s.PlateQuery = clean;
		s.FlagDraft = "";
		s.PlateFor = clean;
		PoliceTabletClient.Plates.Remove( clean );
		PoliceTabletClient.PlateErrors.Remove( clean );
		PoliceService.MdtRequestPlate( clean );
	}

	RenderFragment PlateFile() => @<text>
		@{
			var plateFor = State.PlateFor;
			var v = string.IsNullOrEmpty( plateFor ) ? null : PoliceTabletClient.Plates.GetValueOrDefault( plateFor );
			var plateError = string.IsNullOrEmpty( plateFor ) ? null : PoliceTabletClient.PlateErrors.GetValueOrDefault( plateFor );
		}
		@if ( string.IsNullOrEmpty( plateFor ) )
		{
			<div class="empty"><i>directions_car</i><div class="empty-text">Interrogez une plaque, ou choisissez un véhicule signalé.</div></div>
		}
		else if ( v is null )
		{
			<div class="empty"><i>@(plateError is null ? "hourglass_empty" : "help_outline")</i><div class="empty-text">@(plateError ?? $"Interrogation du fichier pour {plateFor}…")</div></div>
		}
		else
		{
			var flag = v.Flag?.Reason;
			<div class="scroll plate-file">
				@if ( !string.IsNullOrWhiteSpace( flag ) )
				{
					<div class="wanted-banner danger"><i>report</i><div class="wb-main"><div class="wb-title">VÉHICULE SIGNALÉ</div><div class="wb-sub">@flag</div></div></div>
				}
				<div class="vehicle-card">
					<div class="plate">
						<div class="plate-state">New Horizon</div>
						<div class="plate-number">@v.Plate</div>
						<div class="plate-foot">Union City</div>
					</div>
					<div class="id-fields">
						<div class="id-name">@VehicleName( v.Model )</div>
						<div class="id-grid">
							<div class="cell"><div class="id-label">Couleur</div><div class="value color"><div class="swatch" style="background-color: @(ColorHex( v.Color ))"></div>@ColorName( v.Color )</div></div>
							<div class="cell"><div class="id-label">Propriétaire</div>
								@if ( !string.IsNullOrEmpty( v.Owner ) )
								{
									<div class="value link" onclick=@(() => OpenCitizen( v.Owner ))>@(string.IsNullOrWhiteSpace( v.OwnerName ) ? v.Owner : v.OwnerName)</div>
								}
								else
								{
									<div class="value">Aucun propriétaire enregistré</div>
								}
							</div>
						</div>
					</div>
				</div>
				<div class="card">
					<div class="card-title"><i>flag</i>Signalement</div>
					@if ( !string.IsNullOrWhiteSpace( flag ) )
					{
						<div class="text">@($"Signalé : {flag}")</div>
						<div class="actions">
							<div class="btn" onclick=@(() => PoliceService.MdtSetFlag( v.Plate, "" ))><i>flag</i>Retirer le signalement</div>
						</div>
					}
					else
					{
						<div class="chips">
							@foreach ( var reason in FlagReasons )
							{
								<div class="chip @(State.FlagDraft == reason ? "active" : "")" onclick=@(() => State.FlagDraft = reason)>@reason</div>
							}
						</div>
						<TextEntry class="field" Placeholder="Ou un motif libre…" [email protected] OnTextEdited=@((string t) => { State.FlagDraft = t; })></TextEntry>
						<div class="actions">
							<div class="btn danger" onclick=@(() => SetFlag( v.Plate ))><i>report</i>Signaler le véhicule</div>
						</div>
					}
				</div>
			</div>
		}
	</text>;

	void SetFlag( string plate )
	{
		if ( string.IsNullOrWhiteSpace( State.FlagDraft ) )
		{
			HudNotifications.Push( "Choisissez un motif de signalement.", "erreur" );
			return;
		}
		PoliceService.MdtSetFlag( plate, State.FlagDraft );
		State.FlagDraft = "";
	}

	// ------------------------------------------------------------------ avis de recherche

	static string WarrantName( Backend.MdtWarrant w ) =>
		!string.IsNullOrWhiteSpace( w.RpName ) ? w.RpName : $"SteamID {w.Steam}";

	RenderFragment WarrantsScreen() => @<text>
		<div class="split">
			<div class="split-list">
				<div class="list-head">
					<div class="section-label">Avis actifs</div>
					<div class="btn small" onclick=@(() => PoliceService.MdtRequestWarrants())><i>refresh</i></div>
				</div>
				<div class="list">
					@if ( PoliceTabletClient.Warrants is null )
					{
						<div class="hint">@(PoliceTabletClient.WarrantsMissing ? "La liste des avis n'est pas disponible (action serverPoliceWarrants de l'API). Les avis d'une personne restent visibles sur sa fiche." : "Chargement…")</div>
					}
					else if ( !PoliceTabletClient.Warrants.Any( x => x.Active ) )
					{
						<div class="hint">Aucun avis de recherche actif.</div>
					}
					else
					{
						@foreach ( var w in PoliceTabletClient.Warrants.Where( x => x.Active ) )
						{
							<div class="person @(State.WarrantId == w.Id ? "active" : "")" onclick=@(() => { State.WarrantId = w.Id; State.Confirm = null; })>
								<div class="avatar red">@Initial( WarrantName( w ) )</div>
								<div class="row-main">
									<div class="row-title">@WarrantName( w )</div>
									<div class="row-sub">@w.Reason</div>
									<div class="tags">
										<div class="tag">@($"n°{(w.Id)} · {(Stamp( w.CreatedAt ))}")</div>
										@if ( w.IsDangerous )
										{
											<div class="tag red">Dangereux</div>
										}
									</div>
								</div>
							</div>
						}
					}
				</div>
			</div>
			<div class="split-detail">
				@if ( PoliceTabletClient.Warrants?.FirstOrDefault( x => x.Id == State.WarrantId ) is { } wd )
				{
					<div class="wanted-poster @(wd.IsDangerous ? "danger" : "")">
						<div class="poster-head">@(wd.IsDangerous ? "WANTED · ARMÉ ET DANGEREUX" : "AVIS DE RECHERCHE")</div>
						<div class="poster-name">@WarrantName( wd )</div>
						<div class="poster-sub">@($"Avis n°{(wd.Id)} · émis le {(Stamp( wd.CreatedAt ))} par {(wd.IssuedBy ?? "–")}")</div>
					</div>
					<div class="card">
						<div class="card-title"><i>description</i>Motif</div>
						<div class="text">@wd.Reason</div>
						@ChargeChips( wd.ChargeIds )
					</div>
					<div class="actions">
						@if ( ulong.TryParse( wd.Steam, out var wid ) )
						{
							<div class="btn" onclick=@(() => OpenCitizen( wd.Steam ))><i>badge</i>Ouvrir la fiche</div>
						}
						@if ( MyRank >= PoliceRules.RankCloseWarrant )
						{
							<div class="btn danger" onclick=@(() => Ask( $"avis:{wd.Id}", () => PoliceService.MdtCloseWarrant( wd.Id, wid ) ))><i>task_alt</i>@(State.Confirm == $"avis:{wd.Id}" ? "Confirmer la clôture" : "Clore l'avis")</div>
						}
					</div>
				}
				else
				{
					<div class="empty"><i>person_search</i><div class="empty-text">Choisissez un avis dans la liste.</div></div>
				}
			</div>
		</div>
	</text>;

	// ------------------------------------------------------------------ historique du 911

	/// <summary>Lignes par page de l'historique, comme l'API les rend.</summary>
	public const int HistoryPerPage = 25;

	string HistoryKey => $"{State.HistService}|{State.HistStatus}|{Clip( State.HistApplied )}|{State.HistPage}";

	void AskHistory() => PoliceService.MdtRequestHistory( State.HistService, State.HistStatus, Clip( State.HistApplied ), State.HistPage );

	void HistoryFilter( Action<MdtState> change )
	{
		Sounds.Play( "tap", 0.25f );
		change( State );
		State.HistPage = Math.Max( 1, State.HistPage );
		State.HistId = 0;
		AskHistory();
		State.Asked["h:" + HistoryKey] = 0;
	}

	List<Backend.MdtDispatchRow> HistoryRows => PoliceTabletClient.HistoryFor == HistoryKey ? PoliceTabletClient.History : null;

	RenderFragment HistoryScreen() => @<text>
		@{
			var rows = HistoryRows;
			var error = PoliceTabletClient.HistoryFor == HistoryKey ? PoliceTabletClient.HistoryError : null;
			var total = PoliceTabletClient.HistoryFor == HistoryKey ? PoliceTabletClient.HistoryTotal : 0;
			var selected = rows?.FirstOrDefault( x => x.Id == State.HistId );
		}
		<div class="toolbar">
			<div class="seg">
				<div class="seg-item @(State.HistService == "police" ? "on" : "")" onclick=@(() => HistoryFilter( s => { s.HistService = "police"; s.HistPage = 1; } ))>Police</div>
				<div class="seg-item @(State.HistService == "ems" ? "on" : "")" onclick=@(() => HistoryFilter( s => { s.HistService = "ems"; s.HistPage = 1; } ))>Secours</div>
				<div class="seg-item @(State.HistService == "" ? "on" : "")" onclick=@(() => HistoryFilter( s => { s.HistService = ""; s.HistPage = 1; } ))>Tous</div>
			</div>
			<div class="seg">
				@foreach ( var (id, name) in new[] { ("", "Tous"), ("ouvert", "En attente"), ("pris", "Pris"), ("clos", "Clos") } )
				{
					<div class="seg-item @(State.HistStatus == id ? "on" : "")" onclick=@(() => HistoryFilter( s => { s.HistStatus = id; s.HistPage = 1; } ))>@name</div>
				}
			</div>
			<div class="search grow">
				<i>search</i>
				<TextEntry class="field grow" Placeholder="Appelant, numéro, message, lieu, unité…" [email protected] OnTextEdited=@((string v) => { State.HistQuery = v; }) onsubmit=@(() => HistoryFilter( s => { s.HistApplied = s.HistQuery; s.HistPage = 1; } ))></TextEntry>
				<div class="btn primary" onclick=@(() => HistoryFilter( s => { s.HistApplied = s.HistQuery; s.HistPage = 1; } ))>Filtrer</div>
			</div>
		</div>
		<div class="split">
			<div class="table">
				<div class="thead">
					<div class="th c-date">Date</div>
					<div class="th c-id">N°</div>
					<div class="th c-svc">Service</div>
					<div class="th c-who">Appelant</div>
					<div class="th c-msg">Message</div>
					<div class="th c-loc">Lieu</div>
					<div class="th c-units">Unités</div>
					<div class="th c-st">Statut</div>
				</div>
				<div class="tbody">
					@if ( rows is null )
					{
						<div class="hint">@(error ?? "Lecture de l'historique…")</div>
					}
					else if ( rows.Count == 0 )
					{
						<div class="hint">Aucun appel ne répond à ces critères.</div>
					}
					else
					{
						@foreach ( var r in rows )
						{
							<div class="tr @(State.HistId == r.Id ? "active" : "")" onclick=@(() => { State.HistId = r.Id; })>
								<div class="td c-date">@Stamp( r.CreatedAt )</div>
								<div class="td c-id">@r.Id</div>
								<div class="td c-svc"><div class="service-tag @r.Service">@(r.Service == "ems" ? "EMS" : "POLICE")</div></div>
								<div class="td c-who">@(r.CallerName ?? "—")</div>
								<div class="td c-msg">@r.Message</div>
								<div class="td c-loc">@(r.Location ?? "—")</div>
								<div class="td c-units">@(r.UnitList.Count > 0 ? string.Join( ", ", r.UnitList ) : "—")</div>
								<div class="td c-st"><div class="status-tag @r.Status">@StatusName( r.Status )</div></div>
							</div>
						}
					}
				</div>
				<div class="tfoot">
					<div class="meta">@(rows is null ? "" : $"{(total)} appel(s) au fichier · page {(State.HistPage)}")</div>
					<div class="btn small @(State.HistPage <= 1 ? "off" : "")" onclick=@(() => { if ( State.HistPage > 1 ) HistoryFilter( s => s.HistPage-- ); })><i>chevron_left</i></div>
					<div class="btn small @(!HistoryHasNext( rows, total ) ? "off" : "")" onclick=@(() => { if ( HistoryHasNext( HistoryRows, total ) ) HistoryFilter( s => s.HistPage++ ); })><i>chevron_right</i></div>
				</div>
			</div>
			@if ( selected is not null )
			{
				<div class="side-detail">
					<div class="call-head-card @selected.Status">
						<div class="call-big-id">@($"n°{(selected.Id)}")</div>
						<div class="row-main">
							<div class="call-who">@($"{(selected.CallerName ?? "—")} · {(selected.Number ?? "—")}")</div>
							<div class="row-sub">@($"{(Stamp( selected.CreatedAt ))} · {(selected.Service == "ems" ? "Secours médicaux" : "Police")}")</div>
						</div>
					</div>
					<div class="card">
						<div class="card-title"><i>record_voice_over</i>Message</div>
						<div class="text">@($"« {(selected.Message)} »")</div>
						<div class="meta">@($"Lieu : {(selected.Location ?? "—")}")</div>
					</div>
					<div class="card">
						<div class="card-title"><i>local_police</i>Suites</div>
						<div class="text">@($"Statut : {(StatusName( selected.Status ))}")</div>
						@if ( !string.IsNullOrWhiteSpace( selected.TakenBy ) )
						{
							<div class="text">@($"Pris par {(selected.TakenBy)}{(string.IsNullOrEmpty( selected.TakenAt ) ? "" : $" le {Stamp( selected.TakenAt )}")}")</div>
						}
						<div class="text">@($"Unités : {(selected.UnitList.Count > 0 ? string.Join( ", ", selected.UnitList ) : "aucune")}")</div>
						@if ( selected.Closer is { } closer )
						{
							<div class="text">@($"Clos par {(closer)}{(string.IsNullOrEmpty( selected.ClosedAt ) ? "" : $" le {Stamp( selected.ClosedAt )}")}")</div>
						}
					</div>
					@if ( selected.HasPosition )
					{
						<div class="card">
							<div class="card-title"><i>map</i>Lieu de l'appel<div class="card-link" onclick=@(() => { Gps.SetTarget( selected.Position, $"911 n°{selected.Id}" ); HudNotifications.Push( "GPS : itinéraire vers le lieu de l'appel.", "info" ); })>GPS</div></div>
							@MiniMap( selected.Position )
						</div>
					}
					@if ( ulong.TryParse( selected.Caller, out var callerId ) && callerId != 0 )
					{
						<div class="btn" onclick=@(() => OpenCitizen( selected.Caller ))><i>badge</i>Fiche de l'appelant</div>
					}
				</div>
			}
		</div>
	</text>;

	bool HistoryHasNext( List<Backend.MdtDispatchRow> rows, int total )
	{
		if ( rows is null || rows.Count == 0 )
			return false;
		var per = PoliceTabletClient.HistoryPerPage > 0 ? PoliceTabletClient.HistoryPerPage : HistoryPerPage;
		return total > 0 ? State.HistPage * per < total : rows.Count >= per;
	}

	static bool EvidenceHasNext( Backend.MdtEvidenceResponse page, int pageNumber ) =>
		page?.Ok == true && (page.Evidence?.Count ?? 0) > 0
			&& (page.Total > 0 && page.PerPage > 0 ? pageNumber * page.PerPage < page.Total : page.Evidence.Count >= EvidencePerPage);

	// ------------------------------------------------------------------ scellés

	public const int EvidencePerPage = 25;

	string EvidenceListKey => PoliceService.EvidenceKey( State.EvStatus, "", Clip( State.EvApplied ), State.EvPage );

	void EvidenceFilter( Action<MdtState> change )
	{
		Sounds.Play( "tap", 0.25f );
		change( State );
		State.EvAction = null;
		PoliceService.MdtRequestEvidence( State.EvStatus, "", Clip( State.EvApplied ), State.EvPage );
		State.Asked["e:" + EvidenceListKey] = 0;
	}

	static string SealOf( Backend.MdtEvidence e ) => string.IsNullOrWhiteSpace( e.Seal ) ? $"S-{e.Id:000000}" : e.Seal;

	static string EvidenceName( Backend.MdtEvidence e ) => PoliceService.EvidenceLabel( e.Item, e.Count, e.DataJson, e.Cash, e.ItemName );

	static string EvidenceStatus( Backend.MdtEvidence e ) => e.Outcome switch
	{
		"restitue" => "Restitué",
		"detruit" => "Détruit",
		"transfere" => "Transféré",
		_ => "Sous scellé",
	};

	/// <summary>Le scellé choisi : dans la page affichée, sinon dans une autre page déjà lue (fiche du citoyen).</summary>
	Backend.MdtEvidence SelectedEvidence =>
		State.EvId == 0 ? null : PoliceTabletClient.Evidence.Values.Where( p => p.Ok ).SelectMany( p => p.Evidence ?? new() ).FirstOrDefault( x => x.Id == State.EvId );

	RenderFragment EvidenceScreen() => @<text>
		@{
			var page = PoliceTabletClient.Evidence.GetValueOrDefault( EvidenceListKey );
			var rows = page?.Ok == true ? page.Evidence ?? new() : null;
			var e = SelectedEvidence;
		}
		<div class="toolbar">
			<div class="seg">
				@foreach ( var (id, name) in new[] { ("stocke", "Sous scellé"), ("restitue", "Restitués"), ("detruit", "Détruits"), ("transfere", "Transférés"), ("", "Tous") } )
				{
					<div class="seg-item @(State.EvStatus == id ? "on" : "")" onclick=@(() => EvidenceFilter( s => { s.EvStatus = id; s.EvPage = 1; } ))>@name</div>
				}
			</div>
			<div class="search grow">
				<i>search</i>
				<TextEntry class="field grow" Placeholder="N° de scellé, objet, propriétaire, agent…" [email protected] OnTextEdited=@((string v) => { State.EvQuery = v; }) onsubmit=@(() => EvidenceFilter( s => { s.EvApplied = s.EvQuery; s.EvPage = 1; } ))></TextEntry>
				<div class="btn primary" onclick=@(() => EvidenceFilter( s => { s.EvApplied = s.EvQuery; s.EvPage = 1; } ))>Filtrer</div>
			</div>
		</div>
		<div class="split">
			<div class="table">
				<div class="thead">
					<div class="th c-seal">Scellé</div>
					<div class="th c-item">Contenu</div>
					<div class="th c-who">Saisi sur</div>
					<div class="th c-date">Saisi le</div>
					<div class="th c-by">Par</div>
					<div class="th c-st">Statut</div>
				</div>
				<div class="tbody">
					@if ( page is null )
					{
						<div class="hint">Lecture du registre…</div>
					}
					else if ( rows is null )
					{
						<div class="hint">@page.Error</div>
					}
					else if ( rows.Count == 0 )
					{
						<div class="hint">Aucun scellé ne répond à ces critères.</div>
					}
					else
					{
						@foreach ( var r in rows )
						{
							<div class="tr @(State.EvId == r.Id ? "active" : "")" onclick=@(() => { State.EvId = r.Id; State.EvAction = null; State.EvNotes = ""; })>
								<div class="td c-seal"><div class="seal-pill">@SealOf( r )</div></div>
								<div class="td c-item">@EvidenceName( r )</div>
								<div class="td c-who">@(r.OwnerName ?? r.Owner ?? "—")</div>
								<div class="td c-date">@Stamp( r.CreatedAt )</div>
								<div class="td c-by">@(r.By ?? "—")</div>
								<div class="td c-st"><div class="status-tag ev-@(r.Outcome)">@EvidenceStatus( r )</div></div>
							</div>
						}
					}
				</div>
				<div class="tfoot">
					<div class="meta">@(rows is null ? "" : $"{(page.Total > 0 ? page.Total : rows.Count)} scellé(s) · page {(State.EvPage)}")</div>
					<div class="btn small @(State.EvPage <= 1 ? "off" : "")" onclick=@(() => { if ( State.EvPage > 1 ) EvidenceFilter( s => s.EvPage-- ); })><i>chevron_left</i></div>
					<div class="btn small @(EvidenceHasNext( page, State.EvPage ) ? "" : "off")" onclick=@(() => { if ( EvidenceHasNext( page, State.EvPage ) ) EvidenceFilter( s => s.EvPage++ ); })><i>chevron_right</i></div>
				</div>
			</div>
			@if ( e is not null )
			{
				<div class="side-detail">
					<div class="evidence-bag @(e.Active ? "" : "closed")">
						<div class="bag-tape">PIÈCE À CONVICTION · UCPD · NE PAS OUVRIR</div>
						<div class="bag-body">
							<div class="bag-seal">@($"SCELLÉ N° {(SealOf( e ))}")</div>
							<div class="bag-item">@EvidenceName( e )</div>
							<div class="bag-grid">
								<div class="cell"><div class="id-label">Saisi sur</div>
									@if ( ulong.TryParse( e.Owner, out var ownerId ) && ownerId != 0 )
									{
										<div class="value link" onclick=@(() => OpenCitizen( e.Owner ))>@(e.OwnerName ?? e.Owner)</div>
									}
									else
									{
										<div class="value">@(e.OwnerName ?? "—")</div>
									}
								</div>
								<div class="cell"><div class="id-label">Saisi par</div><div class="value">@(e.By ?? "—")</div></div>
								<div class="cell"><div class="id-label">Date</div><div class="value">@Stamp( e.CreatedAt )</div></div>
								<div class="cell"><div class="id-label">Lieu</div><div class="value">@(e.Location ?? "—")</div></div>
								@if ( e.RecordId is > 0 )
								{
									<div class="cell"><div class="id-label">Dossier lié</div><div class="value">@($"Ligne n°{(e.RecordId)}")</div></div>
								}
							</div>
						</div>
						<div class="bag-stamp ev-@(e.Outcome)">@EvidenceStatus( e )</div>
					</div>
					<div class="card">
						<div class="card-title"><i>timeline</i>Chaîne de garde</div>
						<div class="custody">
							<div class="custody-step"><div class="dot-step on"></div><div class="row-main"><div class="row-title">Saisie et mise sous scellé</div><div class="row-sub">@($"{(Stamp( e.CreatedAt ))} · {(e.By ?? "–")}")</div></div></div>
							@if ( !e.Active )
							{
								<div class="custody-step"><div class="dot-step end"></div><div class="row-main"><div class="row-title">@EvidenceStatus( e )</div><div class="row-sub">@($"{(Stamp( e.ClosedAt ))} · {(e.ClosedBy ?? "–")}")</div>
									@if ( !string.IsNullOrWhiteSpace( e.Notes ) )
									{
										<div class="text">@e.Notes</div>
									}
								</div></div>
							}
							else
							{
								<div class="custody-step"><div class="dot-step"></div><div class="row-main"><div class="row-title">En salle des scellés</div><div class="row-sub">En attente de décision (restitution, destruction, transfert)</div></div></div>
							}
						</div>
					</div>
					@if ( e.Active )
					{
						@if ( MyRank >= PoliceService.RankEvidenceTransfer )
						{
							<div class="card form">
								<div class="card-title"><i>gavel</i>Clôturer le scellé</div>
								<div class="chips">
									@if ( MyRank >= PoliceService.RankEvidenceClose )
									{
										<div class="chip @(State.EvAction == "restitue" ? "active" : "")" onclick=@(() => { State.EvAction = "restitue"; State.Confirm = null; })>Restituer au propriétaire</div>
										<div class="chip @(State.EvAction == "detruit" ? "active" : "")" onclick=@(() => { State.EvAction = "detruit"; State.Confirm = null; })>Détruire</div>
									}
									<div class="chip @(State.EvAction == "transfere" ? "active" : "")" onclick=@(() => { State.EvAction = "transfere"; State.Confirm = null; })>Transférer</div>
								</div>
								@if ( MyRank < PoliceService.RankEvidenceClose )
								{
									<div class="hint">@($"Restituer ou détruire demande le grade d'{(PoliceRules.GradeOf( PoliceService.RankEvidenceClose ).Name)}.")</div>
								}
								@if ( State.EvAction is not null )
								{
									<div class="hint">@EvidenceHint( e, State.EvAction )</div>
									<TextEntry class="field" Placeholder=@(State.EvAction == "transfere" ? "À qui, pourquoi (obligatoire) : justice, laboratoire, FBI…" : "Note (facultative)") [email protected] OnTextEdited=@((string v) => { State.EvNotes = v; })></TextEntry>
									<div class="actions">
										<div class="btn @(State.EvAction == "restitue" ? "primary" : "danger")" onclick=@(() => Ask( $"scelle:{e.Id}:{State.EvAction}", () => CloseEvidence( e ) ))><i>task_alt</i>@(State.Confirm == $"scelle:{e.Id}:{State.EvAction}" ? "Confirmer" : EvidenceVerb( State.EvAction ))</div>
										<div class="btn" onclick=@(() => { State.EvAction = null; State.Confirm = null; })>Annuler</div>
									</div>
								}
							</div>
						}
						else
						{
							<div class="hint">@($"La clôture d'un scellé demande le grade d'{(PoliceRules.GradeOf( PoliceService.RankEvidenceTransfer ).Name)}.")</div>
						}
					}
				</div>
			}
		</div>
	</text>;

	string EvidenceHint( Backend.MdtEvidence e, string action )
	{
		if ( action == "restitue" )
		{
			var owner = RoleplayPlayer.All.FirstOrDefault( x => x.SteamId.ToString() == e.Owner );
			return owner is null
				? $"{e.OwnerName ?? "Le propriétaire"} n'est pas en ville : la restitution sera refusée."
				: $"{EvidenceName( e )} retournera dans les poches de {owner.RpName}.";
		}
		return action == "detruit" ? "L'objet sera détruit : il disparaît définitivement." : "Le scellé quitte la salle : la note dit à qui il est remis.";
	}

	static string EvidenceVerb( string action ) => action switch
	{
		"restitue" => "Restituer",
		"detruit" => "Détruire",
		_ => "Transférer",
	};

	void CloseEvidence( Backend.MdtEvidence e )
	{
		Sounds.Play( "envoye", 0.4f );
		PoliceService.MdtCloseEvidence( e.Id, State.EvAction, State.EvNotes ?? "" );
		State.EvAction = null;
		State.EvNotes = "";
	}

	// ------------------------------------------------------------------ code pénal

	IEnumerable<PoliceService.PenalEntry> PenalShown =>
		Penal.Where( x => (State.PenalCategory is null || x.Category == State.PenalCategory) && (string.IsNullOrWhiteSpace( State.PenalQuery ) || Matches( x, State.PenalQuery )) );

	RenderFragment PenalScreen() => @<text>
		<div class="search">
			<i>search</i>
			<TextEntry class="field grow" Placeholder="Rechercher une infraction, un article (PC-211)…" [email protected] OnTextEdited=@((string v) => { State.PenalQuery = v; })></TextEntry>
		</div>
		<div class="chips">
			<div class="chip @(State.PenalCategory is null ? "active" : "")" onclick=@(() => State.PenalCategory = null)>Toutes</div>
			@foreach ( var category in Penal.Select( x => x.Category ).Distinct() )
			{
				<div class="chip @(State.PenalCategory == category ? "active" : "")" onclick=@(() => State.PenalCategory = category)>@category</div>
			}
		</div>
		<div class="legend">
			@foreach ( var (id, name, english, color) in PoliceRules.Classes )
			{
				<div class="legend-item"><div class="dot" style="background-color: @color"></div>@($"{name} ({english})")</div>
			}
		</div>
		<div class="scroll">
			@if ( !Penal.Any() )
			{
				<div class="hint">Le code pénal n'est pas encore chargé (synchronisation avec le fichier).</div>
			}
			@foreach ( var group in PenalShown.GroupBy( x => x.Category ) )
			{
				<div class="section-label">@group.Key</div>
				@foreach ( var entry in group )
				{
					<div class="penal">
						<div class="penal-id">@entry.Id</div>
						<div class="penal-main">
							<div class="penal-title">@entry.Title</div>
							@if ( !string.IsNullOrWhiteSpace( entry.Description ) )
							{
								<div class="penal-desc">@entry.Description</div>
							}
						</div>
						<div class="class-pill" style="@ClassStyle( entry.Class )">@ClassName( entry.Class )</div>
						<div class="penal-fine">@Format.Money( entry.Fine )</div>
						<div class="penal-jail">@(entry.JailMinutes > 0 ? $"{entry.JailMinutes} min" : "—")</div>
					</div>
				}
			}
		</div>
	</text>;

	static (string Id, string Name, string English, string Color) ClassOf( string id ) =>
		PoliceRules.Classes.FirstOrDefault( x => x.Id == id ) is var c && c.Id is not null ? c : ("", "—", "", "#64748b");

	static string ClassName( string id ) => ClassOf( id ).Name;

	static string ClassStyle( string id, bool border = false )
	{
		var color = ClassOf( id ).Color;
		return border ? $"border-left: 3px solid {color};" : $"background-color: {color};";
	}

	// ------------------------------------------------------------------ effectifs

	RenderFragment RosterScreen() => @<text>
		<div class="list-head">
			<div class="section-label">@($"{(Roster.Count)} inscrits · {(OnDuty.Count)} en service")</div>
			<div class="btn small" onclick=@(() => PoliceService.MdtRequestRoster())><i>refresh</i></div>
		</div>
		@if ( MyRank < PoliceRules.RankRoster )
		{
			<div class="hint">@($"La gestion des effectifs demande le grade de {(PoliceRules.GradeOf( PoliceRules.RankRoster ).Name)}.")</div>
		}
		<div class="scroll">
			@if ( PoliceTabletClient.Roster is null )
			{
				<div class="hint">Chargement des effectifs…</div>
			}
			@foreach ( var group in Roster.GroupBy( x => x.Grade ) )
			{
				var grade = PoliceRules.GradeOf( group.Key );
				<div class="grade-head"><i>@grade.Insignia</i>@($"{(grade.Name)} · {(grade.English)}")<span>@group.Count()</span></div>
				@foreach ( var officer in group )
				{
					var online = RoleplayPlayer.All.FirstOrDefault( x => x.SteamId.ToString() == officer.SteamId );
					<div class="officer">
						<div class="duty-dot @(officer.OnDuty ? "on" : officer.Online ? "idle" : "")"></div>
						<div class="row-main">
							<div class="row-title">@officer.Name</div>
							<div class="row-sub">@($"Matricule {(Badge( officer.Badge ))} · {(officer.OnDuty ? "en service" : officer.Online ? "en ville, hors service" : "hors ligne")}")</div>
						</div>
						@if ( online is not null && online.IsCop )
						{
							var st = PoliceService.UnitStateOf( online );
							<div class="unit-chip" style="@UnitStyle( st )"><i>@st.Icon</i>@st.Name</div>
						}
						<div class="callsign">@Callsign( officer.Callsign )</div>
						@if ( CanManage( officer ) )
						{
							<div class="btn small" onclick=@(() => EditOfficer( officer ))><i>edit</i></div>
						}
					</div>
					@if ( State.RosterEdit == officer.SteamId )
					{
						<div class="roster-edit">
							<div class="field-label">Grade</div>
							<div class="chips">
								@foreach ( var g in PoliceRules.Grades.Where( x => x.Rank < MyRank ) )
								{
									<div class="chip @(State.RosterGrade == g.Rank ? "active" : "")" onclick=@(() => State.RosterGrade = g.Rank)>@g.Name</div>
								}
							</div>
							<div class="field-label">Indicatif radio</div>
							<TextEntry class="field" Placeholder="ex. 2-ADAM-12" [email protected] OnTextEdited=@((string v) => { State.RosterCallsign = v; })></TextEntry>
							<div class="actions">
								<div class="btn primary" onclick=@(() => SaveOfficer( officer ))><i>check</i>@RosterVerb( officer )</div>
								<div class="btn danger" onclick=@(() => Ask( $"renvoi:{officer.SteamId}", () => FireOfficer( officer ) ))><i>person_remove</i>@(State.Confirm == $"renvoi:{officer.SteamId}" ? "Confirmer le renvoi" : "Renvoyer")</div>
								<div class="btn" onclick=@(() => State.RosterEdit = null)>Annuler</div>
							</div>
						</div>
					}
				}
			}
		</div>
	</text>;

	/// <summary>Au grade de gestion : un agent de grade inférieur au sien, jamais soi-même (l'hôte et l'API revérifient).</summary>
	bool CanManage( MdtOfficerRow officer ) =>
		MyRank >= PoliceRules.RankRoster && officer.Grade < MyRank && officer.Id != Me.SteamId;

	void EditOfficer( MdtOfficerRow officer )
	{
		Sounds.Play( "tap", 0.25f );
		State.Confirm = null;
		if ( State.RosterEdit == officer.SteamId )
		{
			State.RosterEdit = null;
			return;
		}
		State.RosterEdit = officer.SteamId;
		State.RosterGrade = officer.Grade;
		State.RosterCallsign = officer.Callsign ?? "";
	}

	string RosterVerb( MdtOfficerRow officer ) =>
		State.RosterGrade > officer.Grade ? $"Promouvoir {PoliceRules.GradeOf( State.RosterGrade ).Name}"
		: State.RosterGrade < officer.Grade ? $"Rétrograder {PoliceRules.GradeOf( State.RosterGrade ).Name}"
		: "Enregistrer l'indicatif";

	void SaveOfficer( MdtOfficerRow officer )
	{
		PoliceService.MdtSetRoster( officer.Id, State.RosterGrade, State.RosterCallsign ?? "", false );
		State.RosterEdit = null;
	}

	void FireOfficer( MdtOfficerRow officer )
	{
		PoliceService.MdtSetRoster( officer.Id, -1, "", true );
		State.RosterEdit = null;
	}

	// ------------------------------------------------------------------ petits textes

	static string Initial( string name ) => string.IsNullOrWhiteSpace( name ) ? "?" : name.Trim()[..1].ToUpperInvariant();

	static string Badge( string badge ) => string.IsNullOrWhiteSpace( badge ) ? "—" : badge;

	static string Callsign( string callsign ) => string.IsNullOrWhiteSpace( callsign ) ? "sans indicatif" : callsign;

	static string JobName( string id ) => string.IsNullOrEmpty( id ) ? "—" : Jobs.Get( id )?.Name ?? id;

	static string VehicleName( string model ) => VehicleCatalog.Get( model )?.Name ?? model ?? "Véhicule";

	/// <summary>Une couleur de la base (« #111317 » ou un nom) : son nom dans le nuancier du jeu.</summary>
	static string ColorName( string color )
	{
		if ( string.IsNullOrWhiteSpace( color ) )
			return "—";
		var known = VehicleCatalog.Colors.FirstOrDefault( x => string.Equals( x.Hex, color, StringComparison.OrdinalIgnoreCase ) );
		return known.Name ?? color;
	}

	static string ColorHex( string color ) => color is { Length: > 0 } && color.StartsWith( "#" ) ? color : "#64748b";

	/// <summary>Le motif d'un signalement : celui de la fiche, sinon celui de la liste publiée.</summary>
	string FlagReason( Backend.MdtCarRow car )
	{
		if ( !string.IsNullOrWhiteSpace( car.FlagReason ) )
			return car.FlagReason;
		return Flags.FirstOrDefault( x => x.Plate == car.Plate )?.Reason ?? "Signalé";
	}

	static string KindName( string kind ) => kind switch
	{
		"amende" => "Amende",
		"arrestation" => "Arrestation",
		"avertissement" => "Avertissement",
		"rapport" => "Rapport",
		_ => kind ?? "—",
	};

	static string FineStatus( string status ) => status switch
	{
		"impayee" => "Impayée",
		"payee" => "Payée",
		"annulee" => "Annulée",
		_ => status ?? "—",
	};

	static string Sanction( Backend.MdtRecord r )
	{
		var parts = new List<string>();
		if ( r.FineTotal > 0 )
			parts.Add( $"Amende {Format.Money( r.FineTotal )}" );
		if ( r.JailMinutes > 0 )
			parts.Add( $"{r.JailMinutes} min de prison" );
		return string.Join( " · ", parts );
	}

	/// <summary>« 2026-10-28 16:03:01 » → « 28/10/2026 16:03 ».</summary>
	static string Stamp( string at ) => DateTime.TryParse( at, out var d ) ? d.ToString( "dd/MM/yyyy HH:mm" ) : at ?? "–";

	static string Clock( int seconds ) => $"{seconds / 60}:{seconds % 60:00}";

	protected override int BuildHash()
	{
		if ( State is null || Me is null )
			return 0;
		var service = Service;
		var cops = RoleplayPlayer.All.Aggregate( 0, ( h, p ) => p.IsCop ? HashCode.Combine( h, p.RpName, p.PoliceGrade, p.PoliceCallsign, p.UnitStatus, p.GetComponentInParent<Vehicle>()?.Plate ) : h );
		// Les distances, le lecteur de plaques et le temps de prison bougent : quatre fois par seconde suffit.
		var live = State.Tab is "appels" or "accueil" or "citoyens" or "casiers" or "lecteur" ? (int)(RealTime.Now * 4) : 0;
		return HashCode.Combine(
			State.Hash(),
			HashCode.Combine( PoliceTabletClient.Version, live, Gps.Version, Tablet?.ReadsVersion ),
			HashCode.Combine( service?.DispatchJson, service?.OfficersJson, service?.PenalJson, service?.FlagsJson ),
			HashCode.Combine( cops, Me.PoliceGrade, Me.PoliceCallsign, Me.UnitStatus ) );
	}
}