@saltcorn/reservable 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -15
- package/available-feed.js +277 -0
- package/index.js +4 -1
- package/package.json +17 -1
- package/validate.js +71 -0
package/README.md
CHANGED
|
@@ -2,36 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
To use this Plugin, you should create a table for reservations. This table should have the following fields:
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
- A Date field for the time of the start of the reservation
|
|
6
|
+
- An integer field for the duration of the reservation in minutes
|
|
7
|
+
- Any other fields for the reservation, for instance the email of the person reserving the slot
|
|
8
8
|
|
|
9
9
|
This table also needs to have two views
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
- A view to create, typically an Edit view obtain the information required for the booking.
|
|
12
12
|
This Edit view does not need to have fields for the date or the duration.
|
|
13
|
-
|
|
13
|
+
- A confirmation view, typically a Show view, which is displayed to the user on a successful reservation.
|
|
14
14
|
This may or may not contain Date and duration
|
|
15
15
|
|
|
16
16
|
There may be another table indicating reservable resources. This is optional. If you are building a reservation system
|
|
17
|
-
for a single resource, you can leave this out. On the other hand, if you are building a system where multiple resources
|
|
18
|
-
need to be tracked for whether they are available, use this to denote the resources that can be taken/available.
|
|
17
|
+
for a single resource, you can leave this out. On the other hand, if you are building a system where multiple resources
|
|
18
|
+
need to be tracked for whether they are available, use this to denote the resources that can be taken/available.
|
|
19
19
|
To do this, create a foreign key field on your reservations Table to the reservable resource table.
|
|
20
20
|
|
|
21
21
|
You should then create a Reserve view on the reservations table. In the configuration, you specify the fields
|
|
22
22
|
As explained above. You will also need to configure:
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
- What services are available. Each service has a name and a duration.
|
|
25
|
+
- What the availability is, your opening hours. You specify these in blocks of time and the day of the week.
|
|
26
26
|
|
|
27
27
|
### Example
|
|
28
28
|
|
|
29
|
-
Goldilocks and Rapunzel together run the successful GoldiRap Hair Salon. In fact they are so successful that
|
|
30
|
-
they must now implement an online booking system. Their opening hours are Monday to Friday 9-12 and 13-17
|
|
31
|
-
(they close for lunch) and Saturday 10-13. They offer two services: A haircut taking 30 minutes
|
|
29
|
+
Goldilocks and Rapunzel together run the successful GoldiRap Hair Salon. In fact they are so successful that
|
|
30
|
+
they must now implement an online booking system. Their opening hours are Monday to Friday 9-12 and 13-17
|
|
31
|
+
(they close for lunch) and Saturday 10-13. They offer two services: A haircut taking 30 minutes
|
|
32
32
|
and a full restyle taking 45 minutes. You can book either Goldilocks or Rapunzel for each of the services.
|
|
33
33
|
|
|
34
|
-
Here, the reservable resource is the hairdresser with two rows: Goldilocks and Rapunzel. They have two services,
|
|
34
|
+
Here, the reservable resource is the hairdresser with two rows: Goldilocks and Rapunzel. They have two services,
|
|
35
35
|
haircut and full restyle. The availability can be specified as: Mon-Fri 9-12, Mon-Fri 13-17 and Saturday 10-13.
|
|
36
|
-
|
|
37
|
-
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
const {
|
|
2
|
+
input,
|
|
3
|
+
div,
|
|
4
|
+
text,
|
|
5
|
+
script,
|
|
6
|
+
domReady,
|
|
7
|
+
style,
|
|
8
|
+
button,
|
|
9
|
+
h3,
|
|
10
|
+
ul,
|
|
11
|
+
li,
|
|
12
|
+
form,
|
|
13
|
+
a,
|
|
14
|
+
b,
|
|
15
|
+
} = require("@saltcorn/markup/tags");
|
|
16
|
+
|
|
17
|
+
const View = require("@saltcorn/data/models/view");
|
|
18
|
+
const Workflow = require("@saltcorn/data/models/workflow");
|
|
19
|
+
const Table = require("@saltcorn/data/models/table");
|
|
20
|
+
const Form = require("@saltcorn/data/models/form");
|
|
21
|
+
const Field = require("@saltcorn/data/models/field");
|
|
22
|
+
const {
|
|
23
|
+
InvalidConfiguration,
|
|
24
|
+
isNode,
|
|
25
|
+
isWeb,
|
|
26
|
+
mergeConnectedObjects,
|
|
27
|
+
hashState,
|
|
28
|
+
} = require("@saltcorn/data/utils");
|
|
29
|
+
const {
|
|
30
|
+
link_view,
|
|
31
|
+
stateToQueryString,
|
|
32
|
+
stateFieldsToWhere,
|
|
33
|
+
stateFieldsToQuery,
|
|
34
|
+
readState,
|
|
35
|
+
} = require("@saltcorn/data/plugin-helper");
|
|
36
|
+
const get_state_fields = async (table_id, viewname, { show_view }) => {
|
|
37
|
+
const table = Table.findOne(table_id);
|
|
38
|
+
const table_fields = table.fields;
|
|
39
|
+
return table_fields
|
|
40
|
+
.filter((f) => !f.primary_key)
|
|
41
|
+
.map((f) => {
|
|
42
|
+
const sf = new Field(f);
|
|
43
|
+
sf.required = false;
|
|
44
|
+
return sf;
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const configuration_workflow = (req) =>
|
|
49
|
+
new Workflow({
|
|
50
|
+
steps: [
|
|
51
|
+
{
|
|
52
|
+
name: req.__("Views"),
|
|
53
|
+
form: async (context) => {
|
|
54
|
+
const table = Table.findOne(context.table_id);
|
|
55
|
+
const fields = table.fields;
|
|
56
|
+
|
|
57
|
+
const reservable_entity_fields = fields.filter((f) => f.is_fkey);
|
|
58
|
+
const show_view_opts = {};
|
|
59
|
+
const slots_available_field = {};
|
|
60
|
+
const distinct_slot_fields = new Set();
|
|
61
|
+
for (const rfield of reservable_entity_fields) {
|
|
62
|
+
const retable = Table.findOne(rfield.reftable_name);
|
|
63
|
+
const show_views = await View.find_table_views_where(
|
|
64
|
+
retable.id,
|
|
65
|
+
({ state_fields, viewtemplate, viewrow }) =>
|
|
66
|
+
viewtemplate.runMany &&
|
|
67
|
+
viewrow.name !== context.viewname &&
|
|
68
|
+
state_fields.some((sf) => sf.name === "id")
|
|
69
|
+
);
|
|
70
|
+
show_view_opts[rfield.name] = show_views.map((v) => v.name);
|
|
71
|
+
slots_available_field[rfield.name] = retable.fields
|
|
72
|
+
.filter((f) => f.type?.name === "Integer")
|
|
73
|
+
.map((f) => f.name);
|
|
74
|
+
slots_available_field[rfield.name].forEach((v) =>
|
|
75
|
+
distinct_slot_fields.add(v)
|
|
76
|
+
);
|
|
77
|
+
slots_available_field[rfield.name].unshift("");
|
|
78
|
+
}
|
|
79
|
+
return new Form({
|
|
80
|
+
fields: [
|
|
81
|
+
{
|
|
82
|
+
name: "reservable_entity_key",
|
|
83
|
+
label: "Key to reservable entity",
|
|
84
|
+
type: "String",
|
|
85
|
+
required: true,
|
|
86
|
+
attributes: {
|
|
87
|
+
options: reservable_entity_fields.map((f) => f.name),
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: "valid_field",
|
|
92
|
+
label: "Valid reservation field",
|
|
93
|
+
sublabel: "Only consider reservations with this field valid",
|
|
94
|
+
type: "String",
|
|
95
|
+
attributes: {
|
|
96
|
+
options: fields
|
|
97
|
+
.filter((f) => f.type.name === "Bool")
|
|
98
|
+
.map((f) => f.name),
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: "start_field",
|
|
103
|
+
label: "Start date field",
|
|
104
|
+
type: "String",
|
|
105
|
+
required: true,
|
|
106
|
+
attributes: {
|
|
107
|
+
options: fields
|
|
108
|
+
.filter((f) => f.type.name === "Date")
|
|
109
|
+
.map((f) => f.name),
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: "end_field",
|
|
114
|
+
label: "End date field",
|
|
115
|
+
type: "String",
|
|
116
|
+
required: true,
|
|
117
|
+
attributes: {
|
|
118
|
+
options: fields
|
|
119
|
+
.filter((f) => f.type.name === "Date")
|
|
120
|
+
.map((f) => f.name),
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
/*{
|
|
124
|
+
name: "duration_field",
|
|
125
|
+
label: "Duration field",
|
|
126
|
+
sublabel: "Integer field holding booked duration in minutes",
|
|
127
|
+
type: "String",
|
|
128
|
+
attributes: {
|
|
129
|
+
options: fields
|
|
130
|
+
.filter((f) => f.type.name === "Integer")
|
|
131
|
+
.map((f) => f.name),
|
|
132
|
+
},
|
|
133
|
+
}, */
|
|
134
|
+
{
|
|
135
|
+
name: "show_view",
|
|
136
|
+
label: req.__("Single item view"),
|
|
137
|
+
type: "String",
|
|
138
|
+
sublabel:
|
|
139
|
+
req.__("The underlying individual view of each table row") +
|
|
140
|
+
". " +
|
|
141
|
+
a(
|
|
142
|
+
{
|
|
143
|
+
"data-dyn-href": `\`/viewedit/config/\${show_view}\``,
|
|
144
|
+
target: "_blank",
|
|
145
|
+
},
|
|
146
|
+
req.__("Configure")
|
|
147
|
+
),
|
|
148
|
+
required: true,
|
|
149
|
+
attributes: {
|
|
150
|
+
calcOptions: ["reservable_entity_key", show_view_opts],
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
name: "slots_available_field",
|
|
155
|
+
label: "Slots available field",
|
|
156
|
+
sublabel:
|
|
157
|
+
"Field with a number of available instances of the reservable entity. If blank, treat as one per entity.",
|
|
158
|
+
type: "String",
|
|
159
|
+
attributes: {
|
|
160
|
+
calcOptions: ["reservable_entity_key", slots_available_field],
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
name: "slot_count_field",
|
|
165
|
+
label: "Slots taken field",
|
|
166
|
+
sublabel:
|
|
167
|
+
"Field with the number of entities reserved. If blank, treat as one per entity.",
|
|
168
|
+
type: "String",
|
|
169
|
+
showIf: { slots_available_field: [...distinct_slot_fields] },
|
|
170
|
+
attributes: {
|
|
171
|
+
options: fields
|
|
172
|
+
.filter((f) => f.type.name === "Integer")
|
|
173
|
+
.map((f) => f.name),
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
});
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const first = (xs) => (Array.isArray(xs) ? xs[0] : xs);
|
|
184
|
+
|
|
185
|
+
const run = async (
|
|
186
|
+
table_id,
|
|
187
|
+
viewname,
|
|
188
|
+
{
|
|
189
|
+
reservable_entity_key,
|
|
190
|
+
valid_field,
|
|
191
|
+
slot_count_field,
|
|
192
|
+
slots_available_field,
|
|
193
|
+
show_view,
|
|
194
|
+
start_field,
|
|
195
|
+
end_field,
|
|
196
|
+
},
|
|
197
|
+
state,
|
|
198
|
+
extraArgs
|
|
199
|
+
) => {
|
|
200
|
+
const restable = Table.findOne({ id: table_id });
|
|
201
|
+
const resfields = restable.getFields();
|
|
202
|
+
|
|
203
|
+
const refield = restable.getField(reservable_entity_key);
|
|
204
|
+
const retable = Table.findOne(refield.reftable_name);
|
|
205
|
+
|
|
206
|
+
const state_res = { ...state };
|
|
207
|
+
|
|
208
|
+
readState(state_res, restable.fields);
|
|
209
|
+
|
|
210
|
+
//get reservations
|
|
211
|
+
const reswhere = await stateFieldsToWhere({
|
|
212
|
+
fields: resfields,
|
|
213
|
+
state: state_res,
|
|
214
|
+
table: restable,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
if (valid_field) reswhere[valid_field] = true;
|
|
218
|
+
const reservations = await restable.getRows(reswhere);
|
|
219
|
+
const use_slots = slot_count_field || slots_available_field;
|
|
220
|
+
const sview = await View.findOne({ name: show_view });
|
|
221
|
+
if (!sview)
|
|
222
|
+
throw new InvalidConfiguration(
|
|
223
|
+
`View ${viewname} incorrectly configured: cannot find view ${show_view}`
|
|
224
|
+
);
|
|
225
|
+
const srespAll = await sview.runMany(state, extraArgs);
|
|
226
|
+
const srespsAvailable = [];
|
|
227
|
+
|
|
228
|
+
if (!use_slots) {
|
|
229
|
+
const resEnts = new Set(reservations.map((r) => r[reservable_entity_key]));
|
|
230
|
+
for (const sresp of srespAll) {
|
|
231
|
+
if (!resEnts.has(sresp.row[retable.pk_name])) srespsAvailable.push(sresp);
|
|
232
|
+
}
|
|
233
|
+
} else {
|
|
234
|
+
//console.log("state_res", state_res);
|
|
235
|
+
//console.log("reswhere", reswhere);
|
|
236
|
+
const to = new Date(first(reswhere[start_field])?.lt);
|
|
237
|
+
const from = new Date(first(reswhere[end_field])?.gt);
|
|
238
|
+
if (!from || !to) srespsAvailable.push(...srespAll);
|
|
239
|
+
else
|
|
240
|
+
for (const sresp of srespAll) {
|
|
241
|
+
const myreservations = reservations.filter(
|
|
242
|
+
(r) => r[reservable_entity_key] === sresp.row[retable.pk_name]
|
|
243
|
+
);
|
|
244
|
+
/*console.log({
|
|
245
|
+
taken: resEnts[sresp.row[retable.pk_name]] || 0,
|
|
246
|
+
available: sresp.row[slots_available_field],
|
|
247
|
+
});*/
|
|
248
|
+
const to = new Date(first(reswhere[start_field])?.lt);
|
|
249
|
+
const from = new Date(first(reswhere[end_field])?.gt);
|
|
250
|
+
let maxAvailable = sresp.row[slots_available_field];
|
|
251
|
+
for (let day = from; day <= to; day.setDate(day.getDate() + 1)) {
|
|
252
|
+
const active = myreservations.filter(
|
|
253
|
+
(r) => r[start_field] <= day && r[end_field] >= day
|
|
254
|
+
);
|
|
255
|
+
const taken = active
|
|
256
|
+
.map((r) => r[slot_count_field] || 1)
|
|
257
|
+
.reduce((a, b) => a + b, 0);
|
|
258
|
+
maxAvailable = Math.min(
|
|
259
|
+
maxAvailable,
|
|
260
|
+
sresp.row[slots_available_field] - taken
|
|
261
|
+
);
|
|
262
|
+
//console.log({ car: sresp.row.name, day, maxAvailable });
|
|
263
|
+
}
|
|
264
|
+
if (maxAvailable > 0) srespsAvailable.push(sresp);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const showRow = (r) => r.html;
|
|
268
|
+
return div(srespsAvailable.map(showRow));
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
module.exports = {
|
|
272
|
+
name: "Available Resources Feed",
|
|
273
|
+
display_state_form: false,
|
|
274
|
+
get_state_fields,
|
|
275
|
+
configuration_workflow,
|
|
276
|
+
run,
|
|
277
|
+
};
|
package/index.js
CHANGED
|
@@ -167,7 +167,8 @@ const configuration_workflow = () =>
|
|
|
167
167
|
});
|
|
168
168
|
|
|
169
169
|
const get_state_fields = async (table_id, viewname, { show_view }) => {
|
|
170
|
-
const
|
|
170
|
+
const table = Table.findOne(table_id);
|
|
171
|
+
const table_fields = table.fields;
|
|
171
172
|
return table_fields.map((f) => {
|
|
172
173
|
const sf = new Field(f);
|
|
173
174
|
sf.required = false;
|
|
@@ -525,7 +526,9 @@ module.exports = {
|
|
|
525
526
|
run,
|
|
526
527
|
runPost,
|
|
527
528
|
},
|
|
529
|
+
require("./available-feed"),
|
|
528
530
|
],
|
|
531
|
+
actions: { validate_reservation: require("./validate") },
|
|
529
532
|
};
|
|
530
533
|
|
|
531
534
|
/*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saltcorn/reservable",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Reservable resources",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"dependencies": {
|
|
@@ -18,5 +18,21 @@
|
|
|
18
18
|
},
|
|
19
19
|
"scripts": {
|
|
20
20
|
"test": "jest"
|
|
21
|
+
},
|
|
22
|
+
"eslintConfig": {
|
|
23
|
+
"extends": "eslint:recommended",
|
|
24
|
+
"parserOptions": {
|
|
25
|
+
"ecmaVersion": 2020
|
|
26
|
+
},
|
|
27
|
+
"env": {
|
|
28
|
+
"node": true,
|
|
29
|
+
"es6": true
|
|
30
|
+
},
|
|
31
|
+
"rules": {
|
|
32
|
+
"no-unused-vars": "off",
|
|
33
|
+
"no-case-declarations": "off",
|
|
34
|
+
"no-empty": "warn",
|
|
35
|
+
"no-fallthrough": "warn"
|
|
36
|
+
}
|
|
21
37
|
}
|
|
22
38
|
}
|
package/validate.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const View = require("@saltcorn/data/models/view");
|
|
2
|
+
const Table = require("@saltcorn/data/models/table");
|
|
3
|
+
|
|
4
|
+
module.exports = {
|
|
5
|
+
configFields: async ({ table }) => {
|
|
6
|
+
const views = await View.find({
|
|
7
|
+
viewtemplate: "Available Resources Feed",
|
|
8
|
+
table_id: table.id,
|
|
9
|
+
});
|
|
10
|
+
return [
|
|
11
|
+
{
|
|
12
|
+
name: "feedview",
|
|
13
|
+
label: "View",
|
|
14
|
+
sublabel: `A view on table ${table.name} with pattern: Available Resources Feed`,
|
|
15
|
+
type: "String",
|
|
16
|
+
required: true,
|
|
17
|
+
attributes: {
|
|
18
|
+
options: views.map((f) => f.name),
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
];
|
|
22
|
+
},
|
|
23
|
+
requireRow: true,
|
|
24
|
+
run: async ({ table, req, row, configuration: { feedview } }) => {
|
|
25
|
+
const view = View.findOne({ name: feedview });
|
|
26
|
+
const {
|
|
27
|
+
reservable_entity_key,
|
|
28
|
+
valid_field,
|
|
29
|
+
slot_count_field,
|
|
30
|
+
slots_available_field,
|
|
31
|
+
show_view,
|
|
32
|
+
start_field,
|
|
33
|
+
end_field,
|
|
34
|
+
} = view.configuration;
|
|
35
|
+
//get all relevant reservations
|
|
36
|
+
|
|
37
|
+
const ress = await table.getRows({
|
|
38
|
+
[reservable_entity_key]: row[reservable_entity_key],
|
|
39
|
+
[start_field]: { lt: row[end_field], equal: true, day_only: true },
|
|
40
|
+
[end_field]: { gt: row[start_field], equal: true, day_only: true },
|
|
41
|
+
...(valid_field ? { [valid_field]: true } : {}),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
//get entity
|
|
45
|
+
const refield = table.getField(reservable_entity_key);
|
|
46
|
+
const retable = Table.findOne(refield.reftable_name);
|
|
47
|
+
const entity = await retable.getRow({
|
|
48
|
+
[retable.pk_name]: row[reservable_entity_key],
|
|
49
|
+
});
|
|
50
|
+
//check that for every day, there is availablity
|
|
51
|
+
const from = new Date(row[start_field]);
|
|
52
|
+
const to = new Date(row[end_field]);
|
|
53
|
+
let maxAvailable = entity[slots_available_field];
|
|
54
|
+
// loop for every day
|
|
55
|
+
for (let day = from; day <= to; day.setDate(day.getDate() + 1)) {
|
|
56
|
+
// your day is here
|
|
57
|
+
const active = ress.filter(
|
|
58
|
+
(r) => r[start_field] <= day && r[end_field] >= day
|
|
59
|
+
);
|
|
60
|
+
const taken = active
|
|
61
|
+
.map((r) => r[slot_count_field])
|
|
62
|
+
.reduce((a, b) => a + b, 0);
|
|
63
|
+
maxAvailable = Math.min(
|
|
64
|
+
maxAvailable,
|
|
65
|
+
entity[slots_available_field] - taken
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
if (maxAvailable < row[slot_count_field])
|
|
69
|
+
return { error: `Only ${maxAvailable} are available` };
|
|
70
|
+
},
|
|
71
|
+
};
|