mirror of
https://github.com/LaCasemate/fab-manager.git
synced 2025-02-20 14:54:15 +01:00
create/edit product form
This commit is contained in:
parent
e23e83000d
commit
5e1436eda4
@ -4,7 +4,7 @@
|
||||
# Products are used in store
|
||||
class API::ProductsController < API::ApiController
|
||||
before_action :authenticate_user!, except: %i[index show]
|
||||
before_action :set_product, only: %i[update destroy]
|
||||
before_action :set_product, only: %i[show update destroy]
|
||||
|
||||
def index
|
||||
@products = ProductService.list
|
||||
@ -15,6 +15,8 @@ class API::ProductsController < API::ApiController
|
||||
def create
|
||||
authorize Product
|
||||
@product = Product.new(product_params)
|
||||
@product.amount = nil if @product.amount.zero?
|
||||
@product.amount *= 100 if @product.amount.present?
|
||||
if @product.save
|
||||
render status: :created
|
||||
else
|
||||
@ -25,7 +27,10 @@ class API::ProductsController < API::ApiController
|
||||
def update
|
||||
authorize @product
|
||||
|
||||
if @product.update(product_params)
|
||||
product_parameters = product_params
|
||||
product_parameters[:amount] = nil if product_parameters[:amount].zero?
|
||||
product_parameters[:amount] = product_parameters[:amount] * 100 if product_parameters[:amount].present?
|
||||
if @product.update(product_parameters)
|
||||
render status: :ok
|
||||
else
|
||||
render json: @product.errors.full_messages, status: :unprocessable_entity
|
||||
@ -47,6 +52,6 @@ class API::ProductsController < API::ApiController
|
||||
def product_params
|
||||
params.require(:product).permit(:name, :slug, :sku, :description, :is_active,
|
||||
:product_category_id, :amount, :quantity_min,
|
||||
:low_stock_alert, :low_stock_threshold)
|
||||
:low_stock_alert, :low_stock_threshold, machine_ids: [])
|
||||
end
|
||||
end
|
||||
|
@ -0,0 +1,99 @@
|
||||
import React, { BaseSyntheticEvent } from 'react';
|
||||
import { Controller, Path, FieldPathValue } from 'react-hook-form';
|
||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||
import { FieldPath } from 'react-hook-form/dist/types/path';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { UnpackNestedValue } from 'react-hook-form/dist/types';
|
||||
import { FormControlledComponent } from '../../models/form-component';
|
||||
import { AbstractFormItem, AbstractFormItemProps } from './abstract-form-item';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
|
||||
/**
|
||||
* Checklist Option format
|
||||
*/
|
||||
export type ChecklistOption<TOptionValue> = { value: TOptionValue, label: string };
|
||||
|
||||
interface FormCheckListProps<TFieldValues, TOptionValue, TContext extends object> extends FormControlledComponent<TFieldValues, TContext>, AbstractFormItemProps<TFieldValues> {
|
||||
defaultValue?: Array<TOptionValue>,
|
||||
options: Array<ChecklistOption<TOptionValue>>,
|
||||
onChange?: (values: Array<TOptionValue>) => void,
|
||||
}
|
||||
|
||||
/**
|
||||
* This component is a template for an check list component to use within React Hook Form
|
||||
*/
|
||||
export const FormCheckList = <TFieldValues extends FieldValues, TOptionValue, TContext extends object>({ id, control, label, tooltip, defaultValue, className, rules, disabled, error, warning, formState, onChange, options }: FormCheckListProps<TFieldValues, TOptionValue, TContext>) => {
|
||||
const { t } = useTranslation('shared');
|
||||
|
||||
/**
|
||||
* Verify if the provided option is currently ticked
|
||||
*/
|
||||
const isChecked = (values: Array<TOptionValue>, option: ChecklistOption<TOptionValue>): boolean => {
|
||||
return !!values?.includes(option.value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback triggered when a checkbox is ticked or unticked.
|
||||
*/
|
||||
const toggleCheckbox = (option: ChecklistOption<TOptionValue>, values: Array<TOptionValue> = [], cb: (value: Array<TOptionValue>) => void) => {
|
||||
return (event: BaseSyntheticEvent) => {
|
||||
let newValues: Array<TOptionValue> = [];
|
||||
if (event.target.checked) {
|
||||
newValues = values.concat(option.value);
|
||||
} else {
|
||||
newValues = values.filter(v => v !== option.value);
|
||||
}
|
||||
cb(newValues);
|
||||
if (typeof onChange === 'function') {
|
||||
onChange(newValues);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback triggered to select all options
|
||||
*/
|
||||
const allSelect = (cb: (value: Array<TOptionValue>) => void) => {
|
||||
return () => {
|
||||
const newValues: Array<TOptionValue> = options.map(o => o.value);
|
||||
cb(newValues);
|
||||
if (typeof onChange === 'function') {
|
||||
onChange(newValues);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Compose classnames from props
|
||||
const classNames = [
|
||||
`${className || ''}`
|
||||
].join(' ');
|
||||
|
||||
return (
|
||||
<AbstractFormItem id={id} formState={formState} label={label}
|
||||
className={`form-check-list form-input ${classNames}`} tooltip={tooltip}
|
||||
disabled={disabled}
|
||||
rules={rules} error={error} warning={warning}>
|
||||
<Controller name={id as FieldPath<TFieldValues>}
|
||||
control={control}
|
||||
defaultValue={defaultValue as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>}
|
||||
rules={rules}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<>
|
||||
<div className="checklist">
|
||||
{options.map((option, k) => {
|
||||
return (
|
||||
<div key={k} className="checklist-item">
|
||||
<input id={`option-${k}`} type="checkbox" checked={isChecked(value, option)} onChange={toggleCheckbox(option, value, onChange)} />
|
||||
<label htmlFor={`option-${k}`}>{option.label}</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<FabButton type="button" onClick={allSelect(onChange)} className="checklist-all-button">{t('app.shared.form_check_list.select_all')}</FabButton>
|
||||
</>
|
||||
);
|
||||
}} />
|
||||
</AbstractFormItem>
|
||||
);
|
||||
};
|
@ -0,0 +1,56 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { Loader } from '../base/loader';
|
||||
import { IApplication } from '../../models/application';
|
||||
import { ProductForm } from './product-form';
|
||||
import { Product } from '../../models/product';
|
||||
import ProductAPI from '../../api/product';
|
||||
|
||||
declare const Application: IApplication;
|
||||
|
||||
interface EditProductProps {
|
||||
productId: number,
|
||||
onSuccess: (message: string) => void,
|
||||
onError: (message: string) => void,
|
||||
}
|
||||
|
||||
/**
|
||||
* This component show new product form
|
||||
*/
|
||||
const EditProduct: React.FC<EditProductProps> = ({ productId, onSuccess, onError }) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
const [product, setProduct] = useState<Product>();
|
||||
|
||||
useEffect(() => {
|
||||
ProductAPI.get(productId).then(data => {
|
||||
setProduct(data);
|
||||
}).catch(onError);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Success to save product and return to product list
|
||||
*/
|
||||
const saveProductSuccess = () => {
|
||||
onSuccess(t('app.admin.store.edit_product.successfully_updated'));
|
||||
window.location.href = '/#!/admin/store/products';
|
||||
};
|
||||
|
||||
if (product) {
|
||||
return (
|
||||
<ProductForm product={product} title={product.name} onSuccess={saveProductSuccess} onError={onError} />
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const EditProductWrapper: React.FC<EditProductProps> = ({ productId, onSuccess, onError }) => {
|
||||
return (
|
||||
<Loader>
|
||||
<EditProduct productId={productId} onSuccess={onSuccess} onError={onError} />
|
||||
</Loader>
|
||||
);
|
||||
};
|
||||
|
||||
Application.Components.component('editProduct', react2angular(EditProductWrapper, ['productId', 'onSuccess', 'onError']));
|
58
app/frontend/src/javascript/components/store/new-product.tsx
Normal file
58
app/frontend/src/javascript/components/store/new-product.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { Loader } from '../base/loader';
|
||||
import { IApplication } from '../../models/application';
|
||||
import { ProductForm } from './product-form';
|
||||
|
||||
declare const Application: IApplication;
|
||||
|
||||
interface NewProductProps {
|
||||
onSuccess: (message: string) => void,
|
||||
onError: (message: string) => void,
|
||||
}
|
||||
|
||||
/**
|
||||
* This component show new product form
|
||||
*/
|
||||
const NewProduct: React.FC<NewProductProps> = ({ onSuccess, onError }) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
const product = {
|
||||
id: undefined,
|
||||
name: '',
|
||||
slug: '',
|
||||
sku: '',
|
||||
description: '',
|
||||
is_active: false,
|
||||
quantity_min: 1,
|
||||
stock: {
|
||||
internal: 0,
|
||||
external: 0
|
||||
},
|
||||
low_stock_alert: false,
|
||||
machine_ids: []
|
||||
};
|
||||
|
||||
/**
|
||||
* Success to save product and return to product list
|
||||
*/
|
||||
const saveProductSuccess = () => {
|
||||
onSuccess(t('app.admin.store.new_product.successfully_created'));
|
||||
window.location.href = '/#!/admin/store/products';
|
||||
};
|
||||
|
||||
return (
|
||||
<ProductForm product={product} title={t('app.admin.store.new_product.add_a_new_product')} onSuccess={saveProductSuccess} onError={onError} />
|
||||
);
|
||||
};
|
||||
|
||||
const NewProductWrapper: React.FC<NewProductProps> = ({ onSuccess, onError }) => {
|
||||
return (
|
||||
<Loader>
|
||||
<NewProduct onSuccess={onSuccess} onError={onError} />
|
||||
</Loader>
|
||||
);
|
||||
};
|
||||
|
||||
Application.Components.component('newProduct', react2angular(NewProductWrapper, ['onSuccess', 'onError']));
|
199
app/frontend/src/javascript/components/store/product-form.tsx
Normal file
199
app/frontend/src/javascript/components/store/product-form.tsx
Normal file
@ -0,0 +1,199 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import slugify from 'slugify';
|
||||
import _ from 'lodash';
|
||||
import { HtmlTranslate } from '../base/html-translate';
|
||||
import { Product } from '../../models/product';
|
||||
import { FormInput } from '../form/form-input';
|
||||
import { FormSwitch } from '../form/form-switch';
|
||||
import { FormSelect } from '../form/form-select';
|
||||
import { FormCheckList } from '../form/form-check-list';
|
||||
import { FormRichText } from '../form/form-rich-text';
|
||||
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';
|
||||
|
||||
interface ProductFormProps {
|
||||
product: Product,
|
||||
title: string,
|
||||
onSuccess: (product: Product) => void,
|
||||
onError: (message: string) => void,
|
||||
}
|
||||
|
||||
/**
|
||||
* Option format, expected by react-select
|
||||
* @see https://github.com/JedWatson/react-select
|
||||
*/
|
||||
type selectOption = { value: number, label: string };
|
||||
|
||||
/**
|
||||
* Option format, expected by checklist
|
||||
*/
|
||||
type checklistOption = { value: number, label: string };
|
||||
|
||||
/**
|
||||
* Form component to create or update a product
|
||||
*/
|
||||
export const ProductForm: React.FC<ProductFormProps> = ({ product, title, onSuccess, onError }) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
const { handleSubmit, register, control, formState, setValue, reset } = useForm<Product>({ defaultValues: { ...product } });
|
||||
const [isActivePrice, setIsActivePrice] = useState<boolean>(product.id && _.isFinite(product.amount) && product.amount > 0);
|
||||
const [productCategories, setProductCategories] = useState<selectOption[]>([]);
|
||||
const [machines, setMachines] = useState<checklistOption[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
ProductCategoryAPI.index().then(data => {
|
||||
setProductCategories(buildSelectOptions(data));
|
||||
}).catch(onError);
|
||||
MachineAPI.index({ disabled: false }).then(data => {
|
||||
setMachines(buildChecklistOptions(data));
|
||||
}).catch(onError);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Convert the provided array of items to the react-select format
|
||||
*/
|
||||
const buildSelectOptions = (items: Array<{ id?: number, name: string }>): Array<selectOption> => {
|
||||
return items.map(t => {
|
||||
return { value: t.id, label: t.name };
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert the provided array of items to the checklist format
|
||||
*/
|
||||
const buildChecklistOptions = (items: Array<{ id?: number, name: string }>): Array<checklistOption> => {
|
||||
return items.map(t => {
|
||||
return { value: t.id, label: t.name };
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback triggered when the name has changed.
|
||||
*/
|
||||
const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const name = event.target.value;
|
||||
const slug = slugify(name, { lower: true, strict: true });
|
||||
setValue('slug', slug);
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback triggered when is active price has changed.
|
||||
*/
|
||||
const toggleIsActivePrice = (value: boolean) => {
|
||||
if (!value) {
|
||||
setValue('amount', null);
|
||||
}
|
||||
setIsActivePrice(value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback triggered when the form is submitted: process with the product creation or update.
|
||||
*/
|
||||
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
return handleSubmit((data: Product) => {
|
||||
saveProduct(data);
|
||||
})(event);
|
||||
};
|
||||
|
||||
/**
|
||||
* Call product creation or update api
|
||||
*/
|
||||
const saveProduct = (data: Product) => {
|
||||
if (product.id) {
|
||||
ProductAPI.update(data).then((res) => {
|
||||
reset(res);
|
||||
onSuccess(res);
|
||||
}).catch(onError);
|
||||
} else {
|
||||
ProductAPI.create(data).then((res) => {
|
||||
reset(res);
|
||||
onSuccess(res);
|
||||
}).catch(onError);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>{title}</h2>
|
||||
<FabButton className="save" onClick={handleSubmit(saveProduct)}>{t('app.admin.store.product_form.save')}</FabButton>
|
||||
<form className="product-form" onSubmit={onSubmit}>
|
||||
<FormInput id="name"
|
||||
register={register}
|
||||
rules={{ required: true }}
|
||||
formState={formState}
|
||||
onChange={handleNameChange}
|
||||
label={t('app.admin.store.product_form.name')} />
|
||||
<FormInput id="sku"
|
||||
register={register}
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_form.sku')} />
|
||||
<FormInput id="slug"
|
||||
register={register}
|
||||
rules={{ required: true }}
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_form.slug')} />
|
||||
<FormSwitch control={control}
|
||||
id="is_active"
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_form.is_show_in_store')} />
|
||||
<div className="price-data">
|
||||
<h4>{t('app.admin.store.product_form.price_and_rule_of_selling_product')}</h4>
|
||||
<FormSwitch control={control}
|
||||
id="is_active_price"
|
||||
label={t('app.admin.store.product_form.is_active_price')}
|
||||
tooltip={t('app.admin.store.product_form.is_active_price')}
|
||||
defaultValue={isActivePrice}
|
||||
onChange={toggleIsActivePrice} />
|
||||
{isActivePrice && <div className="price-fields">
|
||||
<FormInput id="amount"
|
||||
type="number"
|
||||
register={register}
|
||||
rules={{ required: true, min: 0.01 }}
|
||||
step={0.01}
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_form.price')} />
|
||||
<FormInput id="quantity_min"
|
||||
type="number"
|
||||
rules={{ required: true }}
|
||||
register={register}
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_form.quantity_min')} />
|
||||
</div>}
|
||||
<h4>{t('app.admin.store.product_form.assigning_category')}</h4>
|
||||
<FabAlert level="warning">
|
||||
<HtmlTranslate trKey="app.admin.store.product_form.assigning_category_info" />
|
||||
</FabAlert>
|
||||
<FormSelect options={productCategories}
|
||||
control={control}
|
||||
id="product_category_id"
|
||||
formState={formState}
|
||||
label={t('app.admin.store.product_form.linking_product_to_category')} />
|
||||
<h4>{t('app.admin.store.product_form.assigning_machines')}</h4>
|
||||
<FabAlert level="warning">
|
||||
<HtmlTranslate trKey="app.admin.store.product_form.assigning_machines_info" />
|
||||
</FabAlert>
|
||||
<FormCheckList options={machines}
|
||||
control={control}
|
||||
id="machine_ids"
|
||||
formState={formState} />
|
||||
<h4>{t('app.admin.store.product_form.product_description')}</h4>
|
||||
<FabAlert level="warning">
|
||||
<HtmlTranslate trKey="app.admin.store.product_form.product_description_info" />
|
||||
</FabAlert>
|
||||
<FormRichText control={control}
|
||||
paragraphTools={true}
|
||||
limit={1000}
|
||||
id="description" />
|
||||
</div>
|
||||
<div className="main-actions">
|
||||
<FabButton type="submit" className="submit-button">{t('app.admin.store.product_form.save')}</FabButton>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,10 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { HtmlTranslate } from '../base/html-translate';
|
||||
import { Loader } from '../base/loader';
|
||||
import { IApplication } from '../../models/application';
|
||||
import { FabAlert } from '../base/fab-alert';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import { ProductsList } from './products-list';
|
||||
import { Product } from '../../models/product';
|
||||
@ -24,7 +22,6 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
const [products, setProducts] = useState<Array<Product>>([]);
|
||||
const [product, setProduct] = useState<Product>(null);
|
||||
|
||||
useEffect(() => {
|
||||
ProductAPI.index().then(data => {
|
||||
@ -33,10 +30,10 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Open edit the product modal
|
||||
* Goto edit product page
|
||||
*/
|
||||
const editProduct = (product: Product) => {
|
||||
setProduct(product);
|
||||
window.location.href = `/#!/admin/store/products/${product.id}/edit`;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -53,10 +50,17 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Goto new product page
|
||||
*/
|
||||
const newProduct = (): void => {
|
||||
window.location.href = '/#!/admin/store/products/new';
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>{t('app.admin.store.products.all_products')}</h2>
|
||||
<FabButton className="save">{t('app.admin.store.products.create_a_product')}</FabButton>
|
||||
<FabButton className="save" onClick={newProduct}>{t('app.admin.store.products.create_a_product')}</FabButton>
|
||||
<ProductsList
|
||||
products={products}
|
||||
onEdit={editProduct}
|
||||
|
@ -0,0 +1,47 @@
|
||||
/* eslint-disable
|
||||
no-return-assign,
|
||||
no-undef,
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
Application.Controllers.controller('AdminStoreProductController', ['$scope', 'CSRF', 'growl', '$state', '$transition$',
|
||||
function ($scope, CSRF, growl, $state, $transition$) {
|
||||
/* PUBLIC SCOPE */
|
||||
$scope.productId = $transition$.params().id;
|
||||
|
||||
/**
|
||||
* Callback triggered in case of error
|
||||
*/
|
||||
$scope.onError = (message) => {
|
||||
growl.error(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback triggered in case of success
|
||||
*/
|
||||
$scope.onSuccess = (message) => {
|
||||
growl.success(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Click Callback triggered in case of back products list
|
||||
*/
|
||||
$scope.backProductsList = () => {
|
||||
$state.go('app.admin.store.products');
|
||||
};
|
||||
|
||||
/* PRIVATE SCOPE */
|
||||
|
||||
/**
|
||||
* Kind of constructor: these actions will be realized first when the controller is loaded
|
||||
*/
|
||||
const initialize = function () {
|
||||
// set the authenticity tokens in the forms
|
||||
CSRF.setMetaTags();
|
||||
};
|
||||
|
||||
// init the controller (call at the end !)
|
||||
return initialize();
|
||||
}
|
||||
|
||||
]);
|
@ -15,10 +15,11 @@ export interface Product {
|
||||
sku: string,
|
||||
description: string,
|
||||
is_active: boolean,
|
||||
product_category_id: number,
|
||||
amount: number,
|
||||
quantity_min: number,
|
||||
product_category_id?: number,
|
||||
amount?: number,
|
||||
quantity_min?: number,
|
||||
stock: Stock,
|
||||
low_stock_alert: boolean,
|
||||
low_stock_threshold: number,
|
||||
low_stock_threshold?: number,
|
||||
machine_ids: number[],
|
||||
}
|
||||
|
@ -1106,7 +1106,11 @@ angular.module('application.router', ['ui.router'])
|
||||
|
||||
.state('app.admin.store', {
|
||||
abstract: true,
|
||||
url: '/admin/store',
|
||||
url: '/admin/store'
|
||||
})
|
||||
|
||||
.state('app.admin.store.settings', {
|
||||
url: '/settings',
|
||||
views: {
|
||||
'main@': {
|
||||
templateUrl: '/admin/store/index.html',
|
||||
@ -1115,20 +1119,54 @@ angular.module('application.router', ['ui.router'])
|
||||
}
|
||||
})
|
||||
|
||||
.state('app.admin.store.settings', {
|
||||
url: '/settings'
|
||||
.state('app.admin.store.products', {
|
||||
url: '/products',
|
||||
views: {
|
||||
'main@': {
|
||||
templateUrl: '/admin/store/index.html',
|
||||
controller: 'AdminStoreController'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
.state('app.admin.store.products', {
|
||||
url: '/products'
|
||||
.state('app.admin.store.products_new', {
|
||||
url: '/products/new',
|
||||
views: {
|
||||
'main@': {
|
||||
templateUrl: '/admin/store/product_new.html',
|
||||
controller: 'AdminStoreProductController'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
.state('app.admin.store.products_edit', {
|
||||
url: '/products/:id/edit',
|
||||
views: {
|
||||
'main@': {
|
||||
templateUrl: '/admin/store/product_edit.html',
|
||||
controller: 'AdminStoreProductController'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
.state('app.admin.store.categories', {
|
||||
url: '/categories'
|
||||
url: '/categories',
|
||||
views: {
|
||||
'main@': {
|
||||
templateUrl: '/admin/store/index.html',
|
||||
controller: 'AdminStoreController'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
.state('app.admin.store.orders', {
|
||||
url: '/orders'
|
||||
url: '/orders',
|
||||
views: {
|
||||
'main@': {
|
||||
templateUrl: '/admin/store/index.html',
|
||||
controller: 'AdminStoreController'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// OpenAPI Clients
|
||||
|
@ -38,6 +38,7 @@
|
||||
@import "modules/form/form-input";
|
||||
@import "modules/form/form-rich-text";
|
||||
@import "modules/form/form-switch";
|
||||
@import "modules/form/form-check-list";
|
||||
@import "modules/group/change-group";
|
||||
@import "modules/machines/machine-card";
|
||||
@import "modules/machines/machines-filters";
|
||||
|
@ -0,0 +1,17 @@
|
||||
.form-check-list {
|
||||
position: relative;
|
||||
|
||||
.form-item-field {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.checklist {
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.checklist-item {
|
||||
flex: 0 0 33.333333%;
|
||||
}
|
||||
}
|
35
app/frontend/templates/admin/store/product_edit.html
Normal file
35
app/frontend/templates/admin/store/product_edit.html
Normal file
@ -0,0 +1,35 @@
|
||||
<section class="heading b-b">
|
||||
<div class="row no-gutter">
|
||||
<div class="col-xs-2 col-sm-2 col-md-1">
|
||||
<section class="heading-btn">
|
||||
<a ng-click="backPrevLocation($event)"><i class="fas fa-long-arrow-alt-left "></i></a>
|
||||
</section>
|
||||
</div>
|
||||
<div class="col-xs-10 col-sm-10 col-md-8 b-l">
|
||||
<section class="heading-title">
|
||||
<h1 translate>{{ 'app.admin.store.manage_the_store' }}</h1>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="m-lg admin-store-manage">
|
||||
<div class="row">
|
||||
|
||||
<div class="col-md-12">
|
||||
<a ng-click="backProductsList()">
|
||||
<i class="fas fa-angle-left"></i>
|
||||
<span translate>{{ 'app.admin.store.back_products_list' }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="row">
|
||||
|
||||
<div class="col-md-12">
|
||||
<edit-product product-id="productId" on-success="onSuccess" on-error="onError"/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
35
app/frontend/templates/admin/store/product_new.html
Normal file
35
app/frontend/templates/admin/store/product_new.html
Normal file
@ -0,0 +1,35 @@
|
||||
<section class="heading b-b">
|
||||
<div class="row no-gutter">
|
||||
<div class="col-xs-2 col-sm-2 col-md-1">
|
||||
<section class="heading-btn">
|
||||
<a ng-click="backPrevLocation($event)"><i class="fas fa-long-arrow-alt-left "></i></a>
|
||||
</section>
|
||||
</div>
|
||||
<div class="col-xs-10 col-sm-10 col-md-8 b-l">
|
||||
<section class="heading-title">
|
||||
<h1 translate>{{ 'app.admin.store.manage_the_store' }}</h1>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="m-lg admin-store-manage">
|
||||
<div class="row">
|
||||
|
||||
<div class="col-md-12">
|
||||
<a ng-click="backProductsList()">
|
||||
<i class="fas fa-angle-left"></i>
|
||||
<span translate>{{ 'app.admin.store.back_products_list' }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="row">
|
||||
|
||||
<div class="col-md-12">
|
||||
<new-product on-success="onSuccess" on-error="onError"/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
@ -29,6 +29,7 @@ class Machine < ApplicationRecord
|
||||
|
||||
has_one :payment_gateway_object, as: :item
|
||||
|
||||
has_and_belongs_to_many :products
|
||||
|
||||
after_create :create_statistic_subtype
|
||||
after_create :create_machine_prices
|
||||
|
@ -1,3 +1,10 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Product is a model for the merchandise hold information of product in store
|
||||
class Product < ApplicationRecord
|
||||
belongs_to :product_category
|
||||
|
||||
has_and_belongs_to_many :machines
|
||||
|
||||
validates_numericality_of :amount, greater_than: 0, allow_nil: true
|
||||
end
|
||||
|
@ -1,3 +1,4 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
json.extract! product, :id, :name, :slug, :sku, :description, :is_active, :product_category_id, :amount, :quantity_min, :stock, :low_stock_alert, :low_stock_threshold
|
||||
json.extract! product, :id, :name, :slug, :sku, :description, :is_active, :product_category_id, :quantity_min, :stock, :low_stock_alert, :low_stock_threshold, :machine_ids
|
||||
json.amount product.amount / 100.0 if product.amount.present?
|
||||
|
@ -1899,6 +1899,7 @@ en:
|
||||
all_products: "All products"
|
||||
categories_of_store: "Store's categories"
|
||||
the_orders: "Orders"
|
||||
back_products_list: "Back to products list"
|
||||
product_categories:
|
||||
title: "Categories"
|
||||
info: "<strong>Information:</strong></br>Find below all the categories created. The categories are arranged on two levels maximum, you can arrange them with a drag and drop. The order of the categories will be identical on the public view and the list below. Please note that you can delete a category or a sub-category even if they are associated with products. The latter will be left without categories. If you delete a category that contains sub-categories, the latter will also be deleted. <strong>Make sure that your categories are well arranged and save your choice.</strong>"
|
||||
@ -1931,3 +1932,25 @@ en:
|
||||
create_a_product: "Create a product"
|
||||
successfully_deleted: "The product has been successfully deleted"
|
||||
unable_to_delete: "Unable to delete the product: "
|
||||
new_product:
|
||||
add_a_new_product: "Add a new product"
|
||||
successfully_created: "The new product has been created."
|
||||
edit_product:
|
||||
successfully_updated: "The product has been updated."
|
||||
product_form:
|
||||
name: "Name of product"
|
||||
sku: "Reference product (SKU)"
|
||||
slug: "Name of URL"
|
||||
is_show_in_store: "Available in the store"
|
||||
is_active_price: "Activate the price"
|
||||
price_and_rule_of_selling_product: "Price and rule for selling the product"
|
||||
price: "Price of product"
|
||||
quantity_min: "Minimum number of items for the shopping cart"
|
||||
linking_product_to_category: "Linking this product to an existing category"
|
||||
assigning_category: "Assigning a category"
|
||||
assigning_category_info: "<strong>Information</strong></br>You can only declare one category per product. If you assign this product to a sub-category, it will automatically be assigned to its parent category as well."
|
||||
assigning_machines: "Assigning machines"
|
||||
assigning_machines_info: "<strong>Information</strong></br>You can link one or more machines from your fablab to your product, this product will then be subject to the filters on the catalogue view.</br>The machines selected below will be linked to the product."
|
||||
product_description: "Product description"
|
||||
product_description_info: "<strong>Information</strong></br>This product description will be present in the product sheet. You have a few editorial styles at your disposal to create the product sheet."
|
||||
save: "Save"
|
||||
|
@ -1899,6 +1899,7 @@ fr:
|
||||
all_products: "Tous les produits"
|
||||
categories_of_store: "Les catégories de la boutique"
|
||||
the_orders: "Les commandes"
|
||||
back_products_list: "Retrounez à la liste"
|
||||
product_categories:
|
||||
title: "Les catégories"
|
||||
info: "<strong>Information:</strong></br>Retrouvez ci-dessous toutes les catégories créés. Les catégories se rangent sur deux niveaux maximum, vous pouvez les agencer avec un glisser-déposer. L'ordre d'affichage des catégories sera identique sur la vue publique et la liste ci-dessous. Attention, Vous pouvez supprimer une catégorie ou une sous-catégorie même si elles sont associées à des produits. Ces derniers se retrouveront sans catégories. Si vous supprimez une catégorie contenant des sous-catégories, ces dernières seront elles aussi supprimées. <strong>Veillez au bon agencement de vos catégories et sauvegarder votre choix.</strong>"
|
||||
@ -1931,3 +1932,25 @@ fr:
|
||||
create_a_product: "Créer un produit"
|
||||
successfully_deleted: "Le produit a bien été supprimé"
|
||||
unable_to_delete: "Impossible de supprimer le produit: "
|
||||
new_product:
|
||||
add_a_new_product: "Ajouter un nouveau produit"
|
||||
successfully_created: "Le produit a bien été créée."
|
||||
edit_product:
|
||||
successfully_updated: "Le produit a bien été mise à jour."
|
||||
product_form:
|
||||
name: "Nom de produit"
|
||||
sku: "Référence produit (SKU)"
|
||||
slug: "Nom de l'URL"
|
||||
is_show_in_store: "Visible dans la boutique"
|
||||
is_active_price: "Activer le prix"
|
||||
price_and_rule_of_selling_product: "Prix et règle de vente du produit"
|
||||
price: "Prix du produit"
|
||||
quantity_min: "Nombre d'article minimum pour la mise au panier"
|
||||
linking_product_to_category: "Lier ce product à une catégorie exisante"
|
||||
assigning_category: "Attribuer à une catégorie"
|
||||
assigning_category_info: "<strong>Information</strong></br>Vous ne pouvez déclarer qu'une catégorie par produit. Si vous attribuez ce produit à une sous catégorie, il sera attribué automatiquement aussi à sa catégorie parent."
|
||||
assigning_machines: "Attribuer aux machines"
|
||||
assigning_machines_info: "<strong>Information</strong></br>Vous pouvez lier une ou plusieurs machines de votre fablab à votre produit, Ce produit sera alors assujetti aux filtres sur la vue catalogue.</br>Les machines sélectionnées ci-dessous seront liées au produit."
|
||||
product_description: "Description du produit"
|
||||
product_description_info: "<strong>Information</strong></br>Cette description du produit sera présente dans la fiche du produit. Vous avez à disposition quelques styles rédactionnels pour créer la fiche du produit."
|
||||
save: "Enregistrer"
|
||||
|
@ -550,3 +550,5 @@ en:
|
||||
validate_button: "Validate the new card"
|
||||
form_multi_select:
|
||||
create_label: "Add {VALUE}"
|
||||
form_check_list:
|
||||
select_all: "Select all"
|
||||
|
@ -550,3 +550,5 @@ fr:
|
||||
validate_button: "Valider la nouvelle carte"
|
||||
form_multi_select:
|
||||
create_label: "Ajouter {VALUE}"
|
||||
form_check_list:
|
||||
select_all: "Tout sélectionner"
|
||||
|
@ -1,3 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateProducts < ActiveRecord::Migration[5.2]
|
||||
def change
|
||||
create_table :products do |t|
|
||||
|
Loading…
x
Reference in New Issue
Block a user