|
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">×</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 |
} |