1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-02-20 14:54:15 +01:00

(merge) Merge branch 'staging' into dev

This commit is contained in:
Sylvain 2022-12-27 12:20:18 +01:00
commit 898ce07509
58 changed files with 1517 additions and 208 deletions

View File

@ -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] },
events: (params[:evt] && params[:evt] == 'true'))
@availabilities = filter_availabilites(@availabilities)
end
def public
@ -27,7 +27,7 @@ class API::AvailabilitiesController < API::ApiController
@availabilities = service.public_availabilities(
display_window,
{ machines: machine_ids, spaces: params[:s], trainings: params[:t] },
(params[:evt] && params[:evt] == 'true')
events: (params[:evt] && params[:evt] == 'true')
)
@title_filter = { machine_ids: machine_ids.map(&:to_i) }
@ -112,7 +112,7 @@ class API::AvailabilitiesController < API::ApiController
render json: @export.errors, status: :unprocessable_entity
end
else
send_file File.join(Rails.root, export.file),
send_file Rails.root.join(export.file),
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
disposition: 'attachment'
end
@ -120,7 +120,7 @@ class API::AvailabilitiesController < API::ApiController
def lock
authorize @availability
if @availability.update_attributes(lock: lock_params)
if @availability.update(lock: lock_params)
render :show, status: :ok, location: @availability
else
render json: @availability.errors, status: :unprocessable_entity

View File

@ -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

View File

@ -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],
advanced_accounting_attributes: %i[code analytical_section])

View File

@ -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<Array<MachineCategory>> {
const res: AxiosResponse<Array<MachineCategory>> = await apiClient.get('/api/machine_categories');
return res?.data;
}
static async create (category: MachineCategory): Promise<MachineCategory> {
const res: AxiosResponse<MachineCategory> = await apiClient.post('/api/machine_categories', { machine_category: category });
return res?.data;
}
static async update (category: MachineCategory): Promise<MachineCategory> {
const res: AxiosResponse<MachineCategory> = await apiClient.patch(`/api/machine_categories/${category.id}`, { machine_category: category });
return res?.data;
}
static async destroy (categoryId: number): Promise<void> {
const res: AxiosResponse<void> = await apiClient.delete(`/api/machine_categories/${categoryId}`);
return res?.data;
}
}

View File

@ -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<DeleteMachineCategoryModalProps> = ({ isOpen, toggleModal, onSuccess, machineCategoryId, onError }) => {
const { t } = useTranslation('admin');
/**
* The user has confirmed the deletion of the requested machine category
*/
const handleDeleteMachineCategory = async (): Promise<void> => {
try {
await MachineCategoryAPI.destroy(machineCategoryId);
onSuccess(t('app.admin.delete_machine_category_modal.deleted'));
} catch (e) {
onError(t('app.admin.delete_machine_category_modal.unable_to_delete') + e);
}
};
return (
<FabModal title={t('app.admin.delete_machine_category_modal.confirmation_required')}
isOpen={isOpen}
toggleModal={toggleModal}
closeButton={true}
confirmButton={t('app.admin.delete_machine_category_modal.confirm')}
onConfirm={handleDeleteMachineCategory}
className="delete-machine-category-modal">
<p>{t('app.admin.delete_machine_category_modal.confirm_machine_category')}</p>
</FabModal>
);
};

View File

@ -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<MachineCategoriesListProps> = ({ onError, onSuccess }) => {
const { t } = useTranslation('admin');
// shown machine categories
const [machineCategories, setMachineCategories] = useState<Array<MachineCategory>>([]);
// all machines, for assign to category
const [machines, setMachines] = useState<Array<Machine>>([]);
// creation/edition modal
const [modalIsOpen, setModalIsOpen] = useState<boolean>(false);
// currently added/edited category
const [machineCategory, setMachineCategory] = useState<MachineCategory>(null);
// deletion modal
const [destroyModalIsOpen, setDestroyModalIsOpen] = useState<boolean>(false);
// currently deleted machine category
const [machineCategoryId, setMachineCategoryId] = useState<number>(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 (
<div className="machine-categories-list">
<h3 className="machines-categories">{t('app.admin.machine_categories_list.machine_categories')}</h3>
<FabButton onClick={addMachineCategory} className="is-secondary" >{t('app.admin.machine_categories_list.add_a_machine_category')}</FabButton>
<MachineCategoryModal isOpen={modalIsOpen}
machines={machines}
machineCategory={machineCategory}
toggleModal={toggleCreateAndEditModal}
onSuccess={onSaveTypeSuccess}
onError={onError} />
<DeleteMachineCategoryModal isOpen={destroyModalIsOpen}
machineCategoryId={machineCategoryId}
toggleModal={toggleDestroyModal}
onSuccess={onDestroySuccess}
onError={onError}/>
<table className="machine-categories-table">
<thead>
<tr>
<th style={{ width: '50%' }}>{t('app.admin.machine_categories_list.name')}</th>
<th style={{ width: '30%' }}>{t('app.admin.machine_categories_list.machines_number')}</th>
<th style={{ width: '20%' }}></th>
</tr>
</thead>
<tbody>
{machineCategories.map(category => {
return (
<tr key={category.id}>
<td>
<span>{category.name}</span>
</td>
<td>
<span>{category.machine_ids.length}</span>
</td>
<td>
<div className="buttons">
<FabButton className="edit-btn" onClick={editMachineCategory(category)}>
<i className="fa fa-edit" /> {t('app.admin.machine_categories_list.edit')}
</FabButton>
<FabButton className="delete-btn" onClick={destroyMachineCategory(category.id)}>
<i className="fa fa-trash" />
</FabButton>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
};
const MachineCategoriesListWrapper: React.FC<MachineCategoriesListProps> = (props) => {
return (
<Loader>
<MachineCategoriesList {...props} />
</Loader>
);
};
Application.Components.component('machineCategoriesList', react2angular(MachineCategoriesListWrapper, ['onError', 'onSuccess']));

View File

@ -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<Machine>,
machineCategory?: MachineCategory,
saveMachineCategory: (data: MachineCategory) => void,
}
/**
* Form to set create/edit machine category
*/
export const MachineCategoryForm: React.FC<MachineCategoryFormProps> = ({ machines, machineCategory, saveMachineCategory }) => {
const { t } = useTranslation('admin');
const { handleSubmit, register, control, formState } = useForm<MachineCategory>({ defaultValues: { ...machineCategory } });
/**
* Convert all machines to the checklist format
*/
const buildOptions = (): Array<SelectOption<number>> => {
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<MachineCategory> = (data: MachineCategory) => {
saveMachineCategory(data);
};
return (
<div className="machine-category-form">
<form name="machineCategoryForm" onSubmit={handleSubmit(onSubmit)}>
<FormInput id="name"
register={register}
rules={{ required: true }}
formState={formState}
label={t('app.admin.machine_category_form.name')}
/>
<div>
<h4>{t('app.admin.machine_category_form.assigning_machines')}</h4>
<FormChecklist options={buildOptions()}
control={control}
id="machine_ids"
formState={formState} />
</div>
<div className="main-actions">
<FabButton type="submit">
{t('app.admin.machine_category_form.save')}
</FabButton>
</div>
</form>
</div>
);
};

View File

@ -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<Machine>,
machineCategory?: MachineCategory,
}
/**
* Modal dialog to create/edit a machine category
*/
export const MachineCategoryModal: React.FC<MachineCategoryModalProps> = ({ isOpen, toggleModal, onSuccess, onError, machines, machineCategory }) => {
const { t } = useTranslation('admin');
/**
* Save the current machine category to the API
*/
const handleSaveMachineCategory = async (data: MachineCategory): Promise<void> => {
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 (
<FabModal title={t(`app.admin.machine_category_modal.${machineCategory?.id ? 'edit' : 'new'}_machine_category`)}
width={ModalSize.large}
isOpen={isOpen}
toggleModal={toggleModal}
closeButton={false}>
<MachineCategoryForm machineCategory={machineCategory} machines={machines} saveMachineCategory={handleSaveMachineCategory}/>
</FabModal>
);
};

View File

@ -1,4 +1,5 @@
import * as React from 'react';
import { useEffect, useState } from 'react';
import { SubmitHandler, useForm, useWatch } from 'react-hook-form';
import { Machine } from '../../models/machine';
import MachineAPI from '../../api/machine';
@ -14,6 +15,10 @@ import { FormSwitch } from '../form/form-switch';
import { FormMultiFileUpload } from '../form/form-multi-file-upload';
import { FabButton } from '../base/fab-button';
import { AdvancedAccountingForm } from '../accounting/advanced-accounting-form';
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;
@ -32,6 +37,15 @@ export const MachineForm: React.FC<MachineFormProps> = ({ action, machine, onErr
const output = useWatch<Machine>({ control });
const { t } = useTranslation('admin');
const [machineCategories, setMachineCategories] = useState<Array<MachineCategory>>([]);
// 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
*/
@ -44,6 +58,15 @@ export const MachineForm: React.FC<MachineFormProps> = ({ action, machine, onErr
});
};
/**
* Convert all machine categories to the select format
*/
const buildOptions = (): Array<SelectOption<number>> => {
return machineCategories.map(t => {
return { value: t.id, label: t.name };
});
};
return (
<form className="machine-form" onSubmit={handleSubmit(onSubmit)}>
<FormInput register={register} id="name"
@ -71,6 +94,11 @@ export const MachineForm: React.FC<MachineFormProps> = ({ action, machine, onErr
label={t('app.admin.machine_form.technical_specifications')}
limit={null}
heading bulletList blockquote link video image />
<FormSelect options={buildOptions()}
control={control}
id="machine_category_id"
formState={formState}
label={t('app.admin.machine_form.assigning_machine_to_category')} />
<div className='form-item-header machine-files-header'>
<p>{t('app.admin.machine_form.attached_files_pdf')}</p>

View File

@ -2,18 +2,21 @@ import * as React from 'react';
import Select from 'react-select';
import { useTranslation } from 'react-i18next';
import { SelectOption } from '../../models/select';
import { MachineCategory } from '../../models/machine-category';
interface MachinesFiltersProps {
onStatusSelected: (enabled: boolean) => void,
onFilterChangedBy: (type: string, value: number | boolean | void) => void,
machineCategories: Array<MachineCategory>,
}
/**
* Allows filtering on machines list
*/
export const MachinesFilters: React.FC<MachinesFiltersProps> = ({ onStatusSelected }) => {
export const MachinesFilters: React.FC<MachinesFiltersProps> = ({ 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<MachinesFiltersProps> = ({ onStatusSelect
];
};
/**
* Provides categories options in the react-select format
*/
const buildCategoriesOptions = (): Array<SelectOption<number|void>> => {
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<boolean>): 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<number>): void => {
onFilterChangedBy('category', option.value);
};
return (
<div className="machines-filters">
<div className="status-filter">
<div className="filter-item">
<label htmlFor="status">{t('app.public.machines_filters.show_machines')}</label>
<Select defaultValue={defaultValue}
id="status"
@ -43,6 +63,16 @@ export const MachinesFilters: React.FC<MachinesFiltersProps> = ({ onStatusSelect
onChange={handleStatusSelected}
options={buildBooleanOptions()}/>
</div>
{machineCategories.length > 0 &&
<div className="filter-item">
<label htmlFor="category">{t('app.public.machines_filters.filter_by_machine_category')}</label>
<Select defaultValue={categoryDefaultValue}
id="machine_category"
className="category-select"
onChange={handleCategorySelected}
options={buildCategoriesOptions()}/>
</div>
}
</div>
);
};

View File

@ -1,10 +1,12 @@
import { useEffect, useState } from 'react';
import * as React 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';
@ -33,31 +35,65 @@ export const MachinesList: React.FC<MachinesListProps> = ({ onError, onSuccess,
const [machines, setMachines] = useState<Array<Machine>>(null);
// we keep the full list of machines, for filtering
const [allMachines, setAllMachines] = useState<Array<Machine>>(null);
// shown machine categories
const [machineCategories, setMachineCategories] = useState<Array<MachineCategory>>([]);
// machine list filter
const [filter, setFilter] = useState<MachineListFilter>({
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
});
};
/**
@ -70,7 +106,7 @@ export const MachinesList: React.FC<MachinesListProps> = ({ onError, onSuccess,
// TODO: Conditionally display the store ad
return (
<div className="machines-list">
<MachinesFilters onStatusSelected={handleFilterByStatus} />
<MachinesFilters onFilterChangedBy={handleFilterChangedBy} machineCategories={machineCategories}/>
<div className="all-machines">
{false &&
<div className='store-ad' onClick={() => linkToStore}>

View File

@ -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,140 @@ 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.accordion = {
trainings: false,
machines: false,
spaces: false
};
$scope.machinesGroupByCategory.forEach(c => $scope.accordion[c.name] = false);
$scope.toggleAccordion = (type) => $scope.accordion[type] = !$scope.accordion[type];
$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

View File

@ -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();
}
]);

View File

@ -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,24 @@ 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.accordion = {
trainings: false,
machines: false,
spaces: false
};
$scope.machinesGroupByCategory.forEach(c => $scope.accordion[c.name] = false);
$scope.toggleFilter = (type, filter) => toggleFilter(type, filter);
$scope.toggleAccordion = (type) => $scope.accordion[type] = !$scope.accordion[type];
$scope.toggleFilter = (type, filter, machineCategoryId) => toggleFilter(type, filter, machineCategoryId);
$scope.filterAvailabilities = filter => filterAvailabilities(filter, $scope);
@ -196,6 +227,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
});
});
};
/**

View File

@ -256,13 +256,16 @@ Application.Controllers.controller('NewMachineController', ['$scope', '$state',
/**
* Controller used in the machine edition page (admin)
*/
Application.Controllers.controller('EditMachineController', ['$scope', '$state', '$transition$', 'machinePromise', 'CSRF', 'growl',
function ($scope, $state, $transition$, machinePromise, CSRF, growl) {
Application.Controllers.controller('EditMachineController', ['$scope', '$state', '$transition$', 'machinePromise', 'machineCategoriesPromise', 'CSRF', 'growl',
function ($scope, $state, $transition$, machinePromise, machineCategoriesPromise, CSRF, growl) {
/* PUBLIC SCOPE */
// 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
*/

View File

@ -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']

View File

@ -0,0 +1,5 @@
export interface MachineCategory {
id?: number,
name: string,
machine_ids: Array<number>,
}

View File

@ -7,6 +7,11 @@ export interface MachineIndexFilter extends ApiFilter {
disabled: boolean,
}
export interface MachineListFilter {
status?: boolean,
category?: number,
}
export interface Machine {
id?: number,
name: string,
@ -30,5 +35,6 @@ export interface Machine {
name: string,
slug: string,
}>,
advanced_accounting_attributes?: AdvancedAccounting
advanced_accounting_attributes?: AdvancedAccounting,
machine_category_id?: number
}

View File

@ -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', {

View File

@ -0,0 +1,11 @@
'use strict';
Application.Services.factory('MachineCategory', ['$resource', function ($resource) {
return $resource('/api/machine_categories/:id',
{ id: '@id' }, {
update: {
method: 'PUT'
}
}
);
}]);

View File

@ -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;

View File

@ -63,6 +63,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";

View File

@ -7,6 +7,9 @@
justify-content: space-between;
align-items: center;
flex-wrap: wrap-reverse;
.calendar-actions {
display: flex;
}
}
&-info {
display: contents;

View File

@ -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;
}
}

View File

@ -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%;
}
}

View File

@ -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 {

View File

@ -41,17 +41,22 @@
<span class="calendar-legend-item text-sm border-event" ng-show="eventsInCalendar" translate>{{ 'app.admin.calendar.events' }}</span>
</div>
</div>
<div ng-show="isAuthorized('admin')">
<a class="btn btn-default export-xls-button"
ng-href="api/availabilities/export_index.xlsx"
target="export-frame"
ng-click="alertExport('index')"
uib-popover="{{ 'app.admin.calendar.availabilities_notice' | translate}}"
popover-trigger="mouseenter"
popover-placement="bottom-left">
<i class="fa fa-file-excel-o"></i> {{ 'app.admin.calendar.availabilities' | translate }}
</a>
<iframe name="export-frame" height="0" width="0" class="none"></iframe>
<div class="calendar-actions">
<div ng-show="isAuthorized('admin')">
<a class="btn btn-default export-xls-button"
ng-href="api/availabilities/export_index.xlsx"
target="export-frame"
ng-click="alertExport('index')"
uib-popover="{{ 'app.admin.calendar.availabilities_notice' | translate}}"
popover-trigger="mouseenter"
popover-placement="bottom-left">
<i class="fa fa-file-excel-o"></i> {{ 'app.admin.calendar.availabilities' | translate }}
</a>
<iframe name="export-frame" height="0" width="0" class="none"></iframe>
</div>
<button type="button" class="btn btn-default m-l" ng-click="openFilterAside()">
<span class="fa fa-filter"></span> {{ 'app.shared.calendar.filter_calendar' | translate }}
</button>
</div>
</div>
<div class="col-12">

View File

@ -0,0 +1,7 @@
<div class="m-t">
<machine-categories-list
on-error="onError"
on-success="onSuccess"
>
<machine-categories-list />
</div>

View File

@ -0,0 +1,33 @@
<div class="header-page">
<div class="back">
<a ng-click="backPrevLocation($event)"><i class="fas fa-long-arrow-alt-left "></i></a>
</div>
<div class="center">
<h1 translate>{{ 'app.admin.machines.the_fablab_s_machines' }}</h1>
</div>
</div>
<section class="m-lg admin-machines-manage">
<div class="row">
<div>
<uib-tabset justified="true" active="tabs.active">
<uib-tab heading="{{ 'app.admin.machines.all_machines' | translate }}" index="0" select="selectTab()">
<div ng-if="tabs.active === 0">
<ng-include src="'/admin/machines/machines.html'"></ng-include>
</div>
</uib-tab>
<uib-tab heading="{{ 'app.admin.machines.manage_machines_categories' | translate }}" index="1" select="selectTab()">
<div ng-if="tabs.active === 1">
<ng-include src="'/admin/machines/categories.html'"></ng-include>
</div>
</uib-tab>
</uib-tabset>
</div>
</div>
</section>

View File

@ -0,0 +1,19 @@
<section class="m-lg"
ui-tour="machines"
ui-tour-backdrop="true"
ui-tour-template-url="'/shared/tour-step-template.html'"
ui-tour-use-hotkeys="true"
ui-tour-scroll-parent-id="content-main"
post-render="setupMachinesTour">
<machines-list user="currentUser"
on-error="onError"
on-success="onSuccess"
on-show-machine="showMachine"
on-reserve-machine="reserveMachine"
on-login-requested="onLoginRequest"
on-enroll-requested="onEnrollRequest"
can-propose-packs="canProposePacks()">
</machines-list>
</section>

View File

@ -14,7 +14,7 @@
<div class="col-xs-12 col-sm-12 col-md-3 b-t hide-b-md">
<div class="heading-actions wrapper">
<button type="button" class="btn btn-default m-t m-b" ng-click="openFilterAside()">
<span class="fa fa-filter"></span> {{ 'app.public.calendar.filter_calendar' | translate }}
<span class="fa fa-filter"></span> {{ 'app.shared.calendar.filter_calendar' | translate }}
</button>
</div>
</div>

View File

@ -1,44 +1,66 @@
<div>
<div class="row">
<h3 class="col-md-11 col-sm-11 col-xs-11 text-black" translate>{{ 'app.shared.calendar.show_unavailables' }}</h3>
<input class="col-md-1 col-sm-1 col-xs-1" type="checkbox" ng-model="filter.dispo" ng-change="filterAvailabilities(filter)">
</div>
<div class="m-t" ng-show="$root.modules.trainings">
<div class="row">
<h3 class="col-md-11 col-sm-11 col-xs-11 text-purple" translate>{{ 'app.public.calendar.trainings' }}</h3>
<h3 class="col-md-11 col-sm-11 col-xs-11">
<i ng-class="{'fa-chevron-up': !accordion.trainings, 'fa-chevron-down': accordion.trainings}" ng-click="toggleAccordion('trainings')" class="fa m-r-xs text-black"></i>
<span class="text-purple" translate>{{ 'app.shared.calendar.trainings' }}</span>
</h3>
<input class="col-md-1 col-sm-1 col-xs-1" type="checkbox" ng-model="filter.trainings" ng-change="toggleFilter('trainings', filter)">
</div>
<div ng-repeat="t in trainings" class="row">
<div ng-repeat="t in trainings" class="row" ng-hide="accordion.trainings">
<span class="col-md-11 col-sm-11 col-xs-11">{{::t.name}}</span>
<input class="col-md-1 col-sm-1 col-xs-1" type="checkbox" ng-model="t.checked" ng-change="filterAvailabilities(filter)">
</div>
</div>
<div class="m-t">
<div class="m-t" ng-show="$root.modules.machines">
<div class="row">
<h3 class="col-md-11 col-sm-11 col-xs-11 text-beige" translate>{{ 'app.public.calendar.machines' }}</h3>
<h3 class="col-md-11 col-sm-11 col-xs-11">
<i ng-class="{'fa-chevron-up': !accordion.machines, 'fa-chevron-down': accordion.machines}" ng-click="toggleAccordion('machines')" class="fa m-r-xs text-black"></i>
<span class="text-beige" translate>{{ 'app.shared.calendar.machines' }}</span>
</h3>
<input class="col-md-1 col-sm-1 col-xs-1" type="checkbox" ng-model="filter.machines" ng-change="toggleFilter('machines', filter)">
</div>
<div ng-repeat="m in machines" class="row">
<div ng-if="!hasMachineCategory" ng-repeat="m in machines" class="row" ng-hide="accordion.machines">
<span class="col-md-11 col-sm-11 col-xs-11">{{::m.name}}</span>
<input class="col-md-1 col-sm-1 col-xs-1" type="checkbox" ng-model="m.checked" ng-change="filterAvailabilities(filter)">
</div>
<div ng-if="hasMachineCategory" ng-repeat="category in machinesGroupByCategory" class="row" ng-hide="accordion.machines">
<span class="col-md-11 col-sm-11 col-xs-11">
<i ng-class="{'fa-chevron-up': !accordion[category.name], 'fa-chevron-down': accordion[category.name]}" ng-click="toggleAccordion(category.name)" class="fa m-r-xs text-black"></i>
{{::category.name}}
</span>
<input class="col-md-1 col-sm-1 col-xs-1" type="checkbox" ng-model="category.checked" ng-change="toggleFilter('machineCategory', filter, category.id)">
<div ng-repeat="m in category.machines" class="col-md-12" ng-hide="accordion[category.name]">
<div class="row p-l-sm">
<span class="col-md-11 col-sm-11 col-xs-11">{{::m.name}}</span>
<input class="col-md-1 col-sm-1 col-xs-1" type="checkbox" ng-model="m.checked" ng-change="filterAvailabilities(filter)">
</div>
</div>
</div>
</div>
<div class="m-t" ng-show="$root.modules.spaces">
<div class="row">
<h3 class="col-md-11 col-sm-11 col-xs-11 text-cyan" translate>{{ 'app.public.calendar.spaces' }}</h3>
<h3 class="col-md-11 col-sm-11 col-xs-11">
<i ng-class="{'fa-chevron-up': !accordion.spaces, 'fa-chevron-down': accordion.spaces}" ng-click="toggleAccordion('spaces')" class="fa m-r-xs text-black"></i>
<span class="text-cyan" translate>{{ 'app.shared.calendar.spaces' }}</span>
</h3>
<input class="col-md-1 col-sm-1 col-xs-1" type="checkbox" ng-model="filter.spaces" ng-change="toggleFilter('spaces', filter)">
</div>
<div ng-repeat="s in spaces" class="row">
<div ng-repeat="s in spaces" class="row" ng-hide="accordion.spaces">
<span class="col-md-11 col-sm-11 col-xs-11">{{::s.name}}</span>
<input class="col-md-1 col-sm-1 col-xs-1" type="checkbox" ng-model="s.checked" ng-change="filterAvailabilities(filter)">
</div>
</div>
<div class="m-t row">
<h3 class="col-md-11 col-sm-11 col-xs-11 text-japonica" translate>{{ 'app.public.calendar.events' }}</h3>
<h3 class="col-md-11 col-sm-11 col-xs-11 text-japonica" translate>{{ 'app.shared.calendar.events' }}</h3>
<input class="col-md-1 col-sm-1 col-xs-1" type="checkbox" ng-model="filter.evt" ng-change="filterAvailabilities(filter)">
</div>
<div class="m-t row">
<h3 class="col-md-11 col-sm-11 col-xs-11 text-black" translate>{{ 'app.public.calendar.show_unavailables' }}</h3>
<input class="col-md-1 col-sm-1 col-xs-1" type="checkbox" ng-model="filter.dispo" ng-change="filterAvailabilities(filter)">
</div>
<div class="m-t" ng-hide="externals.length == 0">
<div class="m-t" ng-hide="!externals || externals.length == 0">
<div class="row">
<h3 class="col-md-11 col-sm-11 col-xs-11 text-black" translate>{{ 'app.public.calendar.externals' }}</h3>
<h3 class="col-md-11 col-sm-11 col-xs-11 text-black" translate>{{ 'app.shared.calendar.externals' }}</h3>
<input class="col-md-1 col-sm-1 col-xs-1" type="checkbox" ng-model="filter.externals" ng-change="toggleFilter('externals', filter)">
</div>

View File

@ -1,7 +1,7 @@
<div class="widget">
<div class="modal-header">
<button type="button" class="close" ng-click="close($event)"><span>&times;</span></button>
<h1 class="modal-title" translate>{{ 'app.public.calendar.filter_calendar' }}</h1>
<h1 class="modal-title" translate>{{ 'app.shared.calendar.filter_calendar' }}</h1>
</div>
<div class="modal-body widget-content calendar-filter calendar-filter-aside">
<ng-include src="'/calendar/filter.html'"></ng-include>

View File

@ -37,6 +37,8 @@ class Machine < ApplicationRecord
has_one :advanced_accounting, as: :accountable, dependent: :destroy
accepts_nested_attributes_for :advanced_accounting, allow_destroy: true
belongs_to :category
after_create :create_statistic_subtype
after_create :create_machine_prices
after_create :update_gateway_product

View File

@ -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

View File

@ -0,0 +1,10 @@
# frozen_string_literal: true
# Check the access policies for API::MachineCategoriesController
class MachineCategoryPolicy < ApplicationPolicy
%w[create update destroy show].each do |action|
define_method "#{action}?" do
user.admin?
end
end
end

View File

@ -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 = {
@ -14,6 +13,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')

View File

@ -7,13 +7,13 @@ class Availabilities::PublicAvailabilitiesService
@service = Availabilities::StatusService.new('public')
end
def public_availabilities(window, ids, events = false)
def public_availabilities(window, ids, events: false)
level = in_same_day(window[:start], window[:end]) ? 'slot' : 'availability'
service = Availabilities::AvailabilitiesService.new(@current_user, level)
machines_slots = service.machines(Machine.where(id: ids[:machines]), @current_user, window)
spaces_slots = service.spaces(Space.where(id:ids[:spaces]), @current_user, window)
trainings_slots = service.trainings(Training.where(id: ids[:trainings]), @current_user, window)
machines_slots = Setting.get('machines_module') ? service.machines(Machine.where(id: ids[:machines]), @current_user, window) : []
spaces_slots = Setting.get('spaces_module') ? service.spaces(Space.where(id: ids[:spaces]), @current_user, window) : []
trainings_slots = Setting.get('trainings_module') ? service.trainings(Training.where(id: ids[:trainings]), @current_user, window) : []
events_slots = events ? service.events(Event.all, @current_user, window) : []
[].concat(trainings_slots).concat(events_slots).concat(machines_slots).concat(spaces_slots)

View File

@ -0,0 +1,3 @@
json.array!(@machine_categories) do |category|
json.extract! category, :id, :name, :machine_ids
end

View File

@ -0,0 +1 @@
json.extract! @machine_category, :id, :name, :machine_ids

View File

@ -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

View File

@ -1,6 +1,33 @@
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"
delete_machine_category_modal:
confirmation_required: "Confirmation required"
confirm: "Confirm"
deleted: "The machine category request has been deleted."
unable_to_delete: "Unable to delete the machine category : "
confirm_delete_supporting_documents_type: "Do you really want to remove this machine category ?"
machine_form:
name: "Name"
illustration: "Visual"

View File

@ -1,6 +1,33 @@
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"
delete_machine_category_modal:
confirmation_required: "Confirmation requise"
confirm: "Valider"
deleted: "La catégorie machine a bien été supprimée."
unable_to_delete: "Impossible de supprimer la catégorie machine : "
confirm_machine_category: "Êtes-vous sûr de vouloir supprimer la catégorie machine ?"
machine_form:
name: "Nom"
illustration: "Illustration"

View File

@ -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"

View File

@ -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"

View File

@ -527,3 +527,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"

View File

@ -527,3 +527,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"

View File

@ -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

View File

@ -0,0 +1,12 @@
# frozen_string_literal: true
# From this migration, machines can be grouped into categories
class CreateMachineCategories < ActiveRecord::Migration[5.2]
def change
create_table :machine_categories do |t|
t.string :name
t.timestamps
end
end
end

View File

@ -0,0 +1,8 @@
# frozen_string_literal:true
# From this migration, each machine can belongs to a MachineCategory
class AddMachineCategoryIdToMachine < ActiveRecord::Migration[5.2]
def change
add_reference :machines, :machine_category, index: true, foreign_key: true
end
end

View File

@ -381,6 +381,12 @@ ActiveRecord::Schema.define(version: 2022_12_20_105939) 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"
@ -390,7 +396,9 @@ ActiveRecord::Schema.define(version: 2022_12_20_105939) 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
@ -1221,6 +1229,7 @@ ActiveRecord::Schema.define(version: 2022_12_20_105939) 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"

View File

@ -932,3 +932,12 @@ history_value_98:
updated_at: 2022-12-23 14:39:12.214510000 Z
footprint:
invoicing_profile_id: 1
history_value_99:
id: 99
setting_id: 98
value: 'true'
created_at: '2022-12-20 14:38:40.000421'
updated_at: '2022-12-20 14:38:40.000421'
footprint:
invoicing_profile_id: 1

5
test/fixtures/machine_categories.yml vendored Normal file
View File

@ -0,0 +1,5 @@
machine_category_1:
id: 1
name: Category 1
created_at: 2022-12-20 14:11:34.544995000 Z
updated_at: 2022-12-20 14:11:34.544995000 Z

View File

@ -574,3 +574,9 @@ setting_97:
name: invoice_VAT-name
created_at: 2022-12-23 14:39:12.214510000 Z
updated_at: 2022-12-23 14:39:12.214510000 Z
setting_98:
id: 98
name: machines_module
created_at: 2020-04-15 14:38:40.000421500 Z
updated_at: 2020-04-15 14:38:40.000421500 Z

View File

@ -2,140 +2,155 @@
require 'test_helper'
module Availabilities
class AsAdminTest < ActionDispatch::IntegrationTest
setup do
admin = User.with_role(:admin).first
login_as(admin, scope: :user)
end
# module definition
module Availabilities; end
test 'return availability by id' do
a = Availability.take
class Availabilities::AsAdminTest < ActionDispatch::IntegrationTest
setup do
admin = User.with_role(:admin).first
login_as(admin, scope: :user)
end
get "/api/availabilities/#{a.id}"
test 'return availability by id' do
a = Availability.take
# Check response format & status
assert_equal 200, response.status
assert_equal Mime[:json], response.content_type
get "/api/availabilities/#{a.id}"
# Check the correct availability was returned
availability = json_response(response.body)
assert_equal a.id, availability[:id], 'availability id does not match'
end
# Check response format & status
assert_equal 200, response.status
assert_equal Mime[:json], response.content_type
test 'get machine availabilities as admin' do
m = Machine.find_by(slug: 'decoupeuse-vinyle')
# Check the correct availability was returned
availability = json_response(response.body)
assert_equal a.id, availability[:id], 'availability id does not match'
end
# this simulates a fullCalendar (v2) call
start_date = DateTime.current.utc.strftime('%Y-%m-%d')
end_date = 7.days.from_now.utc.strftime('%Y-%m-%d')
tz = Time.zone.tzinfo.name
test 'get machine availabilities as admin' do
m = Machine.find_by(slug: 'decoupeuse-vinyle')
get "/api/availabilities/machines/#{m.id}?start=#{start_date}&end=#{end_date}&timezone=#{tz}&_=1217026492144"
# this simulates a fullCalendar (v2) call
start_date = DateTime.current.utc.strftime('%Y-%m-%d')
end_date = 7.days.from_now.utc.strftime('%Y-%m-%d')
tz = Time.zone.tzinfo.name
# Check response format & status
assert_equal 200, response.status
assert_equal Mime[:json], response.content_type
get "/api/availabilities/machines/#{m.id}?start=#{start_date}&end=#{end_date}&timezone=#{tz}&_=1217026492144"
# Check the correct availabilities was returned
availabilities = json_response(response.body)
assert_not_empty availabilities, 'no availabilities were found'
assert_not_nil availabilities[0], 'first availability was unexpectedly nil'
assert_not_nil availabilities[0][:machine], "first availability's machine was unexpectedly nil"
assert_equal m.id, availabilities[0][:machine][:id], "first availability's machine does not match the required machine"
# Check response format & status
assert_equal 200, response.status
assert_equal Mime[:json], response.content_type
# as admin, we can get availabilities from the past (from v4.3.0)
end
# Check the correct availabilities was returned
availabilities = json_response(response.body)
assert_not_empty availabilities, 'no availabilities were found'
assert_not_nil availabilities[0], 'first availability was unexpectedly nil'
assert_not_nil availabilities[0][:machine], "first availability's machine was unexpectedly nil"
assert_equal m.id, availabilities[0][:machine][:id], "first availability's machine does not match the required machine"
test 'get calendar availabilities without spaces' do
# disable spaces in application
Setting.set('spaces_module', false)
# as admin, we can get availabilities from the past (from v4.3.0)
end
# this simulates a fullCalendar (v2) call
start_date = DateTime.current.utc.strftime('%Y-%m-%d')
end_date = 7.days.from_now.utc.strftime('%Y-%m-%d')
tz = Time.zone.tzinfo.name
get "/api/availabilities?start=#{start_date}&end=#{end_date}&timezone=#{tz}&_=1487169767960"
test 'get calendar availabilities without spaces' do
# disable spaces in application
Setting.set('spaces_module', false)
# Check response format & status
assert_equal 200, response.status
assert_equal Mime[:json], response.content_type
# this simulates a fullCalendar (v2) call
start_date = DateTime.current.utc.strftime('%Y-%m-%d')
end_date = 7.days.from_now.utc.strftime('%Y-%m-%d')
tz = Time.zone.tzinfo.name
get "/api/availabilities?start=#{start_date}&end=#{end_date}&timezone=#{tz}&_=1487169767960&#{all_machines}"
# Check the correct availabilities was returned
availabilities = json_response(response.body)
assert_not_empty availabilities, 'no availabilities were found'
assert_not_nil availabilities[0], 'first availability was unexpectedly nil'
# Check response format & status
assert_equal 200, response.status
assert_equal Mime[:json], response.content_type
assert_not availabilities.map { |a| a[:available_type] }.include?('space'), 'unexpected space availability instead that it was disabled'
# Check the correct availabilities was returned
availabilities = json_response(response.body)
assert_not_empty availabilities, 'no availabilities were found'
assert_not_nil availabilities[0], 'first availability was unexpectedly nil'
# re-enable spaces
Setting.set('spaces_module', true)
end
assert_not availabilities.pluck(:available_type).include?('space'), 'unexpected space availability instead that it was disabled'
test 'get calendar availabilities with spaces' do
# this simulates a fullCalendar (v2) call
start_date = DateTime.current.utc.strftime('%Y-%m-%d')
end_date = 7.days.from_now.utc.strftime('%Y-%m-%d')
tz = Time.zone.tzinfo.name
get "/api/availabilities?start=#{start_date}&end=#{end_date}&timezone=#{tz}&_=1487169767960"
# re-enable spaces
Setting.set('spaces_module', true)
end
# Check response format & status
assert_equal 200, response.status
assert_equal Mime[:json], response.content_type
test 'get calendar availabilities with spaces' do
# this simulates a fullCalendar (v2) call
start_date = DateTime.current.utc.strftime('%Y-%m-%d')
end_date = 7.days.from_now.utc.strftime('%Y-%m-%d')
tz = Time.zone.tzinfo.name
get "/api/availabilities?start=#{start_date}&end=#{end_date}&timezone=#{tz}&_=1487169767960&#{all_spaces}"
# Check the correct availabilities was returned
availabilities = json_response(response.body)
assert_not_empty availabilities, 'no availabilities were found'
assert_not_nil availabilities[0], 'first availability was unexpectedly nil'
# Check response format & status
assert_equal 200, response.status
assert_equal Mime[:json], response.content_type
assert availabilities.map { |a| a[:available_type] }.include?('space'), 'space availability not found instead that it was enabled'
end
# Check the correct availabilities was returned
availabilities = json_response(response.body)
assert_not_empty availabilities, 'no availabilities were found'
assert_not_nil availabilities[0], 'first availability was unexpectedly nil'
test 'create availabilities' do
date = DateTime.current.change(hour: 8, min: 0, sec: 0)
slots_count = Slot.count
assert availabilities.pluck(:available_type).include?('space'), 'space availability not found instead that it was enabled'
end
post '/api/availabilities',
params: {
availability: {
start_at: date.iso8601,
end_at: (date + 6.hours).iso8601,
available_type: 'machines',
tag_ids: [],
is_recurrent: true,
period: 'week',
nb_periods: 1,
end_date: (date + 2.weeks).end_of_day.iso8601,
slot_duration: 90,
machine_ids: [2, 3, 5],
occurrences: [
{ start_at: date.iso8601, end_at: (date + 6.hours).iso8601 },
{ start_at: (date + 1.week).iso8601, end_at: (date + 1.week + 6.hours).iso8601 },
{ start_at: (date + 2.weeks).iso8601, end_at: (date + 2.weeks + 6.hours).iso8601 }
],
plan_ids: [1]
}
test 'create availabilities' do
date = DateTime.current.change(hour: 8, min: 0, sec: 0)
slots_count = Slot.count
post '/api/availabilities',
params: {
availability: {
start_at: date.iso8601,
end_at: (date + 6.hours).iso8601,
available_type: 'machines',
tag_ids: [],
is_recurrent: true,
period: 'week',
nb_periods: 1,
end_date: (date + 2.weeks).end_of_day.iso8601,
slot_duration: 90,
machine_ids: [2, 3, 5],
occurrences: [
{ start_at: date.iso8601, end_at: (date + 6.hours).iso8601 },
{ start_at: (date + 1.week).iso8601, end_at: (date + 1.week + 6.hours).iso8601 },
{ start_at: (date + 2.weeks).iso8601, end_at: (date + 2.weeks + 6.hours).iso8601 }
],
plan_ids: [1]
}
}
# Check response format & status
assert_equal 201, response.status
assert_equal Mime[:json], response.content_type
# Check response format & status
assert_equal 201, response.status
assert_equal Mime[:json], response.content_type
# Check the id
availability = json_response(response.body)
assert_not_nil availability[:id], 'availability ID was unexpectedly nil'
# Check the id
availability = json_response(response.body)
assert_not_nil availability[:id], 'availability ID was unexpectedly nil'
# Check the slots
assert_equal (availability[:start_at].to_datetime + availability[:slot_duration].minutes * 4).iso8601,
availability[:end_at],
'expected end_at = start_at + 4 slots of 90 minutes'
assert_equal (slots_count + 4 * 3), Slot.count, 'expected (4*3) slots of 90 minutes were created'
assert_equal 90.minutes, Availability.find(availability[:id]).slots.first.duration
# Check the slots
assert_equal (availability[:start_at].to_datetime + (availability[:slot_duration].minutes * 4)).iso8601,
availability[:end_at],
'expected end_at = start_at + 4 slots of 90 minutes'
assert_equal (slots_count + (4 * 3)), Slot.count, 'expected (4*3) slots of 90 minutes were created'
assert_equal 90.minutes, Availability.find(availability[:id]).slots.first.duration
# Check the recurrence
assert_equal (availability[:start_at].to_datetime + 2.weeks).to_date,
availability[:end_date].to_datetime.utc.to_date,
'expected end_date = start_at + 2 weeks'
end
# Check the recurrence
assert_equal (availability[:start_at].to_datetime + 2.weeks).to_date,
availability[:end_date].to_datetime.utc.to_date,
'expected end_date = start_at + 2 weeks'
end
private
def all_machines
Machine.all.map { |m| "m%5B%5D=#{m.id}" }.join('&')
end
def all_trainings
Training.all.map { |m| "t%5B%5D=#{m.id}" }.join('&')
end
def all_spaces
Space.all.map { |m| "s%5B%5D=#{m.id}" }.join('&')
end
end

View File

@ -3,11 +3,11 @@
require 'test_helper'
class Availabilities::AsPublicTest < ActionDispatch::IntegrationTest
test 'get public machines availabilities' do
test 'get public machines availabilities if machines module is active' do
start_date = DateTime.current.to_date
end_date = (DateTime.current + 7.days).to_date
get "/api/availabilities/public?start=#{start_date.to_s}&end=#{end_date.to_s}&timezone=Europe%2FParis&#{all_machines}"
get "/api/availabilities/public?start=#{start_date}&end=#{end_date}&timezone=Europe%2FParis&#{all_machines}"
# Check response format & status
assert_equal 200, response.status
@ -24,11 +24,27 @@ class Availabilities::AsPublicTest < ActionDispatch::IntegrationTest
end
end
test 'get anymore machines availabilities if machines module is inactive' do
Setting.set('machines_module', false)
start_date = DateTime.current.to_date
end_date = (DateTime.current + 7.days).to_date
get "/api/availabilities/public?start=#{start_date}&end=#{end_date}&timezone=Europe%2FParis&#{all_machines}"
# Check response format & status
assert_equal 200, response.status
assert_equal Mime[:json], response.content_type
# Check the correct availabilities was returned
availabilities = json_response(response.body)
assert_empty availabilities
end
test 'get public trainings availabilities' do
start_date = DateTime.current.to_date
end_date = (DateTime.current + 7.days).to_date
get "/api/availabilities/public?start=#{start_date.to_s}&end=#{end_date.to_s}&timezone=Europe%2FParis&#{all_trainings}"
get "/api/availabilities/public?start=#{start_date}&end=#{end_date}&timezone=Europe%2FParis&#{all_trainings}"
# Check response format & status
assert_equal 200, response.status
@ -49,7 +65,7 @@ class Availabilities::AsPublicTest < ActionDispatch::IntegrationTest
start_date = DateTime.current.to_date
end_date = (DateTime.current + 7.days).to_date
get "/api/availabilities/public?start=#{start_date.to_s}&end=#{end_date.to_s}&timezone=Europe%2FParis&#{all_spaces}"
get "/api/availabilities/public?start=#{start_date}&end=#{end_date}&timezone=Europe%2FParis&#{all_spaces}"
# Check response format & status
assert_equal 200, response.status
@ -70,7 +86,7 @@ class Availabilities::AsPublicTest < ActionDispatch::IntegrationTest
start_date = 8.days.from_now.to_date
end_date = 16.days.from_now.to_date
get "/api/availabilities/public?start=#{start_date.to_s}&end=#{end_date.to_s}&timezone=Europe%2FParis&evt=true"
get "/api/availabilities/public?start=#{start_date}&end=#{end_date}&timezone=Europe%2FParis&evt=true"
# Check response format & status
assert_equal 200, response.status

View File

@ -0,0 +1,64 @@
# frozen_string_literal: true
require 'test_helper'
class MachineCategoriesTest < ActionDispatch::IntegrationTest
def setup
@admin = User.find_by(username: 'admin')
login_as(@admin, scope: :user)
end
test 'create a machine category' do
name = 'Category 2'
post '/api/machine_categories',
params: {
machine_category: {
name: name,
machine_ids: [1]
}
}.to_json,
headers: default_headers
# Check response format & status
assert_equal 201, response.status, response.body
assert_equal Mime[:json], response.content_type
# Check the machine category was correctly created
category = MachineCategory.where(name: name).first
machine1 = Machine.find(1)
assert_not_nil category
assert_equal name, category.name
assert_equal category.machines.length, 1
assert_equal category.id, machine1.machine_category_id
end
test 'update a machine category' do
name = 'category update'
put '/api/machine_categories/1',
params: {
machine_category: {
name: name,
machine_ids: [2, 3]
}
}.to_json,
headers: default_headers
# Check response format & status
assert_equal 200, response.status, response.body
assert_equal Mime[:json], response.content_type
# Check the machine category was correctly updated
category = MachineCategory.find(1)
assert_equal name, category.name
json = json_response(response.body)
assert_equal name, json[:name]
assert_equal category.machines.length, 2
assert_equal category.machine_ids, [2, 3]
end
test 'delete a machine category' do
delete '/api/machine_categories/1', headers: default_headers
assert_response :success
assert_empty response.body
end
end

View File

@ -23,7 +23,8 @@ class MachinesTest < ActionDispatch::IntegrationTest
{ attachment: fixture_file_upload('/files/document.pdf', 'application/pdf', true) },
{ attachment: fixture_file_upload('/files/document2.pdf', 'application/pdf', true) }
],
disabled: false
disabled: false,
machine_category_id: 1
}
},
headers: upload_headers
@ -43,6 +44,7 @@ class MachinesTest < ActionDispatch::IntegrationTest
assert_not_empty db_machine.description
assert_not db_machine.disabled
assert_nil db_machine.deleted_at
assert_equal db_machine.machine_category_id, 1
end
test 'update a machine' do