Subida del módulo y tema de PrestaShop

This commit is contained in:
Kaloyan
2026-04-09 18:31:51 +02:00
parent 12c253296f
commit 16b3ff9424
39262 changed files with 7418797 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
class LocalizationException {
message: string;
name: string;
constructor(message: string) {
this.message = message;
this.name = 'LocalizationException';
}
}
export default LocalizationException;

View File

@@ -0,0 +1,15 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import NumberFormatter from '@app/cldr/number-formatter';
import NumberSymbol from '@app/cldr/number-symbol';
import PriceSpecification from '@app/cldr/specifications/price';
import NumberSpecification from '@app/cldr/specifications/number';
export {
PriceSpecification,
NumberSpecification,
NumberFormatter,
NumberSymbol,
};

View File

@@ -0,0 +1,322 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
/**
* These placeholders are used in CLDR number formatting templates.
* They are meant to be replaced by the correct localized symbols in the number formatting process.
*/
import NumberSymbol from '@app/cldr/number-symbol';
import PriceSpecification from '@app/cldr/specifications/price';
import NumberSpecification from '@app/cldr/specifications/number';
// eslint-disable-next-line
const escapeRE = require('lodash.escaperegexp');
const CURRENCY_SYMBOL_PLACEHOLDER = '¤';
const DECIMAL_SEPARATOR_PLACEHOLDER = '.';
const GROUP_SEPARATOR_PLACEHOLDER = ',';
const MINUS_SIGN_PLACEHOLDER = '-';
const PERCENT_SYMBOL_PLACEHOLDER = '%';
const PLUS_SIGN_PLACEHOLDER = '+';
class NumberFormatter {
numberSpecification: Record<string, any>;
/**
* @param NumberSpecification specification Number specification to be used
* (can be a number spec, a price spec, a percentage spec)
*/
constructor(specification: Record<string, any>) {
this.numberSpecification = specification;
}
/**
* Formats the passed number according to specifications.
*
* @param int|float|string number The number to format
* @param NumberSpecification specification Number specification to be used
* (can be a number spec, a price spec, a percentage spec)
*
* @return string The formatted number
* You should use this this value for display, without modifying it
*/
format(number: number, specification?: Record<string, any>): string {
if (specification !== undefined) {
this.numberSpecification = specification;
}
/*
* We need to work on the absolute value first.
* Then the CLDR pattern will add the sign if relevant (at the end).
*/
const num = Math.abs(number).toFixed(
this.numberSpecification.getMaxFractionDigits(),
);
let [majorDigits, minorDigits] = this.extractMajorMinorDigits(num);
majorDigits = <string> this.splitMajorGroups(majorDigits);
minorDigits = this.adjustMinorDigitsZeroes(minorDigits);
// Assemble the final number
let formattedNumber = majorDigits;
if (minorDigits) {
formattedNumber += DECIMAL_SEPARATOR_PLACEHOLDER + minorDigits;
}
// Get the good CLDR formatting pattern. Sign is important here !
const pattern = this.getCldrPattern(number < 0);
formattedNumber = this.addPlaceholders(formattedNumber, pattern);
formattedNumber = this.replaceSymbols(formattedNumber);
formattedNumber = this.performSpecificReplacements(formattedNumber);
return formattedNumber;
}
/**
* Get number's major and minor digits.
*
* Major digits are the "integer" part (before decimal separator),
* minor digits are the fractional part
* Result will be an array of exactly 2 items: [majorDigits, minorDigits]
*
* Usage example:
* list(majorDigits, minorDigits) = this.getMajorMinorDigits(decimalNumber);
*
* @param DecimalNumber number
*
* @return string[]
*/
extractMajorMinorDigits(number: string): Array<string> {
// Get the number's major and minor digits.
const result = number.toString().split('.');
const majorDigits = result[0];
const minorDigits = result[1] === undefined ? '' : result[1];
return [majorDigits, minorDigits];
}
/**
* Splits major digits into groups.
*
* e.g.: Given the major digits "1234567", and major group size
* configured to 3 digits, the result would be "1 234 567"
*
* @param string majorDigits The major digits to be grouped
*
* @return string The grouped major digits
*/
splitMajorGroups(digit: string): Array<string> | string {
if (!this.numberSpecification.isGroupingUsed()) {
return digit;
}
// Reverse the major digits, since they are grouped from the right.
const majorDigits = digit.split('').reverse();
// Group the major digits.
let groups = [];
groups.push(
majorDigits.splice(0, this.numberSpecification.getPrimaryGroupSize()),
);
while (majorDigits.length) {
groups.push(
majorDigits.splice(0, this.numberSpecification.getSecondaryGroupSize()),
);
}
// Reverse back the digits and the groups
groups = groups.reverse();
const newGroups: Array<string> = [];
groups.forEach((group) => {
newGroups.push(group.reverse().join(''));
});
// Reconstruct the major digits.
return newGroups.join(GROUP_SEPARATOR_PLACEHOLDER);
}
/**
* Adds or remove trailing zeroes, depending on specified min and max fraction digits numbers.
*
* @param string minorDigits Digits to be adjusted with (trimmed or padded) zeroes
*
* @return string The adjusted minor digits
*/
adjustMinorDigitsZeroes(minorDigits: string): string {
let digit = minorDigits;
if (digit.length > this.numberSpecification.getMaxFractionDigits()) {
// Strip any trailing zeroes.
digit = digit.replace(/0+$/, '');
}
if (digit.length < this.numberSpecification.getMinFractionDigits()) {
// Re-add needed zeroes
digit = digit.padEnd(
this.numberSpecification.getMinFractionDigits(),
'0',
);
}
return digit;
}
/**
* Get the CLDR formatting pattern.
*
* @see http://cldr.unicode.org/translation/number-patterns
*
* @param bool isNegative If true, the negative pattern
* will be returned instead of the positive one
*
* @return string The CLDR formatting pattern
*/
getCldrPattern(isNegative: boolean): string {
if (isNegative) {
return this.numberSpecification.getNegativePattern();
}
return this.numberSpecification.getPositivePattern();
}
/**
* Replace placeholder number symbols with relevant numbering system's symbols.
*
* @param string number
* The number to process
*
* @return string
* The number with replaced symbols
*/
replaceSymbols(number: string): string {
const symbols = this.numberSpecification.getSymbol();
const map: Record<string, any> = {};
map[DECIMAL_SEPARATOR_PLACEHOLDER] = symbols.getDecimal();
map[GROUP_SEPARATOR_PLACEHOLDER] = symbols.getGroup();
map[MINUS_SIGN_PLACEHOLDER] = symbols.getMinusSign();
map[PERCENT_SYMBOL_PLACEHOLDER] = symbols.getPercentSign();
map[PLUS_SIGN_PLACEHOLDER] = symbols.getPlusSign();
return this.strtr(number, map);
}
/**
* strtr() for JavaScript
* Translate characters or replace substrings
*
* @param str
* String to parse
* @param pairs
* Hash of ('from' => 'to', ...).
*
* @return string
*/
strtr(str: string, pairs: Record<string, any>): string {
const substrs = Object.keys(pairs).map(escapeRE);
return str
.split(RegExp(`(${substrs.join('|')})`))
.map((part: string) => pairs[part] || part)
.join('');
}
/**
* Add missing placeholders to the number using the passed CLDR pattern.
*
* Missing placeholders can be the percent sign, currency symbol, etc.
*
* e.g. with a currency CLDR pattern:
* - Passed number (partially formatted): 1,234.567
* - Returned number: 1,234.567 ¤
* ("¤" symbol is the currency symbol placeholder)
*
* @see http://cldr.unicode.org/translation/number-patterns
*
* @param formattedNumber
* Number to process
* @param pattern
* CLDR formatting pattern to use
*
* @return string
*/
addPlaceholders(formattedNumber: string, pattern: string): string {
/*
* Regex groups explanation:
* # : literal "#" character. Once.
* (,#+)* : any other "#" characters group, separated by ",". Zero to infinity times.
* 0 : literal "0" character. Once.
* (\.[0#]+)* : any combination of "0" and "#" characters groups, separated by '.'.
* Zero to infinity times.
*/
return pattern.replace(/#?(,#+)*0(\.[0#]+)*/, formattedNumber);
}
/**
* Perform some more specific replacements.
*
* Specific replacements are needed when number specification is extended.
* For instance, prices have an extended number specification in order to
* add currency symbol to the formatted number.
*
* @param string formattedNumber
*
* @return mixed
*/
performSpecificReplacements(formattedNumber: string): string {
if (this.numberSpecification instanceof PriceSpecification) {
return formattedNumber
.split(CURRENCY_SYMBOL_PLACEHOLDER)
.join(this.numberSpecification.getCurrencySymbol());
}
return formattedNumber;
}
static build(specifications: Record<string, any>): NumberFormatter {
let symbol;
if (undefined !== specifications.numberSymbols) {
// @ts-ignore-next-line
symbol = new NumberSymbol(...specifications.numberSymbols);
} else {
// @ts-ignore-next-line
symbol = new NumberSymbol(...specifications.symbol);
}
let specification;
if (specifications.currencySymbol) {
specification = new PriceSpecification(
specifications.positivePattern,
specifications.negativePattern,
symbol,
parseInt(specifications.maxFractionDigits, 10),
parseInt(specifications.minFractionDigits, 10),
specifications.groupingUsed,
specifications.primaryGroupSize,
specifications.secondaryGroupSize,
specifications.currencySymbol,
specifications.currencyCode,
);
} else {
specification = new NumberSpecification(
specifications.positivePattern,
specifications.negativePattern,
symbol,
parseInt(specifications.maxFractionDigits, 10),
parseInt(specifications.minFractionDigits, 10),
specifications.groupingUsed,
specifications.primaryGroupSize,
specifications.secondaryGroupSize,
);
}
return new NumberFormatter(specification);
}
}
export default NumberFormatter;

View File

@@ -0,0 +1,230 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import LocalizationException from '@app/cldr/exception/localization';
class NumberSymbol {
decimal: string;
group: string;
list: string;
percentSign: string;
minusSign: string;
plusSign: string;
exponential: string;
superscriptingExponent: string;
perMille: string;
infinity: string;
nan: string;
/**
* NumberSymbolList constructor.
*
* @param string decimal Decimal separator character
* @param string group Digits group separator character
* @param string list List elements separator character
* @param string percentSign Percent sign character
* @param string minusSign Minus sign character
* @param string plusSign Plus sign character
* @param string exponential Exponential character
* @param string superscriptingExponent Superscripting exponent character
* @param string perMille Permille sign character
* @param string infinity The infinity sign. Corresponds to the IEEE infinity bit pattern.
* @param string nan The NaN (Not A Number) sign. Corresponds to the IEEE NaN bit pattern.
*
* @throws LocalizationException
*/
constructor(
decimal: string,
group: string,
list: string,
percentSign: string,
minusSign: string,
plusSign: string,
exponential: string,
superscriptingExponent: string,
perMille: string,
infinity: string,
nan: string,
) {
this.decimal = decimal;
this.group = group;
this.list = list;
this.percentSign = percentSign;
this.minusSign = minusSign;
this.plusSign = plusSign;
this.exponential = exponential;
this.superscriptingExponent = superscriptingExponent;
this.perMille = perMille;
this.infinity = infinity;
this.nan = nan;
this.validateData();
}
/**
* Get the decimal separator.
*
* @return string
*/
getDecimal(): string {
return this.decimal;
}
/**
* Get the digit groups separator.
*
* @return string
*/
getGroup(): string {
return this.group;
}
/**
* Get the list elements separator.
*
* @return string
*/
getList(): string {
return this.list;
}
/**
* Get the percent sign.
*
* @return string
*/
getPercentSign(): string {
return this.percentSign;
}
/**
* Get the minus sign.
*
* @return string
*/
getMinusSign(): string {
return this.minusSign;
}
/**
* Get the plus sign.
*
* @return string
*/
getPlusSign(): string {
return this.plusSign;
}
/**
* Get the exponential character.
*
* @return string
*/
getExponential(): string {
return this.exponential;
}
/**
* Get the exponent character.
*
* @return string
*/
getSuperscriptingExponent(): string {
return this.superscriptingExponent;
}
/**
* Gert the per mille symbol (often "‰").
*
* @see https://en.wikipedia.org/wiki/Per_mille
*
* @return string
*/
getPerMille(): string {
return this.perMille;
}
/**
* Get the infinity symbol (often "∞").
*
* @see https://en.wikipedia.org/wiki/Infinity_symbol
*
* @return string
*/
getInfinity(): string {
return this.infinity;
}
/**
* Get the NaN (not a number) sign.
*
* @return string
*/
getNan(): string {
return this.nan;
}
/**
* Symbols list validation.
*
* @throws LocalizationException
*/
validateData(): void {
if (!this.decimal || typeof this.decimal !== 'string') {
throw new LocalizationException('Invalid decimal');
}
if (!this.group || typeof this.group !== 'string') {
throw new LocalizationException('Invalid group');
}
if (!this.list || typeof this.list !== 'string') {
throw new LocalizationException('Invalid symbol list');
}
if (!this.percentSign || typeof this.percentSign !== 'string') {
throw new LocalizationException('Invalid percentSign');
}
if (!this.minusSign || typeof this.minusSign !== 'string') {
throw new LocalizationException('Invalid minusSign');
}
if (!this.plusSign || typeof this.plusSign !== 'string') {
throw new LocalizationException('Invalid plusSign');
}
if (!this.exponential || typeof this.exponential !== 'string') {
throw new LocalizationException('Invalid exponential');
}
if (!this.superscriptingExponent || typeof this.superscriptingExponent !== 'string') {
throw new LocalizationException('Invalid superscriptingExponent');
}
if (!this.perMille || typeof this.perMille !== 'string') {
throw new LocalizationException('Invalid perMille');
}
if (!this.infinity || typeof this.infinity !== 'string') {
throw new LocalizationException('Invalid infinity');
}
if (!this.nan || typeof this.nan !== 'string') {
throw new LocalizationException('Invalid nan');
}
}
}
export default NumberSymbol;

View File

@@ -0,0 +1,175 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import LocalizationException from '@app/cldr/exception/localization';
import NumberSymbol from '@app/cldr/number-symbol';
class NumberSpecification {
positivePattern: string;
negativePattern: string;
symbol: NumberSymbol;
maxFractionDigits: number;
minFractionDigits: number;
groupingUsed: boolean;
primaryGroupSize: number;
secondaryGroupSize: number;
/**
* Number specification constructor.
*
* @param string positivePattern CLDR formatting pattern for positive amounts
* @param string negativePattern CLDR formatting pattern for negative amounts
* @param NumberSymbol symbol Number symbol
* @param int maxFractionDigits Maximum number of digits after decimal separator
* @param int minFractionDigits Minimum number of digits after decimal separator
* @param bool groupingUsed Is digits grouping used ?
* @param int primaryGroupSize Size of primary digits group in the number
* @param int secondaryGroupSize Size of secondary digits group in the number
*
* @throws LocalizationException
*/
constructor(
positivePattern: string,
negativePattern: string,
symbol: NumberSymbol,
maxFractionDigits: number,
minFractionDigits: number,
groupingUsed: boolean,
primaryGroupSize: number,
secondaryGroupSize: number,
) {
this.positivePattern = positivePattern;
this.negativePattern = negativePattern;
this.symbol = symbol;
this.maxFractionDigits = maxFractionDigits;
// eslint-disable-next-line
this.minFractionDigits =
maxFractionDigits < minFractionDigits
? maxFractionDigits
: minFractionDigits;
this.groupingUsed = groupingUsed;
this.primaryGroupSize = primaryGroupSize;
this.secondaryGroupSize = secondaryGroupSize;
if (!this.positivePattern || typeof this.positivePattern !== 'string') {
throw new LocalizationException('Invalid positivePattern');
}
if (!this.negativePattern || typeof this.negativePattern !== 'string') {
throw new LocalizationException('Invalid negativePattern');
}
if (!this.symbol || !(this.symbol instanceof NumberSymbol)) {
throw new LocalizationException('Invalid symbol');
}
if (typeof this.maxFractionDigits !== 'number') {
throw new LocalizationException('Invalid maxFractionDigits');
}
if (typeof this.minFractionDigits !== 'number') {
throw new LocalizationException('Invalid minFractionDigits');
}
if (typeof this.groupingUsed !== 'boolean') {
throw new LocalizationException('Invalid groupingUsed');
}
if (typeof this.primaryGroupSize !== 'number') {
throw new LocalizationException('Invalid primaryGroupSize');
}
if (typeof this.secondaryGroupSize !== 'number') {
throw new LocalizationException('Invalid secondaryGroupSize');
}
}
/**
* Get symbol.
*
* @return NumberSymbol
*/
getSymbol(): NumberSymbol {
return this.symbol;
}
/**
* Get the formatting rules for this number (when positive).
*
* This pattern uses the Unicode CLDR number pattern syntax
*
* @return string
*/
getPositivePattern(): string {
return this.positivePattern;
}
/**
* Get the formatting rules for this number (when negative).
*
* This pattern uses the Unicode CLDR number pattern syntax
*
* @return string
*/
getNegativePattern(): string {
return this.negativePattern;
}
/**
* Get the maximum number of digits after decimal separator (rounding if needed).
*
* @return int
*/
getMaxFractionDigits(): number {
return this.maxFractionDigits;
}
/**
* Get the minimum number of digits after decimal separator (fill with "0" if needed).
*
* @return int
*/
getMinFractionDigits(): number {
return this.minFractionDigits;
}
/**
* Get the "grouping" flag. This flag defines if digits
* grouping should be used when formatting this number.
*
* @return bool
*/
isGroupingUsed(): boolean {
return this.groupingUsed;
}
/**
* Get the size of primary digits group in the number.
*
* @return int
*/
getPrimaryGroupSize(): number {
return this.primaryGroupSize;
}
/**
* Get the size of secondary digits groups in the number.
*
* @return int
*/
getSecondaryGroupSize(): number {
return this.secondaryGroupSize;
}
}
export default NumberSpecification;

View File

@@ -0,0 +1,99 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import LocalizationException from '@app/cldr/exception/localization';
import NumberSpecification from '@app/cldr/specifications/number';
import NumberSymbol from '@app/cldr/number-symbol';
/**
* Currency display option: symbol notation.
*/
const CURRENCY_DISPLAY_SYMBOL = 'symbol';
class PriceSpecification extends NumberSpecification {
currencySymbol: string;
currencyCode: string;
/**
* Price specification constructor.
*
* @param string positivePattern CLDR formatting pattern for positive amounts
* @param string negativePattern CLDR formatting pattern for negative amounts
* @param NumberSymbol symbol Number symbol
* @param int maxFractionDigits Maximum number of digits after decimal separator
* @param int minFractionDigits Minimum number of digits after decimal separator
* @param bool groupingUsed Is digits grouping used ?
* @param int primaryGroupSize Size of primary digits group in the number
* @param int secondaryGroupSize Size of secondary digits group in the number
* @param string currencySymbol Currency symbol of this price (eg. : €)
* @param currencyCode Currency code of this price (e.g.: EUR)
*
* @throws LocalizationException
*/
constructor(
positivePattern: string,
negativePattern: string,
symbol: NumberSymbol,
maxFractionDigits: number,
minFractionDigits: number,
groupingUsed: boolean,
primaryGroupSize: number,
secondaryGroupSize: number,
currencySymbol: string,
currencyCode: string,
) {
super(
positivePattern,
negativePattern,
symbol,
maxFractionDigits,
minFractionDigits,
groupingUsed,
primaryGroupSize,
secondaryGroupSize,
);
this.currencySymbol = currencySymbol;
this.currencyCode = currencyCode;
if (!this.currencySymbol || typeof this.currencySymbol !== 'string') {
throw new LocalizationException('Invalid currencySymbol');
}
if (!this.currencyCode || typeof this.currencyCode !== 'string') {
throw new LocalizationException('Invalid currencyCode');
}
}
/**
* Get type of display for currency symbol.
*
* @return string
*/
static getCurrencyDisplay(): string {
return CURRENCY_DISPLAY_SYMBOL;
}
/**
* Get the currency symbol
* e.g.: €.
*
* @return string
*/
getCurrencySymbol(): string {
return this.currencySymbol;
}
/**
* Get the currency ISO code
* e.g.: EUR.
*
* @return string
*/
getCurrencyCode(): string {
return this.currencyCode;
}
}
export default PriceSpecification;

View File

@@ -0,0 +1,75 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div class="md-checkbox md-checkbox-inline">
<label>
<input
v-if="Array.isArray(checked)"
type="checkbox"
:checked="checked.includes(value)"
:class="classes"
:disabled="disabled"
@change="change"
>
<input
v-else
type="checkbox"
:checked="checked"
:class="classes"
:disabled="disabled"
@change="$emit('input', ($event?.target as HTMLInputElement).checked)"
>
<slot>
<!-- - Fallback content -->
<i class="md-checkbox-control" />
</slot>
</label>
</div>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
export default defineComponent({
model: {
prop: 'checked',
event: 'input',
},
props: {
classes: {
type: Array,
default: () => ([
'js-tab-checkbox',
]),
},
checked: {
required: false,
type: [Array, Boolean],
default: false,
},
disabled: {
type: Boolean,
required: false,
default: false,
},
value: {
required: true,
type: String,
},
},
methods: {
change(): void {
if ((<Array<string>> this.checked).includes(this.value)) {
(<Array<string>> this.checked).splice((<Array<string>> this.checked).indexOf(this.value), 1);
} else {
(<Array<string>> this.checked).push(this.value);
}
this.$emit('change');
},
},
});
</script>

View File

@@ -0,0 +1,131 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div class="ps-checkboxes-dropdown">
<div
class="dropdown"
:data-role="`filter-by-${label.toLowerCase()}-block`"
>
<button
:class="[
'btn',
'dropdown-toggle',
selectedChoiceIds.length > 0 ? 'btn-primary' : 'btn-outline-secondary',
'btn',
{disabled: this.disabled}
]"
type="button"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
:data-role="`filter-by-${label.toLowerCase()}-btn`"
>
{{ label }} {{ nbFiles }}
</button>
<div
class="dropdown-menu"
@click="preventClose"
>
<div
class="md-checkbox"
v-for="choice in choices"
:key="choice.id"
>
<label class="dropdown-item">
<div class="md-checkbox-container">
<input
:value="choice.id"
:name="choice.name"
type="checkbox"
:checked="isSelected(choice)"
@change="toggleSelection(choice)"
>
<i class="md-checkbox-control" />
{{ choice.label }}
</div>
</label>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import {defineComponent, PropType} from 'vue';
import {Choice} from '@app/components/checkboxes-dropdown/types';
export default defineComponent({
props: {
parentId: {
type: Number,
default: 1,
},
choices: {
type: Array as PropType<Choice[]>,
required: true,
},
selectedChoiceIds: {
type: Array as PropType<number[]>,
default: () => [],
},
label: {
type: String,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
},
computed: {
nbFiles(): string | null {
return this.selectedChoiceIds.length > 0
? `(${this.selectedChoiceIds.length})`
: null;
},
},
methods: {
isSelected(choice: Choice): boolean {
return this.selectedChoiceIds.some((id) => choice.id === id);
},
toggleSelection(choice: Choice): void {
if (this.selectedChoiceIds.some((id) => choice.id === id)) {
this.$emit('unselectChoice', choice, this.parentId);
} else {
this.$emit('selectChoice', choice, this.parentId);
}
},
preventClose(event: Event): void {
event.stopPropagation();
},
},
});
</script>
<style lang="scss" type="text/scss">
@import "~@scss/config/_settings.scss";
@import "~@scss/config/_bootstrap.scss";
.ps-checkboxes-dropdown {
margin: 0 var(--#{$cdk}size-6);
@include media-breakpoint-down(xs) {
margin-bottom: var(--#{$cdk}size-8);
}
.dropdown-item {
padding: var(--#{$cdk}size-8) var(--#{$cdk}size-16) var(--#{$cdk}size-8) var(--#{$cdk}size-8);
line-height: normal;
color: inherit;
border-bottom: 0;
.md-checkbox-container {
position: relative;
padding-left: var(--#{$cdk}size-28);
}
}
}
</style>

View File

@@ -0,0 +1,10 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
export interface Choice {
id: number,
name: string,
label: string,
}

View File

@@ -0,0 +1,30 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<script lang="ts">
/**
* This mixin component will help you to calculate your col size
* depending on object size.
*/
// Bootstrap row length
const ROW_LENGTH = 12;
export default {
methods: {
getClasses(object: Record<string, any>, offsetCondition = true): Array<string> {
const objectLength = Object.keys(object).length;
const size = Math.floor(ROW_LENGTH / objectLength);
const left = Math.ceil(ROW_LENGTH % objectLength);
const classes = [`col-${size}`];
if (left !== 0 && offsetCondition) {
classes.push(`offset-${left}`);
}
return classes;
},
},
};
</script>

View File

@@ -0,0 +1,12 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import TableSorting from '@app/utils/table-sorting';
const {$} = window;
$(() => {
new TableSorting($('table.table')).attach();
});

View File

@@ -0,0 +1,12 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import ModuleCard from '@components/module-card';
const {$} = window;
$(() => {
new ModuleCard();
});

View File

@@ -0,0 +1,146 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div class="d-flex flex-wrap bulk-row">
<div class="col-4" />
<div class="col-8 d-flex flex-wrap">
<div
class="text-center"
:class="getClasses(types, bulkType === 'view')"
v-for="(bulk, bulkType) in types"
:key="bulkType"
>
<strong>{{ bulk.label }}</strong>
<ps-checkbox
v-model="status"
@change="updateBulk(bulkType)"
:value="bulkType"
:disabled="bulk.value !== true"
/>
</div>
</div>
</div>
</template>
<script lang="ts">
import PsCheckbox from '@app/components/checkbox.vue';
import ColSize from '@app/mixins/col-size.vue';
import {defineComponent} from 'vue';
const TYPE_ALL: string = 'all';
export default defineComponent({
mixins: [
ColSize,
],
components: {
PsCheckbox,
},
props: {
types: {
type: Object,
required: true,
},
profilePermissions: {
type: Object,
required: true,
},
},
data(): {status: Array<string>} {
return {
status: [],
};
},
watch: {
profilePermissions: {
handler: function mandatoryFunctionForDeepWatching(val): void {
this.refreshPermissionsCheckboxes(val);
},
deep: true,
},
},
mounted() {
this.refreshPermissionsCheckboxes(this.profilePermissions);
},
methods: {
/**
* Check if checkboxes must be checked
*/
refreshPermissionsCheckboxes(permissions: Record<string, any>): void {
Object.keys(this.types).forEach((t: string) => {
if (t === TYPE_ALL) {
return;
}
let isChecked = true;
// eslint-disable-next-line no-restricted-syntax
for (const perm of Object.values(permissions)) {
if (perm[t] === '0') {
isChecked = false;
break;
}
}
if (isChecked && !this.status.includes(t)) {
this.status.push(t);
} else if (this.status.includes(t) && !isChecked) {
this.status.splice(this.status.indexOf(t), 1);
}
});
if (this.status.length === 1 && this.status.includes(TYPE_ALL)) {
this.status.splice(this.status.indexOf(TYPE_ALL), 1);
}
this.checkForTypeAllCheckbox();
},
/**
* Check is type all must be checked
*/
checkForTypeAllCheckbox(bulkType?: string): void {
// no need to check there is no type all
if (!(TYPE_ALL in this.types)) {
return;
}
if (bulkType === TYPE_ALL) {
this.status = this.status.includes(bulkType)
? Object.keys(this.types)
: [];
return;
}
// Nothing change
if (this.status.length !== (Object.keys(this.types).length - 1)) {
return;
}
// We can add the TYPE_ALL because we check all checkboxes
if (this.status.includes(TYPE_ALL)) {
this.status.splice(this.status.indexOf(TYPE_ALL), 1);
} else {
this.status.push(TYPE_ALL);
}
},
/**
* Update bulk type
*/
updateBulk(bulkType: string): void {
this.checkForTypeAllCheckbox(bulkType);
this.$emit(
'updateBulk',
{
updateType: bulkType,
status: this.status.includes(bulkType),
types: bulkType !== TYPE_ALL ? [bulkType] : Object.keys(this.types),
},
);
},
},
});
</script>

View File

@@ -0,0 +1,332 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div>
<div
:class="{parent, 'bg-light': parent}"
class="d-flex flex-wrap permission-row"
>
<div
class="col-4 text-nowrap"
:class="`depth-level-${levelDepth}`"
>
&raquo;
<strong v-if="parent">{{ permission.name }}</strong>
<template v-else>
{{ permission.name }}
</template>
</div>
<div class="col-8 d-flex flex-wrap">
<div
class="text-center"
:class="getClasses(types, index === 0)"
v-for="(type, index) in types"
:key="index"
>
<ps-checkbox
:value="type"
v-model="permissionValues"
@change="sendUpdatePermissionRequest(type)"
:disabled="!canEdit || !canEditCheckbox(type)"
/>
</div>
</div>
</div>
<div v-if="permission.children !== undefined">
<row
v-for="(p, pId) in permission.children"
:key="p.id"
:can-edit="canEdit"
:permission="p"
:permission-id="pId.toString()"
:permission-key="permissionKey"
:level-depth="levelDepth + 1"
:profile-permissions.sync="profilePermissions"
:employee-permissions="employeePermissions"
:types="types"
@childUpdated="onChildUpdate"
@sendRequest="sendRequest"
/>
</div>
</div>
</template>
<script lang="ts">
import PsCheckbox from '@app/components/checkbox.vue';
import ColSize from '@app/mixins/col-size.vue';
import {defineComponent, PropType} from 'vue';
export default defineComponent({
name: 'Row',
mixins: [
ColSize,
],
components: {
PsCheckbox,
},
props: {
parent: {
type: Boolean,
required: false,
default: false,
},
profilePermissions: {
type: Object,
required: true,
},
employeePermissions: {
type: Object,
required: false,
default: () => ({}),
},
permission: {
type: Object,
required: true,
},
permissionId: {
type: String,
required: true,
},
permissionKey: {
type: String,
required: true,
},
levelDepth: {
type: Number,
required: true,
},
canEdit: {
type: Boolean,
required: false,
default: false,
},
types: {
type: Array as PropType<Array<string>>,
required: true,
},
},
data(): {permissionValues: Array<string>, TYPE_ALL: string} {
return {
permissionValues: [],
TYPE_ALL: 'all',
};
},
watch: {
profilePermissions: {
handler: function mandatoryFunctionForDeepWatching(): void {
this.refreshPermissions();
},
deep: true,
},
},
/**
* build v-model depends on selected permissions
*/
mounted() {
this.refreshPermissions();
},
computed: {
displayLevelDepth(): string {
if (this.levelDepth < 2) {
return '';
}
return Array(this.levelDepth - 1).join('&nbsp;&nbsp;');
},
},
methods: {
canEditCheckbox(type: string): boolean {
// We don't check for employee permissions
if (Object.keys(this.employeePermissions).length === 0) {
return true;
}
// Permission id not found
if (!this.employeePermissions[this.permissionId]) {
return false;
}
// Check if we can check TYPE_ALL checkbox
if (type === this.TYPE_ALL) {
let canBeChecked = true;
// eslint-disable-next-line no-restricted-syntax
for (const t of this.types) {
if (this.employeePermissions[this.permissionId][<string>t] === '0') {
canBeChecked = false;
break;
}
}
return canBeChecked;
}
// Normal behavior
return this.employeePermissions[this.permissionId][type] === '1';
},
/**
* Get the types length, depends if you have the TYPE_ALL or not
*/
getTypesLength(): number {
return this.types.includes(this.TYPE_ALL)
? this.types.length - 1
: this.types.length;
},
/**
*: void Get permission from id
*/
getPermission(): Record<string, any> {
return this.profilePermissions[this.permissionId];
},
/**
* Check if profile has permission
*/
hasPermission(type: string): boolean {
const permission = this.getPermission();
return permission !== undefined && parseInt(permission[type], 10) === 1;
},
/**
* Refresh permissions and checkboxes
*/
refreshPermissions(): void {
Object.values(this.types).forEach((type) => {
const stringType = type as string;
if (this.hasPermission(stringType)) {
this.addPermission(stringType);
} else if (this.permissionValues.includes(stringType)) {
this.removePermission(stringType);
}
});
if (this.permissionValues.length === this.getTypesLength()) {
this.addPermission(this.TYPE_ALL);
}
},
/**
* Check checkboxes permissions are in this row.
* - if type this.TYPE_ALL is used just toggle values
* - otherwise check if all must be checked or not
*/
checkCheckboxesPermissions(type: string): void {
// no need to check there is no type all
if (!this.types.includes(this.TYPE_ALL)) {
return;
}
// We click on the type all
if (type === this.TYPE_ALL) {
this.permissionValues = this.permissionValues.includes(type)
? [...this.types] as Array<string>
: [];
return;
}
// Nothing change
if (this.permissionValues.length !== this.getTypesLength()) {
return;
}
// We can add the TYPE_ALL because we check all checkboxes
if (this.permissionValues.includes(this.TYPE_ALL)) {
this.removePermission(this.TYPE_ALL);
} else {
this.addPermission(this.TYPE_ALL);
}
},
/**
* Execute ajax request to update permissions
* @param String type
* @param bool sendRequest Check if ajax request must be sent
*/
sendUpdatePermissionRequest(type: string, sendRequest = true): void {
this.checkCheckboxesPermissions(type);
if (sendRequest === true) {
const params: Record<string, any> = {
permission: type,
is_active: this.permissionValues.includes(type),
};
params[this.permissionKey] = this.permission[this.permissionKey] !== undefined
? this.permission[this.permissionKey]
: this.permissionId;
this.$emit('sendRequest', params);
}
// Update profile permission to prevent wrong bulk refresh
this.types.forEach((t) => {
this.profilePermissions[this.permissionId][<string>t] = this.permissionValues.includes(<string>t) ? '1' : '0';
});
if (this.permissionValues.includes(type)) {
this.$emit('childUpdated', type);
}
},
/**
* Add permission to current values
* @return bool
*/
addPermission(type: string): boolean {
if (this.permissionValues.includes(type)) {
return false;
}
this.permissionValues.push(type);
return true;
},
/**
* Remove permission
* @return bool
*/
removePermission(type: string): boolean {
if (!this.permissionValues.includes(type)) {
return false;
}
this.permissionValues.splice(this.permissionValues.indexOf(type), 1);
return true;
},
/**
* A child has been updated
*/
onChildUpdate(type: string): void {
// type already includes
this.sendUpdatePermissionRequest(
type,
this.addPermission(type),
);
},
/**
* Recursive emit send request
*/
sendRequest(data: Record<string, any>): void {
this.$emit('sendRequest', data);
},
},
});
</script>
<style lang="scss" type="text/scss">
@import "~@scss/config/_settings.scss";
@for $i from 2 through 5 {
.depth-level-#{$i} {
padding-left: #{$i}rem;
}
}
@media (max-width: 320px) {
.permission-row {
font-size: var(--#{$cdk}size-14);
}
}
</style>

View File

@@ -0,0 +1,47 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {App, createApp} from 'vue';
import Permission from './permission.vue';
const {$} = window;
/**
* Vue component displaying a permission tree.
*/
class PermissionApp {
vm?: App;
constructor(
profileId: string,
target: string, permissionKey: string,
profilePermissions: Record<string, any>,
employeePermissions: string,
) {
// If the selector cannot be found, we do not load the Vue app
if ($(target).length === 0) {
return;
}
this.vm = createApp(Permission, {
data: () => ({
profileId,
permissionKey,
profilePermissions,
canEdit: $(target).data('can-edit'),
employeePermissions: employeePermissions || {},
messages: window.permissionsMessages,
permissions: $(target).data('permissions'),
types: $(target).data('types'),
title: $(target).data('title'),
emptyData: $(target).data('empty'),
updateUrl: $(target).data('update-url'),
}),
});
this.vm.mount(target);
}
}
export default PermissionApp;

View File

@@ -0,0 +1,188 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div class="card">
<h3 class="card-header">
{{ title }}
</h3>
<div class="card-body">
<div class="table js-permissions-table">
<bulk
:types="types"
:profile-permissions.sync="profileDataPermissions"
@updateBulk="updateBulk"
/>
<div
class="col-xs-12"
v-if="permissions === null"
>
<td colspan="6">
{{ emptyData }}
</td>
</div>
<template
v-else
v-for="(permission, permissionId) in permissions"
:key="permissionId"
>
<row
:can-edit="canEdit"
:level-depth="1"
:max-level-depth="4"
:permission="permission"
:permission-id="permissionId"
:permission-key="permissionKey"
:profile-permissions.sync="profileDataPermissions"
:employee-permissions="employeePermissions"
:parent="permission.children !== undefined"
:types="Object.keys(types)"
@sendRequest="sendRequest"
/>
</template>
</div>
</div>
</div>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
import Bulk from './components/bulk.vue';
import Row from './components/row.vue';
const {$} = window;
interface Data {
profileDataPermissions: Record<string, any>;
}
export default defineComponent({
name: 'Permission',
components: {
Bulk,
Row,
},
props: {
title: {
type: String,
required: true,
},
emptyData: {
type: String,
required: true,
},
profileId: {
type: Number,
required: true,
},
messages: {
type: Object,
required: true,
},
updateUrl: {
type: String,
required: true,
},
permissionKey: {
type: String,
required: true,
},
types: {
type: Object,
required: true,
},
permissions: {
type: Object,
required: true,
},
profilePermissions: {
type: Object,
required: true,
},
employeePermissions: {
type: Object,
required: true,
},
canEdit: {
type: Boolean,
required: false,
default: false,
},
},
data(): Data {
return {
profileDataPermissions: this.profilePermissions,
};
},
methods: {
/**
* Send ajax request to target url
*/
sendRequest(data: Record<string, any>): void {
data.profile_id = this.profileId;
$.ajax(
this.updateUrl,
{
method: 'POST',
data,
},
).then((response) => {
if (response.success) {
window.showSuccessMessage(this.messages.success);
return;
}
window.showErrorMessage(this.messages.error);
}).catch(() => {
window.showErrorMessage(this.messages.error);
});
},
/**
* Update user permissions from bulk action
*/
updateBulk(data: Record<string, any>): void {
Object.keys(this.profileDataPermissions).forEach((key: string) => {
data.types.forEach((type: string) => {
this.profileDataPermissions[key][type] = data.status ? '1' : '0';
});
});
const params: Record<string, any> = {
is_active: data.status,
permission: data.updateType,
};
params[this.permissionKey] = '-1';
this.sendRequest(params);
},
},
});
</script>
<style lang="scss" type="text/scss">
@import "~@scss/config/_settings.scss";
.js-permissions-table {
.permission-row {
padding: var(--#{$cdk}size-4) 0;
border-bottom: 1px solid var(--#{$cdk}primary-500);
}
.bulk-row {
padding-bottom: var(--#{$cdk}size-10);
border-bottom: var(--#{$cdk}size-2) solid var(--#{$cdk}primary-800);
strong {
display: block;
font-size: var(--#{$cdk}size-12);
font-weight: 600;
font-family: var(--#{$cdk}font-family-primary);
white-space: nowrap;
padding-bottom: var(--#{$cdk}size-5);
}
}
}
</style>

View File

@@ -0,0 +1,152 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div
v-if="isReady"
id="app"
class="stock-app container-fluid"
>
<StockHeader />
<Search
ref="search"
@search="onSearch"
@applyFilter="applyFilter"
/>
<LowFilter
v-if="isOverview"
:filters="filters"
@lowStockChecked="onLowStockChecked"
/>
<div class="card container-fluid pa-2 clearfix">
<router-view
class="view"
@resetFilters="resetFilters"
@fetch="fetch"
/>
<PSPagination
:current-index="currentPagination"
:pages-count="pagesCount"
@pageChanged="onPageChanged"
/>
</div>
</div>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
import PSPagination from '@app/widgets/ps-pagination.vue';
import StockHeader from './header/stock-header.vue';
import Search, {SearchInstanceType} from './header/search.vue';
import LowFilter from './header/filters/low-filter.vue';
import {FiltersInstanceType} from './header/filters.vue';
/* eslint-disable camelcase */
export interface StockFilters {
active?: string;
suppliers?: Array<number>;
categories?: Array<number>;
date_add?: Array<any>;
id_employee?: Array<number>;
id_stock_mvt_reason?: Array<number>;
order?: string;
page_size?: number,
page_index?: number;
keywords?: any;
low_stock?: number | boolean | string;
}
/* eslint-enable camelcase */
const FIRST_PAGE = 1;
export default defineComponent({
name: 'App',
computed: {
isReady(): boolean {
return this.$store.state.isReady;
},
pagesCount(): number {
return this.$store.state.totalPages;
},
currentPagination(): number {
return this.$store.state.pageIndex;
},
isOverview(): boolean {
return this.$route.name === 'overview';
},
isMovements(): boolean {
return this.$route.name === 'movements';
},
searchRef(): SearchInstanceType {
return <SearchInstanceType>(this.$refs.search);
},
filtersRef(): FiltersInstanceType {
return this.searchRef?.filtersRef;
},
},
beforeMount() {
this.$store.dispatch('getTranslations');
},
methods: {
onPageChanged(pageIndex: number): void {
this.$store.dispatch('updatePageIndex', pageIndex);
this.fetch(this.$store.state.sort);
},
fetch(sortDirection?: string): void {
const action = this.isOverview ? 'getStock' : 'getMovements';
const sorting = sortDirection === 'desc' ? ' desc' : '';
this.$store.dispatch('isLoading');
this.filters = {
...this.filters,
order: `${this.$store.state.order}${sorting}`,
page_size: this.$store.state.productsPerPage,
page_index: this.$store.state.pageIndex,
keywords: this.$store.state.keywords,
};
this.$store.dispatch(action, this.filters);
},
onSearch(keywords: any): void {
this.$store.dispatch('updateKeywords', keywords);
this.resetPagination();
this.fetch();
},
applyFilter(filters: StockFilters): void {
this.filters = filters;
this.resetPagination();
this.fetch();
},
resetFilters(): void {
this.filtersRef?.reset();
this.filters = {};
},
resetPagination(): void {
this.$store.dispatch('updatePageIndex', FIRST_PAGE);
},
onLowStockChecked(isChecked: boolean): void {
this.filters = {...this.filters, low_stock: isChecked};
this.fetch();
},
},
components: {
StockHeader,
Search,
PSPagination,
LowFilter,
},
data: (): {filters: StockFilters} => ({
filters: {},
}),
});
</script>
<style lang="scss" type="text/scss">
// hide the layout header
#main-div > .header-toolbar {
height: 0;
display: none;
}
</style>

View File

@@ -0,0 +1,40 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<nav aria-label="Breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a :href="catalogLink">{{ trans('link_catalog') }}</a>
</li>
<li class="breadcrumb-item">
<a :href="stockLink">{{ trans('link_stock') }}</a>
</li>
<li class="breadcrumb-item active">
<span v-if="isOverview">{{ trans('link_overview') }}</span>
<span v-else>{{ trans('link_movements') }}</span>
</li>
</ol>
</nav>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
import translate from '@app/pages/stock/mixins/translate';
export default defineComponent({
mixins: [translate],
computed: {
isOverview(): boolean {
return this.$route.name === 'overview';
},
catalogLink(): string {
return window.data.catalogUrl;
},
stockLink(): string {
return window.data.stockUrl;
},
},
});
</script>

View File

@@ -0,0 +1,292 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div id="filters-container">
<button
class="search-input collapse-button"
type="button"
data-toggle="collapse"
data-target="#filters"
>
<i class="material-icons mr-1">filter_list</i>
<i class="material-icons float-right ">keyboard_arrow_down</i>
{{ trans('button_advanced_filter') }}
</button>
<div
id="filters"
class="container-fluid collapse"
>
<div class="row">
<div class="col-lg-4">
<div
v-if="isOverview"
class="py-3"
>
<h2>{{ trans('filter_suppliers') }}</h2>
<FilterComponent
ref="suppliers"
:placeholder="trans('filter_search_suppliers')"
:list="$store.getters.suppliers"
class="filter-suppliers"
item-id="supplier_id"
label="name"
@active="onFilterActive"
/>
</div>
<div
v-else
class="py-3"
>
<h2>{{ trans('filter_movements_type') }}</h2>
<PSSelect
:items="movementsTypes"
item-id="id_stock_mvt_reason"
item-name="name"
@change="onChange"
>
{{ trans('none') }}
</PSSelect>
<h2 class="mt-4">
{{ trans('filter_movements_employee') }}
</h2>
<PSSelect
:items="employees"
item-id="id_employee"
item-name="name"
@change="onChange"
>
{{ trans('none') }}
</PSSelect>
<h2 class="mt-4">
{{ trans('filter_movements_period') }}
</h2>
<form class="row">
<div class="col-md-6">
<label>{{ trans('filter_datepicker_from') }}</label>
<PSDatePicker
:locale="locale"
@dpChange="onDpChange"
@reset="onClear"
type="sup"
/>
</div>
<div class="col-md-6">
<label>{{ trans('filter_datepicker_to') }}</label>
<PSDatePicker
:locale="locale"
@dpChange="onDpChange"
@reset="onClear"
type="inf"
/>
</div>
</form>
</div>
</div>
<div class="col-lg-4">
<div class="py-3">
<h2>{{ trans('filter_categories') }}</h2>
<FilterComponent
ref="categories"
:placeholder="trans('filter_search_category')"
:list="categoriesList"
class="filter-categories"
item-id="id_category"
label="name"
@active="onFilterActive"
/>
</div>
</div>
<div class="col-lg-4">
<div class="py-3">
<h2>{{ trans('filter_status') }}</h2>
<PSRadio
id="enable"
:label="trans('filter_status_enable')"
:checked="false"
value="1"
@change="onRadioChange(1)"
/>
<PSRadio
id="disable"
:label="trans('filter_status_disable')"
:checked="false"
value="0"
@change="onRadioChange(0)"
/>
<PSRadio
id="all"
:label="trans('filter_status_all')"
:checked="true"
value="null"
@change="onRadioChange(undefined)"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
/* eslint-disable camelcase */
import PSSelect from '@app/widgets/ps-select.vue';
import PSDatePicker from '@app/widgets/ps-datepicker.vue';
import PSRadio from '@app/widgets/ps-radio.vue';
import {defineComponent} from 'vue';
import translate from '@app/pages/stock/mixins/translate';
import {Moment} from 'moment';
import FilterComponent, {FilterComponentInstanceType} from './filters/filter-component.vue';
export interface StockCategory {
active: number;
children: Array<StockCategory>;
id: string;
id_category: number;
id_parent: number;
name: string;
position: string;
visible: boolean;
}
type DateFilter = {
[key:string]: number;
}
type DatepickerEvent = {
dateType: string;
date: Moment;
oldDate: Moment;
}
// sup is the starting date while inf is the end date
// Cf: src/PrestaShopBundle/Api/QueryParamsCollection::appendSqlDateAddFilter
const DATE_TYPE_SUP = 'sup';
const DATE_TYPE_INF = 'inf';
const Filters = defineComponent({
computed: {
locale(): string {
return window.data.locale;
},
isOverview(): boolean {
return this.$route.name === 'overview';
},
employees(): Array<{id_employee: number, name: string}> {
return this.$store.state.employees;
},
movementsTypes(): Array<{id_stock_mvt_reason: Array<number>, name: string}> {
return this.$store.state.movementsTypes;
},
categoriesList(): Array<StockCategory> {
return this.$store.getters.categories;
},
suppliersFilterRef(): FilterComponentInstanceType {
return <FilterComponentInstanceType>(this.$refs.suppliers);
},
categoriesFilterRef(): FilterComponentInstanceType {
return <FilterComponentInstanceType>(this.$refs.categories);
},
},
mixins: [translate],
methods: {
reset(): void {
const dataOption = this.$options.data;
Object.assign(
this.$data,
dataOption instanceof Function ? (<any>dataOption).apply(this) : dataOption,
);
this.suppliersFilterRef?.reset();
this.categoriesFilterRef?.reset();
},
onClear(event: any): void {
delete this.date_add[<string>event.dateType];
this.applyFilter();
},
onClick(): void {
this.applyFilter();
},
onFilterActive(list: Array<any>, type: string): void {
if (type === 'supplier') {
this.suppliers = list;
} else {
this.categories = list;
}
this.disabled = !this.suppliers.length && !this.categories.length;
this.applyFilter();
},
applyFilter(): void {
this.$store.dispatch('isLoading');
this.$emit('applyFilter', {
suppliers: this.suppliers,
categories: this.categories,
id_stock_mvt_reason: this.id_stock_mvt_reason,
id_employee: this.id_employee,
date_add: this.date_add,
active: this.active,
});
},
onChange(item: any): void {
if (item.itemId === 'id_stock_mvt_reason') {
this.id_stock_mvt_reason = item.value === 'default' ? [] : item.value;
} else {
this.id_employee = item.value === 'default' ? [] : item.value;
}
this.applyFilter();
},
onDpChange(event: DatepickerEvent) {
if (event.dateType === DATE_TYPE_SUP) {
event.date.minutes(0).hours(0).seconds(1);
$(`.datepicker-${DATE_TYPE_INF}`).data('DateTimePicker').minDate(event.date);
} else if (event.dateType === DATE_TYPE_INF) {
event.date.minutes(59).hours(23).seconds(59);
$(`.datepicker-${DATE_TYPE_SUP}`).data('DateTimePicker').maxDate(event.date);
}
this.date_add[event.dateType] = event.date.unix();
this.applyFilter();
},
onRadioChange(value: any): void {
this.active = value;
this.applyFilter();
},
},
components: {
FilterComponent,
PSSelect,
PSDatePicker,
PSRadio,
},
mounted() {
this.date_add = {};
this.$store.dispatch('getSuppliers');
this.$store.dispatch('getCategories');
},
data(): {
disabled: boolean,
suppliers: Array<any>,
categories: Array<any>,
id_stock_mvt_reason: Array<any>,
id_employee: Array<any>,
date_add: DateFilter,
active: boolean | null,
} {
return {
disabled: true,
suppliers: [],
categories: [],
id_stock_mvt_reason: [],
id_employee: [],
date_add: {},
active: null,
};
},
});
export type FiltersInstanceType = InstanceType<typeof Filters> | undefined;
export default Filters;
</script>

View File

@@ -0,0 +1,204 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div class="filter-container">
<PSTags
v-if="!hasChildren"
ref="tags"
class="form-control search search-input mb-2"
:tags="tags"
:placeholder="hasPlaceholder?placeholder:''"
:has-icon="true"
@tagChange="onTagChanged"
@typing="onTyping"
/>
<div v-if="hasChildren">
<PSTree
v-if="isOverview"
v-once
ref="tree"
:has-checkbox="true"
:model="list"
@checked="onCheck"
:translations="PSTreeTranslations"
/>
<PSTree
v-else
ref="tree"
:has-checkbox="true"
:model="list"
@checked="onCheck"
:translations="PSTreeTranslations"
/>
</div>
<ul
class="mt-1"
v-else
>
<li
v-for="(item, index) in visibleItems"
:key="index"
class="item"
>
<PSTreeItem
:label="item[label]"
:model="item"
@checked="onCheck"
:has-checkbox="true"
/>
</li>
</ul>
</div>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
import PSTags from '@app/widgets/ps-tags.vue';
import PSTreeItem from '@app/widgets/ps-tree/ps-tree-item.vue';
import PSTree from '@app/widgets/ps-tree/ps-tree.vue';
import {EventEmitter} from '@components/event-emitter';
import translate from '@app/pages/stock/mixins/translate';
const FilterComponent = defineComponent({
props: {
placeholder: {
type: String,
required: false,
default: '',
},
itemId: {
type: String,
required: true,
},
label: {
type: String,
required: true,
default: '',
},
list: {
type: Array,
required: true,
},
},
mixins: [translate],
computed: {
isOverview(): boolean {
return this.$route.name === 'overview';
},
hasPlaceholder(): boolean {
return !this.tags.length;
},
PSTreeTranslations(): {expand: string, reduce: string} {
return {
expand: this.trans('tree_expand'),
reduce: this.trans('tree_reduce'),
};
},
visibleItems(): Array<any> {
const items = this.getItems();
return items.filter((item) => item.visible);
},
},
methods: {
reset(): void {
this.tags = [];
},
getItems(): Array<any> {
/* eslint-disable camelcase */
const matchList: Array<{
id: number,
name: string,
supplier_id: number,
visible: boolean,
}> = [];
/* eslint-enable camelcase */
this.list.filter((data: any) => {
const label = data[this.label].toLowerCase();
data.visible = false;
if (label.match(this.currentVal)) {
data.visible = true;
matchList.push(data);
}
if (data.children) {
this.hasChildren = true;
}
return data;
});
if (matchList.length === 1) {
this.match = matchList[0];
} else {
this.match = null;
}
return this.list;
},
onCheck(obj: any): void {
const itemLabel = obj.item[this.label];
const filterType = this.hasChildren ? 'category' : 'supplier';
if (obj.checked) {
this.tags.push(itemLabel);
} else {
const index = this.tags.indexOf(itemLabel);
this.tags.splice(index, 1);
}
if (this.tags.length) {
this.$emit('active', this.filterList(this.tags), filterType);
} else {
this.$emit('active', [], filterType);
}
},
onTyping(val: string): void {
this.currentVal = val.toLowerCase();
},
onTagChanged(tag: any): void {
let checkedTag = tag;
if (this.tags.indexOf(this.currentVal) !== -1) {
this.tags.pop();
}
if (this.match) {
checkedTag = this.match[this.label];
}
EventEmitter.emit('toggleCheckbox', checkedTag);
this.currentVal = '';
},
filterList(tags: Array<any>): Array<number> {
const idList: Array<number> = [];
const {categoryList} = this.$store.state;
const list = this.hasChildren ? categoryList : this.list;
list.map((data: Record<string, any>) => {
const isInIdList = idList.indexOf(Number(data[this.itemId])) === -1;
if (tags.indexOf(data[this.label]) !== -1 && isInIdList) {
idList.push(Number(data[this.itemId]));
}
return idList;
});
return idList;
},
},
data() {
return {
currentVal: '',
match: null as null | Record<string, any>,
tags: [] as Array<any>,
hasChildren: false,
};
},
components: {
PSTags,
PSTree,
PSTreeItem,
},
});
export type FilterComponentInstanceType = InstanceType<typeof FilterComponent> | undefined;
export default FilterComponent;
</script>

View File

@@ -0,0 +1,91 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div class="content-topbar container-fluid">
<div class="row py-2">
<div class="col row ml-1">
<PSCheckbox
ref="low-filter"
id="low-filter"
@checked="onCheck"
/>
<span class="ml-2">{{ trans('filter_low_stock') }}</span>
</div>
<div class="content-topbar-right col mr-3 d-flex align-items-center justify-content-end">
<a :href="stockExportUrl">
<span
data-toggle="pstooltip"
:title="stockExportTitle"
data-html="true"
data-placement="top"
>
<i class="material-icons">cloud_upload</i>
</span>
</a>
<a
class="ml-2"
:href="stockImportUrl"
target="_blank"
>
<span
data-toggle="pstooltip"
:title="stockImportTitle"
data-html="true"
data-placement="top"
>
<i class="material-icons">cloud_download</i>
</span>
</a>
</div>
</div>
</div>
</template>
<script lang="ts">
import PSCheckbox from '@app/widgets/ps-checkbox.vue';
import {defineComponent} from 'vue';
import translate from '@app/pages/stock/mixins/translate';
export default defineComponent({
props: {
filters: {
type: Object,
required: false,
default: () => ({}),
},
},
mixins: [translate],
computed: {
stockImportTitle(): string {
return this.trans('title_import');
},
stockExportTitle(): string {
return this.trans('title_export');
},
stockImportUrl(): string {
return window.data.stockImportUrl;
},
stockExportUrl(): string {
const filtersClone = {...this.filters};
const params = $.param(filtersClone);
return `${window.data.stockExportUrl}&${params}`;
},
},
methods: {
onCheck(checkbox: HTMLInputElement): void {
const isChecked = checkbox.checked ? 1 : 0;
this.$emit('lowStockChecked', isChecked);
},
},
mounted() {
$('[data-toggle="pstooltip"]').pstooltip();
},
components: {
PSCheckbox,
},
});
</script>

View File

@@ -0,0 +1,125 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div
id="search"
class="row mb-2"
>
<div class="col-md-12">
<div class="mb-2">
<form
class="search-form"
@submit.prevent
>
<label>{{ trans('product_search') }}</label>
<div class="input-group">
<PSTags
ref="psTags"
:tags="tags"
@tagChange="onSearch"
/>
<div class="input-group-append">
<PSButton
@click="onClick"
class="search-button"
:primary="true"
>
<i class="material-icons">search</i>
{{ trans('button_search') }}
</PSButton>
</div>
</div>
</form>
</div>
<Filters
ref="filters"
@applyFilter="applyFilter"
/>
</div>
<div class="col-md-4 alert-box">
<transition name="fade">
<PSAlert
v-if="showAlert"
:alert-type="alertType"
:has-close="true"
@closeAlert="onCloseAlert"
>
<span v-if="error">{{ trans('alert_bulk_edit') }}</span>
<span v-else>{{ trans('notification_stock_updated') }}</span>
</PSAlert>
</transition>
</div>
</div>
</template>
<script lang="ts">
import PSTags from '@app/widgets/ps-tags.vue';
import PSButton from '@app/widgets/ps-button.vue';
import PSAlert from '@app/widgets/ps-alert.vue';
import {EventEmitter} from '@components/event-emitter';
import {defineComponent} from 'vue';
import translate from '@app/pages/stock/mixins/translate';
import Filters, {FiltersInstanceType} from './filters.vue';
const Search = defineComponent({
components: {
Filters,
PSTags,
PSButton,
PSAlert,
},
computed: {
filtersRef(): FiltersInstanceType {
return <FiltersInstanceType>(this.$refs.filters);
},
error(): boolean {
return (this.alertType === 'ALERT_TYPE_DANGER');
},
},
mixins: [translate],
methods: {
onClick(): void {
const refPsTags = this.$refs.psTags as VTags;
const {tag} = refPsTags;
refPsTags.add(tag);
},
onSearch(): void {
this.$emit('search', this.tags);
},
applyFilter(filters: Array<any>): void {
this.$emit('applyFilter', filters);
},
onCloseAlert(): void {
this.showAlert = false;
},
},
watch: {
$route() {
this.tags = [];
},
},
mounted() {
EventEmitter.on('displayBulkAlert', (type: string) => {
this.alertType = type === 'success' ? 'ALERT_TYPE_SUCCESS' : 'ALERT_TYPE_DANGER';
this.showAlert = true;
setTimeout(() => {
this.showAlert = false;
}, 5000);
});
},
data() {
return {
tags: [],
showAlert: false,
alertType: 'ALERT_TYPE_DANGER',
duration: false,
};
},
});
export type SearchInstanceType = InstanceType<typeof Search> | undefined;
export default Search;
</script>

View File

@@ -0,0 +1,61 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div class="header-toolbar">
<div class="container-fluid">
<Breadcrumb />
<div class="title-row">
<h1 class="title">
{{ trans('head_title') }}
</h1>
</div>
</div>
<Tabs />
</div>
</template>
<script lang="ts">
import ComponentsMap from '@components/components-map';
import {defineComponent} from 'vue';
import translate from '@app/pages/stock/mixins/translate';
import Breadcrumb from './breadcrumb.vue';
import Tabs from './tabs.vue';
const {$} = window;
function getOldHeaderToolbarButtons() {
return $('.header-toolbar')
.first()
.find('.toolbar-icons');
}
function getNotificationsElements() {
return $(`${ComponentsMap.ajaxConfirmation}, #${ComponentsMap.contextualNotification.messageBoxId}`);
}
export default defineComponent({
components: {
Breadcrumb,
Tabs,
},
mixins: [translate],
mounted() {
const $vueElement = $(this.$el);
// move the toolbar buttons to this header
const toolbarButtons = getOldHeaderToolbarButtons();
toolbarButtons.insertAfter($vueElement.find('.title-row > .title'));
const notifications = getNotificationsElements();
notifications.insertAfter($vueElement);
// signal header change (so size can be updated)
const event = $.Event('vueHeaderMounted', {
name: 'stock-header',
});
$(document).trigger(event);
},
});
</script>

View File

@@ -0,0 +1,52 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div
class="page-head-tabs"
id="head_tabs"
>
<ul class="nav nav-pills">
<li class="nav-item">
<router-link
data-toggle="tab"
class="nav-link"
:class="{active : isOverview}"
to="/"
role="tab"
>
{{ trans('menu_stock') }}
</router-link>
</li>
<li class="nav-item">
<router-link
data-toggle="tab"
class="nav-link"
:class="{active : isMovements}"
to="/movements"
role="tab"
>
{{ trans('menu_movements') }}
</router-link>
</li>
</ul>
</div>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
import translate from '@app/pages/stock/mixins/translate';
export default defineComponent({
mixins: [translate],
computed: {
isOverview(): boolean {
return this.$route.name === 'overview';
},
isMovements(): boolean {
return this.$route.name === 'movements';
},
},
});
</script>

View File

@@ -0,0 +1,178 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<section class="stock-movements">
<PSTable class="mt-1">
<thead>
<tr>
<th
scope="col"
>
<PSSort
order="product_id"
@sort="sort"
:current-sort="currentSort"
>
{{ trans('title_product_id') }}
</PSSort>
</th>
<th width="30%">
<PSSort
order="product_name"
@sort="sort"
:current-sort="currentSort"
>
{{ trans('title_product') }}
</PSSort>
</th>
<th>
<PSSort
order="reference"
@sort="sort"
:current-sort="currentSort"
>
{{ trans('title_reference') }}
</PSSort>
</th>
<th>
{{ trans('title_movements_type') }}
</th>
<th class="text-center">
{{ trans('title_quantity') }}
</th>
<th class="text-center">
<PSSort
order="date_add"
@sort="sort"
:current-sort="currentSort"
>
{{ trans('title_date') }}
</PSSort>
</th>
<th>
{{ trans('title_employee') }}
</th>
</tr>
</thead>
<tbody>
<tr v-if="isLoading">
<td colspan="6">
<PSLoader
v-for="(n, index) in 3"
class="mt-1"
:key="index"
>
<div class="background-masker header-top" />
<div class="background-masker header-left" />
<div class="background-masker header-bottom" />
<div class="background-masker subheader-left" />
<div class="background-masker subheader-bottom" />
</PSLoader>
</td>
</tr>
<tr v-else-if="emptyMovements">
<td colspan="6">
<PSAlert
alert-type="ALERT_TYPE_WARNING"
:has-close="false"
>
{{ trans('no_product') }}
</PSAlert>
</td>
</tr>
<MovementLine
v-else
v-for="(product, index) in movements"
:key="index"
:product="product"
/>
</tbody>
</PSTable>
</section>
</template>
<script lang="ts">
import PSTable from '@app/widgets/ps-table/ps-table.vue';
import PSSort from '@app/widgets/ps-table/ps-sort.vue';
import PSAlert from '@app/widgets/ps-alert.vue';
import PSLoader from '@app/widgets/ps-loader.vue';
import {defineComponent} from 'vue';
import TranslationMixin from '@app/pages/stock/mixins/translate';
import MovementLine from './movement-line.vue';
const DEFAULT_SORT = 'desc';
/* eslint-disable camelcase */
export interface StockMovement {
attribute_name: string | null;
combination_cover_id: number;
combination_id: number;
combination_name: string;
combination_thumbnail: string;
date_add: string;
employee_firstname: string;
employee_lastname: string;
id_employee: number;
id_order: number;
id_stock: number;
id_stock_mvt: number;
id_stock_mvt_reason: number;
movement_reason: string;
order_link: string;
physical_quantity: number;
product_attributes: string;
product_cover_id: number;
product_features: string;
product_id: number;
product_name: string;
product_reference: string;
product_thumbnail: string;
sign: number;
supplier_id: number;
supplier_name: string;
}
/* eslint-enable camelcase */
export default defineComponent({
computed: {
isLoading(): boolean {
return this.$store.state.isLoading;
},
movements(): Array<StockMovement> {
return this.$store.state.movements;
},
emptyMovements(): boolean {
return !this.$store.state.movements.length;
},
currentSort(): string {
return this.$store.state.order;
},
},
mixins: [TranslationMixin],
methods: {
sort(order: string, sortDirection: string): void {
this.$store.dispatch('updateOrder', order);
this.$store.dispatch('updateSort', sortDirection);
this.$emit('fetch', sortDirection === 'desc' ? 'desc' : 'asc');
},
},
mounted() {
this.$store.dispatch('updatePageIndex', 1);
this.$store.dispatch('updateKeywords', []);
this.$store.dispatch('getEmployees');
this.$store.dispatch('getMovementsTypes');
this.$store.dispatch('updateOrder', 'date_add');
this.$emit('resetFilters');
this.$emit('fetch', DEFAULT_SORT);
},
components: {
PSTable,
PSSort,
PSAlert,
PSLoader,
MovementLine,
},
});
</script>

View File

@@ -0,0 +1,89 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<tr>
<td>
{{ product.product_id }}
</td>
<td>
<div class="d-flex align-items-center">
<PSMedia
class="d-flex align-items-center"
:thumbnail="thumbnail"
>
<p>
{{ product.product_name }}
<small v-if="hasCombination"><br>
{{ product.combination_name }}
</small>
</p>
</PSMedia>
</div>
</td>
<td>
{{ product.product_reference }}
</td>
<td>
<a
v-if="orderLink"
:href="orderLink"
target="_blank"
>
{{ product.movement_reason }}
</a>
<span v-else>{{ product.movement_reason }}</span>
</td>
<td class="text-sm-center">
<span
class="qty-number"
:class="{'is-positive' : isPositive}"
>
<span v-if="isPositive">+</span>
<span v-else>-</span>
{{ qty }}
</span>
</td>
<td class="text-sm-center">
{{ product.date_add_formatted }}
</td>
<td>
{{ employeeName }}
</td>
</tr>
</template>
<script lang="ts">
import PSMedia from '@app/widgets/ps-media.vue';
import productDesc from '@app/pages/stock/mixins/product-desc';
import {defineComponent, PropType} from 'vue';
import {StockMovement} from './index.vue';
export default defineComponent({
props: {
product: {
type: Object as PropType<StockMovement>,
required: true,
},
},
mixins: [productDesc],
computed: {
qty(): number {
return this.product.physical_quantity;
},
employeeName(): string {
return `${this.product.employee_firstname} ${this.product.employee_lastname}`;
},
isPositive(): boolean {
return this.product.sign > 0;
},
orderLink(): string | null {
return this.product.order_link !== 'N/A' ? this.product.order_link : null;
},
},
components: {
PSMedia,
},
});
</script>

View File

@@ -0,0 +1,46 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<section class="stock-overview">
<ProductsActions />
<ProductsTable
:is-loading="isLoading"
@sort="sort"
/>
</section>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
import ProductsActions from './products-actions.vue';
import ProductsTable from './products-table.vue';
const DEFAULT_SORT = 'desc';
export default defineComponent({
computed: {
isLoading(): boolean {
return this.$store.state.isLoading;
},
},
methods: {
sort(sortDirection: string): void {
this.$emit('fetch', sortDirection);
},
},
mounted() {
this.$store.dispatch('updatePageIndex', 1);
this.$store.dispatch('updateKeywords', []);
this.$store.dispatch('updateOrder', 'product_id');
this.$store.dispatch('isLoading');
this.$emit('resetFilters');
this.$emit('fetch', DEFAULT_SORT);
},
components: {
ProductsActions,
ProductsTable,
},
});
</script>

View File

@@ -0,0 +1,59 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div class="col-md-4">
<div class="movements">
<PSButton
type="button"
class="update-qty float-sm-right"
:class="classObject"
:disabled="disabled"
:primary="true"
@click="sendQty"
>
<i class="material-icons">edit</i>
{{ trans('button_movement_type') }}
</PSButton>
</div>
</div>
</template>
<script lang="ts">
import PSButton from '@app/widgets/ps-button.vue';
import {defineComponent} from 'vue';
import TranslationMixin from '@app/pages/stock/mixins/translate';
export default defineComponent({
computed: {
disabled(): boolean {
return !this.$store.state.hasQty;
},
classObject(): {'btn-primary': boolean} {
return {
'btn-primary': !this.disabled,
};
},
},
mixins: [TranslationMixin],
methods: {
sendQty(): void {
this.$store.state.hasQty = false;
this.$store.dispatch('updateQtyByProductsId');
},
},
components: {
PSButton,
},
});
</script>
<style lang="scss" scoped>
@import '~@scss/config/_settings.scss';
.update-qty {
color: white;
transition: background-color 0.2s ease;
}
</style>

View File

@@ -0,0 +1,229 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<tr :class="{'low-stock':lowStock}">
<td data-role="product-id">
<div class="d-flex align-items-left">
<PSCheckbox
:id="id"
:ref="id"
:model="product"
@checked="productChecked"
/>
<p
class="d-flex align-items-center ml-2"
>
{{ product.product_id }}
</p>
</div>
</td>
<td data-role="product-name">
<div class="d-flex align-items-center">
<PSMedia
class="d-flex align-items-center ml-2"
:thumbnail="thumbnail"
>
{{ product.product_name }}
<small
v-if="hasCombination"
class="product-combinations"
>
<br>
{{ product.combination_name }}
</small>
</PSMedia>
</div>
</td>
<td data-role="product-reference">
{{ reference }}
</td>
<td data-role="product-supplier-name">
{{ product.supplier_name }}
</td>
<td
v-if="product.active"
class="text-sm-center"
data-role="product-active"
>
<i class="material-icons enable">check</i>
</td>
<td
v-else
class="text-sm-center"
>
<i class="material-icons disable">close</i>
</td>
<td
class="text-sm-center"
:class="{'stock-warning':lowStock}"
data-role="physical-quantity"
>
{{ physical }}
<span
v-if="updatedQty"
class="qty-update"
:class="{'stock-warning':lowStock}"
>
<i class="material-icons rtl-flip">trending_flat</i>
{{ physicalQtyUpdated }}
</span>
</td>
<td
class="text-sm-center"
:class="{'stock-warning':lowStock}"
data-role="reserved-quantity"
>
{{ product.product_reserved_quantity }}
</td>
<td
class="text-sm-center"
:class="{'stock-warning':lowStock}"
data-role="available-quantity"
>
{{ product.product_available_quantity }}
<span
v-if="updatedQty"
class="qty-update"
:class="{'stock-warning':lowStock}"
>
<i class="material-icons rtl-flip">trending_flat</i>
{{ availableQtyUpdated }}
</span>
<span
v-if="lowStock"
class="stock-warning ico ml-2"
data-toggle="pstooltip"
data-placement="top"
data-html="true"
:title="lowStockLevel"
>!</span>
</td>
<td
class="qty-spinner text-right"
data-role="update-quantity"
>
<Spinner
:product="product"
@updateProductQty="updateProductQty"
/>
</td>
</tr>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
import PSCheckbox from '@app/widgets/ps-checkbox.vue';
import PSMedia from '@app/widgets/ps-media.vue';
import {StockProduct} from '@app/pages/stock/components/overview/products-table.vue';
import ProductDesc from '@app/pages/stock/mixins/product-desc';
import {EventEmitter} from '@components/event-emitter';
import Spinner from '@app/pages/stock/components/overview/spinner.vue';
import TranslationMixin from '@app/pages/stock/mixins/translate';
export interface StockProductToUpdate {
product: StockProduct;
delta: number;
}
export default defineComponent({
props: {
product: {
type: Object,
required: true,
},
},
mixins: [TranslationMixin, ProductDesc],
computed: {
reference(): string {
if (this.product.combination_reference !== 'N/A') {
return this.product.combination_reference;
}
return this.product.product_reference;
},
updatedQty(): boolean {
return !!this.product.qty;
},
physicalQtyUpdated(): number {
return Number(this.physical) + Number(this.product.qty);
},
availableQtyUpdated(): number {
return Number(this.product.product_available_quantity) + Number(this.product.qty);
},
physical(): number {
const productAvailableQty = Number(this.product.product_available_quantity);
const productReservedQty = Number(this.product.product_reserved_quantity);
return productAvailableQty + productReservedQty;
},
lowStock(): boolean {
return this.product.product_low_stock_alert;
},
lowStockLevel(): string {
return `<div class="text-sm-left">
<p>${this.trans('product_low_stock')}</p>
<p><strong>${this.trans('product_low_stock_level')} ${this.product.product_low_stock_threshold}</strong></p>
</div>`;
},
lowStockAlert(): string {
return `<div class="text-sm-left">
<p><strong>${this.trans('product_low_stock_alert')} ${this.product.product_low_stock_alert}</strong></p>
</div>`;
},
id(): string {
return `product-${this.product.product_id}${this.product.combination_id}`;
},
},
methods: {
productChecked(checkbox: any): void {
if (checkbox.checked) {
this.$store.dispatch('addSelectedProduct', checkbox.item);
} else {
this.$store.dispatch('removeSelectedProduct', checkbox.item);
}
},
updateProductQty(productToUpdate: StockProductToUpdate): void {
const updatedProduct = {
product_id: productToUpdate.product.product_id,
combination_id: productToUpdate.product.combination_id,
delta: productToUpdate.delta,
};
this.$store.dispatch('updateProductQty', updatedProduct);
if (productToUpdate.delta) {
this.$store.dispatch('addProductToUpdate', updatedProduct);
} else {
this.$store.dispatch('removeProductToUpdate', updatedProduct);
}
},
},
mounted() {
EventEmitter.on('toggleProductsCheck', (checked: boolean) => {
const ref = this.id;
if (this.$refs[ref]) {
(<VCheckboxDatas> this.$refs[ref]).checked = checked;
}
});
$('[data-toggle="pstooltip"]').pstooltip();
},
data: () => ({
bulkEdition: false,
}),
components: {
Spinner,
PSMedia,
PSCheckbox,
},
});
</script>
<style lang="scss" scoped>
@import '~@scss/config/_settings.scss';
.product-combinations {
padding-top: var(--#{$cdk}size-4);
color: var(--#{$cdk}primary-500);
}
</style>

View File

@@ -0,0 +1,161 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div class="row product-actions">
<div
class="col-md-8 qty d-flex align-items-center"
:class="{'active' : isFocused}"
>
<PSCheckbox
id="bulk-action"
ref="bulk-action"
class="mt-3"
:is-indeterminate="isIndeterminate()"
@checked="bulkChecked"
/>
<div class="ml-2">
<small>{{ trans('title_bulk') }}</small>
<PSNumber
class="bulk-qty"
:danger="danger"
:value="bulkValue"
:buttons="isFocused"
:hover-buttons="isFocused"
@keyup="onKeyup($event)"
@keydown="onKeydown($event)"
@change="onChange($event)"
@focus="focusIn($event)"
@blur="focusOut"
/>
</div>
</div>
<div class="col-md-4">
<PSButton
type="button"
class="update-qty float-sm-right my-4 mr-2"
:class="{'btn-primary': disabled }"
:disabled="disabled"
:primary="true"
@click="sendQty"
>
<i class="material-icons">edit</i>
{{ trans('button_movement_type') }}
</PSButton>
</div>
</div>
</template>
<script lang="ts">
import PSNumber from '@app/widgets/ps-number.vue';
import PSCheckbox from '@app/widgets/ps-checkbox.vue';
import PSButton from '@app/widgets/ps-button.vue';
import {EventEmitter} from '@components/event-emitter';
import {defineComponent} from 'vue';
import TranslationMixin from '@app/pages/stock/mixins/translate';
import isNumber from 'lodash/isNumber';
export default defineComponent({
computed: {
disabled(): boolean {
return !this.$store.state.hasQty || this.bulkValue === 0;
},
selectedProductsLng(): any {
return this.$store.getters.selectedProductsLng;
},
},
mixins: [TranslationMixin],
watch: {
bulkValue(val: number): void {
if (isNumber(val)) {
this.$store.dispatch('updateBulkEditQty', val);
}
},
selectedProductsLng(value: number): void {
if (value === 0 && this.$refs['bulk-action']) {
(<HTMLInputElement> this.$refs['bulk-action']).checked = false;
this.isFocused = false;
}
if (value === 1 && this.$refs['bulk-action']) {
this.isFocused = true;
}
},
},
methods: {
isChecked(): boolean {
return (<HTMLInputElement> this.$refs['bulk-action']).checked;
},
isIndeterminate(): boolean {
const {selectedProductsLng} = this;
const productsLng = this.$store.state.products.length;
const isIndeterminate = (selectedProductsLng > 0 && selectedProductsLng < productsLng);
if (isIndeterminate) {
(<HTMLInputElement> this.$refs['bulk-action']).checked = true;
}
return isIndeterminate;
},
focusIn(event: Event): void {
this.danger = !this.selectedProductsLng;
this.isFocused = !this.danger;
if (this.danger) {
EventEmitter.emit('displayBulkAlert', 'error');
} else {
(<HTMLInputElement>event.target).select();
}
},
focusOut(): void {
this.isFocused = this.isChecked();
this.danger = false;
},
bulkChecked(checkbox: HTMLInputElement): void {
if (!checkbox.checked) {
this.bulkValue = '';
}
if (!this.isIndeterminate()) {
EventEmitter.emit('toggleProductsCheck', checkbox.checked);
}
},
sendQty(): void {
this.$store.state.hasQty = false;
this.$store.dispatch('updateQtyByProductsId');
},
onChange(event: Event): void {
if (this.isChecked()) {
const value = (<HTMLInputElement>event.target).value !== ''
? parseInt((<HTMLInputElement>event.target).value, 10)
: 0;
this.bulkValue = value;
this.disabled = !!value;
}
},
onKeydown(event: KeyboardEvent): void {
if (event.key === '.' || event.key === ',') {
event.preventDefault();
}
},
onKeyup(event: KeyboardEvent): void {
if (this.isChecked() && event.key !== '-') {
const value = (<HTMLInputElement>event.target).value !== ''
? parseInt((<HTMLInputElement>event.target).value, 10)
: 0;
this.bulkValue = value;
this.disabled = !!value;
}
},
},
data() {
return {
bulkValue: '' as string | number,
isFocused: false,
danger: false,
};
},
components: {
PSNumber,
PSCheckbox,
PSButton,
},
});
</script>

View File

@@ -0,0 +1,190 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<PSTable class="mt-1">
<thead>
<tr class="column-headers">
<th
scope="col"
>
<PSSort
order="product_id"
@sort="sort"
:current-sort="currentSort"
>
{{ trans('title_product_id') }}
</PSSort>
</th>
<th
scope="col"
width="27%"
class="product-title"
>
<PSSort
order="product_name"
@sort="sort"
:current-sort="currentSort"
>
{{ trans('title_product') }}
</PSSort>
</th>
<th scope="col">
<PSSort
order="reference"
@sort="sort"
:current-sort="currentSort"
>
{{ trans('title_reference') }}
</PSSort>
</th>
<th>
<PSSort
order="supplier"
@sort="sort"
:current-sort="currentSort"
>
{{ trans('title_supplier') }}
</PSSort>
</th>
<th class="text-center">
{{ trans('title_status') }}
</th>
<th class="text-center">
<PSSort
order="physical_quantity"
@sort="sort"
:current-sort="currentSort"
>
{{ trans('title_physical') }}
</PSSort>
</th>
<th class="text-center">
{{ trans('title_reserved') }}
</th>
<th class="text-center">
<PSSort
order="available_quantity"
@sort="sort"
:current-sort="currentSort"
>
{{ trans('title_available') }}
</PSSort>
</th>
<th :title="trans('title_edit_quantity')">
<i class="material-icons">edit</i>
{{ trans('title_edit_quantity') }}
</th>
</tr>
</thead>
<tbody>
<tr v-if="isLoading">
<td colspan="9">
<PSLoader
v-for="(n, index) in 3"
class="mt-1"
:key="index"
>
<div class="background-masker header-top" />
<div class="background-masker header-left" />
<div class="background-masker header-bottom" />
<div class="background-masker subheader-left" />
<div class="background-masker subheader-bottom" />
</PSLoader>
</td>
</tr>
<tr v-else-if="emptyProducts">
<td colspan="9">
<PSAlert
alert-type="ALERT_TYPE_WARNING"
:has-close="false"
>
{{ trans('no_product') }}
</PSAlert>
</td>
</tr>
<ProductLine
v-else
v-for="(product, index) in products"
:key="index"
:product="product"
/>
</tbody>
</PSTable>
</template>
<script lang="ts">
import PSAlert from '@app/widgets/ps-alert.vue';
import PSTable from '@app/widgets/ps-table/ps-table.vue';
import PSSort from '@app/widgets/ps-table/ps-sort.vue';
import PSLoader from '@app/widgets/ps-loader.vue';
import {defineComponent} from 'vue';
import TranslationMixin from '@app/pages/stock/mixins/translate';
import ProductLine from './product-line.vue';
/* eslint-disable camelcase */
export interface StockProduct {
active: number;
attribute_name: string;
combination_cover_id: number;
combination_id: number;
combination_name: string;
combination_reference: string;
combination_thumbnail: string;
combinations_product_url: string;
edit_url: string;
product_attributes: string;
product_available_quantity: number;
product_cover_id: number;
product_features: string;
product_id: number;
product_low_stock_alert: number;
product_low_stock_threshold: string;
product_name: string;
product_physical_quantity: number;
product_reference: string;
product_reserved_quantity: number;
product_thumbnail: string;
qty: number;
supplier_id: number;
supplier_name: string;
total_combinations: number;
}
/* eslint-enable camelcase */
export default defineComponent({
props: {
isLoading: {
type: Boolean,
required: true,
},
},
mixins: [TranslationMixin],
components: {
ProductLine,
PSSort,
PSAlert,
PSTable,
PSLoader,
},
methods: {
sort(order: string, sortDirection: string): void {
this.$store.dispatch('updateOrder', order);
this.$store.dispatch('updateSort', sortDirection);
this.$emit('sort', sortDirection === 'desc' ? 'desc' : 'asc');
},
},
computed: {
products(): Array<StockProduct> {
return this.$store.state.products;
},
emptyProducts(): boolean {
return !this.$store.state.products.length;
},
currentSort(): string {
return this.$store.state.order;
},
},
});
</script>

View File

@@ -0,0 +1,165 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<form
class="qty"
:class="classObject"
@mouseover="focusIn"
@mouseleave="focusOut($event)"
@submit.prevent="sendQty"
>
<PSNumber
name="qty"
class="edit-qty"
placeholder="0"
pattern="\d*"
step="1"
:buttons="true"
:hover-buttons="true"
:value="getQuantity()"
@change="onChange($event)"
@keyup="onKeyup($event)"
@keydown="onKeydown($event)"
@focus="focusIn"
@blur="focusOut($event)"
/>
<transition name="fade">
<button
v-if="isActive"
class="check-button"
>
<i class="material-icons">check</i>
</button>
</transition>
</form>
</template>
<script lang="ts">
import PSNumber from '@app/widgets/ps-number.vue';
import isNumber from 'lodash/isNumber';
import {defineComponent} from 'vue';
const {$} = window;
export default defineComponent({
props: {
product: {
type: Object,
required: true,
},
},
computed: {
id(): string {
return `qty-${this.product.product_id}-${this.product.combination_id}`;
},
classObject(): {active: boolean, disabled: boolean} {
return {
active: this.isActive,
disabled: !this.isEnabled,
};
},
},
methods: {
getQuantity(): number | string {
if (!this.product.qty) {
this.isEnabled = false;
this.value = '';
}
return <string> this.value === '' ? '' : Number.parseInt(<string> this.value, 10);
},
onChange(event: Event): void {
this.value = parseInt((<HTMLInputElement>event.target).value, 10);
this.isEnabled = !!parseInt((<HTMLInputElement>event.target).value, 10);
},
deActivate(): void {
this.isActive = false;
this.isEnabled = false;
this.value = '';
this.product.qty = null;
},
// @see Preventing decimal numbers inside input: https://github.com/PrestaShop/PrestaShop/pull/28510
onKeydown(event: KeyboardEvent): void {
if (event.key === '.' || event.key === ',') {
event.preventDefault();
}
},
onKeyup(event: Event): void {
const val = (<HTMLInputElement>event.target).value;
if (parseInt(val, 10) === 0) {
this.deActivate();
} else {
this.isActive = true;
this.isEnabled = true;
this.value = parseInt(val, 10);
}
},
focusIn(): void {
this.isActive = true;
},
focusOut(event: Event): void {
const value = isNumber(this.value) ? Math.round(this.value) : 0;
if (
!$(<HTMLElement>event.target).hasClass('ps-number')
&& (Number.isNaN(value) || value === 0)
) {
this.isActive = false;
}
this.isEnabled = !!this.value;
},
sendQty(): void {
const postUrl = this.product.edit_url;
if (
this.value !== ''
&& parseInt(this.product.qty, 10) !== 0
&& !Number.isNaN(Math.round(<number> this.value))
) {
this.$store.dispatch('updateQtyByProductId', {
url: postUrl,
delta: this.value,
});
this.deActivate();
}
},
},
watch: {
value(val: number): void {
if (isNumber(val)) {
this.$emit('updateProductQty', {
product: this.product,
delta: val,
});
}
},
},
components: {
PSNumber,
},
data() {
return {
value: '' as string | number,
isActive: false,
isEnabled: false,
};
},
});
</script>
<style lang="scss" type="text/scss" scoped>
@import "~jquery-ui-dist/jquery-ui.css";
* {
outline: none;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,12 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {createApp} from 'vue';
import App from './components/app.vue';
import store from './store';
import router from './router';
const vueApp = createApp(App).use(store).use(router);
vueApp.mount('#stock-app');

View File

@@ -0,0 +1,45 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {defineComponent} from 'vue';
interface ProductDescProps {
product: Record<string, any>;
thumbnail?: string;
hasCombination?: boolean;
}
export default defineComponent<ProductDescProps>({
computed: {
thumbnail(): string | undefined {
if (this.product.combination_thumbnail !== 'N/A') {
return `${this.product.combination_thumbnail}`;
}
if (this.product.product_thumbnail !== 'N/A') {
return `${this.product.product_thumbnail}`;
}
return undefined;
},
combinationName(): string {
const combinations = this.product.combination_name.split(',');
const attributes = this.product.attribute_name.split(',');
const separator = ' - ';
let attr = '';
combinations.forEach((attribute: string, index: string) => {
const value = attribute.trim().slice(attributes[index].trim().length + separator.length);
attr += attr.length ? ` - ${value}` : value;
});
return attr;
},
hasCombination() {
return !!this.product.combination_id;
},
},
});

View File

@@ -0,0 +1,13 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {defineComponent} from 'vue';
export default defineComponent({
methods: {
trans(key: string): string {
return this.$store.state.translations[key];
},
},
});

View File

@@ -0,0 +1,36 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {createRouter, createWebHistory, RouteLocationNormalized} from 'vue-router';
import Overview from '@app/pages/stock/components/overview/index.vue';
import Movements from '@app/pages/stock/components/movements/index.vue';
const router = createRouter({
history: createWebHistory(`${window.data.baseUrl}${/(index\.php)/.exec(window.location.href) ? '/index.php' : ''}/sell/stocks`),
routes: [
{
path: '/',
name: 'overview',
component: async () => Overview,
},
{
path: '/movements',
name: 'movements',
component: async () => Movements,
},
],
});
function hasTokenQueryParams(route: RouteLocationNormalized) {
return '_token' in route.query;
}
router.beforeEach((to, from, next) => {
if (!hasTokenQueryParams(to) && hasTokenQueryParams(from)) {
next({name: to.name!, query: from.query});
} else {
next();
}
});
export default router;

View File

@@ -0,0 +1,230 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {Commit} from 'vuex/types';
import * as types from '@app/pages/stock/store/mutation-types';
import {showGrowl} from '@app/utils/growl';
import {EventEmitter} from '@components/event-emitter';
import {
omitBy, isNil,
} from 'lodash';
const isParamInvalid = (value: any) => isNil(value) || value.length <= 0;
export const getStock = async ({commit}: {commit: Commit}, payload: Record<string, any>): Promise<void> => {
const url = new URL(window.data.apiStockUrl, window.location.origin);
const queryParams = omitBy({
order: payload.order,
page_size: payload.page_size,
page_index: payload.page_index,
keywords: payload.keywords,
active: payload.active,
low_stock: payload.low_stock,
}, isParamInvalid);
Object.entries(queryParams).forEach(([key, value]) => {
url.searchParams.append(key, String(value));
});
if (payload.suppliers) {
payload.suppliers.forEach((v: string) => {
url.searchParams.append('supplier_id[]', v);
});
}
if (payload.categories) {
payload.categories.forEach((v: string) => {
url.searchParams.append('category_id[]', v);
});
}
try {
const response = await fetch(url);
const datas = await response.json();
commit(types.LOADING_STATE, false);
commit(types.SET_TOTAL_PAGES, response.headers.get('Total-Pages'));
commit(types.ADD_PRODUCTS, datas);
} catch (error: any) {
showGrowl('error', error.statusText);
}
};
export const getSuppliers = async ({commit}: {commit: Commit}): Promise<void> => {
const url = window.data.suppliersUrl;
try {
const response = await fetch(url);
const datas = await response.json();
commit(types.SET_SUPPLIERS, datas);
} catch (error: any) {
showGrowl('error', error.statusText);
}
};
export const getCategories = async ({commit}: {commit: Commit}): Promise<void> => {
const url = window.data.categoriesUrl;
try {
const response = await fetch(url);
const datas = await response.json();
commit(types.SET_CATEGORIES, datas);
} catch (error: any) {
showGrowl('error', error.statusText);
}
};
export const getMovements = async ({commit}: {commit: Commit}, payload: Record<string, any>): Promise<void> => {
const url = new URL(window.data.apiMovementsUrl, window.location.origin);
const queryParams = omitBy({
order: payload.order,
page_size: payload.page_size,
page_index: payload.page_index,
keywords: payload.keywords,
supplier_id: payload.suppliers,
category_id: payload.categories,
id_stock_mvt_reason: payload.id_stock_mvt_reason,
id_employee: payload.id_employee,
}, isParamInvalid);
Object.entries(queryParams).forEach(([key, value]) => {
url.searchParams.append(key, String(value));
});
if (payload.date_add?.sup) {
url.searchParams.append('date_add[sup]', payload.date_add.sup);
}
if (payload.date_add?.inf) {
url.searchParams.append('date_add[inf]', payload.date_add.inf);
}
try {
const response = await fetch(url);
const datas = await response.json();
commit(types.LOADING_STATE, false);
commit(types.SET_TOTAL_PAGES, response.headers.get('Total-Pages'));
commit(types.SET_MOVEMENTS, datas);
} catch (error: any) {
showGrowl('error', error.statusText);
}
};
export const getTranslations = async ({commit}: {commit: Commit}): Promise<void> => {
const url = window.data.translationUrl;
try {
const response = await fetch(url);
const datas = await response.json();
commit(types.SET_TRANSLATIONS, datas);
commit(types.APP_IS_READY);
} catch (error: any) {
showGrowl('error', error.statusText);
}
};
export const getEmployees = async ({commit}: {commit: Commit}): Promise<void> => {
const url = window.data.employeesUrl;
try {
const response = await fetch(url);
const datas = await response.json();
commit(types.SET_EMPLOYEES_LIST, datas);
} catch (error: any) {
showGrowl('error', error.statusText);
}
};
export const getMovementsTypes = async ({commit}: {commit: Commit}): Promise<void> => {
const url = window.data.movementsTypesUrl;
try {
const response = await fetch(url);
const datas = await response.json();
commit(types.SET_MOVEMENTS_TYPES, datas);
} catch (error: any) {
showGrowl('error', error.statusText);
}
};
export const updateOrder = ({commit}: {commit: Commit}, order: Record<string, any>): void => {
commit(types.UPDATE_ORDER, order);
};
export const updateSort = ({commit}: {commit: Commit}, sort: string): void => {
commit(types.UPDATE_SORT, sort);
};
export const updatePageIndex = ({commit}: {commit: Commit}, pageIndex: number): void => {
commit(types.SET_PAGE_INDEX, pageIndex);
};
export const updateKeywords = ({commit}: {commit: Commit}, keywords: Array<string>): void => {
commit(types.UPDATE_KEYWORDS, keywords);
};
export const isLoading = ({commit}: {commit: Commit}): void => {
commit(types.LOADING_STATE, true);
};
export const updateProductQty = ({commit}: {commit: Commit}, payload: Record<string, any>): void => {
commit(types.UPDATE_PRODUCT_QTY, payload);
};
export const updateQtyByProductId = async ({commit}: {commit: Commit}, payload: Record<string, any>): Promise<void> => {
const {url} = payload;
const {delta} = payload;
try {
const res = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({delta}),
});
const datas = await res.json();
commit(types.UPDATE_PRODUCT, datas);
EventEmitter.emit('displayBulkAlert', 'success');
} catch (error: any) {
showGrowl('error', error.statusText);
}
};
export const updateQtyByProductsId = async ({commit, state}: {commit: Commit, state: Record<string, any>}): Promise<void> => {
const url = state.editBulkUrl;
const productsQty = state.productsToUpdate;
try {
const res = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(productsQty),
});
const datas = await res.json();
commit(types.UPDATE_PRODUCTS_QTY, datas);
EventEmitter.emit('displayBulkAlert', 'success');
} catch (error: any) {
showGrowl('error', error.body?.error ?? error.statusText);
}
};
export const updateBulkEditQty = ({commit}: {commit: Commit}, value: number): void => {
commit(types.UPDATE_BULK_EDIT_QTY, value);
};
export const addProductToUpdate = ({commit}: {commit: Commit}, product: Record<string, any>): void => {
commit(types.ADD_PRODUCT_TO_UPDATE, product);
};
export const removeProductToUpdate = ({commit}: {commit: Commit}, product: Record<string, any>): void => {
commit(types.REMOVE_PRODUCT_TO_UPDATE, product);
};
export const addSelectedProduct = ({commit}: {commit: Commit}, product: Record<string, any>): void => {
commit(types.ADD_SELECTED_PRODUCT, product);
};
export const removeSelectedProduct = ({commit}: {commit: Commit}, product: Record<string, any>): void => {
commit(types.REMOVE_SELECTED_PRODUCT, product);
};

View File

@@ -0,0 +1,77 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
/* eslint-disable no-param-reassign */
import {createStore} from 'vuex';
import _ from 'lodash';
import * as actions from './actions';
import mutations from './mutations';
// root state object.
const state = {
order: '',
sort: 'desc',
pageIndex: 1,
totalPages: 0,
productsPerPage: 30,
products: [],
hasQty: false,
keywords: [],
suppliers: {
data: [],
},
categories: [],
categoryList: [],
movements: [],
employees: [],
movementsTypes: [],
translations: {},
isLoading: false,
isReady: false,
editBulkUrl: '',
bulkEditQty: null,
productsToUpdate: [],
selectedProducts: [],
};
// getters are functions
const getters = {
suppliers(rootState: Record<string, any>) {
function convert(suppliers: Record<string, any>) {
suppliers.forEach((supplier: Record<string, any>) => {
supplier.id = supplier.supplier_id;
});
return suppliers;
}
return convert(rootState.suppliers.data);
},
categories(rootState: Record<string, any>) {
function convert(categories: Record<string, any>) {
categories.forEach((category: Record<string, any>) => {
category.children = _.values(category.children);
rootState.categoryList.push(category);
category.id = `${category.id_parent}-${category.id_category}`;
convert(category.children);
});
return categories;
}
return convert(rootState.categories);
},
selectedProductsLng(rootState: Record<string, any>) {
return rootState.selectedProducts.length;
},
};
// A Vuex instance is created by combining the state, mutations, actions,
// and getters.
export default createStore({
state() {
return state;
},
getters,
actions,
mutations,
});

View File

@@ -0,0 +1,26 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
export const ADD_PRODUCTS = 'ADD_PRODUCTS';
export const ADD_PRODUCT_TO_UPDATE = 'ADD_PRODUCT_TO_UPDATE';
export const ADD_SELECTED_PRODUCT = 'ADD_SELECTED_PRODUCT';
export const APP_IS_READY = 'APP_IS_READY';
export const LOADING_STATE = 'LOADING_STATE';
export const REMOVE_PRODUCT_TO_UPDATE = 'REMOVE_PRODUCT_TO_UPDATE';
export const REMOVE_SELECTED_PRODUCT = 'REMOVE_SELECTED_PRODUCT';
export const SET_CATEGORIES = 'SET_CATEGORIES';
export const SET_EMPLOYEES_LIST = 'SET_EMPLOYEES_LIST';
export const SET_MOVEMENTS = 'SET_MOVEMENTS';
export const SET_MOVEMENTS_TYPES = 'SET_MOVEMENTS_TYPES';
export const SET_PAGE_INDEX = 'SET_PAGE_INDEX';
export const SET_SUPPLIERS = 'SET_SUPPLIERS';
export const SET_TOTAL_PAGES = 'SET_TOTAL_PAGES';
export const SET_TRANSLATIONS = 'SET_TRANSLATIONS';
export const UPDATE_BULK_EDIT_QTY = 'UPDATE_BULK_EDIT_QTY';
export const UPDATE_KEYWORDS = 'UPDATE_KEYWORDS';
export const UPDATE_PRODUCT = 'UPDATE_PRODUCT';
export const UPDATE_PRODUCT_QTY = 'UPDATE_PRODUCT_QTY';
export const UPDATE_PRODUCTS_QTY = 'UPDATE_PRODUCTS_QTY';
export const UPDATE_ORDER = 'UPDATE_ORDER';
export const UPDATE_SORT = 'UPDATE_SORT';

View File

@@ -0,0 +1,182 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
/* eslint-disable no-param-reassign */
import _ from 'lodash';
import * as types from './mutation-types';
export default {
[types.UPDATE_ORDER](state: Record<string, any>, order: Record<string, any>): void {
state.order = order;
},
[types.UPDATE_SORT](state: Record<string, any>, sort: string): void {
state.sort = sort;
},
[types.UPDATE_KEYWORDS](state: Record<string, any>, keywords: Array<string>): void {
state.keywords = keywords;
},
[types.SET_TOTAL_PAGES](state: Record<string, any>, totalPages: number): void {
state.totalPages = Number(totalPages);
},
[types.SET_PAGE_INDEX](state: Record<string, any>, pageIndex: number): void {
state.pageIndex = pageIndex;
},
[types.SET_SUPPLIERS](state: Record<string, any>, suppliers: Array<Record<string, any>>): void {
state.suppliers = suppliers;
},
[types.SET_CATEGORIES](state: Record<string, any>, categories: Record<string, any>): void {
state.categories = categories.data.tree.children;
},
[types.SET_MOVEMENTS](state: Record<string, any>, movements: Record<string, any>): void {
state.movements = movements.data;
},
[types.SET_TRANSLATIONS](state: Record<string, any>, translations: Record<string, any>): void {
translations.data.forEach((t: Record<string, any>) => {
state.translations[t.translation_id] = t.name;
});
},
[types.LOADING_STATE](state: Record<string, any>, isLoading: boolean): void {
state.isLoading = isLoading;
},
[types.APP_IS_READY](state: Record<string, any>): void {
state.isReady = true;
},
[types.SET_EMPLOYEES_LIST](state: Record<string, any>, employees: Record<string, any>): void {
state.employees = employees.data;
},
[types.SET_MOVEMENTS_TYPES](state: Record<string, any>, movementsTypes: Record<string, any>): void {
state.movementsTypes = movementsTypes.data;
},
[types.ADD_PRODUCTS](state: Record<string, any>, products: Record<string, any>): void {
state.productsToUpdate = [];
state.selectedProducts = [];
_.forEach(products.data.data, (product) => {
product.qty = 0;
});
state.editBulkUrl = products.data.info.edit_bulk_url;
state.products = products.data.data;
},
[types.UPDATE_PRODUCT](state: Record<string, any>, updatedProduct: Record<string, any>): void {
const index = _.findIndex(state.products, {
product_id: updatedProduct.product_id,
combination_id: updatedProduct.combination_id,
});
const updatedIndex = _.findIndex(state.productsToUpdate, {
product_id: updatedProduct.product_id,
combination_id: updatedProduct.combination_id,
});
updatedProduct.qty = 0;
state.products.splice(index, 1, updatedProduct);
state.productsToUpdate.splice(updatedIndex, 1);
},
[types.UPDATE_PRODUCTS_QTY](state: Record<string, any>, updatedProducts: Record<string, any>): void {
state.productsToUpdate = [];
state.selectedProducts = [];
_.forEach(updatedProducts, (product) => {
const index = _.findIndex(state.products, {
product_id: product.product_id,
combination_id: product.combination_id,
});
product.qty = 0;
state.products.splice(index, 1, product);
});
state.hasQty = false;
},
[types.UPDATE_PRODUCT_QTY](state: Record<string, any>, updatedProduct: Record<string, any>): void {
let hasQty = false;
const productToUpdate = _.find(state.products, {
product_id: updatedProduct.product_id,
combination_id: updatedProduct.combination_id,
});
_.forEach(state.products, (product) => {
productToUpdate.qty = updatedProduct.delta;
if (product.qty) {
hasQty = true;
}
});
state.hasQty = hasQty;
},
[types.ADD_PRODUCT_TO_UPDATE](state: Record<string, any>, updatedProduct: Record<string, any>): void {
const index = _.findIndex(state.productsToUpdate, {
product_id: updatedProduct.product_id,
combination_id: updatedProduct.combination_id,
});
if (index !== -1) {
state.productsToUpdate.splice(index, 1, updatedProduct);
} else {
state.productsToUpdate.push(updatedProduct);
}
},
[types.REMOVE_PRODUCT_TO_UPDATE](state: Record<string, any>, updatedProduct: Record<string, any>): void {
const index = _.findIndex(state.productsToUpdate, {
product_id: updatedProduct.product_id,
combination_id: updatedProduct.combination_id,
});
state.productsToUpdate.splice(index, 1);
},
[types.UPDATE_BULK_EDIT_QTY](state: Record<string, any>, value: number): void {
state.bulkEditQty = value;
if (value) {
_.forEach(state.selectedProducts, (product: Record<string, any>) => {
const index = _.findIndex(state.productsToUpdate, {
product_id: product.product_id,
combination_id: product.combination_id,
});
product.qty = value;
product.delta = state.bulkEditQty;
if (index !== -1) {
state.productsToUpdate.splice(index, 1, product);
} else {
state.productsToUpdate.push(product);
}
});
state.hasQty = true;
return;
}
if (value === 0) {
_.forEach(state.selectedProducts, (product) => {
product.qty = 0;
});
state.hasQty = false;
return;
}
if (value === null) {
_.forEach(state.selectedProducts, (product) => {
product.qty = 0;
});
state.productsToUpdate = [];
state.selectedProducts = [];
state.hasQty = false;
}
},
[types.ADD_SELECTED_PRODUCT](state: Record<string, any>, product: Record<string, any>): void {
const index = _.findIndex(state.selectedProducts, {
product_id: product.product_id,
combination_id: product.combination_id,
});
if (index !== -1) {
state.selectedProducts.splice(index, 1, product);
} else {
state.selectedProducts.push(product);
}
},
[types.REMOVE_SELECTED_PRODUCT](state: Record<string, any>, product: Record<string, any>): void {
const index = _.findIndex(state.selectedProducts, {
product_id: product.product_id,
combination_id: product.combination_id,
});
if (index !== -1) {
state.selectedProducts[index].qty = 0;
}
state.selectedProducts.splice(index, 1);
},
};

View File

@@ -0,0 +1,171 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div
v-if="isReady"
id="app"
class="translations-app"
>
<div class="row justify-content-between align-items-end">
<div class="col-md-8 mb-3">
<Search @search="onSearch" />
</div>
<div class="col-md-4 mb-3">
<div class="translations-summary text-md-right">
<span>{{ totalTranslations }}</span>
<span v-show="totalMissingTranslations">
-
<span class="missing">{{ totalMissingTranslationsString }}</span>
</span>
</div>
</div>
</div>
<div class="row">
<div class="col-md-5 col-lg-4 mb-3">
<Sidebar
:modal="$refs.transModal"
:principal="$refs.principal"
/>
</div>
<div class="col-md-7 col-lg-8 mb-3">
<Principal
:modal="$refs.transModal"
ref="principal"
/>
</div>
</div>
<PSModal
ref="transModal"
:translations="translations"
/>
</div>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
import Search from '@app/pages/translations/components/header/search.vue';
import Sidebar from '@app/pages/translations/components/sidebar/index.vue';
import Principal from '@app/pages/translations/components/principal/index.vue';
import TranslationMixin from '@app/pages/translations/mixins/translate';
import PSModal from '@app/widgets/ps-modal.vue';
export default defineComponent({
name: 'App',
mixins: [TranslationMixin],
computed: {
isReady(): boolean {
return this.$store.getters.isReady;
},
totalTranslations(): string {
return this.$store.state.totalTranslations <= 1
? this.trans('label_total_domain_singular')
.replace('%nb_translation%', this.$store.state.totalTranslations.toString())
: this.trans('label_total_domain')
.replace('%nb_translations%', this.$store.state.totalTranslations.toString());
},
totalMissingTranslations(): number {
return this.$store.state.totalMissingTranslations;
},
totalMissingTranslationsString(): string {
return this.totalMissingTranslations === 1
? this.trans('label_missing_singular')
: this.trans('label_missing').replace('%d', <string><unknown> this.totalMissingTranslations);
},
translations(): Record<string, any> {
return {
button_save: this.trans('button_save'),
button_leave: this.trans('button_leave'),
modal_content: this.trans('modal_content'),
modal_title: this.trans('modal_title'),
};
},
},
beforeMount() {
this.$store.dispatch('getTranslations');
},
mounted() {
$('a').on('click', (e: JQueryEventObject): void => {
if ($(e.currentTarget).attr('href')) {
this.destHref = <string>$(e.currentTarget).attr('href');
}
});
window.onbeforeunload = (): any => {
if (!this.destHref && this.isEdited() && !this.leave) {
return true;
}
if (!this.leave && this.isEdited()) {
setTimeout(() => {
window.stop();
}, 500);
if (this.$refs.transModal && this.$refs.principal) {
const refTransModal = this.$refs.transModal as VTransModal;
refTransModal.showModal();
refTransModal.$once('save', (): void => {
(this.$refs.principal as VPrincipal).saveTranslations();
this.leavePage();
});
refTransModal.$once('leave', () => {
this.leavePage();
});
}
return null;
}
return undefined;
};
},
methods: {
onSearch(): void {
this.$store.dispatch('getDomainsTree', {
store: this.$store,
});
this.$store.state.currentDomain = '';
},
/**
* Set leave to true and redirect the user to the new location
*/
leavePage(): void {
this.leave = true;
window.location.href = <string> this.destHref;
},
isEdited(): boolean {
return (this.$refs.principal as VPrincipal).edited();
},
},
data: () => ({
destHref: null as null | string,
leave: false,
}),
components: {
Search,
Sidebar,
Principal,
PSModal,
},
});
</script>
<style lang="scss" type="text/scss">
@import '~@scss/config/_settings.scss';
.flex {
display: flex;
align-items: center;
}
.missing {
color: var(--#{$cdk}red-500);
}
.translations-summary {
font-weight: 500;
font-size: var(--#{$cdk}size-16);
}
</style>

View File

@@ -0,0 +1,68 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div id="search">
<form
class="search-form"
@submit.prevent
>
<label>{{ trans('search_label') }}</label>
<div class="input-group">
<PSTags
ref="psTags"
:tags="tags"
@tagChange="onSearch"
:placeholder="trans('search_placeholder')"
/>
<div class="input-group-append">
<PSButton
@click="onClick"
class="search-button"
:primary="true"
>
<i class="material-icons">search</i>
{{ trans('button_search') }}
</PSButton>
</div>
</div>
</form>
</div>
</template>
<script lang="ts">
import PSTags from '@app/widgets/ps-tags.vue';
import PSButton from '@app/widgets/ps-button.vue';
import {defineComponent} from 'vue';
import TranslationMixin from '@app/pages/translations/mixins/translate';
export default defineComponent({
components: {
PSTags,
PSButton,
},
mixins: [TranslationMixin],
methods: {
onClick() {
const refPsTags = this.$refs.psTags as VTags;
const {tag} = refPsTags;
refPsTags.add(tag);
},
onSearch() {
this.$store.dispatch('updateSearch', this.tags);
this.$emit('search', this.tags);
},
},
watch: {
$route() {
this.tags = [];
},
},
data() {
return {
tags: [],
};
},
});
</script>

View File

@@ -0,0 +1,304 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<transition name="fade">
<div
class="card"
v-if="principalReady"
>
<div class="p-3 translations-wrapper">
<PSAlert
v-if="noResult"
alert-type="ALERT_TYPE_WARNING"
:has-close="false"
>
{{ noResultInfo }}
</PSAlert>
<div
class="translations-catalog row p-0"
v-else
>
<div class="col-12">
<PSAlert
v-if="searchActive"
class="col-sm-12"
alert-type="ALERT_TYPE_INFO"
:has-close="false"
>
{{ searchInfo }}
</PSAlert>
</div>
<div class="col-sm-8 pt-3">
<h3 class="domain-info">
<span>{{ currentDomain }}</span>
<span>{{ currentDomainTotalTranslations }}</span>
<span
v-show="currentDomainTotalMissingTranslations"
> - <span class="missing">{{ currentDomainTotalMissingTranslationsString }}</span></span>
</h3>
</div>
<div class="col-sm-4">
<PSPagination
:current-index="currentPagination"
:pages-count="pagesCount"
class="float-sm-right"
@pageChanged="onPageChanged"
/>
</div>
<form
class="col-sm-12"
method="post"
:action="saveAction"
:isEdited="isEdited"
@submit.prevent="saveTranslations"
>
<div class="row">
<div class="col-sm-12 mb-2">
<PSButton
:primary="true"
type="submit"
class="float-sm-right"
>
{{ trans('button_save') }}
</PSButton>
</div>
</div>
<TranslationInput
v-for="(translation, key, index) in translationsCatalog"
:key="key"
:id="index"
:translated="translation"
:label="translation.default"
:extra-info="getDomain(translation.tree_domain)"
@editedAction="isEdited"
/>
<div class="row">
<div class="col-sm-12">
<PSButton
:primary="true"
type="submit"
class="float-sm-right mt-3"
>
{{ trans('button_save') }}
</PSButton>
</div>
</div>
</form>
<div class="col-sm-12">
<PSPagination
:current-index="currentPagination"
:pages-count="pagesCount"
@pageChanged="onPageChanged"
/>
</div>
</div>
</div>
</div>
</transition>
</template>
<script lang="ts">
import PSButton from '@app/widgets/ps-button.vue';
import PSPagination from '@app/widgets/ps-pagination.vue';
import PSAlert from '@app/widgets/ps-alert.vue';
import {EventEmitter} from '@components/event-emitter';
import TranslationMixin from '@app/pages/translations/mixins/translate';
import {defineComponent} from 'vue';
import TranslationInput from './translation-input.vue';
export default defineComponent({
props: {
modal: {
type: Object,
required: false,
default: () => ({}),
},
},
mixins: [TranslationMixin],
data: () => ({
originalTranslations: [] as Array<Record<string, any>>,
modifiedTranslations: [] as Array<Record<string, any>>,
}),
computed: {
principalReady(): boolean {
return !this.$store.state.principalLoading;
},
translationsCatalog(): any {
return this.$store.getters.catalog.data.data;
},
saveAction(): string {
return this.$store.getters.catalog.data.info ? this.$store.getters.catalog.data.info.edit_url : '';
},
resetAction(): string {
return this.$store.getters.catalog.data.info ? this.$store.getters.catalog.data.info.reset_url : '';
},
pagesCount(): number {
return this.$store.getters.totalPages;
},
currentPagination(): number {
return this.$store.getters.pageIndex;
},
currentDomain(): string {
return this.$store.state.currentDomain;
},
currentDomainTotalTranslations(): string {
/* eslint-disable max-len */
return (this.$store.state.currentDomainTotalTranslations <= 1)
? `- ${this.trans('label_total_domain_singular').replace('%nb_translation%', this.$store.state.currentDomainTotalTranslations.toString())}`
: `- ${this.trans('label_total_domain').replace('%nb_translations%', this.$store.state.currentDomainTotalTranslations.toString())}`;
/* eslint-enable max-len */
},
currentDomainTotalMissingTranslations(): number {
return this.$store.state.currentDomainTotalMissingTranslations;
},
currentDomainTotalMissingTranslationsString(): string {
let totalMissingTranslationsString = '';
if (this.currentDomainTotalMissingTranslations) {
if (this.currentDomainTotalMissingTranslations === 1) {
totalMissingTranslationsString = this.trans('label_missing_singular');
} else {
totalMissingTranslationsString = this.trans('label_missing')
.replace('%d', <string><unknown> this.currentDomainTotalMissingTranslations);
}
}
return totalMissingTranslationsString;
},
noResult(): boolean {
return (this.$store.getters.currentDomain === '' || typeof this.$store.getters.currentDomain === 'undefined');
},
noResultInfo(): string {
return this.trans('no_result').replace('%s', this.$store.getters.searchTags.join(' - '));
},
searchActive(): number {
return this.$store.getters.searchTags.length;
},
searchInfo(): string {
const transKey = (this.$store.state.totalTranslations <= 1) ? 'search_info_singular' : 'search_info';
return this.trans(transKey)
.replace('%s', this.$store.getters.searchTags.join(' - '))
.replace('%d', this.$store.state.totalTranslations.toString());
},
},
methods: {
/**
* Dispatch the event to change the page index,
* get the translations and reset the modified translations into the state
* @param {Number} pageIndex
*/
changePage: function changePage(pageIndex: number): void {
this.$store.dispatch('updatePageIndex', pageIndex);
this.fetch();
this.$store.state.modifiedTranslations = [];
},
isEdited(input: Record<string, any>): void {
if (input.translation.edited) {
this.$store.state.modifiedTranslations[input.id] = input.translation;
} else {
this.$store.state.modifiedTranslations.splice(
this.$store.state.modifiedTranslations.indexOf(input.id),
1,
);
}
},
onPageChanged(pageIndex: number): void {
if (this.edited()) {
this.modal.showModal();
this.modal.$once('save', () => {
this.saveTranslations();
this.changePage(pageIndex);
});
this.modal.$once('leave', () => {
this.changePage(pageIndex);
});
} else {
this.changePage(pageIndex);
}
},
fetch(): void {
this.$store.dispatch('getCatalog', {
url: this.$store.getters.catalog.info.current_url_without_pagination,
page_size: this.$store.state.translationsPerPage,
page_index: this.$store.getters.pageIndex,
});
},
getDomain(domains: Array<string>): string {
let domain = '';
domains.forEach((d) => {
domain += `${d} > `;
});
return domain.slice(0, -3);
},
saveTranslations(): void {
const modifiedTranslations = this.getModifiedTranslations();
if (modifiedTranslations.length) {
this.$store.dispatch('saveTranslations', {
url: this.saveAction,
translations: this.getModifiedTranslations(),
store: this.$store,
});
}
},
getModifiedTranslations(): Array<Record<string, any>> {
this.modifiedTranslations = [];
const targetTheme = (window.data.type === 'modules') ? '' : window.data.selected;
Object.values(this.$store.state.modifiedTranslations).forEach((translation: any): void => {
this.modifiedTranslations.push({
default: translation.default,
edited: translation.edited,
domain: translation.tree_domain.join(''),
locale: window.data.locale,
theme: targetTheme,
});
});
return this.modifiedTranslations;
},
edited(): boolean {
return Object.keys(this.$store.state.modifiedTranslations).length > 0;
},
},
mounted() {
EventEmitter.on('resetTranslation', (el: Record<string, any>) => {
const translations = [];
translations.push({
default: el.default,
domain: el.tree_domain.join(''),
locale: window.data.locale,
theme: window.data.selected,
});
this.$store.dispatch('resetTranslation', {
url: this.resetAction,
translations,
});
});
},
components: {
TranslationInput,
PSButton,
PSPagination,
PSAlert,
},
});
</script>
<style lang="scss" scoped>
@import '~@scss/config/_settings.scss';
.fade-enter-active, .fade-leave-active {
transition: opacity .5s
}
.fade-enter, .fade-leave-to /* .fade-leave-active in <2.1.8 */ {
opacity: 0
}
</style>

View File

@@ -0,0 +1,102 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div class="form-group">
<label>{{ label }}</label>
<textarea
class="form-control"
rows="2"
v-model="getTranslated"
:class="{ missing : isMissing }"
/>
<div class="d-flex flex-column flex-md-row justify-content-md-between">
<div>
<small>{{ extraInfo }}</small>
</div>
<div>
<PSButton
class="mt-2"
:primary="false"
ghost
@click="resetTranslation"
>
{{ trans('button_reset') }}
</PSButton>
</div>
</div>
</div>
</template>
<script lang="ts">
import PSButton from '@app/widgets/ps-button.vue';
import {EventEmitter} from '@components/event-emitter';
import TranslationMixin from '@app/pages/translations/mixins/translate';
import {defineComponent} from 'vue';
export default defineComponent({
name: 'TranslationInput',
mixins: [TranslationMixin],
props: {
id: {
type: Number,
required: false,
default: 0,
},
extraInfo: {
type: String,
required: false,
default: '',
},
label: {
type: String,
required: true,
},
translated: {
type: Object,
required: true,
},
},
computed: {
getTranslated: {
get(): any {
return this.translated.user ? this.translated.user : this.translated.project;
},
set(modifiedValue: any): void {
const modifiedTranslated = this.translated;
modifiedTranslated.user = modifiedValue;
modifiedTranslated.edited = modifiedValue;
this.$emit('input', modifiedTranslated);
this.$emit('editedAction', {
translation: modifiedTranslated,
id: this.id,
});
},
},
isMissing(): boolean {
return this.getTranslated === null;
},
},
methods: {
resetTranslation(): void {
this.getTranslated = '';
EventEmitter.emit('resetTranslation', this.translated);
},
},
components: {
PSButton,
},
});
</script>
<style lang="scss" scoped>
@import '~@scss/config/_settings.scss';
.form-group {
overflow: hidden;
}
.missing {
border: 1px solid var(--#{$cdk}red-500);
}
</style>

View File

@@ -0,0 +1,217 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div class="card p-3">
<PSTree
ref="domainsTree"
:model="$store.getters.domainsTree"
class-name="translationTree"
:translations="translations"
:current-item="currentItem"
v-if="treeReady"
/>
<PSSpinner v-else />
</div>
</template>
<script lang="ts">
import PSTree from '@app/widgets/ps-tree/ps-tree.vue';
import PSSpinner from '@app/widgets/ps-spinner.vue';
import {EventEmitter} from '@components/event-emitter';
import TranslationMixin from '@app/pages/translations/mixins/translate';
import {defineComponent} from 'vue';
export default defineComponent({
props: {
modal: {
type: Object,
required: false,
default: () => ({}),
},
principal: {
type: Object,
required: false,
default: () => ({}),
},
},
mixins: [TranslationMixin],
computed: {
treeReady(): boolean {
return !this.$store.state.sidebarLoading;
},
currentItem(): string {
if (this.$store.getters.currentDomain === '' || typeof this.$store.getters.currentDomain === 'undefined') {
if (this.$store.getters.domainsTree.length) {
const domain = this.getFirstDomainToDisplay(this.$store.getters.domainsTree);
EventEmitter.emit('reduce');
this.$store.dispatch('updateCurrentDomain', domain);
if (domain !== '') {
this.$store.dispatch('getCatalog', {url: (<Record<string, any>>domain).dataValue});
EventEmitter.emit('setCurrentElement', (<Record<string, any>>domain).full_name);
return (<Record<string, any>>domain).full_name;
}
this.$store.dispatch('updatePrincipalLoading', false);
return '';
}
}
return this.$store.getters.currentDomain;
},
translations(): Record<string, any> {
return {
expand: this.trans('sidebar_expand'),
reduce: this.trans('sidebar_collapse'),
extra: this.trans('label_missing'),
extra_singular: this.trans('label_missing_singular'),
};
},
},
mounted() {
this.$store.dispatch('getDomainsTree', {
store: this.$store,
});
EventEmitter.on('lastTreeItemClick', (el: any): void => {
if (this.edited()) {
this.modal.showModal();
this.modal.$once('save', () => {
this.principal.saveTranslations();
this.itemClick(el);
});
this.modal.$once('leave', () => {
this.itemClick(el);
});
} else {
this.itemClick(el);
}
});
},
methods: {
/**
* Update the domain, retrieve the translations catalog, set the page to 1
* and reset the modified translations
* @param {object} el - Domain to set
*/
itemClick(el: any): void {
this.$store.dispatch('updateCurrentDomain', el.item);
this.$store.dispatch('getCatalog', {url: el.item.dataValue});
this.$store.dispatch('updatePageIndex', 1);
this.$store.state.modifiedTranslations = [];
},
getFirstDomainToDisplay(tree: any): string | Record<string, any> {
const keys = Object.keys(tree);
let toDisplay = '';
for (let i = 0; i < tree.length; i += 1) {
if (!tree[keys[i]].disable) {
if (tree[keys[i]].children && tree[keys[i]].children.length > 0) {
return this.getFirstDomainToDisplay(tree[keys[i]].children);
}
toDisplay = tree[keys[i]];
break;
}
}
return toDisplay;
},
/**
* Check if some translations have been edited
* @returns {boolean}
*/
edited: function edited(): boolean {
return Object.keys(this.$store.state.modifiedTranslations).length > 0;
},
},
components: {
PSTree,
PSSpinner,
},
});
</script>
<style lang="scss" type="text/scss">
@import '~@scss/config/_settings.scss';
.translationTree {
.tree-name {
margin-bottom: var(--#{$cdk}size-16);
&.active {
font-weight: bold;
}
&.extra {
color: var(--#{$cdk}red-500);
}
}
.tree-extra-label {
color: var(--#{$cdk}red-500);
text-transform: uppercase;
font-size: var(--#{$cdk}size-10);
margin-left: auto;
}
.tree-extra-label-mini {
background-color: var(--#{$cdk}red-500);
color: var(--#{$cdk}white);
padding: 0 var(--#{$cdk}size-8);
border-radius: var(--#{$cdk}size-12);
display: inline-block;
font-size: var(--#{$cdk}size-12);
height: var(--#{$cdk}size-24);
margin-left: auto;
}
.tree-label {
&:hover {
text-decoration: underline;
}
}
}
.ps-loader {
$loader-white-height: 20px;
$loader-line-height: 16px;
.animated-background {
height: 144px!important;
animation-duration: 2s!important;
}
.background-masker {
&.header-left {
left: 0;
top: $loader-line-height;
height: 108px;
width: 20px;
}
&.content-top {
left: 0;
top: $loader-line-height;
height: $loader-white-height;
}
&.content-first-end {
left: 0;
top: $loader-line-height*2+$loader-white-height;
height: $loader-white-height;
}
&.content-second-end {
left: 0;
top: $loader-line-height*3+$loader-white-height*2;
height: $loader-white-height;
}
&.content-third-end {
left: 0;
top: $loader-line-height*4+$loader-white-height*3;
height: $loader-white-height;
}
}
}
</style>

View File

@@ -0,0 +1,10 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {createApp} from 'vue';
import App from './components/app.vue';
import store from './store';
import router from './router';
export default createApp(App).use(store).use(router).mount('#translations-app');

View File

@@ -0,0 +1,13 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {defineComponent} from 'vue';
export default defineComponent({
methods: {
trans(key: string): string {
return this.$store.getters.translations[key];
},
},
});

View File

@@ -0,0 +1,17 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {createRouter, createWebHistory} from 'vue-router';
import Overview from '@app/pages/translations/components/app.vue';
export default createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'overview',
component: async () => Overview,
},
],
});

View File

@@ -0,0 +1,144 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {Commit} from 'vuex/types';
import * as types from '@app/pages/translations/store/mutation-types';
import {showGrowl} from '@app/utils/growl';
import {
omitBy, isNil,
} from 'lodash';
const isParamInvalid = (value: any) => isNil(value) || value.length <= 0;
export const getTranslations = async ({commit}: {commit: Commit}): Promise<void> => {
const url = window.data.translationUrl;
try {
const response = await fetch(url);
const datas = await response.json();
commit(types.SET_TRANSLATIONS, datas);
commit(types.APP_IS_READY);
} catch (error: any) {
showGrowl('error', error.bodyText ? JSON.parse(error.bodyText).error : error.statusText);
}
};
export const getCatalog = async ({commit}: {commit: Commit}, payload: Record<string, any>): Promise<void> => {
commit(types.PRINCIPAL_LOADING, true);
const url = new URL(payload.url, window.location.origin);
const queryParams = omitBy({
page_size: payload.page_size,
page_index: payload.page_index,
}, isParamInvalid);
Object.entries(queryParams).forEach(([key, value]) => {
url.searchParams.append(key, String(value));
});
try {
const response = await fetch(url);
const datas = await response.json();
commit(types.SET_TOTAL_PAGES, response.headers.get('Total-Pages'));
commit(types.SET_CATALOG, datas);
commit(types.PRINCIPAL_LOADING, false);
} catch (error: any) {
showGrowl('error', error.bodyText ? JSON.parse(error.bodyText).error : error.statusText);
}
};
export const getDomainsTree = async ({commit}: {commit: Commit}, payload: Record<string, any>): Promise<void> => {
const url = new URL(window.data.domainsTreeUrl, window.location.origin);
commit(types.SIDEBAR_LOADING, true);
commit(types.PRINCIPAL_LOADING, true);
payload.store.getters.searchTags.forEach((tag: any) => {
url.searchParams.append('search[]', tag);
});
try {
const response = await fetch(url);
const datas = await response.json();
commit(types.SET_DOMAINS_TREE, datas);
commit(types.SIDEBAR_LOADING, false);
commit(types.RESET_CURRENT_DOMAIN);
} catch (error: any) {
showGrowl('error', error.bodyText ? JSON.parse(error.bodyText).error : error.statusText);
}
};
export const refreshCounts = async ({commit}: {commit: Commit}, payload: Record<string, any>): Promise<void> => {
const url = new URL(window.data.domainsTreeUrl, window.location.origin);
payload.store.getters.searchTags.forEach((tag: any) => {
url.searchParams.append('search[]', tag);
});
try {
const response = await fetch(url);
const datas = await response.json();
commit(types.DECREASE_CURRENT_DOMAIN_TOTAL_MISSING_TRANSLATIONS, payload.successfullySaved);
commit(types.SET_DOMAINS_TREE, datas);
} catch (error: any) {
showGrowl('error', error.bodyText ? JSON.parse(error.bodyText).error : error.statusText);
}
};
export const saveTranslations = async ({commit}: {commit: Commit}, payload: Record<string, any>): Promise<void> => {
const {url} = payload;
const {translations} = payload;
try {
await fetch(url, {
method: 'POST',
body: JSON.stringify({translations}),
});
payload.store.dispatch('refreshCounts', {
successfullySaved: translations.length,
store: payload.store,
});
commit(types.RESET_MODIFIED_TRANSLATIONS);
showGrowl('success', 'Translations successfully updated');
} catch (error: any) {
showGrowl('error', error.bodyText ? JSON.parse(error.bodyText).error : error.statusText);
}
};
/* eslint-disable-next-line no-unused-vars */
export const resetTranslation = async (params: Record<string, any>, payload: Record<string, any>): Promise<void> => {
const {url} = payload;
const {translations} = payload;
try {
await fetch(url, {
method: 'POST',
body: JSON.stringify({translations}),
});
showGrowl('success', 'Translations successfully reset');
} catch (error: any) {
showGrowl('error', error.bodyText ? JSON.parse(error.bodyText).error : error.statusText);
}
};
export const updatePageIndex = ({commit}: {commit: Commit}, pageIndex: string): void => {
commit(types.SET_PAGE_INDEX, pageIndex);
};
export const updateCurrentDomain = ({commit}: {commit: Commit}, currentDomain: string): void => {
commit(types.SET_CURRENT_DOMAIN, currentDomain);
};
export const updatePrincipalLoading = ({commit}: {commit: Commit}, principalLoading: string): void => {
commit(types.PRINCIPAL_LOADING, principalLoading);
};
export const updateSearch = ({commit}: {commit: Commit}, searchTags: Array<Record<string, any>>): void => {
commit(types.SEARCH_TAGS, searchTags);
};

View File

@@ -0,0 +1,89 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {createStore} from 'vuex';
import _ from 'lodash';
import * as actions from './actions';
import mutations from './mutations';
// root state object.
const state = {
pageIndex: 1,
totalPages: 0,
translationsPerPage: 20,
currentDomain: '',
translations: {
data: {},
info: {},
},
catalog: {
data: {},
info: {},
},
domainsTree: [],
totalMissingTranslations: 0,
totalTranslations: 0,
currentDomainTotalTranslations: 0,
currentDomainTotalMissingTranslations: 0,
isReady: false,
sidebarLoading: true,
principalLoading: true,
searchTags: [],
modifiedTranslations: [],
};
// getters are functions
const getters = {
totalPages(rootState: Record<string, any>) {
return rootState.totalPages;
},
pageIndex(rootState: Record<string, any>) {
return rootState.pageIndex;
},
currentDomain(rootState: Record<string, any>) {
return rootState.currentDomain;
},
translations(rootState: Record<string, any>) {
return rootState.translations;
},
catalog(rootState: Record<string, any>) {
return rootState.catalog;
},
domainsTree(rootState: Record<string, any>): Array<Record<string, any>> {
function convert(domains: Array<Record<string, any>>): Array<Record<string, any>> {
domains.forEach((domain: Record<string, any>) => {
/* eslint-disable */
domain.children = _.values(domain.children);
domain.extraLabel = domain.total_missing_translations;
domain.dataValue = domain.domain_catalog_link;
domain.warning = Boolean(domain.total_missing_translations);
domain.disable = !domain.total_translations;
domain.id = domain.full_name;
/* eslint-enable */
convert(domain.children);
});
return domains;
}
return convert(rootState.domainsTree);
},
isReady(rootState: Record<string, any>): boolean {
return rootState.isReady;
},
searchTags(rootState: Record<string, any>): Record<string, any> {
return rootState.searchTags;
},
};
// A Vuex instance is created by combining the state, mutations, actions,
// and getters.
export default createStore({
state() {
return state;
},
getters,
actions,
mutations,
});

View File

@@ -0,0 +1,17 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
export const SET_TRANSLATIONS = 'SET_TRANSLATIONS';
export const SET_CATALOG = 'SET_CATALOG';
export const SET_DOMAINS_TREE = 'SET_DOMAINS_TREE';
export const APP_IS_READY = 'APP_IS_READY';
export const SET_TOTAL_PAGES = 'SET_TOTAL_PAGES';
export const SET_PAGE_INDEX = 'SET_PAGE_INDEX';
export const SET_CURRENT_DOMAIN = 'SET_CURRENT_DOMAIN';
export const RESET_CURRENT_DOMAIN = 'RESET_CURRENT_DOMAIN';
export const SIDEBAR_LOADING = 'SIDEBAR_LOADING';
export const PRINCIPAL_LOADING = 'PRINCIPAL_LOADING';
export const SEARCH_TAGS = 'SEARCH_TAGS';
export const DECREASE_CURRENT_DOMAIN_TOTAL_MISSING_TRANSLATIONS = 'DECREASE_CURRENT_DOMAIN_TOTAL_MISSING_TRANSLATIONS';
export const RESET_MODIFIED_TRANSLATIONS = 'RESET_MODIFIED_TRANSLATIONS';

View File

@@ -0,0 +1,56 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
/* eslint-disable no-param-reassign */
import * as types from './mutation-types';
export default {
[types.SET_TRANSLATIONS](state: Record<string, any>, translations: Record<string, any>): void {
translations.data.forEach((t: Record<string, any>) => {
state.translations[t.translation_id] = t.name;
});
},
[types.SET_CATALOG](state: Record<string, any>, catalog: Record<string, any>): void {
state.catalog = catalog;
},
[types.SET_DOMAINS_TREE](state: Record<string, any>, domainsTree: Record<string, any>): void {
state.totalMissingTranslations = domainsTree.data.tree.total_missing_translations;
state.totalTranslations = domainsTree.data.tree.total_translations;
state.domainsTree = domainsTree.data.tree.children;
},
[types.APP_IS_READY](state: Record<string, any>): void {
state.isReady = true;
},
[types.SET_TOTAL_PAGES](state: Record<string, any>, totalPages: number): void {
state.totalPages = Number(totalPages);
},
[types.SET_PAGE_INDEX](state: Record<string, any>, pageIndex: string): void {
state.pageIndex = pageIndex;
},
[types.SET_CURRENT_DOMAIN](state: Record<string, any>, currentDomain: Record<string, any>): void {
state.currentDomain = currentDomain.full_name;
state.currentDomainTotalTranslations = currentDomain.total_translations;
state.currentDomainTotalMissingTranslations = currentDomain.total_missing_translations;
},
[types.RESET_CURRENT_DOMAIN](state: Record<string, any>): void {
state.currentDomain = '';
state.currentDomainTotalTranslations = 0;
state.currentDomainTotalMissingTranslations = 0;
},
[types.SIDEBAR_LOADING](state: Record<string, any>, isLoading: boolean): void {
state.sidebarLoading = isLoading;
},
[types.PRINCIPAL_LOADING](state: Record<string, any>, isLoading: boolean): void {
state.principalLoading = isLoading;
},
[types.SEARCH_TAGS](state: Record<string, any>, searchTags: Array<Record<string, any>>): void {
state.searchTags = searchTags;
},
[types.DECREASE_CURRENT_DOMAIN_TOTAL_MISSING_TRANSLATIONS](state: Record<string, any>, successfullySaved: number): void {
state.currentDomainTotalMissingTranslations -= successfullySaved;
},
[types.RESET_MODIFIED_TRANSLATIONS](state: Record<string, any>): void {
state.modifiedTranslations = [];
},
};

View File

@@ -0,0 +1,29 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import Vue from 'vue';
import Vuex from 'vuex';
Vue.use(Vuex);
// root state object.
const state = {
};
// getters are functions
const getters = {
};
// A Vuex instance is created by combining the state, mutations, actions,
// and getters.
export default new Vuex.Store({
state,
getters,
modules: {
},
});

View File

@@ -0,0 +1,29 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
/**
* Get the correct transition keyword of the browser.
* @param {string} type - The property name (transition for example).
* @param {string} lifecycle - Which lifecycle of the property name to catch (end, start...).
* @return {string} The transition keywoard of the browser.
*/
const getAnimationEvent = (type, lifecycle) => {
const el = document.createElement('element');
const typeUpper = type.charAt(0).toUpperCase() + type.substring(1);
const lifecycleUpper = lifecycle.charAt(0).toUpperCase() + lifecycle.substring(1);
const properties = {
transition: `${type}${lifecycle}`,
OTransition: `o${typeUpper}${lifecycleUpper}`,
MozTransition: `${type}${lifecycle}`,
WebkitTransition: `webkit${typeUpper}${lifecycleUpper}`,
};
const key = Object.keys(properties).find((propKey) => el.style[propKey] !== undefined);
return key !== undefined ? properties[key] : false;
};
export default getAnimationEvent;

View File

@@ -0,0 +1,24 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import 'bootstrap-colorpicker';
const {$} = window;
/**
* Enable all colorpickers.
*/
const init = function initDatePickers() {
$('.colorpicker input[type="text"]').each((i, picker) => {
$(picker).colorpicker();
$(picker).on('colorpickerCreate', () => {
$(picker).css('background-color', $(picker).val());
});
$(picker).on('colorpickerChange', (event) => {
$(picker).css('background-color', event.color.toString());
});
});
};
export default init;

View File

@@ -0,0 +1,77 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import 'url-polyfill';
const {$} = window;
const replaceDatePicker = () => {
const datepickerWidget = $('body').find(
'.bootstrap-datetimepicker-widget:last',
);
if (datepickerWidget.length <= 0) {
return;
}
const position = datepickerWidget.offset();
const originalHeight = datepickerWidget.outerHeight();
const margin = (datepickerWidget.outerHeight(true) - originalHeight) / 2;
// Move datepicker to the exact same place it was but attached to body
datepickerWidget.appendTo('body');
// Height changed because the css from column-filters is not applied any more
let top = position.top + margin;
// Datepicker is settle to the top position
if (datepickerWidget.hasClass('top')) {
top += originalHeight - datepickerWidget.outerHeight(true) - margin;
}
datepickerWidget.css({
position: 'absolute',
top,
bottom: 'auto',
left: position.left,
right: 'auto',
});
$(window).on('resize', replaceDatePicker);
};
/**
* Enable all datepickers.
*/
const init = function initDatePickers() {
const $datePickers = $('.datepicker input[type="text"]');
$.each($datePickers, (i, picker) => {
$(picker)
.datetimepicker({
locale: window.full_language_code,
format: $(picker).data('format')
? $(picker).data('format')
: 'YYYY-MM-DD',
sideBySide: true,
icons: {
time: 'time',
date: 'date',
up: 'up',
down: 'down',
},
})
.on('dp.show', replaceDatePicker)
.on('dp.hide', () => {
$(window).off('resize', replaceDatePicker);
})
.on('dp.change', (e) => {
// Looks like we can't bind an event to a datepicker selected afterwhile.
// So we emit an event on change to manipulate datas
const event = new CustomEvent('datepickerChange', e);
window.document.dispatchEvent(event);
});
});
};
export default init;

View File

@@ -0,0 +1,23 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import punycode from 'punycode';
const {$} = window;
const init = function initEmailFields(selector) {
const $emailFields = $(selector);
$.each($emailFields, (i, field) => {
if (!field.checkValidity()) {
const parts = field.value.split('@');
// if local part is not ASCII only, chrome will not auto-convert the domain part to utf8
if (punycode.toASCII(parts[0]) === parts[0]) {
field.value = punycode.toASCII(field.value);
}
}
});
};
export default init;

View File

@@ -0,0 +1,17 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
const {$} = window;
/**
* Enable all datepickers.
*/
const initInvalidFields = () => {
$('input,select,textarea').on('invalid', function scroll() {
this.scrollIntoView(false);
});
};
export default initInvalidFields;

View File

@@ -0,0 +1,25 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
export const showGrowl = (type, message, durationTime) => {
const duration = undefined !== durationTime ? durationTime : 2000;
if (type === 'success') {
window.$.growl({
title: '',
size: 'large',
message,
duration,
});
} else {
window.$.growl[type]({
title: '',
size: 'large',
message,
duration,
});
}
};
export default showGrowl;

View File

@@ -0,0 +1,155 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
// External components
import {EventEmitter} from '@components/event-emitter';
// Core components
import ChoiceTable from '@js/components/choice-table';
import ChoiceTree from '@js/components/form/choice-tree';
import ColorPicker from '@js/app/utils/colorpicker';
import CountryDniRequiredToggler from '@components/country-dni-required-toggler';
import CountryStateSelectionToggler from '@components/country-state-selection-toggler';
import DateRange from '@js/components/form/date-range';
import DeltaQuantityInput from '@components/form/delta-quantity-input';
import DisablingSwitch from '@components/form/disabling-switch';
import GeneratableInput from '@js/components/generatable-input';
import TextWithRecommendedLengthCounter from '@components/form/text-with-recommended-length-counter';
import Grid from '@components/grid/grid';
import ModifyAllShopsCheckbox from '@components/modify-all-shops-checkbox';
import MultipleChoiceTable from '@js/components/multiple-choice-table';
import MultistoreConfigField from '@js/components/form/multistore-config-field';
import PreviewOpener from '@components/form/preview-opener';
import Router from '@components/router';
import ShopSelector from '@components/shop-selector/shop-selector';
import TaggableField from '@js/components/taggable-field';
import TextWithLengthCounter from '@components/form/text-with-length-counter';
import TinyMCEEditor from '@js/components/tinymce-editor';
import TranslatableField from '@js/components/translatable-field';
import TranslatableInput from '@js/components/translatable-input';
import EntitySearchInput from '@js/components/entity-search-input';
import MultipleZoneChoice from '@js/components/form/multiple-zone-choice';
import ToggleChildrenChoice from '@js/components/form/toggle-children-choice';
import FilterLinkGroup from '@components/filter/filter-link-group';
// Grid extensions
import AsyncToggleColumnExtension from '@components/grid/extension/column/common/async-toggle-column-extension';
import BulkActionCheckboxExtension from '@components/grid/extension/bulk-action-checkbox-extension';
import BulkOpenTabsExtension from '@components/grid/extension/bulk-open-tabs-extension';
import ChoiceExtension from '@components/grid/extension/choice-extension';
import ColumnTogglingExtension from '@components/grid/extension/column-toggling-extension';
import ExportToSqlManagerExtension from '@components/grid/extension/export-to-sql-manager-extension';
import FiltersResetExtension from '@components/grid/extension/filters-reset-extension';
import FiltersSubmitButtonEnablerExtension from '@components/grid/extension/filters-submit-button-enabler-extension';
import IframeClient from '@components/modal/iframe-client';
import LinkRowActionExtension from '@components/grid/extension/link-row-action-extension';
import ModalFormSubmitExtension from '@components/grid/extension/modal-form-submit-extension';
import PositionExtension from '@components/grid/extension/position-extension';
import PreviewExtension from '@components/grid/extension/preview-extension';
import ReloadListExtension from '@components/grid/extension/reload-list-extension';
import SortingExtension from '@components/grid/extension/sorting-extension';
import SubmitBulkActionExtension from '@components/grid/extension/submit-bulk-action-extension';
import AjaxBulkActionExtension from '@components/grid/extension/ajax-bulk-action-extension';
import SubmitGridActionExtension from '@components/grid/extension/submit-grid-action-extension';
import SubmitRowActionExtension from '@components/grid/extension/action/row/submit-row-action-extension';
import FormFieldToggler from '@components/form/form-field-toggler';
import EmailInput from '@components/email-input';
const GridExtensions = {
AjaxBulkActionExtension,
AsyncToggleColumnExtension,
BulkActionCheckboxExtension,
BulkOpenTabsExtension,
ChoiceExtension,
ColumnTogglingExtension,
ExportToSqlManagerExtension,
FilterLinkGroup,
FiltersResetExtension,
FiltersSubmitButtonEnablerExtension,
LinkRowActionExtension,
ModalFormSubmitExtension,
PositionExtension,
PreviewExtension,
ReloadListExtension,
SortingExtension,
SubmitBulkActionExtension,
SubmitGridActionExtension,
SubmitRowActionExtension,
};
const initPrestashopComponents = (): void => {
window.prestashop = {...window.prestashop};
if (!window.prestashop.instance) {
window.prestashop.instance = {};
}
window.prestashop.component = {
initComponents(components: string[]) {
components.forEach((component: string): void => {
if (window.prestashop.component[component] === undefined) {
console.error(`Failed to initialize PrestaShop component "${component}". This component doesn't exist.`);
return;
}
const componentInstanceName = component.charAt(0).toLowerCase() + component.slice(1);
if (window.prestashop.instance[componentInstanceName] !== undefined) {
console.warn(
`Failed to initialize PrestaShop component "${component}". This component is already initialized.`,
);
return;
}
// EventEmitter is a special case it has no constructor and could be used via
// window.prestashop.component.EventEmitter straight away
if (component === 'EventEmitter') {
window.prestashop.instance[componentInstanceName] = window.prestashop.component[component];
return;
}
window.prestashop.instance[componentInstanceName] = new window.prestashop.component[component]();
});
// Send an event so external users can initiate their own components
EventEmitter.emit('PSComponentsInitiated');
},
// @todo: add all standard components in this list
ChoiceTable,
ChoiceTree,
ColorPicker,
CountryDniRequiredToggler,
CountryStateSelectionToggler,
DeltaQuantityInput,
DisablingSwitch,
EventEmitter,
FormFieldToggler,
GeneratableInput,
DateRange,
Grid,
GridExtensions,
IframeClient,
ModifyAllShopsCheckbox,
MultipleChoiceTable,
MultistoreConfigField,
PreviewOpener,
Router,
TextWithRecommendedLengthCounter,
ShopSelector,
TaggableField,
TextWithLengthCounter,
TinyMCEEditor,
TranslatableField,
TranslatableInput,
EntitySearchInput,
EmailInput,
MultipleZoneChoice,
ToggleChildrenChoice,
};
};
export default initPrestashopComponents;

View File

@@ -0,0 +1,58 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
const findAllUnwantedCharsExceptTheLatestOne = /(?:(?!^-\d+))[^\d]+(?=.*[^\d])/g;
const findAllUnwantedChars = /(?:(?!^-\d+))([^\d]+)/g;
/**
* If there is a dot in the string
* split the string at the first dot, and
* replace all unwanted characters.
* Otherwise, replace all unwanted characters expect the
* latest one, and replace the latest character
* by a dot.
*/
export const transform = (value) => {
let val = value;
const unwantedChars = val.match(findAllUnwantedChars);
if (unwantedChars === null) {
return val;
}
if (unwantedChars.length > 1) {
const unwantedCharsSet = new Set(unwantedChars);
const unique = Array.from(unwantedCharsSet);
if (unique.length === 1) {
return val.replace(findAllUnwantedChars, '');
}
}
val = val
.replace(findAllUnwantedCharsExceptTheLatestOne, '')
.replace(findAllUnwantedChars, '.');
return val;
};
const clearNumberInputValue = (event, selector) => {
if (!event.target.matches(selector)) {
return;
}
const {value} = event.target;
event.target.value = transform(value);
};
export default (selector) => {
document.addEventListener(
'change',
(event) => {
clearNumberInputValue(event, selector);
},
true,
);
};

View File

@@ -0,0 +1,16 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
/**
* Send a Post Request to reset search Action.
*/
const {$} = window;
const init = function resetSearch(url, redirectUrl) {
$.post(url).then(() => window.location.assign(redirectUrl));
};
export default init;

View File

@@ -0,0 +1,177 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {createApp} from 'vue';
import {EventEmitter} from '@components/event-emitter';
import serp from './serp.vue';
const {$} = window;
/**
* Vue component displaying a search page result, Google style.
* Requires a tag with the id "#serp-app" to be present in the DOM to run it.
* The component is automatically updated by watching several inputs.
* Set the proper class to link a input to a part of the panel.
*/
class SerpApp {
constructor(selectors, url) {
// If the selector cannot be found, we do not load the Vue app
if ($(selectors.container).length === 0) {
return;
}
this.originalUrl = url;
this.selectors = selectors;
this.useMultiLang = selectors.multiLanguageInput !== undefined || selectors.multiLanguageField !== undefined;
if (this.useMultiLang) {
const possibleSelectors = [];
if (selectors.multiLanguageInput) {
possibleSelectors.push(selectors.multiLanguageInput);
}
if (selectors.multiLanguageField) {
possibleSelectors.push(selectors.multiLanguageField);
}
this.multiLangSelector = possibleSelectors.join(',');
this.attachMultiLangEvents();
}
this.data = {
url,
title: '',
description: '',
};
this.appendTitle = selectors.appendTitle ? $(selectors.appendTitle).val() : '';
this.initializeSelectors(selectors);
this.attachInputEvents();
}
updateComponent() {
if (this.vm) {
this.vm.unmount();
}
this.vm = createApp({
template: '<serp ref="serp" :url="url" :title="title" :description="description" />',
components: {serp},
data: () => this.data,
});
this.vm.mount(this.selectors.container);
}
attachMultiLangEvents(itemSelector) {
$('body').on(
'click',
itemSelector,
() => {
this.checkTitle();
this.checkDesc();
this.checkUrl();
},
);
EventEmitter.on('languageSelected', () => {
this.checkTitle();
this.checkDesc();
this.checkUrl();
});
}
initializeSelectors(selectors) {
this.defaultTitle = $(selectors.defaultTitle);
this.watchedTitle = $(selectors.watchedTitle);
this.defaultDescription = $(selectors.defaultDescription);
this.watchedDescription = $(selectors.watchedDescription);
this.watchedMetaUrl = $(selectors.watchedMetaUrl);
}
attachInputEvents() {
$(this.defaultTitle).on('keyup change', () => this.checkTitle());
$(this.watchedTitle).on('keyup change', () => this.checkTitle());
$(this.defaultDescription).on('keyup change', () => this.checkDesc());
$(this.watchedDescription).on('keyup change', () => this.checkDesc());
this.watchedMetaUrl.on('keyup change', () => this.checkUrl());
this.checkTitle();
this.checkDesc();
this.checkUrl();
}
setTitle(title) {
this.data.title = title;
}
setDescription(description) {
this.data.description = description;
}
setUrl(rewrite) {
// We replace two placeholders because there was a typo in the initial one ('friendy' instead of 'friendly')
this.data.url = this.originalUrl.replace(
'{friendy-url}',
rewrite,
);
this.data.url = this.data.url.replace(
'{friendly-url}',
rewrite,
);
}
checkTitle() {
let {defaultTitle} = this;
let {watchedTitle} = this;
if (this.useMultiLang) {
watchedTitle = watchedTitle.closest(this.multiLangSelector).find('input');
defaultTitle = defaultTitle.closest(this.multiLangSelector).find('input');
}
const title1 = watchedTitle.length ? watchedTitle.val() : '';
const title2 = defaultTitle.length ? defaultTitle.val() : '';
this.setTitle(`${title1 === '' ? title2 : title1}${this.appendTitle ? ` ${this.appendTitle}` : ''}`);
// Always check for url if title change
this.checkUrl();
this.updateComponent();
}
checkDesc() {
let {watchedDescription} = this;
let {defaultDescription} = this;
if (this.useMultiLang) {
const watchedDescriptionTarget = watchedDescription
.closest(this.multiLangSelector)
.find(this.watchedDescription.is('input') ? 'input' : 'textarea');
watchedDescription = watchedDescriptionTarget.length ? watchedDescriptionTarget : watchedDescription;
const defaultDescriptionTarget = defaultDescription
.closest(this.multiLangSelector)
.find(this.defaultDescription.is('input') ? 'input' : 'textarea');
defaultDescription = defaultDescriptionTarget.length ? defaultDescriptionTarget : defaultDescription;
}
const desc1 = watchedDescription.length ? watchedDescription.val().innerText || watchedDescription.val() : '';
const desc2 = defaultDescription.length ? defaultDescription.text() : '';
this.setDescription(desc1 === '' ? desc2 : desc1);
this.updateComponent();
}
checkUrl() {
let {watchedMetaUrl} = this;
if (this.useMultiLang) {
watchedMetaUrl = watchedMetaUrl.closest(this.multiLangSelector).find('input');
}
this.setUrl(watchedMetaUrl.val());
this.updateComponent();
}
}
export default SerpApp;

View File

@@ -0,0 +1,151 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div id="serp">
<div class="serp-preview">
<div class="serp-url">
<span class="serp-base-url">{{ displayedBaseURL }}</span>
{{ displayedRelativePath }}
<i class="material-icons serp-url-more">more_vert</i>
</div>
<div class="serp-title">
{{ displayedTitle }}
</div>
<div class="serp-description">
{{ displayedDescription }}
</div>
</div>
</div>
</template>
<script>
import {defineComponent} from 'vue';
export default defineComponent({
name: 'Serp',
props: {
url: {
type: String,
default: 'https://www.example.com/',
},
description: {
type: String,
default: '',
},
title: {
type: String,
default: '',
},
},
computed: {
displayedBaseURL() {
const parseUrl = new URL(this.url);
const baseUrl = `${parseUrl.protocol}//${parseUrl.hostname}`;
return baseUrl;
},
displayedRelativePath() {
const parseUrl = new URL(this.url);
const relativePath = decodeURI(parseUrl.pathname).replaceAll('/', ' \u203a ');
if (relativePath.length > 50) {
return `${relativePath.substring(0, 50)}...`;
}
return relativePath;
},
displayedTitle() {
if (this.title.length > 70) {
return `${this.title.substring(0, 70)}...`;
}
return this.title;
},
displayedDescription() {
const plainTextDescription = this.stripHtml(this.description);
if (plainTextDescription.length > 150) {
return `${plainTextDescription.substring(0, 150)}...`;
}
return plainTextDescription;
},
},
methods: {
stripHtml(html) {
const div = document.createElement('div');
div.innerHTML = html;
return div.textContent || '';
},
},
});
</script>
<style lang="scss" type="text/scss" scoped>
@import "~@scss/config/bootstrap.scss";
@import "~@scss/config/settings.scss";
.serp-preview {
padding: var(--#{$cdk}size-24) var(--#{$cdk}size-30);
margin: var(--#{$cdk}size-16) 0;
background-color: var(--#{$cdk}white);
border: 1px solid var(--#{$cdk}primary-400);
box-shadow: var(--#{$cdk}box-shadow-default);
.serp-url {
font-family: arial, sans-serif;
font-size: var(--#{$cdk}size-12);
font-style: normal;
font-weight: 400;
line-height: var(--#{$cdk}size-18);
color: $serp-url-light-color;
text-align: left;
direction: ltr;
cursor: pointer;
visibility: visible;
display: flex;
align-items: center;
}
.serp-base-url {
color: $serp-url-dark-color;
}
.serp-url-more {
margin-left: var(--#{$cdk}size-12);
font-size: var(--#{$cdk}size-18);
color: $serp-url-light-color;
cursor: pointer;
}
.serp-title {
font-family: arial, sans-serif;
font-size: 1.25rem;
font-weight: 400;
color: $serp-title-color;
text-align: left;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
visibility: visible;
text-overflow: ellipsis;
overflow: hidden;
}
.serp-title:hover {
text-decoration: underline;
}
.serp-description {
font-family: arial, sans-serif;
font-size: 0.875rem;
font-weight: 400;
color: $serp-description-color;
text-align: left;
word-wrap: break-word;
visibility: visible;
}
}
</style>

View File

@@ -0,0 +1,65 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
const {$} = window;
/**
* Allow to display the last SQL query in a modal and redirect to SQL Manager.
*/
class SqlManager {
showLastSqlQuery() {
$('#catalog_sql_query_modal_content textarea[name="sql"]').val($('tbody.sql-manager').data('query'));
$('#catalog_sql_query_modal .btn-sql-submit').on('click', () => {
$('#catalog_sql_query_modal_content').submit();
});
$('#catalog_sql_query_modal').modal('show');
}
sendLastSqlQuery(name) {
$('#catalog_sql_query_modal_content textarea[name="sql"]').val($('tbody.sql-manager').data('query'));
$('#catalog_sql_query_modal_content input[name="name"]').val(name);
$('#catalog_sql_query_modal_content').submit();
}
createSqlQueryName() {
let container = false;
let current = false;
if ($('.breadcrumb')) {
container = $('.breadcrumb li').eq(0).text().replace(/\s+/g, ' ')
.trim();
current = $('.breadcrumb li').eq(-1).text().replace(/\s+/g, ' ')
.trim();
}
let title = false;
if ($('h2.title')) {
title = $('h2.title').first().text().replace(/\s+/g, ' ')
.trim();
}
let name = false;
if (container && current && container !== current) {
name = `${container} > ${current}`;
} else if (container) {
name = container;
} else if (current) {
name = current;
}
if (title && title !== current && title !== container) {
if (name) {
name = `${name} > ${title}`;
} else {
name = title;
}
}
return name.trim();
}
}
export default SqlManager;

View File

@@ -0,0 +1,102 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
const {$} = window;
/**
* Makes a table sortable by columns.
* This forces a page reload with more query parameters.
*/
class TableSorting {
selector: string;
idTable: string;
columns: JQuery;
/**
* @param {jQuery} table
*/
constructor(table: JQuery) {
this.selector = '.ps-sortable-column';
this.idTable = table.attr('id') ?? '';
this.columns = table.find(this.selector);
}
/**
* Attaches the listeners
*/
attach(): void {
this.columns.on('click', (e) => {
const $column = $(e.delegateTarget);
this.sortByColumn($column, this.getToggledSortDirection($column));
});
}
/**
* Sort using a column name
* @param {string} columnName
* @param {string} direction "asc" or "desc"
*/
sortBy(columnName: string, direction: string): void {
const $column = this.columns.is(`[data-sort-col-name="${columnName}"]`);
if (!$column) {
throw new Error(`Cannot sort by "${columnName}": invalid column`);
}
this.sortByColumn(this.columns, direction);
}
/**
* Sort using a column element
* @param {jQuery} column
* @param {string} direction "asc" or "desc"
* @private
*/
private sortByColumn(column: JQuery, direction: string): void {
window.location.href = this.getUrl(
column.data('sortColName'),
direction === 'desc' ? 'desc' : 'asc',
column.data('sortPrefix'),
);
}
/**
* Returns the inverted direction to sort according to the column's current one
* @param {jQuery} column
* @return {string}
* @private
*/
private getToggledSortDirection(column: JQuery): string {
return column.data('sortDirection') === 'asc' ? 'desc' : 'asc';
}
/**
* Returns the url for the sorted table
* @param {string} colName
* @param {string} direction
* @param {string} prefix
* @return {string}
* @private
*/
private getUrl(colName: string, direction: string, prefix: string): string {
const url = new URL(window.location.href);
const params = url.searchParams;
if (prefix) {
params.set(`${prefix}[orderBy]`, colName);
params.set(`${prefix}[sortOrder]`, direction);
} else {
params.set('orderBy', colName);
params.set('sortOrder', direction);
}
url.hash = this.idTable;
return url.toString();
}
}
export default TableSorting;

View File

@@ -0,0 +1,59 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
// Mimic Symfony debug toolbar getPreference function to get the toolbar state
const profilerStorageKey = 'symfony/profiler/';
const getPreference = (name: string): string | null => {
if (!window.localStorage) {
return null;
}
return localStorage.getItem(profilerStorageKey + name);
};
const refreshDelay = 100;
const waitForDebugContent = (debugToken: string): void => {
// Wait until the toolbar content is present on page
const debugBarContentId = `sfToolbarMainContent-${debugToken}`;
const toolbar = document.getElementById(debugBarContentId);
if (toolbar) {
initToggleWatching(debugToken);
} else {
setTimeout(() => waitForDebugContent(debugToken), refreshDelay);
}
};
const initToggleWatching = (debugToken: string): void => {
document.getElementById(`sfToolbarMiniToggler-${debugToken}`)?.addEventListener('click', toggleDebugMode);
document.getElementById(`sfToolbarHideButton-${debugToken}`)?.addEventListener('click', toggleDebugMode);
toggleDebugMode();
};
const toggleDebugMode = (): void => {
if (getPreference('toolbar/displayState') === 'none') {
document.body.classList.add('debug-toolbar-hidden');
document.body.classList.remove('debug-toolbar-shown');
} else {
// Alternative is block (set as shown) or null (default setting is shown)
document.body.classList.add('debug-toolbar-shown');
document.body.classList.remove('debug-toolbar-hidden');
}
};
const watchSymfonyDebugBar = (): void => {
const debugToolbar = document.querySelector<HTMLElement>('[id^=sfwdt]');
if (!debugToolbar) {
// If initial container is not present the debug toolbar will never be displayed, so nothing to do
return;
}
const debugToken = debugToolbar.id.replace(/^sfwdt/, '');
waitForDebugContent(debugToken);
};
export default watchSymfonyDebugBar;

View File

@@ -0,0 +1,75 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div
class="ps-alert alert"
:class="classObject"
role="alert"
>
<button
v-if="hasClose"
type="button"
class="close"
data-dismiss="alert"
aria-label="Close"
@click.stop="onClick"
>
<span class="material-icons">close</span>
</button>
<p class="alert-text">
<slot />
</p>
</div>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
const ALERT_TYPE_INFO = 'ALERT_TYPE_INFO';
const ALERT_TYPE_WARNING = 'ALERT_TYPE_WARNING';
const ALERT_TYPE_DANGER = 'ALERT_TYPE_DANGER';
const ALERT_TYPE_SUCCESS = 'ALERT_TYPE_SUCCESS';
export default defineComponent({
props: {
duration: {
type: Boolean,
required: false,
default: false,
},
alertType: {
type: String,
required: true,
},
hasClose: {
type: Boolean,
required: true,
},
},
computed: {
classObject(): {
'alert-info': boolean,
'alert-warning': boolean,
'alert-danger': boolean,
'alert-success': boolean
} {
return {
'alert-info': this.alertType === ALERT_TYPE_INFO,
'alert-warning': this.alertType === ALERT_TYPE_WARNING,
'alert-danger': this.alertType === ALERT_TYPE_DANGER,
'alert-success': this.alertType === ALERT_TYPE_SUCCESS,
};
},
isInfo(): boolean {
return this.alertType === ALERT_TYPE_INFO;
},
},
methods: {
onClick(): void {
this.$emit('closeAlert');
},
},
});
</script>

View File

@@ -0,0 +1,44 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<button
type="button"
class="btn"
:class="classObject"
>
<slot />
</button>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
export default defineComponent({
props: {
primary: {type: Boolean},
ghost: {type: Boolean},
},
computed: {
classObject(): {
'btn-outline-primary'?: boolean,
'btn-outline-secondary'?: boolean,
'btn-primary'?: boolean,
'btn-secondary'?: boolean
} {
if (this.ghost) {
return {
'btn-outline-primary': this.primary,
'btn-outline-secondary': !this.primary,
};
}
return {
'btn-primary': this.primary,
'btn-secondary': !this.primary,
};
},
},
});
</script>

View File

@@ -0,0 +1,58 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div class="md-checkbox">
<label>
<input
type="checkbox"
:id="id"
v-model="checked"
:class="{'indeterminate' : isIndeterminate }"
>
<i class="md-checkbox-control" />
<slot name="label" />
</label>
</div>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
/**
* @deprecated since 8.0, use app/components/checkbox.vue instead
*/
export default defineComponent({
props: {
id: {
type: String,
required: false,
default: '',
},
model: {
type: Object,
required: false,
default: () => ({}),
},
isIndeterminate: {
type: Boolean,
required: false,
default: false,
},
},
watch: {
checked(val: boolean): void {
this.$emit('checked', {
checked: val,
item: this.model,
});
},
},
data() {
return {
checked: false,
};
},
});
</script>

View File

@@ -0,0 +1,71 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div class="input-group date">
<input
ref="datepicker"
type="text"
:class="['form-control', `datepicker-${type}`]"
>
<div class="input-group-append">
<span class="input-group-text">
<i class="material-icons">event</i>
</span>
</div>
</div>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
export default defineComponent({
props: {
locale: {
type: String,
required: true,
default: 'en',
},
type: {
type: String,
required: true,
},
},
mounted() {
$(<HTMLInputElement> this.$refs.datepicker).datetimepicker({
format: 'YYYY-MM-DD',
showClear: true,
useCurrent: false,
}).on('dp.change', (infos: Record<string, any>) => {
infos.dateType = this.type;
this.$emit(
infos.date ? 'dpChange' : 'reset',
infos,
);
});
},
});
</script>
<style lang="scss">
@import '~@scss/config/_settings.scss';
.date {
a[data-action='clear']::before {
font-family: var(--#{$cdk}font-family-material-icons);
content: "\E14C";
font-size: var(--#{$cdk}size-20);
position: absolute;
bottom: var(--#{$cdk}size-16);
left: 50%;
margin-left: calc(-1 * var(--#{$cdk}size-10));
color: var(--#{$cdk}primary-800);
cursor: pointer;
}
.bootstrap-datetimepicker-widget tr td span:hover {
background-color: var(--#{$cdk}white);
}
}
</style>

View File

@@ -0,0 +1,56 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div class="ps-loader">
<div class="timeline-item">
<div class="animated-background">
<slot />
</div>
</div>
</div>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
export default defineComponent({
});
</script>
<style lang="scss" scoped>
@import '~@scss/config/_settings.scss';
.ps-loader {
width: 100%;
.animated-background {
animation-duration: 1s;
animation-iteration-count: infinite;
animation-name: loading;
animation-timing-function: linear;
background: var(--#{$cdk}primary-200);
background:
linear-gradient(to right, var(--#{$cdk}primary-200) 10%,
var(--#{$cdk}primary-500) 20%,
var(--#{$cdk}primary-200) 30%);
background-size: 100%;
height: var(--#{$cdk}size-40);
position: relative;
}
.background-masker {
background: var(--#{$cdk}white);
position: absolute;
}
}
@keyframes loading{
0%{
background-position: -500px 0
}
100%{
background-position: 500px 0
}
}
</style>

View File

@@ -0,0 +1,63 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div class="media">
<img
v-if="displayThumb"
:src="thumbnail"
class="thumbnail d-flex"
>
<div
v-else
class="no-img"
/>
<div class="ml-2 desc media-body">
<slot />
</div>
</div>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
export default defineComponent({
props: {
thumbnail: {
type: String,
required: false,
default: '',
},
},
computed: {
displayThumb(): boolean {
return this.thumbnail === '' ? false : !!this.thumbnail;
},
},
});
</script>
<style lang="scss" scoped>
@import '~@scss/config/_settings.scss';
.product-title {
.has-combination & {
font-weight: 600;
}
}
.thumbnail, .no-img {
border: 1px solid var(--#{$cdk}primary-400);
max-width: var(--#{$cdk}size-44);
}
.no-img {
background: var(--#{$cdk}white);
width: var(--#{$cdk}size-44);
height: var(--#{$cdk}size-44);
display: inline-block;
vertical-align: middle;
}
.desc {
white-space: normal;
}
</style>

View File

@@ -0,0 +1,106 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div
class="modal fade"
id="ps-modal"
tabindex="-1"
role="dialog"
>
<div
class="modal-dialog"
role="document"
>
<div class="modal-content">
<div class="modal-header">
<button
type="button"
class="close"
data-dismiss="modal"
>
<i class="material-icons">close</i>
</button>
<h4 class="modal-title">
{{ translations.modal_title }}
</h4>
</div>
<div class="modal-body">
{{ translations.modal_content }}
</div>
<div class="modal-footer">
<PSButton
@click="onSave"
class="btn-lg"
primary
data-dismiss="modal"
>
{{ translations.button_save }}
</PSButton>
<PSButton
@click="onLeave"
class="btn-lg"
ghost
data-dismiss="modal"
>
{{ translations.button_leave }}
</PSButton>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import PSButton from '@app/widgets/ps-button.vue';
import {EventEmitter} from '@components/event-emitter';
import {defineComponent} from 'vue';
export default defineComponent({
props: {
translations: {
type: Object,
required: false,
default: () => ({}),
},
},
mounted() {
EventEmitter.on('showModal', () => {
this.showModal();
});
EventEmitter.on('hideModal', () => {
this.hideModal();
});
},
methods: {
showModal(): void {
$(this.$el).modal('show');
},
hideModal(): void {
$(this.$el).modal('hide');
},
onSave(): void {
this.$emit('save');
},
onLeave(): void {
this.$emit('leave');
},
},
components: {
PSButton,
},
});
</script>
<style lang="scss" scoped>
@import '~@scss/config/_settings.scss';
.modal-header .close {
font-size: var(--#{$cdk}size-20);
opacity: 1;
}
.modal-content {
border-radius: 0
}
</style>

View File

@@ -0,0 +1,88 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div
class="ps-number"
:class="{ 'hover-buttons': hoverButtons }"
>
<input
type="number"
class="form-control"
:class="{ danger }"
:value="value"
placeholder="0"
@keyup="onKeyup($event)"
@keydown="onKeydown($event)"
@focus="focusIn($event)"
@blur="focusOut($event)"
>
<div
class="ps-number-spinner d-flex"
v-if="buttons"
>
<span
class="ps-number-up"
@click="increment($event)"
/>
<span
class="ps-number-down"
@click="decrement($event)"
/>
</div>
</div>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
export default defineComponent({
props: {
value: {
type: [Number, String],
default: 0,
},
danger: {
type: Boolean,
default: false,
},
buttons: {
type: Boolean,
default: false,
},
hoverButtons: {
type: Boolean,
default: false,
},
},
methods: {
getValue(): number {
const value = Number.isNaN(this.value) ? 0 : Number.parseInt(<string> this.value, 10);
return Number.isNaN(value) ? 0 : value;
},
onKeyup($event: Event): void {
this.$emit('keyup', $event);
},
onKeydown($event: Event): void {
this.$emit('keydown', $event);
},
focusIn($event: Event): void {
this.$emit('focus', $event);
},
focusOut($event: Event): void {
this.$emit('blur', $event);
},
increment($event: Event) {
(<HTMLInputElement>$event.target).value = `${this.getValue() + 1}`;
this.$emit('change', $event);
},
decrement($event: Event): void {
(<HTMLInputElement>$event.target).value = `${this.getValue() - 1}`;
this.$emit('change', $event);
},
},
});
</script>

View File

@@ -0,0 +1,153 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<nav
class="mt-1 mx-auto"
v-if="displayPagination"
>
<ul
class="pagination"
:class="{'multi':isMultiPagination}"
>
<li
v-if="isMultiPagination"
class="page-item previous"
>
<a
v-show="activeLeftArrow"
class="float-left page-link"
@click="prev()"
href="#"
>
<span class="sr-only">Previous</span>
</a>
</li>
<li
class="page-item"
:class="{'active' : checkCurrentIndex(index)}"
v-for="index in pagesCount"
:key="index"
>
<a
v-if="showIndex(index)"
class="page-link"
:class="{
'pl-0' : showFirstDots(index),
'pr-0' : showLastDots(index)
}"
@click.prevent="changePage(index)"
href="#"
>
<span
v-if="isMultiPagination"
v-show="showFirstDots(index)"
>...</span>
{{ index }}
<span
v-if="isMultiPagination"
v-show="showLastDots(index)"
>...</span>
</a>
</li>
<li
v-if="isMultiPagination"
class="page-item next"
>
<a
v-show="activeRightArrow"
class="float-left page-link"
@click="next()"
href="#"
>
<span class="sr-only">Next</span>
</a>
</li>
</ul>
</nav>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
export default defineComponent({
props: {
pagesCount: {
type: Number,
required: true,
},
currentIndex: {
type: Number,
required: true,
},
},
computed: {
isMultiPagination(): boolean {
return this.pagesCount > this.multiPagesActivationLimit;
},
activeLeftArrow(): boolean {
return this.currentIndex !== 1;
},
activeRightArrow(): boolean {
return this.currentIndex !== this.pagesCount;
},
pagesToDisplay(): number {
return this.multiPagesToDisplay;
},
displayPagination(): boolean {
return this.pagesCount > 1;
},
},
methods: {
checkCurrentIndex(index: number): boolean {
return this.currentIndex === index;
},
showIndex(index: number): boolean {
const startPaginationIndex = index < this.currentIndex + this.multiPagesToDisplay;
const lastPaginationIndex = index > this.currentIndex - this.multiPagesToDisplay;
const indexToDisplay = startPaginationIndex && lastPaginationIndex;
const lastIndex = index === this.pagesCount;
const firstIndex = index === 1;
if (!this.isMultiPagination) {
return !this.isMultiPagination;
}
return indexToDisplay || firstIndex || lastIndex;
},
changePage(pageIndex: number): void {
this.$emit('pageChanged', pageIndex);
},
showFirstDots(index: number): boolean {
const pagesToDisplay = this.pagesCount - this.multiPagesToDisplay;
if (!this.isMultiPagination) {
return this.isMultiPagination;
}
return index === this.pagesCount && this.currentIndex <= pagesToDisplay;
},
showLastDots(index: number): boolean {
if (!this.isMultiPagination) {
return this.isMultiPagination;
}
return index === 1 && this.currentIndex > this.multiPagesToDisplay;
},
prev(): void {
if (this.currentIndex > 1) {
this.changePage(this.currentIndex - 1);
}
},
next(): void {
if (this.currentIndex < this.pagesCount) {
this.changePage(this.currentIndex + 1);
}
},
},
data() {
return {
multiPagesToDisplay: 2,
multiPagesActivationLimit: 5,
};
},
});
</script>

View File

@@ -0,0 +1,49 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div class="ps-radio">
<input
type="radio"
:id="id"
name="radio-group"
:checked="checked"
@change="onChange"
>
<label :for="id">{{ label }}</label>
</div>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
export default defineComponent({
props: {
id: {
type: String,
required: true,
},
label: {
type: String,
required: false,
default: '',
},
checked: {
type: Boolean,
required: false,
},
value: {
type: String,
required: false,
default: '',
},
},
methods: {
onChange(): void {
this.$emit('change', this.value);
},
},
});
</script>

View File

@@ -0,0 +1,87 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div
class="ps-select"
:id="itemId"
>
<select
class="form-control"
v-model="selected"
@change="onChange"
>
<option
value="default"
selected
>
<slot />
</option>
<option
v-for="(item, index) in items"
:key="index"
:value="item[itemId]"
>
{{ item[itemName] }}
</option>
</select>
</div>
</template>
<script lang="ts">
import {defineComponent, PropType} from 'vue';
export default defineComponent({
props: {
items: {
type: Array as PropType<Array<Record<string, any>>>,
required: true,
},
itemId: {
type: String,
required: false,
default: '',
},
itemName: {
type: String,
required: false,
default: '',
},
},
methods: {
onChange(): void {
this.$emit('change', {
value: this.selected,
itemId: this.itemId,
});
},
},
data() {
return {
selected: 'default',
};
},
});
</script>
<style lang="scss" scoped>
@import '~@scss/config/_settings.scss';
.ps-select {
position: relative;
select {
appearance: none;
border-radius: 0;
}
&::after {
content: "\E313";
font-family: var(--#{$cdk}font-family-material-icons);
color: var(--#{$cdk}primary-400);
font-size: var(--#{$cdk}size-20);
position: absolute;
right: var(--#{$cdk}size-5);
top: var(--#{$cdk}size-5);
}
}
</style>

View File

@@ -0,0 +1,14 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div class="ps-spinner" />
</template>
<script lang="ts">
import {defineComponent} from 'vue';
export default defineComponent({
});
</script>

View File

@@ -0,0 +1,56 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div
class="ps-sortable-column"
:data-sort-col-name="this.order"
:data-sort-is-current="isCurrent"
:data-sort-direction="sortDirection"
@click="sortToggle"
>
<span role="columnheader"><slot /></span>
<span
role="button"
class="ps-sort"
aria-label="Tri"
/>
</div>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
export default defineComponent({
props: {
// column name
order: {
type: String,
required: true,
},
// indicates the currently sorted column in the table
currentSort: {
type: String,
required: true,
},
},
methods: {
sortToggle(): void {
// toggle direction
this.sortDirection = (this.sortDirection === 'asc') ? 'desc' : 'asc';
this.$emit('sort', this.order, this.sortDirection);
},
},
data() {
return {
sortDirection: 'desc',
};
},
computed: {
isCurrent(): boolean {
return this.currentSort === this.order;
},
},
});
</script>

View File

@@ -0,0 +1,18 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div class="table-responsive">
<table class="table">
<slot />
</table>
</div>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
export default defineComponent({
});
</script>

View File

@@ -0,0 +1,93 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div
class="tags-input search-input search d-flex flex-wrap"
:class="{ 'search-with-icon': hasIcon }"
@click="focus()"
>
<div class="tags-wrapper">
<span
v-for="(tag, index) in tags"
:key="index"
class="tag"
>{{ tag }}<i
class="material-icons"
@click="close(index)"
>close</i></span>
</div>
<input
ref="tags"
:placeholder="placeholderToDisplay"
type="text"
v-model="tag"
class="form-control input"
@keyup="onKeyUp"
@keydown.enter="add(tag)"
@keydown.delete.stop="remove()"
:size="inputSize"
>
</div>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
export default defineComponent({
props: {
tags: {
type: Array,
required: false,
default: () => ([]),
},
placeholder: {
type: String,
required: false,
default: '',
},
hasIcon: {
type: Boolean,
required: false,
},
},
computed: {
inputSize(): number {
return !this.tags.length && this.placeholder ? this.placeholder.length : 0;
},
placeholderToDisplay(): string {
return this.tags.length ? '' : this.placeholder;
},
},
methods: {
onKeyUp() {
this.$emit('typing', (<VTagsInput> this.$refs.tags).value);
},
add(tag: string): void {
if (tag) {
this.tags.push(tag.trim());
this.tag = '';
this.focus();
this.$emit('tagChange', this.tag);
}
},
close(index: number): void {
const tagName = this.tags[index];
this.tags.splice(index, 1);
this.$emit('tagChange', tagName);
},
remove(): void {
if (this.tags && this.tags.length && !this.tag.length) {
const tagName = this.tags[this.tags.length - 1];
this.tags.pop();
this.$emit('tagChange', tagName);
}
},
focus(): void {
(<HTMLInputElement> this.$refs.tags).focus();
},
},
data: () => ({tag: ''}),
});
</script>

View File

@@ -0,0 +1,197 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div
class="ps-tree-items"
:class="{className}"
>
<div
class="d-flex tree-name"
:class="{active: active, disable: model.disable}"
@click="clickElement"
>
<button
class="btn btn-text"
:class="[{hidden: isHidden}, chevronStatus]"
>
<span
v-if="translations"
class="sr-only"
>{{ model.open ? translations.reduce : translations.expand }}</span>
</button>
<PSCheckbox
:ref="model.name"
:id="id.toString()"
:model="model"
@checked="onCheck"
v-if="hasCheckbox"
/>
<span
class="tree-label"
:class="{warning: isWarning}"
>{{ model.name }}</span>
<span
class="tree-extra-label d-sm-none d-xl-inline-block"
v-if="displayExtraLabel"
>{{ getExtraLabel }}</span>
<span
class="tree-extra-label-mini d-xl-none"
v-if="displayExtraLabel"
>{{ model.extraLabel }}</span>
</div>
<ul
v-show="open"
v-if="isFolder"
class="tree"
>
<li
v-for="(element, index) in model.children"
:key="index"
class="tree-item"
:class="{disable: model.disable}"
>
<PSTreeItem
:ref="element.id"
:class="className"
:has-checkbox="hasCheckbox"
:model="element"
:label="element.name"
:translations="translations"
:current-item="currentItem"
@checked="onCheck"
@setCurrentElement="setCurrentElement"
/>
</li>
</ul>
</div>
</template>
<script lang="ts">
import PSCheckbox from '@app/widgets/ps-checkbox.vue';
import {EventEmitter} from '@components/event-emitter';
import {defineComponent} from 'vue';
export default defineComponent({
name: 'PSTreeItem',
props: {
model: {
type: Object,
required: true,
},
className: {
type: String,
required: false,
default: '',
},
hasCheckbox: {
type: Boolean,
required: false,
},
translations: {
type: Object,
required: false,
default: () => ({}),
},
currentItem: {
type: String,
required: false,
default: '',
},
},
computed: {
id(): number {
return this.model.id.toString();
},
isFolder(): boolean {
return this.model.children && this.model.children.length;
},
displayExtraLabel(): boolean {
return this.isFolder && this.model.extraLabel;
},
getExtraLabel(): string {
let extraLabel = '';
if (this.model.extraLabel && this.model.extraLabel === 1) {
extraLabel = this.translations.extra_singular;
} else if (this.model.extraLabel) {
extraLabel = this.translations.extra.replace('%d', this.model.extraLabel);
}
return extraLabel;
},
isHidden(): boolean {
return !this.isFolder;
},
chevronStatus(): string {
return this.open ? 'open' : 'closed';
},
isWarning(): boolean {
return !this.isFolder && this.model.warning;
},
active(): boolean {
return this.model.full_name === this.currentItem;
},
},
methods: {
setCurrentElement(el: any): void {
if (this.$refs[el]) {
this.openTreeItemAction();
this.current = true;
this.parentElement(this.$parent);
} else {
this.current = false;
}
},
parentElement(parent: any): void {
if (parent.clickElement) {
parent.clickElement();
this.parentElement(parent.$parent);
}
},
clickElement(): boolean | void {
return !this.model.disable ? this.openTreeItemAction() : false;
},
openTreeItemAction(): void {
this.setCurrentElement(this.model.full_name);
if (this.isFolder) {
this.open = !this.open;
} else {
EventEmitter.emit('lastTreeItemClick', {
item: this.model,
});
}
},
onCheck(obj: any): void {
this.$emit('checked', obj);
},
},
mounted() {
EventEmitter.on('toggleCheckbox', (tag: any) => {
const checkbox = this.$refs[tag];
if (checkbox) {
(<VCheckbox>checkbox).$data.checked = !(<VCheckbox>checkbox).$data.checked;
}
});
EventEmitter.on('expand', () => {
this.open = true;
});
EventEmitter.on('reduce', () => {
this.open = false;
});
EventEmitter.on('setCurrentElement', (el: HTMLElement) => {
this.setCurrentElement(el);
});
this.setCurrentElement(this.currentItem);
},
components: {
PSCheckbox,
},
data: () => ({
open: false,
current: false,
}),
});
</script>

View File

@@ -0,0 +1,96 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div class="ps-tree">
<div class="mb-3 tree-header">
<button
class="btn btn-text text-uppercase pointer"
@click="expand"
data-action="expand"
>
<i class="material-icons">keyboard_arrow_down</i>
<span v-if="translations">{{ translations.expand }}</span>
</button>
<button
class="btn btn-text float-right text-uppercase pointer"
@click="reduce"
data-action="reduce"
>
<i class="material-icons">keyboard_arrow_up</i>
<span v-if="translations">{{ translations.reduce }}</span>
</button>
</div>
<ul
class="tree"
:class="className"
>
<li
v-for="(element, index) in model"
:key="index"
>
<PSTreeItem
ref="item"
:has-checkbox="hasCheckbox"
:model="element"
:label="element.name"
:translations="translations"
:current-item="currentItem"
@checked="onCheck"
@setCurrentElement="setCurrentElement"
/>
</li>
</ul>
</div>
</template>
<script lang="ts">
import {defineComponent, PropType} from 'vue';
import {EventEmitter} from '@components/event-emitter';
import PSTreeItem from './ps-tree-item.vue';
export default defineComponent({
name: 'PSTree',
props: {
model: {
type: Array as PropType<Array<Record<string, any>>>,
default: () => ([]),
},
className: {
type: String,
default: '',
},
currentItem: {
type: String,
default: '',
},
hasCheckbox: {
type: Boolean,
default: false,
},
translations: {
type: Object,
required: false,
default: () => ({}),
},
},
methods: {
onCheck(obj: any): void {
this.$emit('checked', obj);
},
expand(): void {
EventEmitter.emit('expand');
},
reduce(): void {
EventEmitter.emit('reduce');
},
setCurrentElement(id: string | number): void {
EventEmitter.emit('setCurrentElement', id);
},
},
components: {
PSTreeItem,
},
});
</script>