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,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 = [];
},
};