wip
This commit is contained in:
parent
51df9e75ad
commit
7c38a1e606
6 changed files with 126 additions and 31 deletions
|
|
@ -28,23 +28,40 @@ const freeName = ref(model.value?.kind === 'free' ? model.value.name : '')
|
|||
const onlyFree = computed(() => props.units.length === 0)
|
||||
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) {
|
||||
const name = freeName.value.trim()
|
||||
model.value = name ? { kind: 'free', name } : undefined
|
||||
return
|
||||
// An empty box is an unfinished choice, not another association: the field
|
||||
// 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)
|
||||
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) => {
|
||||
if (choice === undefined && (selected.value || freeName.value)) {
|
||||
selected.value = ''
|
||||
freeName.value = ''
|
||||
}
|
||||
if (sameChoice(choice, local.value)) return
|
||||
selected.value = choice?.kind === 'known' ? String(choice.id) : choice ? FREE : ''
|
||||
freeName.value = choice?.kind === 'free' ? choice.name : ''
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -39,19 +39,18 @@ const activeSorted = computed(() => byStart(props.active))
|
|||
|
||||
<template>
|
||||
<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>
|
||||
<CardDescription>{{ $t('admin.reservations.intro') }}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="grid gap-6">
|
||||
<div>
|
||||
<Button size="sm" @click="createOpen = true">
|
||||
</div>
|
||||
<Button size="sm" class="shrink-0" @click="createOpen = true">
|
||||
<Plus class="size-4" />
|
||||
{{ $t('admin.reservations.create.action') }}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="grid gap-6">
|
||||
<div v-if="isPending" class="grid gap-3">
|
||||
<Skeleton class="h-28 w-full" />
|
||||
<Skeleton class="h-28 w-full" />
|
||||
|
|
|
|||
|
|
@ -63,6 +63,19 @@ const BADGE_CLASS: Record<ReservationStatus, string> = {
|
|||
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])
|
||||
|
||||
/**
|
||||
|
|
@ -148,7 +161,10 @@ function move(status: ReservationStatus, reason?: string) {
|
|||
</script>
|
||||
|
||||
<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 min-w-0 flex-wrap items-center gap-2">
|
||||
<span class="font-semibold">#{{ reservation.id }}</span>
|
||||
|
|
|
|||
|
|
@ -52,6 +52,19 @@ const BADGE_CLASS: Record<ReservationStatus, string> = {
|
|||
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(() => [
|
||||
{ key: 'requested', reservations: requested.data.value?.items ?? [] },
|
||||
{ key: 'active', reservations: active.data.value?.items ?? [] },
|
||||
|
|
@ -133,7 +146,8 @@ function editableEmails(reservation: Reservation) {
|
|||
<div
|
||||
v-for="reservation in section.reservations"
|
||||
: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 min-w-0 flex-wrap items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -21,7 +21,10 @@ pub struct ReservationCard {
|
|||
pub unit: String,
|
||||
pub requester: 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 end_time: DateTime<Utc>,
|
||||
pub bikes: Vec<String>,
|
||||
|
|
@ -43,7 +46,7 @@ impl ReservationCard {
|
|||
|user| format!("{} {}", user.firstname, user.name),
|
||||
),
|
||||
telegram: reservation.telegram.clone(),
|
||||
email: requester.map(|user| user.email.clone()).unwrap_or_default(),
|
||||
emails: reservation.linka_emails.clone(),
|
||||
start_time: reservation.start_time,
|
||||
end_time: reservation.end_time,
|
||||
bikes,
|
||||
|
|
@ -55,23 +58,27 @@ impl ReservationCard {
|
|||
/// The block shared by every message about this reservation, so a request
|
||||
/// and the decision it becomes carry exactly the same facts.
|
||||
pub fn details(&self) -> String {
|
||||
let bikes = if self.bikes.is_empty() {
|
||||
let list = |values: &[String]| {
|
||||
if values.is_empty() {
|
||||
"—".to_owned()
|
||||
} else {
|
||||
self.bikes.join(", ")
|
||||
values.join(", ")
|
||||
}
|
||||
};
|
||||
let bikes = list(&self.bikes);
|
||||
let emails = list(&self.emails);
|
||||
format!(
|
||||
"<b>Association :</b> {unit}\n\
|
||||
<b>Demandeur :</b> {requester}\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>Cargobike(s) :</b> {bikes}\n\
|
||||
<b>Raison :</b> {description}",
|
||||
unit = escape(&self.unit),
|
||||
requester = escape(&self.requester),
|
||||
telegram = escape(&self.telegram),
|
||||
email = escape(&self.email),
|
||||
emails = escape(&emails),
|
||||
start = format_moment(self.start_time),
|
||||
end = format_moment(self.end_time),
|
||||
bikes = escape(&bikes),
|
||||
|
|
@ -112,7 +119,10 @@ pub mod tests {
|
|||
unit: "PolyNite".to_owned(),
|
||||
requester: "Milan Hyenne".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),
|
||||
end_time: moment(26, 16),
|
||||
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]
|
||||
fn markup_in_a_user_value_is_escaped() {
|
||||
let mut card = card(ReservationStatus::Requested);
|
||||
|
|
|
|||
|
|
@ -44,7 +44,8 @@ mod tests {
|
|||
"PolyNite",
|
||||
"Milan Hyenne",
|
||||
"@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",
|
||||
"Transport du matériel",
|
||||
// Rendered in local time: 10:00 UTC is noon in Lausanne
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue