mirror of
https://github.com/LaCasemate/fab-manager.git
synced 2025-02-26 20:54:21 +01:00
Merge branch 'product-store_dev' into product-store
This commit is contained in:
commit
76a3a5c37c
@ -7,9 +7,9 @@ Layout/LineLength:
|
||||
Metrics/MethodLength:
|
||||
Max: 35
|
||||
Metrics/CyclomaticComplexity:
|
||||
Max: 13
|
||||
Max: 14
|
||||
Metrics/PerceivedComplexity:
|
||||
Max: 12
|
||||
Max: 14
|
||||
Metrics/AbcSize:
|
||||
Max: 45
|
||||
Metrics/ClassLength:
|
||||
@ -34,7 +34,7 @@ Style/AndOr:
|
||||
EnforcedStyle: conditionals
|
||||
Style/FormatString:
|
||||
EnforcedStyle: sprintf
|
||||
Style/FormatStringToken:
|
||||
EnforcedStyle: template
|
||||
Rails/RedundantPresenceValidationOnBelongsTo:
|
||||
Enabled: false
|
||||
Rails/UnknownEnv:
|
||||
Environments: development, test, staging, production
|
||||
|
25
CHANGELOG.md
25
CHANGELOG.md
@ -1,5 +1,30 @@
|
||||
# Changelog Fab-manager
|
||||
|
||||
- Allow searching by username (#401)
|
||||
- Fix a bug: adding a new event without updating the dates results in internal server error (undefined method `div' for nil)
|
||||
- Fix a bug: portuguese time formatting (#405)
|
||||
- Fix a bug: admin users groups being overriden by SSO group_id (#404)
|
||||
- Fix a bug: no statistics on trainings and spaces reservations
|
||||
- Fix a bug: invalid ventilation for amount coupons
|
||||
- Fix a bug: invalid VAT for invoices using amount coupons
|
||||
- Fix a bug: invalid 1 cent rounding for invoices using coupons
|
||||
- Fix a bug: plans list error when there was no plan for the user's group
|
||||
- Fix a security issue: updated nokogiri to 1.13.9 to fix [GHSA-2qc6-mcvw-92cw](https://github.com/advisories/GHSA-2qc6-mcvw-92cw)
|
||||
- [TODO DEPLOY] `rails fablab:maintenance:regenerate_statistics[2021,6]`
|
||||
- [TODO DEPLOY] `rails fablab:setup:set_admins_group`
|
||||
|
||||
## v5.4.25 2022 October 19
|
||||
|
||||
- Fix a bug: unable apply a coupon if this coupon has used by an user removed
|
||||
- Improved automated test on prepaid pack
|
||||
|
||||
## v5.4.24 2022 October 14
|
||||
|
||||
- Fix a bug: unable debit hours of prepaid pack after a reservation of machine
|
||||
|
||||
## v5.4.23 2022 October 12
|
||||
|
||||
- Fix a bug: unable to build docker image
|
||||
- Fablab's store module
|
||||
- Fix a bug: missing translations in PayZen configuration screens
|
||||
- Fix a bug: wrong translation key prevents the display of the schedule deadline's payment mean
|
||||
|
@ -17,6 +17,7 @@ RUN apk update && apk upgrade && \
|
||||
libc-dev \
|
||||
ruby-dev \
|
||||
zlib-dev \
|
||||
xz \
|
||||
xz-dev \
|
||||
postgresql-dev \
|
||||
postgresql-client \
|
||||
|
@ -236,7 +236,7 @@ GEM
|
||||
multi_xml (0.6.0)
|
||||
multipart-post (2.1.1)
|
||||
nio4r (2.5.8)
|
||||
nokogiri (1.13.8)
|
||||
nokogiri (1.13.9)
|
||||
mini_portile2 (~> 2.8.0)
|
||||
racc (~> 1.4)
|
||||
notify_with (0.0.2)
|
||||
|
@ -35,7 +35,7 @@ class API::AdminsController < API::ApiController
|
||||
|
||||
def admin_params
|
||||
params.require(:admin).permit(
|
||||
:username, :email,
|
||||
:username, :email, :group_id,
|
||||
profile_attributes: %i[first_name last_name phone],
|
||||
invoicing_profile_attributes: [address_attributes: [:address]],
|
||||
statistic_profile_attributes: %i[gender birthday]
|
||||
|
@ -32,7 +32,7 @@ class API::CartController < API::ApiController
|
||||
end
|
||||
|
||||
def set_offer
|
||||
authorize @current_order, policy_class: CartPolicy
|
||||
authorize CartContext.new(params[:customer_id], cart_params[:is_offered])
|
||||
@order = Cart::SetOfferService.new.call(@current_order, orderable, cart_params[:is_offered])
|
||||
render 'api/orders/show'
|
||||
end
|
||||
|
@ -6,7 +6,7 @@ class API::GroupsController < API::ApiController
|
||||
before_action :authenticate_user!, except: :index
|
||||
|
||||
def index
|
||||
@groups = GroupService.list(current_user, params)
|
||||
@groups = GroupService.list(params)
|
||||
end
|
||||
|
||||
def create
|
||||
|
@ -157,7 +157,7 @@ class API::MembersController < API::ApiController
|
||||
end
|
||||
|
||||
def search
|
||||
@members = Members::ListService.search(current_user, params[:query], params[:subscription], params[:include_admins])
|
||||
@members = Members::ListService.search(current_user, params[:query], params[:subscription])
|
||||
end
|
||||
|
||||
def mapping
|
||||
|
@ -8,7 +8,7 @@ class API::PaymentSchedulesController < API::ApiController
|
||||
|
||||
# retrieve all payment schedules for the current user, paginated
|
||||
def index
|
||||
@payment_schedules = PaymentSchedule.where('invoicing_profile_id = ?', current_user.invoicing_profile.id)
|
||||
@payment_schedules = PaymentSchedule.where(invoicing_profile_id: current_user.invoicing_profile.id)
|
||||
.includes(:invoicing_profile, :payment_schedule_items, :payment_schedule_objects)
|
||||
.joins(:invoicing_profile)
|
||||
.order('payment_schedules.created_at DESC')
|
||||
@ -34,14 +34,14 @@ class API::PaymentSchedulesController < API::ApiController
|
||||
|
||||
def download
|
||||
authorize @payment_schedule
|
||||
send_file File.join(Rails.root, @payment_schedule.file), type: 'application/pdf', disposition: 'attachment'
|
||||
send_file Rails.root.join(@payment_schedule.file), type: 'application/pdf', disposition: 'attachment'
|
||||
end
|
||||
|
||||
def cash_check
|
||||
authorize @payment_schedule_item.payment_schedule
|
||||
PaymentScheduleService.new.generate_invoice(@payment_schedule_item, payment_method: 'check')
|
||||
attrs = { state: 'paid', payment_method: 'check' }
|
||||
@payment_schedule_item.update_attributes(attrs)
|
||||
@payment_schedule_item.update(attrs)
|
||||
|
||||
render json: attrs, status: :ok
|
||||
end
|
||||
@ -50,7 +50,7 @@ class API::PaymentSchedulesController < API::ApiController
|
||||
authorize @payment_schedule_item.payment_schedule
|
||||
PaymentScheduleService.new.generate_invoice(@payment_schedule_item, payment_method: 'transfer')
|
||||
attrs = { state: 'paid', payment_method: 'transfer' }
|
||||
@payment_schedule_item.update_attributes(attrs)
|
||||
@payment_schedule_item.update(attrs)
|
||||
|
||||
render json: attrs, status: :ok
|
||||
end
|
||||
|
BIN
app/frontend/images/default-image.png
Normal file
BIN
app/frontend/images/default-image.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 19 KiB |
@ -24,7 +24,7 @@ export default class CartAPI {
|
||||
}
|
||||
|
||||
static async setOffer (order: Order, orderableId: number, isOffered: boolean): Promise<Order> {
|
||||
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/set_offer', { order_token: order.token, orderable_id: orderableId, is_offered: isOffered });
|
||||
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/set_offer', { order_token: order.token, orderable_id: orderableId, is_offered: isOffered, customer_id: order.user?.id });
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
|
@ -5,7 +5,7 @@ import ApiLib from '../lib/api';
|
||||
|
||||
export default class OrderAPI {
|
||||
static async index (filters?: OrderIndexFilter): Promise<OrderIndex> {
|
||||
const res: AxiosResponse<OrderIndex> = await apiClient.get(`/api/orders${ApiLib.filtersToQuery(filters)}`);
|
||||
const res: AxiosResponse<OrderIndex> = await apiClient.get(`/api/orders${ApiLib.filtersToQuery(filters, false)}`);
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
|
@ -12,7 +12,7 @@ import ProductLib from '../lib/product';
|
||||
|
||||
export default class ProductAPI {
|
||||
static async index (filters?: ProductIndexFilter): Promise<ProductsIndex> {
|
||||
const res: AxiosResponse<ProductsIndex> = await apiClient.get(`/api/products${ApiLib.filtersToQuery(ProductLib.indexFiltersToIds(filters))}`);
|
||||
const res: AxiosResponse<ProductsIndex> = await apiClient.get(`/api/products${ApiLib.filtersToQuery(ProductLib.indexFiltersToIds(filters), false)}`);
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
|
@ -101,9 +101,9 @@ const FabTextEditor: React.ForwardRefRenderFunction<FabTextEditorRef, FabTextEdi
|
||||
<div className={`fab-text-editor ${disabled ? 'is-disabled' : ''}`}>
|
||||
<MenuBar editor={editor} heading={heading} bulletList={bulletList} blockquote={blockquote} video={video} image={image} link={link} disabled={disabled} />
|
||||
<EditorContent editor={editor} />
|
||||
<div className="fab-text-editor-character-count">
|
||||
{limit && <div className="fab-text-editor-character-count">
|
||||
{editor?.storage.characterCount.characters()} / {limit}
|
||||
</div>
|
||||
</div>}
|
||||
{error &&
|
||||
<div className="fab-text-editor-error">
|
||||
<WarningOctagon size={24} />
|
||||
|
@ -181,7 +181,14 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
|
||||
* Change cart's customer by admin/manger
|
||||
*/
|
||||
const handleChangeMember = (user: User): void => {
|
||||
setCart({ ...cart, user: { id: user.id, role: 'member' } });
|
||||
// if the selected user is the operator, he cannot offer products to himself
|
||||
if (user.id === currentUser.id && cart.order_items_attributes.filter(item => item.is_offered).length > 0) {
|
||||
Promise.all(cart.order_items_attributes.filter(item => item.is_offered).map(item => {
|
||||
return CartAPI.setOffer(cart, item.orderable_id, false);
|
||||
})).then((data) => setCart({ ...data[data.length - 1], user: { id: user.id, role: user.role } }));
|
||||
} else {
|
||||
setCart({ ...cart, user: { id: user.id, role: user.role } });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@ -205,7 +212,13 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
|
||||
return (checked: boolean) => {
|
||||
CartAPI.setOffer(cart, item.orderable_id, checked).then(data => {
|
||||
setCart(data);
|
||||
}).catch(onError);
|
||||
}).catch(e => {
|
||||
if (e.match(/code 403/)) {
|
||||
onError(t('app.public.store_cart.errors.unauthorized_offering_product'));
|
||||
} else {
|
||||
onError(e);
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -4,17 +4,13 @@ import { Path } from 'react-hook-form';
|
||||
import { UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form';
|
||||
import { FieldPathValue } from 'react-hook-form/dist/types/path';
|
||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||
import { FormInput } from '../form/form-input';
|
||||
import { FormInput } from './form-input';
|
||||
import { FormComponent } from '../../models/form-component';
|
||||
import { AbstractFormItemProps } from './abstract-form-item';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import { FilePdf, Trash } from 'phosphor-react';
|
||||
|
||||
export interface FileType {
|
||||
id?: number,
|
||||
attachment_name?: string,
|
||||
attachment_url?: string
|
||||
}
|
||||
import { FileType } from '../../models/file';
|
||||
import FileUploadLib from '../../lib/file-upload';
|
||||
|
||||
interface FormFileUploadProps<TFieldValues> extends FormComponent<TFieldValues>, AbstractFormItemProps<TFieldValues> {
|
||||
setValue: UseFormSetValue<TFieldValues>,
|
||||
@ -36,7 +32,7 @@ export const FormFileUpload = <TFieldValues extends FieldValues>({ id, register,
|
||||
* Check if file is selected
|
||||
*/
|
||||
const hasFile = (): boolean => {
|
||||
return !!file?.attachment_name;
|
||||
return FileUploadLib.hasFile(file);
|
||||
};
|
||||
|
||||
/**
|
||||
@ -49,7 +45,7 @@ export const FormFileUpload = <TFieldValues extends FieldValues>({ id, register,
|
||||
attachment_name: f.name
|
||||
});
|
||||
setValue(
|
||||
`${id}[_destroy]` as Path<TFieldValues>,
|
||||
`${id}._destroy` as Path<TFieldValues>,
|
||||
false as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
|
||||
);
|
||||
if (typeof onFileChange === 'function') {
|
||||
@ -62,20 +58,7 @@ export const FormFileUpload = <TFieldValues extends FieldValues>({ id, register,
|
||||
* Callback triggered when the user clicks on the delete button.
|
||||
*/
|
||||
function onRemoveFile () {
|
||||
if (file?.id) {
|
||||
setValue(
|
||||
`${id}[_destroy]` as Path<TFieldValues>,
|
||||
true as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
|
||||
);
|
||||
}
|
||||
setValue(
|
||||
`${id}[attachment_files]` as Path<TFieldValues>,
|
||||
null as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
|
||||
);
|
||||
setFile(null);
|
||||
if (typeof onFileRemove === 'function') {
|
||||
onFileRemove();
|
||||
}
|
||||
FileUploadLib.onRemoveFile(file, id, setFile, setValue, onFileRemove);
|
||||
}
|
||||
|
||||
// Compose classnames from props
|
||||
|
@ -1,24 +1,19 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Path } from 'react-hook-form';
|
||||
import { Path, Controller } from 'react-hook-form';
|
||||
import { UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form';
|
||||
import { FieldPathValue } from 'react-hook-form/dist/types/path';
|
||||
import { FieldPath, FieldPathValue } from 'react-hook-form/dist/types/path';
|
||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||
import { FormInput } from '../form/form-input';
|
||||
import { FormComponent } from '../../models/form-component';
|
||||
import { FormInput } from './form-input';
|
||||
import { FormComponent, FormControlledComponent } from '../../models/form-component';
|
||||
import { AbstractFormItemProps } from './abstract-form-item';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import noImage from '../../../../images/no_image.png';
|
||||
import { Trash } from 'phosphor-react';
|
||||
import { ImageType } from '../../models/file';
|
||||
import FileUploadLib from '../../lib/file-upload';
|
||||
|
||||
export interface ImageType {
|
||||
id?: number,
|
||||
attachment_name?: string,
|
||||
attachment_url?: string,
|
||||
is_main?: boolean
|
||||
}
|
||||
|
||||
interface FormImageUploadProps<TFieldValues> extends FormComponent<TFieldValues>, AbstractFormItemProps<TFieldValues> {
|
||||
interface FormImageUploadProps<TFieldValues, TContext extends object> extends FormComponent<TFieldValues>, FormControlledComponent<TFieldValues, TContext>, AbstractFormItemProps<TFieldValues> {
|
||||
setValue: UseFormSetValue<TFieldValues>,
|
||||
defaultImage?: ImageType,
|
||||
accept?: string,
|
||||
@ -26,13 +21,13 @@ interface FormImageUploadProps<TFieldValues> extends FormComponent<TFieldValues>
|
||||
mainOption?: boolean,
|
||||
onFileChange?: (value: ImageType) => void,
|
||||
onFileRemove?: () => void,
|
||||
onFileIsMain?: () => void,
|
||||
onFileIsMain?: (setIsMain: () => void) => void,
|
||||
}
|
||||
|
||||
/**
|
||||
* This component allows to upload image, in forms managed by react-hook-form.
|
||||
*/
|
||||
export const FormImageUpload = <TFieldValues extends FieldValues>({ id, register, defaultImage, className, rules, disabled, error, warning, formState, onFileChange, onFileRemove, accept, setValue, size, onFileIsMain, mainOption = false }: FormImageUploadProps<TFieldValues>) => {
|
||||
export const FormImageUpload = <TFieldValues extends FieldValues, TContext extends object>({ id, label, register, control, defaultImage, className, rules, disabled, error, warning, formState, onFileChange, onFileRemove, accept, setValue, size, onFileIsMain, mainOption = false }: FormImageUploadProps<TFieldValues, TContext>) => {
|
||||
const { t } = useTranslation('shared');
|
||||
|
||||
const [file, setFile] = useState<ImageType>(defaultImage);
|
||||
@ -46,7 +41,7 @@ export const FormImageUpload = <TFieldValues extends FieldValues>({ id, register
|
||||
* Check if image is selected
|
||||
*/
|
||||
const hasImage = (): boolean => {
|
||||
return !!file?.attachment_name;
|
||||
return FileUploadLib.hasFile(file);
|
||||
};
|
||||
|
||||
/**
|
||||
@ -65,12 +60,11 @@ export const FormImageUpload = <TFieldValues extends FieldValues>({ id, register
|
||||
attachment_name: f.name
|
||||
});
|
||||
setValue(
|
||||
`${id}[attachment_name]` as Path<TFieldValues>,
|
||||
f.name as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
|
||||
);
|
||||
setValue(
|
||||
`${id}[_destroy]` as Path<TFieldValues>,
|
||||
false as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
|
||||
id as Path<TFieldValues>,
|
||||
{
|
||||
attachment_name: f.name,
|
||||
_destroy: false
|
||||
} as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
|
||||
);
|
||||
if (typeof onFileChange === 'function') {
|
||||
onFileChange({ attachment_name: f.name });
|
||||
@ -82,20 +76,7 @@ export const FormImageUpload = <TFieldValues extends FieldValues>({ id, register
|
||||
* Callback triggered when the user clicks on the delete button.
|
||||
*/
|
||||
function onRemoveFile () {
|
||||
if (file?.id) {
|
||||
setValue(
|
||||
`${id}[_destroy]` as Path<TFieldValues>,
|
||||
true as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
|
||||
);
|
||||
}
|
||||
setValue(
|
||||
`${id}[attachment_files]` as Path<TFieldValues>,
|
||||
null as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
|
||||
);
|
||||
setFile(null);
|
||||
if (typeof onFileRemove === 'function') {
|
||||
onFileRemove();
|
||||
}
|
||||
FileUploadLib.onRemoveFile(file, id, setFile, setValue, onFileRemove);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -103,44 +84,44 @@ export const FormImageUpload = <TFieldValues extends FieldValues>({ id, register
|
||||
*/
|
||||
const placeholder = (): string => hasImage() ? t('app.shared.form_image_upload.edit') : t('app.shared.form_image_upload.browse');
|
||||
|
||||
/**
|
||||
* Callback triggered when the user set the image is main
|
||||
*/
|
||||
function setMainImage () {
|
||||
setValue(
|
||||
`${id}[is_main]` as Path<TFieldValues>,
|
||||
true as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
|
||||
);
|
||||
onFileIsMain();
|
||||
}
|
||||
|
||||
// Compose classnames from props
|
||||
const classNames = [
|
||||
`${className || ''}`
|
||||
].join(' ');
|
||||
|
||||
return (
|
||||
<div className={`form-image-upload form-image-upload--${size} ${classNames}`}>
|
||||
<div className={`form-image-upload form-image-upload--${size} ${label ? 'with-label' : ''} ${classNames}`}>
|
||||
<div className={`image image--${size}`}>
|
||||
<img src={image || noImage} />
|
||||
<img src={hasImage() ? image : noImage} alt={file?.attachment_name || 'no image'} onError={({ currentTarget }) => {
|
||||
currentTarget.onerror = null;
|
||||
currentTarget.src = noImage;
|
||||
}} />
|
||||
</div>
|
||||
<div className="actions">
|
||||
{mainOption &&
|
||||
<label className='fab-button'>
|
||||
{t('app.shared.form_image_upload.main_image')}
|
||||
<input type="radio" checked={!!file?.is_main} onChange={setMainImage} />
|
||||
<Controller name={`${id}.is_main` as FieldPath<TFieldValues>}
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) =>
|
||||
<input id={`${id}.is_main`}
|
||||
type="radio"
|
||||
checked={value}
|
||||
onChange={() => { onFileIsMain(onChange); }} />
|
||||
} />
|
||||
</label>
|
||||
}
|
||||
<FormInput className="image-file-input"
|
||||
type="file"
|
||||
accept={accept}
|
||||
register={register}
|
||||
label={label}
|
||||
formState={formState}
|
||||
rules={rules}
|
||||
disabled={disabled}
|
||||
error={error}
|
||||
warning={warning}
|
||||
id={`${id}[attachment_files]`}
|
||||
id={`${id}.attachment_files`}
|
||||
onChange={onFileSelected}
|
||||
placeholder={placeholder()}/>
|
||||
{hasImage() && <FabButton onClick={onRemoveFile} icon={<Trash size={20} weight="fill" />} className="is-main" />}
|
||||
@ -148,3 +129,7 @@ export const FormImageUpload = <TFieldValues extends FieldValues>({ id, register
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FormImageUpload.defaultProps = {
|
||||
size: 'medium'
|
||||
};
|
||||
|
@ -0,0 +1,48 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { FormFileUpload } from './form-file-upload';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import { Plus } from 'phosphor-react';
|
||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||
import { FormComponent, FormControlledComponent } from '../../models/form-component';
|
||||
import { AbstractFormItemProps } from './abstract-form-item';
|
||||
import { UseFormSetValue } from 'react-hook-form/dist/types/form';
|
||||
import { ArrayPath, FieldArray, useFieldArray } from 'react-hook-form';
|
||||
import { FileType } from '../../models/file';
|
||||
import { UnpackNestedValue } from 'react-hook-form/dist/types';
|
||||
|
||||
interface FormMultiFileUploadProps<TFieldValues, TContext extends object> extends FormComponent<TFieldValues>, FormControlledComponent<TFieldValues, TContext>, AbstractFormItemProps<TFieldValues> {
|
||||
setValue: UseFormSetValue<TFieldValues>,
|
||||
addButtonLabel: ReactNode,
|
||||
accept: string
|
||||
}
|
||||
|
||||
/**
|
||||
* This component allows to upload multiple files, in forms managed by react-hook-form.
|
||||
*/
|
||||
export const FormMultiFileUpload = <TFieldValues extends FieldValues, TContext extends object>({ id, className, register, control, setValue, formState, addButtonLabel, accept }: FormMultiFileUploadProps<TFieldValues, TContext>) => {
|
||||
const { fields, append, remove } = useFieldArray({ control, name: id as ArrayPath<TFieldValues> });
|
||||
|
||||
return (
|
||||
<div className={`form-multi-file-upload ${className || ''}`}>
|
||||
<div className="list">
|
||||
{fields.map((field: FileType, index) => (
|
||||
<FormFileUpload key={field.id}
|
||||
defaultFile={field}
|
||||
id={`${id}.${index}`}
|
||||
accept={accept}
|
||||
register={register}
|
||||
setValue={setValue}
|
||||
formState={formState}
|
||||
className={field._destroy ? 'hidden' : ''}
|
||||
onFileRemove={() => remove(index)}/>
|
||||
))}
|
||||
</div>
|
||||
<FabButton
|
||||
onClick={() => append({ _destroy: false } as UnpackNestedValue<FieldArray<TFieldValues, ArrayPath<TFieldValues>>>)}
|
||||
className='is-secondary'
|
||||
icon={<Plus size={24} />}>
|
||||
{addButtonLabel}
|
||||
</FabButton>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,102 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||
import { FormComponent, FormControlledComponent } from '../../models/form-component';
|
||||
import { AbstractFormItemProps } from './abstract-form-item';
|
||||
import { UseFormSetValue } from 'react-hook-form/dist/types/form';
|
||||
import { ArrayPath, FieldArray, Path, useFieldArray, useWatch } from 'react-hook-form';
|
||||
import { FormImageUpload } from './form-image-upload';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import { Plus } from 'phosphor-react';
|
||||
import { ImageType } from '../../models/file';
|
||||
import { UnpackNestedValue } from 'react-hook-form/dist/types';
|
||||
import { FieldPathValue } from 'react-hook-form/dist/types/path';
|
||||
|
||||
interface FormMultiImageUploadProps<TFieldValues, TContext extends object> extends FormComponent<TFieldValues>, FormControlledComponent<TFieldValues, TContext>, AbstractFormItemProps<TFieldValues> {
|
||||
setValue: UseFormSetValue<TFieldValues>,
|
||||
addButtonLabel: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* This component allows to upload multiple images, in forms managed by react-hook-form.
|
||||
*/
|
||||
export const FormMultiImageUpload = <TFieldValues extends FieldValues, TContext extends object>({ id, className, register, control, setValue, formState, addButtonLabel }: FormMultiImageUploadProps<TFieldValues, TContext>) => {
|
||||
const { fields, append, remove } = useFieldArray({ control, name: id as ArrayPath<TFieldValues> });
|
||||
const output = useWatch({ control, name: id as Path<TFieldValues> });
|
||||
|
||||
/**
|
||||
* Add new image, set as main if it is the first
|
||||
*/
|
||||
const addImage = () => {
|
||||
append({
|
||||
is_main: output.filter(i => i.is_main).length === 0,
|
||||
_destroy: false
|
||||
} as UnpackNestedValue<FieldArray<TFieldValues, ArrayPath<TFieldValues>>>);
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove an image and set the first image as the new main image if the provided was main
|
||||
*/
|
||||
const handleRemoveImage = (image: ImageType, index: number) => {
|
||||
return () => {
|
||||
if (image.is_main && output.length > 1) {
|
||||
setValue(
|
||||
`${id}.${index === 0 ? 1 : 0}.is_main` as Path<TFieldValues>,
|
||||
true as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
|
||||
);
|
||||
}
|
||||
if (typeof image.id === 'string') {
|
||||
remove(index);
|
||||
} else {
|
||||
setValue(
|
||||
`${id}.${index}._destroy` as Path<TFieldValues>,
|
||||
true as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
|
||||
);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the image at the given index as the new main image, and unset the current main image
|
||||
*/
|
||||
const handleSetMainImage = (index: number) => {
|
||||
return (setNewImageValue) => {
|
||||
const mainImageIndex = output.findIndex(i => i.is_main && i !== index);
|
||||
if (mainImageIndex > -1) {
|
||||
setValue(
|
||||
`${id}.${mainImageIndex}.is_main` as Path<TFieldValues>,
|
||||
false as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
|
||||
);
|
||||
}
|
||||
setNewImageValue(true);
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`form-multi-image-upload ${className || ''}`}>
|
||||
<div className="list">
|
||||
{fields.map((field: ImageType, index) => (
|
||||
<FormImageUpload key={field.id}
|
||||
defaultImage={field}
|
||||
id={`${id}.${index}`}
|
||||
accept="image/*"
|
||||
size="small"
|
||||
register={register}
|
||||
control={control}
|
||||
setValue={setValue}
|
||||
formState={formState}
|
||||
className={field._destroy ? 'hidden' : ''}
|
||||
onFileRemove={handleRemoveImage(field, index)}
|
||||
onFileIsMain={handleSetMainImage(index)}
|
||||
mainOption
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<FabButton
|
||||
onClick={addImage}
|
||||
className='is-secondary'
|
||||
icon={<Plus size={24} />}>
|
||||
{addButtonLabel}
|
||||
</FabButton>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -38,7 +38,7 @@ export const ChangeGroup: React.FC<ChangeGroupProps> = ({ user, onSuccess, onErr
|
||||
const { handleSubmit, control } = useForm();
|
||||
|
||||
useEffect(() => {
|
||||
GroupAPI.index({ disabled: false, admins: user?.role === 'admin' }).then(setGroups).catch(onError);
|
||||
GroupAPI.index({ disabled: false }).then(setGroups).catch(onError);
|
||||
MemberAPI.current().then(setOperator).catch(onError);
|
||||
SettingAPI.get('user_change_group').then((setting) => {
|
||||
setAllowedUserChangeGoup(setting.value === 'true');
|
||||
|
@ -44,12 +44,12 @@ const MachineCard: React.FC<MachineCardProps> = ({ user, machine, onShowMachine,
|
||||
* Return the machine's picture or a placeholder
|
||||
*/
|
||||
const machinePicture = (): ReactNode => {
|
||||
if (!machine.machine_image) {
|
||||
if (!machine.machine_image_attributes) {
|
||||
return <div className="machine-picture no-picture" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="machine-picture" style={{ backgroundImage: `url(${machine.machine_image})` }} onClick={handleShowMachine} />
|
||||
<div className="machine-picture" style={{ backgroundImage: `url(${machine.machine_image_attributes.attachment_url}), url('/default-image.png')` }} onClick={handleShowMachine} />
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
import { SubmitHandler, useForm, useWatch } from 'react-hook-form';
|
||||
import { Machine } from '../../models/machine';
|
||||
import MachineAPI from '../../api/machine';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FormInput } from '../form/form-input';
|
||||
import { FormImageUpload } from '../form/form-image-upload';
|
||||
import { IApplication } from '../../models/application';
|
||||
import { Loader } from '../base/loader';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { ErrorBoundary } from '../base/error-boundary';
|
||||
import { FormRichText } from '../form/form-rich-text';
|
||||
import { FormSwitch } from '../form/form-switch';
|
||||
|
||||
declare const Application: IApplication;
|
||||
|
||||
interface MachineFormProps {
|
||||
action: 'create' | 'update',
|
||||
machine?: Machine,
|
||||
onError: (message: string) => void,
|
||||
onSuccess: (message: string) => void,
|
||||
}
|
||||
|
||||
/**
|
||||
* Form to edit or create machines
|
||||
*/
|
||||
export const MachineForm: React.FC<MachineFormProps> = ({ action, machine, onError, onSuccess }) => {
|
||||
const { handleSubmit, register, control, setValue, formState } = useForm<Machine>({ defaultValues: { ...machine } });
|
||||
const output = useWatch<Machine>({ control });
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
/**
|
||||
* Callback triggered when the user validates the machine form: handle create or update
|
||||
*/
|
||||
const onSubmit: SubmitHandler<Machine> = (data: Machine) => {
|
||||
MachineAPI[action](data).then(() => {
|
||||
onSuccess(t(`app.admin.machine_form.${action}_success`));
|
||||
}).catch(error => {
|
||||
onError(error);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="machine-form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<FormInput register={register} id="name"
|
||||
formState={formState}
|
||||
rules={{ required: true }}
|
||||
label={t('app.admin.machine_form.name')} />
|
||||
<FormImageUpload setValue={setValue}
|
||||
register={register}
|
||||
control={control}
|
||||
formState={formState}
|
||||
rules={{ required: true }}
|
||||
id="machine_image_attributes"
|
||||
accept="image/*"
|
||||
defaultImage={output.machine_image_attributes}
|
||||
label={t('app.admin.machine_form.illustration')} />
|
||||
<FormRichText control={control}
|
||||
id="description"
|
||||
rules={{ required: true }}
|
||||
label={t('app.admin.machine_form.description')}
|
||||
limit={null}
|
||||
heading bulletList blockquote link video image />
|
||||
<FormRichText control={control}
|
||||
id="spec"
|
||||
rules={{ required: true }}
|
||||
label={t('app.admin.machine_form.technical_specifications')}
|
||||
limit={null}
|
||||
heading bulletList blockquote link video image />
|
||||
|
||||
<FormSwitch control={control}
|
||||
id="disabled"
|
||||
label={t('app.admin.machine_form.disable_machine')}
|
||||
tooltip={t('app.admin.machine_form.disabled_help')} />
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const MachineFormWrapper: React.FC<MachineFormProps> = (props) => {
|
||||
return (
|
||||
<Loader>
|
||||
<ErrorBoundary>
|
||||
<MachineForm {...props} />
|
||||
</ErrorBoundary>
|
||||
</Loader>
|
||||
);
|
||||
};
|
||||
|
||||
Application.Components.component('machineForm', react2angular(MachineFormWrapper, ['action', 'machine', 'onError', 'onSuccess']));
|
@ -100,10 +100,10 @@ export const MachinesList: React.FC<MachinesListProps> = ({ onError, onSuccess,
|
||||
);
|
||||
};
|
||||
|
||||
const MachinesListWrapper: React.FC<MachinesListProps> = ({ user, onError, onSuccess, onShowMachine, onReserveMachine, onLoginRequested, onEnrollRequested, canProposePacks }) => {
|
||||
const MachinesListWrapper: React.FC<MachinesListProps> = (props) => {
|
||||
return (
|
||||
<Loader>
|
||||
<MachinesList user={user} onError={onError} onSuccess={onSuccess} onShowMachine={onShowMachine} onReserveMachine={onReserveMachine} onLoginRequested={onLoginRequested} onEnrollRequested={onEnrollRequested} canProposePacks={canProposePacks}/>
|
||||
<MachinesList {...props} />
|
||||
</Loader>
|
||||
);
|
||||
};
|
||||
|
@ -34,7 +34,7 @@ export const PlansFilter: React.FC<PlansFilterProps> = ({ user, groups, onGroupS
|
||||
* Convert all groups to the react-select format
|
||||
*/
|
||||
const buildGroupOptions = (): Array<SelectOption<number>> => {
|
||||
return groups.filter(g => !g.disabled && g.slug !== 'admins').map(g => {
|
||||
return groups.filter(g => !g.disabled).map(g => {
|
||||
return { value: g.id, label: g.name };
|
||||
});
|
||||
};
|
||||
|
@ -235,7 +235,7 @@ export const PlansList: React.FC<PlansListProps> = ({ onError, onPlanSelection,
|
||||
{plans && Array.from(filteredPlans()).map(([groupId, plansByGroup]) => {
|
||||
return (
|
||||
<div key={groupId} className="plans-per-group">
|
||||
{plansByGroup.size > 0 && <h2 className="group-title">{ groupName(groupId) }</h2>}
|
||||
{plansByGroup?.size > 0 && <h2 className="group-title">{ groupName(groupId) }</h2>}
|
||||
{plansByGroup && renderPlansByCategory(plansByGroup)}
|
||||
</div>
|
||||
);
|
||||
|
@ -41,7 +41,7 @@ export const MachinesPricing: React.FC<MachinesPricingProps> = ({ onError, onSuc
|
||||
MachineAPI.index({ disabled: false })
|
||||
.then(data => setMachines(data))
|
||||
.catch(error => onError(error));
|
||||
GroupAPI.index({ disabled: false, admins: false })
|
||||
GroupAPI.index({ disabled: false })
|
||||
.then(data => setGroups(data))
|
||||
.catch(error => onError(error));
|
||||
PriceAPI.index({ priceable_type: 'Machine', plan_id: null })
|
||||
|
@ -38,7 +38,7 @@ export const SpacesPricing: React.FC<SpacesPricingProps> = ({ onError, onSuccess
|
||||
SpaceAPI.index()
|
||||
.then(data => setSpaces(data))
|
||||
.catch(error => onError(error));
|
||||
GroupAPI.index({ disabled: false, admins: false })
|
||||
GroupAPI.index({ disabled: false })
|
||||
.then(data => setGroups(data))
|
||||
.catch(error => onError(error));
|
||||
PriceAPI.index({ priceable_type: 'Space', plan_id: null })
|
||||
|
@ -10,20 +10,19 @@ import { FormSwitch } from '../form/form-switch';
|
||||
import { FormSelect } from '../form/form-select';
|
||||
import { FormChecklist } from '../form/form-checklist';
|
||||
import { FormRichText } from '../form/form-rich-text';
|
||||
import { FormFileUpload } from '../form/form-file-upload';
|
||||
import { FormImageUpload } from '../form/form-image-upload';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import { FabAlert } from '../base/fab-alert';
|
||||
import ProductCategoryAPI from '../../api/product-category';
|
||||
import MachineAPI from '../../api/machine';
|
||||
import ProductAPI from '../../api/product';
|
||||
import { Plus } from 'phosphor-react';
|
||||
import { ProductStockForm } from './product-stock-form';
|
||||
import { CloneProductModal } from './clone-product-modal';
|
||||
import ProductLib from '../../lib/product';
|
||||
import { UnsavedFormAlert } from '../form/unsaved-form-alert';
|
||||
import { UIRouter } from '@uirouter/angularjs';
|
||||
import { SelectOption, ChecklistOption } from '../../models/select';
|
||||
import { FormMultiFileUpload } from '../form/form-multi-file-upload';
|
||||
import { FormMultiImageUpload } from '../form/form-multi-image-upload';
|
||||
|
||||
interface ProductFormProps {
|
||||
product: Product,
|
||||
@ -149,101 +148,6 @@ export const ProductForm: React.FC<ProductFormProps> = ({ product, title, onSucc
|
||||
setOpenCloneModal(!openCloneModal);
|
||||
};
|
||||
|
||||
/**
|
||||
* Add new product file
|
||||
*/
|
||||
const addProductFile = () => {
|
||||
setValue('product_files_attributes', output.product_files_attributes.concat({}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove a product file
|
||||
*/
|
||||
const handleRemoveProductFile = (i: number) => {
|
||||
return () => {
|
||||
const productFile = output.product_files_attributes[i];
|
||||
if (!productFile.id) {
|
||||
output.product_files_attributes.splice(i, 1);
|
||||
setValue('product_files_attributes', output.product_files_attributes);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Add new product image
|
||||
*/
|
||||
const addProductImage = () => {
|
||||
setValue('product_images_attributes', output.product_images_attributes.concat({
|
||||
is_main: output.product_images_attributes.filter(i => i.is_main).length === 0
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove a product image
|
||||
*/
|
||||
const handleRemoveProductImage = (i: number) => {
|
||||
return () => {
|
||||
const productImage = output.product_images_attributes[i];
|
||||
if (!productImage.id) {
|
||||
output.product_images_attributes.splice(i, 1);
|
||||
if (productImage.is_main) {
|
||||
setValue('product_images_attributes', output.product_images_attributes.map((image, k) => {
|
||||
if (k === 0) {
|
||||
return {
|
||||
...image,
|
||||
is_main: true
|
||||
};
|
||||
}
|
||||
return image;
|
||||
}));
|
||||
} else {
|
||||
setValue('product_images_attributes', output.product_images_attributes);
|
||||
}
|
||||
} else {
|
||||
if (productImage.is_main) {
|
||||
let mainImage = false;
|
||||
setValue('product_images_attributes', output.product_images_attributes.map((image, k) => {
|
||||
if (i !== k && !mainImage) {
|
||||
mainImage = true;
|
||||
return {
|
||||
...image,
|
||||
_destroy: i === k,
|
||||
is_main: true
|
||||
};
|
||||
}
|
||||
return {
|
||||
...image,
|
||||
is_main: i === k ? false : image.is_main,
|
||||
_destroy: i === k
|
||||
};
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove main image in others product images
|
||||
*/
|
||||
const handleSetMainImage = (i: number) => {
|
||||
return () => {
|
||||
if (output.product_images_attributes.length > 1) {
|
||||
setValue('product_images_attributes', output.product_images_attributes.map((image, k) => {
|
||||
if (i !== k) {
|
||||
return {
|
||||
...image,
|
||||
is_main: false
|
||||
};
|
||||
}
|
||||
return {
|
||||
...image,
|
||||
is_main: true
|
||||
};
|
||||
}));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<header>
|
||||
@ -336,31 +240,12 @@ export const ProductForm: React.FC<ProductFormProps> = ({ product, title, onSucc
|
||||
<FabAlert level="warning">
|
||||
<HtmlTranslate trKey="app.admin.store.product_form.product_images_info" />
|
||||
</FabAlert>
|
||||
<div className="product-images">
|
||||
<div className="list">
|
||||
{output.product_images_attributes.map((image, i) => (
|
||||
<FormImageUpload key={i}
|
||||
defaultImage={image}
|
||||
id={`product_images_attributes[${i}]`}
|
||||
accept="image/*"
|
||||
size="small"
|
||||
<FormMultiImageUpload setValue={setValue}
|
||||
addButtonLabel={t('app.admin.store.product_form.add_product_image')}
|
||||
register={register}
|
||||
setValue={setValue}
|
||||
formState={formState}
|
||||
className={image._destroy ? 'hidden' : ''}
|
||||
mainOption={true}
|
||||
onFileRemove={handleRemoveProductImage(i)}
|
||||
onFileIsMain={handleSetMainImage(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<FabButton
|
||||
onClick={addProductImage}
|
||||
className='is-secondary'
|
||||
icon={<Plus size={24} />}>
|
||||
{t('app.admin.store.product_form.add_product_image')}
|
||||
</FabButton>
|
||||
</div>
|
||||
control={control}
|
||||
id="product_images_attributes"
|
||||
className="product-images" />
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
@ -413,27 +298,13 @@ export const ProductForm: React.FC<ProductFormProps> = ({ product, title, onSucc
|
||||
<FabAlert level="warning">
|
||||
<HtmlTranslate trKey="app.admin.store.product_form.product_files_info" />
|
||||
</FabAlert>
|
||||
<div className="product-documents">
|
||||
<div className="list">
|
||||
{output.product_files_attributes.map((file, i) => (
|
||||
<FormFileUpload key={i}
|
||||
defaultFile={file}
|
||||
id={`product_files_attributes[${i}]`}
|
||||
accept="application/pdf"
|
||||
register={register}
|
||||
setValue={setValue}
|
||||
formState={formState}
|
||||
className={file._destroy ? 'hidden' : ''}
|
||||
onFileRemove={handleRemoveProductFile(i)}/>
|
||||
))}
|
||||
</div>
|
||||
<FabButton
|
||||
onClick={addProductFile}
|
||||
className='is-secondary'
|
||||
icon={<Plus size={24} />}>
|
||||
{t('app.admin.store.product_form.add_product_file')}
|
||||
</FabButton>
|
||||
</div>
|
||||
<FormMultiFileUpload setValue={setValue}
|
||||
addButtonLabel={t('app.admin.store.product_form.add_product_file')}
|
||||
control={control}
|
||||
accept="application/pdf"
|
||||
register={register}
|
||||
id="product_files_attributes"
|
||||
className="product-documents" />
|
||||
</div>
|
||||
|
||||
<div className="main-actions">
|
||||
|
@ -45,7 +45,7 @@ const SupportingDocumentsTypesList: React.FC<SupportingDocumentsTypesListProps>
|
||||
|
||||
// get groups
|
||||
useEffect(() => {
|
||||
GroupAPI.index({ disabled: false, admins: false }).then(data => {
|
||||
GroupAPI.index({ disabled: false }).then(data => {
|
||||
setGroups(data);
|
||||
ProofOfIdentityTypeAPI.index().then(pData => {
|
||||
setSupportingDocumentsTypes(pData);
|
||||
|
@ -45,7 +45,7 @@ export const ChangeRoleModal: React.FC<ChangeRoleModalProps> = ({ isOpen, toggle
|
||||
const [selectedRole, setSelectedRole] = useState<UserRole>(user.role);
|
||||
|
||||
useEffect(() => {
|
||||
GroupAPI.index({ disabled: false, admins: false }).then(setGroups).catch(onError);
|
||||
GroupAPI.index({ disabled: false }).then(setGroups).catch(onError);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
|
@ -72,7 +72,7 @@ export const UserProfileForm: React.FC<UserProfileFormProps> = ({ action, size,
|
||||
setIsLocalDatabaseProvider(data.providable_type === 'DatabaseProvider');
|
||||
}).catch(error => onError(error));
|
||||
if (showGroupInput) {
|
||||
GroupAPI.index({ disabled: false, admins: user.role === 'admin' }).then(data => {
|
||||
GroupAPI.index({ disabled: false }).then(data => {
|
||||
setGroups(buildOptions(data));
|
||||
}).catch(error => onError(error));
|
||||
}
|
||||
@ -150,11 +150,6 @@ export const UserProfileForm: React.FC<UserProfileFormProps> = ({ action, size,
|
||||
* Check if the given field path should be disabled
|
||||
*/
|
||||
const isDisabled = function (id: string) {
|
||||
// never allows admins to change their group
|
||||
if (id === 'group_id' && user.role === 'admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// if the current provider is the local database, then all fields are enabled
|
||||
if (isLocalDatabaseProvider) {
|
||||
return false;
|
||||
|
@ -584,7 +584,7 @@ Application.Controllers.controller('NewEventController', ['$scope', '$state', 'C
|
||||
end_date: new Date(),
|
||||
start_time: new Date(),
|
||||
end_time: new Date(),
|
||||
all_day: 'false',
|
||||
all_day: true,
|
||||
recurrence: 'none',
|
||||
category_id: null,
|
||||
prices: []
|
||||
|
@ -38,7 +38,7 @@
|
||||
class MembersController {
|
||||
constructor ($scope, $state, Group, Training) {
|
||||
// Retrieve the profiles groups (e.g. students ...)
|
||||
Group.query(function (groups) { $scope.groups = groups.filter(function (g) { return (g.slug !== 'admins') && !g.disabled; }); });
|
||||
Group.query(function (groups) { $scope.groups = groups.filter(function (g) { return !g.disabled; }); });
|
||||
|
||||
// Retrieve the list of available trainings
|
||||
Training.query().$promise.then(function (data) {
|
||||
@ -1118,8 +1118,8 @@ Application.Controllers.controller('ImportMembersResultController', ['$scope', '
|
||||
/**
|
||||
* Controller used in the admin creation page (admin view)
|
||||
*/
|
||||
Application.Controllers.controller('NewAdminController', ['$state', '$scope', 'Admin', 'growl', '_t', 'settingsPromise',
|
||||
function ($state, $scope, Admin, growl, _t, settingsPromise) {
|
||||
Application.Controllers.controller('NewAdminController', ['$state', '$scope', 'Admin', 'growl', '_t', 'settingsPromise', 'groupsPromise',
|
||||
function ($state, $scope, Admin, growl, _t, settingsPromise, groupsPromise) {
|
||||
// default admin profile
|
||||
let getGender;
|
||||
$scope.admin = {
|
||||
@ -1145,6 +1145,9 @@ Application.Controllers.controller('NewAdminController', ['$state', '$scope', 'A
|
||||
// is the address required in _admin_form?
|
||||
$scope.addressRequired = (settingsPromise.address_required === 'true');
|
||||
|
||||
// all available groups
|
||||
$scope.groups = groupsPromise;
|
||||
|
||||
/**
|
||||
* Shows the birthday datepicker
|
||||
*/
|
||||
@ -1208,7 +1211,7 @@ Application.Controllers.controller('NewManagerController', ['$state', '$scope',
|
||||
};
|
||||
|
||||
// list of all groups
|
||||
$scope.groups = groupsPromise.filter(function (g) { return (g.slug !== 'admins') && !g.disabled; });
|
||||
$scope.groups = groupsPromise.filter(function (g) { return !g.disabled; });
|
||||
|
||||
// list of all tags
|
||||
$scope.tags = tagsPromise;
|
||||
|
@ -27,7 +27,7 @@ class PlanController {
|
||||
|
||||
// groups list
|
||||
$scope.groups = groups
|
||||
.filter(function (g) { return (g.slug !== 'admins') && !g.disabled; })
|
||||
.filter(function (g) { return !g.disabled; })
|
||||
.map(e => Object.assign({}, e, { category: 'app.shared.plan.groups', id: `${e.id}` }));
|
||||
$scope.groups.push({ id: 'all', name: 'app.shared.plan.transversal_all_groups', category: 'app.shared.plan.all' });
|
||||
|
||||
|
@ -30,8 +30,8 @@ Application.Controllers.controller('EditPricingController', ['$scope', '$state',
|
||||
$scope.enabledPlans = plans.filter(function (p) { return !p.disabled; });
|
||||
|
||||
// List of groups (eg. normal, student ...)
|
||||
$scope.groups = groups.filter(function (g) { return g.slug !== 'admins'; });
|
||||
$scope.enabledGroups = groups.filter(function (g) { return (g.slug !== 'admins') && !g.disabled; });
|
||||
$scope.groups = groups;
|
||||
$scope.enabledGroups = groups.filter(function (g) { return !g.disabled; });
|
||||
|
||||
// List of all plan-categories
|
||||
$scope.planCategories = planCategories;
|
||||
|
@ -118,9 +118,7 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
|
||||
// retrieve the groups (standard, student ...)
|
||||
Group.query(function (groups) {
|
||||
$scope.groups = groups;
|
||||
$scope.enabledGroups = groups.filter(function (g) {
|
||||
return (g.slug !== 'admins') && !g.disabled;
|
||||
});
|
||||
$scope.enabledGroups = groups.filter(g => !g.disabled);
|
||||
});
|
||||
|
||||
// retrieve the CGU
|
||||
|
@ -263,7 +263,21 @@ Application.Controllers.controller('EditMachineController', ['$scope', '$state',
|
||||
$scope.method = 'put';
|
||||
|
||||
// Retrieve the details for the machine id in the URL, if an error occurs redirect the user to the machines list
|
||||
$scope.machine = machinePromise;
|
||||
$scope.machine = cleanMachine(machinePromise);
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
|
||||
/* PRIVATE SCOPE */
|
||||
|
||||
@ -277,6 +291,13 @@ Application.Controllers.controller('EditMachineController', ['$scope', '$state',
|
||||
return new MachinesController($scope, $state);
|
||||
};
|
||||
|
||||
// prepare the machine for the react-hook-form
|
||||
function cleanMachine (machine) {
|
||||
delete machine.$promise;
|
||||
delete machine.$resolved;
|
||||
return machine;
|
||||
}
|
||||
|
||||
// !!! MUST BE CALLED AT THE END of the controller
|
||||
return initialize();
|
||||
}
|
||||
@ -403,7 +424,7 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$tran
|
||||
// the moment when the slot selection changed for the last time, used to trigger changes in the cart
|
||||
$scope.selectionTime = null;
|
||||
|
||||
// the last clicked event in the calender
|
||||
// the last clicked event in the calendar
|
||||
$scope.selectedEvent = null;
|
||||
|
||||
// the application global settings
|
||||
|
@ -161,7 +161,7 @@ Application.Controllers.controller('EditProfileController', ['$scope', '$rootSco
|
||||
* Check if it is allowed the change the group of the current user
|
||||
*/
|
||||
$scope.isAllowedChangingGroup = function () {
|
||||
return !$scope.user.subscribed_plan?.name && $scope.user.role !== 'admin';
|
||||
return !$scope.user.subscribed_plan?.name;
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -231,7 +231,7 @@ class ProjectsController {
|
||||
const asciiName = Diacritics.remove(nameLookup);
|
||||
|
||||
Member.search(
|
||||
{ query: asciiName, include_admins: 'true' },
|
||||
{ query: asciiName },
|
||||
function (users) { $scope.matchingMembers = users; },
|
||||
function (error) { console.error(error); }
|
||||
);
|
||||
|
@ -291,13 +291,11 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the currently logged user has the 'admin' role OR the 'manager' role, but is not taking reseravtion for himself
|
||||
* Check if the currently logged user has the 'admin' OR 'manager' role, but is not taking reseravtion for himself
|
||||
* @returns {boolean}
|
||||
*/
|
||||
$scope.isAuthorized = function () {
|
||||
if (AuthService.isAuthorized('admin')) return true;
|
||||
|
||||
if (AuthService.isAuthorized('manager')) {
|
||||
if (AuthService.isAuthorized(['admin', 'manager'])) {
|
||||
return ($rootScope.currentUser.id !== $scope.user.id);
|
||||
}
|
||||
|
||||
@ -823,11 +821,10 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
|
||||
return Wallet.getWalletByUser({ user_id: $scope.user.id }, function (wallet) {
|
||||
const amountToPay = helpers.getAmountToPay($scope.amountTotal, wallet.amount);
|
||||
if ((AuthService.isAuthorized(['member']) && (amountToPay > 0 || (amountToPay === 0 && hasOtherDeadlines()))) ||
|
||||
(AuthService.isAuthorized('manager') && $scope.user.id === $rootScope.currentUser.id && amountToPay > 0)) {
|
||||
($scope.user.id === $rootScope.currentUser.id && amountToPay > 0)) {
|
||||
return payOnline(items);
|
||||
} else {
|
||||
if (AuthService.isAuthorized(['admin']) ||
|
||||
(AuthService.isAuthorized('manager') && $scope.user.id !== $rootScope.currentUser.id) ||
|
||||
if (AuthService.isAuthorized(['admin', 'manager'] && $scope.user.id !== $rootScope.currentUser.id) ||
|
||||
(amountToPay === 0 && !hasOtherDeadlines())) {
|
||||
return payOnSite(items);
|
||||
}
|
||||
|
@ -2,11 +2,11 @@ import _ from 'lodash';
|
||||
import { ApiFilter } from '../models/api';
|
||||
|
||||
export default class ApiLib {
|
||||
static filtersToQuery (filters?: ApiFilter): string {
|
||||
static filtersToQuery (filters?: ApiFilter, keepNullValues = true): string {
|
||||
if (!filters) return '';
|
||||
|
||||
return '?' + Object.entries(filters)
|
||||
.filter(filter => !_.isNil(filter[1]))
|
||||
.filter(filter => keepNullValues || !_.isNil(filter[1]))
|
||||
.map(filter => `${filter[0]}=${filter[1]}`)
|
||||
.join('&');
|
||||
}
|
||||
|
29
app/frontend/src/javascript/lib/file-upload.ts
Normal file
29
app/frontend/src/javascript/lib/file-upload.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { Path } from 'react-hook-form';
|
||||
import { UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form';
|
||||
import { FieldPathValue } from 'react-hook-form/dist/types/path';
|
||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||
import { FileType } from '../models/file';
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
export default class FileUploadLib {
|
||||
public static onRemoveFile<TFieldValues extends FieldValues> (file: FileType, id: string, setFile: Dispatch<SetStateAction<FileType>>, setValue: UseFormSetValue<TFieldValues>, onFileRemove: () => void) {
|
||||
if (file?.id) {
|
||||
setValue(
|
||||
`${id}._destroy` as Path<TFieldValues>,
|
||||
true as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
|
||||
);
|
||||
}
|
||||
setValue(
|
||||
`${id}.attachment_files` as Path<TFieldValues>,
|
||||
null as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
|
||||
);
|
||||
setFile(null);
|
||||
if (typeof onFileRemove === 'function') {
|
||||
onFileRemove();
|
||||
}
|
||||
}
|
||||
|
||||
public static hasFile (file: FileType): boolean {
|
||||
return file?.attachment_name && !file?._destroy;
|
||||
}
|
||||
}
|
@ -13,9 +13,7 @@ export default class UserLib {
|
||||
* Check if the current user has privileged access for resources concerning the provided customer
|
||||
*/
|
||||
isPrivileged = (customer: User): boolean => {
|
||||
if (this.user?.role === 'admin') return true;
|
||||
|
||||
if (this.user?.role === 'manager') {
|
||||
if (this.user?.role === 'admin' || this.user?.role === 'manager') {
|
||||
return (this.user?.id !== customer.id);
|
||||
}
|
||||
|
||||
|
10
app/frontend/src/javascript/models/file.ts
Normal file
10
app/frontend/src/javascript/models/file.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export interface FileType {
|
||||
id?: number|string,
|
||||
attachment_name?: string,
|
||||
attachment_url?: string,
|
||||
_destroy?: boolean
|
||||
}
|
||||
|
||||
export interface ImageType extends FileType {
|
||||
is_main?: boolean
|
||||
}
|
@ -1,8 +1,7 @@
|
||||
import { ApiFilter } from './api';
|
||||
|
||||
export interface GroupIndexFilter extends ApiFilter {
|
||||
disabled?: boolean,
|
||||
admins?: boolean,
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
|
@ -1,23 +1,20 @@
|
||||
import { Reservation } from './reservation';
|
||||
import { ApiFilter } from './api';
|
||||
import { FileType } from './file';
|
||||
|
||||
export interface MachineIndexFilter extends ApiFilter {
|
||||
disabled: boolean,
|
||||
}
|
||||
|
||||
export interface Machine {
|
||||
id: number,
|
||||
id?: number,
|
||||
name: string,
|
||||
description?: string,
|
||||
spec?: string,
|
||||
disabled: boolean,
|
||||
slug: string,
|
||||
machine_image: string,
|
||||
machine_files_attributes?: Array<{
|
||||
id: number,
|
||||
attachment: string,
|
||||
attachment_url: string
|
||||
}>,
|
||||
machine_image_attributes: FileType,
|
||||
machine_files_attributes?: Array<FileType>,
|
||||
trainings?: Array<{
|
||||
id: number,
|
||||
name: string,
|
||||
|
@ -73,8 +73,8 @@ export const stockMovementAllReasons = [...stockMovementInReasons, ...stockMovem
|
||||
export type StockMovementReason = typeof stockMovementAllReasons[number];
|
||||
|
||||
export interface Stock {
|
||||
internal: number,
|
||||
external: number,
|
||||
internal?: number,
|
||||
external?: number,
|
||||
}
|
||||
|
||||
export type ProductsIndex = PaginatedIndex<Product>;
|
||||
@ -99,21 +99,21 @@ export interface StockMovementIndexFilter extends ApiFilter {
|
||||
|
||||
export interface Product {
|
||||
id?: number,
|
||||
name: string,
|
||||
slug: string,
|
||||
name?: string,
|
||||
slug?: string,
|
||||
sku?: string,
|
||||
description?: string,
|
||||
is_active: boolean,
|
||||
is_active?: boolean,
|
||||
product_category_id?: number,
|
||||
is_active_price?: boolean,
|
||||
amount?: number,
|
||||
quantity_min?: number,
|
||||
stock: Stock,
|
||||
low_stock_alert: boolean,
|
||||
stock?: Stock,
|
||||
low_stock_alert?: boolean,
|
||||
low_stock_threshold?: number,
|
||||
machine_ids: number[],
|
||||
machine_ids?: number[],
|
||||
created_at?: TDateISO,
|
||||
product_files_attributes: Array<{
|
||||
product_files_attributes?: Array<{
|
||||
id?: number,
|
||||
attachment?: File,
|
||||
attachment_files?: FileList,
|
||||
@ -121,7 +121,7 @@ export interface Product {
|
||||
attachment_url?: string,
|
||||
_destroy?: boolean
|
||||
}>,
|
||||
product_images_attributes: Array<{
|
||||
product_images_attributes?: Array<{
|
||||
id?: number,
|
||||
attachment?: File,
|
||||
attachment_files?: FileList,
|
||||
|
@ -1084,7 +1084,8 @@ angular.module('application.router', ['ui.router'])
|
||||
}
|
||||
},
|
||||
resolve: {
|
||||
settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['phone_required', 'address_required']" }).$promise; }]
|
||||
settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['phone_required', 'address_required']" }).$promise; }],
|
||||
groupsPromise: ['Group', function (Group) { return Group.query({ disabled: false }).$promise; }]
|
||||
}
|
||||
})
|
||||
.state('app.admin.managers_new', {
|
||||
|
@ -41,6 +41,8 @@
|
||||
@import "modules/events/event";
|
||||
@import "modules/form/abstract-form-item";
|
||||
@import "modules/form/form-input";
|
||||
@import "modules/form/form-multi-file-upload";
|
||||
@import "modules/form/form-multi-image-upload";
|
||||
@import "modules/form/form-rich-text";
|
||||
@import "modules/form/form-select";
|
||||
@import "modules/form/form-switch";
|
||||
|
@ -14,6 +14,12 @@
|
||||
@include base;
|
||||
}
|
||||
|
||||
&.with-label {
|
||||
margin-top: 2.6rem;
|
||||
position: relative;
|
||||
margin-bottom: 1.6rem;
|
||||
}
|
||||
|
||||
.image {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
@ -51,5 +57,11 @@
|
||||
.image-file-input {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.form-item-header {
|
||||
position: absolute;
|
||||
top: -1.5em;
|
||||
left: 0;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,11 @@
|
||||
.form-multi-file-upload {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.list {
|
||||
margin-bottom: 2.4rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(440px, 1fr));
|
||||
gap: 2.4rem;
|
||||
}
|
||||
button { margin-left: auto; }
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
.form-multi-image-upload {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.list {
|
||||
margin-bottom: 2.4rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(440px, 1fr));
|
||||
gap: 2.4rem;
|
||||
}
|
||||
button { margin-left: auto; }
|
||||
}
|
@ -49,18 +49,8 @@
|
||||
border-top-right-radius: 5px;
|
||||
position: relative;
|
||||
|
||||
&.no-picture::before {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
content: '\f03e';
|
||||
font-family: 'Font Awesome 5 Free' !important;
|
||||
font-weight: 900;
|
||||
font-size: 80px;
|
||||
color: #ebebeb;
|
||||
&.no-picture {
|
||||
background-image: url('../../../../images/default-image.png');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -50,7 +50,7 @@
|
||||
flex: 1 1 320px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.header-switch {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@ -65,18 +65,4 @@
|
||||
gap: 0 3.2rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.product-images,
|
||||
.product-documents {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.list {
|
||||
margin-bottom: 2.4rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(440px, 1fr));
|
||||
gap: 2.4rem;
|
||||
}
|
||||
button { margin-left: auto; }
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -37,7 +37,7 @@
|
||||
<i class="fa fa-times"></i>
|
||||
</button>
|
||||
</form>
|
||||
<div class="buttons" ng-hide="rowform.$visible || group.slug === 'admins'">
|
||||
<div class="buttons" ng-hide="rowform.$visible">
|
||||
<button class="btn btn-default" ng-click="rowform.$show()">
|
||||
<i class="fa fa-edit"></i> <span class="hidden-xs hidden-sm" translate>{{ 'app.shared.buttons.edit' }}</span>
|
||||
</button>
|
||||
|
@ -31,6 +31,9 @@
|
||||
<td>{{ admin.email }}</td>
|
||||
<td>{{ admin.profile_attributes.phone }}</td>
|
||||
<td>
|
||||
<button class="btn btn-default edit-member" ui-sref="app.admin.members_edit({id: admin.id})">
|
||||
<i class="fa fa-edit"></i>
|
||||
</button>
|
||||
<button class="btn btn-danger" ng-if="isAuthorized('admin') && admin.id != currentUser.id" ng-click="destroyAdmin(admins, admin)">
|
||||
<i class="fa fa-trash-o"></i>
|
||||
</button>
|
||||
|
@ -24,6 +24,11 @@
|
||||
|
||||
<div class="row no-gutter">
|
||||
<div class="col-sm-12 col-md-12 col-lg-9 b-r-lg nopadding">
|
||||
<!-- <div class="panel panel-default bg-light m-lg">-->
|
||||
<!-- <div class="panel-body m-r">-->
|
||||
<!-- <machine-form action="'update'" machine="machine" on-error="onError" on-success="onSuccess"></machine-form>-->
|
||||
<!-- </div>-->
|
||||
<!-- </div>-->
|
||||
<ng-include src="'/machines/_form.html'"></ng-include>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -119,6 +119,19 @@
|
||||
ng-required="phoneRequired">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ng-class="{'has-error': adminForm['admin[group_id]'].$dirty && adminForm['admin[group_id]'].$invalid}">
|
||||
<div class="input-group">
|
||||
<span class="input-group-addon"><i class="fa fa-group"></i> <span class="exponent"><i class="fa fa-asterisk" aria-hidden="true"></i></span></span>
|
||||
<select ng-model="admin.group_id"
|
||||
name="admin[group_id]"
|
||||
class="form-control"
|
||||
id="group_id"
|
||||
required
|
||||
ng-options="g.id as g.name for g in groups">
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
@ -19,7 +19,7 @@ class AccountingPeriod < ApplicationRecord
|
||||
validates_with PeriodOverlapValidator
|
||||
validates_with PeriodIntegrityValidator
|
||||
|
||||
belongs_to :user, class_name: 'User', foreign_key: 'closed_by'
|
||||
belongs_to :user, class_name: 'User', foreign_key: 'closed_by', inverse_of: :accounting_periods
|
||||
|
||||
def delete
|
||||
false
|
||||
|
@ -47,6 +47,6 @@ class CartItem::MachineReservation < CartItem::Reservation
|
||||
return 0 if @plan.nil?
|
||||
|
||||
machine_credit = @plan.machine_credits.find { |credit| credit.creditable_id == @reservable.id }
|
||||
credits_hours(machine_credit, @new_subscription)
|
||||
credits_hours(machine_credit, new_plan_being_bought: @new_subscription)
|
||||
end
|
||||
end
|
||||
|
@ -23,7 +23,7 @@ class CartItem::Reservation < CartItem::BaseItem
|
||||
amount = 0
|
||||
|
||||
hours_available = credits
|
||||
grouped_slots.values.each do |slots|
|
||||
grouped_slots.each_value do |slots|
|
||||
prices = applicable_prices(slots)
|
||||
slots.each_with_index do |slot, index|
|
||||
amount += get_slot_price_from_prices(prices, slot, is_privileged,
|
||||
@ -100,7 +100,7 @@ class CartItem::Reservation < CartItem::BaseItem
|
||||
slot_minutes = (slot[:slot_attributes][:end_at].to_time - slot[:slot_attributes][:start_at].to_time) / SECONDS_PER_MINUTE
|
||||
price = prices[:prices].find { |p| p[:duration] <= slot_minutes && p[:duration].positive? }
|
||||
price = prices[:prices].first if price.nil?
|
||||
hourly_rate = (price[:price].amount.to_f / price[:price].duration) * MINUTES_PER_HOUR
|
||||
hourly_rate = ((Rational(price[:price].amount.to_f) / Rational(price[:price].duration)) * Rational(MINUTES_PER_HOUR)).to_f
|
||||
|
||||
# apply the base price to the real slot duration
|
||||
real_price = get_slot_price(hourly_rate, slot, is_privileged, options)
|
||||
@ -130,7 +130,7 @@ class CartItem::Reservation < CartItem::BaseItem
|
||||
slot_minutes = (slot[:slot_attributes][:end_at].to_time - slot[:slot_attributes][:start_at].to_time) / SECONDS_PER_MINUTE
|
||||
# apply the base price to the real slot duration
|
||||
real_price = if options[:is_division]
|
||||
(slot_rate / MINUTES_PER_HOUR) * slot_minutes
|
||||
((Rational(slot_rate) / Rational(MINUTES_PER_HOUR)) * Rational(slot_minutes)).to_f
|
||||
else
|
||||
slot_rate
|
||||
end
|
||||
@ -138,7 +138,7 @@ class CartItem::Reservation < CartItem::BaseItem
|
||||
if real_price.positive? && options[:prepaid][:minutes]&.positive?
|
||||
consumed = slot_minutes
|
||||
consumed = options[:prepaid][:minutes] if slot_minutes > options[:prepaid][:minutes]
|
||||
real_price = (slot_minutes - consumed) * (slot_rate / MINUTES_PER_HOUR)
|
||||
real_price = (Rational(slot_minutes - consumed) * (Rational(slot_rate) / Rational(MINUTES_PER_HOUR))).to_f
|
||||
options[:prepaid][:minutes] -= consumed
|
||||
end
|
||||
|
||||
@ -158,7 +158,9 @@ class CartItem::Reservation < CartItem::BaseItem
|
||||
# and the base price (1 hours), we use the 7 hours price, then 3 hours price, and finally the base price twice (7+3+1+1 = 12).
|
||||
# All these prices are returned to be applied to the reservation.
|
||||
def applicable_prices(slots)
|
||||
total_duration = slots.map { |slot| (slot[:slot_attributes][:end_at].to_time - slot[:slot_attributes][:start_at].to_time) / SECONDS_PER_MINUTE }.reduce(:+)
|
||||
total_duration = slots.map do |slot|
|
||||
(slot[:slot_attributes][:end_at].to_time - slot[:slot_attributes][:start_at].to_time) / SECONDS_PER_MINUTE
|
||||
end.reduce(:+)
|
||||
rates = { prices: [] }
|
||||
|
||||
remaining_duration = total_duration
|
||||
@ -182,7 +184,7 @@ class CartItem::Reservation < CartItem::BaseItem
|
||||
##
|
||||
# Compute the number of remaining hours in the users current credits (for machine or space)
|
||||
##
|
||||
def credits_hours(credits, new_plan_being_bought = false)
|
||||
def credits_hours(credits, new_plan_being_bought: false)
|
||||
return 0 unless credits
|
||||
|
||||
hours_available = credits.hours
|
||||
|
@ -41,6 +41,6 @@ class CartItem::SpaceReservation < CartItem::Reservation
|
||||
return 0 if @plan.nil?
|
||||
|
||||
space_credit = @plan.space_credits.find { |credit| credit.creditable_id == @reservable.id }
|
||||
credits_hours(space_credit, @new_subscription)
|
||||
credits_hours(space_credit, new_plan_being_bought: @new_subscription)
|
||||
end
|
||||
end
|
||||
|
@ -13,27 +13,8 @@ module SingleSignOnConcern
|
||||
## Retrieve the requested data in the User and user's Profile tables
|
||||
## @param sso_mapping {String} must be of form 'user._field_' or 'profile._field_'. Eg. 'user.email'
|
||||
def get_data_from_sso_mapping(sso_mapping)
|
||||
parsed = /^(user|profile)\.(.+)$/.match(sso_mapping)
|
||||
if parsed[1] == 'user'
|
||||
self[parsed[2].to_sym]
|
||||
elsif parsed[1] == 'profile'
|
||||
case sso_mapping
|
||||
when 'profile.avatar'
|
||||
profile.user_avatar.remote_attachment_url
|
||||
when 'profile.address'
|
||||
invoicing_profile.address&.address
|
||||
when 'profile.organization_name'
|
||||
invoicing_profile.organization&.name
|
||||
when 'profile.organization_address'
|
||||
invoicing_profile.organization&.address&.address
|
||||
when 'profile.gender'
|
||||
statistic_profile.gender
|
||||
when 'profile.birthday'
|
||||
statistic_profile.birthday
|
||||
else
|
||||
profile[parsed[2].to_sym]
|
||||
end
|
||||
end
|
||||
service = UserSetterService.new(self)
|
||||
service.read_attribute(sso_mapping)
|
||||
end
|
||||
|
||||
## Set some data on the current user, according to the sso_key given
|
||||
@ -42,36 +23,8 @@ module SingleSignOnConcern
|
||||
def set_data_from_sso_mapping(sso_mapping, data)
|
||||
return if data.nil? || data.blank?
|
||||
|
||||
if sso_mapping.to_s.start_with? 'user.'
|
||||
self[sso_mapping[5..-1].to_sym] = data
|
||||
elsif sso_mapping.to_s.start_with? 'profile.'
|
||||
case sso_mapping.to_s
|
||||
when 'profile.avatar'
|
||||
profile.user_avatar ||= UserAvatar.new
|
||||
profile.user_avatar.remote_attachment_url = data
|
||||
when 'profile.address'
|
||||
self.invoicing_profile ||= InvoicingProfile.new
|
||||
self.invoicing_profile.address ||= Address.new
|
||||
self.invoicing_profile.address.address = data
|
||||
when 'profile.organization_name'
|
||||
self.invoicing_profile ||= InvoicingProfile.new
|
||||
self.invoicing_profile.organization ||= Organization.new
|
||||
self.invoicing_profile.organization.name = data
|
||||
when 'profile.organization_address'
|
||||
self.invoicing_profile ||= InvoicingProfile.new
|
||||
self.invoicing_profile.organization ||= Organization.new
|
||||
self.invoicing_profile.organization.address ||= Address.new
|
||||
self.invoicing_profile.organization.address.address = data
|
||||
when 'profile.gender'
|
||||
self.statistic_profile ||= StatisticProfile.new
|
||||
self.statistic_profile.gender = data
|
||||
when 'profile.birthday'
|
||||
self.statistic_profile ||= StatisticProfile.new
|
||||
self.statistic_profile.birthday = data
|
||||
else
|
||||
profile[sso_mapping[8..-1].to_sym] = data
|
||||
end
|
||||
end
|
||||
service = UserSetterService.new(self)
|
||||
service.assign_attibute(sso_mapping, data)
|
||||
|
||||
return if mapped_from_sso&.include?(sso_mapping)
|
||||
|
||||
@ -80,7 +33,7 @@ module SingleSignOnConcern
|
||||
|
||||
## used to allow the migration of existing users between authentication providers
|
||||
def generate_auth_migration_token
|
||||
update_attributes(auth_token: Devise.friendly_token)
|
||||
update(auth_token: Devise.friendly_token)
|
||||
end
|
||||
|
||||
## link the current user to the given provider (omniauth attributes hash)
|
||||
@ -93,7 +46,7 @@ module SingleSignOnConcern
|
||||
raise DuplicateIndexError, "This #{active_provider.name} account is already linked to an existing user"
|
||||
end
|
||||
|
||||
update_attributes(provider: auth.provider, uid: auth.uid, auth_token: nil)
|
||||
update(provider: auth.provider, uid: auth.uid, auth_token: nil)
|
||||
end
|
||||
|
||||
## Merge the provided User's SSO details into the current user and drop the provided user to ensure the unity
|
||||
@ -118,13 +71,16 @@ module SingleSignOnConcern
|
||||
|
||||
# update the user's profile to set the data managed by the SSO
|
||||
auth_provider = AuthProvider.from_strategy_name(sso_user.provider)
|
||||
logger.debug "found auth_provider=#{auth_provider.name}"
|
||||
auth_provider.sso_fields.each do |field|
|
||||
logger.debug "found auth_provider=#{auth_provider&.name}"
|
||||
auth_provider&.sso_fields&.each do |field|
|
||||
value = sso_user.get_data_from_sso_mapping(field)
|
||||
logger.debug "mapping sso field #{field} with value=#{value}"
|
||||
# we do not merge the email field if its end with the special value '-duplicate' as this means
|
||||
# that the user is currently merging with the account that have the same email than the sso
|
||||
set_data_from_sso_mapping(field, value) unless (field == 'user.email' && value.end_with?('-duplicate')) || (field == 'user.group_id' && sso_user.admin?)
|
||||
# We do not merge the email field if its end with the special value '-duplicate' as this means
|
||||
# that the user is currently merging with the account that have the same email than the sso.
|
||||
# Moreover, if the user is an administrator, we must keep him in his group
|
||||
unless (field == 'user.email' && value.end_with?('-duplicate')) || (field == 'user.group_id' && admin?)
|
||||
set_data_from_sso_mapping(field, value)
|
||||
end
|
||||
end
|
||||
|
||||
# run the account transfer in an SQL transaction to ensure data integrity
|
||||
|
@ -83,7 +83,8 @@ class Coupon < ApplicationRecord
|
||||
end
|
||||
|
||||
def users
|
||||
invoices.map(&:user).uniq(&:id)
|
||||
# compact to user removed
|
||||
invoices.map(&:user).compact.uniq(&:id)
|
||||
end
|
||||
|
||||
def users_ids
|
||||
|
@ -2,17 +2,15 @@
|
||||
|
||||
# Group is way to bind users with prices. Different prices can be defined for each plan/reservable, for each group
|
||||
class Group < ApplicationRecord
|
||||
has_many :plans
|
||||
has_many :users
|
||||
has_many :statistic_profiles
|
||||
has_many :plans, dependent: :destroy
|
||||
has_many :users, dependent: :nullify
|
||||
has_many :statistic_profiles, dependent: :nullify
|
||||
has_many :trainings_pricings, dependent: :destroy
|
||||
has_many :machines_prices, -> { where(priceable_type: 'Machine') }, class_name: 'Price', dependent: :destroy
|
||||
has_many :spaces_prices, -> { where(priceable_type: 'Space') }, class_name: 'Price', dependent: :destroy
|
||||
has_many :machines_prices, -> { where(priceable_type: 'Machine') }, class_name: 'Price', dependent: :destroy, inverse_of: :group
|
||||
has_many :spaces_prices, -> { where(priceable_type: 'Space') }, class_name: 'Price', dependent: :destroy, inverse_of: :group
|
||||
has_many :proof_of_identity_types_groups, dependent: :destroy
|
||||
has_many :proof_of_identity_types, through: :proof_of_identity_types_groups
|
||||
|
||||
scope :all_except_admins, -> { where.not(slug: 'admins') }
|
||||
|
||||
extend FriendlyId
|
||||
friendly_id :name, use: :slugged
|
||||
|
||||
@ -41,26 +39,26 @@ class Group < ApplicationRecord
|
||||
end
|
||||
|
||||
def create_trainings_pricings
|
||||
Training.all.each do |training|
|
||||
Training.find_each do |training|
|
||||
TrainingsPricing.create(group: self, training: training, amount: 0)
|
||||
end
|
||||
end
|
||||
|
||||
def create_machines_prices
|
||||
Machine.all.each do |machine|
|
||||
Machine.find_each do |machine|
|
||||
Price.create(priceable: machine, group: self, amount: 0)
|
||||
end
|
||||
end
|
||||
|
||||
def create_spaces_prices
|
||||
Space.all.each do |space|
|
||||
Space.find_each do |space|
|
||||
Price.create(priceable: space, group: self, amount: 0)
|
||||
end
|
||||
end
|
||||
|
||||
def create_statistic_subtype
|
||||
user_index = StatisticIndex.find_by(es_type_key: 'user')
|
||||
StatisticSubType.create!( statistic_types: user_index.statistic_types, key: slug, label: name)
|
||||
StatisticSubType.create!(statistic_types: user_index.statistic_types, key: slug, label: name)
|
||||
end
|
||||
|
||||
def update_statistic_subtype
|
||||
@ -74,7 +72,7 @@ class Group < ApplicationRecord
|
||||
|
||||
def disable_plans
|
||||
plans.each do |plan|
|
||||
plan.update_attributes(disabled: disabled)
|
||||
plan.update(disabled: disabled)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -13,15 +13,17 @@ class Invoice < PaymentDocument
|
||||
belongs_to :wallet_transaction
|
||||
belongs_to :coupon
|
||||
|
||||
has_one :avoir, class_name: 'Invoice', foreign_key: :invoice_id, dependent: :destroy
|
||||
has_one :payment_schedule_item
|
||||
has_one :payment_gateway_object, as: :item
|
||||
belongs_to :operator_profile, foreign_key: :operator_profile_id, class_name: 'InvoicingProfile'
|
||||
has_one :avoir, class_name: 'Invoice', dependent: :destroy, inverse_of: :avoir
|
||||
has_one :payment_schedule_item, dependent: :nullify
|
||||
has_one :payment_gateway_object, as: :item, dependent: :destroy
|
||||
belongs_to :operator_profile, class_name: 'InvoicingProfile'
|
||||
|
||||
delegate :user, to: :invoicing_profile
|
||||
|
||||
before_create :add_environment
|
||||
after_create :update_reference, :chain_record
|
||||
after_commit :generate_and_send_invoice, on: [:create], if: :persisted?
|
||||
after_update :log_changes
|
||||
after_commit :generate_and_send_invoice, on: [:create], if: :persisted?
|
||||
|
||||
validates_with ClosedPeriodValidator
|
||||
|
||||
@ -44,10 +46,6 @@ class Invoice < PaymentDocument
|
||||
"#{prefix}-#{id}_#{created_at.strftime('%d%m%Y')}.pdf"
|
||||
end
|
||||
|
||||
def user
|
||||
invoicing_profile.user
|
||||
end
|
||||
|
||||
def order_number
|
||||
PaymentDocumentService.generate_order_number(self)
|
||||
end
|
||||
@ -75,7 +73,7 @@ class Invoice < PaymentDocument
|
||||
invoice_items.each do |ii|
|
||||
paid_items += 1 unless ii.amount.zero?
|
||||
next unless attrs[:invoice_items_ids].include? ii.id # list of items to refund (partial refunds)
|
||||
raise Exception if ii.invoice_item # cannot refund an item that was already refunded
|
||||
raise StandardError if ii.invoice_item # cannot refund an item that was already refunded
|
||||
|
||||
refund_items += 1 unless ii.amount.zero?
|
||||
avoir_ii = avoir.invoice_items.build(ii.dup.attributes)
|
||||
@ -84,17 +82,7 @@ class Invoice < PaymentDocument
|
||||
avoir.total += avoir_ii.amount
|
||||
end
|
||||
# handle coupon
|
||||
unless avoir.coupon_id.nil?
|
||||
discount = avoir.total
|
||||
if avoir.coupon.type == 'percent_off'
|
||||
discount = avoir.total * avoir.coupon.percent_off / 100.0
|
||||
elsif avoir.coupon.type == 'amount_off'
|
||||
discount = (avoir.coupon.amount_off / paid_items) * refund_items
|
||||
else
|
||||
raise InvalidCouponError
|
||||
end
|
||||
avoir.total -= discount
|
||||
end
|
||||
avoir.total = CouponService.apply_on_refund(avoir.total, avoir.coupon, paid_items, refund_items)
|
||||
avoir
|
||||
end
|
||||
|
||||
@ -145,6 +133,10 @@ class Invoice < PaymentDocument
|
||||
invoice_items.where(main: true).first
|
||||
end
|
||||
|
||||
def other_items
|
||||
invoice_items.where(main: [nil, false])
|
||||
end
|
||||
|
||||
# get amount total paid
|
||||
def amount_paid
|
||||
total - (wallet_amount || 0)
|
||||
|
@ -4,8 +4,8 @@
|
||||
class InvoiceItem < Footprintable
|
||||
belongs_to :invoice
|
||||
|
||||
has_one :invoice_item # associates invoice_items of an invoice to invoice_items of an Avoir
|
||||
has_one :payment_gateway_object, as: :item
|
||||
has_one :invoice_item, dependent: :destroy # associates invoice_items of an invoice to invoice_items of an Avoir
|
||||
has_one :payment_gateway_object, as: :item, dependent: :destroy
|
||||
|
||||
belongs_to :object, polymorphic: true
|
||||
|
||||
@ -15,7 +15,8 @@ class InvoiceItem < Footprintable
|
||||
def amount_after_coupon
|
||||
# deduct coupon discount
|
||||
coupon_service = CouponService.new
|
||||
coupon_service.ventilate(invoice.total, amount, invoice.coupon)
|
||||
total_without_coupon = coupon_service.invoice_total_no_coupon(invoice)
|
||||
coupon_service.ventilate(total_without_coupon, amount, invoice.coupon)
|
||||
end
|
||||
|
||||
# return the item amount, coupon discount deducted, if any, and VAT excluded, if applicable
|
||||
|
@ -4,7 +4,7 @@
|
||||
class PaymentScheduleItem < Footprintable
|
||||
belongs_to :payment_schedule
|
||||
belongs_to :invoice
|
||||
has_one :payment_gateway_object, as: :item
|
||||
has_one :payment_gateway_object, as: :item, dependent: :destroy
|
||||
|
||||
after_create :chain_record
|
||||
|
||||
|
@ -7,13 +7,13 @@ class Plan < ApplicationRecord
|
||||
belongs_to :plan_category
|
||||
|
||||
has_many :credits, dependent: :destroy
|
||||
has_many :training_credits, -> { where(creditable_type: 'Training') }, class_name: 'Credit'
|
||||
has_many :machine_credits, -> { where(creditable_type: 'Machine') }, class_name: 'Credit'
|
||||
has_many :space_credits, -> { where(creditable_type: 'Space') }, class_name: 'Credit'
|
||||
has_many :subscriptions
|
||||
has_many :training_credits, -> { where(creditable_type: 'Training') }, class_name: 'Credit', dependent: :destroy, inverse_of: :plan
|
||||
has_many :machine_credits, -> { where(creditable_type: 'Machine') }, class_name: 'Credit', dependent: :destroy, inverse_of: :plan
|
||||
has_many :space_credits, -> { where(creditable_type: 'Space') }, class_name: 'Credit', dependent: :destroy, inverse_of: :plan
|
||||
has_many :subscriptions, dependent: :nullify
|
||||
has_one :plan_file, as: :viewable, dependent: :destroy
|
||||
has_many :prices, dependent: :destroy
|
||||
has_one :payment_gateway_object, as: :item
|
||||
has_one :payment_gateway_object, as: :item, dependent: :destroy
|
||||
|
||||
extend FriendlyId
|
||||
friendly_id :base_name, use: :slugged
|
||||
@ -37,7 +37,7 @@ class Plan < ApplicationRecord
|
||||
|
||||
def self.create_for_all_groups(plan_params)
|
||||
plans = []
|
||||
Group.all_except_admins.each do |group|
|
||||
Group.find_each do |group|
|
||||
plan = if plan_params[:type] == 'PartnerPlan'
|
||||
PartnerPlan.new(plan_params.except(:group_id, :type))
|
||||
else
|
||||
@ -59,14 +59,14 @@ class Plan < ApplicationRecord
|
||||
end
|
||||
|
||||
def create_machines_prices
|
||||
Machine.all.each do |machine|
|
||||
Machine.all.find_each do |machine|
|
||||
default_price = Price.find_by(priceable: machine, plan: nil, group_id: group_id)&.amount || 0
|
||||
Price.create(priceable: machine, plan: self, group_id: group_id, amount: default_price)
|
||||
end
|
||||
end
|
||||
|
||||
def create_spaces_prices
|
||||
Space.all.each do |space|
|
||||
Space.all.find_each do |space|
|
||||
default_price = Price.find_by(priceable: space, plan: nil, group_id: group_id)&.amount || 0
|
||||
Price.create(priceable: space, plan: self, group_id: group_id, amount: default_price)
|
||||
end
|
||||
@ -123,12 +123,12 @@ class Plan < ApplicationRecord
|
||||
StatisticTypeSubType.create!(statistic_type: stat_type, statistic_sub_type: stat_subtype)
|
||||
else
|
||||
Rails.logger.error 'Unable to create the statistics association for the new plan. ' \
|
||||
'Possible causes: the type or the subtype were not created successfully.'
|
||||
'Possible causes: the type or the subtype were not created successfully.'
|
||||
end
|
||||
end
|
||||
|
||||
def set_name
|
||||
update_columns(name: human_readable_name)
|
||||
update_columns(name: human_readable_name) # rubocop:disable Rails/SkipsModelValidations
|
||||
end
|
||||
|
||||
def update_gateway_product
|
||||
|
@ -204,7 +204,7 @@ class Setting < ApplicationRecord
|
||||
# @return {String|Boolean}
|
||||
##
|
||||
def self.get(name)
|
||||
res = find_by(name: name)&.value
|
||||
res = find_by('LOWER(name) = ? ', name.downcase)&.value
|
||||
|
||||
# handle boolean values
|
||||
return true if res == 'true'
|
||||
|
@ -160,6 +160,7 @@ class ShoppingCart
|
||||
# The total booked minutes are subtracted from the user's prepaid minutes
|
||||
def update_packs(objects)
|
||||
objects.filter { |o| o.is_a? Reservation }.each do |reservation|
|
||||
reservation.reload
|
||||
PrepaidPackService.update_user_minutes(@customer, reservation)
|
||||
end
|
||||
end
|
||||
|
@ -31,7 +31,6 @@ class Subscription < ApplicationRecord
|
||||
if expired?
|
||||
false
|
||||
else
|
||||
# TODO, check if the rubocop:disable directove can be deleted
|
||||
update_columns(expiration_date: time, canceled_at: time) # rubocop:disable Rails/SkipsModelValidations
|
||||
notify_admin_subscription_canceled
|
||||
notify_member_subscription_canceled
|
||||
|
@ -43,9 +43,9 @@ class User < ApplicationRecord
|
||||
has_many :exports, dependent: :destroy
|
||||
has_many :imports, dependent: :nullify
|
||||
|
||||
has_one :payment_gateway_object, as: :item
|
||||
has_one :payment_gateway_object, as: :item, dependent: :nullify
|
||||
|
||||
has_many :accounting_periods, foreign_key: 'closed_by', dependent: :nullify
|
||||
has_many :accounting_periods, foreign_key: 'closed_by', dependent: :nullify, inverse_of: :user
|
||||
|
||||
has_many :proof_of_identity_files, dependent: :destroy
|
||||
has_many :proof_of_identity_refusals, dependent: :destroy
|
||||
@ -56,14 +56,15 @@ class User < ApplicationRecord
|
||||
end
|
||||
|
||||
before_create :assign_default_role
|
||||
after_commit :create_gateway_customer, on: [:create]
|
||||
after_commit :notify_admin_when_user_is_created, on: :create
|
||||
after_create :init_dependencies
|
||||
after_update :update_invoicing_profile, if: :invoicing_data_was_modified?
|
||||
after_update :update_statistic_profile, if: :statistic_data_was_modified?
|
||||
before_destroy :remove_orphan_drafts
|
||||
after_commit :create_gateway_customer, on: [:create]
|
||||
after_commit :notify_admin_when_user_is_created, on: :create
|
||||
|
||||
attr_accessor :cgu
|
||||
|
||||
delegate :first_name, to: :profile
|
||||
delegate :last_name, to: :profile
|
||||
delegate :subscriptions, to: :statistic_profile
|
||||
@ -116,11 +117,11 @@ class User < ApplicationRecord
|
||||
end
|
||||
|
||||
def self.online_payers
|
||||
User.with_any_role(:manager, :member)
|
||||
User.with_any_role(:admin, :manager, :member)
|
||||
end
|
||||
|
||||
def self.adminsys
|
||||
return unless Rails.application.secrets.adminsys_email.present?
|
||||
return if Rails.application.secrets.adminsys_email.blank?
|
||||
|
||||
User.find_by('lower(email) = ?', Rails.application.secrets.adminsys_email&.downcase)
|
||||
end
|
||||
@ -225,7 +226,7 @@ class User < ApplicationRecord
|
||||
def update_statistic_profile
|
||||
raise NoProfileError if statistic_profile.nil? || statistic_profile.id.nil?
|
||||
|
||||
statistic_profile.update_attributes(
|
||||
statistic_profile.update(
|
||||
group_id: group_id,
|
||||
role_id: roles.first.id
|
||||
)
|
||||
@ -255,7 +256,7 @@ class User < ApplicationRecord
|
||||
def remove_orphan_drafts
|
||||
orphans = my_projects
|
||||
.joins('LEFT JOIN project_users ON projects.id = project_users.project_id')
|
||||
.where('project_users.project_id IS NULL')
|
||||
.where(project_users: { project_id: nil })
|
||||
.where(state: 'draft')
|
||||
orphans.map(&:destroy!)
|
||||
end
|
||||
@ -342,7 +343,7 @@ class User < ApplicationRecord
|
||||
def update_invoicing_profile
|
||||
raise NoProfileError if invoicing_profile.nil?
|
||||
|
||||
invoicing_profile.update_attributes(
|
||||
invoicing_profile.update(
|
||||
email: email,
|
||||
first_name: first_name,
|
||||
last_name: last_name
|
||||
@ -351,7 +352,7 @@ class User < ApplicationRecord
|
||||
|
||||
def password_complexity
|
||||
return if password.blank? || SecurePassword.is_secured?(password)
|
||||
|
||||
|
||||
errors.add I18n.t("app.public.common.password_is_too_weak"), I18n.t("app.public.common.password_is_too_weak_explanations")
|
||||
end
|
||||
end
|
||||
|
@ -53,10 +53,10 @@ class PDF::Invoice < Prawn::Document
|
||||
end
|
||||
|
||||
# user/organization's information
|
||||
if invoice&.invoicing_profile&.organization
|
||||
if invoice.invoicing_profile.organization
|
||||
name = invoice.invoicing_profile.organization.name
|
||||
full_name = "#{name} (#{invoice.invoicing_profile.full_name})"
|
||||
others = invoice&.invoicing_profile&.user_profile_custom_fields&.joins(:profile_custom_field)
|
||||
others = invoice.invoicing_profile.user_profile_custom_fields&.joins(:profile_custom_field)
|
||||
&.where('profile_custom_fields.actived' => true)
|
||||
&.order('profile_custom_fields.id ASC')
|
||||
&.select { |f| f.value.present? }
|
||||
@ -102,7 +102,7 @@ class PDF::Invoice < Prawn::Document
|
||||
next unless item.object_type == Subscription.name
|
||||
|
||||
subscription = item.object
|
||||
cancellation = invoice.is_a?(Avoir) ? I18n.t('invoices.cancellation') + ' - ' : ''
|
||||
cancellation = invoice.is_a?(Avoir) ? "#{I18n.t('invoices.cancellation')} - " : ''
|
||||
object = "\n- #{object}\n- #{cancellation + subscription_verbose(subscription, name)}"
|
||||
break
|
||||
end
|
||||
@ -120,7 +120,7 @@ class PDF::Invoice < Prawn::Document
|
||||
Rails.logger.error "specified main_item.object_type type (#{invoice.main_item.object_type}) is unknown"
|
||||
end
|
||||
end
|
||||
text I18n.t('invoices.object') + ' ' + object
|
||||
text "#{I18n.t('invoices.object')} #{object}"
|
||||
|
||||
# details table of the invoice's elements
|
||||
move_down 20
|
||||
@ -135,7 +135,7 @@ class PDF::Invoice < Prawn::Document
|
||||
invoice.invoice_items.each do |item|
|
||||
price = item.amount.to_i / 100.00
|
||||
|
||||
details = invoice.is_a?(Avoir) ? I18n.t('invoices.cancellation') + ' - ' : ''
|
||||
details = invoice.is_a?(Avoir) ? "#{I18n.t('invoices.cancellation')} - " : ''
|
||||
|
||||
if item.object_type == Subscription.name
|
||||
subscription = item.object
|
||||
@ -144,9 +144,10 @@ class PDF::Invoice < Prawn::Document
|
||||
START: I18n.l(invoice.main_item.object.start_at.to_date),
|
||||
END: I18n.l(invoice.main_item.object.end_at.to_date))
|
||||
else
|
||||
subscription_end_at = if subscription_expiration_date.is_a?(Time)
|
||||
subscription_end_at = case subscription_expiration_date
|
||||
when Time
|
||||
subscription_expiration_date
|
||||
elsif subscription_expiration_date.is_a?(String)
|
||||
when String
|
||||
DateTime.parse(subscription_expiration_date)
|
||||
else
|
||||
subscription.expiration_date
|
||||
@ -173,12 +174,12 @@ class PDF::Invoice < Prawn::Document
|
||||
details += I18n.t('invoices.event_reservation_DESCRIPTION', DESCRIPTION: item.description)
|
||||
# details of the number of tickets
|
||||
if invoice.main_item.object.nb_reserve_places.positive?
|
||||
details += "\n " + I18n.t('invoices.full_price_ticket', count: invoice.main_item.object.nb_reserve_places)
|
||||
details += "\n #{I18n.t('invoices.full_price_ticket', count: invoice.main_item.object.nb_reserve_places)}"
|
||||
end
|
||||
invoice.main_item.object.tickets.each do |t|
|
||||
details += "\n " + I18n.t('invoices.other_rate_ticket',
|
||||
count: t.booked,
|
||||
NAME: t.event_price_category.price_category.name)
|
||||
details += "\n #{I18n.t('invoices.other_rate_ticket',
|
||||
count: t.booked,
|
||||
NAME: t.event_price_category.price_category.name)}"
|
||||
end
|
||||
else
|
||||
details += item.description
|
||||
@ -196,22 +197,9 @@ class PDF::Invoice < Prawn::Document
|
||||
## subtract the coupon, if any
|
||||
unless invoice.coupon_id.nil?
|
||||
cp = invoice.coupon
|
||||
discount = 0
|
||||
if cp.type == 'percent_off'
|
||||
discount = total_calc * cp.percent_off / 100.00
|
||||
elsif cp.type == 'amount_off'
|
||||
# refunds of invoices with cash coupons: we need to ventilate coupons on paid items
|
||||
if invoice.is_a?(Avoir)
|
||||
paid_items = invoice.invoice.invoice_items.select { |ii| ii.amount.positive? }.length
|
||||
refund_items = invoice.invoice_items.select { |ii| ii.amount.positive? }.length
|
||||
|
||||
discount = ((invoice.coupon.amount_off / paid_items) * refund_items) / 100.00
|
||||
else
|
||||
discount = cp.amount_off / 100.00
|
||||
end
|
||||
else
|
||||
raise InvalidCouponError
|
||||
end
|
||||
coupon_service = CouponService.new
|
||||
total_without_coupon = coupon_service.invoice_total_no_coupon(invoice)
|
||||
discount = (total_without_coupon - invoice.total) / 100.00
|
||||
|
||||
total_calc -= discount
|
||||
|
||||
@ -286,7 +274,7 @@ class PDF::Invoice < Prawn::Document
|
||||
# payment details
|
||||
move_down 20
|
||||
if invoice.is_a?(Avoir)
|
||||
payment_verbose = I18n.t('invoices.refund_on_DATE', DATE: I18n.l(invoice.avoir_date.to_date)) + ' '
|
||||
payment_verbose = "#{I18n.t('invoices.refund_on_DATE', DATE: I18n.l(invoice.avoir_date.to_date))} "
|
||||
case invoice.payment_method
|
||||
when 'stripe'
|
||||
payment_verbose += I18n.t('invoices.by_card_online_payment')
|
||||
@ -303,7 +291,7 @@ class PDF::Invoice < Prawn::Document
|
||||
else
|
||||
Rails.logger.error "specified refunding method (#{payment_verbose}) is unknown"
|
||||
end
|
||||
payment_verbose += ' ' + I18n.t('invoices.for_an_amount_of_AMOUNT', AMOUNT: number_to_currency(total))
|
||||
payment_verbose += " #{I18n.t('invoices.for_an_amount_of_AMOUNT', AMOUNT: number_to_currency(total))}"
|
||||
else
|
||||
# subtract the wallet amount for this invoice from the total
|
||||
if invoice.wallet_amount
|
||||
@ -323,18 +311,18 @@ class PDF::Invoice < Prawn::Document
|
||||
# if the invoice was 100% payed with the wallet ...
|
||||
payment_verbose = I18n.t('invoices.settlement_by_wallet') if total.zero? && wallet_amount
|
||||
|
||||
payment_verbose += ' ' + I18n.t('invoices.on_DATE_at_TIME',
|
||||
DATE: I18n.l(invoice.created_at.to_date),
|
||||
TIME: I18n.l(invoice.created_at, format: :hour_minute))
|
||||
payment_verbose += " #{I18n.t('invoices.on_DATE_at_TIME',
|
||||
DATE: I18n.l(invoice.created_at.to_date),
|
||||
TIME: I18n.l(invoice.created_at, format: :hour_minute))}"
|
||||
if total.positive? || !invoice.wallet_amount
|
||||
payment_verbose += ' ' + I18n.t('invoices.for_an_amount_of_AMOUNT', AMOUNT: number_to_currency(total))
|
||||
payment_verbose += " #{I18n.t('invoices.for_an_amount_of_AMOUNT', AMOUNT: number_to_currency(total))}"
|
||||
end
|
||||
if invoice.wallet_amount
|
||||
payment_verbose += if total.positive?
|
||||
' ' + I18n.t('invoices.and') + ' ' + I18n.t('invoices.by_wallet') + ' ' +
|
||||
I18n.t('invoices.for_an_amount_of_AMOUNT', AMOUNT: number_to_currency(wallet_amount))
|
||||
" #{I18n.t('invoices.and')} #{I18n.t('invoices.by_wallet')} " \
|
||||
"#{I18n.t('invoices.for_an_amount_of_AMOUNT', AMOUNT: number_to_currency(wallet_amount))}"
|
||||
else
|
||||
' ' + I18n.t('invoices.for_an_amount_of_AMOUNT', AMOUNT: number_to_currency(wallet_amount))
|
||||
" #{I18n.t('invoices.for_an_amount_of_AMOUNT', AMOUNT: number_to_currency(wallet_amount))}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -360,7 +348,7 @@ class PDF::Invoice < Prawn::Document
|
||||
|
||||
transparent(0.1) do
|
||||
rotate(45, origin: [0, 0]) do
|
||||
image "#{Rails.root}/app/pdfs/data/watermark-#{I18n.default_locale}.png", at: [90, 150]
|
||||
image Rails.root.join("app/pdfs/data/watermark-#{I18n.default_locale}.png"), at: [90, 150]
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -369,16 +357,16 @@ class PDF::Invoice < Prawn::Document
|
||||
|
||||
def reservation_dates_verbose(slot)
|
||||
if slot.start_at.to_date == slot.end_at.to_date
|
||||
'- ' + I18n.t('invoices.on_DATE_from_START_to_END',
|
||||
DATE: I18n.l(slot.start_at.to_date),
|
||||
START: I18n.l(slot.start_at, format: :hour_minute),
|
||||
END: I18n.l(slot.end_at, format: :hour_minute)) + "\n"
|
||||
"- #{I18n.t('invoices.on_DATE_from_START_to_END',
|
||||
DATE: I18n.l(slot.start_at.to_date),
|
||||
START: I18n.l(slot.start_at, format: :hour_minute),
|
||||
END: I18n.l(slot.end_at, format: :hour_minute))}\n"
|
||||
else
|
||||
'- ' + I18n.t('invoices.from_STARTDATE_to_ENDDATE_from_STARTTIME_to_ENDTIME',
|
||||
STARTDATE: I18n.l(slot.start_at.to_date),
|
||||
ENDDATE: I18n.l(slot.start_at.to_date),
|
||||
STARTTIME: I18n.l(slot.start_at, format: :hour_minute),
|
||||
ENDTIME: I18n.l(slot.end_at, format: :hour_minute)) + "\n"
|
||||
"- #{I18n.t('invoices.from_STARTDATE_to_ENDDATE_from_STARTTIME_to_ENDTIME',
|
||||
STARTDATE: I18n.l(slot.start_at.to_date),
|
||||
ENDDATE: I18n.l(slot.start_at.to_date),
|
||||
STARTTIME: I18n.l(slot.start_at, format: :hour_minute),
|
||||
ENDTIME: I18n.l(slot.end_at, format: :hour_minute))}\n"
|
||||
end
|
||||
end
|
||||
|
||||
|
15
app/policies/cart_context.rb
Normal file
15
app/policies/cart_context.rb
Normal file
@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Pundit Additional context for authorizing a product offering
|
||||
class CartContext
|
||||
attr_reader :customer_id, :is_offered
|
||||
|
||||
def initialize(customer_id, is_offered)
|
||||
@customer_id = customer_id
|
||||
@is_offered = is_offered
|
||||
end
|
||||
|
||||
def policy_class
|
||||
CartPolicy
|
||||
end
|
||||
end
|
@ -15,6 +15,6 @@ class CartPolicy < ApplicationPolicy
|
||||
end
|
||||
|
||||
def set_offer?
|
||||
user.privileged?
|
||||
!record.is_offered || (user.privileged? && record.customer_id != user.id)
|
||||
end
|
||||
end
|
||||
|
@ -6,6 +6,6 @@ class LocalPaymentPolicy < ApplicationPolicy
|
||||
# only admins and managers can offer free extensions of a subscription
|
||||
has_free_days = record.shopping_cart.items.any? { |item| item.is_a? CartItem::FreeExtension }
|
||||
|
||||
user.admin? || (user.manager? && record.shopping_cart.customer.id != user.id) || (record.price.zero? && !has_free_days)
|
||||
((user.admin? || user.manager?) && record.shopping_cart.customer.id != user.id) || (record.price.zero? && !has_free_days)
|
||||
end
|
||||
end
|
||||
|
@ -61,28 +61,21 @@ class AccountingExportService
|
||||
|
||||
# Generate the "subscription" and "reservation" rows associated with the provided invoice
|
||||
def items_rows(invoice)
|
||||
rows = invoice.subscription_invoice? ? "#{subscription_row(invoice)}\n" : ''
|
||||
case invoice.main_item.object_type
|
||||
when 'Reservation'
|
||||
items = invoice.invoice_items.reject { |ii| ii.object_type == 'Subscription' }
|
||||
rows = ''
|
||||
{
|
||||
subscription: 'Subscription', reservation: 'Reservation', wallet: 'WalletTransaction',
|
||||
pack: 'StatisticProfilePrepaidPack', product: 'OrderItem', error: 'Error'
|
||||
}.each do |type, object_type|
|
||||
items = invoice.invoice_items.filter { |ii| ii.object_type == object_type }
|
||||
items.each do |item|
|
||||
rows << "#{reservation_row(invoice, item)}\n"
|
||||
rows << "#{row(
|
||||
invoice,
|
||||
account(invoice, type),
|
||||
account(invoice, type, type: :label),
|
||||
item.net_amount / 100.00,
|
||||
line_label: label(invoice)
|
||||
)}\n"
|
||||
end
|
||||
when 'WalletTransaction'
|
||||
rows << "#{wallet_row(invoice)}\n"
|
||||
when 'StatisticProfilePrepaidPack'
|
||||
rows << "#{pack_row(invoice)}\n"
|
||||
when 'OrderItem'
|
||||
rows << "#{product_row(invoice)}\n"
|
||||
when 'Error'
|
||||
items = invoice.invoice_items.reject { |ii| ii.object_type == 'Subscription' }
|
||||
items.each do |item|
|
||||
rows << "#{error_row(invoice, item)}\n"
|
||||
end
|
||||
when 'Subscription'
|
||||
# do nothing, subscription was already handled by subscription_row
|
||||
else
|
||||
Rails.logger.warn { "Unknown main object type #{invoice.main_item.object_type}" }
|
||||
end
|
||||
rows
|
||||
end
|
||||
@ -93,8 +86,8 @@ class AccountingExportService
|
||||
invoice.payment_means.each do |details|
|
||||
rows << row(
|
||||
invoice,
|
||||
account(invoice, :projets, means: details[:means]),
|
||||
account(invoice, :projets, means: details[:means], type: :label),
|
||||
account(invoice, :client, means: details[:means]),
|
||||
account(invoice, :client, means: details[:means], type: :label),
|
||||
details[:amount] / 100.00,
|
||||
line_label: label(invoice),
|
||||
debit_method: :debit_client,
|
||||
@ -105,61 +98,6 @@ class AccountingExportService
|
||||
rows
|
||||
end
|
||||
|
||||
# Generate the "reservation" row, which contains the credit to the reservation account, all taxes excluded
|
||||
def reservation_row(invoice, item)
|
||||
row(
|
||||
invoice,
|
||||
account(invoice, :reservation),
|
||||
account(invoice, :reservation, type: :label),
|
||||
item.net_amount / 100.00,
|
||||
line_label: label(invoice)
|
||||
)
|
||||
end
|
||||
|
||||
# Generate the "subscription" row, which contains the credit to the subscription account, all taxes excluded
|
||||
def subscription_row(invoice)
|
||||
subscription_item = invoice.invoice_items.select { |ii| ii.object_type == 'Subscription' }.first
|
||||
row(
|
||||
invoice,
|
||||
account(invoice, :subscription),
|
||||
account(invoice, :subscription, type: :label),
|
||||
subscription_item.net_amount / 100.00,
|
||||
line_label: label(invoice)
|
||||
)
|
||||
end
|
||||
|
||||
# Generate the "wallet" row, which contains the credit to the wallet account, all taxes excluded
|
||||
# This applies to wallet crediting, when an Avoir is generated at this time
|
||||
def wallet_row(invoice)
|
||||
row(
|
||||
invoice,
|
||||
account(invoice, :wallet),
|
||||
account(invoice, :wallet, type: :label),
|
||||
invoice.invoice_items.first.net_amount / 100.00,
|
||||
line_label: label(invoice)
|
||||
)
|
||||
end
|
||||
|
||||
def pack_row(invoice)
|
||||
row(
|
||||
invoice,
|
||||
account(invoice, :pack),
|
||||
account(invoice, :pack, type: :label),
|
||||
invoice.invoice_items.first.net_amount / 100.00,
|
||||
line_label: label(invoice)
|
||||
)
|
||||
end
|
||||
|
||||
def product_row(invoice)
|
||||
row(
|
||||
invoice,
|
||||
account(invoice, :product),
|
||||
account(invoice, :product, type: :label),
|
||||
invoice.invoice_items.first.net_amount / 100.00,
|
||||
line_label: label(invoice)
|
||||
)
|
||||
end
|
||||
|
||||
# Generate the "VAT" row, which contains the credit to the VAT account, with VAT amount only
|
||||
def vat_row(invoice)
|
||||
total = invoice.invoice_items.map(&:net_amount).sum
|
||||
@ -175,16 +113,6 @@ class AccountingExportService
|
||||
)
|
||||
end
|
||||
|
||||
def error_row(invoice, item)
|
||||
row(
|
||||
invoice,
|
||||
account(invoice, :error),
|
||||
account(invoice, :error, type: :label),
|
||||
item.net_amount / 100.00,
|
||||
line_label: label(invoice)
|
||||
)
|
||||
end
|
||||
|
||||
# Generate a row of the export, filling the configured columns with the provided values
|
||||
def row(invoice, account_code, account_label, amount, line_label: '', debit_method: :debit, credit_method: :credit)
|
||||
row = ''
|
||||
@ -219,44 +147,12 @@ class AccountingExportService
|
||||
# Get the account code (or label) for the given invoice and the specified line type (client, vat, subscription or reservation)
|
||||
def account(invoice, account, type: :code, means: :other)
|
||||
case account
|
||||
when :projets
|
||||
when :client
|
||||
Setting.get("accounting_#{means}_client_#{type}")
|
||||
when :vat
|
||||
Setting.get("accounting_VAT_#{type}")
|
||||
when :subscription
|
||||
if invoice.subscription_invoice?
|
||||
Setting.get("accounting_subscription_#{type}")
|
||||
else
|
||||
Rails.logger.debug { "WARN: Invoice #{invoice.id} has no subscription" }
|
||||
end
|
||||
when :reservation
|
||||
if invoice.main_item.object_type == 'Reservation'
|
||||
Setting.get("accounting_#{invoice.main_item.object.reservable_type}_#{type}")
|
||||
else
|
||||
Rails.logger.debug { "WARN: Invoice #{invoice.id} has no reservation" }
|
||||
end
|
||||
when :wallet
|
||||
if invoice.main_item.object_type == 'WalletTransaction'
|
||||
Setting.get("accounting_wallet_#{type}")
|
||||
else
|
||||
Rails.logger.debug { "WARN: Invoice #{invoice.id} is not a wallet credit" }
|
||||
end
|
||||
when :pack
|
||||
if invoice.main_item.object_type == 'StatisticProfilePrepaidPack'
|
||||
Setting.get("accounting_Pack_#{type}")
|
||||
else
|
||||
Rails.logger.debug { "WARN: Invoice #{invoice.id} has no prepaid-pack" }
|
||||
end
|
||||
when :product
|
||||
if invoice.main_item.object_type == 'OrderItem'
|
||||
Setting.get("accounting_Product_#{type}")
|
||||
else
|
||||
Rails.logger.debug { "WARN: Invoice #{invoice.id} has no prepaid-pack" }
|
||||
end
|
||||
when :error
|
||||
Setting.get("accounting_Error_#{type}")
|
||||
Setting.get("accounting_#{invoice.main_item.object.reservable_type}_#{type}") if invoice.main_item.object_type == 'Reservation'
|
||||
else
|
||||
Rails.logger.debug { "Unsupported account #{account}" }
|
||||
Setting.get("accounting_#{account}_#{type}")
|
||||
end || ''
|
||||
end
|
||||
|
||||
@ -297,6 +193,7 @@ class AccountingExportService
|
||||
items.push I18n.t("accounting_export.#{invoice.main_item.object.reservable_type}_reservation")
|
||||
end
|
||||
items.push I18n.t('accounting_export.wallet') if invoice.main_item.object_type == 'WalletTransaction'
|
||||
items.push I18n.t('accounting_export.shop_order') if invoice.main_item.object_type == 'OrderItem'
|
||||
|
||||
summary = items.join(' + ')
|
||||
res = "#{reference}, #{summary}"
|
||||
|
@ -18,16 +18,14 @@ class Checkout::PaymentService
|
||||
CouponService.new.validate(coupon_code, order.statistic_profile.user.id)
|
||||
|
||||
amount = debit_amount(order)
|
||||
if operator.privileged? || amount.zero?
|
||||
if (operator.privileged? && operator != order.statistic_profile.user) || amount.zero?
|
||||
Payments::LocalService.new.payment(order, coupon_code)
|
||||
elsif operator.member?
|
||||
if Stripe::Helper.enabled?
|
||||
Payments::StripeService.new.payment(order, coupon_code, payment_id)
|
||||
elsif PayZen::Helper.enabled?
|
||||
Payments::PayzenService.new.payment(order, coupon_code)
|
||||
else
|
||||
raise Error('Bad gateway or online payment is disabled')
|
||||
end
|
||||
elsif Stripe::Helper.enabled? && payment_id.present?
|
||||
Payments::StripeService.new.payment(order, coupon_code, payment_id)
|
||||
elsif PayZen::Helper.enabled?
|
||||
Payments::PayzenService.new.payment(order, coupon_code)
|
||||
else
|
||||
raise Error('Bad gateway or online payment is disabled')
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -26,17 +26,35 @@ class CouponService
|
||||
return price if coupon_object.nil?
|
||||
|
||||
if coupon_object.status(user_id, total) == 'active'
|
||||
if coupon_object.type == 'percent_off'
|
||||
price -= (price * coupon_object.percent_off / 100.00).truncate
|
||||
elsif coupon_object.type == 'amount_off'
|
||||
case coupon_object.type
|
||||
when 'percent_off'
|
||||
price -= (Rational(price * coupon_object.percent_off) / Rational(100.0)).to_f.ceil
|
||||
when 'amount_off'
|
||||
# do not apply cash coupon unless it has a lower amount that the total price
|
||||
price -= coupon_object.amount_off if coupon_object.amount_off <= price
|
||||
else
|
||||
raise InvalidCouponError("unsupported coupon type #{coupon_object.type}")
|
||||
end
|
||||
end
|
||||
|
||||
price
|
||||
end
|
||||
|
||||
# Apply the provided coupon to the given amount, considering that this applies to a refund invoice (Avoir),
|
||||
# potentially partial
|
||||
def self.apply_on_refund(amount, coupon, paid_items = 1, refund_items = 1)
|
||||
return amount if coupon.nil?
|
||||
|
||||
case coupon.type
|
||||
when 'percent_off'
|
||||
amount - (Rational(amount * coupon.percent_off) / Rational(100.0)).to_f.ceil
|
||||
when 'amount_off'
|
||||
amount - (Rational(coupon.amount_off / paid_items) * Rational(refund_items)).to_f.ceil
|
||||
else
|
||||
raise InvalidCouponError
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Find the coupon associated with the given code and check it is valid for the given user
|
||||
# @param code {String} the literal code of the coupon
|
||||
@ -61,14 +79,15 @@ class CouponService
|
||||
def ventilate(total, amount, coupon)
|
||||
price = amount
|
||||
if !coupon.nil? && total != 0
|
||||
if coupon.type == 'percent_off'
|
||||
price = amount - (amount * coupon.percent_off / 100.00)
|
||||
elsif coupon.type == 'amount_off'
|
||||
ratio = (coupon.amount_off / 100.00) / total
|
||||
discount = (amount * ratio.abs) * 100
|
||||
price = amount - discount
|
||||
case coupon.type
|
||||
when 'percent_off'
|
||||
price = amount - (Rational(amount * coupon.percent_off) / Rational(100.00)).to_f.round
|
||||
when 'amount_off'
|
||||
ratio = Rational(amount) / Rational(total)
|
||||
discount = (coupon.amount_off * ratio.abs)
|
||||
price = (amount - discount).to_f.round
|
||||
else
|
||||
raise InvalidCouponError
|
||||
raise InvalidCouponError("unsupported coupon type #{coupon.type}")
|
||||
end
|
||||
end
|
||||
price
|
||||
|
@ -31,14 +31,15 @@ class EventService
|
||||
def date_range(starting, ending, all_day)
|
||||
start_date = Time.zone.parse(starting[:date])
|
||||
end_date = Time.zone.parse(ending[:date])
|
||||
start_time = Time.parse(starting[:time]) if starting[:time]
|
||||
end_time = Time.parse(ending[:time]) if ending[:time]
|
||||
if all_day
|
||||
start_time = starting[:time] ? Time.zone.parse(starting[:time]) : nil
|
||||
end_time = ending[:time] ? Time.zone.parse(ending[:time]) : nil
|
||||
if all_day || start_time.nil? || end_time.nil?
|
||||
start_at = DateTime.new(start_date.year, start_date.month, start_date.day, 0, 0, 0, start_date.zone)
|
||||
end_at = DateTime.new(end_date.year, end_date.month, end_date.day, 23, 59, 59, end_date.zone)
|
||||
else
|
||||
start_at = DateTime.new(start_date.year, start_date.month, start_date.day, start_time&.hour, start_time&.min, start_time&.sec, start_date.zone)
|
||||
end_at = DateTime.new(end_date.year, end_date.month, end_date.day, end_time&.hour, end_time&.min, end_time&.sec, end_date.zone)
|
||||
start_at = DateTime.new(start_date.year, start_date.month, start_date.day, start_time.hour, start_time.min, start_time.sec,
|
||||
start_date.zone)
|
||||
end_at = DateTime.new(end_date.year, end_date.month, end_date.day, end_time.hour, end_time.min, end_time.sec, end_date.zone)
|
||||
end
|
||||
{ start_at: start_at, end_at: end_at }
|
||||
end
|
||||
@ -59,16 +60,13 @@ class EventService
|
||||
)
|
||||
.references(:availabilities, :events)
|
||||
when 'all'
|
||||
Event.where(
|
||||
'recurrence_id = ?',
|
||||
event.recurrence_id
|
||||
)
|
||||
Event.where(recurrence_id: event.recurrence_id)
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
events.each do |e|
|
||||
# here we use double negation because safe_destroy can return either a boolean (false) or an Availability (in case of delete success)
|
||||
# we use double negation because safe_destroy can return either a boolean (false) or an Availability (in case of delete success)
|
||||
results.push status: !!e.safe_destroy, event: e # rubocop:disable Style/DoubleNegation
|
||||
end
|
||||
results
|
||||
@ -89,99 +87,41 @@ class EventService
|
||||
.references(:availabilities, :events)
|
||||
when 'all'
|
||||
Event.includes(:availability, :event_price_categories, :event_files)
|
||||
.where(
|
||||
'recurrence_id = ?',
|
||||
event.recurrence_id
|
||||
)
|
||||
.where(recurrence_id: event.recurrence_id)
|
||||
else
|
||||
[]
|
||||
end
|
||||
update_events(event, events, event_params)
|
||||
update_occurrences(event, events, event_params)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_events(event, events, event_params)
|
||||
def update_occurrences(base_event, occurrences, event_params)
|
||||
results = {
|
||||
events: [],
|
||||
slots: []
|
||||
}
|
||||
events.each do |e|
|
||||
next unless e.id != event.id
|
||||
original_slots_ids = base_event.availability.slots.map(&:id)
|
||||
|
||||
start_at = event_params['availability_attributes']['start_at']
|
||||
end_at = event_params['availability_attributes']['end_at']
|
||||
event_price_categories_attributes = event_params['event_price_categories_attributes']
|
||||
event_files_attributes = event_params['event_files_attributes']
|
||||
e_params = event_params.merge(
|
||||
availability_id: e.availability_id,
|
||||
availability_attributes: {
|
||||
id: e.availability_id,
|
||||
start_at: e.availability.start_at.change(hour: start_at.hour, min: start_at.min),
|
||||
end_at: e.availability.end_at.change(hour: end_at.hour, min: end_at.min),
|
||||
available_type: e.availability.available_type
|
||||
}
|
||||
)
|
||||
epc_attributes = []
|
||||
event_price_categories_attributes&.each do |epca|
|
||||
epc = e.event_price_categories.find_by(price_category_id: epca['price_category_id'])
|
||||
if epc
|
||||
epc_attributes.push(
|
||||
id: epc.id,
|
||||
price_category_id: epc.price_category_id,
|
||||
amount: epca['amount'],
|
||||
_destroy: epca['_destroy']
|
||||
)
|
||||
elsif epca['id'].present?
|
||||
event_price = event.event_price_categories.find(epca['id'])
|
||||
epc_attributes.push(
|
||||
price_category_id: epca['price_category_id'],
|
||||
amount: event_price.amount,
|
||||
_destroy: ''
|
||||
)
|
||||
end
|
||||
end
|
||||
unless epc_attributes.empty?
|
||||
e_params = e_params.merge(
|
||||
event_price_categories_attributes: epc_attributes
|
||||
)
|
||||
end
|
||||
occurrences.each do |occurrence|
|
||||
next unless occurrence.id != base_event.id
|
||||
|
||||
ef_attributes = []
|
||||
event_files_attributes&.each do |efa|
|
||||
if efa['id'].present?
|
||||
event_file = event.event_files.find(efa['id'])
|
||||
ef = e.event_files.find_by(attachment: event_file.attachment.file.filename)
|
||||
if ef
|
||||
ef_attributes.push(
|
||||
id: ef.id,
|
||||
attachment: efa['attachment'],
|
||||
_destroy: efa['_destroy']
|
||||
)
|
||||
end
|
||||
else
|
||||
ef_attributes.push(efa)
|
||||
end
|
||||
end
|
||||
e_params = e_params.merge(
|
||||
event_files_attributes: ef_attributes
|
||||
)
|
||||
original_slots_ids = event.availability.slots.map(&:id)
|
||||
e_params = occurrence_params(base_event, occurrence, event_params)
|
||||
begin
|
||||
results[:events].push status: !!e.update(e_params.permit!), event: e # rubocop:disable Style/DoubleNegation
|
||||
rescue StandardError => err
|
||||
results[:events].push status: false, event: e, error: err.try(:record).try(:class).try(:name), message: err.message
|
||||
results[:events].push status: !!occurrence.update(e_params.permit!), event: occurrence # rubocop:disable Style/DoubleNegation
|
||||
rescue StandardError => e
|
||||
results[:events].push status: false, event: occurrence, error: e.try(:record).try(:class).try(:name), message: e.message
|
||||
end
|
||||
results[:slots].concat(update_slots(e.availability_id, original_slots_ids))
|
||||
results[:slots].concat(update_slots(occurrence.availability_id, original_slots_ids))
|
||||
end
|
||||
original_slots_ids = event.availability.slots.map(&:id)
|
||||
|
||||
begin
|
||||
event_params[:availability_attributes][:id] = event.availability_id
|
||||
results[:events].push status: !!event.update(event_params), event: event # rubocop:disable Style/DoubleNegation
|
||||
rescue StandardError => err
|
||||
results[:events].push status: false, event: event, error: err.try(:record).try(:class).try(:name), message: err.message
|
||||
event_params[:availability_attributes][:id] = base_event.availability_id
|
||||
results[:events].push status: !!base_event.update(event_params), event: base_event # rubocop:disable Style/DoubleNegation
|
||||
rescue StandardError => e
|
||||
results[:events].push status: false, event: base_event, error: e.try(:record).try(:class).try(:name), message: e.message
|
||||
end
|
||||
results[:slots].concat(update_slots(event.availability_id, original_slots_ids))
|
||||
results[:slots].concat(update_slots(base_event.availability_id, original_slots_ids))
|
||||
results
|
||||
end
|
||||
|
||||
@ -190,13 +130,81 @@ class EventService
|
||||
avail = Availability.find(availability_id)
|
||||
Slot.where(id: original_slots_ids).each do |slot|
|
||||
results.push(
|
||||
status: !!slot.update_attributes(availability_id: availability_id, start_at: avail.start_at, end_at: avail.end_at), # rubocop:disable Style/DoubleNegation
|
||||
status: !!slot.update(availability_id: availability_id, start_at: avail.start_at, end_at: avail.end_at), # rubocop:disable Style/DoubleNegation
|
||||
slot: slot
|
||||
)
|
||||
rescue StandardError => err
|
||||
results.push status: false, slot: s, error: err.try(:record).try(:class).try(:name), message: err.message
|
||||
rescue StandardError => e
|
||||
results.push status: false, slot: s, error: e.try(:record).try(:class).try(:name), message: e.message
|
||||
end
|
||||
results
|
||||
end
|
||||
|
||||
def occurrence_params(base_event, occurrence, event_params)
|
||||
start_at = event_params['availability_attributes']['start_at']
|
||||
end_at = event_params['availability_attributes']['end_at']
|
||||
e_params = event_params.merge(
|
||||
availability_id: occurrence.availability_id,
|
||||
availability_attributes: {
|
||||
id: occurrence.availability_id,
|
||||
start_at: occurrence.availability.start_at.change(hour: start_at.hour, min: start_at.min),
|
||||
end_at: occurrence.availability.end_at.change(hour: end_at.hour, min: end_at.min),
|
||||
available_type: occurrence.availability.available_type
|
||||
}
|
||||
)
|
||||
epc_attributes = price_categories_attributes(base_event, occurrence, event_params)
|
||||
unless epc_attributes.empty?
|
||||
e_params = e_params.merge(
|
||||
event_price_categories_attributes: epc_attributes
|
||||
)
|
||||
end
|
||||
|
||||
ef_attributes = file_attributes(base_event, occurrence, event_params)
|
||||
e_params.merge(
|
||||
event_files_attributes: ef_attributes
|
||||
)
|
||||
end
|
||||
|
||||
def price_categories_attributes(base_event, occurrence, event_params)
|
||||
epc_attributes = []
|
||||
event_params['event_price_categories_attributes']&.each do |epca|
|
||||
epc = occurrence.event_price_categories.find_by(price_category_id: epca['price_category_id'])
|
||||
if epc
|
||||
epc_attributes.push(
|
||||
id: epc.id,
|
||||
price_category_id: epc.price_category_id,
|
||||
amount: epca['amount'],
|
||||
_destroy: epca['_destroy']
|
||||
)
|
||||
elsif epca['id'].present?
|
||||
event_price = base_event.event_price_categories.find(epca['id'])
|
||||
epc_attributes.push(
|
||||
price_category_id: epca['price_category_id'],
|
||||
amount: event_price.amount,
|
||||
_destroy: ''
|
||||
)
|
||||
end
|
||||
end
|
||||
epc_attributes
|
||||
end
|
||||
|
||||
def file_attributes(base_event, occurrence, event_params)
|
||||
ef_attributes = []
|
||||
event_params['event_files_attributes']&.each do |efa|
|
||||
if efa['id'].present?
|
||||
event_file = base_event.event_files.find(efa['id'])
|
||||
ef = occurrence.event_files.find_by(attachment: event_file.attachment.file.filename)
|
||||
if ef
|
||||
ef_attributes.push(
|
||||
id: ef.id,
|
||||
attachment: efa['attachment'],
|
||||
_destroy: efa['_destroy']
|
||||
)
|
||||
end
|
||||
else
|
||||
ef_attributes.push(efa)
|
||||
end
|
||||
end
|
||||
ef_attributes
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -2,20 +2,14 @@
|
||||
|
||||
# Provides methods for Groups
|
||||
class GroupService
|
||||
def self.list(operator, filters = {})
|
||||
groups = if operator&.admin?
|
||||
Group.where(nil)
|
||||
else
|
||||
Group.where.not(slug: 'admins')
|
||||
end
|
||||
def self.list(filters = {})
|
||||
groups = Group.where(nil)
|
||||
|
||||
if filters[:disabled].present?
|
||||
state = filters[:disabled] == 'false' ? [nil, false] : true
|
||||
groups = groups.where(disabled: state)
|
||||
end
|
||||
|
||||
groups = groups.where.not(slug: 'admins') if filters[:admins] == 'false'
|
||||
|
||||
groups
|
||||
end
|
||||
end
|
||||
|
@ -73,7 +73,7 @@ class InvoicesService
|
||||
method = if payment_method
|
||||
payment_method
|
||||
else
|
||||
operator&.admin? || (operator&.manager? && operator != user) ? nil : 'card'
|
||||
(operator&.admin? || operator&.manager?) && operator != user ? nil : 'card'
|
||||
end
|
||||
|
||||
invoice = Invoice.new(
|
||||
|
@ -28,7 +28,7 @@ class Members::ListService
|
||||
if params[:search].size.positive?
|
||||
@query = @query.where('users.username ILIKE :search OR ' \
|
||||
'profiles.first_name ILIKE :search OR ' \
|
||||
'profiles.last_name ILIKE :search OR ' \
|
||||
'profiles.last_name ILIKE :search OR ' \
|
||||
'profiles.phone ILIKE :search OR ' \
|
||||
'email ILIKE :search OR ' \
|
||||
'groups.name ILIKE :search OR ' \
|
||||
@ -41,20 +41,21 @@ class Members::ListService
|
||||
@query
|
||||
end
|
||||
|
||||
def search(current_user, query, subscription, include_admins = 'false')
|
||||
def search(current_user, query, subscription)
|
||||
members = User.includes(:profile, :statistic_profile)
|
||||
.joins(:profile,
|
||||
:statistic_profile,
|
||||
:roles,
|
||||
'LEFT JOIN "subscriptions" ON "subscriptions"."statistic_profile_id" = "statistic_profiles"."id" AND ' \
|
||||
'"subscriptions"."created_at" = ( ' \
|
||||
'SELECT max("created_at") ' \
|
||||
'FROM "subscriptions" ' \
|
||||
'WHERE "statistic_profile_id" = "statistic_profiles"."id")')
|
||||
'SELECT max("created_at") ' \
|
||||
'FROM "subscriptions" ' \
|
||||
'WHERE "statistic_profile_id" = "statistic_profiles"."id")')
|
||||
.where("users.is_active = 'true'")
|
||||
.limit(50)
|
||||
query.downcase.split(' ').each do |word|
|
||||
members = members.where('lower(f_unaccent(profiles.first_name)) ~ :search OR ' \
|
||||
query.downcase.split.each do |word|
|
||||
members = members.where('lower(f_unaccent(users.username)) ~ :search OR ' \
|
||||
'lower(f_unaccent(profiles.first_name)) ~ :search OR ' \
|
||||
'lower(f_unaccent(profiles.last_name)) ~ :search',
|
||||
search: word)
|
||||
end
|
||||
@ -65,13 +66,11 @@ class Members::ListService
|
||||
members = members.where("users.is_allow_contact = 'true'")
|
||||
elsif subscription == 'true'
|
||||
# only admins have the ability to filter by subscription
|
||||
members = members.where('subscriptions.id IS NOT NULL AND subscriptions.expiration_date >= :now', now: Date.today.to_s)
|
||||
members = members.where('subscriptions.id IS NOT NULL AND subscriptions.expiration_date >= :now', now: Time.zone.today.to_s)
|
||||
elsif subscription == 'false'
|
||||
members = members.where('subscriptions.id IS NULL OR subscriptions.expiration_date < :now', now: Date.today.to_s)
|
||||
members = members.where('subscriptions.id IS NULL OR subscriptions.expiration_date < :now', now: Time.zone.today.to_s)
|
||||
end
|
||||
|
||||
members = members.where("roles.name = 'member' OR roles.name = 'manager'") if include_admins == 'false' || include_admins.blank?
|
||||
|
||||
members.to_a.filter(&:valid?)
|
||||
end
|
||||
|
||||
|
@ -15,12 +15,6 @@ class Members::MembersService
|
||||
return false
|
||||
end
|
||||
|
||||
if admin_group_change?(params)
|
||||
# an admin cannot change his group
|
||||
@member.errors.add(:group_id, I18n.t('members.admins_cant_change_group'))
|
||||
return false
|
||||
end
|
||||
|
||||
group_changed = user_group_change?(params)
|
||||
ex_group = @member.group
|
||||
|
||||
@ -130,9 +124,7 @@ class Members::MembersService
|
||||
@member.remove_role ex_role
|
||||
@member.add_role new_role
|
||||
|
||||
# if the new role is 'admin', then change the group to the admins group, otherwise to change to the provided group
|
||||
group_id = new_role == 'admin' ? Group.find_by(slug: 'admins').id : new_group_id
|
||||
@member.update(group_id: group_id)
|
||||
@member.update(group_id: new_group_id)
|
||||
|
||||
# notify
|
||||
NotificationCenter.call type: 'notify_user_role_update',
|
||||
@ -176,10 +168,6 @@ class Members::MembersService
|
||||
params[:group_id] && @member.group_id != params[:group_id].to_i && !@member.subscribed_plan.nil?
|
||||
end
|
||||
|
||||
def admin_group_change?(params)
|
||||
params[:group_id] && params[:group_id].to_i != Group.find_by(slug: 'admins').id && @member.admin?
|
||||
end
|
||||
|
||||
def user_group_change?(params)
|
||||
@member.group_id && params[:group_id] && @member.group_id != params[:group_id].to_i
|
||||
end
|
||||
|
@ -17,35 +17,14 @@ class PaymentScheduleService
|
||||
ps = PaymentSchedule.new(total: price + other_items, coupon: coupon)
|
||||
deadlines = plan.duration / 1.month
|
||||
per_month = (price / deadlines).truncate
|
||||
adjustment = if per_month * deadlines + other_items.truncate != ps.total
|
||||
ps.total - (per_month * deadlines + other_items.truncate)
|
||||
else
|
||||
adjustment = if (per_month * deadlines) + other_items.truncate == ps.total
|
||||
0
|
||||
else
|
||||
ps.total - ((per_month * deadlines) + other_items.truncate)
|
||||
end
|
||||
items = []
|
||||
(0..deadlines - 1).each do |i|
|
||||
date = (start_at || DateTime.current) + i.months
|
||||
details = { recurring: per_month }
|
||||
amount = if i.zero?
|
||||
details[:adjustment] = adjustment.truncate
|
||||
details[:other_items] = other_items.truncate
|
||||
per_month + adjustment.truncate + other_items.truncate
|
||||
else
|
||||
per_month
|
||||
end
|
||||
if coupon
|
||||
cs = CouponService.new
|
||||
if (coupon.validity_per_user == 'once' && i.zero?) || coupon.validity_per_user == 'forever'
|
||||
details[:without_coupon] = amount
|
||||
amount = cs.apply(amount, coupon)
|
||||
end
|
||||
end
|
||||
items.push PaymentScheduleItem.new(
|
||||
amount: amount,
|
||||
due_date: date,
|
||||
payment_schedule: ps,
|
||||
details: details
|
||||
)
|
||||
items.push compute_deadline(i, ps, per_month, adjustment, other_items, coupon: coupon, schedule_start_at: start_at)
|
||||
end
|
||||
ps.start_at = start_at
|
||||
ps.total = items.map(&:amount).reduce(:+)
|
||||
@ -54,9 +33,35 @@ class PaymentScheduleService
|
||||
{ payment_schedule: ps, items: items }
|
||||
end
|
||||
|
||||
def compute_deadline(deadline_index, payment_schedule, price_per_month, adjustment_price, other_items_price,
|
||||
coupon: nil, schedule_start_at: nil)
|
||||
date = (schedule_start_at || DateTime.current) + deadline_index.months
|
||||
details = { recurring: price_per_month }
|
||||
amount = if deadline_index.zero?
|
||||
details[:adjustment] = adjustment_price.truncate
|
||||
details[:other_items] = other_items_price.truncate
|
||||
price_per_month + adjustment_price.truncate + other_items_price.truncate
|
||||
else
|
||||
price_per_month
|
||||
end
|
||||
if coupon
|
||||
cs = CouponService.new
|
||||
if (coupon.validity_per_user == 'once' && deadline_index.zero?) || coupon.validity_per_user == 'forever'
|
||||
details[:without_coupon] = amount
|
||||
amount = cs.apply(amount, coupon)
|
||||
end
|
||||
end
|
||||
PaymentScheduleItem.new(
|
||||
amount: amount,
|
||||
due_date: date,
|
||||
payment_schedule: payment_schedule,
|
||||
details: details
|
||||
)
|
||||
end
|
||||
|
||||
def create(objects, total, customer, coupon: nil, operator: nil, payment_method: nil,
|
||||
payment_id: nil, payment_type: nil)
|
||||
subscription = objects.find { |item| item.class == Subscription }
|
||||
subscription = objects.find { |item| item.instance_of?(Subscription) }
|
||||
|
||||
schedule = compute(subscription.plan, total, customer, coupon: coupon, start_at: subscription.start_at)
|
||||
ps = schedule[:payment_schedule]
|
||||
@ -80,7 +85,7 @@ class PaymentScheduleService
|
||||
def build_objects(objects)
|
||||
res = []
|
||||
res.push(PaymentScheduleObject.new(object: objects[0], main: true))
|
||||
objects[1..-1].each do |object|
|
||||
objects[1..].each do |object|
|
||||
res.push(PaymentScheduleObject.new(object: object))
|
||||
end
|
||||
res
|
||||
@ -117,7 +122,7 @@ class PaymentScheduleService
|
||||
|
||||
# save the results
|
||||
invoice.save
|
||||
payment_schedule_item.update_attributes(invoice_id: invoice.id)
|
||||
payment_schedule_item.update(invoice_id: invoice.id)
|
||||
end
|
||||
|
||||
##
|
||||
@ -133,7 +138,6 @@ class PaymentScheduleService
|
||||
.page(page)
|
||||
.per(size)
|
||||
|
||||
|
||||
unless filters[:reference].nil?
|
||||
ps = ps.where(
|
||||
'payment_schedules.reference LIKE :search',
|
||||
@ -168,7 +172,7 @@ class PaymentScheduleService
|
||||
payment_schedule.ordered_items.each do |item|
|
||||
next if item.state == 'paid'
|
||||
|
||||
item.update_attributes(state: 'canceled')
|
||||
item.update(state: 'canceled')
|
||||
end
|
||||
# cancel subscription
|
||||
subscription = payment_schedule.payment_schedule_objects.find { |pso| pso.object_type == Subscription.name }.subscription
|
||||
@ -192,7 +196,7 @@ class PaymentScheduleService
|
||||
##
|
||||
def reset_erroneous_payment_schedule_items(payment_schedule)
|
||||
results = payment_schedule.payment_schedule_items.where(state: %w[error gateway_canceled]).map do |item|
|
||||
item.update_attributes(state: item.due_date < DateTime.current ? 'pending' : 'new')
|
||||
item.update(state: item.due_date < DateTime.current ? 'pending' : 'new')
|
||||
end
|
||||
results.reduce(true) { |acc, item| acc && item }
|
||||
end
|
||||
@ -208,7 +212,10 @@ class PaymentScheduleService
|
||||
}
|
||||
|
||||
# the subscription and reservation items
|
||||
subscription = payment_schedule_item.payment_schedule.payment_schedule_objects.find { |pso| pso.object_type == Subscription.name }.subscription
|
||||
subscription = payment_schedule_item.payment_schedule
|
||||
.payment_schedule_objects
|
||||
.find { |pso| pso.object_type == Subscription.name }
|
||||
.subscription
|
||||
if payment_schedule_item.payment_schedule.main_object.object_type == Reservation.name
|
||||
details[:reservation] = payment_schedule_item.details['other_items']
|
||||
reservation = payment_schedule_item.payment_schedule.main_object.reservation
|
||||
@ -227,7 +234,10 @@ class PaymentScheduleService
|
||||
##
|
||||
def complete_next_invoice(payment_schedule_item, invoice)
|
||||
# the subscription item
|
||||
subscription = payment_schedule_item.payment_schedule.payment_schedule_objects.find { |pso| pso.object_type == Subscription.name }.subscription
|
||||
subscription = payment_schedule_item.payment_schedule
|
||||
.payment_schedule_objects
|
||||
.find { |pso| pso.object_type == Subscription.name }
|
||||
.subscription
|
||||
|
||||
# sub-price for the subscription
|
||||
details = { subscription: payment_schedule_item.details['recurring'] }
|
||||
@ -244,7 +254,7 @@ class PaymentScheduleService
|
||||
|
||||
return unless subscription
|
||||
|
||||
generate_subscription_item(invoice, subscription, payment_details, reservation.nil?)
|
||||
generate_subscription_item(invoice, subscription, payment_details, main: reservation.nil?)
|
||||
end
|
||||
|
||||
##
|
||||
@ -271,7 +281,7 @@ class PaymentScheduleService
|
||||
# Generate an InvoiceItem for the given subscription and save it in invoice.invoice_items.
|
||||
# This method must be called only with a valid subscription
|
||||
##
|
||||
def generate_subscription_item(invoice, subscription, payment_details, main = true)
|
||||
def generate_subscription_item(invoice, subscription, payment_details, main: true)
|
||||
raise TypeError unless subscription
|
||||
|
||||
invoice.invoice_items.push InvoiceItem.new(
|
||||
@ -291,11 +301,9 @@ class PaymentScheduleService
|
||||
|
||||
total = invoice.invoice_items.map(&:amount).map(&:to_i).reduce(:+)
|
||||
|
||||
unless coupon.nil?
|
||||
if (coupon.validity_per_user == 'once' && payment_schedule_item.first?) || coupon.validity_per_user == 'forever'
|
||||
total = CouponService.new.apply(total, coupon, user.id)
|
||||
invoice.coupon_id = coupon.id
|
||||
end
|
||||
if !coupon.nil? && ((coupon.validity_per_user == 'once' && payment_schedule_item.first?) || coupon.validity_per_user == 'forever')
|
||||
total = CouponService.new.apply(total, coupon, user.id)
|
||||
invoice.coupon_id = coupon.id
|
||||
end
|
||||
|
||||
invoice.total = total
|
||||
|
@ -38,7 +38,7 @@ class PrepaidPackService
|
||||
|
||||
# total number of minutes in the reservation's slots
|
||||
slots_minutes = reservation.slots.map do |slot|
|
||||
(slot.end_at.to_time - slot.start_at.to_time) / SECONDS_PER_MINUTE
|
||||
(slot.end_at.to_time - slot.start_at.to_time) / 60.0
|
||||
end
|
||||
reservation_minutes = slots_minutes.reduce(:+) || 0
|
||||
|
||||
|
@ -4,31 +4,73 @@
|
||||
# Due to the way the controller updates the settings, we cannot safely use ActiveRecord's callbacks (eg. after_update, after_commit...)
|
||||
# so this service provides a wrapper around these operations.
|
||||
class SettingService
|
||||
def self.before_update(setting)
|
||||
return false if Rails.application.secrets.locked_settings.include? setting.name
|
||||
class << self
|
||||
def before_update(setting)
|
||||
return false if Rails.application.secrets.locked_settings.include? setting.name
|
||||
|
||||
true
|
||||
end
|
||||
true
|
||||
end
|
||||
|
||||
def self.after_update(setting)
|
||||
# update the stylesheet
|
||||
Stylesheet.theme&.rebuild! if %w[main_color secondary_color].include? setting.name
|
||||
Stylesheet.home_page&.rebuild! if setting.name == 'home_css'
|
||||
def after_update(setting)
|
||||
update_theme_stylesheet(setting)
|
||||
update_home_stylesheet(setting)
|
||||
notify_privacy_update(setting)
|
||||
sync_stripe_objects(setting)
|
||||
build_stats(setting)
|
||||
export_projects_to_openlab(setting)
|
||||
validate_admins(setting)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# rebuild the theme stylesheet
|
||||
def update_theme_stylesheet(setting)
|
||||
return unless %w[main_color secondary_color].include? setting.name
|
||||
|
||||
Stylesheet.theme&.rebuild!
|
||||
end
|
||||
|
||||
# rebuild the home page stylesheet
|
||||
def update_home_stylesheet(setting)
|
||||
return unless setting.name == 'home_css'
|
||||
|
||||
Stylesheet.home_page&.rebuild!
|
||||
end
|
||||
|
||||
# notify about a change in privacy policy
|
||||
NotifyPrivacyUpdateWorker.perform_async(setting.id) if setting.name == 'privacy_body'
|
||||
def notify_privacy_update(setting)
|
||||
return unless setting.name == 'privacy_body'
|
||||
|
||||
NotifyPrivacyUpdateWorker.perform_async(setting.id)
|
||||
end
|
||||
|
||||
# sync all objects on stripe
|
||||
SyncObjectsOnStripeWorker.perform_async(setting.history_values.last&.invoicing_profile&.user&.id) if setting.name == 'stripe_secret_key'
|
||||
def sync_stripe_objects(setting)
|
||||
return unless setting.name == 'stripe_secret_key'
|
||||
|
||||
# generate statistics
|
||||
PeriodStatisticsWorker.perform_async(setting.previous_update) if setting.name == 'statistics_module' && setting.value == 'true'
|
||||
SyncObjectsOnStripeWorker.perform_async(setting.history_values.last&.invoicing_profile&.user&.id)
|
||||
end
|
||||
|
||||
# generate the statistics since the last update
|
||||
def build_stats(setting)
|
||||
return unless setting.name == 'statistics_module' && setting.value == 'true'
|
||||
|
||||
PeriodStatisticsWorker.perform_async(setting.previous_update)
|
||||
end
|
||||
|
||||
# export projects to openlab
|
||||
if %w[openlab_app_id openlab_app_secret].include? setting.name
|
||||
if Setting.get('openlab_app_id').present? && Setting.get('openlab_app_secret').present?
|
||||
Project.all.each { |pr| pr.openlab_create }
|
||||
end
|
||||
def export_projects_to_openlab(setting)
|
||||
return unless %w[openlab_app_id openlab_app_secret].include?(setting.name) &&
|
||||
Setting.get('openlab_app_id').present? && Setting.get('openlab_app_secret').present?
|
||||
|
||||
Project.all.each(&:openlab_create)
|
||||
end
|
||||
|
||||
# automatically validate the admins
|
||||
def validate_admins(setting)
|
||||
return unless setting.name == 'user_validation_required' && setting.value == 'true'
|
||||
|
||||
User.admins.each { |admin| admin.update(validated_at: DateTime.current) if admin.validated_at.nil? }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -7,39 +7,30 @@ class Statistics::Builders::ReservationsBuilderService
|
||||
class << self
|
||||
def build(options = default_options)
|
||||
# machine/space/training list
|
||||
%w[machine space training].each do |category|
|
||||
%w[machine space training event].each do |category|
|
||||
Statistics::FetcherService.send("reservations_#{category}_list", options).each do |r|
|
||||
%w[booking hour].each do |type|
|
||||
stat = Stats::Machine.new({ date: format_date(r[:date]),
|
||||
type: type,
|
||||
subType: r["#{category}_type".to_sym],
|
||||
ca: r[:ca],
|
||||
machineId: r["#{category}_id".to_sym],
|
||||
name: r["#{category}_name".to_sym],
|
||||
reservationId: r[:reservation_id] }.merge(user_info_stat(r)))
|
||||
stat.stat = (type == 'booking' ? 1 : r[:nb_hours])
|
||||
stat = "Stats::#{category.capitalize}"
|
||||
.constantize
|
||||
.new({ date: format_date(r[:date]),
|
||||
type: type,
|
||||
subType: r["#{category}_type".to_sym],
|
||||
ca: r[:ca],
|
||||
name: r["#{category}_name".to_sym],
|
||||
reservationId: r[:reservation_id] }.merge(user_info_stat(r)))
|
||||
stat[:stat] = (type == 'booking' ? 1 : r[:nb_hours])
|
||||
stat["#{category}Id".to_sym] = r["#{category}_id".to_sym]
|
||||
|
||||
if category == 'event'
|
||||
stat[:eventDate] = r[:event_date]
|
||||
stat[:eventTheme] = r[:event_theme]
|
||||
stat[:ageRange] = r[:age_range]
|
||||
end
|
||||
|
||||
stat.save
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# event list
|
||||
Statistics::FetcherService.reservations_event_list(options).each do |r|
|
||||
%w[booking hour].each do |type|
|
||||
stat = Stats::Event.new({ date: format_date(r[:date]),
|
||||
type: type,
|
||||
subType: r[:event_type],
|
||||
ca: r[:ca],
|
||||
eventId: r[:event_id],
|
||||
name: r[:event_name],
|
||||
eventDate: r[:event_date],
|
||||
reservationId: r[:reservation_id],
|
||||
eventTheme: r[:event_theme],
|
||||
ageRange: r[:age_range] }.merge(user_info_stat(r)))
|
||||
stat.stat = (type == 'booking' ? r[:nb_places] : r[:nb_hours])
|
||||
stat.save
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
35
app/services/user_getter_service.rb
Normal file
35
app/services/user_getter_service.rb
Normal file
@ -0,0 +1,35 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# helpers to read data from a user
|
||||
class UserGetterService
|
||||
def initialize(user)
|
||||
@user = user
|
||||
end
|
||||
|
||||
def read_attribute(attribute)
|
||||
parsed = /^(user|profile)\.(.+)$/.match(attribute)
|
||||
case parsed[1]
|
||||
when 'user'
|
||||
@user[parsed[2].to_sym]
|
||||
when 'profile'
|
||||
case attribute
|
||||
when 'profile.avatar'
|
||||
@user.profile.user_avatar.remote_attachment_url
|
||||
when 'profile.address'
|
||||
@user.invoicing_profile.address&.address
|
||||
when 'profile.organization_name'
|
||||
@user.invoicing_profile.organization&.name
|
||||
when 'profile.organization_address'
|
||||
@user.invoicing_profile.organization&.address&.address
|
||||
when 'profile.gender'
|
||||
@user.statistic_profile.gender
|
||||
when 'profile.birthday'
|
||||
@user.statistic_profile.birthday
|
||||
else
|
||||
@user.profile[parsed[2].to_sym]
|
||||
end
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
@ -2,67 +2,66 @@
|
||||
|
||||
# helpers for managing users with special roles
|
||||
class UserService
|
||||
def self.create_partner(params)
|
||||
generated_password = SecurePassword.generate
|
||||
group_id = Group.first.id
|
||||
user = User.new(
|
||||
email: params[:email],
|
||||
username: "#{params[:first_name]}#{params[:last_name]}".parameterize,
|
||||
password: generated_password,
|
||||
password_confirmation: generated_password,
|
||||
group_id: group_id
|
||||
)
|
||||
user.build_profile(
|
||||
first_name: params[:first_name],
|
||||
last_name: params[:last_name],
|
||||
phone: '0000000000'
|
||||
)
|
||||
user.build_statistic_profile(
|
||||
gender: true,
|
||||
birthday: DateTime.current
|
||||
)
|
||||
class << self
|
||||
def create_partner(params)
|
||||
generated_password = SecurePassword.generate
|
||||
group_id = Group.first.id
|
||||
user = User.new(
|
||||
email: params[:email],
|
||||
username: "#{params[:first_name]}#{params[:last_name]}".parameterize,
|
||||
password: generated_password,
|
||||
password_confirmation: generated_password,
|
||||
group_id: group_id
|
||||
)
|
||||
user.build_profile(
|
||||
first_name: params[:first_name],
|
||||
last_name: params[:last_name],
|
||||
phone: '0000000000'
|
||||
)
|
||||
user.build_statistic_profile(
|
||||
gender: true,
|
||||
birthday: DateTime.current
|
||||
)
|
||||
|
||||
saved = user.save
|
||||
if saved
|
||||
user.remove_role :member
|
||||
user.add_role :partner
|
||||
saved = user.save
|
||||
if saved
|
||||
user.remove_role :member
|
||||
user.add_role :partner
|
||||
end
|
||||
{ saved: saved, user: user }
|
||||
end
|
||||
{ saved: saved, user: user }
|
||||
end
|
||||
|
||||
def self.create_admin(params)
|
||||
generated_password = SecurePassword.generate
|
||||
admin = User.new(params.merge(password: generated_password))
|
||||
admin.send :set_slug
|
||||
def create_admin(params)
|
||||
generated_password = SecurePassword.generate
|
||||
admin = User.new(params.merge(password: generated_password, validated_at: DateTime.current))
|
||||
admin.send :set_slug
|
||||
|
||||
# we associate the admin group to prevent linking any other 'normal' group (which won't be deletable afterwards)
|
||||
admin.group = Group.find_by(slug: 'admins')
|
||||
# if the authentication is made through an SSO, generate a migration token
|
||||
admin.generate_auth_migration_token unless AuthProvider.active.providable_type == DatabaseProvider.name
|
||||
|
||||
# if the authentication is made through an SSO, generate a migration token
|
||||
admin.generate_auth_migration_token unless AuthProvider.active.providable_type == DatabaseProvider.name
|
||||
|
||||
saved = admin.save
|
||||
if saved
|
||||
admin.send_confirmation_instructions
|
||||
admin.add_role(:admin)
|
||||
admin.remove_role(:member)
|
||||
UsersMailer.notify_user_account_created(admin, generated_password).deliver_later
|
||||
saved = admin.save
|
||||
if saved
|
||||
admin.send_confirmation_instructions
|
||||
admin.add_role(:admin)
|
||||
admin.remove_role(:member)
|
||||
UsersMailer.notify_user_account_created(admin, generated_password).deliver_later
|
||||
end
|
||||
{ saved: saved, user: admin }
|
||||
end
|
||||
{ saved: saved, user: admin }
|
||||
end
|
||||
|
||||
def self.create_manager(params)
|
||||
generated_password = SecurePassword.generate
|
||||
manager = User.new(params.merge(password: generated_password))
|
||||
manager.send :set_slug
|
||||
def create_manager(params)
|
||||
generated_password = SecurePassword.generate
|
||||
manager = User.new(params.merge(password: generated_password))
|
||||
manager.send :set_slug
|
||||
|
||||
saved = manager.save
|
||||
if saved
|
||||
manager.send_confirmation_instructions
|
||||
manager.add_role(:manager)
|
||||
manager.remove_role(:member)
|
||||
UsersMailer.notify_user_account_created(manager, generated_password).deliver_later
|
||||
saved = manager.save
|
||||
if saved
|
||||
manager.send_confirmation_instructions
|
||||
manager.add_role(:manager)
|
||||
manager.remove_role(:member)
|
||||
UsersMailer.notify_user_account_created(manager, generated_password).deliver_later
|
||||
end
|
||||
{ saved: saved, user: manager }
|
||||
end
|
||||
{ saved: saved, user: manager }
|
||||
end
|
||||
end
|
||||
|
73
app/services/user_setter_service.rb
Normal file
73
app/services/user_setter_service.rb
Normal file
@ -0,0 +1,73 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# helpers to assign data to a user
|
||||
class UserSetterService
|
||||
def initialize(user)
|
||||
@user = user
|
||||
end
|
||||
|
||||
def assign_avatar(data)
|
||||
@user.profile.user_avatar ||= UserAvatar.new
|
||||
@user.profile.user_avatar.remote_attachment_url = data
|
||||
end
|
||||
|
||||
def assign_address(data)
|
||||
@user.invoicing_profile ||= InvoicingProfile.new
|
||||
@user.invoicing_profile.address ||= Address.new
|
||||
@user.invoicing_profile.address.address = data
|
||||
end
|
||||
|
||||
def assign_organization_name(data)
|
||||
@user.invoicing_profile ||= InvoicingProfile.new
|
||||
@user.invoicing_profile.organization ||= Organization.new
|
||||
@user.invoicing_profile.organization.name = data
|
||||
end
|
||||
|
||||
def assign_organization_address(data)
|
||||
@user.invoicing_profile ||= InvoicingProfile.new
|
||||
@user.invoicing_profile.organization ||= Organization.new
|
||||
@user.invoicing_profile.organization.address ||= Address.new
|
||||
@user.invoicing_profile.organization.address.address = data
|
||||
end
|
||||
|
||||
def assign_gender(data)
|
||||
@user.statistic_profile ||= StatisticProfile.new
|
||||
@user.statistic_profile.gender = data
|
||||
end
|
||||
|
||||
def assign_birthday(data)
|
||||
@user.statistic_profile ||= StatisticProfile.new
|
||||
@user.statistic_profile.birthday = data
|
||||
end
|
||||
|
||||
def assign_profile_attribute(attribute, data)
|
||||
@user.profile[attribute[8..].to_sym] = data
|
||||
end
|
||||
|
||||
def assign_user_attribute(attribute, data)
|
||||
@user[attribute[5..].to_sym] = data
|
||||
end
|
||||
|
||||
def assign_attibute(attribute, data)
|
||||
if attribute.to_s.start_with? 'user.'
|
||||
assign_user_attribute(attribute, data)
|
||||
elsif attribute.to_s.start_with? 'profile.'
|
||||
case attribute.to_s
|
||||
when 'profile.avatar'
|
||||
assign_avatar(data)
|
||||
when 'profile.address'
|
||||
assign_address(data)
|
||||
when 'profile.organization_name'
|
||||
assign_organization_name(data)
|
||||
when 'profile.organization_address'
|
||||
assign_organization_address(data)
|
||||
when 'profile.gender'
|
||||
assign_gender(data)
|
||||
when 'profile.birthday'
|
||||
assign_birthday(data)
|
||||
else
|
||||
assign_profile_attribute(attribute, data)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
@ -37,7 +37,9 @@ class VatHistoryService
|
||||
|
||||
private
|
||||
|
||||
def vat_history(vat_rate_type)
|
||||
# This method is really complex and cannot be simplified using the current data model
|
||||
# As a futur improvement, we should save the VAT rate for each invoice_item in the DB
|
||||
def vat_history(vat_rate_type) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
||||
chronology = []
|
||||
end_date = DateTime.current
|
||||
Setting.find_by(name: 'invoice_VAT-active').history_values.order(created_at: 'DESC').each do |v|
|
||||
@ -92,7 +94,7 @@ class VatHistoryService
|
||||
# when the VAT rate was enabled, set the date it was enabled and the rate
|
||||
range = chronology.find { |p| rate.created_at.to_i.between?(p[:start].to_i, p[:end].to_i) }
|
||||
date = range[:enabled] ? rate.created_at : range[:end]
|
||||
date_rates.push(date: date, rate: rate.value.to_i) unless date_rates.find { |d| d[:date] == date }
|
||||
date_rates.push(date: date, rate: rate.value.to_f) unless date_rates.find { |d| d[:date].to_i == date.to_i }
|
||||
end
|
||||
chronology.reverse_each do |period|
|
||||
# when the VAT rate was disabled, set the date it was disabled and rate=0
|
||||
|
11
app/views/api/machines/_machine.json.jbuilder
Normal file
11
app/views/api/machines/_machine.json.jbuilder
Normal file
@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
json.extract! machine, :id, :name, :slug, :disabled
|
||||
|
||||
if machine.machine_image
|
||||
json.machine_image_attributes do
|
||||
json.id machine.machine_image.id
|
||||
json.attachment_name machine.machine_image.attachment_identifier
|
||||
json.attachment_url machine.machine_image.attachment.url
|
||||
end
|
||||
end
|
@ -1,7 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
json.array!(@machines) do |machine|
|
||||
json.extract! machine, :id, :name, :slug, :disabled
|
||||
|
||||
json.machine_image machine.machine_image.attachment.medium.url if machine.machine_image
|
||||
json.partial! 'api/machines/machine', machine: machine
|
||||
end
|
||||
|
@ -1,10 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
json.extract! @machine, :id, :name, :description, :spec, :disabled, :slug
|
||||
json.machine_image @machine.machine_image.attachment.large.url if @machine.machine_image
|
||||
json.partial! 'api/machines/machine', machine: @machine
|
||||
json.extract! @machine, :description, :spec
|
||||
|
||||
json.machine_files_attributes @machine.machine_files do |f|
|
||||
json.id f.id
|
||||
json.attachment f.attachment_identifier
|
||||
json.attachment_name f.attachment_identifier
|
||||
json.attachment_url f.attachment_url
|
||||
end
|
||||
json.trainings @machine.trainings.each, :id, :name, :disabled
|
||||
|
@ -1,27 +1,29 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# asynchronously export the statistics to an excel file and send the result by email
|
||||
class StatisticsExportWorker
|
||||
include Sidekiq::Worker
|
||||
|
||||
def perform(export_id)
|
||||
export = Export.find(export_id)
|
||||
|
||||
unless export.user.admin?
|
||||
raise SecurityError, 'Not allowed to export'
|
||||
end
|
||||
raise SecurityError, 'Not allowed to export' unless export.user.admin?
|
||||
|
||||
unless export.category == 'statistics'
|
||||
raise KeyError, 'Wrong worker called'
|
||||
end
|
||||
raise KeyError, 'Wrong worker called' unless export.category == 'statistics'
|
||||
|
||||
service = StatisticsExportService.new
|
||||
method_name = "export_#{export.export_type}"
|
||||
|
||||
if %w(account event machine project subscription training space global).include?(export.export_type) and service.respond_to?(method_name)
|
||||
service.public_send(method_name, export)
|
||||
|
||||
NotificationCenter.call type: :notify_admin_export_complete,
|
||||
receiver: export.user,
|
||||
attached_object: export
|
||||
unless %w[account event machine project subscription training space global].include?(export.export_type) &&
|
||||
service.respond_to?(method_name)
|
||||
return
|
||||
end
|
||||
|
||||
service.public_send(method_name, export)
|
||||
|
||||
NotificationCenter.call type: :notify_admin_export_complete,
|
||||
receiver: export.user,
|
||||
attached_object: export
|
||||
|
||||
end
|
||||
end
|
||||
|
@ -2,18 +2,16 @@
|
||||
|
||||
ActiveRecord::Base.class_eval do
|
||||
def dump_fixture
|
||||
fixture_file = "#{Rails.root}/test/fixtures/#{self.class.table_name}.yml"
|
||||
fixture_file = Rails.root.join("test/fixtures/#{self.class.table_name}.yml")
|
||||
File.open(fixture_file, 'a') do |f|
|
||||
f.puts({ "#{self.class.table_name.singularize}_#{id}" => attributes }.
|
||||
to_yaml.sub!(/---\s?/, "\n"))
|
||||
f.puts({ "#{self.class.table_name.singularize}_#{id}" => attributes }.to_yaml.sub!(/---\s?/, "\n"))
|
||||
end
|
||||
end
|
||||
|
||||
def self.dump_fixtures
|
||||
fixture_file = "#{Rails.root}/test/fixtures/#{table_name}.yml"
|
||||
fixture_file = Rails.root.join("test/fixtures/#{table_name}.yml")
|
||||
mode = (File.exist?(fixture_file) ? 'a' : 'w')
|
||||
File.open(fixture_file, mode) do |f|
|
||||
|
||||
if attribute_names.include?('id')
|
||||
all.each do |instance|
|
||||
f.puts({ "#{table_name.singularize}_#{instance.id}" => instance.attributes }.to_yaml.sub!(/---\s?/, "\n"))
|
||||
|
@ -1,8 +0,0 @@
|
||||
# Be sure to restart your server when you modify this file.
|
||||
|
||||
# ActiveSupport::Reloader.to_prepare do
|
||||
# ApplicationController.renderer.defaults.merge!(
|
||||
# http_host: 'example.org',
|
||||
# https: false
|
||||
# )
|
||||
# end
|
@ -1,39 +0,0 @@
|
||||
# # frozen_string_literal: true
|
||||
#
|
||||
# # Be sure to restart your server when you modify this file.
|
||||
#
|
||||
# # Version of your assets, change this if you want to expire all your assets.
|
||||
# Rails.application.config.assets.version = '1.0'
|
||||
#
|
||||
# # allow use rails helpers in angular templates
|
||||
# Rails.application.config.assets.configure do |env|
|
||||
# env.context_class.class_eval do
|
||||
# include ActionView::Helpers
|
||||
# include Rails.application.routes.url_helpers
|
||||
# end
|
||||
# end
|
||||
#
|
||||
# # Add additional assets to the asset load path.
|
||||
# # Rails.application.config.assets.paths << Emoji.images_path
|
||||
# # Add Yarn node_modules folder to the asset load path.
|
||||
# Rails.application.config.assets.paths << Rails.root.join('node_modules')
|
||||
#
|
||||
# # Precompile additional assets.
|
||||
# # application.js, application.css, and all non-JS/CSS in the app/assets
|
||||
# # folder are already added.
|
||||
# # Rails.application.config.assets.precompile += %w( admin.js admin.css )
|
||||
#
|
||||
# Rails.application.config.assets.precompile += %w[
|
||||
# fontawesome-webfont.eot
|
||||
# fontawesome-webfont.woff
|
||||
# fontawesome-webfont.svg
|
||||
# fontawesome-webfont.ttf
|
||||
# ]
|
||||
# Rails.application.config.assets.precompile += %w[app.printer.css]
|
||||
#
|
||||
# Rails.application.config.assets.precompile += %w[
|
||||
# angular-i18n/angular-locale_*.js
|
||||
# moment/locale/*.js
|
||||
# summernote/lang/*.js
|
||||
# fullcalendar/dist/lang/*.js
|
||||
# ]
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user