diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5b4679a37..ac4d5c8d3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
# Changelog Fab-manager
+- Ability to configure reservation slot restricted for plan subscribers
- Ability to configure the policy (allow or prevent) for members booking a machine/formation/event slot, if they already have a reservation the same day at the same time
- Ability to create and delete periodic calendar availabilities (recurrence)
- Ability to fully customize the home page
diff --git a/app/assets/javascripts/controllers/admin/calendar.js.erb b/app/assets/javascripts/controllers/admin/calendar.js.erb
index 45a32e6ee..45f0fed92 100644
--- a/app/assets/javascripts/controllers/admin/calendar.js.erb
+++ b/app/assets/javascripts/controllers/admin/calendar.js.erb
@@ -18,8 +18,8 @@
* Controller used in the calendar management page
*/
-Application.Controllers.controller('AdminCalendarController', ['$scope', '$state', '$uibModal', 'moment', 'Availability', 'Slot', 'Setting', 'Export', 'growl', 'dialogs', 'bookingWindowStart', 'bookingWindowEnd', 'machinesPromise', '_t', 'uiCalendarConfig', 'CalendarConfig', 'Member', 'uiTourService',
- function ($scope, $state, $uibModal, moment, Availability, Slot, Setting, Export, growl, dialogs, bookingWindowStart, bookingWindowEnd, machinesPromise, _t, uiCalendarConfig, CalendarConfig, Member, uiTourService) {
+Application.Controllers.controller('AdminCalendarController', ['$scope', '$state', '$uibModal', 'moment', 'Availability', 'Slot', 'Setting', 'Export', 'growl', 'dialogs', 'bookingWindowStart', 'bookingWindowEnd', 'machinesPromise', 'plansPromise', 'groupsPromise', '_t', 'uiCalendarConfig', 'CalendarConfig', 'Member', 'uiTourService',
+ function ($scope, $state, $uibModal, moment, Availability, Slot, Setting, Export, growl, dialogs, bookingWindowStart, bookingWindowEnd, machinesPromise, plansPromise, groupsPromise, _t, uiCalendarConfig, CalendarConfig, Member, uiTourService) {
/* PRIVATE STATIC CONSTANTS */
// The calendar is divided in slots of 30 minutes
@@ -164,6 +164,44 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state
}
};
+ /**
+ * Open a confirmation modal to remove a plan for the currently selected availability,
+ * @param plan {Object} must contain the machine ID and name
+ */
+ $scope.removePlan = function (plan) {
+ // open a confirmation dialog
+ return dialogs.confirm({
+ resolve: {
+ object () {
+ return {
+ title: _t('app.admin.calendar.confirmation_required'),
+ msg: _t('app.admin.calendar.do_you_really_want_to_remove_PLAN_from_this_slot', { GENDER: getGender($scope.currentUser), PLAN: plan.name }) + ' ' +
+ _t('app.admin.calendar.this_will_prevent_any_new_reservation_on_this_slot_but_wont_cancel_those_existing') + '
' +
+ _t('app.admin.calendar.beware_this_cannot_be_reverted') + ''
+ };
+ }
+ }
+ }
+ , function () {
+ // the admin has confirmed, remove the plan
+ const plans = _.drop($scope.availability.plan_ids, plan.id);
+
+ return Availability.update({ id: $scope.availability.id }, { availability: { plans_attributes: [{ id: plan.id, _destroy: true }] } }
+ , function (data, status) { // success
+ // update the plan_ids attribute
+ $scope.availability.plan_ids = data.plan_ids;
+ $scope.availability.plans = availabilityPlans();
+ uiCalendarConfig.calendars.calendar.fullCalendar('rerenderEvents');
+ // notify the admin
+ return growl.success(_t('app.admin.calendar.the_plan_was_successfully_removed_from_the_slot'));
+ }
+ , function (data, status) { // failed
+ growl.error(_t('app.admin.calendar.deletion_failed'));
+ }
+ );
+ });
+ };
+
/**
* Callback to alert the admin that the export request was acknowledged and is
* processing right now.
@@ -340,6 +378,26 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state
} else { return 'other'; }
};
+ /**
+ * Return a list of plans classified by group
+ *
+ * @returns {array}
+ */
+ var availabilityPlans = function() {
+ const plansClassifiedByGroup = [];
+ const _plans = _.filter(plansPromise, function (p) { return _.include($scope.availability.plan_ids, p.id) });
+ for (let group of Array.from(groupsPromise)) {
+ const groupObj = { id: group.id, name: group.name, plans: [] };
+ for (let plan of Array.from(_plans)) {
+ if (plan.group_id === group.id) { groupObj.plans.push(plan); }
+ }
+ if (groupObj.plans.length > 0) {
+ plansClassifiedByGroup.push(groupObj);
+ }
+ }
+ return plansClassifiedByGroup;
+ };
+
// Triggered when the admin drag on the agenda to create a new reservable slot.
// @see http://fullcalendar.io/docs/selection/select_callback/
//
@@ -372,7 +430,9 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state
machinesPromise: ['Machine', function (Machine) { return Machine.query().$promise; }],
trainingsPromise: ['Training', function (Training) { return Training.query().$promise; }],
spacesPromise: ['Space', function (Space) { return Space.query().$promise; }],
- tagsPromise: ['Tag', function (Tag) { return Tag.query().$promise; }]
+ tagsPromise: ['Tag', function (Tag) { return Tag.query().$promise; }],
+ plansPromise: ['Plan', function (Plan) { return Plan.query().$promise; }],
+ groupsPromise: ['Group', function (Group) { return Group.query().$promise; }]
} });
// when the modal is closed, we send the slot to the server for saving
modalInstance.result.then(
@@ -389,7 +449,8 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state
borderColor: availability.borderColor,
tag_ids: availability.tag_ids,
tags: availability.tags,
- machine_ids: availability.machine_ids
+ machine_ids: availability.machine_ids,
+ plan_ids: availability.plan_ids
},
true
);
@@ -406,6 +467,7 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state
*/
const calendarEventClickCb = function (event, jsEvent, view) {
$scope.availability = event;
+ $scope.availability.plans = availabilityPlans();
if ($scope.availabilityDom) {
$scope.availabilityDom.classList.remove("fc-selected")
@@ -484,8 +546,8 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state
/**
* Controller used in the slot creation modal window
*/
-Application.Controllers.controller('CreateEventModalController', ['$scope', '$uibModalInstance', '$sce', 'moment', 'start', 'end', 'machinesPromise', 'Availability', 'trainingsPromise', 'spacesPromise', 'tagsPromise', 'growl', '_t',
- function ($scope, $uibModalInstance, $sce, moment, start, end, machinesPromise, Availability, trainingsPromise, spacesPromise, tagsPromise, growl, _t) {
+Application.Controllers.controller('CreateEventModalController', ['$scope', '$uibModalInstance', '$sce', 'moment', 'start', 'end', 'machinesPromise', 'Availability', 'trainingsPromise', 'spacesPromise', 'tagsPromise', 'plansPromise', 'groupsPromise', 'growl', '_t',
+ function ($scope, $uibModalInstance, $sce, moment, start, end, machinesPromise, Availability, trainingsPromise, spacesPromise, tagsPromise, plansPromise, groupsPromise, growl, _t) {
// $uibModal parameter
$scope.start = start;
@@ -504,6 +566,21 @@ Application.Controllers.controller('CreateEventModalController', ['$scope', '$ui
// all tags list
$scope.tags = tagsPromise;
+ $scope.isOnlySubscriptions = false;
+ $scope.selectedPlans = [];
+ $scope.selectedPlansBinding = {};
+ // list of plans, classified by group
+ $scope.plansClassifiedByGroup = [];
+ for (let group of Array.from(groupsPromise)) {
+ const groupObj = { id: group.id, name: group.name, plans: [] };
+ for (let plan of Array.from(plansPromise)) {
+ if (plan.group_id === group.id) { groupObj.plans.push(plan); }
+ }
+ if (groupObj.plans.length > 0) {
+ $scope.plansClassifiedByGroup.push(groupObj);
+ }
+ }
+
// machines associated with the created slot
$scope.selectedMachines = [];
$scope.selectedMachinesBinding = {};
@@ -553,6 +630,9 @@ Application.Controllers.controller('CreateEventModalController', ['$scope', '$ui
// localized name(s) of the selected tag(s)
$scope.tagsName = '';
+ // localized name(s) of the selected plan(s)
+ $scope.plansName = '';
+
// make the duration available for display
$scope.slotDuration = Fablab.slotDuration;
@@ -584,6 +664,34 @@ Application.Controllers.controller('CreateEventModalController', ['$scope', '$ui
}
}
+ /**
+ * Adds or removes the provided plan from the current slot
+ * @param plan {Object}
+ */
+ $scope.toggleSelectPlan = function (plan) {
+ const index = $scope.selectedPlans.indexOf(plan);
+ if (index > -1) {
+ return $scope.selectedPlans.splice(index, 1);
+ } else {
+ return $scope.selectedPlans.push(plan);
+ }
+ };
+
+ /**
+ * Select/unselect all the plans
+ */
+ $scope.toggleAllPlans = function() {
+ const count = $scope.selectedPlans.length;
+ $scope.selectedPlans = [];
+ $scope.selectedPlansBinding = {};
+ if (count == 0) {
+ plansPromise.forEach(function (plan) {
+ $scope.selectedPlans.push(plan);
+ $scope.selectedPlansBinding[plan.id] = true;
+ })
+ }
+ };
+
/**
* Callback for the modal window validation: save the slot and closes the modal
*/
@@ -603,6 +711,9 @@ Application.Controllers.controller('CreateEventModalController', ['$scope', '$ui
if ($scope.availability.is_recurrent) {
$scope.availability.occurrences = $scope.occurrences;
}
+ if ($scope.isOnlySubscriptions && $scope.selectedPlans.length > 0) {
+ $scope.availability.plan_ids = $scope.selectedPlans.map(function (p) { return p.id; });
+ }
return Availability.save(
{ availability: $scope.availability },
function (availability) { $uibModalInstance.close(availability); }
@@ -653,6 +764,14 @@ Application.Controllers.controller('CreateEventModalController', ['$scope', '$ui
$scope.selectedSpace = $scope.spaces[0];
}
+ // when disable is only subscriptions option, reset all selected plans
+ $scope.$watch('isOnlySubscriptions', function(value) {
+ if (!value) {
+ $scope.selectedPlans = [];
+ $scope.selectedPlansBinding = {};
+ }
+ });
+
// When we configure a machine/space availability, do not let the user change the end time, as the total
// time must be dividable by Fablab.slotDuration minutes (base slot duration). For training availabilities, the user
// can configure any duration as it does not matters.
@@ -778,6 +897,9 @@ Application.Controllers.controller('CreateEventModalController', ['$scope', '$ui
return $scope.availability.tag_ids.indexOf(t.id) > -1;
})
$scope.tagsName = localizedList(tags);
+ if ($scope.isOnlySubscriptions && $scope.selectedPlans.length > 0) {
+ $scope.plansName = localizedList($scope.selectedPlans);
+ }
}
const localizedList = function (items) {
diff --git a/app/assets/javascripts/controllers/machines.js.erb b/app/assets/javascripts/controllers/machines.js.erb
index 2f761df11..f21f106ce 100644
--- a/app/assets/javascripts/controllers/machines.js.erb
+++ b/app/assets/javascripts/controllers/machines.js.erb
@@ -450,6 +450,8 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$stat
$scope.settings = settingsPromise;
// list of plans, classified by group
+ $scope.groups = groupsPromise;
+ $scope.plans = plansPromise;
$scope.plansClassifiedByGroup = [];
for (let group of Array.from(groupsPromise)) {
const groupObj = { id: group.id, name: group.name, plans: [] };
diff --git a/app/assets/javascripts/controllers/spaces.js.erb b/app/assets/javascripts/controllers/spaces.js.erb
index aa7550a15..bc0fba8ab 100644
--- a/app/assets/javascripts/controllers/spaces.js.erb
+++ b/app/assets/javascripts/controllers/spaces.js.erb
@@ -328,6 +328,8 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$stateP
// list of plans, classified by group
$scope.plansClassifiedByGroup = [];
+ $scope.groups = groupsPromise;
+ $scope.plans = plansPromise;
for (let group of Array.from(groupsPromise)) {
const groupObj = { id: group.id, name: group.name, plans: [] };
for (let plan of Array.from(plansPromise)) {
diff --git a/app/assets/javascripts/controllers/trainings.js.erb b/app/assets/javascripts/controllers/trainings.js.erb
index 22ed4e5dd..dfecc48af 100644
--- a/app/assets/javascripts/controllers/trainings.js.erb
+++ b/app/assets/javascripts/controllers/trainings.js.erb
@@ -111,6 +111,8 @@ Application.Controllers.controller('ReserveTrainingController', ['$scope', '$sta
{ member: {} };
// list of plans, classified by group
+ $scope.groups = groupsPromise;
+ $scope.plans = plansPromise;
$scope.plansClassifiedByGroup = [];
for (let group of Array.from(groupsPromise)) {
const groupObj = { id: group.id, name: group.name, plans: [] };
diff --git a/app/assets/javascripts/directives/cart.js.erb b/app/assets/javascripts/directives/cart.js.erb
index efafc2763..35546e187 100644
--- a/app/assets/javascripts/directives/cart.js.erb
+++ b/app/assets/javascripts/directives/cart.js.erb
@@ -23,6 +23,8 @@ Application.Directives.directive('cart', [ '$rootScope', '$uibModal', 'dialogs',
plan: '=',
planSelectionTime: '=',
settings: '=',
+ plans: '=',
+ groups: '=',
onSlotAddedToCart: '=',
onSlotRemovedFromCart: '=',
onSlotStartToModify: '=',
@@ -164,22 +166,55 @@ Application.Directives.directive('cart', [ '$rootScope', '$uibModal', 'dialogs',
$scope.payCart = function () {
// first, we check that a user was selected
if (Object.keys($scope.user).length > 0) {
- const reservation = mkReservation($scope.user, $scope.events.reserved, $scope.selectedPlan);
- return Wallet.getWalletByUser({ user_id: $scope.user.id }, function (wallet) {
- const amountToPay = helpers.getAmountToPay($scope.amountTotal, wallet.amount);
- if (!$scope.isAdmin() && (amountToPay > 0)) {
- if ($rootScope.fablabWithoutOnlinePayment) {
- growl.error(_t('app.shared.cart.online_payment_disabled'));
+ // check user was selected a plan if slot is restricted for subscriptions
+ const slotValidations = [];
+ let slotNotValid;
+ let slotNotValidError;
+ $scope.events.reserved.forEach(function (slot) {
+ if (slot.plan_ids.length > 0) {
+ if (
+ ($scope.selectedPlan && _.include(slot.plan_ids, $scope.selectedPlan.id)) ||
+ ($scope.user.subscribed_plan && _.include(slot.plan_ids, $scope.user.subscribed_plan.id))
+ ) {
+ slotValidations.push(true);
} else {
- return payByStripe(reservation);
- }
- } else {
- if ($scope.isAdmin() || (amountToPay === 0)) {
- return payOnSite(reservation);
+ slotNotValid = slot;
+ if ($scope.selectedPlan && !_.include(slot.plan_ids, $scope.selectedPlan.id)) {
+ slotNotValidError = 'selectedPlanError';
+ }
+ if ($scope.user.subscribed_plan && !_.include(slot.plan_ids, $scope.user.subscribed_plan.id)) {
+ slotNotValidError = 'userPlanError';
+ }
+ if (!$scope.selectedPlan || !$scope.user.subscribed_plan) {
+ slotNotValidError = 'noPlanError';
+ }
+ slotValidations.push(false);
}
}
});
+ const hasPlanForSlot = slotValidations.every(function (a) { return a; });
+ if (!hasPlanForSlot) {
+ if (!$scope.isAdmin()) {
+ return growl.error(_t('app.shared.cart.slot_restrict_subscriptions_must_select_plan'));
+ } else {
+ const modalInstance = $uibModal.open({
+ animation: true,
+ templateUrl: '<%= asset_path "shared/_reserve_slot_without_plan.html" %>',
+ size: 'md',
+ controller: 'ReserveSlotWithoutPlanController',
+ resolve: {
+ slot: function() { return slotNotValid; },
+ slotNotValidError: function() { return slotNotValidError; },
+ }
+ });
+ modalInstance.result.then(function(res) {
+ return paySlots();
+ });
+ }
+ } else {
+ return paySlots();
+ }
} else {
// otherwise we alert, this error musn't occur when the current user is not admin
return growl.error(_t('app.shared.cart.please_select_a_member_first'));
@@ -292,6 +327,27 @@ Application.Directives.directive('cart', [ '$rootScope', '$uibModal', 'dialogs',
*/
var slotSelectionChanged = function () {
if ($scope.slot) {
+ // build a list of plans if this slot is restricted for subscriptions
+ if ($scope.slot.plan_ids.length > 0) {
+ const _plans = _.filter($scope.plans, function (p) { return _.include($scope.slot.plan_ids, p.id) });
+ $scope.slot.plansGrouped = [];
+ $scope.slot.group_ids = [];
+ for (let group of Array.from($scope.groups)) {
+ const groupObj = { id: group.id, name: group.name, plans: [] };
+ for (let plan of Array.from(_plans)) {
+ if (plan.group_id === group.id) { groupObj.plans.push(plan); }
+ }
+ if (groupObj.plans.length > 0) {
+ if ($scope.isAdmin()) {
+ $scope.slot.plansGrouped.push(groupObj);
+ } else if ($scope.user.group_id === groupObj.id) {
+ $scope.slot.plansGrouped.push(groupObj);
+ }
+ }
+ }
+ $scope.slot.group_ids = $scope.slot.plansGrouped.map(function(g) { return g.id; });
+ }
+
if (!$scope.slot.is_reserved && !$scope.events.modifiable && !$scope.slot.is_completed) {
// slot is not reserved and we are not currently modifying a slot
// -> can be added to cart or removed if already present
@@ -638,6 +694,28 @@ Application.Directives.directive('cart', [ '$rootScope', '$uibModal', 'dialogs',
return $scope.selectedPlan = null;
};
+ /**
+ * Actions to pay slots
+ */
+ var paySlots = function() {
+ const reservation = mkReservation($scope.user, $scope.events.reserved, $scope.selectedPlan);
+
+ return Wallet.getWalletByUser({ user_id: $scope.user.id }, function (wallet) {
+ const amountToPay = helpers.getAmountToPay($scope.amountTotal, wallet.amount);
+ if (!$scope.isAdmin() && (amountToPay > 0)) {
+ if ($rootScope.fablabWithoutOnlinePayment) {
+ growl.error(_t('app.shared.cart.online_payment_disabled'));
+ } else {
+ return payByStripe(reservation);
+ }
+ } else {
+ if ($scope.isAdmin() || (amountToPay === 0)) {
+ return payOnSite(reservation);
+ }
+ }
+ });
+ };
+
// !!! MUST BE CALLED AT THE END of the directive
return initialize();
}
@@ -666,3 +744,25 @@ Application.Controllers.controller('ReserveSlotSameTimeController', ['$scope', '
}
}
]);
+
+/**
+ * Controller used to alert admin reserve slot without plan
+ */
+Application.Controllers.controller('ReserveSlotWithoutPlanController', ['$scope', '$uibModalInstance', 'slot', 'slotNotValidError', 'growl', '_t',
+ function ($scope, $uibModalInstance, slot, slotNotValidError, growl, _t) {
+ $scope.slot = slot;
+ $scope.slotNotValidError = slotNotValidError;
+ /**
+ * Confirmation callback
+ */
+ $scope.ok = function () {
+ $uibModalInstance.close({});
+ }
+ /**
+ * Cancellation callback
+ */
+ $scope.cancel = function () {
+ $uibModalInstance.dismiss('cancel');
+ }
+ }
+]);
diff --git a/app/assets/javascripts/router.js.erb b/app/assets/javascripts/router.js.erb
index fab0277a1..cb3935ea2 100644
--- a/app/assets/javascripts/router.js.erb
+++ b/app/assets/javascripts/router.js.erb
@@ -584,7 +584,9 @@ angular.module('application.router', ['ui.router'])
resolve: {
bookingWindowStart: ['Setting', function (Setting) { return Setting.get({ name: 'booking_window_start' }).$promise; }],
bookingWindowEnd: ['Setting', function (Setting) { return Setting.get({ name: 'booking_window_end' }).$promise; }],
- machinesPromise: ['Machine', function (Machine) { return Machine.query().$promise; }]
+ machinesPromise: ['Machine', function (Machine) { return Machine.query().$promise; }],
+ plansPromise: ['Plan', function (Plan) { return Plan.query().$promise; }],
+ groupsPromise: ['Group', function (Group) { return Group.query().$promise; }]
}
})
.state('app.admin.calendar.icalendar', {
diff --git a/app/assets/templates/admin/calendar/calendar.html.erb b/app/assets/templates/admin/calendar/calendar.html.erb
index aac772311..fe97354e8 100644
--- a/app/assets/templates/admin/calendar/calendar.html.erb
+++ b/app/assets/templates/admin/calendar/calendar.html.erb
@@ -90,6 +90,25 @@
+
{{ 'app.admin.calendar.restrict_this_slot_for_subscriptions_optional' }}
+{{ 'app.admin.calendar.select_some_plans' | translate }}
+ +{{ 'app.admin.calendar.recurrence' }}