fix: date selection and hand selection
This commit is contained in:
parent
754d2ddfc4
commit
b50d1fe855
14 changed files with 184 additions and 47 deletions
18
db/migrations/20260824120000_bike_size.sql
Normal file
18
db/migrations/20260824120000_bike_size.sql
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
-- migrate:up
|
||||
|
||||
-- The fleet mixes two frame sizes and the reservation form asks for one of
|
||||
-- them, so the size is an attribute of the bike rather than something guessed
|
||||
-- from its name.
|
||||
CREATE TYPE bike_size AS ENUM ('large', 'small');
|
||||
|
||||
ALTER TABLE bikes ADD COLUMN "size" bike_size NOT NULL DEFAULT 'small';
|
||||
|
||||
-- The current fleet: 1000 and 2000 are the large ones, 3000/4000/5000 the small
|
||||
UPDATE bikes SET "size" = 'large' WHERE "name" IN ('1000', '2000');
|
||||
|
||||
-- Every bike must state its size, so no value is implied from now on
|
||||
ALTER TABLE bikes ALTER COLUMN "size" DROP DEFAULT;
|
||||
|
||||
-- migrate:down
|
||||
ALTER TABLE bikes DROP COLUMN "size";
|
||||
DROP TYPE bike_size;
|
||||
|
|
@ -15,6 +15,16 @@ SET xmloption = content;
|
|||
SET client_min_messages = warning;
|
||||
SET row_security = off;
|
||||
|
||||
--
|
||||
-- Name: bike_size; Type: TYPE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE TYPE public.bike_size AS ENUM (
|
||||
'large',
|
||||
'small'
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: bike_status; Type: TYPE; Schema: public; Owner: -
|
||||
--
|
||||
|
|
@ -55,6 +65,7 @@ CREATE TABLE public.bikes (
|
|||
drivetrain text,
|
||||
battery text,
|
||||
status public.bike_status DEFAULT 'in_service'::public.bike_status NOT NULL,
|
||||
size public.bike_size NOT NULL,
|
||||
CONSTRAINT bikes_key_quantity_positive CHECK ((key_quantity >= 0))
|
||||
);
|
||||
|
||||
|
|
@ -490,4 +501,5 @@ INSERT INTO public.schema_migrations (version) VALUES
|
|||
('20260823153320'),
|
||||
('20260823170000'),
|
||||
('20260823210000'),
|
||||
('20260823230000');
|
||||
('20260823230000'),
|
||||
('20260824120000');
|
||||
|
|
|
|||
12
db/seed.sql
12
db/seed.sql
|
|
@ -31,12 +31,12 @@ FROM (VALUES
|
|||
JOIN public.units u ON u."name" = membership.unit_name
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO public.bikes (id, "name", key_number, key_quantity, drivetrain, battery, status) VALUES
|
||||
(1, '1000', 'K-1042', 2, 'Shimano Nexus 8', 'BAT-A12', 'in_service'),
|
||||
(2, '2000', 'K-1043', 2, 'Shimano Nexus 8', 'BAT-A13', 'in_service'),
|
||||
(3, '3000', 'K-1044', 1, 'Rohloff Speedhub', NULL, 'out_of_service'),
|
||||
(4, '4000', 'K-1042', 2, 'Shimano Nexus 8', 'BAT-A12', 'in_service'),
|
||||
(5, '5000', 'K-1043', 2, 'Shimano Nexus 8', 'BAT-A13', 'in_service')
|
||||
INSERT INTO public.bikes (id, "name", key_number, key_quantity, drivetrain, battery, "size", status) VALUES
|
||||
(1, '1000', 'K-1042', 2, 'Shimano Nexus 8', 'BAT-A12', 'large', 'in_service'),
|
||||
(2, '2000', 'K-1043', 2, 'Shimano Nexus 8', 'BAT-A13', 'large', 'in_service'),
|
||||
(3, '3000', 'K-1044', 1, 'Rohloff Speedhub', NULL, 'small', 'out_of_service'),
|
||||
(4, '4000', 'K-1042', 2, 'Shimano Nexus 8', 'BAT-A12', 'small', 'in_service'),
|
||||
(5, '5000', 'K-1043', 2, 'Shimano Nexus 8', 'BAT-A13', 'small', 'in_service')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Reservations across the current week, one per status, so the admin page and
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const forwardedProps = useForwardProps(delegatedProps)
|
|||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const forwardedProps = useForwardProps(delegatedProps)
|
|||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:truncate text-start',
|
||||
'border-input dark:bg-input/30 flex h-9 w-full items-center justify-between rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none data-[placeholder]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:truncate text-start',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
|
|
|
|||
9
frontend/src/lib/api.d.ts
vendored
9
frontend/src/lib/api.d.ts
vendored
|
|
@ -103,7 +103,7 @@ export interface paths {
|
|||
}
|
||||
content?: never
|
||||
}
|
||||
/** @description no content */
|
||||
/** @description Unauthenticated - a session is required */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
|
|
@ -529,8 +529,15 @@ export interface components {
|
|||
/** Format: int32 */
|
||||
key_quantity: number
|
||||
name: string
|
||||
size: components['schemas']['BikeSize']
|
||||
status: components['schemas']['BikeStatus']
|
||||
}
|
||||
/**
|
||||
* @description Frame size. The reservation form lets a requester ask for one kind or the
|
||||
* other, so it is part of the bike rather than being read off its name.
|
||||
* @enum {string}
|
||||
*/
|
||||
BikeSize: 'large' | 'small'
|
||||
/** @enum {string} */
|
||||
BikeStatus: 'in_service' | 'out_of_service'
|
||||
DevUser: {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ reservation:
|
|||
bikes-empty: No cargobike available for this period.
|
||||
bikes-error: Unable to load the cargobikes.
|
||||
bike-out-of-service: Out of service
|
||||
bike-size-large: Large cargo bikes
|
||||
bike-size-small: Small cargo bikes
|
||||
bike-size-empty: No bike of this size.
|
||||
telegram: Telegram username
|
||||
emails: Email addresses of the Linka Go accounts to authorize
|
||||
email-nth: 'Email address {n}'
|
||||
|
|
|
|||
|
|
@ -31,6 +31,9 @@ reservation:
|
|||
bikes-empty: Aucun cargobike disponible pour ce créneau.
|
||||
bikes-error: Impossible de charger les cargobikes.
|
||||
bike-out-of-service: Hors service
|
||||
bike-size-large: Grands cargos
|
||||
bike-size-small: Petits cargos
|
||||
bike-size-empty: Aucun vélo de cette taille.
|
||||
telegram: Username Telegram
|
||||
emails: Adresses mail du/des comptes Linka Go à autoriser
|
||||
email-nth: 'Adresse e-mail {n}'
|
||||
|
|
|
|||
|
|
@ -120,4 +120,22 @@
|
|||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
/* Tailwind v4 dropped the v3 preflight rule that gave buttons a pointer
|
||||
cursor, so anything actionable would otherwise show the arrow. The
|
||||
`[role=]` selectors cover the elements reka-ui builds out of divs:
|
||||
calendar days, select options, popover triggers. */
|
||||
button:not(:disabled),
|
||||
[role='button']:not([aria-disabled='true']),
|
||||
[role='option']:not([aria-disabled='true']),
|
||||
[role='menuitem']:not([aria-disabled='true']),
|
||||
[role='tab']:not([aria-disabled='true']),
|
||||
label[for],
|
||||
summary,
|
||||
a[href] {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
button:disabled,
|
||||
[aria-disabled='true'] {
|
||||
@apply cursor-not-allowed;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,18 @@ function isUnavailable(bike: Bike) {
|
|||
|
||||
const availableBikes = computed(() => (bikes.value ?? []).filter((bike) => !isUnavailable(bike)))
|
||||
|
||||
// The fleet is presented one column per frame size, since that is what the
|
||||
// requester actually chooses. The size lives on the bike, so adding a sixth one
|
||||
// only means giving it a size in the admin, never touching this file.
|
||||
const BIKE_SIZES = ['large', 'small'] as const
|
||||
|
||||
const bikeGroups = computed(() =>
|
||||
BIKE_SIZES.map((size) => ({
|
||||
size,
|
||||
bikes: (bikes.value ?? []).filter((bike) => bike.size === size),
|
||||
})),
|
||||
)
|
||||
|
||||
watch(availableBikes, (available) => {
|
||||
const ids = new Set(available.map((bike) => bike.id))
|
||||
form.bikes = form.bikes.filter((id) => ids.has(id))
|
||||
|
|
@ -204,10 +216,14 @@ function submit() {
|
|||
<p v-if="errors.reason" class="text-destructive text-xs">{{ errors.reason }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Start -->
|
||||
<!-- Start. The caption names the date/time pair rather than one of
|
||||
them: a `<label for>` on the date would make a click on the text
|
||||
open the calendar, since the browser forwards the activation. -->
|
||||
<div class="grid gap-2">
|
||||
<Label for="start-date">{{ $t('reservation.start') }}</Label>
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<span id="start-label" class="text-sm leading-none font-medium">
|
||||
{{ $t('reservation.start') }}
|
||||
</span>
|
||||
<div role="group" aria-labelledby="start-label" class="grid gap-2 sm:grid-cols-2">
|
||||
<DatePicker
|
||||
id="start-date"
|
||||
v-model="startDate"
|
||||
|
|
@ -222,8 +238,10 @@ function submit() {
|
|||
|
||||
<!-- End -->
|
||||
<div class="grid gap-2">
|
||||
<Label for="end-date">{{ $t('reservation.end') }}</Label>
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<span id="end-label" class="text-sm leading-none font-medium">
|
||||
{{ $t('reservation.end') }}
|
||||
</span>
|
||||
<div role="group" aria-labelledby="end-label" class="grid gap-2 sm:grid-cols-2">
|
||||
<DatePicker
|
||||
id="end-date"
|
||||
v-model="endDate"
|
||||
|
|
@ -264,9 +282,27 @@ function submit() {
|
|||
{{ $t('reservation.bikes-empty') }}
|
||||
</p>
|
||||
|
||||
<div v-else class="grid gap-2 sm:grid-cols-2">
|
||||
<div v-else class="grid gap-4 sm:grid-cols-2">
|
||||
<div
|
||||
v-for="group in bikeGroups"
|
||||
:key="group.size"
|
||||
role="group"
|
||||
:aria-labelledby="`bike-size-${group.size}`"
|
||||
class="grid content-start gap-2"
|
||||
>
|
||||
<span
|
||||
:id="`bike-size-${group.size}`"
|
||||
class="text-muted-foreground text-xs font-semibold tracking-wide uppercase"
|
||||
>
|
||||
{{ $t(`reservation.bike-size-${group.size}`) }}
|
||||
</span>
|
||||
|
||||
<p v-if="group.bikes.length === 0" class="text-muted-foreground text-sm">
|
||||
{{ $t('reservation.bike-size-empty') }}
|
||||
</p>
|
||||
|
||||
<button
|
||||
v-for="bike in bikes"
|
||||
v-for="bike in group.bikes"
|
||||
:key="bike.id"
|
||||
type="button"
|
||||
:disabled="isUnavailable(bike)"
|
||||
|
|
@ -288,6 +324,7 @@ function submit() {
|
|||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="errors.bikes" class="text-destructive text-xs">{{ errors.bikes }}</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -44,9 +44,7 @@ pub fn get_router(aac: AnonAppController) -> Router {
|
|||
let config = utils::config::get();
|
||||
|
||||
let session_layer = SessionManagerLayer::new(MemoryStore::default())
|
||||
// Over plain http in development the cookie cannot be `Secure`
|
||||
.with_secure(config.get_base_url().starts_with("https://"))
|
||||
// The provider sends the browser back with a top level navigation
|
||||
.with_same_site(SameSite::Lax)
|
||||
.with_expiry(Expiry::OnInactivity(Duration::minutes(
|
||||
config.get_session_lifetime(),
|
||||
|
|
|
|||
|
|
@ -12,6 +12,15 @@ pub enum BikeStatus {
|
|||
OutOfService,
|
||||
}
|
||||
|
||||
/// Frame size. The reservation form lets a requester ask for one kind or the
|
||||
/// other, so it is part of the bike rather than being read off its name.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Copy, JsonSchema, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BikeSize {
|
||||
Large,
|
||||
Small,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
|
||||
pub struct Bike {
|
||||
pub id: BikeId,
|
||||
|
|
@ -20,6 +29,7 @@ pub struct Bike {
|
|||
pub key_quantity: i32,
|
||||
pub drivetrain: Option<String>,
|
||||
pub battery: Option<String>,
|
||||
pub size: BikeSize,
|
||||
pub status: BikeStatus,
|
||||
}
|
||||
|
||||
|
|
@ -30,5 +40,6 @@ pub struct NewBike {
|
|||
pub key_quantity: i32,
|
||||
pub drivetrain: Option<String>,
|
||||
pub battery: Option<String>,
|
||||
pub size: BikeSize,
|
||||
pub status: BikeStatus,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
//! The bike fleet. `bike_status` is a postgres enum: `BikeStatusDB` mirrors the
|
||||
//! core `BikeStatus` so that sqlx stays out of `core/models`.
|
||||
//! The bike fleet. `bike_status` and `bike_size` are postgres enums:
|
||||
//! `BikeStatusDB` and `BikeSizeDB` mirror the core types so that sqlx stays out
|
||||
//! of `core/models`.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sqlx::{query, query_as};
|
||||
|
||||
use crate::{
|
||||
core::{
|
||||
models::bike::{Bike, BikeId, BikeStatus, NewBike},
|
||||
models::bike::{Bike, BikeId, BikeSize, BikeStatus, NewBike},
|
||||
repositories::{RepositoryError, bikes_repository::BikesRepository},
|
||||
},
|
||||
services::database::SqlxDatabase,
|
||||
|
|
@ -37,6 +38,31 @@ impl From<BikeStatus> for BikeStatusDB {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, sqlx::Type)]
|
||||
#[sqlx(type_name = "bike_size", rename_all = "snake_case")]
|
||||
enum BikeSizeDB {
|
||||
Large,
|
||||
Small,
|
||||
}
|
||||
|
||||
impl From<BikeSizeDB> for BikeSize {
|
||||
fn from(value: BikeSizeDB) -> Self {
|
||||
match value {
|
||||
BikeSizeDB::Large => BikeSize::Large,
|
||||
BikeSizeDB::Small => BikeSize::Small,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BikeSize> for BikeSizeDB {
|
||||
fn from(value: BikeSize) -> Self {
|
||||
match value {
|
||||
BikeSize::Large => BikeSizeDB::Large,
|
||||
BikeSize::Small => BikeSizeDB::Small,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct BikeDB {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
|
|
@ -44,6 +70,7 @@ struct BikeDB {
|
|||
pub key_quantity: i32,
|
||||
pub drivetrain: Option<String>,
|
||||
pub battery: Option<String>,
|
||||
pub size: BikeSizeDB,
|
||||
pub status: BikeStatusDB,
|
||||
}
|
||||
|
||||
|
|
@ -56,6 +83,7 @@ impl From<BikeDB> for Bike {
|
|||
key_quantity: value.key_quantity,
|
||||
drivetrain: value.drivetrain,
|
||||
battery: value.battery,
|
||||
size: value.size.into(),
|
||||
status: value.status.into(),
|
||||
}
|
||||
}
|
||||
|
|
@ -67,7 +95,7 @@ impl BikesRepository for SqlxDatabase {
|
|||
Ok(query_as!(
|
||||
BikeDB,
|
||||
r#"SELECT id, "name", key_number, key_quantity, drivetrain, battery,
|
||||
status AS "status: BikeStatusDB"
|
||||
"size" AS "size: BikeSizeDB", status AS "status: BikeStatusDB"
|
||||
FROM bikes
|
||||
ORDER BY "name""#
|
||||
)
|
||||
|
|
@ -82,7 +110,7 @@ impl BikesRepository for SqlxDatabase {
|
|||
Ok(query_as!(
|
||||
BikeDB,
|
||||
r#"SELECT id, "name", key_number, key_quantity, drivetrain, battery,
|
||||
status AS "status: BikeStatusDB"
|
||||
"size" AS "size: BikeSizeDB", status AS "status: BikeStatusDB"
|
||||
FROM bikes
|
||||
WHERE id = $1"#,
|
||||
id
|
||||
|
|
@ -94,17 +122,19 @@ impl BikesRepository for SqlxDatabase {
|
|||
|
||||
async fn create_bike(&self, bike: NewBike) -> Result<Bike, RepositoryError> {
|
||||
let status: BikeStatusDB = bike.status.into();
|
||||
let size: BikeSizeDB = bike.size.into();
|
||||
Ok(query_as!(
|
||||
BikeDB,
|
||||
r#"INSERT INTO bikes ("name", key_number, key_quantity, drivetrain, battery, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
r#"INSERT INTO bikes ("name", key_number, key_quantity, drivetrain, battery, "size", status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, "name", key_number, key_quantity, drivetrain, battery,
|
||||
status AS "status: BikeStatusDB""#,
|
||||
"size" AS "size: BikeSizeDB", status AS "status: BikeStatusDB""#,
|
||||
bike.name,
|
||||
bike.key_number,
|
||||
bike.key_quantity,
|
||||
bike.drivetrain,
|
||||
bike.battery,
|
||||
size as BikeSizeDB,
|
||||
status as BikeStatusDB
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
|
|
@ -114,10 +144,11 @@ impl BikesRepository for SqlxDatabase {
|
|||
|
||||
async fn update_bike(&self, bike: Bike) -> Result<(), RepositoryError> {
|
||||
let status: BikeStatusDB = bike.status.into();
|
||||
let size: BikeSizeDB = bike.size.into();
|
||||
let result = query!(
|
||||
r#"UPDATE bikes
|
||||
SET "name" = $2, key_number = $3, key_quantity = $4, drivetrain = $5,
|
||||
battery = $6, status = $7
|
||||
battery = $6, "size" = $7, status = $8
|
||||
WHERE id = $1"#,
|
||||
bike.id,
|
||||
bike.name,
|
||||
|
|
@ -125,6 +156,7 @@ impl BikesRepository for SqlxDatabase {
|
|||
bike.key_quantity,
|
||||
bike.drivetrain,
|
||||
bike.battery,
|
||||
size as BikeSizeDB,
|
||||
status as BikeStatusDB
|
||||
)
|
||||
.execute(&self.pool)
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ pub struct OidcConfig {
|
|||
pub issuer_url: String,
|
||||
pub client_id: String,
|
||||
pub client_secret: String,
|
||||
/// How long a session stays valid, in minutes
|
||||
pub session_lifetime: Option<i64>,
|
||||
}
|
||||
|
||||
|
|
@ -38,7 +37,6 @@ pub struct OidcConfig {
|
|||
pub struct DevUserConfig {
|
||||
pub firstname: String,
|
||||
pub name: String,
|
||||
/// Also the handle used to pick the user at login
|
||||
pub email: String,
|
||||
pub external_id: Option<String>,
|
||||
#[serde(default)]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue