diff --git a/app/controllers/api/availabilities_controller.rb b/app/controllers/api/availabilities_controller.rb index d79a171c8..342f7c537 100644 --- a/app/controllers/api/availabilities_controller.rb +++ b/app/controllers/api/availabilities_controller.rb @@ -11,12 +11,12 @@ class API::AvailabilitiesController < API::ApiController def index authorize Availability display_window = window - @availabilities = Availability.includes(:machines, :tags, :trainings, :spaces) - .where('start_at >= ? AND end_at <= ?', display_window[:start], display_window[:end]) - - @availabilities = @availabilities.where.not(available_type: 'event') unless Setting.get('events_in_calendar') - - @availabilities = @availabilities.where.not(available_type: 'space') unless Setting.get('spaces_module') + service = Availabilities::AvailabilitiesService.new(@current_user, 'availability') + machine_ids = params[:m] || [] + @availabilities = service.index(display_window, + { machines: machine_ids, spaces: params[:s], trainings: params[:t] }, + (params[:evt] && params[:evt] == 'true')) + @availabilities = filter_availabilites(@availabilities) end def public diff --git a/app/controllers/api/machine_categories_controller.rb b/app/controllers/api/machine_categories_controller.rb new file mode 100644 index 000000000..ae9193a8e --- /dev/null +++ b/app/controllers/api/machine_categories_controller.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +# API Controller for resources of type Machine Category +# Categories are used to classify Machine +class API::MachineCategoriesController < API::ApiController + before_action :authenticate_user!, except: [:index] + before_action :set_machine_category, only: %i[show update destroy] + + def index + @machine_categories = MachineCategory.all.order(name: :asc) + end + + def show; end + + def create + authorize MachineCategory + @machine_category = MachineCategory.new(machine_category_params) + if @machine_category.save + render :show, status: :created, location: @category + else + render json: @machine_category.errors, status: :unprocessable_entity + end + end + + def update + authorize MachineCategory + if @machine_category.update(machine_category_params) + render :show, status: :ok, location: @category + else + render json: @machine_category.errors, status: :unprocessable_entity + end + end + + def destroy + authorize MachineCategory + if @machine_category.destroy + head :no_content + else + render json: @machine_category.errors, status: :unprocessable_entity + end + end + + private + + def set_machine_category + @machine_category = MachineCategory.find(params[:id]) + end + + def machine_category_params + params.require(:machine_category).permit(:name, machine_ids: []) + end +end diff --git a/app/controllers/api/machines_controller.rb b/app/controllers/api/machines_controller.rb index 810f08b4a..b739c86cc 100644 --- a/app/controllers/api/machines_controller.rb +++ b/app/controllers/api/machines_controller.rb @@ -49,7 +49,7 @@ class API::MachinesController < API::ApiController end def machine_params - params.require(:machine).permit(:name, :description, :spec, :disabled, :plan_ids, + params.require(:machine).permit(:name, :description, :spec, :disabled, :machine_category_id, :plan_ids, plan_ids: [], machine_image_attributes: [:attachment], machine_files_attributes: %i[id attachment _destroy]) end diff --git a/app/frontend/src/javascript/api/machine-category.ts b/app/frontend/src/javascript/api/machine-category.ts new file mode 100644 index 000000000..96667c62a --- /dev/null +++ b/app/frontend/src/javascript/api/machine-category.ts @@ -0,0 +1,25 @@ +import apiClient from './clients/api-client'; +import { AxiosResponse } from 'axios'; +import { MachineCategory } from '../models/machine-category'; + +export default class MachineCategoryAPI { + static async index (): Promise> { + const res: AxiosResponse> = await apiClient.get('/api/machine_categories'); + return res?.data; + } + + static async create (category: MachineCategory): Promise { + const res: AxiosResponse = await apiClient.post('/api/machine_categories', { machine_category: category }); + return res?.data; + } + + static async update (category: MachineCategory): Promise { + const res: AxiosResponse = await apiClient.patch(`/api/machine_categories/${category.id}`, { machine_category: category }); + return res?.data; + } + + static async destroy (categoryId: number): Promise { + const res: AxiosResponse = await apiClient.delete(`/api/machine_categories/${categoryId}`); + return res?.data; + } +} diff --git a/app/frontend/src/javascript/components/machines/delete-machine-category-modal.tsx b/app/frontend/src/javascript/components/machines/delete-machine-category-modal.tsx new file mode 100644 index 000000000..357c4479d --- /dev/null +++ b/app/frontend/src/javascript/components/machines/delete-machine-category-modal.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { FabModal } from '../base/fab-modal'; +import MachineCategoryAPI from '../../api/machine-category'; + +interface DeleteMachineCategoryModalProps { + isOpen: boolean, + machineCategoryId: number, + toggleModal: () => void, + onSuccess: (message: string) => void, + onError: (message: string) => void, +} + +/** + * Modal dialog to remove a requested machine category + */ +export const DeleteMachineCategoryModal: React.FC = ({ isOpen, toggleModal, onSuccess, machineCategoryId, onError }) => { + const { t } = useTranslation('admin'); + + /** + * The user has confirmed the deletion of the requested machine category + */ + const handleDeleteMachineCategory = async (): Promise => { + try { + await MachineCategoryAPI.destroy(machineCategoryId); + onSuccess(t('app.admin.machines.delete_machine_category_modal.deleted')); + } catch (e) { + onError(t('app.admin.machines.delete_machine_category_modal.unable_to_delete') + e); + } + }; + + return ( + +

{t('app.admin.machines.delete_machine_category_modal.confirm_machine_category')}

+
+ ); +}; diff --git a/app/frontend/src/javascript/components/machines/machine-categories-list.tsx b/app/frontend/src/javascript/components/machines/machine-categories-list.tsx new file mode 100644 index 000000000..408dc588e --- /dev/null +++ b/app/frontend/src/javascript/components/machines/machine-categories-list.tsx @@ -0,0 +1,178 @@ +import React, { useEffect, useState } from 'react'; +import { MachineCategory } from '../../models/machine-category'; +import { Machine } from '../../models/machine'; +import { IApplication } from '../../models/application'; +import { react2angular } from 'react2angular'; +import { Loader } from '../base/loader'; +import MachineCategoryAPI from '../../api/machine-category'; +import MachineAPI from '../../api/machine'; +import { useTranslation } from 'react-i18next'; +import { FabButton } from '../base/fab-button'; +import { MachineCategoryModal } from './machine-category-modal'; +import { DeleteMachineCategoryModal } from './delete-machine-category-modal'; + +declare const Application: IApplication; + +interface MachineCategoriesListProps { + onError: (message: string) => void, + onSuccess: (message: string) => void, +} + +/** + * This component shows a list of all machines and allows filtering on that list. + */ +export const MachineCategoriesList: React.FC = ({ onError, onSuccess }) => { + const { t } = useTranslation('admin'); + + // shown machine categories + const [machineCategories, setMachineCategories] = useState>([]); + // all machines, for assign to category + const [machines, setMachines] = useState>([]); + // creation/edition modal + const [modalIsOpen, setModalIsOpen] = useState(false); + // currently added/edited category + const [machineCategory, setMachineCategory] = useState(null); + // deletion modal + const [destroyModalIsOpen, setDestroyModalIsOpen] = useState(false); + // currently deleted machine category + const [machineCategoryId, setMachineCategoryId] = useState(null); + + // retrieve the full list of machine categories on component mount + useEffect(() => { + MachineCategoryAPI.index() + .then(data => setMachineCategories(data)) + .catch(e => onError(e)); + MachineAPI.index() + .then(data => setMachines(data)) + .catch(e => onError(e)); + }, []); + + /** + * Toggle the modal dialog to create/edit a machine category + */ + const toggleCreateAndEditModal = (): void => { + setModalIsOpen(!modalIsOpen); + }; + + /** + * Callback triggred when the current machine category was successfully saved + */ + const onSaveTypeSuccess = (message: string): void => { + setModalIsOpen(false); + MachineCategoryAPI.index().then(data => { + setMachineCategories(data); + onSuccess(message); + }).catch((error) => { + onError('Unable to load machine categories' + error); + }); + }; + + /** + * Init the process of creating a new machine category + */ + const addMachineCategory = (): void => { + setMachineCategory({} as MachineCategory); + setModalIsOpen(true); + }; + + /** + * Init the process of editing the given machine category + */ + const editMachineCategory = (category: MachineCategory): () => void => { + return (): void => { + setMachineCategory(category); + setModalIsOpen(true); + }; + }; + + /** + * Init the process of deleting a machine category (ask for confirmation) + */ + const destroyMachineCategory = (id: number): () => void => { + return (): void => { + setMachineCategoryId(id); + setDestroyModalIsOpen(true); + }; + }; + + /** + * Open/closes the confirmation before deletion modal + */ + const toggleDestroyModal = (): void => { + setDestroyModalIsOpen(!destroyModalIsOpen); + }; + + /** + * Callback triggred when the current machine category was successfully deleted + */ + const onDestroySuccess = (message: string): void => { + setDestroyModalIsOpen(false); + MachineCategoryAPI.index().then(data => { + setMachineCategories(data); + onSuccess(message); + }).catch((error) => { + onError('Unable to load machine categories' + error); + }); + }; + + return ( +
+

{t('app.admin.machine_categories_list.machine_categories')}

+ {t('app.admin.machine_categories_list.add_a_machine_category')} + + + + + + + + + + + + {machineCategories.map(category => { + return ( + + + + + + ); + })} + +
{t('app.admin.machine_categories_list.name')}{t('app.admin.machine_categories_list.machines_number')}
+ {category.name} + + {category.machine_ids.length} + +
+ + {t('app.admin.machine_categories_list.edit')} + + + + +
+
+
+ ); +}; + +const MachineCategoriesListWrapper: React.FC = (props) => { + return ( + + + + ); +}; + +Application.Components.component('machineCategoriesList', react2angular(MachineCategoriesListWrapper, ['onError', 'onSuccess'])); diff --git a/app/frontend/src/javascript/components/machines/machine-category-form.tsx b/app/frontend/src/javascript/components/machines/machine-category-form.tsx new file mode 100644 index 000000000..e0bccd283 --- /dev/null +++ b/app/frontend/src/javascript/components/machines/machine-category-form.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { SubmitHandler, useForm } from 'react-hook-form'; +import { FormInput } from '../form/form-input'; +import { FormChecklist } from '../form/form-checklist'; +import { MachineCategory } from '../../models/machine-category'; +import { FabButton } from '../base/fab-button'; +import { Machine } from '../../models/machine'; +import { SelectOption } from '../../models/select'; + +interface MachineCategoryFormProps { + machines: Array, + machineCategory?: MachineCategory, + saveMachineCategory: (data: MachineCategory) => void, +} + +/** + * Form to set create/edit machine category + */ +export const MachineCategoryForm: React.FC = ({ machines, machineCategory, saveMachineCategory }) => { + const { t } = useTranslation('admin'); + + const { handleSubmit, register, control, formState } = useForm({ defaultValues: { ...machineCategory } }); + + /** + * Convert all machines to the checklist format + */ + const buildOptions = (): Array> => { + return machines.map(t => { + return { value: t.id, label: t.name }; + }); + }; + + /** + * Callback triggered when the form is submitted: process with the machine category creation or update. + */ + const onSubmit: SubmitHandler = (data: MachineCategory) => { + saveMachineCategory(data); + }; + + return ( +
+
+ +
+

{t('app.admin.machine_category_form.assigning_machines')}

+ +
+
+ + {t('app.admin.machine_category_form.save')} + +
+ +
+ ); +}; diff --git a/app/frontend/src/javascript/components/machines/machine-category-modal.tsx b/app/frontend/src/javascript/components/machines/machine-category-modal.tsx new file mode 100644 index 000000000..024977c1b --- /dev/null +++ b/app/frontend/src/javascript/components/machines/machine-category-modal.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { FabModal, ModalSize } from '../base/fab-modal'; +import { MachineCategory } from '../../models/machine-category'; +import { Machine } from '../../models/machine'; +import MachineCategoryAPI from '../../api/machine-category'; +import { MachineCategoryForm } from './machine-category-form'; + +interface MachineCategoryModalProps { + isOpen: boolean, + toggleModal: () => void, + onSuccess: (message: string) => void, + onError: (message: string) => void, + machines: Array, + machineCategory?: MachineCategory, +} + +/** + * Modal dialog to create/edit a machine category + */ +export const MachineCategoryModal: React.FC = ({ isOpen, toggleModal, onSuccess, onError, machines, machineCategory }) => { + const { t } = useTranslation('admin'); + + /** + * Save the current machine category to the API + */ + const handleSaveMachineCategory = async (data: MachineCategory): Promise => { + try { + if (machineCategory?.id) { + await MachineCategoryAPI.update(data); + onSuccess(t('app.admin.machine_category_modal.successfully_updated')); + } else { + await MachineCategoryAPI.create(data); + onSuccess(t('app.admin.machine_category_modal.successfully_created')); + } + } catch (e) { + if (machineCategory?.id) { + onError(t('app.admin.machine_category_modal.unable_to_update') + e); + } else { + onError(t('app.admin.machine_category_modal.unable_to_create') + e); + } + } + }; + + return ( + + + + ); +}; diff --git a/app/frontend/src/javascript/components/machines/machine-form.tsx b/app/frontend/src/javascript/components/machines/machine-form.tsx index b466f887a..ca5383d28 100644 --- a/app/frontend/src/javascript/components/machines/machine-form.tsx +++ b/app/frontend/src/javascript/components/machines/machine-form.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { SubmitHandler, useForm, useWatch } from 'react-hook-form'; import { Machine } from '../../models/machine'; import MachineAPI from '../../api/machine'; @@ -11,6 +11,10 @@ import { react2angular } from 'react2angular'; import { ErrorBoundary } from '../base/error-boundary'; import { FormRichText } from '../form/form-rich-text'; import { FormSwitch } from '../form/form-switch'; +import { FormSelect } from '../form/form-select'; +import { SelectOption } from '../../models/select'; +import MachineCategoryAPI from '../../api/machine-category'; +import { MachineCategory } from '../../models/machine-category'; declare const Application: IApplication; @@ -29,6 +33,15 @@ export const MachineForm: React.FC = ({ action, machine, onErr const output = useWatch({ control }); const { t } = useTranslation('admin'); + const [machineCategories, setMachineCategories] = useState>([]); + + // retrieve the full list of machine categories on component mount + useEffect(() => { + MachineCategoryAPI.index() + .then(data => setMachineCategories(data)) + .catch(e => onError(e)); + }, []); + /** * Callback triggered when the user validates the machine form: handle create or update */ @@ -40,6 +53,15 @@ export const MachineForm: React.FC = ({ action, machine, onErr }); }; + /** + * Convert all machine categories to the select format + */ + const buildOptions = (): Array> => { + return machineCategories.map(t => { + return { value: t.id, label: t.name }; + }); + }; + return (
= ({ action, machine, onErr label={t('app.admin.machine_form.technical_specifications')} limit={null} heading bulletList blockquote link video image /> + void, + onFilterChangedBy: (type: string, value: number | boolean | void) => void, + machineCategories: Array, } /** * Allows filtering on machines list */ -export const MachinesFilters: React.FC = ({ onStatusSelected }) => { +export const MachinesFilters: React.FC = ({ onFilterChangedBy, machineCategories }) => { const { t } = useTranslation('public'); const defaultValue = { value: true, label: t('app.public.machines_filters.status_enabled') }; + const categoryDefaultValue = { value: null, label: t('app.public.machines_filters.all_machines') }; /** * Provides boolean options in the react-select format (yes/no/all) @@ -26,16 +29,33 @@ export const MachinesFilters: React.FC = ({ onStatusSelect ]; }; + /** + * Provides categories options in the react-select format + */ + const buildCategoriesOptions = (): Array> => { + const options = machineCategories.map(c => { + return { value: c.id, label: c.name }; + }); + return [categoryDefaultValue].concat(options); + }; + /** * Callback triggered when the user selects a machine status in the dropdown list */ const handleStatusSelected = (option: SelectOption): void => { - onStatusSelected(option.value); + onFilterChangedBy('status', option.value); + }; + + /** + * Callback triggered when the user selects a machine category in the dropdown list + */ + const handleCategorySelected = (option: SelectOption): void => { + onFilterChangedBy('category', option.value); }; return (
-
+
+
+ }
); }; diff --git a/app/frontend/src/javascript/components/machines/machines-list.tsx b/app/frontend/src/javascript/components/machines/machines-list.tsx index 0476464cf..971f9bfb3 100644 --- a/app/frontend/src/javascript/components/machines/machines-list.tsx +++ b/app/frontend/src/javascript/components/machines/machines-list.tsx @@ -1,9 +1,11 @@ import React, { useEffect, useState } from 'react'; -import { Machine } from '../../models/machine'; +import { Machine, MachineListFilter } from '../../models/machine'; import { IApplication } from '../../models/application'; import { react2angular } from 'react2angular'; import { Loader } from '../base/loader'; import MachineAPI from '../../api/machine'; +import MachineCategoryAPI from '../../api/machine-category'; +import { MachineCategory } from '../../models/machine-category'; import { MachineCard } from './machine-card'; import { MachinesFilters } from './machines-filters'; import { User } from '../../models/user'; @@ -32,31 +34,65 @@ export const MachinesList: React.FC = ({ onError, onSuccess, const [machines, setMachines] = useState>(null); // we keep the full list of machines, for filtering const [allMachines, setAllMachines] = useState>(null); + // shown machine categories + const [machineCategories, setMachineCategories] = useState>([]); + // machine list filter + const [filter, setFilter] = useState({ + status: true, + category: null + }); // retrieve the full list of machines on component mount useEffect(() => { MachineAPI.index() .then(data => setAllMachines(data)) .catch(e => onError(e)); + MachineCategoryAPI.index() + .then(data => setMachineCategories(data)) + .catch(e => onError(e)); }, []); // filter the machines shown when the full list was retrieved useEffect(() => { - handleFilterByStatus(true); + handleFilter(); }, [allMachines]); - /** - * Callback triggered when the user changes the status filter. - * Set the 'machines' state to a filtered list, depending on the provided parameter. - * @param status, true = enabled machines, false = disabled machines, null = all machines - */ - const handleFilterByStatus = (status: boolean): void => { - if (!allMachines) return; - if (status === null) return setMachines(allMachines); + // filter the machines shown when the filter was changed + useEffect(() => { + handleFilter(); + }, [filter]); - // enabled machines may have the m.disabled property null (for never disabled machines) - // or false (for re-enabled machines) - setMachines(allMachines.filter(m => !!m.disabled === !status)); + /** + * Callback triggered when the user changes the filter. + * filter the machines shown when the filter was changed. + */ + const handleFilter = (): void => { + let machinesFiltered = []; + if (allMachines) { + if (filter.status === null) { + machinesFiltered = allMachines; + } else { + // enabled machines may have the m.disabled property null (for never disabled machines) + // or false (for re-enabled machines) + machinesFiltered = allMachines.filter(m => !!m.disabled === !filter.status); + } + if (filter.category !== null) { + machinesFiltered = machinesFiltered.filter(m => m.machine_category_id === filter.category); + } + } + setMachines(machinesFiltered); + }; + + /** + * Callback triggered when the user changes the filter. + * @param type, status, category + * @param value, status and category value + */ + const handleFilterChangedBy = (type: string, value: number | boolean | void) => { + setFilter({ + ...filter, + [type]: value + }); }; /** @@ -69,7 +105,7 @@ export const MachinesList: React.FC = ({ onError, onSuccess, // TODO: Conditionally display the store ad return (
- +
{false &&
linkToStore}> diff --git a/app/frontend/src/javascript/controllers/admin/calendar.js b/app/frontend/src/javascript/controllers/admin/calendar.js index eef4effdd..dc0c97eaf 100644 --- a/app/frontend/src/javascript/controllers/admin/calendar.js +++ b/app/frontend/src/javascript/controllers/admin/calendar.js @@ -18,9 +18,15 @@ * Controller used in the calendar management page */ -Application.Controllers.controller('AdminCalendarController', ['$scope', '$state', '$uibModal', 'moment', 'AuthService', 'Availability', 'SlotsReservation', 'Setting', 'Export', 'growl', 'dialogs', 'bookingWindowStart', 'bookingWindowEnd', 'machinesPromise', 'plansPromise', 'groupsPromise', 'settingsPromise', '_t', 'uiCalendarConfig', 'CalendarConfig', 'Member', 'uiTourService', - function ($scope, $state, $uibModal, moment, AuthService, Availability, SlotsReservation, Setting, Export, growl, dialogs, bookingWindowStart, bookingWindowEnd, machinesPromise, plansPromise, groupsPromise, settingsPromise, _t, uiCalendarConfig, CalendarConfig, Member, uiTourService) { +Application.Controllers.controller('AdminCalendarController', ['$scope', '$state', '$uibModal', 'moment', 'AuthService', 'Availability', 'SlotsReservation', 'Setting', 'Export', 'growl', 'dialogs', 'bookingWindowStart', 'bookingWindowEnd', 'machinesPromise', 'plansPromise', 'groupsPromise', 'settingsPromise', '_t', 'uiCalendarConfig', 'CalendarConfig', 'Member', 'uiTourService', 'trainingsPromise', 'spacesPromise', 'machineCategoriesPromise', '$aside', + function ($scope, $state, $uibModal, moment, AuthService, Availability, SlotsReservation, Setting, Export, growl, dialogs, bookingWindowStart, bookingWindowEnd, machinesPromise, plansPromise, groupsPromise, settingsPromise, _t, uiCalendarConfig, CalendarConfig, Member, uiTourService, trainingsPromise, spacesPromise, machineCategoriesPromise, $aside) { /* PRIVATE STATIC CONSTANTS */ + machinesPromise.forEach(m => m.checked = true); + trainingsPromise.forEach(t => t.checked = true); + spacesPromise.forEach(s => s.checked = true); + + // check all formation/machine is select in filter + const isSelectAll = (type, scope) => scope[type].length === scope[type].filter(t => t.checked).length; // The calendar is divided in slots of 30 minutes const BASE_SLOT = '00:30:00'; @@ -33,9 +39,21 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state /* PUBLIC SCOPE */ + // List of trainings + $scope.trainings = trainingsPromise.filter(t => !t.disabled); + // list of the FabLab machines $scope.machines = machinesPromise; + // List of machine categories + $scope.machineCategories = machineCategoriesPromise; + + // List of machines group by category + $scope.machinesGroupByCategory = []; + + // List of spaces + $scope.spaces = spacesPromise.filter(t => !t.disabled); + // currently selected availability $scope.availability = null; @@ -45,12 +63,6 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state // Should we show the scheduled events in the calendar? $scope.eventsInCalendar = (settingsPromise.events_in_calendar === 'true'); - // bind the availabilities slots with full-Calendar events - $scope.eventSources = [{ - url: '/api/availabilities', - textColor: 'black' - }]; - // fullCalendar (v2) configuration $scope.calendarConfig = CalendarConfig({ slotDuration: BASE_SLOT, @@ -356,12 +368,132 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state } }; + // filter availabilities if have change + $scope.filterAvailabilities = function (filter, scope) { + if (!scope) { scope = $scope; } + scope.filter = ($scope.filter = { + trainings: isSelectAll('trainings', scope), + machines: isSelectAll('machines', scope), + spaces: isSelectAll('spaces', scope), + evt: filter.evt, + dispo: filter.dispo + }); + scope.machinesGroupByCategory.forEach(c => c.checked = _.every(c.machines, 'checked')); + // remove all + $scope.eventSources.splice(0, $scope.eventSources.length); + // recreate source for trainings/machines/events with new filters + $scope.eventSources.push({ + url: availabilitySourceUrl(), + textColor: 'black' + }); + uiCalendarConfig.calendars.calendar.fullCalendar('refetchEvents'); + }; + + // a variable for formation/machine/event/dispo checkbox is or not checked + $scope.filter = { + trainings: isSelectAll('trainings', $scope), + machines: isSelectAll('machines', $scope), + spaces: isSelectAll('spaces', $scope), + evt: true, + dispo: true + }; + + // toggle to select all formation/machine + $scope.toggleFilter = function (type, filter, machineCategoryId) { + if (type === 'machineCategory') { + const category = _.find($scope.machinesGroupByCategory, (c) => (c.id).toString() === machineCategoryId); + if (category) { + category.machines.forEach(m => m.checked = category.checked); + } + filter.machines = isSelectAll('machines', $scope); + } else { + $scope[type].forEach(t => t.checked = filter[type]); + if (type === 'machines') { + $scope.machinesGroupByCategory.forEach(t => t.checked = filter[type]); + } + } + $scope.filterAvailabilities(filter, $scope); + }; + + $scope.openFilterAside = () => + $aside.open({ + templateUrl: '/calendar/filterAside.html', + placement: 'right', + size: 'md', + backdrop: false, + resolve: { + trainings () { + return $scope.trainings; + }, + machines () { + return $scope.machines; + }, + machinesGroupByCategory () { + return $scope.machinesGroupByCategory; + }, + spaces () { + return $scope.spaces; + }, + filter () { + return $scope.filter; + }, + toggleFilter () { + return $scope.toggleFilter; + }, + filterAvailabilities () { + return $scope.filterAvailabilities; + } + }, + controller: ['$scope', '$uibModalInstance', 'trainings', 'machines', 'machinesGroupByCategory', 'spaces', 'filter', 'toggleFilter', 'filterAvailabilities', function ($scope, $uibModalInstance, trainings, machines, machinesGroupByCategory, spaces, filter, toggleFilter, filterAvailabilities) { + $scope.trainings = trainings; + $scope.machines = machines; + $scope.machinesGroupByCategory = machinesGroupByCategory; + $scope.hasMachineCategory = _.some(machines, 'machine_category_id'); + $scope.spaces = spaces; + $scope.filter = filter; + + $scope.toggleFilter = (type, filter, machineCategoryId) => toggleFilter(type, filter, machineCategoryId); + + $scope.filterAvailabilities = filter => filterAvailabilities(filter, $scope); + + return $scope.close = function (e) { + $uibModalInstance.dismiss(); + return e.stopPropagation(); + }; + }] + }); + /* PRIVATE SCOPE */ + const getFilter = function () { + const t = $scope.trainings.filter(t => t.checked).map(t => t.id); + const m = $scope.machines.filter(m => m.checked).map(m => m.id); + const s = $scope.spaces.filter(s => s.checked).map(s => s.id); + return { t, m, s, evt: $scope.filter.evt, dispo: $scope.filter.dispo }; + }; + + const availabilitySourceUrl = () => `/api/availabilities?${$.param(getFilter())}`; /** * Kind of constructor: these actions will be realized first when the controller is loaded */ - const initialize = function () {}; + const initialize = function () { + // bind the availabilities slots with full-Calendar events + $scope.eventSources = [{ + url: availabilitySourceUrl(), + textColor: 'black' + }]; + // group machines by category + _.forIn(_.groupBy($scope.machines, 'machine_category_id'), (ms, categoryId) => { + const category = _.find($scope.machineCategories, (c) => (c.id).toString() === categoryId); + $scope.machinesGroupByCategory.push({ + id: categoryId, + name: category ? category.name : _t('app.shared.machine.machine_uncategorized'), + checked: true, + machine_ids: category ? category.machine_ids : [], + machines: ms + }); + }); + }; /** * Return an enumerable meaninful string for the gender of the provider user diff --git a/app/frontend/src/javascript/controllers/admin/machines.js b/app/frontend/src/javascript/controllers/admin/machines.js new file mode 100644 index 000000000..c60686e5b --- /dev/null +++ b/app/frontend/src/javascript/controllers/admin/machines.js @@ -0,0 +1,152 @@ +/* eslint-disable + no-return-assign, + no-undef, +*/ +'use strict'; + +Application.Controllers.controller('AdminMachinesController', ['$scope', 'CSRF', 'growl', '$state', '_t', 'AuthService', 'settingsPromise', 'Member', 'uiTourService', 'machinesPromise', 'helpers', + function ($scope, CSRF, growl, $state, _t, AuthService, settingsPromise, Member, uiTourService, machinesPromise, helpers) { + /* PUBLIC SCOPE */ + + // default tab: machines list + $scope.tabs = { active: 0 }; + + // the application global settings + $scope.settings = settingsPromise; + + /** + * Redirect the user to the machine details page + */ + $scope.showMachine = function (machine) { $state.go('app.public.machines_show', { id: machine.slug }); }; + + /** + * Shows an error message forwarded from a child component + */ + $scope.onError = function (message) { + growl.error(message); + }; + + /** + * Shows a success message forwarded from a child react components + */ + $scope.onSuccess = function (message) { + growl.success(message); + }; + + /** + * Open the modal dialog to log the user and resolves the returned promise when the logging process + * was successfully completed. + */ + $scope.onLoginRequest = function (e) { + return new Promise((resolve, _reject) => { + $scope.login(e, resolve); + }); + }; + + /** + * Redirect the user to the training reservation page + */ + $scope.onEnrollRequest = function (trainingId) { + $state.go('app.logged.trainings_reserve', { id: trainingId }); + }; + + /** + * Callback to book a reservation for the current machine + */ + $scope.reserveMachine = function (machine) { + $state.go('app.logged.machines_reserve', { id: machine.slug }); + }; + + $scope.canProposePacks = function () { + return AuthService.isAuthorized(['admin', 'manager']) || !helpers.isUserValidationRequired($scope.settings, 'pack') || (helpers.isUserValidationRequired($scope.settings, 'pack') && helpers.isUserValidated($scope.currentUser)); + }; + + /** + * Setup the feature-tour for the machines page. (admins only) + * This is intended as a contextual help (when pressing F1) + */ + $scope.setupMachinesTour = function () { + // setup the tour for admins only + if (AuthService.isAuthorized(['admin', 'manager'])) { + // get the tour defined by the ui-tour directive + const uitour = uiTourService.getTourByName('machines'); + if (AuthService.isAuthorized('admin')) { + uitour.createStep({ + selector: 'body', + stepId: 'welcome', + order: 0, + title: _t('app.public.tour.machines.welcome.title'), + content: _t('app.public.tour.machines.welcome.content'), + placement: 'bottom', + orphan: true + }); + if (machinesPromise.length > 0) { + uitour.createStep({ + selector: '.machines-list .show-button', + stepId: 'view', + order: 1, + title: _t('app.public.tour.machines.view.title'), + content: _t('app.public.tour.machines.view.content'), + placement: 'top' + }); + } + } else { + uitour.createStep({ + selector: 'body', + stepId: 'welcome_manager', + order: 0, + title: _t('app.public.tour.machines.welcome_manager.title'), + content: _t('app.public.tour.machines.welcome_manager.content'), + placement: 'bottom', + orphan: true + }); + } + if (machinesPromise.length > 0) { + uitour.createStep({ + selector: '.machines-list .reserve-button', + stepId: 'reserve', + order: 2, + title: _t('app.public.tour.machines.reserve.title'), + content: _t('app.public.tour.machines.reserve.content'), + placement: 'top' + }); + } + uitour.createStep({ + selector: 'body', + stepId: 'conclusion', + order: 3, + title: _t('app.public.tour.conclusion.title'), + content: _t('app.public.tour.conclusion.content'), + placement: 'bottom', + orphan: true + }); + // on tour end, save the status in database + uitour.on('ended', function () { + if (uitour.getStatus() === uitour.Status.ON && $scope.currentUser.profile_attributes.tours.indexOf('machines') < 0) { + Member.completeTour({ id: $scope.currentUser.id }, { tour: 'machines' }, function (res) { + $scope.currentUser.profile_attributes.tours = res.tours; + }); + } + }); + // if the user has never seen the tour, show him now + if (settingsPromise.feature_tour_display !== 'manual' && $scope.currentUser.profile_attributes.tours.indexOf('machines') < 0) { + uitour.start(); + } + } + }; + + /* PRIVATE SCOPE */ + + /** + * Kind of constructor: these actions will be realized first when the controller is loaded + */ + const initialize = function () { + // set the authenticity tokens in the forms + CSRF.setMetaTags(); + }; + + // init the controller (call at the end !) + return initialize(); + } + +]); diff --git a/app/frontend/src/javascript/controllers/calendar.js b/app/frontend/src/javascript/controllers/calendar.js index 3d1a1fa59..b153235bd 100644 --- a/app/frontend/src/javascript/controllers/calendar.js +++ b/app/frontend/src/javascript/controllers/calendar.js @@ -16,8 +16,8 @@ * Controller used in the public calendar global */ -Application.Controllers.controller('CalendarController', ['$scope', '$state', '$aside', 'moment', 'Availability', 'Setting', 'growl', 'dialogs', 'bookingWindowStart', 'bookingWindowEnd', '_t', 'uiCalendarConfig', 'CalendarConfig', 'trainingsPromise', 'machinesPromise', 'spacesPromise', 'iCalendarPromise', - function ($scope, $state, $aside, moment, Availability, Setting, growl, dialogs, bookingWindowStart, bookingWindowEnd, _t, uiCalendarConfig, CalendarConfig, trainingsPromise, machinesPromise, spacesPromise, iCalendarPromise) { +Application.Controllers.controller('CalendarController', ['$scope', '$state', '$aside', 'moment', 'Availability', 'Setting', 'growl', 'dialogs', 'bookingWindowStart', 'bookingWindowEnd', '_t', 'uiCalendarConfig', 'CalendarConfig', 'trainingsPromise', 'machinesPromise', 'spacesPromise', 'iCalendarPromise', 'machineCategoriesPromise', + function ($scope, $state, $aside, moment, Availability, Setting, growl, dialogs, bookingWindowStart, bookingWindowEnd, _t, uiCalendarConfig, CalendarConfig, trainingsPromise, machinesPromise, spacesPromise, iCalendarPromise, machineCategoriesPromise) { /* PRIVATE STATIC CONSTANTS */ let currentMachineEvent = null; machinesPromise.forEach(m => m.checked = true); @@ -35,6 +35,12 @@ Application.Controllers.controller('CalendarController', ['$scope', '$state', '$ // List of machines $scope.machines = machinesPromise.filter(t => !t.disabled); + // List of machine categories + $scope.machineCategories = machineCategoriesPromise; + + // List of machines group by category + $scope.machinesGroupByCategory = []; + // List of spaces $scope.spaces = spacesPromise.filter(t => !t.disabled); @@ -55,6 +61,7 @@ Application.Controllers.controller('CalendarController', ['$scope', '$state', '$ evt: filter.evt, dispo: filter.dispo }); + scope.machinesGroupByCategory.forEach(c => c.checked = _.every(c.machines, 'checked')); // remove all $scope.eventSources.splice(0, $scope.eventSources.length); // recreate source for trainings/machines/events with new filters @@ -104,8 +111,19 @@ Application.Controllers.controller('CalendarController', ['$scope', '$state', '$ }; // toggle to select all formation/machine - $scope.toggleFilter = function (type, filter) { - $scope[type].forEach(t => t.checked = filter[type]); + $scope.toggleFilter = function (type, filter, machineCategoryId) { + if (type === 'machineCategory') { + const category = _.find($scope.machinesGroupByCategory, (c) => (c.id).toString() === machineCategoryId); + if (category) { + category.machines.forEach(m => m.checked = category.checked); + } + filter.machines = isSelectAll('machines', $scope); + } else { + $scope[type].forEach(t => t.checked = filter[type]); + if (type === 'machines') { + $scope.machinesGroupByCategory.forEach(t => t.checked = filter[type]); + } + } $scope.filterAvailabilities(filter, $scope); }; @@ -122,6 +140,9 @@ Application.Controllers.controller('CalendarController', ['$scope', '$state', '$ machines () { return $scope.machines; }, + machinesGroupByCategory () { + return $scope.machinesGroupByCategory; + }, spaces () { return $scope.spaces; }, @@ -138,14 +159,16 @@ Application.Controllers.controller('CalendarController', ['$scope', '$state', '$ return $scope.filterAvailabilities; } }, - controller: ['$scope', '$uibModalInstance', 'trainings', 'machines', 'spaces', 'externals', 'filter', 'toggleFilter', 'filterAvailabilities', function ($scope, $uibModalInstance, trainings, machines, spaces, externals, filter, toggleFilter, filterAvailabilities) { + controller: ['$scope', '$uibModalInstance', 'trainings', 'machines', 'machinesGroupByCategory', 'spaces', 'externals', 'filter', 'toggleFilter', 'filterAvailabilities', function ($scope, $uibModalInstance, trainings, machines, machinesGroupByCategory, spaces, externals, filter, toggleFilter, filterAvailabilities) { $scope.trainings = trainings; $scope.machines = machines; + $scope.machinesGroupByCategory = machinesGroupByCategory; + $scope.hasMachineCategory = _.some(machines, 'machine_category_id'); $scope.spaces = spaces; $scope.externals = externals; $scope.filter = filter; - $scope.toggleFilter = (type, filter) => toggleFilter(type, filter); + $scope.toggleFilter = (type, filter, machineCategoryId) => toggleFilter(type, filter, machineCategoryId); $scope.filterAvailabilities = filter => filterAvailabilities(filter, $scope); @@ -196,6 +219,18 @@ Application.Controllers.controller('CalendarController', ['$scope', '$state', '$ }); } }); + + // group machines by category + _.forIn(_.groupBy($scope.machines, 'machine_category_id'), (ms, categoryId) => { + const category = _.find($scope.machineCategories, (c) => (c.id).toString() === categoryId); + $scope.machinesGroupByCategory.push({ + id: categoryId, + name: category ? category.name : _t('app.shared.machine.machine_uncategorized'), + checked: true, + machine_ids: category ? category.machine_ids : [], + machines: ms + }); + }); }; /** diff --git a/app/frontend/src/javascript/controllers/machines.js.erb b/app/frontend/src/javascript/controllers/machines.js.erb index 2783f7c98..8bd123cf0 100644 --- a/app/frontend/src/javascript/controllers/machines.js.erb +++ b/app/frontend/src/javascript/controllers/machines.js.erb @@ -252,8 +252,8 @@ Application.Controllers.controller('NewMachineController', ['$scope', '$state', /** * Controller used in the machine edition page (admin) */ -Application.Controllers.controller('EditMachineController', ['$scope', '$state', '$transition$', 'machinePromise', 'CSRF', - function ($scope, $state, $transition$, machinePromise, CSRF) { +Application.Controllers.controller('EditMachineController', ['$scope', '$state', '$transition$', 'machinePromise', 'machineCategoriesPromise', 'CSRF', + function ($scope, $state, $transition$, machinePromise, machineCategoriesPromise, CSRF) { /* PUBLIC SCOPE */ // API URL where the form will be posted @@ -265,6 +265,9 @@ Application.Controllers.controller('EditMachineController', ['$scope', '$state', // Retrieve the details for the machine id in the URL, if an error occurs redirect the user to the machines list $scope.machine = cleanMachine(machinePromise); + // Retrieve all machine categories + $scope.machineCategories = machineCategoriesPromise; + /** * Shows an error message forwarded from a child component */ diff --git a/app/frontend/src/javascript/controllers/main_nav.js b/app/frontend/src/javascript/controllers/main_nav.js index ed404f50e..e28e27e8f 100644 --- a/app/frontend/src/javascript/controllers/main_nav.js +++ b/app/frontend/src/javascript/controllers/main_nav.js @@ -99,7 +99,7 @@ Application.Controllers.controller('MainNavController', ['$scope', 'settingsProm authorizedRoles: ['admin', 'manager'] }, $scope.$root.modules.machines && { - state: 'app.public.machines_list', + state: 'app.admin.machines_list', linkText: 'app.public.common.manage_the_machines', linkIcon: 'cogs', authorizedRoles: ['admin', 'manager'] diff --git a/app/frontend/src/javascript/models/machine-category.ts b/app/frontend/src/javascript/models/machine-category.ts new file mode 100644 index 000000000..6cb82a779 --- /dev/null +++ b/app/frontend/src/javascript/models/machine-category.ts @@ -0,0 +1,5 @@ +export interface MachineCategory { + id?: number, + name: string, + machine_ids: Array, +} diff --git a/app/frontend/src/javascript/models/machine.ts b/app/frontend/src/javascript/models/machine.ts index da506c0d7..0336d5186 100644 --- a/app/frontend/src/javascript/models/machine.ts +++ b/app/frontend/src/javascript/models/machine.ts @@ -5,6 +5,11 @@ export interface MachineIndexFilter extends ApiFilter { disabled: boolean, } +export interface MachineListFilter { + status: boolean | void, + category: number | void, +} + export interface Machine { id?: number, name: string, @@ -31,5 +36,6 @@ export interface Machine { id: number, name: string, slug: string, - }> + }>, + machine_category_id: number | void } diff --git a/app/frontend/src/javascript/router.js b/app/frontend/src/javascript/router.js index be9bafc6b..d7457b859 100644 --- a/app/frontend/src/javascript/router.js +++ b/app/frontend/src/javascript/router.js @@ -359,6 +359,20 @@ angular.module('application.router', ['ui.router']) settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['feature_tour_display', 'user_validation_required', 'user_validation_required_list']" }).$promise; }] } }) + .state('app.admin.machines_list', { + url: '/admin/machines', + abstract: !Fablab.machinesModule, + views: { + 'main@': { + templateUrl: '/admin/machines/index.html', + controller: 'AdminMachinesController' + } + }, + resolve: { + machinesPromise: ['Machine', function (Machine) { return Machine.query().$promise; }], + settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['feature_tour_display', 'user_validation_required', 'user_validation_required_list']" }).$promise; }] + } + }) .state('app.admin.machines_new', { url: '/machines/new', abstract: !Fablab.machinesModule, @@ -414,7 +428,8 @@ angular.module('application.router', ['ui.router']) } }, resolve: { - machinePromise: ['Machine', '$transition$', function (Machine, $transition$) { return Machine.get({ id: $transition$.params().id }).$promise; }] + machinePromise: ['Machine', '$transition$', function (Machine, $transition$) { return Machine.get({ id: $transition$.params().id }).$promise; }], + machineCategoriesPromise: ['MachineCategory', function (MachineCategory) { return MachineCategory.query().$promise; }] } }) @@ -620,7 +635,8 @@ angular.module('application.router', ['ui.router']) trainingsPromise: ['Training', function (Training) { return Training.query().$promise; }], machinesPromise: ['Machine', function (Machine) { return Machine.query().$promise; }], spacesPromise: ['Space', function (Space) { return Space.query().$promise; }], - iCalendarPromise: ['ICalendar', function (ICalendar) { return ICalendar.query().$promise; }] + iCalendarPromise: ['ICalendar', function (ICalendar) { return ICalendar.query().$promise; }], + machineCategoriesPromise: ['MachineCategory', function (MachineCategory) { return MachineCategory.query().$promise; }] } }) @@ -687,7 +703,10 @@ angular.module('application.router', ['ui.router']) machinesPromise: ['Machine', function (Machine) { return Machine.query().$promise; }], plansPromise: ['Plan', function (Plan) { return Plan.query().$promise; }], groupsPromise: ['Group', function (Group) { return Group.query().$promise; }], - settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['slot_duration', 'events_in_calendar', 'feature_tour_display']" }).$promise; }] + settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['slot_duration', 'events_in_calendar', 'feature_tour_display']" }).$promise; }], + trainingsPromise: ['Training', function (Training) { return Training.query().$promise; }], + spacesPromise: ['Space', function (Space) { return Space.query().$promise; }], + machineCategoriesPromise: ['MachineCategory', function (MachineCategory) { return MachineCategory.query().$promise; }] } }) .state('app.admin.calendar.icalendar', { diff --git a/app/frontend/src/javascript/services/machine_category.js b/app/frontend/src/javascript/services/machine_category.js new file mode 100644 index 000000000..3ee7c51c2 --- /dev/null +++ b/app/frontend/src/javascript/services/machine_category.js @@ -0,0 +1,11 @@ +'use strict'; + +Application.Services.factory('MachineCategory', ['$resource', function ($resource) { + return $resource('/api/machine_categories/:id', + { id: '@id' }, { + update: { + method: 'PUT' + } + } + ); +}]); diff --git a/app/frontend/src/stylesheets/app.utilities.scss b/app/frontend/src/stylesheets/app.utilities.scss index 0dc97deec..d2b4c58cc 100644 --- a/app/frontend/src/stylesheets/app.utilities.scss +++ b/app/frontend/src/stylesheets/app.utilities.scss @@ -458,6 +458,11 @@ p, .widget p { .p-l { padding: 16px; } + +.p-l-sm { + padding-left: 10px; +} + .p-h-0 { padding-left: 0; padding-right: 0; diff --git a/app/frontend/src/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index 19126124e..0f7fdb0fa 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -55,6 +55,7 @@ @import "modules/machines/machines-filters"; @import "modules/machines/machines-list"; @import "modules/machines/required-training-modal"; +@import "modules/machines/machine-categories"; @import "modules/payment-schedule/payment-schedule-dashboard"; @import "modules/payment-schedule/payment-schedule-summary"; @import "modules/payment-schedule/payment-schedules-list"; diff --git a/app/frontend/src/stylesheets/modules/calendar/calendar.scss b/app/frontend/src/stylesheets/modules/calendar/calendar.scss index a05541bb0..0161ab9dd 100644 --- a/app/frontend/src/stylesheets/modules/calendar/calendar.scss +++ b/app/frontend/src/stylesheets/modules/calendar/calendar.scss @@ -7,6 +7,9 @@ justify-content: space-between; align-items: center; flex-wrap: wrap-reverse; + .calendar-actions { + display: flex; + } } &-info { display: contents; diff --git a/app/frontend/src/stylesheets/modules/machines/machine-categories.scss b/app/frontend/src/stylesheets/modules/machines/machine-categories.scss new file mode 100644 index 000000000..5cbe40062 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/machines/machine-categories.scss @@ -0,0 +1,61 @@ +.machine-categories-list { + .buttons { + display: flex; + justify-content: flex-end; + align-items: center; + button { + border-radius: 5; + &:hover { opacity: 0.75; } + } + .edit-btn { + color: var(--gray-hard-darkest); + margin-right: 10px; + } + .delete-btn { + color: var(--gray-soft-lightest); + background: var(--main); + } + } + + .machine-categories-table { + width: 100%; + max-width: 100%; + margin-bottom: 24px; + border-collapse: collapse; + border-spacing: 0; + & thead:first-child > tr:first-child > th { + border-top: 0; + } + & thead > tr > th { + vertical-align: bottom; + border-bottom: 2px solid #ddd; + } + & th { + text-align: left; + } + & thead > tr > th, & tbody > tr > td { + padding: 8px; + line-height: 1.5; + vertical-align: top; + border-top: 1px solid #ddd; + } + } +} + +.machine-category-form { + .form-checklist { + .actions { + align-self: flex-start; + margin: 1rem 0; + } + .checklist { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 0.3rem 3.2rem; + } + } + + .main-actions { + align-self: flex-end; + } +} diff --git a/app/frontend/src/stylesheets/modules/machines/machines-filters.scss b/app/frontend/src/stylesheets/modules/machines/machines-filters.scss index 575b236cd..7f5e7a1e5 100644 --- a/app/frontend/src/stylesheets/modules/machines/machines-filters.scss +++ b/app/frontend/src/stylesheets/modules/machines/machines-filters.scss @@ -1,9 +1,14 @@ .machines-filters { - margin: 1.5em; + margin: 1.5em 0; + display: flex; + justify-content: space-between; - .status-filter { + .filter-item { + &:first-child { + padding-right: 20px; + } & { - display: inline-flex; + display: block; width: 50%; } & > label { @@ -13,18 +18,18 @@ & > * { display: inline-block; } - .status-select { + .status-select, .category-select { width: 100%; - margin-left: 10px; } } } @media screen and (max-width: 720px){ .machines-filters { - .status-filter { - padding-right: 0; - display: inline-block; + display: block; + .filter-item { + padding-right: 0 !important; + display: block; width: 100%; } } diff --git a/app/frontend/src/stylesheets/modules/machines/machines-list.scss b/app/frontend/src/stylesheets/modules/machines/machines-list.scss index 3d93c80ca..18a368a22 100644 --- a/app/frontend/src/stylesheets/modules/machines/machines-list.scss +++ b/app/frontend/src/stylesheets/modules/machines/machines-list.scss @@ -1,9 +1,9 @@ -.machines-list { +.machines-list { .all-machines { max-width: 1600px; margin: 0 auto; display: grid; - grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 3.2rem; .store-ad { diff --git a/app/frontend/templates/admin/calendar/calendar.html b/app/frontend/templates/admin/calendar/calendar.html index f7a132f7d..4bf33cb26 100644 --- a/app/frontend/templates/admin/calendar/calendar.html +++ b/app/frontend/templates/admin/calendar/calendar.html @@ -41,17 +41,22 @@ {{ 'app.admin.calendar.events' }}
-
diff --git a/app/frontend/templates/admin/machines/categories.html b/app/frontend/templates/admin/machines/categories.html new file mode 100644 index 000000000..e3d87aab1 --- /dev/null +++ b/app/frontend/templates/admin/machines/categories.html @@ -0,0 +1,7 @@ +
+ + +
diff --git a/app/frontend/templates/admin/machines/index.html b/app/frontend/templates/admin/machines/index.html new file mode 100644 index 000000000..3366b70ac --- /dev/null +++ b/app/frontend/templates/admin/machines/index.html @@ -0,0 +1,33 @@ +
+
+ +
+ +
+

{{ 'app.admin.machines.the_fablab_s_machines' }}

+
+
+ +
+
+ +
+ + + +
+ +
+
+ + +
+ +
+
+ +
+
+ +
+
diff --git a/app/frontend/templates/admin/machines/machines.html b/app/frontend/templates/admin/machines/machines.html new file mode 100644 index 000000000..1d59dd4de --- /dev/null +++ b/app/frontend/templates/admin/machines/machines.html @@ -0,0 +1,19 @@ +
+ + + + +
diff --git a/app/frontend/templates/calendar/calendar.html b/app/frontend/templates/calendar/calendar.html index 597e83557..1505badd6 100644 --- a/app/frontend/templates/calendar/calendar.html +++ b/app/frontend/templates/calendar/calendar.html @@ -14,7 +14,7 @@
diff --git a/app/frontend/templates/calendar/filter.html b/app/frontend/templates/calendar/filter.html index f1a9f61b4..eafbd6044 100644 --- a/app/frontend/templates/calendar/filter.html +++ b/app/frontend/templates/calendar/filter.html @@ -1,6 +1,10 @@ +
+

{{ 'app.shared.calendar.show_unavailables' }}

+ +
-

{{ 'app.public.calendar.trainings' }}

+

{{ 'app.shared.calendar.trainings' }}

@@ -10,17 +14,27 @@
-

{{ 'app.public.calendar.machines' }}

+

{{ 'app.shared.calendar.machines' }}

-
+
{{::m.name}}
+
+ {{::category.name}} + +
+
+ {{::m.name}} + +
+
+
-

{{ 'app.public.calendar.spaces' }}

+

{{ 'app.shared.calendar.spaces' }}

@@ -29,16 +43,12 @@
-

{{ 'app.public.calendar.events' }}

+

{{ 'app.shared.calendar.events' }}

-
-

{{ 'app.public.calendar.show_unavailables' }}

- -
-
+
-

{{ 'app.public.calendar.externals' }}

+

{{ 'app.shared.calendar.externals' }}

diff --git a/app/frontend/templates/machines/_form.html b/app/frontend/templates/machines/_form.html index 76cf91140..367f77315 100644 --- a/app/frontend/templates/machines/_form.html +++ b/app/frontend/templates/machines/_form.html @@ -89,6 +89,27 @@
+
+ +
+ + {{ 'app.shared.machine.assigning_machine_to_category_info_html' }} + + {{ 'app.shared.machine.linking_machine_to_category' }} + + + + + + + + + +
+
+
diff --git a/app/models/machine.rb b/app/models/machine.rb index 0abd5e1c3..53ff9ed2b 100644 --- a/app/models/machine.rb +++ b/app/models/machine.rb @@ -34,6 +34,8 @@ class Machine < ApplicationRecord has_many :machines_products, dependent: :destroy has_many :products, through: :machines_products + belongs_to :category + after_create :create_statistic_subtype after_create :create_machine_prices after_create :update_gateway_product diff --git a/app/models/machine_category.rb b/app/models/machine_category.rb new file mode 100644 index 000000000..9fe2e9de0 --- /dev/null +++ b/app/models/machine_category.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# MachineCategory used to categorize Machines. +class MachineCategory < ApplicationRecord + has_many :machines, dependent: :nullify + accepts_nested_attributes_for :machines, allow_destroy: true +end diff --git a/app/policies/machine_category_policy.rb b/app/policies/machine_category_policy.rb new file mode 100644 index 000000000..d862fa40a --- /dev/null +++ b/app/policies/machine_category_policy.rb @@ -0,0 +1,7 @@ +class MachineCategoryPolicy < ApplicationPolicy + %w[create update destroy show].each do |action| + define_method "#{action}?" do + user.admin? + end + end +end diff --git a/app/services/availabilities/availabilities_service.rb b/app/services/availabilities/availabilities_service.rb index 656e76c0e..ac21ce15b 100644 --- a/app/services/availabilities/availabilities_service.rb +++ b/app/services/availabilities/availabilities_service.rb @@ -2,7 +2,6 @@ # List all Availability's slots for the given resources class Availabilities::AvailabilitiesService - def initialize(current_user, level = 'slot') @current_user = current_user @maximum_visibility = { @@ -13,6 +12,19 @@ class Availabilities::AvailabilitiesService @level = level end + def index(window, ids, events = false) + machines_availabilities = Setting.get('machines_module') ? machines(Machine.where(id: ids[:machines]), @current_user, window) : [] + spaces_availabilities = Setting.get('spaces_module') ? spaces(Space.where(id: ids[:spaces]), @current_user, window) : [] + trainings_availabilities = Setting.get('trainings_module') ? trainings(Training.where(id: ids[:trainings]), @current_user, window) : [] + events_availabilities = if events && Setting.get('events_in_calendar') + events(Event.all, @current_user, window) + else + [] + end + + [].concat(trainings_availabilities).concat(events_availabilities).concat(machines_availabilities).concat(spaces_availabilities) + end + # list all slots for the given machines, with visibility relative to the given user def machines(machines, user, window) ma_availabilities = Availability.includes('machines_availabilities') diff --git a/app/views/api/machine_categories/index.json.jbuilder b/app/views/api/machine_categories/index.json.jbuilder new file mode 100644 index 000000000..ac42acb42 --- /dev/null +++ b/app/views/api/machine_categories/index.json.jbuilder @@ -0,0 +1,3 @@ +json.array!(@machine_categories) do |category| + json.extract! category, :id, :name, :machine_ids +end diff --git a/app/views/api/machine_categories/show.json.jbuilder b/app/views/api/machine_categories/show.json.jbuilder new file mode 100644 index 000000000..97e525513 --- /dev/null +++ b/app/views/api/machine_categories/show.json.jbuilder @@ -0,0 +1 @@ +json.extract! @machine_category, :id, :name, :machine_ids diff --git a/app/views/api/machines/_machine.json.jbuilder b/app/views/api/machines/_machine.json.jbuilder index 22e41d7ee..3838bc004 100644 --- a/app/views/api/machines/_machine.json.jbuilder +++ b/app/views/api/machines/_machine.json.jbuilder @@ -1,6 +1,6 @@ # frozen_string_literal: true -json.extract! machine, :id, :name, :slug, :disabled +json.extract! machine, :id, :name, :slug, :disabled, :machine_category_id if machine.machine_image json.machine_image_attributes do diff --git a/app/views/api/machines/index.json.jbuilder b/app/views/api/machines/index.json.jbuilder index b9e8f5b76..a7aa80396 100644 --- a/app/views/api/machines/index.json.jbuilder +++ b/app/views/api/machines/index.json.jbuilder @@ -1,7 +1,7 @@ # frozen_string_literal: true json.array!(@machines) do |machine| - json.extract! machine, :id, :name, :slug, :disabled + json.extract! machine, :id, :name, :slug, :disabled, :machine_category_id json.machine_image machine.machine_image.attachment.medium.url if machine.machine_image end diff --git a/app/views/api/machines/show.json.jbuilder b/app/views/api/machines/show.json.jbuilder index 2b271f395..48310eaee 100644 --- a/app/views/api/machines/show.json.jbuilder +++ b/app/views/api/machines/show.json.jbuilder @@ -1,6 +1,6 @@ # frozen_string_literal: true -json.extract! @machine, :id, :name, :description, :spec, :disabled, :slug +json.extract! @machine, :id, :name, :description, :spec, :disabled, :slug, :machine_category_id json.machine_image @machine.machine_image.attachment.large.url if @machine.machine_image json.machine_files_attributes @machine.machine_files do |f| diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index 72847b85e..04a67fb5c 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -1,6 +1,27 @@ en: app: admin: + machines: + the_fablab_s_machines: "The FabLab's machines" + all_machines: "All machines" + manage_machines_categories: "Manage machines categories" + machine_categories_list: + machine_categories: "Machines Categories" + add_a_machine_category: "Add a machine category" + name: "Name" + machines_number: "Nb of machines" + edit: "Edit" + machine_category_modal: + new_machine_category: "New categorie" + edit_machine_category: "Edit catégorie" + successfully_created: "The new machine category request has been created." + unable_to_create: "Unable to delete the machine category request: " + successfully_updated: "The machine category request has been updated." + unable_to_update: "Unable to modify the machine category request: " + machine_category_form: + name: "Name of category" + assigning_machines: "Assigning the machines to this category" + save: "Save" machine_form: name: "Name" illustration: "Visual" diff --git a/config/locales/app.admin.fr.yml b/config/locales/app.admin.fr.yml index 75971a2db..64598e4a6 100644 --- a/config/locales/app.admin.fr.yml +++ b/config/locales/app.admin.fr.yml @@ -1,6 +1,27 @@ fr: app: admin: + machines: + the_fablab_s_machines: "Les machines du FabLab" + all_machines: "Toutes les machines" + manage_machines_categories: "Gérer les catégories machines" + machine_categories_list: + machine_categories: "Catégories Machines" + add_a_machine_category: "Ajouter une catégorie machine" + name: "Nom" + machines_number: "Nb de machines" + edit: "Editer" + machine_category_modal: + new_machine_category: "Créer une nouvelle catégorie" + edit_machine_category: "Modifier la catégorie" + successfully_created: "La nouvelle catégorie machine a bien été créée." + unable_to_create: "Impossible de supprimer la catégorie machine : " + successfully_updated: "La nouvelle catégorie machine a bien été mise à jour." + unable_to_update: "Impossible de modifier la catégorie machine : " + machine_category_form: + name: "Nom de la catégorie" + assigning_machines: "Assigner les machines à cette catégorie" + save: "Enregistrer" machine_form: name: "Nom" illustration: "Illustration" diff --git a/config/locales/app.public.en.yml b/config/locales/app.public.en.yml index b3f2d1c2f..abce13394 100644 --- a/config/locales/app.public.en.yml +++ b/config/locales/app.public.en.yml @@ -230,6 +230,8 @@ en: status_enabled: "Enabled" status_disabled: "Disabled" status_all: "All" + filter_by_machine_category: "Filter by category" + all_machines: "All machines" machine_card: book: "Book" consult: "Consult" diff --git a/config/locales/app.public.fr.yml b/config/locales/app.public.fr.yml index c934e7990..d16ba670a 100644 --- a/config/locales/app.public.fr.yml +++ b/config/locales/app.public.fr.yml @@ -230,6 +230,8 @@ fr: status_enabled: "Actives" status_disabled: "Désactivées" status_all: "Toutes" + filter_by_machine_category: "Filtrer par catégorie" + all_machines: "Toutes les machines" machine_card: book: "Réserver" consult: "Consulter" diff --git a/config/locales/app.shared.en.yml b/config/locales/app.shared.en.yml index 8dbc6c09b..6cabef6d8 100644 --- a/config/locales/app.shared.en.yml +++ b/config/locales/app.shared.en.yml @@ -156,6 +156,10 @@ en: add_an_attachment: "Add an attachment" disable_machine: "Disable machine" validate_your_machine: "Validate your machine" + assigning_machine_to_category: "Assign a category" + assigning_machine_to_category_info_html: "Information
You can only assign one category per machine." + linking_machine_to_category: "Link this product to a machine category" + machine_uncategorized: 'Uncategorized' #button to book a machine reservation reserve_button: book_this_machine: "Book this machine" @@ -641,3 +645,12 @@ en: keyword: "Keyword: {KEYWORD}" stock_internal: "Private stock" stock_external: "Public stock" + calendar: + calendar: "Calendar" + show_unavailables: "Show unavailable slots" + filter_calendar: "Filter calendar" + trainings: "Trainings" + machines: "Machines" + spaces: "Spaces" + events: "Events" + externals: "Other calendars" diff --git a/config/locales/app.shared.fr.yml b/config/locales/app.shared.fr.yml index 37bcfda27..596049af5 100644 --- a/config/locales/app.shared.fr.yml +++ b/config/locales/app.shared.fr.yml @@ -156,6 +156,10 @@ fr: add_an_attachment: "Ajouter une pièce jointe" disable_machine: "Désactiver la machine" validate_your_machine: "Valider votre machine" + assigning_machine_to_category: "Attributer une catégorie" + assigning_machine_to_category_info_html: "Information
Vous ne pouvez attribuer qu'une seule catégoriez par machine." + linking_machine_to_category: "Lier ce produit à une catégorie machine" + machine_uncategorized: 'Non catégorisé' #button to book a machine reservation reserve_button: book_this_machine: "Réserver cette machine" @@ -641,3 +645,12 @@ fr: keyword: "Mot-clef : {KEYWORD}" stock_internal: "Stock interne" stock_external: "Stock externe" + calendar: + calendar: "Calendrier" + show_unavailables: "Afficher les créneaux non disponibles" + filter_calendar: "Filtrer le calendrier" + trainings: "Formations" + machines: "Machines" + spaces: "Espaces" + events: "Événements" + externals: "Autres calendriers" diff --git a/config/routes.rb b/config/routes.rb index 4759349aa..70a078452 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -41,6 +41,7 @@ Rails.application.routes.draw do end resources :openlab_projects, only: :index resources :machines + resources :machine_categories resources :components resources :themes resources :licences diff --git a/db/migrate/20221212162655_create_machine_categories.rb b/db/migrate/20221212162655_create_machine_categories.rb new file mode 100644 index 000000000..e96d5702d --- /dev/null +++ b/db/migrate/20221212162655_create_machine_categories.rb @@ -0,0 +1,9 @@ +class CreateMachineCategories < ActiveRecord::Migration[5.2] + def change + create_table :machine_categories do |t| + t.string :name + + t.timestamps + end + end +end diff --git a/db/migrate/20221216090005_add_machine_category_id_to_machine.rb b/db/migrate/20221216090005_add_machine_category_id_to_machine.rb new file mode 100644 index 000000000..c22c9e42e --- /dev/null +++ b/db/migrate/20221216090005_add_machine_category_id_to_machine.rb @@ -0,0 +1,7 @@ +# frozen_string_literal:true + +class AddMachineCategoryIdToMachine < ActiveRecord::Migration[5.2] + def change + add_reference :machines, :machine_category, index: true, foreign_key: true + end +end diff --git a/db/schema.rb b/db/schema.rb index effaba6fc..1d2d274a5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2022_11_22_123605) do +ActiveRecord::Schema.define(version: 2022_12_16_090005) do # These are extensions that must be enabled in order to support this database enable_extension "fuzzystrmatch" @@ -350,6 +350,12 @@ ActiveRecord::Schema.define(version: 2022_11_22_123605) do t.text "description" end + create_table "machine_categories", force: :cascade do |t| + t.string "name" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "machines", id: :serial, force: :cascade do |t| t.string "name", null: false t.text "description" @@ -359,7 +365,9 @@ ActiveRecord::Schema.define(version: 2022_11_22_123605) do t.string "slug" t.boolean "disabled" t.datetime "deleted_at" + t.bigint "machine_category_id" t.index ["deleted_at"], name: "index_machines_on_deleted_at" + t.index ["machine_category_id"], name: "index_machines_on_machine_category_id" t.index ["slug"], name: "index_machines_on_slug", unique: true end @@ -1187,6 +1195,7 @@ ActiveRecord::Schema.define(version: 2022_11_22_123605) do add_foreign_key "invoices", "statistic_profiles" add_foreign_key "invoices", "wallet_transactions" add_foreign_key "invoicing_profiles", "users" + add_foreign_key "machines", "machine_categories" add_foreign_key "order_activities", "invoicing_profiles", column: "operator_profile_id" add_foreign_key "order_activities", "orders" add_foreign_key "order_items", "orders"