Compare commits

...

10 Commits

Author SHA1 Message Date
Alexandro Uc
e243e8397d fix: translate contactCard 2026-04-03 14:47:35 -06:00
Alexandro Uc
4570526ceb fix: sidebar label privacy list 2026-04-03 14:45:01 -06:00
Alexandro Uc
a9f7349039 add: translate texts of privacy list module 2026-04-03 14:41:44 -06:00
Alexandro Uc
0fbe83d737 local search & fix texts 2026-04-01 20:41:09 -06:00
Alexandro Uc
974c34ad1c add: private load actions & delete contact 2026-04-01 20:41:09 -06:00
Alexandro Uc
9e6e0948d4 add: services 2026-04-01 20:41:09 -06:00
Alexandro Uc
291dbd2f35 add: contacts module & actions to private list 2026-04-01 20:41:09 -06:00
Josepablo Cruz
4cc8fd7082 fix: develop -> development branch 2026-03-28 15:21:41 -06:00
Josepablo Cruz Baas
215414fb8c Merge branch 'sandbox_mode' into 'master'
Sandbox mode

See merge request jcruzbaasworkspace/enruta/webeta!14
2026-03-28 21:18:37 +00:00
Josepablo Cruz Baas
8fd5b4b1bc Enable sandbox mode with CICD deply strategy 2026-03-28 21:18:37 +00:00
32 changed files with 908 additions and 64 deletions

1
.gitignore vendored
View File

@@ -28,3 +28,4 @@ coverage
*.sw? *.sw?
scripts/env.sh scripts/env.sh
**.zip **.zip
*.env

View File

@@ -1,30 +1,54 @@
stages: stages:
- build - build
- upload
- deploy - deploy
variables: variables:
PIPELINE_WORK_DIR: "./" PIPELINE_WORK_DIR: "./"
DOCKERFILE_PATH: "./"
BUILD_NAME: "enruta_web_dashboard" BUILD_NAME: "enruta_web_dashboard"
CONTAINER_NAME: "enruta-web_dashboard"
VITE_API_URL: "https://api.etaviaporte.com/api"
PUBLIC_PORT: 8000
PRIVATE_PORT: 8000
build-job: build-job:
stage: build stage: build
script: script:
- . ./scripts/ci_functions.sh - . ./scripts/ci_functions.sh
- build_static - build_production
artifacts: artifacts:
paths: paths:
- $PIPELINE_WORK_DIR/$BUILD_NAME.zip - $PIPELINE_WORK_DIR/$BUILD_NAME.zip
expire_in: 1 week expire_in: 1 week
only:
- master
deploy-job: deploy-job:
stage: deploy stage: deploy
script: script:
- . ./scripts/ci_functions.sh - . ./scripts/ci_functions.sh
- deploy - deploy_production
only: only:
- master - master
build-sandbox-job:
stage: build
script:
- . ./scripts/ci_functions.sh
- build_sandbox
only:
- development
upload-sandbox-job:
stage: upload
script:
- . ./scripts/ci_functions.sh
- upload_sandbox
only:
- development
deploy-sandbox-job:
stage: deploy
script:
- . ./scripts/ci_functions.sh
- deploy_sandbox
only:
- development

10
Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM nginx:stable-alpine
WORKDIR /app
COPY dist /usr/share/nginx/html
RUN sed -i.bak '/index.html/ a try_files $uri $uri/ /index.html;' /etc/nginx/conf.d/default.conf
EXPOSE 80

View File

@@ -3,28 +3,75 @@ source ~/bash_config.sh
# Requirements # Requirements
# Node v18 # Node v18
function build_static(){ function build_production(){
#Global ENV VAR: BUILD_NAME #Global ENV VAR: BUILD_NAME
#Global ENV VAR: VITE_API_URL
nvm use 18 nvm use 18
npm install --force 2>/dev/null npm install --force 2>/dev/null
# Production API
echo "VITE_API_URL=\"https://api.etaviaporte.com/api\"" > .env
echo "VITE_MAP_KEY=\"${CICD_MAP_KEY}\"" >> .env
set -x set -x
cat dotenv > .env
npm run build 2>/dev/null npm run build 2>/dev/null
cp htaccess dist/.htaccess cp htaccess dist/.htaccess
zip -r $BUILD_NAME.zip dist/ zip -r ${BUILD_NAME}.zip dist/
set +x set +x
} }
function deploy(){ function deploy_production(){
# Global Env Var: SYSTEM_HOSTINGER_HOSTNAME # Global Env Var: SYSTEM_HOSTINGER_HOSTNAME
# Global Env Var: SYSTEM_HOSTINGER_SSH_USERNAME # Global Env Var: SYSTEM_HOSTINGER_SSH_USERNAME
# Global Env Var: SYSTEM_HOSTINGER_SSH_PORT # Global Env Var: SYSTEM_HOSTINGER_SSH_PORT
# Global Env Var: BUILD_NAME # Global Env Var: BUILD_NAME
INSTALL_PATH="public_html/subdomains/console/" INSTALL_PATH="public_html/subdomains/console/"
set -x set -x
scp ./$BUILD_NAME.zip "$SYSTEM_HOSTINGER_HOSTNAME":~/$INSTALL_PATH scp ./${BUILD_NAME}.zip "${SYSTEM_HOSTINGER_HOSTNAME}":~/${INSTALL_PATH}
scp ./scripts/ssh_install_script.sh "$SYSTEM_HOSTINGER_HOSTNAME":~/$INSTALL_PATH scp ./scripts/ssh_install_script.sh "${SYSTEM_HOSTINGER_HOSTNAME}":~/${INSTALL_PATH}
ssh "$SYSTEM_HOSTINGER_HOSTNAME" "cd ~/$INSTALL_PATH && bash ssh_install_script.sh && rm ssh_install_script.sh" ssh "${SYSTEM_HOSTINGER_HOSTNAME}" "cd ~/${INSTALL_PATH} && bash ssh_install_script.sh && rm ssh_install_script.sh"
set +x set +x
} }
function build_sandbox(){
# Global Env Var: CICD_REGISTRY_HOST
# Global Env Var: SANDBOX_HOMEPAGE
CONTAINER_NAME="console"
CONTAINER_VERSION="sandbox"
# Sandbox API
echo "VITE_API_URL=\"https://dev.api.etaviaporte.com/api\"" > .env
echo "VITE_MAP_KEY=\"${CICD_MAP_KEY}\"" >> .env
set -x
nvm use 18
npm install
npm run build
npm run build 2>/dev/null
cp htaccess dist/.htaccess
docker rmi -f "${CICD_REGISTRY_HOST}/${CONTAINER_NAME}:${CONTAINER_VERSION}"
docker buildx build --no-cache -t "${CICD_REGISTRY_HOST}/${CONTAINER_NAME}:${CONTAINER_VERSION}" ./
set +x
}
function upload_sandbox(){
# Global Env Var: CICD_REGISTRY_HOST
# Global Env Var: CICD_REGISTRY_USER
# Global Env Var: CICD_REGISTRY_TOKEN
CONTAINER_NAME="console"
CONTAINER_VERSION="sandbox"
docker login ${CICD_REGISTRY_HOST} -u ${CICD_REGISTRY_USER} -p ${CICD_REGISTRY_TOKEN}
set -x
docker push "${CICD_REGISTRY_HOST}/${CONTAINER_NAME}:${CONTAINER_VERSION}"
set +x
}
function deploy_sandbox(){
#Global ENV VAR: CICD_CONSOLE_SANDBOX_WEBHOOK
set -x
curl -X POST "${CICD_CONSOLE_SANDBOX_WEBHOOK}"
set +x
}

View File

@@ -3,6 +3,11 @@ body {
padding: 0px; padding: 0px;
} }
/*Colors*/
.primary-color-eta {
color: #FBBA33;
}
.flex-d-column { .flex-d-column {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -35,12 +40,6 @@ body {
border-radius: 3rem !important; border-radius: 3rem !important;
} }
/* Fuentes */
.font-bold {
font-weight: bold;
}
/* *********** */
.divider { .divider {
display: block; display: block;
height: 2px; height: 2px;
@@ -130,6 +129,12 @@ body {
color: #FBBA33; color: #FBBA33;
} }
.text-tertiary {
font-size: 1rem !important;
font-weight: 500 !important;
color: rgb(181, 168, 168) !important;
}
.card-info, .card-info,
.card-fixed { .card-fixed {
width: 100%; width: 100%;

View File

@@ -0,0 +1,15 @@
.font-bold {
font-weight: bold;
}
.fsize-1 {
font-size: 1rem;
}
.fsize-1-5 {
font-size: 1.5rem;
}
.fsize-2 {
font-size: 2rem;
}

View File

@@ -121,6 +121,9 @@
<template> <template>
<div class="card-fixed card-load mt-4"> <div class="card-fixed card-load mt-4">
<div class="row"> <div class="row">
<p v-if="load?.privacy === true && load?.company?._id === authStore?.user?.company._id && !share">
<i class="fa-solid fa-lock primary-color-eta fsize-1-5"></i> <span class="text-tertiary fsize-1">Carga privada</span>
</p>
<div class="col-lg-6 col-sm-12"> <div class="col-lg-6 col-sm-12">
<p> <p>
<span>{{t('loads.origin')}}: </span> <span>{{t('loads.origin')}}: </span>

View File

@@ -0,0 +1,43 @@
<script setup>
const props = defineProps({
saerch: {
type: [String]
},
placeholder: {
type: String,
}
})
defineEmits(['update:saerch'])
</script>
<template>
<div class="mb-4">
<input
class="custom-search"
type="search"
name="search"
:placeholder="placeholder"
:value="saerch"
@input="$event => $emit('update:saerch', $event.target.value)"
/>
</div>
</template>
<style scoped>
.custom-search {
width: 100%;
padding: 12px 16px;
border: 1px solid rgb(222, 214, 214);
border-radius: 5px;
}
.custom-search:focus {
outline: none;
border-color: rgb(217, 202, 202);
}
</style>

View File

@@ -0,0 +1,96 @@
<script setup>
defineProps({
modelValue: {
type: Boolean,
default: false
},
label: {
type: String,
required: false
},
name: {
type: String,
required: true
},
value: {
type: Boolean,
default: true
}
})
const emit = defineEmits(['update:modelValue'])
const toggle = (event) => {
emit('update:modelValue', event.target.checked)
}
</script>
<template>
<label class="switch-container">
<span v-if="label" class="custom-label label-text flex1">{{ label }}</span>
<input
type="checkbox"
:name="name"
:checked="modelValue"
@change="toggle"
>
<span class="slider"></span>
</label>
</template>
<style scoped>
.flex1 {
flex: 1;
}
.switch-container {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
font-size: 16px;
}
/* ocultar input */
.switch-container input {
display: none;
}
/* fondo del switch */
.slider {
position: relative;
width: 45px;
height: 25px;
background-color: #ccc;
border-radius: 25px;
transition: 0.3s;
}
/* circulo */
.slider::before {
content: "";
position: absolute;
width: 18px;
height: 18px;
left: 4px;
top: 3.5px;
background-color: white;
border-radius: 50%;
transition: 0.3s;
}
/* estado activo */
input:checked + .slider {
background-color: #5A67DD; /* tu color */
}
input:checked + .slider::before {
transform: translateX(20px);
}
/* hover */
.switch-container:hover .slider {
opacity: 0.8;
}
</style>

View File

@@ -198,7 +198,7 @@
<option v-for="vehicle in vehiclesAvailable" :value="vehicle._id"> <option v-for="vehicle in vehiclesAvailable" :value="vehicle._id">
{{vehicle.vehicle_code?.toUpperCase()}} - {{ vehicle.truck_type }} {{vehicle.vehicle_code?.toUpperCase()}} - {{ vehicle.truck_type }}
<span v-if="vehicle?.driver">- {{ vehicle?.driver?.first_name + ' ' + vehicle?.driver?.last_name }}</span> <span v-if="vehicle?.driver">- {{ vehicle?.driver?.first_name + ' ' + vehicle?.driver?.last_name }}</span>
<span v-else>- Sin conductor</span> <span v-else>- Sin conductor -</span>
</option> </option>
</select> </select>
</div> </div>

View File

@@ -159,7 +159,10 @@ const en = {
loading: 'Please wait!', loading: 'Please wait!',
savingChanes: 'Saving changes', savingChanes: 'Saving changes',
observerWarehouse: 'Warehouse observers will receive notifications about the load location, including arrival and departure times, and will be able to view the cargo on the warehouse dashboard.', observerWarehouse: 'Warehouse observers will receive notifications about the load location, including arrival and departure times, and will be able to view the cargo on the warehouse dashboard.',
observerClient: 'Observers will receive notifications about the load, including the loading and unloading date, as well as any changes in the load status. They can monitor the cargo on their customer dashboard.' observerClient: 'Observers will receive notifications about the load, including the loading and unloading date, as well as any changes in the load status. They can monitor the cargo on their customer dashboard.',
notDriverAssign: 'This offer does not include an assigned driver. Please request that the carrier assign an operator to continue the loading process.',
changeSuccess: 'Changes successfully implemented!',
actionSuccess: 'Action successfully completed!',
}, },
global: { global: {
signIn: "Sign In", signIn: "Sign In",
@@ -208,6 +211,9 @@ const en = {
delivered: 'Delivered', delivered: 'Delivered',
downloading: 'Downloading', downloading: 'Downloading',
add: 'Add', add: 'Add',
dashboard: 'Dashboard',
notDriver: 'Without driver',
mistake: 'Mistake'
}, },
login: { login: {
title: 'Sign in', title: 'Sign in',
@@ -495,6 +501,19 @@ const en = {
}, },
store: { store: {
title: "Warehouse" title: "Warehouse"
},
contacts: {
privacyList: 'Private list',
truckTypesUsed: 'Types of Used Trucks',
deleteCarrier: 'Delete carrier',
QuestionDeleteCarrier: 'Are you sure you want to remove this carrier from the private list?',
QuestionAddCarrier: 'Are you sure you want to add this carrier to the private list?',
deletedCarrier: 'Carrier removed',
deletedCarrierSuccess: 'The carrier has been removed from the private list',
deletedCarrierError: 'The removal of the carrier from the private list could not be completed.',
settingsCompany: 'Company settings',
privacyCompanyLabel: 'Activate company privacy settings',
privacyLoadLabel: 'Post as private',
} }
}; };

View File

@@ -162,7 +162,10 @@ const es = {
loading: 'Por favor espere!', loading: 'Por favor espere!',
savingChanes: 'Guardando cambios', savingChanes: 'Guardando cambios',
observerWarehouse: 'Los observadores de bodega recibirán notificaciones sobre la ubicación de la carga, incluyendo la hora de llegada y salida, podran visualizar la carga en el panel de bodega.', observerWarehouse: 'Los observadores de bodega recibirán notificaciones sobre la ubicación de la carga, incluyendo la hora de llegada y salida, podran visualizar la carga en el panel de bodega.',
observerClient: 'Los observadores recibirán notificaciones sobre la carga, incluyendo la fecha de carga y descarga, así como cualquier cambio en el estado de la carga. podran monitorer la carga en el panel de clientes.' observerClient: 'Los observadores recibirán notificaciones sobre la carga, incluyendo la fecha de carga y descarga, así como cualquier cambio en el estado de la carga. podran monitorer la carga en el panel de clientes.',
notDriverAssign: 'Oferta sin conductor asignado. Solicite al transportista que asigne un operador para continuar con el proceso de carga.',
changeSuccess: 'Cambios aplicados con exito!',
actionSuccess: 'Acción realizada con exito!',
}, },
global: { global: {
signIn: 'Ingresar', signIn: 'Ingresar',
@@ -211,6 +214,9 @@ const es = {
delivered: 'Entregado', delivered: 'Entregado',
downloading: 'Descargando', downloading: 'Descargando',
add: 'Agregar', add: 'Agregar',
dashboard: 'Panel',
notDriver: 'Sin conductor',
mistake: 'Error'
}, },
login: { login: {
title: 'Iniciar sesión', title: 'Iniciar sesión',
@@ -504,6 +510,19 @@ const es = {
}, },
store: { store: {
title: "Bodega" title: "Bodega"
},
contacts: {
privacyList: 'Lista privada',
truckTypesUsed: 'Tipos de camiones usados',
deleteCarrier: 'Eliminar transportista',
QuestionDeleteCarrier: '¿Estas seguro de eliminar este transportista de la lista privada?',
QuestionAddCarrier: '¿Estas seguro de añadir a este transportista de la lista privada?',
deletedCarrier: 'Transportista eliminado',
deletedCarrierSuccess: 'Se ha eliminado el transportista de la lista privada',
deletedCarrierError: 'No se pudo completar la eliminación del transportista de la lista privada',
settingsCompany: 'Configuraciones de empresa',
privacyCompanyLabel: 'Activar configuración de privacidad de la empresa',
privacyLoadLabel: 'Publicar como privada',
} }
}; };

View File

@@ -1,10 +1,9 @@
<script setup> <script setup>
import LoadingModal from '../components/ui/LoadingModal.vue'; import LoadingModal from '../components/ui/LoadingModal.vue';
import NavBar from '../components/NavBar.vue'; import NavBar from './components/NavBar.vue';
import Sidebar from '../components/Sidebar.vue'; import Sidebar from './components/Sidebar.vue';
import ProfilePopup from '../views/profile/modals/ProfilePopup.vue'; import ProfilePopup from '../views/profile/modals/ProfilePopup.vue';
import NotificationsPopup from '../components/NotificationsPopup.vue'; import NotificationsPopup from './components/NotificationsPopup.vue';
</script> </script>
<template> <template>
@@ -19,6 +18,7 @@
</div> </div>
<LoadingModal/> <LoadingModal/>
<ProfilePopup/> <ProfilePopup/>
<!-- <ConfigPopup/> -->
<NotificationsPopup/> <NotificationsPopup/>
</template> </template>

View File

@@ -0,0 +1,99 @@
<script setup>
import { useNotificationsStore } from '../../stores/notifications';
import CustomSwitch from '../../components/CustomSwitch.vue'
import { useI18n } from 'vue-i18n';
import { ref } from 'vue';
import { onMounted } from 'vue';
import { watch } from 'vue';
import { usePrivacyStore } from '../../stores/privacy';
const lang = ref(null);
const privacy = ref(false);
const noty = useNotificationsStore();
const privacyStore = usePrivacyStore();
const { t, locale } = useI18n();
onMounted(() => {
lang.value = localStorage.getItem('lang') ?? 'es';
locale.value = lang.value;
privacy.value = privacyStore.privacy;
});
watch(lang, () => {
locale.value = lang.value
localStorage.setItem('lang', lang.value)
})
watch(privacy, () => {
privacyStore.updatePrivacy(privacy.value);
})
const closePopup = () => {
noty.toggleConfig();
}
</script>
<template>
<div
v-if="noty.openConfig"
>
<div
class="content-popup"
@click="closePopup()"
>
</div>
<div
class="profile-card">
<i class="fa-solid fa-xmark close-icon" @click="closePopup()"></i>
<br>
<CustomSwitch
label="Activar configuracion de privacidad"
v-model="privacy"
name="privacity"
/>
</div>
</div>
</template>
<style scoped>
.content-popup {
position: fixed;
z-index: 1000;
width: 100wv;
right: 0px;
top: 0px;
left: 0px;
cursor: pointer;
bottom: 0px;
background-color: #000;
opacity: 0.2;
}
.profile-card {
position: fixed;
flex: 1;
right: 20px;
top: 70px;
z-index: 2000;
width: 340px;
background-color: #FFF;
opacity: 1;
border-radius: 13px;
padding: 20px 20px;
display: flex;
flex-direction: column;
filter: drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.10));
}
.close-icon {
display: flex;
position: absolute;
right: 20px;
top: 16px;
cursor: pointer;
font-size: 24px;
}
</style>

View File

@@ -1,10 +1,10 @@
<script setup> <script setup>
import { RouterLink } from 'vue-router'; import { RouterLink } from 'vue-router';
import { useAuthStore } from '../stores/auth'; import { useAuthStore } from '../../stores/auth';
import { useNotificationsStore } from '../stores/notifications'; import { useNotificationsStore } from '../../stores/notifications';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { getNotificationsCompany } from '../services/company'; import { getNotificationsCompany } from '../../services/company';
const auth = useAuthStore(); const auth = useAuthStore();
const noty = useNotificationsStore(); const noty = useNotificationsStore();
@@ -75,7 +75,16 @@
<a <a
active-class="router-link-active" active-class="router-link-active"
@click="noty.toggleProfile" @click="noty.toggleProfile"
class="nav-link"><i class="fa-regular fa-user"></i></a> class="nav-link">
<i class="fa-regular fa-user"></i>
</a>
<!-- <a
v-if="permission === 'role_shipper'"
active-class="router-link-active"
@click="noty.toggleConfig"
class="nav-link">
<i class="fa-solid fa-gear"></i>
</a> -->
</div> </div>
</div> </div>
</nav> </nav>

View File

@@ -2,10 +2,10 @@
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import {getDateTime} from '../helpers/date_formats'; import {getDateTime} from '../../helpers/date_formats';
import { deleteNotification } from '../services/company'; import { deleteNotification } from '../../services/company';
import { useNotificationsStore } from '../stores/notifications'; import { useNotificationsStore } from '../../stores/notifications';
import Spiner from './ui/Spiner.vue'; import Spiner from '../../components/ui/Spiner.vue';
const props = defineProps({ const props = defineProps({
noty: { noty: {

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { useNotificationsStore } from '../stores/notifications'; import { useNotificationsStore } from '../../stores/notifications';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import NotificationCard from './NotificationCard.vue'; import NotificationCard from './NotificationCard.vue';

View File

@@ -1,12 +1,12 @@
<script setup> <script setup>
import { RouterLink, useRoute, useRouter } from 'vue-router'; import { RouterLink, useRoute, useRouter } from 'vue-router';
import { useAuthStore } from '../stores/auth'; import { useAuthStore } from '../../stores/auth';
import Swal from 'sweetalert2'; import Swal from 'sweetalert2';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useCompanyStore } from '../stores/company'; import { useCompanyStore } from '../../stores/company';
import { useVehiclesStore } from '../stores/vehicles'; import { useVehiclesStore } from '../../stores/vehicles';
import { useLoadsStore } from '../stores/loads'; import { useLoadsStore } from '../../stores/loads';
import { useNotificationsStore } from '../stores/notifications'; import { useNotificationsStore } from '../../stores/notifications';
const route = useRoute(); const route = useRoute();
const auth = useAuthStore(); const auth = useAuthStore();
@@ -65,7 +65,7 @@
<i class="fa-solid fa-gauge-high" :class="[route.name === 'home' ? 'router-link-active' : '']"></i> <i class="fa-solid fa-gauge-high" :class="[route.name === 'home' ? 'router-link-active' : '']"></i>
<RouterLink <RouterLink
active-class="router-link-active" active-class="router-link-active"
class="nav-link" :to="{name: 'home'}">Dashboard</RouterLink> class="nav-link" :to="{name: 'home'}">{{ t('global.dashboard') }}</RouterLink>
</div> </div>
</li> </li>
<li <li
@@ -159,6 +159,16 @@
class="nav-link" :to="{name: 'calculator'}">{{ t('global.calculator') }}</RouterLink> class="nav-link" :to="{name: 'calculator'}">{{ t('global.calculator') }}</RouterLink>
</div> </div>
</li> </li>
<li
v-if="permission === 'role_shipper' && jobRole !== roleCheck"
:class="[route.name === 'groups' ? 'bg-nav-active' : '']">
<div>
<i class="fa-regular fa-address-book" :class="[route.name === 'groups' ? 'router-link-active' : '']"></i>
<RouterLink
active-class=""
class="nav-link" :to="{name: 'groups'}">{{ t('contacts.privacyList') }}</RouterLink>
</div>
</li>
</ul> </ul>
<div class="eta-info"> <div class="eta-info">
<div class="divider"></div> <div class="divider"></div>

View File

@@ -1,4 +1,5 @@
import './assets/main.css' import './assets/main.css'
import './assets/styles/fonts.css'
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'

View File

@@ -200,7 +200,16 @@ const router = createRouter({
path: '/:pathMatch(.*)*', path: '/:pathMatch(.*)*',
name: 'not-found', name: 'not-found',
component: () => import('../views/dashboard/HomeView.vue'), component: () => import('../views/dashboard/HomeView.vue'),
} },
{
path: 'lista-privada',
name: 'groups',
meta: {
permissions: ['role_shipper'],
roles: ['staff', 'manager', 'owner']
},
component: () => import('../views/contacts/ContactsView.vue'),
},
] ]
} }
] ]

View File

@@ -202,3 +202,67 @@ export const deleteNotification = async(id) => {
}; };
} }
} }
export const enablePrivacyCompany = async(formData) => {
try {
const endpoint = '/v1/companies/own';
const {data} = await api.patch(endpoint, formData);
return {
msg: 'success',
data: data
};
} catch (error) {
return {
msg: error.response.data.error ?? "Algo salió mal, intente nás tarde",
data: null
};
}
}
export const getPrivateListService = async() => {
try {
const endpoint = `/v1/groups/private`;
const {data} = await api.get(endpoint);
return {
msg: 'success',
data: data
};
} catch (error) {
return {
msg: error.response.data.error ?? "Algo salió mal, intente nás tarde",
data: null
};
}
}
export const addCompanyPrivicyListService = async(id) => {
try {
const endpoint = `/v1/groups/private/${id}`;
const {data} = await api.patch(endpoint);
return {
msg: 'success',
data: data
};
} catch (error) {
return {
msg: error.response.data.error ?? "Algo salió mal, intente nás tarde",
data: null
};
}
}
export const deleteCompanyPrivicyListService = async(id) => {
try {
const endpoint = `/v1/groups/private/${id}`;
const {data} = await api.delete(endpoint);
return {
msg: 'success',
data: data
};
} catch (error) {
return {
msg: error.response.data.error ?? "Algo salió mal, intente nás tarde",
data: null
};
}
}

View File

@@ -5,12 +5,14 @@ import { renewToken } from '../services/auth';
import {useNotificationsStore} from './notifications'; import {useNotificationsStore} from './notifications';
import { useLoadsStore } from "./loads"; import { useLoadsStore } from "./loads";
import { updateMyUserProfile } from "../services/company"; import { updateMyUserProfile } from "../services/company";
import { usePrivacyStore } from "./privacy";
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const noty = useNotificationsStore(); const noty = useNotificationsStore();
const privicyStore = usePrivacyStore();
const loadStore = useLoadsStore(); const loadStore = useLoadsStore();
const sesion = ref('') const sesion = ref('')
const checking = ref(false); const checking = ref(false);
@@ -39,6 +41,7 @@ export const useAuthStore = defineStore('auth', () => {
if(resp.msg === 'success') { if(resp.msg === 'success') {
user.value = resp.data.user; user.value = resp.data.user;
sesion.value = resp.data.session_token; sesion.value = resp.data.session_token;
privicyStore.privacy = resp.data.user?.company?.privacy || false;
token.value = resp.data.accessToken; token.value = resp.data.accessToken;
localStorage.setItem('session', resp.data.session_token); localStorage.setItem('session', resp.data.session_token);
localStorage.setItem('access', resp.data.accessToken); localStorage.setItem('access', resp.data.accessToken);

View File

@@ -8,6 +8,7 @@ export const useNotificationsStore = defineStore('notifications', () => {
const error = ref(false) const error = ref(false)
const show = ref(false); const show = ref(false);
const openProfile = ref(false); const openProfile = ref(false);
const openConfig = ref(false);
const openNotifications = ref(false); const openNotifications = ref(false);
const notifications = ref([]); const notifications = ref([]);
const newNoty = ref(false); const newNoty = ref(false);
@@ -26,6 +27,10 @@ export const useNotificationsStore = defineStore('notifications', () => {
openProfile.value = !openProfile.value; openProfile.value = !openProfile.value;
} }
const toggleConfig = () => {
openConfig.value = !openConfig.value;
}
const toggleNotifications = () => { const toggleNotifications = () => {
openNotifications.value = !openNotifications.value; openNotifications.value = !openNotifications.value;
} }
@@ -39,6 +44,7 @@ export const useNotificationsStore = defineStore('notifications', () => {
newNoty.value = false; newNoty.value = false;
show.value = false; show.value = false;
openNotifications.value = false; openNotifications.value = false;
openConfig.value = false;
toggleProfile.value = false; toggleProfile.value = false;
} }
@@ -51,8 +57,10 @@ export const useNotificationsStore = defineStore('notifications', () => {
notifications, notifications,
newNoty, newNoty,
openProfile, openProfile,
openConfig,
removeNoty, removeNoty,
toggleProfile, toggleProfile,
toggleConfig,
openNotifications, openNotifications,
toggleNotifications, toggleNotifications,
clear clear

76
src/stores/privacy.js Normal file
View File

@@ -0,0 +1,76 @@
import { defineStore } from "pinia"
import { ref } from "vue";
import { addCompanyPrivicyListService, deleteCompanyPrivicyListService, enablePrivacyCompany, getPrivateListService } from "../services/company";
export const usePrivacyStore = defineStore('privacy', () => {
const privacy = ref(false);
const privateListRef = ref([]);
const loading = ref(false);
const privateList = ref([]);
const updatePrivacy = async (value) => {
const data = {
"privacy": value
};
const response = await enablePrivacyCompany(data);
if(response.msg == 'success') {
privacy.value = response.data.privacy || false;
}
}
const getPrivateList = async (id) => {
loading.value = true;
const response = await getPrivateListService();
loading.value = false;
if(response.msg == 'success') {
privateList.value = response.data.allowedCompanies || [];
privateListRef.value = privateList.value.map((e) => e._id);
return 'success';
} else {
return response.msg;
}
}
const addCompanyToPrivateList = async (id) => {
const response = await addCompanyPrivicyListService(id);
if(response.msg == 'success') {
privateListRef.value = [
...privateListRef.value,
id
];
return 'success';
} else {
return response.msg;
}
}
const deleteCompanyToPrivateList = async (id) => {
const response = await deleteCompanyPrivicyListService(id);
if(response.msg == 'success') {
removeContact(id);
return 'success';
} else {
return response.msg;
}
}
/// State
const removeContact = (id) => {
privateList.value = privateList.value.filter((e) => e._id !== id);
privateListRef.value = privateListRef.value.filter((e) => e !== id);
}
return {
loading,
privacy,
privateListRef,
privateList,
removeContact,
updatePrivacy,
getPrivateList,
deleteCompanyToPrivateList,
addCompanyToPrivateList
}
});

View File

@@ -98,7 +98,7 @@
localStorage.setItem('id', result.data._id); localStorage.setItem('id', result.data._id);
localStorage.setItem('session', auth.sesion); localStorage.setItem('session', auth.sesion);
localStorage.setItem('access', auth.accessToken); localStorage.setItem('access', auth.token);
const userData = { const userData = {
"first_name" : user.name, "first_name" : user.name,

View File

@@ -8,16 +8,21 @@
import EditCompanyModal from './modals/EditCompanyModal.vue'; import EditCompanyModal from './modals/EditCompanyModal.vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import CustomSwitch from '../../components/CustomSwitch.vue';
import { usePrivacyStore } from '../../stores/privacy';
const auth = useAuthStore() const auth = useAuthStore()
const privacyStore = usePrivacyStore();
const company = useCompanyStore(); const company = useCompanyStore();
const { user } = storeToRefs(auth); const { user } = storeToRefs(auth);
const { t, locale } = useI18n() const { t, locale } = useI18n()
const privacy = ref(false);
onMounted(() => { onMounted(() => {
if(user.value) { if(user.value) {
getInitialData() getInitialData()
} }
privacy.value = privacyStore.privacy;
}); });
watch(user, () => { watch(user, () => {
@@ -26,6 +31,10 @@
} }
}) })
watch(privacy, () => {
privacyStore.updatePrivacy(privacy.value);
})
const getInitialData = async() => { const getInitialData = async() => {
await company.getCompanyData(); await company.getCompanyData();
} }
@@ -36,7 +45,7 @@
<template> <template>
<EditCompanyModal v-if="company.loading === false"/> <EditCompanyModal v-if="company.loading === false"/>
<div> <div>
<h2 class="title my-5">{{ t('company.title') }}</h2> <h2 class="title my-2">{{ t('company.title') }}</h2>
<div class="card-info"> <div class="card-info">
<Spiner v-if="company.loading"/> <Spiner v-if="company.loading"/>
<div v-else class="view"> <div v-else class="view">
@@ -93,6 +102,16 @@
</div> </div>
</div> </div>
</div> </div>
<div class="card-fixed flex-d-column"
v-if="auth.user?.job_role === 'owner' || auth.user?.job_role === 'manager'">
<h3> {{ t('contacts.settingsCompany') }} </h3>
<hr>
<CustomSwitch
:label="t('contacts.privacyCompanyLabel')"
v-model="privacy"
name="privacity"
/>
</div>
</div> </div>
</template> </template>

View File

@@ -0,0 +1,69 @@
<script setup>
import { onMounted, ref, watch } from 'vue';
import { usePrivacyStore } from '../../stores/privacy';
import ContactCard from './components/ContactCard.vue';
import { useI18n } from 'vue-i18n';
import CustomSearchInput from '../../components/CustomSearchInput.vue';
import Spiner from '../../components/ui/Spiner.vue';
const privateStore = usePrivacyStore();
const contacts = ref([]);
const { t } = useI18n();
const query = ref('');
let timeout = null;
onMounted( async () => {
await privateStore.getPrivateList();
contacts.value = privateStore.privateList;
});
watch(query, () => {
clearTimeout(timeout);
timeout = setTimeout(() => {
search();
}, 400);
});
watch(
() => privateStore.privateList,
(newValue) => {
contacts.value = newValue;
}
);
const search = () => {
if(query.value.length > 0) {
contacts.value = privateStore.privateList.filter(
(e) => e.company_name
.toLowerCase()
.includes(query.value.toLocaleLowerCase()));
} else {
contacts.value = privateStore.privateList;
}
};
</script>
<template>
<CustomSearchInput
:placeholder="t('carriers.searchByCarrier')"
v-model:saerch="query"
/>
<div class="row">
<div v-if="privateStore.loading" class="d-flex justify-content-center">
<Spiner />
</div>
<ContactCard
v-else
v-for="contact in contacts"
:key="contact.id"
:contact="contact"
/>
</div>
</template>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,108 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '../../../stores/auth';
import Swal from 'sweetalert2';
import { usePrivacyStore } from '../../../stores/privacy';
import { computed } from 'vue';
const props = defineProps({
contact: {
type: Object,
required: true
}
})
const { t } = useI18n();
const authStore = useAuthStore();
const contactsStore = usePrivacyStore();
const handleDeleteContact = async() => {
Swal.fire({
title: t('contacts.deleteCarrier'),
text: t('contacts.QuestionDeleteCarrier'),
icon: 'warning',
showCancelButton: true,
cancelButtonColor: "#d33",
confirmButtonText: t('buttons.delete'),
cancelButtonText: t('buttons.cancel'),
}).then(async(result) => {
const id = props.contact._id;
if(result.isConfirmed) {
Swal.fire({
title: t('messages.loading'),
allowOutsideClick: false,
didOpen: () => {
Swal.showLoading()
},
});
const resp = await contactsStore.deleteCompanyToPrivateList(id);
Swal.close();
if(resp === 'success') {
Swal.fire({
text: t('messages.actionSuccess'),
icon: "success"
});
} else {
Swal.fire({
title: t('global.mistake'),
text: resp,
icon: "error"
});
}
}
});
}
const categories = computed(() => (props.contact.categories)
? props.contact.categories.map((e) => e.name).join(', ')
: ''
)
const truckTypes = computed(() => (props.contact.truck_type)
? props.contact.truck_type.join(', ')
: ''
)
const states = computed(() => (props.contact.company_state)
? props.contact.company_state.join(', ')
: ''
)
</script>
<template>
<div class="col-lg-6 col-12">
<div class="card-fixed flex-d-column">
<div class="d-flex">
<h2 class="flex1">{{ contact.company_name }}</h2>
<button
v-if="(authStore.user?.job_role === 'owner' || authStore.user?.job_role === 'manager')"
class="btn-primary-sm bg-danger sizeBtn"
@click="handleDeleteContact"
>
<i class="fa-solid fa-trash"></i>
</button>
</div>
<p><span class="font-bold">RFC: </span> {{ contact.rfc}}</p>
<p><span class="font-bold">{{ t('global.segments') }}:</span> {{ categories }}</p>
<p><span class="font-bold">{{ t('contacts.truckTypesUsed') }}: </span> {{ truckTypes }}</p>
<p><span class="font-bold">{{ t('global.states') }}: </span> {{ states }}</p>
</div>
</div>
</template>
<style lang="scss" scoped>
.flex1 {
flex: 1;
}
.sizeBtn {
width: 46px;
height: 40px;
margin-left: 16px;
}
</style>

View File

@@ -14,15 +14,17 @@
import { useCompanyStore } from '../../../stores/company'; import { useCompanyStore } from '../../../stores/company';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { computed } from 'vue'; import { computed } from 'vue';
import {getDateToLocal } from '../../../helpers/date_formats';
import { validateEmail } from '../../../helpers/validations'; import { validateEmail } from '../../../helpers/validations';
import AddressPreview from '../../../components/AddressPreview.vue'; import AddressPreview from '../../../components/AddressPreview.vue';
import CustomSwitch from '../../../components/CustomSwitch.vue';
import { usePrivacyStore } from '../../../stores/privacy';
const loadStore = useLoadsStore(); const loadStore = useLoadsStore();
const notyStore = useNotificationsStore(); const notyStore = useNotificationsStore();
const auth = useAuthStore(); const auth = useAuthStore();
const companyStore = useCompanyStore() const companyStore = useCompanyStore()
const privacyStore = usePrivacyStore()
const windowWidth = ref(window.innerWidth); const windowWidth = ref(window.innerWidth);
const zoom = ref(6); const zoom = ref(6);
const heightMap = ref(768); const heightMap = ref(768);
@@ -42,6 +44,7 @@
const destinationRef = ref('') const destinationRef = ref('')
const emails = ref([]); const emails = ref([]);
const emailInput = ref(''); const emailInput = ref('');
const isPrivate = ref(false);
const errors = ref({ const errors = ref({
segment: null, segment: null,
@@ -76,10 +79,12 @@
zoom.value = 4; zoom.value = 4;
heightMap.value = 420; heightMap.value = 420;
} }
isPrivate.value = privacyStore.privacy;
getLocations('unloading'); getLocations('unloading');
getLocations('loading'); getLocations('loading');
formLoad.owner = auth.user?.first_name + ' ' + auth.user?.last_name; formLoad.owner = auth.user?.first_name + ' ' + auth.user?.last_name;
if(loadStore.currentLoad){ if(loadStore.currentLoad){
isPrivate.value = loadStore.currentLoad.privacy || false
const dateStart = loadStore.currentLoad.est_loading_date; const dateStart = loadStore.currentLoad.est_loading_date;
const dateEnd = loadStore.currentLoad.est_unloading_date; const dateEnd = loadStore.currentLoad.est_unloading_date;
formLoad.price = loadStore.currentLoad.actual_cost; formLoad.price = loadStore.currentLoad.actual_cost;
@@ -257,7 +262,8 @@
posted_by_name: formLoad.owner, posted_by_name: formLoad.owner,
origin_warehouse: locationLoadSelected.value?._id || null, origin_warehouse: locationLoadSelected.value?._id || null,
destination_warehouse: locationDownloadSelected.value?._id || null, destination_warehouse: locationDownloadSelected.value?._id || null,
alert_list: emails.value.length > 0 ? emails.value : null alert_list: emails.value.length > 0 ? emails.value : null,
privacy: isPrivate.value
}; };
return loadData; return loadData;
} }
@@ -605,6 +611,13 @@
<div class="modal-footer custom-footer"> <div class="modal-footer custom-footer">
<Spiner v-if="isLoading"/> <Spiner v-if="isLoading"/>
<div v-else class="btns-footer"> <div v-else class="btns-footer">
<div class="switch-container">
<CustomSwitch
:label="t('contacts.privacyLoadLabel')"
v-model="isPrivate"
name="contacts"
/>
</div>
<button <button
type="button" type="button"
class="btn btn-danger" class="btn btn-danger"
@@ -651,20 +664,22 @@
width: 100%; width: 100%;
} }
.custom-footer {
display: flex;
justify-content: center;
}
.error-msg { .error-msg {
color: red; color: red;
font-size: 12px; font-size: 12px;
font-weight: 300; font-weight: 300;
} }
.custom-footer {
display: flex;
justify-content: flex-end;
}
.btns-footer { .btns-footer {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
justify-content: flex-end;
align-items: center;
} }
.chekmark { .chekmark {
@@ -690,5 +705,15 @@
flex-direction: column; flex-direction: column;
gap: 2rem; gap: 2rem;
} }
.btns-footer {
flex-wrap: wrap;
}
.switch-container {
flex: 1 1 100%;
justify-content: flex-end;
margin-bottom: 0.3rem;
}
} }
</style> </style>

View File

@@ -215,7 +215,7 @@
<div v-if="!proposal.vehicle?.driver" <div v-if="!proposal.vehicle?.driver"
class="box-note bg-warning mb-3" class="box-note bg-warning mb-3"
> >
<i class="fa-solid fa-triangle-exclamation"></i> Oferta sin conductor asignado. Solicite al transportista que asigne un operador para continuar con el proceso de carga. <i class="fa-solid fa-triangle-exclamation"></i> {{ t('messages.notDriverAssign') }}
</div> </div>
<Spiner v-if="isLoadingActions"/> <Spiner v-if="isLoadingActions"/>
<div class="d-flex justify-content-end gap-3" v-else> <div class="d-flex justify-content-end gap-3" v-else>

View File

@@ -9,7 +9,9 @@
import Cities from '../../components/ui/Cities.vue'; import Cities from '../../components/ui/Cities.vue';
import Pagination from '../../components/Pagination.vue'; import Pagination from '../../components/Pagination.vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { usePrivacyStore } from '../../stores/privacy';
const privacyStore = usePrivacyStore();
const {loading, companies, getCompaniesData, companiesTotal, currentCompaniesPage} = useDirectory(); const {loading, companies, getCompaniesData, companiesTotal, currentCompaniesPage} = useDirectory();
const query = ref(''); const query = ref('');
const selectedTruckType = ref([]); const selectedTruckType = ref([]);
@@ -22,10 +24,11 @@
const limit = 10; const limit = 10;
onMounted(() => { onMounted( async () => {
filterQuery.value.company_type = 'carrier'; filterQuery.value.company_type = 'carrier';
filterQuery.value.limit = 'elements=' + limit; filterQuery.value.limit = 'elements=' + limit;
filterQuery.value.page = "page=0"; filterQuery.value.page = "page=0";
await privacyStore.getPrivateList();
getCompaniesData(filterQuery.value); getCompaniesData(filterQuery.value);
}); });

View File

@@ -2,14 +2,66 @@
import { RouterLink } from 'vue-router'; import { RouterLink } from 'vue-router';
import { getDateMonthDay } from '../../../helpers/date_formats'; import { getDateMonthDay } from '../../../helpers/date_formats';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import Swal from 'sweetalert2';
import { onMounted, ref } from 'vue';
import { useAuthStore } from '../../../stores/auth';
import { usePrivacyStore } from '../../../stores/privacy';
defineProps({ const props = defineProps({
company: { company: {
type: Object, type: Object,
required: true, required: true,
} }
}); });
const authStore = useAuthStore();
const privacyStore = usePrivacyStore();
const existInPrivateList = ref(false);
onMounted(() => {
const id = props.company._id;
existInPrivateList.value = privacyStore.privateListRef.includes(id);
})
const handleAddPrivateList = async() => {
Swal.fire({
title: t('contacts.privacyList'),
text: t('contacts.QuestionAddCarrier'),
icon: 'info',
showCancelButton: true,
cancelButtonColor: "#d33",
confirmButtonText: t('buttons.yes'),
cancelButtonText: t('buttons.no'),
}).then(async(result) => {
if(result.isConfirmed) {
Swal.fire({
title: t('messages.loading'),
allowOutsideClick: false,
didOpen: () => {
Swal.showLoading()
},
});
const response = await privacyStore.addCompanyToPrivateList(props.company._id);
Swal.close();
if(response === 'success') {
existInPrivateList.value = true;
Swal.fire({
text: t('messages.actionSuccess'),
icon: "success"
});
} else {
Swal.fire({
title: 'Error',
text: response,
icon: "error"
});
}
}
});
}
const { t } = useI18n() const { t } = useI18n()
</script> </script>
@@ -30,9 +82,16 @@
<p><span>{{ t('labels.infoCompany') }}: </span>{{company.company_description}}</p> <p><span>{{ t('labels.infoCompany') }}: </span>{{company.company_description}}</p>
</div> </div>
</div> </div>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end gap-2">
<button
v-if="authStore.user?.permissions === 'role_shipper' && !existInPrivateList"
class="btn-primary-sm bg-dark radius-sm"
@click="handleAddPrivateList"
>
Añadir a lista privada
</button>
<RouterLink <RouterLink
class="btn-primary-sm" class="btn-primary-sm radius-sm"
:to="{name: 'public-users', params: {id: company._id}}" :to="{name: 'public-users', params: {id: company._id}}"
>{{ t('buttons.profile') }}</RouterLink> >{{ t('buttons.profile') }}</RouterLink>
</div> </div>