# vue2-booking-calendar

A customizable booking calendar component for Vue 2 with service, specialist, and customer management.

## Features

- Month, week, and day views
- Booking creation with bidirectional cascading dropdowns (brand ↔ branch ↔ service ↔ specialist ↔ customer)
- Select any field first — all other dropdowns filter automatically
- View booking details with full customer info (name, email, phone, address, notes)
- No-show and cancel booking with confirmation dialogs
- Specialist availability side panel with booked slot detection
- New customer inline creation from booking form
- Per-service duration with auto-computed end times
- Customizable business hours (standard, 24h, and overnight schedules)
- Event callbacks with custom function editors and live log
- Full JSON configuration for dropdown options and sample events
- Drag and drop events with 15-minute snap granularity
- SVG icons throughout
- Fully customizable via slots

## Installation

```bash
npm install vue2-booking-calendar
```

## Quick Start

```js
import Vue from 'vue'
import Vuex from 'vuex'
import BookingCalendar, { storeConfig } from 'vue2-booking-calendar'
import 'vue2-booking-calendar/dist/style.css'

Vue.use(Vuex)
Vue.use(BookingCalendar)

const store = new Vuex.Store(storeConfig)

new Vue({
  store,
  template: '<BookingCalendar />'
}).$mount('#app')
```

### Initializing with Custom Data

Pass your own brands, branches, services, specialists, and customers at setup:

```js
const store = new Vuex.Store({
  ...storeConfig,
  state() {
    const state = storeConfig.state()

    // Set your dropdown data
    state.config.dropdownOptions.brand = [
      { uuid: 'b1', name: 'My Salon' },
      { uuid: 'b2', name: 'My Spa' }
    ]

    state.config.dropdownOptions.branch = [
      { uuid: 'br1', brand_uuid: 'b1', name: 'Main Street' },
      { uuid: 'br2', brand_uuid: 'b1', name: 'Mall Location' },
      { uuid: 'br3', brand_uuid: 'b2', name: 'Downtown' }
    ]

    state.config.dropdownOptions.service = [
      { uuid: 'sv1', name: 'Haircut', duration: 60, brands: ['b1'], branches: ['br1', 'br2'], specialists: ['sp1', 'sp2'] },
      { uuid: 'sv2', name: 'Massage', duration: 90, brands: ['b2'], branches: ['br3'], specialists: ['sp3'] }
    ]

    state.config.dropdownOptions.specialist = [
      { uuid: 'sp1', name: 'Jane Smith', services: ['sv1'], brand_uuid: 'b1', branch_uuid: 'br1', availability: { monday: ['09:00','09:30','10:00'], tuesday: ['09:00','09:30'], wednesday: [], thursday: [], friday: [], saturday: [], sunday: [] } },
      { uuid: 'sp2', name: 'Bob Jones', services: ['sv1'], brand_uuid: 'b1', branch_uuid: 'br2', availability: { monday: ['10:00','10:30','11:00'], tuesday: [], wednesday: ['09:00','09:30'], thursday: [], friday: [], saturday: [], sunday: [] } },
      { uuid: 'sp3', name: 'Dr. Lee', services: ['sv2'], brand_uuid: 'b2', branch_uuid: 'br3', availability: { monday: ['14:00','14:30','15:00'], tuesday: ['14:00','14:30'], wednesday: [], thursday: [], friday: [], saturday: [], sunday: [] } }
    ]

    state.config.dropdownOptions.customer = [
      { uuid: 'c1', name: 'Alice Brown', email: 'alice@example.com', phone: '+1 555-0101', gender: 'Female', address: '123 Oak St', notes: '' },
      { uuid: 'c2', name: 'Charlie Davis', email: 'charlie@example.com', phone: '+1 555-0102', gender: 'Male', address: '', notes: 'VIP' }
    ]

    state.config.dropdownOptions.time = [
      '09:00', '09:30', '10:00', '10:30', '11:00', '11:30',
      '12:00', '12:30', '13:00', '13:30', '14:00', '14:30',
      '15:00', '15:30', '16:00', '16:30', '17:00'
    ]

    // Clear sample events (start empty)
    state.events = []

    return state
  }
})
```

### Loading Data from API

```js
// After store is created, fetch and set data dynamically
async function loadCalendarData() {
  const [brands, branches, services, specialists, customers] = await Promise.all([
    fetch('/api/brands').then(r => r.json()),
    fetch('/api/branches').then(r => r.json()),
    fetch('/api/services').then(r => r.json()),
    fetch('/api/specialists').then(r => r.json()),
    fetch('/api/customers').then(r => r.json())
  ])

  store.commit('SET_CONFIG', { key: 'dropdownOptions.brand', value: brands })
  store.commit('SET_CONFIG', { key: 'dropdownOptions.branch', value: branches })
  store.commit('SET_CONFIG', { key: 'dropdownOptions.service', value: services })
  store.commit('SET_CONFIG', { key: 'dropdownOptions.specialist', value: specialists })
  store.commit('SET_CONFIG', { key: 'dropdownOptions.customer', value: customers })

  // Load existing bookings
  const events = await fetch('/api/bookings').then(r => r.json())
  store.commit('SET_EVENTS', events)
}

loadCalendarData()
```

## Configuration

All configuration is managed through the Vuex store `config` object. You can override defaults before creating the store:

```js
const store = new Vuex.Store({
  ...storeConfig,
  state() {
    const state = storeConfig.state()
    // Override config
    state.config.businessHoursStart = 9
    state.config.businessHoursEnd = 17
    state.config.timeFormat = '24h'
    state.config.showLogo = false
    return state
  }
})
```

### Config Options

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `slotDuration` | `number` | `60` | Time slot duration in minutes (15, 30, 60) |
| `businessHoursStart` | `number` | `8` | Business hours start (0-23) |
| `businessHoursEnd` | `number` | `18` | Business hours end (0-23) |
| `is24HourOperation` | `boolean` | `false` | Enable 24-hour operation (disables business hours filtering) |
| `timeFormat` | `string` | `'12h'` | Time display format (`'12h'` or `'24h'`) |
| `firstDayOfWeek` | `number` | `0` | First day of week (0 = Sunday, 1 = Monday) |
| `showWeekends` | `boolean` | `true` | Show weekend columns |
| `maxVisibleEvents` | `number` | `3` | Max events visible per day in month view |
| `defaultEventDuration` | `number` | `60` | Fallback duration when service has no duration |
| `sidebarPosition` | `string` | `'left'` | Mini calendar sidebar position (`'left'`, `'right'`, `'none'`) |
| `showHeader` | `boolean` | `true` | Show calendar navigation header |
| `showLogo` | `boolean` | `true` | Show the logo header bar |
| `showCustomizationPanel` | `boolean` | `true` | Show the customization settings panel |
| `showManageButton` | `boolean` | `true` | Show the Manage button in the view booking footer |
| `showCompleteButton` | `boolean` | `true` | Show the Complete action in the Actions dropdown |
| `showCancelButton` | `boolean` | `true` | Show the Cancel Booking action in the Actions dropdown |
| `showViewEventModal` | `boolean` | `true` | Show the view booking dialog when clicking an event (callback still fires when disabled) |
| `maxVisibleEvents` | `number` | `3` | Max events visible per hour slot in week/day views (overflow shows "+N more" badge) |
| `allowPastDateBooking` | `boolean` | `false` | Allow creating bookings on past dates |
| `actionsPosition` | `string` | `'left'` | Actions dropdown position in footer (`'left'`, `'center'`, `'right'`) |
| `buttonRadius` | `string` | `'rounded'` | Button border-radius style (`'square'`, `'rounded'`, `'pill'`) |
| `showSelectCustomer` | `boolean` | `true` | Show existing customer dropdown in booking form |
| `showNewCustomer` | `boolean` | `true` | Show new customer inline form in booking form |
| `showCurrentTime` | `boolean` | `true` | Show current time indicator line |
| `highlightToday` | `boolean` | `true` | Highlight today's column |
| `eventBorderRadius` | `string` | `'rounded'` | Event card style (`'rounded'` or `'square'`) |
| `dayStartHour` | `number` | `0` | Hour to start the day view from |
### Booking Field Visibility

Control which fields appear in the booking form and view modal:

```js
bookingFields: {
  brand: true,       // Show in create/edit form
  branch: true,
  service: true,
  date: true,
  time: true,
  specialist: true,
  customer: true,
  color: true
}

viewFields: {
  brand: true,       // Show in view booking modal
  branch: true,
  service: true,
  date: true,
  time: true,
  specialist: true,
  customer: true
}
```

### Business Hours

**Standard schedule** (e.g. 8 AM – 6 PM):
```js
businessHoursStart: 8,
businessHoursEnd: 18,
is24HourOperation: false
```

**Overnight schedule** (e.g. 8 PM – 5 AM):
```js
businessHoursStart: 20,
businessHoursEnd: 5,
is24HourOperation: false
```

**24-hour operation**:
```js
is24HourOperation: true
```

## Dropdown Data Formats

### Brand

```json
{ "uuid": "b1a2c3d4-...", "name": "RUSH Salon" }
```

### Branch

```json
{
  "uuid": "br1-0001-...",
  "brand_uuid": "b1a2c3d4-...",
  "name": "Downtown"
}
```

### Service

```json
{
  "uuid": "sv-0001",
  "name": "Haircut",
  "duration": 60,
  "brands": ["b1a2c3d4-..."],
  "branches": ["br1-0001-...", "br1-0002-..."],
  "specialists": ["sp-0001", "sp-0002"]
}
```

- `duration` — service duration in minutes, used to auto-compute booking end time
- `brands` — list of brand UUIDs that offer this service
- `branches` — list of branch UUIDs where this service is available
- `specialists` — list of specialist UUIDs who can perform this service

### Specialist

```json
{
  "uuid": "sp-0001",
  "name": "Maria Santos",
  "services": ["sv-0001", "sv-0002"],
  "brand_uuid": "b1a2c3d4-...",
  "branch_uuid": "br1-0001-...",
  "availability": {
    "monday": ["09:00", "09:30", "10:00", "10:30", "11:00"],
    "tuesday": ["09:00", "09:30", "10:00"],
    "wednesday": [],
    "thursday": ["14:00", "14:30", "15:00"],
    "friday": ["09:00", "09:30", "10:00", "10:30"],
    "saturday": [],
    "sunday": []
  }
}
```

- `brand_uuid` — single brand the specialist belongs to
- `branch_uuid` — single branch the specialist is assigned to
- `availability` — time slots per day of week (used by the availability side panel)
- When a specialist + date are selected, the availability panel shows available/booked slots

### Customer

```json
{
  "uuid": "cu-0001",
  "name": "John Doe",
  "email": "john.doe@email.com",
  "phone": "+1 (555) 100-2001",
  "gender": "Male",
  "address": "123 Main St, New York, NY 10001",
  "notes": "Prefers short fade on sides"
}
```

All customer details are displayed in the view booking modal. New customers can be created inline from the booking form.

### Customer Search & Selection

The booking form provides a searchable customer field:

- **Search by name or mobile** — type in the search box to filter customers in real-time
- **Phone search** — strips non-numeric characters for flexible matching (e.g. "555" matches "+1 (555) 100-2001")
- **Customer details** — when a customer is selected, their full details (name, phone, email, address, notes) are displayed in a read-only form below
- **New customer creation** — toggle to inline form with first name, last name, mobile, email
- **Duplicate check** — "Check Record" button searches existing customers by phone/email/name before creating a new one. If matches found, user can select an existing customer instead

### Time Slots

```json
["09:00", "09:30", "10:00", "10:30", "11:00", "11:30", "12:00", ...]
```

Time slots outside business hours are automatically disabled in the booking form.

## Drag & Drop

Events can be dragged and dropped between time slots in week and day views:

- **15-minute snapping** — drop position within the hour cell determines the minute offset (top quarter = `:00`, second = `:15`, third = `:30`, bottom = `:45`)
- **Auto-collapse** — clicking any event or empty cell automatically collapses all expanded "+N more" sections
- **Duration preserved** — the event's original duration is maintained after moving

## Event Data Format

```json
{
  "id": "s1",
  "title": "Haircut",
  "brand": "b1a2c3d4-...",
  "branch": "br1-0001-...",
  "service": "sv-0001",
  "specialist": "sp-0001",
  "customer": "cu-0001",
  "start": "2025-01-10T09:00:00.000Z",
  "end": "2025-01-10T10:00:00.000Z",
  "color": "#FF6B00"
}
```

## Callbacks

All callbacks can be toggled on/off and have customizable function bodies:

| Callback | Fires When | Payload |
|----------|-----------|---------|
| `onEventCreate` | New booking saved | `{ id, title, start, end }` |
| `onEventUpdate` | Booking edited | `{ id, title, start, end }` |
| `onEventDelete` | Booking deleted | `{ id }` |
| `onEventClick` | Booking clicked/viewed | `{ id, title }` |
| `onDateChange` | Calendar navigated | `{ date, direction }` |
| `onViewChange` | View switched | `{ view }` |
| `onSlotClick` | Empty slot clicked | `{ date, hour }` |
| `onEventDrop` | Event drag & dropped | `{ id, from, to }` |
| `onSpecialistScheduleCheck` | Availability panel loads | `{ specialist_uuid, specialist_name, date, day, available_slots, booked_slots, total_available, total_booked }` |
| `onBookingCancel` | Cancel booking confirmed | `{ id, title, customer, specialist }` |
| `onBookingNoShow` | No-show confirmed | `{ id, title, customer, specialist }` |
| `onBookingComplete` | Booking marked as complete | `{ id, title, customer, specialist }` |

### Custom Callback Functions

Each callback has an editable function body that receives a `payload` argument:

```js
callbackFns: {
  onEventCreate: 'console.log("Event created:", payload)',
  onSpecialistScheduleCheck: `
    // Fetch real availability from API
    fetch('/api/availability?specialist=' + payload.specialist_uuid + '&date=' + payload.date)
      .then(r => r.json())
      .then(data => console.log(data))
  `
}
```

### Callback Examples

#### onEventCreate — Send new booking to API

```js
callbackFns: {
  onEventCreate: `
    fetch('/api/bookings', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload)
    })
    .then(r => r.json())
    .then(data => console.log('Booking saved:', data.id))
    .catch(err => console.error('Failed to save:', err))
  `
}
```

#### onEventUpdate — Sync changes to backend

```js
callbackFns: {
  onEventUpdate: `
    fetch('/api/bookings/' + payload.id, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload)
    })
    .then(() => console.log('Booking updated:', payload.id))
  `
}
```

#### onEventDelete — Delete from backend with confirmation log

```js
callbackFns: {
  onEventDelete: `
    fetch('/api/bookings/' + payload.id, { method: 'DELETE' })
      .then(() => console.log('Booking deleted:', payload.id))
  `
}
```

#### onEventClick — Track booking views in analytics

```js
callbackFns: {
  onEventClick: `
    if (window.gtag) {
      gtag('event', 'view_booking', {
        booking_id: payload.id,
        booking_title: payload.title
      })
    }
    console.log('Viewed booking:', payload.title)
  `
}
```

#### onDateChange — Load events for the new date range

```js
callbackFns: {
  onDateChange: `
    console.log('Calendar navigated:', payload.direction, 'to', payload.date)
    // Fetch events for the new visible range
    // fetch('/api/bookings?date=' + payload.date)
    //   .then(r => r.json())
    //   .then(events => store.commit('SET_EVENTS', events))
  `
}
```

#### onViewChange — Persist user's preferred view

```js
callbackFns: {
  onViewChange: `
    localStorage.setItem('calendarView', payload.view)
    console.log('View changed to:', payload.view)
  `
}
```

#### onSlotClick — Pre-fill booking from clicked slot

```js
callbackFns: {
  onSlotClick: `
    console.log('Slot clicked:', payload.date, 'at', payload.hour + ':00')
  `
}
```

#### onEventDrop — Update booking after drag & drop

```js
callbackFns: {
  onEventDrop: `
    fetch('/api/bookings/' + payload.id + '/reschedule', {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ from: payload.from, to: payload.to })
    })
    .then(() => console.log('Booking rescheduled:', payload.id))
  `
}
```

#### onSpecialistScheduleCheck — Fetch real-time availability from API

```js
callbackFns: {
  onSpecialistScheduleCheck: `
    console.log('Checking schedule for', payload.specialist_name, 'on', payload.date)
    console.log('Day:', payload.day)
    console.log('Available slots:', payload.total_available)
    console.log('Booked slots:', payload.total_booked)
    console.log('Available times:', payload.available_slots.join(', '))
    console.log('Booked times:', payload.booked_slots.join(', '))

    // Example: Fetch real availability from your API
    // fetch('/api/specialists/' + payload.specialist_uuid + '/availability?date=' + payload.date)
    //   .then(r => r.json())
    //   .then(data => {
    //     // Update specialist availability dynamically
    //     console.log('Real availability:', data)
    //   })
  `
}
```

#### onBookingCancel — Handle booking cancellation

```js
callbackFns: {
  onBookingCancel: `
    fetch('/api/bookings/' + payload.id + '/cancel', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ reason: 'customer_request' })
    })
    .then(() => showSuccess('Booking cancelled successfully'))
    .catch(err => showError('Failed to cancel: ' + err.message))
  `
}
```

#### onBookingNoShow — Record no-show and notify

```js
callbackFns: {
  onBookingNoShow: `
    fetch('/api/bookings/' + payload.id + '/no-show', { method: 'POST' })
      .then(() => showSuccess('No-show recorded for ' + payload.title))
      .catch(err => showError('Failed to record no-show: ' + err.message))
  `
}
```

#### onBookingComplete — Mark service as delivered

```js
callbackFns: {
  onBookingComplete: `
    fetch('/api/bookings/' + payload.id + '/complete', { method: 'POST' })
      .then(() => showSuccess('Service completed: ' + payload.title))
      .catch(err => showError('Failed to complete: ' + err.message))
  `
}
```

> **Note:** All callback functions receive `showSuccess(message)` and `showError(message)` as arguments. Call these to display success (green) or error (red) toast notifications in the booking dialog. Toasts auto-dismiss after 4 seconds or can be clicked to dismiss.

### Disabling Specific Callbacks

Toggle individual callbacks on/off:

```js
// Via config
callbacks: {
  onEventCreate: true,     // enabled
  onEventUpdate: true,     // enabled
  onEventDelete: false,    // disabled
  onEventClick: false,     // disabled
  onDateChange: true,      // enabled
  onViewChange: false,     // disabled
  onSlotClick: false,      // disabled
  onEventDrop: true,       // enabled
  onSpecialistScheduleCheck: true,  // enabled
  onBookingCancel: true,   // enabled
  onBookingNoShow: true,   // enabled
  onBookingComplete: true  // enabled
}

// Or at runtime
store.commit('SET_CONFIG', { key: 'callbacks.onEventDelete', value: false })
store.commit('SET_CONFIG', { key: 'callbacks.onEventDrop', value: true })
store.commit('SET_CONFIG', { key: 'callbacks.onBookingCancel', value: false })
store.commit('SET_CONFIG', { key: 'callbacks.onBookingComplete', value: true })
```

### Updating Callback Functions at Runtime

```js
// Update a callback function body
store.commit('SET_CONFIG', {
  key: 'callbackFns.onEventCreate',
  value: 'fetch("/api/bookings", { method: "POST", body: JSON.stringify(payload) })'
})

// Reset to default
store.commit('SET_CONFIG', {
  key: 'callbackFns.onEventCreate',
  value: 'console.log("Event created:", payload)'
})
```

## Vuex Store API

### Mutations

| Mutation | Payload | Description |
|----------|---------|-------------|
| `SET_DATE` | `Date` | Set current calendar date |
| `SET_VIEW` | `string` | Set view (`'month'`, `'week'`, `'day'`) |
| `SET_EVENTS` | `array` | Replace all events |
| `ADD_EVENT` | `object` | Add a single event |
| `UPDATE_EVENT` | `object` | Update event by id |
| `DELETE_EVENT` | `string` | Delete event by id |
| `SET_CONFIG` | `{ key, value }` | Update config (supports dot notation: `'bookingFields.brand'`) |
| `SHOW_EVENT_MODAL` | `boolean` | Show/hide create/edit modal |
| `SHOW_VIEW_MODAL` | `boolean` | Show/hide view modal |

### Actions

| Action | Payload | Description |
|--------|---------|-------------|
| `next` | — | Navigate to next period |
| `prev` | — | Navigate to previous period |
| `today` | — | Navigate to today |
| `changeView` | `string` | Switch view with callback |
| `addEvent` | `object` | Add event with callback |
| `updateEvent` | `object` | Update event with callback |
| `deleteEvent` | `string` | Delete event with callback |
| `openCreateModal` | `slot?` | Open create booking modal |
| `openEditModal` | `event` | Open edit booking modal |
| `openViewModal` | `event` | Open view booking modal |
| `moveEvent` | `{ id, newDate, newHour, newMinute }` | Move event (drag & drop, snaps to 15-min intervals) |
| `clearAllEvents` | — | Remove all events |
| `resetEvents` | — | Reset to sample data |

## Programmatic Config Updates

Update any config at runtime:

```js
// Simple value
store.commit('SET_CONFIG', { key: 'timeFormat', value: '24h' })

// Nested value
store.commit('SET_CONFIG', { key: 'bookingFields.color', value: false })

// Replace dropdown options
store.commit('SET_CONFIG', {
  key: 'dropdownOptions.brand',
  value: [
    { uuid: 'b1', name: 'My Brand' },
    { uuid: 'b2', name: 'Other Brand' }
  ]
})

// Replace all events
store.commit('SET_EVENTS', myEventsArray)
```

## Custom Create/Edit Modal (Slots)

Replace the built-in booking modal with your own component using the `create-modal` slot:

```html
<BookingCalendar>
  <template #create-modal="{ form, event, close, isEdit }">
    <MyCustomBookingDialog
      v-if="$store.state.showEventModal"
      :slot-data="form"
      :event="event"
      :is-edit="isEdit"
      @close="close"
      @save="handleSave"
    />
  </template>
</BookingCalendar>
```

### Slot Props

| Prop | Type | Description |
|------|------|-------------|
| `form` | `object\|null` | The selected slot data (`{ date, hour }`) when creating |
| `event` | `object\|null` | The selected event object when editing |
| `close` | `function` | Call to close the modal |
| `isEdit` | `boolean` | `true` if editing, `false` if creating |

### Available Internal Slots

The built-in EventModal also exposes these slots for partial customization:

| Slot | Props | Description |
|------|-------|-------------|
| `header` | `{ form, isEdit, close }` | Replace the hero header |
| `content` | `{ form, isEdit }` | Replace the entire form body (all cards) |
| `before-fields` | `{ form, isEdit }` | Insert content before the form fields |
| `after-fields` | `{ form, isEdit }` | Insert content after the form fields |
| `footer` | `{ form, isEdit, save, close, deleteEvent }` | Replace the footer buttons |

```html
<!-- Example: Add a note above the form -->
<BookingCalendar>
  <template #create-modal="{ form, event, close, isEdit }">
    <EventModal v-if="$store.state.showEventModal">
      <template #before-fields>
        <div class="my-note">Please fill all required fields</div>
      </template>
      <template #footer="{ save, close }">
        <button @click="close">Cancel</button>
        <button @click="save">Book Now</button>
      </template>
    </EventModal>
  </template>
</BookingCalendar>
```

## License

MIT
