View | Details | Raw Unified | Return to bug 41129
Collapse All | Expand All

(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingModal.vue (-146 / +107 lines)
Lines 1-29 Link Here
1
<template>
1
<template>
2
    <div
2
    <div
3
        v-if="modalState.isOpen"
3
        ref="modalElement"
4
        class="modal show booking-modal-backdrop"
4
        class="modal fade"
5
        tabindex="-1"
5
        tabindex="-1"
6
        role="dialog"
6
        role="dialog"
7
    >
7
    >
8
        <div
8
        <div
9
            class="modal-dialog booking-modal-window booking-modal"
9
            class="modal-dialog modal-lg"
10
            role="document"
10
            role="document"
11
        >
11
        >
12
            <div class="modal-content">
12
            <div class="modal-content">
13
                <div class="booking-modal-header">
13
                <div class="modal-header">
14
                    <h5 class="booking-modal-title">
14
                    <h1 class="modal-title fs-5">
15
                        {{ modalTitle }}
15
                        {{ modalTitle }}
16
                    </h5>
16
                    </h1>
17
                    <button
17
                    <button
18
                        type="button"
18
                        type="button"
19
                        class="booking-modal-close"
19
                        class="btn-close"
20
                        aria-label="Close"
20
                        aria-label="Close"
21
                        @click="handleClose"
21
                        @click="handleClose"
22
                    >
22
                    ></button>
23
                        <span aria-hidden="true">&times;</span>
24
                    </button>
25
                </div>
23
                </div>
26
                <div class="modal-body booking-modal-body">
24
                <div class="modal-body">
27
                    <form
25
                    <form
28
                        id="form-booking"
26
                        id="form-booking"
29
                        :action="submitUrl"
27
                        :action="submitUrl"
Lines 135-142 import { Link Here
135
    computed,
133
    computed,
136
    reactive,
134
    reactive,
137
    watch,
135
    watch,
136
    ref,
137
    onMounted,
138
    onUnmounted,
138
    onUnmounted,
139
} from "vue";
139
} from "vue";
140
import { Modal } from "bootstrap";
140
import BookingPatronStep from "./BookingPatronStep.vue";
141
import BookingPatronStep from "./BookingPatronStep.vue";
141
import BookingDetailsStep from "./BookingDetailsStep.vue";
142
import BookingDetailsStep from "./BookingDetailsStep.vue";
142
import BookingPeriodStep from "./BookingPeriodStep.vue";
143
import BookingPeriodStep from "./BookingPeriodStep.vue";
Lines 150-156 import { Link Here
150
import { useBookingStore } from "../../stores/bookings";
151
import { useBookingStore } from "../../stores/bookings";
151
import { storeToRefs } from "pinia";
152
import { storeToRefs } from "pinia";
152
import { updateExternalDependents } from "./lib/adapters/external-dependents.mjs";
153
import { updateExternalDependents } from "./lib/adapters/external-dependents.mjs";
153
import { enableBodyScroll, disableBodyScroll } from "./lib/adapters/modal-scroll.mjs";
154
import { appendHiddenInputs } from "./lib/adapters/form.mjs";
154
import { appendHiddenInputs } from "./lib/adapters/form.mjs";
155
import { calculateStepNumbers } from "./lib/ui/steps.mjs";
155
import { calculateStepNumbers } from "./lib/ui/steps.mjs";
156
import { useBookingValidation } from "./composables/useBookingValidation.mjs";
156
import { useBookingValidation } from "./composables/useBookingValidation.mjs";
Lines 217-222 export default { Link Here
217
    setup(props, { emit }) {
217
    setup(props, { emit }) {
218
        const store = useBookingStore();
218
        const store = useBookingStore();
219
219
220
        const modalElement = ref(null);
221
        let bsModal = null;
222
220
        // Properly destructure reactive store state using storeToRefs
223
        // Properly destructure reactive store state using storeToRefs
221
        const {
224
        const {
222
            bookingId,
225
            bookingId,
Lines 415-497 export default { Link Here
415
            canSubmit: isSubmitReady.value,
418
            canSubmit: isSubmitReady.value,
416
        }));
419
        }));
417
420
421
        onMounted(() => {
422
            if (modalElement.value) {
423
                bsModal = new Modal(modalElement.value, {
424
                    backdrop: 'static',
425
                    keyboard: false
426
                });
427
428
                // Note: We don't emit "close" here because the close flow is:
429
                // 1. Component emits "close" → 2. Parent sets props.open = false
430
                // 3. Watcher calls bsModal.hide() → 4. This event fires for cleanup
431
                modalElement.value.addEventListener('hidden.bs.modal', () => {
432
                    resetModalState();
433
                });
434
            }
435
        });
436
437
        onUnmounted(() => {
438
            if (bsModal) {
439
                bsModal.dispose();
440
            }
441
        });
442
418
        // Watchers: synchronize open state, orchestrate initial data load,
443
        // Watchers: synchronize open state, orchestrate initial data load,
419
        // push availability to store, fetch rules/pickup locations, and keep
444
        // push availability to store, fetch rules/pickup locations, and keep
420
        // UX guidance/errors fresh while inputs change.
445
        // UX guidance/errors fresh while inputs change.
421
446
422
        // Sync internal modal state with the `open` prop and reset inputs when
447
        // Controls Bootstrap modal visibility and orchestrates data loading on open.
423
        // opening to ensure a clean form each time.
448
        // Handles async fetch ordering and proper blur/hide sequence on close.
424
        watch(
449
        watch(
425
            () => props.open,
450
            () => props.open,
426
            val => {
451
            async (open) => {
427
                modalState.isOpen = val;
452
                if (!bsModal || !modalElement.value) return;
428
                if (val) {
429
                    resetModalState();
430
                }
431
            }
432
        );
433
453
434
        // Orchestrates initial data loading on modal open. This needs to
435
        // remain isolated because it handles async fetch ordering, DOM body
436
        // scroll state and localization preload.
437
        watch(
438
            () => modalState.isOpen,
439
            async open => {
440
                if (open) {
454
                if (open) {
441
                    disableBodyScroll();
455
                    bsModal.show();
442
                } else {
456
                    resetModalState();
443
                    enableBodyScroll();
444
                    return;
445
                }
446
447
                modalState.step = 1;
448
                const biblionumber = props.biblionumber;
449
                if (!biblionumber) return;
450
451
                bookingId.value = props.bookingId;
452
453
                try {
454
                    // Fetch core data first
455
                    await Promise.all([
456
                        store.fetchBookableItems(biblionumber),
457
                        store.fetchBookings(biblionumber),
458
                        store.fetchCheckouts(biblionumber),
459
                    ]);
460
461
                    modalState.hasAdditionalFields = false;
462
463
                    // Derive item types after bookable items are loaded
464
                    store.deriveItemTypesFromBookableItems();
465
466
                    // If editing with patron, fetch patron-specific data
467
                    if (props.patronId) {
468
                        const patron = await store.fetchPatron(props.patronId);
469
                        await store.fetchPickupLocations(
470
                            biblionumber,
471
                            props.patronId
472
                        );
473
474
                        // Now set patron after data is available
475
                        bookingPatron.value = patron;
476
                    }
477
478
                    // Set other form values after all dependencies are loaded
479
457
480
                    // Normalize itemId type to match bookableItems' item_id type for vue-select strict matching
458
                    modalState.step = 1;
481
                    bookingItemId.value = (props.itemId != null) ? normalizeIdType(bookableItems.value?.[0]?.item_id, props.itemId) : null;
459
                    const biblionumber = props.biblionumber;
482
                    if (props.itemtypeId) {
460
                    if (!biblionumber) return;
483
                        bookingItemtypeId.value = props.itemtypeId;
461
462
                    bookingId.value = props.bookingId;
463
464
                    try {
465
                        // Fetch core data first
466
                        await Promise.all([
467
                            store.fetchBookableItems(biblionumber),
468
                            store.fetchBookings(biblionumber),
469
                            store.fetchCheckouts(biblionumber),
470
                        ]);
471
472
                        modalState.hasAdditionalFields = false;
473
474
                        // Derive item types after bookable items are loaded
475
                        store.deriveItemTypesFromBookableItems();
476
477
                        // If editing with patron, fetch patron-specific data
478
                        if (props.patronId) {
479
                            const patron = await store.fetchPatron(props.patronId);
480
                            await store.fetchPickupLocations(
481
                                biblionumber,
482
                                props.patronId
483
                            );
484
485
                            // Now set patron after data is available
486
                            bookingPatron.value = patron;
487
                        }
488
489
                        // Set other form values after all dependencies are loaded
490
491
                        // Normalize itemId type to match bookableItems' item_id type for vue-select strict matching
492
                        bookingItemId.value = (props.itemId != null) ? normalizeIdType(bookableItems.value?.[0]?.item_id, props.itemId) : null;
493
                        if (props.itemtypeId) {
494
                            bookingItemtypeId.value = props.itemtypeId;
495
                        }
496
497
                        if (props.startDate && props.endDate) {
498
                            selectedDateRange.value = [
499
                                toISO(props.startDate),
500
                                toISO(props.endDate),
501
                            ];
502
                        }
503
                    } catch (error) {
504
                        console.error("Error initializing booking modal:", error);
505
                        setError(processApiError(error), "api");
484
                    }
506
                    }
485
507
                } else {
486
                    if (props.startDate && props.endDate) {
508
                    // Only hide if modal is actually shown (prevents double-hiding)
487
                        selectedDateRange.value = [
509
                    const isShown = modalElement.value.classList.contains('show');
488
                            toISO(props.startDate),
510
                    if (isShown) {
489
                            toISO(props.endDate),
511
                        // Blur active element to prevent aria-hidden warning
490
                        ];
512
                        if (document.activeElement instanceof HTMLElement) {
513
                            document.activeElement.blur();
514
                        }
515
                        bsModal.hide();
491
                    }
516
                    }
492
                } catch (error) {
493
                    console.error("Error initializing booking modal:", error);
494
                    setError(processApiError(error), "api");
495
                }
517
                }
496
            }
518
            }
497
        );
519
        );
Lines 610-619 export default { Link Here
610
        }
632
        }
611
633
612
        function handleClose() {
634
        function handleClose() {
613
            modalState.isOpen = false;
614
            enableBodyScroll();
615
            emit("close");
635
            emit("close");
616
            resetModalState();
617
        }
636
        }
618
637
619
        async function handleSubmit(event) {
638
        async function handleSubmit(event) {
Lines 662-679 export default { Link Here
662
                const result = await store.saveOrUpdateBooking(bookingData);
681
                const result = await store.saveOrUpdateBooking(bookingData);
663
                updateExternalDependents(result, bookingPatron.value, !!props.bookingId);
682
                updateExternalDependents(result, bookingPatron.value, !!props.bookingId);
664
                emit("close");
683
                emit("close");
665
                resetModalState();
666
            } catch (errorObj) {
684
            } catch (errorObj) {
667
                setError(processApiError(errorObj), "api");
685
                setError(processApiError(errorObj), "api");
668
            }
686
            }
669
        }
687
        }
670
688
671
        // Cleanup function for proper memory management
672
        onUnmounted(() => {
673
            enableBodyScroll();
674
        });
675
676
        return {
689
        return {
690
            modalElement,
677
            modalState,
691
            modalState,
678
            modalTitle,
692
            modalTitle,
679
            submitLabel,
693
            submitLabel,
Lines 722-739 export default { Link Here
722
</script>
736
</script>
723
737
724
<style>
738
<style>
725
.booking-modal-backdrop {
726
    position: fixed;
727
    top: 0;
728
    left: 0;
729
    width: 100%;
730
    height: 100%;
731
    background: rgba(0, 0, 0, 0.5);
732
    z-index: 1050;
733
    display: block;
734
    overflow-y: auto;
735
}
736
737
/* Global variables for external libraries (flatpickr) and cross-block usage */
739
/* Global variables for external libraries (flatpickr) and cross-block usage */
738
:root {
740
:root {
739
    /* Success colors for constraint highlighting */
741
    /* Success colors for constraint highlighting */
Lines 772-795 export default { Link Here
772
    --booking-border-radius-sm: 0.25rem;
774
    --booking-border-radius-sm: 0.25rem;
773
    --booking-border-radius-md: 0.5rem;
775
    --booking-border-radius-md: 0.5rem;
774
    --booking-border-radius-full: 50%;
776
    --booking-border-radius-full: 50%;
775
}
776
777
777
/* Design System: CSS Custom Properties (First Style Block Only) */
778
    /* Additional colors */
778
.booking-modal-backdrop {
779
    /* Colors not used in second style block */
780
    --booking-warning-bg-hover: hsl(var(--booking-warning-hue), 100%, 70%);
779
    --booking-warning-bg-hover: hsl(var(--booking-warning-hue), 100%, 70%);
781
    --booking-neutral-100: hsl(var(--booking-neutral-hue), 15%, 92%);
780
    --booking-neutral-100: hsl(var(--booking-neutral-hue), 15%, 92%);
782
    --booking-neutral-300: hsl(var(--booking-neutral-hue), 15%, 75%);
781
    --booking-neutral-300: hsl(var(--booking-neutral-hue), 15%, 75%);
783
    --booking-neutral-500: hsl(var(--booking-neutral-hue), 10%, 55%);
782
    --booking-neutral-500: hsl(var(--booking-neutral-hue), 10%, 55%);
784
783
785
    /* Spacing Scale (first block only) */
784
    /* Spacing Scale */
786
    --booking-space-sm: 0.25rem; /* 4px */
785
    --booking-space-sm: 0.25rem; /* 4px */
787
    --booking-space-md: 0.5rem; /* 8px */
786
    --booking-space-md: 0.5rem; /* 8px */
788
    --booking-space-lg: 1rem; /* 16px */
787
    --booking-space-lg: 1rem; /* 16px */
789
    --booking-space-xl: 1.5rem; /* 24px */
788
    --booking-space-xl: 1.5rem; /* 24px */
790
    --booking-space-2xl: 2rem; /* 32px */
789
    --booking-space-2xl: 2rem; /* 32px */
791
790
792
    /* Typography Scale (first block only) */
791
    /* Typography Scale */
793
    --booking-text-sm: 0.8125rem;
792
    --booking-text-sm: 0.8125rem;
794
    --booking-text-base: 1rem;
793
    --booking-text-base: 1rem;
795
    --booking-text-lg: 1.1rem;
794
    --booking-text-lg: 1.1rem;
Lines 841-847 export default { Link Here
841
}
840
}
842
841
843
/* Modal Layout Components */
842
/* Modal Layout Components */
844
.booking-modal-window {
843
.modal-dialog {
845
    max-height: var(--booking-modal-max-height);
844
    max-height: var(--booking-modal-max-height);
846
    margin: var(--booking-space-lg) auto;
845
    margin: var(--booking-space-lg) auto;
847
}
846
}
Lines 852-896 export default { Link Here
852
    flex-direction: column;
851
    flex-direction: column;
853
}
852
}
854
853
855
.booking-modal-header {
854
.modal-body {
856
    padding: var(--booking-space-lg);
857
    border-bottom: var(--booking-border-width) solid var(--booking-neutral-100);
858
    display: flex;
859
    align-items: center;
860
    justify-content: space-between;
861
    flex-shrink: 0;
862
}
863
864
.booking-modal-title {
865
    margin: 0;
866
    font-size: var(--booking-text-xl);
867
    font-weight: 600;
868
}
869
870
.booking-modal-close {
871
    background: transparent;
872
    border: none;
873
    font-size: var(--booking-text-2xl);
874
    line-height: 1;
875
    cursor: pointer;
876
    color: var(--booking-neutral-500);
877
    opacity: 0.5;
878
    transition: opacity var(--booking-transition-fast);
879
    padding: 0;
880
    margin: 0;
881
    width: var(--booking-space-2xl);
882
    height: var(--booking-space-2xl);
883
    display: flex;
884
    align-items: center;
885
    justify-content: center;
886
}
887
888
.booking-modal-close:hover {
889
    opacity: 0.75;
890
}
891
892
.booking-modal-body {
893
    padding: var(--booking-space-xl);
894
    overflow-y: auto;
855
    overflow-y: auto;
895
    flex: 1 1 auto;
856
    flex: 1 1 auto;
896
}
857
}
Lines 999-1005 hr { Link Here
999
}
960
}
1000
961
1001
/* External Library Integration */
962
/* External Library Integration */
1002
.booking-modal-body .vs__selected {
963
.modal-body .vs__selected {
1003
    font-size: var(--vs-font-size);
964
    font-size: var(--vs-font-size);
1004
    line-height: var(--vs-line-height);
965
    line-height: var(--vs-line-height);
1005
}
966
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/modal-scroll.mjs (-37 lines)
Lines 1-36 Link Here
1
/**
2
 * Modal scroll helpers shared across components.
3
 * Uses a window-scoped counter to manage body scroll lock for nested modals.
4
 */
5
6
/**
7
 * Enable body scroll when the last modal closes.
8
 */
9
export function enableBodyScroll() {
10
    const count = Number(window["kohaModalCount"] ?? 0);
11
    window["kohaModalCount"] = Math.max(0, count - 1);
12
13
    if ((window["kohaModalCount"] ?? 0) === 0) {
14
        document.body.classList.remove("modal-open");
15
        if (document.body.style.paddingRight) {
16
            document.body.style.paddingRight = "";
17
        }
18
    }
19
}
20
21
/**
22
 * Disable body scroll while a modal is open.
23
 */
24
export function disableBodyScroll() {
25
    const current = Number(window["kohaModalCount"] ?? 0);
26
    window["kohaModalCount"] = current + 1;
27
28
    if (!document.body.classList.contains("modal-open")) {
29
        const scrollbarWidth =
30
            window.innerWidth - document.documentElement.clientWidth;
31
        if (scrollbarWidth > 0) {
32
            document.body.style.paddingRight = `${scrollbarWidth}px`;
33
        }
34
        document.body.classList.add("modal-open");
35
    }
36
}
37
- 

Return to bug 41129