This commit is contained in:
Antoine Pelletier 2026-08-25 11:09:50 +02:00
parent 51df9e75ad
commit 7c38a1e606
6 changed files with 126 additions and 31 deletions

View file

@ -28,23 +28,40 @@ const freeName = ref(model.value?.kind === 'free' ? model.value.name : '')
const onlyFree = computed(() => props.units.length === 0) const onlyFree = computed(() => props.units.length === 0)
const showFreeInput = computed(() => onlyFree.value || selected.value === FREE) const showFreeInput = computed(() => onlyFree.value || selected.value === FREE)
function emitChoice() { /** The choice the two controls above currently stand for */
const local = computed<UnitChoice | undefined>(() => {
if (showFreeInput.value) { if (showFreeInput.value) {
const name = freeName.value.trim() const name = freeName.value.trim()
model.value = name ? { kind: 'free', name } : undefined // An empty box is an unfinished choice, not another association: the field
return // reads as unfilled, while the box itself stays open to type in.
return name ? { kind: 'free', name } : undefined
} }
const unit = props.units.find((u) => String(u.id) === selected.value) const unit = props.units.find((u) => String(u.id) === selected.value)
model.value = unit ? { kind: 'known', id: unit.id, name: unit.name } : undefined return unit ? { kind: 'known', id: unit.id, name: unit.name } : undefined
})
function sameChoice(left: UnitChoice | undefined, right: UnitChoice | undefined) {
if (!left || !right) return left === right
if (left.kind === 'known') return right.kind === 'known' && left.id === right.id
return right.kind === 'free' && left.name === right.name
} }
watch([selected, freeName], emitChoice) watch(local, (choice) => {
model.value = choice
})
/**
* Follows the model when it says something other than what the controls say —
* a form being reset, mostly.
*
* Comparing the two, rather than watching for any change, is what keeps an
* emptied text box open: clearing it does report "nothing chosen", and that
* report must not be read back as somebody else cancelling the choice.
*/
watch(model, (choice) => { watch(model, (choice) => {
if (choice === undefined && (selected.value || freeName.value)) { if (sameChoice(choice, local.value)) return
selected.value = '' selected.value = choice?.kind === 'known' ? String(choice.id) : choice ? FREE : ''
freeName.value = '' freeName.value = choice?.kind === 'free' ? choice.name : ''
}
}) })
</script> </script>

View file

@ -39,19 +39,18 @@ const activeSorted = computed(() => byStart(props.active))
<template> <template>
<Card> <Card>
<CardHeader> <CardHeader class="flex-row items-start justify-between gap-4">
<div class="grid gap-y-1.5">
<CardTitle>{{ $t('admin.reservations.title') }}</CardTitle> <CardTitle>{{ $t('admin.reservations.title') }}</CardTitle>
<CardDescription>{{ $t('admin.reservations.intro') }}</CardDescription> <CardDescription>{{ $t('admin.reservations.intro') }}</CardDescription>
</CardHeader> </div>
<Button size="sm" class="shrink-0" @click="createOpen = true">
<CardContent class="grid gap-6">
<div>
<Button size="sm" @click="createOpen = true">
<Plus class="size-4" /> <Plus class="size-4" />
{{ $t('admin.reservations.create.action') }} {{ $t('admin.reservations.create.action') }}
</Button> </Button>
</div> </CardHeader>
<CardContent class="grid gap-6">
<div v-if="isPending" class="grid gap-3"> <div v-if="isPending" class="grid gap-3">
<Skeleton class="h-28 w-full" /> <Skeleton class="h-28 w-full" />
<Skeleton class="h-28 w-full" /> <Skeleton class="h-28 w-full" />

View file

@ -63,6 +63,19 @@ const BADGE_CLASS: Record<ReservationStatus, string> = {
archived: 'bg-muted text-muted-foreground', archived: 'bg-muted text-muted-foreground',
} }
/**
* The rail down the left edge: a status is seen before it is read, which is
* what an administrator scanning the queue actually does.
*/
const RAIL_CLASS: Record<ReservationStatus, string> = {
requested: 'border-l-amber-500',
approved: 'border-l-emerald-500',
ongoing: 'border-l-primary',
refused: 'border-l-destructive',
cancelled: 'border-l-destructive',
archived: 'border-l-muted-foreground/40',
}
const transitions = computed(() => TRANSITIONS[props.reservation.status]) const transitions = computed(() => TRANSITIONS[props.reservation.status])
/** /**
@ -148,7 +161,10 @@ function move(status: ReservationStatus, reason?: string) {
</script> </script>
<template> <template>
<div class="bg-muted/60 rounded-lg border p-4"> <div
class="bg-card rounded-lg border border-l-4 p-4 shadow-sm transition-shadow hover:shadow-md"
:class="RAIL_CLASS[reservation.status]"
>
<div class="flex flex-wrap items-start justify-between gap-3"> <div class="flex flex-wrap items-start justify-between gap-3">
<div class="flex min-w-0 flex-wrap items-center gap-2"> <div class="flex min-w-0 flex-wrap items-center gap-2">
<span class="font-semibold">#{{ reservation.id }}</span> <span class="font-semibold">#{{ reservation.id }}</span>

View file

@ -52,6 +52,19 @@ const BADGE_CLASS: Record<ReservationStatus, string> = {
archived: 'bg-muted text-muted-foreground', archived: 'bg-muted text-muted-foreground',
} }
/**
* The rail down the left edge: a status is seen before it is read, which is
* what an administrator scanning the queue actually does.
*/
const RAIL_CLASS: Record<ReservationStatus, string> = {
requested: 'border-l-amber-500',
approved: 'border-l-emerald-500',
ongoing: 'border-l-primary',
refused: 'border-l-destructive',
cancelled: 'border-l-destructive',
archived: 'border-l-muted-foreground/40',
}
const sections = computed(() => [ const sections = computed(() => [
{ key: 'requested', reservations: requested.data.value?.items ?? [] }, { key: 'requested', reservations: requested.data.value?.items ?? [] },
{ key: 'active', reservations: active.data.value?.items ?? [] }, { key: 'active', reservations: active.data.value?.items ?? [] },
@ -133,7 +146,8 @@ function editableEmails(reservation: Reservation) {
<div <div
v-for="reservation in section.reservations" v-for="reservation in section.reservations"
:key="reservation.id" :key="reservation.id"
class="bg-muted/60 rounded-lg border p-4" class="bg-card rounded-lg border border-l-4 p-4 shadow-sm transition-shadow hover:shadow-md"
:class="RAIL_CLASS[reservation.status]"
> >
<div class="flex flex-wrap items-start justify-between gap-3"> <div class="flex flex-wrap items-start justify-between gap-3">
<div class="flex min-w-0 flex-wrap items-center gap-2"> <div class="flex min-w-0 flex-wrap items-center gap-2">

View file

@ -21,7 +21,10 @@ pub struct ReservationCard {
pub unit: String, pub unit: String,
pub requester: String, pub requester: String,
pub telegram: String, pub telegram: String,
pub email: String, /// The Linka Go accounts on the reservation — the addresses that end up on
/// the bikes' whitelist. Whoever filed the request is named above; their own
/// address means nothing here unless it is one of these.
pub emails: Vec<String>,
pub start_time: DateTime<Utc>, pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>, pub end_time: DateTime<Utc>,
pub bikes: Vec<String>, pub bikes: Vec<String>,
@ -43,7 +46,7 @@ impl ReservationCard {
|user| format!("{} {}", user.firstname, user.name), |user| format!("{} {}", user.firstname, user.name),
), ),
telegram: reservation.telegram.clone(), telegram: reservation.telegram.clone(),
email: requester.map(|user| user.email.clone()).unwrap_or_default(), emails: reservation.linka_emails.clone(),
start_time: reservation.start_time, start_time: reservation.start_time,
end_time: reservation.end_time, end_time: reservation.end_time,
bikes, bikes,
@ -55,23 +58,27 @@ impl ReservationCard {
/// The block shared by every message about this reservation, so a request /// The block shared by every message about this reservation, so a request
/// and the decision it becomes carry exactly the same facts. /// and the decision it becomes carry exactly the same facts.
pub fn details(&self) -> String { pub fn details(&self) -> String {
let bikes = if self.bikes.is_empty() { let list = |values: &[String]| {
if values.is_empty() {
"—".to_owned() "—".to_owned()
} else { } else {
self.bikes.join(", ") values.join(", ")
}
}; };
let bikes = list(&self.bikes);
let emails = list(&self.emails);
format!( format!(
"<b>Association :</b> {unit}\n\ "<b>Association :</b> {unit}\n\
<b>Demandeur :</b> {requester}\n\ <b>Demandeur :</b> {requester}\n\
<b>Telegram :</b> {telegram}\n\ <b>Telegram :</b> {telegram}\n\
<b>E-mail :</b> {email}\n\ <b>Comptes Linka Go :</b> {emails}\n\
<b>Période :</b> {start} → {end}\n\ <b>Période :</b> {start} → {end}\n\
<b>Cargobike(s) :</b> {bikes}\n\ <b>Cargobike(s) :</b> {bikes}\n\
<b>Raison :</b> {description}", <b>Raison :</b> {description}",
unit = escape(&self.unit), unit = escape(&self.unit),
requester = escape(&self.requester), requester = escape(&self.requester),
telegram = escape(&self.telegram), telegram = escape(&self.telegram),
email = escape(&self.email), emails = escape(&emails),
start = format_moment(self.start_time), start = format_moment(self.start_time),
end = format_moment(self.end_time), end = format_moment(self.end_time),
bikes = escape(&bikes), bikes = escape(&bikes),
@ -112,7 +119,10 @@ pub mod tests {
unit: "PolyNite".to_owned(), unit: "PolyNite".to_owned(),
requester: "Milan Hyenne".to_owned(), requester: "Milan Hyenne".to_owned(),
telegram: "@tibiscuit_18".to_owned(), telegram: "@tibiscuit_18".to_owned(),
email: "milan.hyenne@epfl.ch".to_owned(), emails: vec![
"milan.hyenne@epfl.ch".to_owned(),
"alice.martin@epfl.ch".to_owned(),
],
start_time: moment(25, 10), start_time: moment(25, 10),
end_time: moment(26, 16), end_time: moment(26, 16),
bikes: vec!["1000".to_owned(), "2000".to_owned()], bikes: vec!["1000".to_owned(), "2000".to_owned()],
@ -121,6 +131,44 @@ pub mod tests {
} }
} }
/// The group is shown the addresses that end up on the bikes' whitelist —
/// the Linka Go accounts — and not the address of whoever filled the form
/// in, which unlocks nothing.
#[test]
fn the_card_carries_the_linka_accounts_rather_than_the_filers_address() {
use crate::core::models::{reservation::ReservationUnit, user::UserSummary};
let reservation = Reservation {
id: 12,
unit: ReservationUnit::Free {
name: "PolyNite".to_owned(),
},
start_time: moment(25, 10),
end_time: moment(26, 16),
requester: 7,
users: vec![UserSummary {
id: 7,
firstname: "Milan".to_owned(),
name: "Hyenne".to_owned(),
email: "milan.hyenne@epfl.ch".to_owned(),
}],
telegram: "@tibiscuit_18".to_owned(),
description: "Transport du matériel".to_owned(),
bikes: vec![],
linka_emails: vec!["whitelist@epfl.ch".to_owned()],
status: ReservationStatus::Requested,
};
let card = ReservationCard::new(&reservation, vec!["1000".to_owned()]);
let details = card.details();
assert_eq!(card.emails, vec!["whitelist@epfl.ch"]);
assert!(details.contains("whitelist@epfl.ch"));
assert!(!details.contains("milan.hyenne@epfl.ch"));
// Whoever filed it is still named, by name
assert!(details.contains("Milan Hyenne"));
}
#[test] #[test]
fn markup_in_a_user_value_is_escaped() { fn markup_in_a_user_value_is_escaped() {
let mut card = card(ReservationStatus::Requested); let mut card = card(ReservationStatus::Requested);

View file

@ -44,7 +44,8 @@ mod tests {
"PolyNite", "PolyNite",
"Milan Hyenne", "Milan Hyenne",
"@tibiscuit_18", "@tibiscuit_18",
"milan.hyenne@epfl.ch", // Both Linka Go accounts, which is what the whitelist is built from
"milan.hyenne@epfl.ch, alice.martin@epfl.ch",
"1000, 2000", "1000, 2000",
"Transport du matériel", "Transport du matériel",
// Rendered in local time: 10:00 UTC is noon in Lausanne // Rendered in local time: 10:00 UTC is noon in Lausanne