├── .editorconfig ├── .github └── FUNDING.yml ├── assets ├── app.css └── app.js ├── cache.appcache ├── index.html └── readme.md /.editorconfig: -------------------------------------------------------------------------------- 1 | ; http://editorconfig.org 2 | ; 3 | ; Sublime: https://github.com/sindresorhus/editorconfig-sublime 4 | ; Phpstorm: https://plugins.jetbrains.com/plugin/7294-editorconfig 5 | 6 | root = true 7 | 8 | [*] 9 | indent_style = space 10 | indent_size = 2 11 | end_of_line = lf 12 | charset = utf-8 13 | trim_trailing_whitespace = true 14 | insert_final_newline = true 15 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: adhocore 2 | custom: ['https://paypal.me/ji10'] 3 | -------------------------------------------------------------------------------- /assets/app.css: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Jitendra Adhikari 3 | */ 4 | .bg-gray { 5 | background: #EEEEEE; 6 | } 7 | .bg-dark-gray { 8 | background: #F5F6F1; 9 | } 10 | .bg-dark-gray.row, .bg-light-gray.row { 11 | padding: 5px 0; 12 | border: 1px solid #DCDCDC; 13 | border-bottom: none; 14 | } 15 | .bg-light-gray { 16 | background: #F9F9F9; 17 | } 18 | .form-group { 19 | margin-bottom: 0px; 20 | } 21 | .dotted-bottom { 22 | cursor: pointer; 23 | text-decoration-line: underline; 24 | text-decoration-style: dotted; 25 | text-decoration-color: #AAAAAA; 26 | } 27 | .full { 28 | width: 100%; 29 | } 30 | .left-30, .left-70 { 31 | float: left; 32 | width: 30%; 33 | } 34 | .left-70 { 35 | width: 70%; 36 | } 37 | label.no-bold { 38 | font-weight: normal; 39 | } 40 | .clear { 41 | clear: both; 42 | } 43 | .spacer-10 { 44 | margin: 0; 45 | padding: 0; 46 | height: 10px; 47 | } 48 | .feedback { 49 | margin-left: -50px; 50 | margin-top: 5px; 51 | } 52 | tr.single { 53 | background-color: #C6C78E; 54 | } 55 | tr.double { 56 | background-color: #CAE6EB; 57 | } 58 | .calendar { 59 | border: 1px solid #DCDCDC; 60 | border-collapse: collapse; 61 | } 62 | .calendar-data { 63 | overflow-x: hidden; 64 | } 65 | .calendar th, .calendar td { 66 | text-align: center; 67 | } 68 | .calendar th { 69 | padding: 0 5px; 70 | } 71 | .calendar th.compact { 72 | padding: 0; 73 | } 74 | .calendar th.cozy { 75 | padding: 5px; 76 | } 77 | .calendar th.weekday { 78 | background-color: #DCDCDC; 79 | } 80 | .calendar th.weekend { 81 | background-color: #860101; 82 | color: #FFFFFF; 83 | } 84 | .calendar .title { 85 | text-align: left; 86 | width: 156px; 87 | padding: 0 5px; 88 | } 89 | .calendar .title-month { 90 | text-align: center; 91 | width: 994px; 92 | } 93 | .calendar span.month { 94 | width: 115px; 95 | display: inline-block; 96 | } 97 | .calendar .text-left { 98 | text-align: left; 99 | } 100 | .calendar .value { 101 | width: 99px; 102 | max-height: 20px; 103 | } 104 | .prompt { 105 | width: 240px; 106 | height: 52px; 107 | border: 1px solid #EDEDEB; 108 | border-radius: 2px; 109 | background: #FFFFFF; 110 | padding: 10px; 111 | position: absolute; 112 | } 113 | .prompt .prompt-input { 114 | width: 140px; 115 | border: 1px solid #DDD; 116 | border-radius: 2px; 117 | padding: 5px; 118 | } 119 | .prompt button { 120 | font-weight: bold; 121 | } 122 | .prompt .prompt-input:focus { 123 | outline: none; 124 | border: 1px solid #66AFE9; 125 | } 126 | .prompt-input.error { 127 | border: 1px solid #DD5555; 128 | } 129 | .prompt .caret { 130 | color: #DCDCDC; 131 | } 132 | .caret.pointer { 133 | cursor: pointer; 134 | } 135 | .caret.right, .caret.left { 136 | border-bottom: 8px solid transparent; 137 | border-top: 8px solid transparent; 138 | border-left: 8px solid; 139 | display: inline-block; 140 | height: 0; 141 | vertical-align: middle; 142 | width: 0; 143 | } 144 | .caret.left { 145 | border-right: 8px solid; 146 | border-left: none; 147 | } 148 | .caret.down { 149 | margin-top: 8px; 150 | } 151 | .caret.up { 152 | margin-top: -8px; 153 | margin-left: -12px; 154 | } 155 | .scroller { 156 | position: relative; 157 | bottom: -45px; 158 | margin: -8px; 159 | left: 996px; 160 | } 161 | .scroller.left { 162 | left: 980px; 163 | } 164 | .border-left { 165 | border-left: 1px solid #CCCCCC; 166 | } 167 | .border-all { 168 | border: 1px solid #CCCCCC; 169 | } 170 | .top-less { 171 | border-top: none !important; 172 | } 173 | .bottom-less { 174 | border-bottom: none !important; 175 | } 176 | .with-bottom { 177 | border-bottom: 1px solid #DCDCDC !important; 178 | padding-bottom: 25px !important; 179 | } 180 | -------------------------------------------------------------------------------- /assets/app.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Jitendra Adhikari 3 | */ 4 | class Store 5 | { 6 | constructor(prefix) { this.prefix = prefix } 7 | 8 | get currentDate () { return localStorage.getItem(this.prefix + 'current-date') } 9 | 10 | set currentDate (value) { localStorage.setItem(this.prefix + 'current-date', value) } 11 | 12 | get template () { return { price: 0, quota: 0, currency: 'n/a' } } 13 | 14 | getMonthlyData (month) { 15 | let data = localStorage.getItem(this.prefix + month); 16 | 17 | if (data) return JSON.parse(data); 18 | 19 | const days = moment(month, 'YYYY-MM').daysInMonth(); 20 | const tmpl = this.template; 21 | 22 | data = {}; 23 | for (let i = 1; i <= days; i++) { 24 | data[month + '-' + (i < 10 ? '0' + i : i)] = { single: tmpl, double: tmpl }; 25 | } 26 | 27 | return data; 28 | } 29 | 30 | setMonthlyData (month, data) { localStorage.setItem(this.prefix + month, JSON.stringify(data)) } 31 | 32 | setDailyData (day, type, set) { 33 | const month = day.substr(0, 7); 34 | let data = this.getMonthlyData(month); 35 | 36 | if (!data[day][type]) { 37 | data[day][type] = set; 38 | } else { 39 | data[day][type] = { ...data[day][type], ...set }; 40 | } 41 | 42 | this.setMonthlyData(month, data); 43 | } 44 | } 45 | 46 | Vue.component('prompt', { 47 | template: '#prompt', 48 | 49 | delimiters: ['[[', ']]'], 50 | 51 | props: ['storage'], 52 | 53 | data() { return { opened: false, newValue: null, data: {}, max: null, error: false } }, 54 | 55 | methods: { 56 | open (data, pos) { 57 | this.data = data; 58 | this.opened = true; 59 | this.newValue = data.value; 60 | this.$el.style.top = (pos.top - 58) + 'px'; 61 | this.$el.style.left = (pos.left - 118) + 'px'; 62 | }, 63 | 64 | close () { this.error = this.opened = false }, 65 | 66 | update () { 67 | if (+this.newValue < 1 || isNaN(+this.newValue) || (this.data.max && this.newValue > this.data.max)) { 68 | return this.error = true; 69 | } 70 | this.error = false; 71 | 72 | // No change! 73 | if (+this.data.value === +this.newValue) { 74 | return this.close(); 75 | } 76 | 77 | this.storage.setDailyData(this.data.fullDate, this.data.roomType, { 78 | [this.data.prop]: +this.newValue 79 | }); 80 | 81 | this.$parent.updated.call(this.$parent, this.newValue); 82 | this.close(); 83 | } 84 | } 85 | }); 86 | 87 | Vue.component('calendar', { 88 | template: '#calendar-grid', 89 | 90 | delimiters: ['[[', ']]'], 91 | 92 | props: ['storage', 'roomTypes'], 93 | 94 | data () { 95 | const date = this.storage.currentDate || moment().format('YYYY-MM'); 96 | 97 | return { date, monthlyData: {}, currentElem: null, moment: moment(date, 'YYYY-MM') }; 98 | }, 99 | 100 | computed: { 101 | month () { return this.moment.format('MMMM') }, 102 | 103 | year () { return this.moment.format('YYYY') }, 104 | 105 | totalDays () { return this.moment.daysInMonth() }, 106 | 107 | daysOfWeek () { 108 | const weekDays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; 109 | const weekEnds = { 0: 'weekend', 6: 'weekend' }; 110 | const total = this.moment.daysInMonth(); 111 | 112 | let days = []; 113 | let startIdx = weekDays.indexOf(this.moment.format('dddd')); 114 | 115 | for (let day = 1; day <= total; day++) { 116 | days.push({ name: weekDays[startIdx % 7], type: weekEnds[startIdx % 7] || 'weekday' }); 117 | startIdx++; 118 | } 119 | 120 | return days; 121 | }, 122 | 123 | daysOfMonth () { 124 | const total = this.moment.daysInMonth(); 125 | let days = []; 126 | 127 | for (let day = 1; day <= total; day++) { days.push({ day }) } 128 | 129 | return days; 130 | }, 131 | }, 132 | 133 | created() { this.fetchData() }, 134 | 135 | watch: { 136 | date () { 137 | this.moment = moment(this.date, 'YYYY-MM'); 138 | this.storage.currentDate = this.date; 139 | 140 | this.fetchData(); 141 | }, 142 | }, 143 | 144 | methods: { 145 | fetchData () { this.monthlyData = this.storage.getMonthlyData(this.date) }, 146 | 147 | fetchIfOverlap (from, to) { 148 | const thisFrom = +this.moment.format('X'); 149 | const thisTo = +moment(this.date + '-' + this.moment.daysInMonth(), 'YYYY-MM-DD').format('X'); 150 | 151 | if (thisFrom <= to && from <= thisTo) { this.fetchData() } 152 | }, 153 | 154 | showPrompt (event) { 155 | const elem = event.currentTarget || event.target; 156 | 157 | this.currentElem = elem; 158 | this.$refs.prompt.open(elem.dataset, elem.getBoundingClientRect()); 159 | }, 160 | 161 | scrollLeft () { document.getElementById('calendar-data').scrollLeft -= 1050 }, 162 | 163 | scrollRight () { document.getElementById('calendar-data').scrollLeft += 1050 }, 164 | 165 | updated (newValue) { this.currentElem.innerText = this.currentElem.dataset.value = newValue }, 166 | 167 | addMonth () { this.date = this.moment.add(1, 'months').format('YYYY-MM') }, 168 | 169 | addYear () { this.date = this.moment.add(1, 'years').format('YYYY-MM') }, 170 | 171 | subMonth () { this.date = this.moment.subtract(1, 'months').format('YYYY-MM') }, 172 | 173 | subYear () { this.date = this.moment.subtract(1, 'years').format('YYYY-MM') }, 174 | }, 175 | }); 176 | 177 | const App = new Vue({ 178 | el: '#app', 179 | 180 | delimiters: ['[[', ']]'], 181 | 182 | data: { 183 | fromDate: null, 184 | toDate: null, 185 | bulkPrice: null, 186 | bulkQuota: null, 187 | roomType: 'double', 188 | roomTypes: { 189 | single: {label: 'Single Room', inventory: 5}, 190 | double: {label: 'Double Room', inventory: 5} 191 | }, 192 | filterDays: ['*'], 193 | refineDays: [ 194 | {value: '0,1,2,3,4,5,6', label: 'All days'}, 195 | {value: '1', label: 'Mondays'}, 196 | {value: '4', label: 'Thursdays'}, 197 | {value: '0', label: 'Sundays'}, 198 | {value: '1,2,3,4,5', label: 'All Weekdays'}, 199 | {value: '2', label: 'Tuesdays'}, 200 | {value: '5', label: 'Fridays'}, 201 | {value: '6,0', label: 'All weekends'}, 202 | {value: '3', label: 'Wednesdays'}, 203 | {value: '6', label: 'Saturdays'}, 204 | ], 205 | storage: new Store('hotel-calendar-'), 206 | currency: 'USD', 207 | saved: false, 208 | errors: null, 209 | }, 210 | 211 | methods: { 212 | clear () { 213 | this.roomType = 'double'; 214 | this.filterDays = ['*']; 215 | this.fromDate = this.toDate = this.bulkPrice = this.bulkQuota = null; 216 | }, 217 | 218 | fromTimestamp () { return +moment(this.fromDate, 'YYYY-MM-DD').format('X') }, 219 | 220 | toTimestamp () { return +moment(this.toDate, 'YYYY-MM-DD').format('X') }, 221 | 222 | weekDayNum (date) { return moment(date, 'YYYY-MM-DD').format('e') }, 223 | 224 | cancel () { this.clear() }, 225 | 226 | validate () { 227 | let errors = []; 228 | if (isNaN(+this.bulkPrice) || +this.bulkPrice < 1) { 229 | errors.push('Price must be amount greater than 0'); 230 | } 231 | 232 | const max = this.roomTypes[this.roomType].inventory; 233 | const num = +this.bulkQuota; 234 | if (isNaN(num) || num < 1 || num > max) { 235 | errors.push('Quota must be a number between 1 and ' + max); 236 | } 237 | 238 | if (errors.length) { 239 | this.errors = errors.join('. '); 240 | 241 | return false; 242 | } 243 | 244 | this.errors = null; 245 | 246 | return true; 247 | }, 248 | 249 | bulkUpdate () { 250 | if (!this.validate.call(this)) { 251 | return; 252 | } 253 | 254 | const row = { price: +this.bulkPrice, quota: +this.bulkQuota, currency: this.currency }; 255 | 256 | let day, month, dataset = {}; 257 | let filterDays = this.filterDays.join(',').split(','); 258 | let allDay = filterDays.length === 0 || filterDays.indexOf('*') > -1; 259 | let from = this.fromTimestamp(), to = this.toTimestamp(); 260 | 261 | if (from > to) [from, to] = [to, from]; 262 | 263 | while (from <= to) { 264 | day = moment(from, 'X').format('YYYY-MM-DD'); 265 | month = day.substr(0, 7); 266 | 267 | if (allDay || filterDays.indexOf(this.weekDayNum(day)) > -1) { 268 | if (!dataset[month]) dataset[month] = this.storage.getMonthlyData(month); 269 | dataset[month][day][this.roomType] = row; 270 | } 271 | 272 | from += 86400; 273 | } 274 | 275 | Object.keys(dataset).forEach(month => this.storage.setMonthlyData(month, dataset[month])); 276 | this.updated(); 277 | }, 278 | 279 | updated () { 280 | this.saved = true; 281 | setTimeout(() => this.saved = false, 2500); 282 | 283 | // Reload the calendar if the bulk date ranges overlap! 284 | this.$refs.calendar.fetchIfOverlap(this.fromTimestamp(), this.toTimestamp()); 285 | this.clear(); 286 | }, 287 | } 288 | }); 289 | -------------------------------------------------------------------------------- /cache.appcache: -------------------------------------------------------------------------------- 1 | CACHE MANIFEST 2 | 3 | # Offline ability ;) 4 | 5 | # Main page 6 | index.html 7 | 8 | # CSS 9 | https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/css/bootstrap.min.css 10 | assets/app.css 11 | 12 | # JavaScript 13 | https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.18.1/moment.min.js 14 | https://cdnjs.cloudflare.com/ajax/libs/vue/2.4.2/vue.min.js 15 | assets/app.js 16 | 17 | NETWORK: 18 | * 19 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Hotel Room Inventory 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |
15 |
16 |
17 |
Bulk Operations
18 |
19 |
20 |
21 |
22 |
23 | 24 |
25 |
26 | 31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | 39 |
40 |
41 |
42 |
43 | 44 |
45 |
46 | 47 |
48 |
49 |
50 |
51 | 52 |
53 |
54 | 55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 | 63 |
64 |
65 | 71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 | 80 |
81 |
82 | 83 |
84 |
85 |
86 |
87 |
88 |
89 | 90 |
91 |
92 | 93 |
94 |
95 |
96 |
97 |
98 | 99 | 100 |
101 | 105 |
106 |
 
107 |
108 |
109 |
 
110 | 111 |
112 |
113 |

114 | Use the bulk operations section to tune in critera and set price and availability at once for date range. Click on the dotted numbers in calendar to set price or availability for a day. 115 |

116 |
117 |
118 |
119 | 199 | 207 | 208 | 209 | 210 | 211 | 212 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # vue-inventory 2 | 3 | - a vuejs calendar app for managing hotel inventory in bulk or per day basis 4 | - saves data in client side (web browser) using `localStorage` 5 | - `Store` class can be easily extended to persist in server side (backend) 6 | - offline capable ;) 7 | 8 | # components used 9 | 10 | - `vuejs` 11 | - `bootstrap` 12 | - `momentjs` 13 | 14 | 15 | # demo 16 | 17 | - [adhocore.github.io/vue-inventory](https://adhocore.github.io/vue-inventory/) 18 | --------------------------------------------------------------------------------