mirror of
https://github.com/ae-utbm/sith.git
synced 2025-06-29 06:35:18 +00:00
Room reservation form
This commit is contained in:
parent
de7caea9a5
commit
5b29250b39
@ -1,7 +1,8 @@
|
|||||||
|
import { morph } from "@alpinejs/morph";
|
||||||
import sort from "@alpinejs/sort";
|
import sort from "@alpinejs/sort";
|
||||||
import Alpine from "alpinejs";
|
import Alpine from "alpinejs";
|
||||||
|
|
||||||
Alpine.plugin(sort);
|
Alpine.plugin([sort, morph]);
|
||||||
window.Alpine = Alpine;
|
window.Alpine = Alpine;
|
||||||
|
|
||||||
window.addEventListener("DOMContentLoaded", () => {
|
window.addEventListener("DOMContentLoaded", () => {
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import htmx from "htmx.org";
|
import htmx from "htmx.org";
|
||||||
|
import "htmx-ext-alpine-morph";
|
||||||
|
|
||||||
document.body.addEventListener("htmx:beforeRequest", (event) => {
|
document.body.addEventListener("htmx:beforeRequest", (event) => {
|
||||||
event.target.ariaBusy = true;
|
event.target.ariaBusy = true;
|
||||||
|
@ -109,7 +109,7 @@ class FragmentMixin(TemplateResponseMixin, ContextMixin):
|
|||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"app/template.jinja",
|
"app/template.jinja",
|
||||||
context={"fragment": fragment(request)
|
context={"fragment": fragment(request)}
|
||||||
}
|
}
|
||||||
|
|
||||||
# in urls.py
|
# in urls.py
|
||||||
|
16
package-lock.json
generated
16
package-lock.json
generated
@ -9,6 +9,7 @@
|
|||||||
"version": "3",
|
"version": "3",
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@alpinejs/morph": "^3.14.9",
|
||||||
"@alpinejs/sort": "^3.14.7",
|
"@alpinejs/sort": "^3.14.7",
|
||||||
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
|
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
@ -33,6 +34,7 @@
|
|||||||
"d3-force-3d": "^3.0.5",
|
"d3-force-3d": "^3.0.5",
|
||||||
"easymde": "^2.19.0",
|
"easymde": "^2.19.0",
|
||||||
"glob": "^11.0.0",
|
"glob": "^11.0.0",
|
||||||
|
"htmx-ext-alpine-morph": "^2.0.1",
|
||||||
"htmx.org": "^2.0.3",
|
"htmx.org": "^2.0.3",
|
||||||
"jquery": "^3.7.1",
|
"jquery": "^3.7.1",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
@ -58,6 +60,12 @@
|
|||||||
"vite-plugin-static-copy": "^3.0.2"
|
"vite-plugin-static-copy": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@alpinejs/morph": {
|
||||||
|
"version": "3.14.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@alpinejs/morph/-/morph-3.14.9.tgz",
|
||||||
|
"integrity": "sha512-i1mrH5Gza/egszxnCVwQWypRhsKGq28RFWHWuW7aI0+rWo1pvFw+aPhJLImbpt7hx44DtDOr5m4l9Ah+JPFmFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@alpinejs/sort": {
|
"node_modules/@alpinejs/sort": {
|
||||||
"version": "3.14.9",
|
"version": "3.14.9",
|
||||||
"resolved": "https://registry.npmjs.org/@alpinejs/sort/-/sort-3.14.9.tgz",
|
"resolved": "https://registry.npmjs.org/@alpinejs/sort/-/sort-3.14.9.tgz",
|
||||||
@ -4233,6 +4241,14 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/htmx-ext-alpine-morph": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/htmx-ext-alpine-morph/-/htmx-ext-alpine-morph-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-teGpcVatx5IjDUYQs959x9FcePM1TIksjfW5tSe1KVQVEVSmbGxEoemneC7XV6RYpX+27i/xn1fPjduwvHDrAw==",
|
||||||
|
"dependencies": {
|
||||||
|
"htmx.org": "^2.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/htmx.org": {
|
"node_modules/htmx.org": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.4.tgz",
|
||||||
|
@ -40,6 +40,7 @@
|
|||||||
"vite-plugin-static-copy": "^3.0.2"
|
"vite-plugin-static-copy": "^3.0.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@alpinejs/morph": "^3.14.9",
|
||||||
"@alpinejs/sort": "^3.14.7",
|
"@alpinejs/sort": "^3.14.7",
|
||||||
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
|
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
@ -64,6 +65,7 @@
|
|||||||
"d3-force-3d": "^3.0.5",
|
"d3-force-3d": "^3.0.5",
|
||||||
"easymde": "^2.19.0",
|
"easymde": "^2.19.0",
|
||||||
"glob": "^11.0.0",
|
"glob": "^11.0.0",
|
||||||
|
"htmx-ext-alpine-morph": "^2.0.1",
|
||||||
"htmx.org": "^2.0.3",
|
"htmx.org": "^2.0.3",
|
||||||
"jquery": "^3.7.1",
|
"jquery": "^3.7.1",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
|
@ -2,7 +2,8 @@ from django import forms
|
|||||||
|
|
||||||
from club.widgets.ajax_select import AutoCompleteSelectClub
|
from club.widgets.ajax_select import AutoCompleteSelectClub
|
||||||
from core.models import User
|
from core.models import User
|
||||||
from reservation.models import Room
|
from core.views.forms import FutureDateTimeField, SelectDateTime
|
||||||
|
from reservation.models import ReservationSlot, Room
|
||||||
|
|
||||||
|
|
||||||
class RoomCreateForm(forms.ModelForm):
|
class RoomCreateForm(forms.ModelForm):
|
||||||
@ -31,3 +32,22 @@ class RoomUpdateForm(forms.ModelForm):
|
|||||||
# (i.e. it's a club board member, but not a sith admin)
|
# (i.e. it's a club board member, but not a sith admin)
|
||||||
# some fields aren't editable
|
# some fields aren't editable
|
||||||
del self.fields["club"]
|
del self.fields["club"]
|
||||||
|
|
||||||
|
|
||||||
|
class ReservationForm(forms.ModelForm):
|
||||||
|
required_css_class = "required"
|
||||||
|
error_css_class = "error"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ReservationSlot
|
||||||
|
fields = ["room", "start_at", "end_at", "comment"]
|
||||||
|
field_classes = {"start_at": FutureDateTimeField, "end_at": FutureDateTimeField}
|
||||||
|
widgets = {"start_at": SelectDateTime(), "end_at": SelectDateTime()}
|
||||||
|
|
||||||
|
def __init__(self, *args, author: User, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.author = author
|
||||||
|
|
||||||
|
def save(self, commit: bool = True): # noqa FBT001
|
||||||
|
self.instance.author = self.author
|
||||||
|
return super().save(commit)
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
|
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
|
||||||
import {
|
import {
|
||||||
Calendar,
|
Calendar,
|
||||||
|
type DateSelectArg,
|
||||||
type EventDropArg,
|
type EventDropArg,
|
||||||
type EventSourceFuncArg,
|
type EventSourceFuncArg,
|
||||||
} from "@fullcalendar/core";
|
} from "@fullcalendar/core";
|
||||||
@ -18,6 +19,7 @@ import {
|
|||||||
import { paginated } from "#core:utils/api";
|
import { paginated } from "#core:utils/api";
|
||||||
import interactionPlugin from "@fullcalendar/interaction";
|
import interactionPlugin from "@fullcalendar/interaction";
|
||||||
import resourceTimelinePlugin from "@fullcalendar/resource-timeline";
|
import resourceTimelinePlugin from "@fullcalendar/resource-timeline";
|
||||||
|
import type { SlotSelectedEventArg } from "#reservation:reservation/types";
|
||||||
|
|
||||||
@registerComponent("room-scheduler")
|
@registerComponent("room-scheduler")
|
||||||
export class RoomScheduler extends inheritHtmlElement("div") {
|
export class RoomScheduler extends inheritHtmlElement("div") {
|
||||||
@ -26,6 +28,7 @@ export class RoomScheduler extends inheritHtmlElement("div") {
|
|||||||
private locale = "en";
|
private locale = "en";
|
||||||
private canEditSlot = false;
|
private canEditSlot = false;
|
||||||
private canBookSlot = false;
|
private canBookSlot = false;
|
||||||
|
private canDeleteSlot = false;
|
||||||
|
|
||||||
attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) {
|
attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) {
|
||||||
if (name === "locale") {
|
if (name === "locale") {
|
||||||
@ -37,6 +40,9 @@ export class RoomScheduler extends inheritHtmlElement("div") {
|
|||||||
if (name === "can_create_slot") {
|
if (name === "can_create_slot") {
|
||||||
this.canBookSlot = newValue.toLowerCase() === "true";
|
this.canBookSlot = newValue.toLowerCase() === "true";
|
||||||
}
|
}
|
||||||
|
if (name === "can_delete_slot") {
|
||||||
|
this.canDeleteSlot = newValue.toLowerCase() === "true";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -84,6 +90,18 @@ export class RoomScheduler extends inheritHtmlElement("div") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
selectFreeSlot(infos: DateSelectArg) {
|
||||||
|
document.dispatchEvent(
|
||||||
|
new CustomEvent<SlotSelectedEventArg>("timeSlotSelected", {
|
||||||
|
detail: {
|
||||||
|
ressource: Number.parseInt(infos.resource.id),
|
||||||
|
start: infos.startStr,
|
||||||
|
end: infos.endStr,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
this.scheduler = new Calendar(this.node, {
|
this.scheduler = new Calendar(this.node, {
|
||||||
@ -109,6 +127,7 @@ export class RoomScheduler extends inheritHtmlElement("div") {
|
|||||||
resourceAreaWidth: "20%",
|
resourceAreaWidth: "20%",
|
||||||
resources: this.fetchResources,
|
resources: this.fetchResources,
|
||||||
events: this.fetchEvents,
|
events: this.fetchEvents,
|
||||||
|
select: this.selectFreeSlot,
|
||||||
selectOverlap: false,
|
selectOverlap: false,
|
||||||
selectable: this.canBookSlot,
|
selectable: this.canBookSlot,
|
||||||
selectConstraint: { start: new Date() },
|
selectConstraint: { start: new Date() },
|
||||||
|
@ -0,0 +1,23 @@
|
|||||||
|
import type { SlotSelectedEventArg } from "#reservation:reservation/types";
|
||||||
|
|
||||||
|
document.addEventListener("alpine:init", () => {
|
||||||
|
Alpine.data("slotReservation", () => ({
|
||||||
|
start: null as string,
|
||||||
|
end: null as string,
|
||||||
|
room: null as number,
|
||||||
|
showForm: false,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
document.addEventListener(
|
||||||
|
"timeSlotSelected",
|
||||||
|
(event: CustomEvent<SlotSelectedEventArg>) => {
|
||||||
|
this.start = event.detail.start.split("+")[0];
|
||||||
|
this.end = event.detail.end.split("+")[0];
|
||||||
|
this.room = event.detail.ressource;
|
||||||
|
this.showForm = true;
|
||||||
|
this.$nextTick(() => this.$el.scrollIntoView({ behavior: "smooth" })).then();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
5
reservation/static/bundled/reservation/types.d.ts
vendored
Normal file
5
reservation/static/bundled/reservation/types.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export interface SlotSelectedEventArg {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
ressource: number;
|
||||||
|
}
|
39
reservation/static/reservation/reservation.scss
Normal file
39
reservation/static/reservation/reservation.scss
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
#slot-reservation {
|
||||||
|
margin-top: 3em;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
display: block;
|
||||||
|
margin: auto;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert, .error {
|
||||||
|
display: block;
|
||||||
|
margin: 1em auto auto;
|
||||||
|
max-width: 400px;
|
||||||
|
word-wrap: break-word;
|
||||||
|
text-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .5em;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.buttons-row {
|
||||||
|
input[type="submit"], button {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
textarea {
|
||||||
|
max-width: unset;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,51 @@
|
|||||||
|
<section
|
||||||
|
id="slot-reservation"
|
||||||
|
x-data="slotReservation"
|
||||||
|
x-show="showForm"
|
||||||
|
hx-target="this"
|
||||||
|
hx-ext="alpine-morph"
|
||||||
|
hx-swap="morph"
|
||||||
|
>
|
||||||
|
<h3>{% trans %}Book a room{% endtrans %}</h3>
|
||||||
|
{% set non_field_errors = form.non_field_errors() %}
|
||||||
|
{% if non_field_errors %}
|
||||||
|
<div class="alert alert-red">
|
||||||
|
{% for error in non_field_errors %}
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<form
|
||||||
|
id="slot-reservation-form"
|
||||||
|
hx-post="{{ url("reservation:make_reservation") }}"
|
||||||
|
hx-disabled-elt="find input[type='submit']"
|
||||||
|
>
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.room.errors }}
|
||||||
|
{{ form.room.label_tag() }}
|
||||||
|
{{ form.room|add_attr("x-model=room") }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.start_at.errors }}
|
||||||
|
{{ form.start_at.label_tag() }}
|
||||||
|
{{ form.start_at|add_attr("x-model=start") }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.end_at.errors }}
|
||||||
|
{{ form.end_at.label_tag() }}
|
||||||
|
{{ form.end_at|add_attr("x-model=end") }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.comment.errors }}
|
||||||
|
{{ form.comment.label_tag() }}
|
||||||
|
{{ form.comment }}
|
||||||
|
</div>
|
||||||
|
<div class="row gap buttons-row">
|
||||||
|
<button class="btn btn-grey grow" @click.prevent="showForm = false">
|
||||||
|
{% trans %}Cancel{% endtrans %}
|
||||||
|
</button>
|
||||||
|
<input class="btn btn-blue grow" type="submit">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
@ -18,4 +18,8 @@
|
|||||||
can_edit_slot="{{ user.has_perm("reservation.change_reservationslot") }}"
|
can_edit_slot="{{ user.has_perm("reservation.change_reservationslot") }}"
|
||||||
can_create_slot="{{ user.has_perm("reservation.add_reservationslot") }}"
|
can_create_slot="{{ user.has_perm("reservation.add_reservationslot") }}"
|
||||||
></room-scheduler>
|
></room-scheduler>
|
||||||
|
{% if user.has_perm("reservation.add_reservationslot") %}
|
||||||
|
<p><em>{% trans %}You can book a room by selecting a free slot in the calendar.{% endtrans %}</em></p>
|
||||||
|
{{ add_slot_fragment }}
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
@ -1,6 +1,7 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from reservation.views import (
|
from reservation.views import (
|
||||||
|
ReservationFragment,
|
||||||
ReservationScheduleView,
|
ReservationScheduleView,
|
||||||
RoomCreateView,
|
RoomCreateView,
|
||||||
RoomDeleteView,
|
RoomDeleteView,
|
||||||
@ -12,4 +13,7 @@ urlpatterns = [
|
|||||||
path("room/create/", RoomCreateView.as_view(), name="room_create"),
|
path("room/create/", RoomCreateView.as_view(), name="room_create"),
|
||||||
path("room/<int:room_id>/edit", RoomUpdateView.as_view(), name="room_edit"),
|
path("room/<int:room_id>/edit", RoomUpdateView.as_view(), name="room_edit"),
|
||||||
path("room/<int:room_id>/delete", RoomDeleteView.as_view(), name="room_delete"),
|
path("room/<int:room_id>/delete", RoomDeleteView.as_view(), name="room_delete"),
|
||||||
|
path(
|
||||||
|
"fragment/reservation", ReservationFragment.as_view(), name="make_reservation"
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
@ -8,13 +8,37 @@ from django.views.generic import CreateView, DeleteView, TemplateView, UpdateVie
|
|||||||
|
|
||||||
from club.models import Club
|
from club.models import Club
|
||||||
from core.auth.mixins import CanEditMixin
|
from core.auth.mixins import CanEditMixin
|
||||||
from reservation.forms import RoomCreateForm, RoomUpdateForm
|
from core.views import UseFragmentsMixin
|
||||||
from reservation.models import Room
|
from core.views.mixins import FragmentMixin
|
||||||
|
from reservation.forms import ReservationForm, RoomCreateForm, RoomUpdateForm
|
||||||
|
from reservation.models import ReservationSlot, Room
|
||||||
|
|
||||||
|
|
||||||
class ReservationScheduleView(PermissionRequiredMixin, TemplateView):
|
class ReservationFragment(PermissionRequiredMixin, FragmentMixin, CreateView):
|
||||||
|
model = ReservationSlot
|
||||||
|
form_class = ReservationForm
|
||||||
|
permission_required = "reservation.add_reservationslot"
|
||||||
|
template_name = "reservation/fragments/create_reservation.jinja"
|
||||||
|
success_url = reverse_lazy("reservation:main")
|
||||||
|
reload_on_redirect = True
|
||||||
|
object = None
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
return super().get_form_kwargs() | {"author": self.request.user}
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
# if method is POST, then it means the form just failed
|
||||||
|
# to validate, so it must be shown, with error messages.
|
||||||
|
# Else, it can be initially hidden.
|
||||||
|
return super().get_context_data(**kwargs) | {
|
||||||
|
"displayForm": self.request.method == "POST"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ReservationScheduleView(PermissionRequiredMixin, UseFragmentsMixin, TemplateView):
|
||||||
template_name = "reservation/schedule.jinja"
|
template_name = "reservation/schedule.jinja"
|
||||||
permission_required = "reservation.view_room"
|
permission_required = "reservation.view_room"
|
||||||
|
fragments = {"add_slot_fragment": ReservationFragment}
|
||||||
|
|
||||||
|
|
||||||
class RoomCreateView(SuccessMessageMixin, PermissionRequiredMixin, CreateView):
|
class RoomCreateView(SuccessMessageMixin, PermissionRequiredMixin, CreateView):
|
||||||
|
Loading…
x
Reference in New Issue
Block a user