Initial commit

This commit is contained in:
Juan Felipe Zapata Moreno 2025-11-10 10:44:28 -06:00
commit c44fc36fd5
156 changed files with 13797 additions and 0 deletions

20
.env.example Normal file
View File

@ -0,0 +1,20 @@
VITE_APP_NAME=GOLSCONTROL
VITE_APP_URL=http://frontend.golscontrol.test
VITE_APP_LANG=es-MX
VITE_APP_PORT=3000
VITE_PAGINATION=25
VITE_APP_API=http://localhost:8080 #Colocar http://localhost:8080 con el puerto del backend / http://backend.golscontrol.test
VITE_APP_API_SECURE=false
VITE_MICROSERVICE_STOCK=http://localhost:3000/api
VITE_APP_NOTIFICATIONS=false
VITE_REVERB_APP_ID=
VITE_REVERB_APP_KEY=
VITE_REVERB_APP_SECRET=
VITE_REVERB_HOST="backend.golscontrol.test"
VITE_REVERB_PORT=8080
VITE_REVERB_SCHEME=http
APP_PORT=3001 # Puerto para la aplicación frontend

27
.gitignore vendored Normal file
View File

@ -0,0 +1,27 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.env
colors.css
notes.md
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
README.md Normal file
View File

@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

34
colors.css.example Normal file
View File

@ -0,0 +1,34 @@
@theme {
--color-page: #fff;
--color-page-t: #000;
--color-page-d: #292524;
--color-page-dt: #fff;
--color-primary: #374151;
--color-primary-t: #fff;
--color-primary-d: #1c1917;
--color-primary-dt: #fff;
--color-secondary: #3b82f6;
--color-secondary-t: #fff;
--color-secondary-d: #312e81;
--color-secondary-dt: #fff;
--color-primary-info: #06b6d4;
--color-primary-info-t: #fff;
--color-primary-info-d: #06b6d4;
--color-primary-info-dt: #fff;
--color-secondary-info: #06b6d4;
--color-secondary-info-t: #fff;
--color-secondary-info-d: #06b6d4;
--color-secondary-info-dt: #fff;
--color-success: #22c55e;
--color-success-t: #fff;
--color-success-d: #22c55e;
--color-success-dt: #fff;
--color-danger: #ef4444;
--color-danger-t: #fff;
--color-danger-d: #ef4444;
--color-danger-dt: #fecaca;
--color-warning: #eab308;
--color-warning-t: #fff;
--color-warning-d: #eab308;
--color-warning-dt: #fff;
}

16
docker-compose.yml Normal file
View File

@ -0,0 +1,16 @@
services:
holos-frontend:
build:
context: .
dockerfile: dockerfile
ports:
- "${APP_PORT}:5173"
volumes:
- .:/var/www/holos.frontend
- /var/www/holos.frontend/node_modules
networks:
- holos-network
networks:
holos-network:
driver: bridge

17
dockerfile Normal file
View File

@ -0,0 +1,17 @@
FROM node:22-alpine AS build
WORKDIR /var/www/holos.frontend
COPY package*.json ./
RUN npm install
COPY . .
COPY install.sh /usr/local/bin/install.sh
RUN chmod +x /usr/local/bin/install.sh
EXPOSE 3000
ENTRYPOINT ["/usr/local/bin/install.sh"]
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

14
index.html Normal file
View File

@ -0,0 +1,14 @@
<!doctype html>
<html id="main-page" lang="es">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Holos</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/index.js"></script>
</body>
</html>

13
install.sh Executable file
View File

@ -0,0 +1,13 @@
#! /bin/bash
if [ ! -f .env ]; then
cp .env.example .env
fi
if [ ! -f colors.json ]; then
cp colors.json.example colors.json
fi
exec "$@"
echo "Done!"

3716
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
package.json Normal file
View File

@ -0,0 +1,35 @@
{
"name": "notsoweb.frontend",
"copyright": "Notsoweb Software Inc.",
"private": true,
"version": "0.9.10",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/postcss": "^4.0.9",
"@tailwindcss/vite": "^4.0.9",
"@vitejs/plugin-vue": "^5.2.1",
"axios": "^1.8.1",
"laravel-echo": "^2.0.2",
"luxon": "^3.5.0",
"pinia": "^3.0.1",
"pusher-js": "^8.4.0",
"tailwindcss": "^4.0",
"toastr": "^2.1.4",
"uuid": "^11.1.0",
"vite": "^6.2.0",
"vue": "^3.5.13",
"vue-i18n": "^11.1.1",
"vue-multiselect": "^3.2.0",
"vue-router": "^4.5.0",
"ziggy-js": "^2.5.2"
},
"devDependencies": {
"autoprefixer": "^10.4.20",
"vite-plugin-html": "^3.2.2"
}
}

5
postcss.config.js Normal file
View File

@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
}

1
public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

1
src/assets/vue.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

23
src/components/App.vue Normal file
View File

@ -0,0 +1,23 @@
<script setup>
import { onMounted } from 'vue';
import { useRouter } from 'vue-router';
import useLoader from '@Stores/Loader';
import { hasToken } from '@Services/Api';
/** Definidores */
const router = useRouter();
const loader = useLoader();
/** Ciclos */
onMounted(() => {
if(!hasToken()) {
return router.push({ name: 'auth.index' })
}
loader.boot()
})
</script>
<template>
<router-view />
</template>

View File

@ -0,0 +1,22 @@
<script setup>
import SectionTitle from './SectionTitle.vue';
</script>
<template>
<div class="md:grid md:grid-cols-3 md:gap-6">
<SectionTitle>
<template #title>
<slot name="title" />
</template>
<template #description>
<slot name="description" />
</template>
</SectionTitle>
<div class="mt-5 md:mt-0 md:col-span-2">
<div class="px-4 py-5 sm:p-6 shadow-sm dark:shadow-xs dark:shadow-white/50 sm:rounded-sm">
<slot name="content" />
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,18 @@
<script setup>
/** Propiedades */
defineProps({
type: {
default: 'submit',
type: String
}
});
</script>
<template>
<button
class="btn bg-danger"
:type="type"
>
<slot />
</button>
</template>

View File

@ -0,0 +1,32 @@
<script setup>
import GoogleIcon from '@Shared/GoogleIcon.vue'
/** Propiedades */
const props = defineProps({
icon: String,
fill: Boolean,
style: {
type: String,
default: 'rounded'
},
title: String,
type: {
type: String,
default: 'button'
}
});
</script>
<template>
<button
class="flex justify-center items-center h-7 w-7 rounded-sm btn-icon"
:title="title"
:type="type"
>
<GoogleIcon
:fill="fill"
:name="icon"
:style="style"
/>
</button>
</template>

View File

@ -0,0 +1,18 @@
<script setup>
/** Propiedades */
defineProps({
type: {
default: 'submit',
type: String
}
});
</script>
<template>
<button
class="btn btn-primary"
:type="type"
>
<slot />
</button>
</template>

View File

@ -0,0 +1,18 @@
<script setup>
/** Propiedades */
defineProps({
type: {
default: 'submit',
type: String
}
});
</script>
<template>
<button
class="btn btn-secondary"
:type="type"
>
<slot />
</button>
</template>

View File

@ -0,0 +1,34 @@
<script setup>
import { RouterLink } from 'vue-router';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Propiedades */
defineProps({
icon: String,
title: String,
to: String,
value: Number
});
</script>
<template>
<RouterLink
class="relative flex-1 flex flex-col gap-2 p-4 rounded-sm -md bg-gray-200 dark:bg-transparent dark:border"
:to="to"
>
<label class="text-base font-semibold tracking-wider">
{{ title }}
</label>
<label class="text-primary dark:text-primary-dt text-4xl font-bold">
{{ value }}
</label>
<div class="absolute bg-primary dark:bg-primary-d rounded-md font-semibold text-xs text-gray-100 p-2 right-4 bottom-4">
<GoogleIcon
class="text-3xl md:text-2xl lg:text-3xl"
:name="icon"
filled
/>
</div>
</RouterLink>
</template>

View File

@ -0,0 +1,19 @@
<script setup>
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Propiedades */
defineProps({
icon: String,
title: String
});
</script>
<template>
<div class="flex flex-col justify-center items-center p-4 bg-primary text-white rounded-md">
<GoogleIcon
class="text-4xl"
:name="icon"
/>
<h4>{{ title }}</h4>
</div>
</template>

View File

@ -0,0 +1,36 @@
<script setup>
import { computed } from 'vue';
const emit = defineEmits(['update:checked']);
const props = defineProps({
checked: {
type: [Array, Boolean],
default: false,
},
value: {
type: String,
default: null,
},
});
const proxyChecked = computed({
get() {
return props.checked;
},
set(val) {
emit('update:checked', val);
},
});
</script>
<template>
<input
v-model="proxyChecked"
type="checkbox"
:value="value"
class="rounded-sm border-gray-300 text-indigo-600 shadow-xs focus:ring-indigo-500"
>
</template>

View File

@ -0,0 +1,99 @@
<script setup>
import { ref, nextTick } from 'vue';
import { api, useForm } from '@Services/Api';
import Input from './Form/Input.vue';
import DialogModal from './DialogModal.vue';
import PrimaryButton from './Button/Primary.vue';
import SecondaryButton from './Button/Secondary.vue';
const emit = defineEmits(['confirmed']);
defineProps({
title: {
type: String,
default: Lang('confirm'),
},
content: {
type: String,
default: Lang('account.password.verify'),
},
button: {
type: String,
default: Lang('confirm'),
},
});
const confirmingPassword = ref(false);
const form = useForm({
password: '',
});
const passwordInput = ref(null);
const startConfirmingPassword = () => {
confirmingPassword.value = true;
};
const confirmPassword = () => {
form.post(route('user.password-confirm'), {
onSuccess: () => {
closeModal();
nextTick(() => emit('confirmed'));
},
onFail: () => {
passwordInput.value.focus();
}
});
};
const closeModal = () => {
confirmingPassword.value = false;
form.password = '';
};
</script>
<template>
<span>
<span @click="startConfirmingPassword">
<slot />
</span>
<DialogModal :show="confirmingPassword" @close="closeModal">
<template #title>
{{ title }}
</template>
<template #content>
{{ content }}
{{ form }}
<div class="mt-4">
<Input
v-model="form.password"
id="password"
type="password"
:onError="form.errors.password"
/>
</div>
</template>
<template #footer>
<SecondaryButton @click="closeModal">
{{ $t('cancel') }}
</SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
@click="confirmPassword"
>
{{ button }}
</PrimaryButton>
</template>
</DialogModal>
</span>
</template>

View File

@ -0,0 +1,45 @@
<script setup>
import Modal from './Modal.vue';
const emit = defineEmits(['close']);
defineProps({
show: {
type: Boolean,
default: false,
},
maxWidth: {
type: String,
default: '2xl',
},
closeable: {
type: Boolean,
default: true,
},
});
const close = () => {
emit('close');
};
</script>
<template>
<Modal
:show="show"
:max-width="maxWidth"
:closeable="closeable"
@close="close"
>
<div>
<div class="text-lg px-4 font-medium">
<slot name="title" />
</div>
<div class="text-sm">
<slot name="content" />
</div>
</div>
<div class="flex flex-row justify-center p-2 text-end">
<slot name="footer" />
</div>
</Modal>
</template>

View File

@ -0,0 +1,104 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
const props = defineProps({
align: {
default: 'right',
type: String
},
contentClasses: {
default: () => [
'pt-1',
'bg-primary text-primary-t dark:bg-primary-d dark:text-primary-dt'
],
type: Array
},
width: {
default: '48',
type: String
}
});
const open = ref(false);
const closeOnEscape = (e) => {
if (open.value && e.key === 'Escape') {
open.value = false;
}
};
onMounted(() => document.addEventListener('keydown', closeOnEscape));
onUnmounted(() => document.removeEventListener('keydown', closeOnEscape));
const widthClass = computed(() => {
return {
'48': 'w-48',
'52': 'w-52',
'56': 'w-56',
'60': 'w-60',
'64': 'w-64',
'72': 'w-52 md:w-72',
}[props.width.toString()];
});
const alignmentClasses = computed(() => {
if (props.align === 'left') {
return 'origin-top-left left-0';
}
if (props.align === 'right') {
return 'origin-top-right right-0';
}
if (props.align === 'icon') {
const size = {
'48': '-right-20',
'52': '-right-22',
'56': '-right-24',
'60': '-right-26',
'64': '-right-28',
'72': '-right-36',
}[props.width.toString()];
return `origin-top-right ${size}`;
}
return 'origin-top';
});
</script>
<template>
<div class="relative">
<div @click="open = ! open">
<slot name="trigger" />
</div>
<!-- Full Screen Dropdown Overlay -->
<div
v-show="open"
class=" fixed inset-0 z-40"
@click="open = false"
/>
<transition
enter-active-class="transition ease-out duration-200"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<div
v-show="open"
class="absolute z-[1000] mt-2 rounded-t-md shadow-lg"
:class="[widthClass, alignmentClasses]"
style="display: none;"
@click="open = false"
>
<div class="rounded-sm ring-1 ring-black/5" :class="contentClasses">
<slot name="content" />
</div>
</div>
</transition>
</div>
</template>

View File

@ -0,0 +1,39 @@
<script setup>
import { RouterLink } from 'vue-router'
defineProps({
as: String,
to: String
});
const style = 'block px-4 py-2 text-sm leading-5 hover:bg-secondary/80 dark:hover:bg-secondary-d/80 focus:outline-hidden focus:bg-gray-100 cursor-pointer transition';
</script>
<template>
<div>
<button
v-if="as == 'button'"
class="w-full text-left"
:class="style"
type="submit"
>
<slot />
</button>
<a
v-else-if="as =='a'"
:href="href"
:class="style"
>
<slot />
</a>
<RouterLink
v-else
:to="$view({ name: to })"
:class="style"
>
<slot />
</RouterLink>
</div>
</template>

View File

@ -0,0 +1,44 @@
<script setup>
import { computed } from 'vue';
import { v4 as uuidv4 } from 'uuid';
const emit = defineEmits([
'update:modelValue'
]);
const uuid = uuidv4();
const props = defineProps({
title: String,
modelValue: Object | Boolean,
value: Object | Boolean,
});
const vModel = computed({
get() {
return props.modelValue;
},
set(value) {
emit('update:modelValue', value);
},
});
</script>
<template>
<div class="relative w-full h-8">
<input
class="appearance-none rounded-sm bg-primary cursor-pointer h-full w-full checked:bg-secondary dark:checked:bg-secondary-d transition-all duration-200 peer"
type="checkbox"
:id="uuid"
v-model="vModel"
:value="value"
/>
<label
:for="uuid"
class="absolute top-[50%] left-3 text-primary-t dark:text-primary-dt -translate-y-[50%] peer-checked:text-white dark:peer-checked:text-primary-dt transition-all duration-200 select-none"
>
{{ title }}
</label>
</div>
</template>

View File

@ -0,0 +1,14 @@
<script setup>
/** Propiedades */
defineProps({
onError: String | Array
});
</script>
<template>
<p v-if="onError"
class="mt-1 pl-2 text-xs text-red-500 dark:text-red-300"
>
{{ Array.isArray(onError) ? onError[0] : onError }}
</p>
</template>

View File

@ -0,0 +1,22 @@
<script setup>
/** Propiedades */
defineProps({
id: String,
title: String,
required: Boolean
});
</script>
<template>
<label v-if="title"
class="block text-sm font-medium text-page-t dark:text-page-dt"
:for="id"
>
{{ $t(title) }}
<span v-if="required"
class="text-danger dark:text-danger-d"
>
*
</span>
</label>
</template>

View File

@ -0,0 +1,107 @@
<script setup>
import { ref } from 'vue';
import GoogleIcon from '@Shared/GoogleIcon.vue'
import Label from './Elements/Label.vue';
import SecondaryButton from '../Button/Secondary.vue';
/** Eventos */
const emit = defineEmits([
'photoInput'
]);
/** Propiedades */
const props = defineProps({
accept: {
default: 'image/png, image/jpeg',
type: String
},
class: String,
required: Boolean,
title: {
default: 'photo.title',
type: String
}
});
const fileType = ref(null);
const photoInput = ref(null);
const photoPreview = ref(null);
/** Métodos */
const selectNewPhoto = () => {
photoInput.value.click();
};
const updatePhotoPreview = () => {
const image_file = photoInput.value.files[0];
if (! image_file) return;
emit('photoInput', image_file);
fileType.value = image_file.type;
if(image_file.type == "application/pdf"){
photoPreview.value = image_file.name;
}else{
const reader = new FileReader();
reader.onload = (e) => {
photoPreview.value = e.target.result;
};
reader.readAsDataURL(image_file);
}
};
</script>
<template>
<div class="col-span-6">
<input
ref="photoInput"
class="hidden"
type="file"
:accept="accept"
:required="required"
@change="updatePhotoPreview"
>
<Label
id="image_file"
class="dark:text-gray-800"
:required="required"
:title="title"
/>
<div v-show="! photoPreview" class="mt-2">
<!-- si existe una imagen cargada, entonces se muestra en este slot -->
<slot />
</div>
<div v-show="photoPreview" class="mt-2">
<div v-if="fileType == 'application/pdf'" class="flex overflow-hidden max-w-full">
<GoogleIcon
class="text-gray-400"
name="picture_as_pdf"
:title="$t('crud.edit')"
outline
/>
<div class="ml-2 font-bold text-gray-400 flex-1">
{{ photoPreview }}
</div>
</div>
<div v-else>
<span
class="block rounded-lg h-40 bg-cover bg-no-repeat bg-center"
:class="class"
:style="'background-image: url(\'' + photoPreview + '\');'"
/>
</div>
</div>
<SecondaryButton
v-text="$t('photo.new')"
class="mt-2 mr-2"
type="button"
@click.prevent="selectNewPhoto"
/>
</div>
</template>

View File

@ -0,0 +1,88 @@
<script setup>
import { v4 as uuidv4 } from 'uuid';
import { computed, onMounted, ref } from 'vue';
import Error from './Elements/Error.vue';
import Label from './Elements/Label.vue';
/** Opciones */
defineOptions({
inheritAttrs: false
})
/** Eventos */
const emit = defineEmits([
'update:modelValue'
]);
/** Propiedades */
const props = defineProps({
class: String,
disabled: Boolean,
id: String,
modelValue: Number | String,
onError: String | Array,
placeholder: String,
required: Boolean,
title: String,
type: {
default: 'text',
type: String
}
});
const input = ref(null);
/** Propiedades calculadas */
const autoId = computed(() => {
return (props.id)
? props.id
: uuidv4()
})
const autoTitle = computed(() => {
if(props.title) {
return props.title;
}
return props.id;
});
/** Exposiciones */
defineExpose({
focus: () => input.value.focus()
});
/** Ciclos */
onMounted(() => {
if (input.value.hasAttribute('autofocus')) {
input.value.focus();
}
});
</script>
<template>
<div class="w-full">
<Label
:id="autoId"
:required="required"
:title="autoTitle"
/>
<input
v-bind="$attrs"
ref="input"
class="input-primary"
:class="{ 'cursor-not-allowed': disabled }"
:disabled="disabled"
:id="autoId"
:placeholder="placeholder"
:required="required"
:type="type"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
>
<Error
:onError="onError"
/>
</div>
</template>

View File

@ -0,0 +1,88 @@
<script setup>
import { v4 as uuidv4 } from 'uuid';
import { computed, onMounted, ref } from 'vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Error from './Elements/Error.vue';
/** Opciones */
defineOptions({
inheritAttrs: false
})
/** Eventos */
const emit = defineEmits([
'update:modelValue'
]);
/** Propiedades */
const props = defineProps({
class: String,
disabled: Boolean,
id: String,
icon: String,
modelValue: Number | String,
onError: String | Array,
placeholder: String,
required: Boolean,
title: String,
type: {
default: 'text',
type: String
}
});
const input = ref(null);
/** Propiedades computadas */
const autoId = computed(() => {
return (props.id)
? props.id
: uuidv4()
})
const value = computed({
get() {
return props.modelValue
},
set(value) {
emit('update:modelValue', value)
}
})
/** Exposiciones */
defineExpose({
focus: () => input.value.focus()
});
/** Ciclos */
onMounted(() => {
if (input.value.hasAttribute('autofocus')) {
input.value.focus();
}
});
</script>
<template>
<div class="mb-4">
<div class="flex items-center border-2 py-2 px-3 rounded-sm">
<GoogleIcon
:name="icon"
/>
<input
ref="input"
v-model="value"
v-bind="$attrs"
class="pl-2 w-full outline-hidden border-none bg-transparent"
:class="{ 'cursor-not-allowed': disabled }"
:disabled="disabled"
:id="autoId"
:placeholder="placeholder"
:type="type"
/>
</div>
<Error
:onError="onError"
/>
</div>
</template>

View File

@ -0,0 +1,89 @@
<script setup>
import { computed, ref } from 'vue';
import VueMultiselect from 'vue-multiselect';
import Error from './Elements/Error.vue';
import Label from './Elements/Label.vue';
/** Eventos */
const emit = defineEmits([
'select',
'update:modelValue'
]);
/** Propiedades */
const props = defineProps({
customLabel: String,
disabled: Boolean,
label: {
default: 'name',
type: String
},
modelValue: String | Number,
multiple: Boolean,
onError: String | Array,
options: Object,
placeholder: {
default: 'Buscar ...',
type: String
},
required: Boolean,
trackBy: {
default: 'id',
type: String
},
title: String,
});
const multiselect = ref();
/** Propiedades computadas */
const value = computed({
get() {
return props.modelValue
},
set(value) {
emit('update:modelValue', value)
}
})
/** Exposiciones */
defineExpose({
clean: () => multiselect.value.removeLastElement()
});
</script>
<template>
<div class="flex flex-col">
<Label
:required="required"
:title="title"
/>
<VueMultiselect
ref="multiselect"
v-model="value"
deselectLabel="Remover"
selectedLabel="Seleccionado"
selectLabel="Seleccionar"
:clear-on-select="false"
:close-on-select="true"
:custom-label="customLabel"
:disabled="disabled"
:label="label"
:multiple="multiple"
:options="options"
:placeholder="placeholder"
:preserve-search="true"
:required="required && !value"
:track-by="trackBy"
@select="(x, y) => emit('select', x, y)"
>
<template #noOptions>
{{ $t('noRecords') }}
</template>
</VueMultiselect>
<Error
:onError="onError"
/>
</div>
</template>

View File

@ -0,0 +1,103 @@
<script setup>
import { ref } from 'vue';
import GoogleIcon from '@Shared/GoogleIcon.vue'
import Label from './Elements/Label.vue';
import SecondaryButton from '../Button/Secondary.vue';
/** Eventos */
const emit = defineEmits([
'update:modelValue'
]);
/** Propiedades */
const props = defineProps({
accept: {
default: 'image/png, image/jpeg',
type: String
},
class: String,
modelValue:Object|String,
required: Boolean,
title: {
default: 'photo.title',
type: String
}
});
const fileType = ref(null);
const fileName = ref(null);
const photoInput = ref(null);
const photoPreview = ref(null);
/** Métodos */
const selectNewPhoto = () => {
photoInput.value.click();
};
const updatePhotoPreview = () => {
const image_file = photoInput.value.files[0];
if (! image_file) return;
emit('update:modelValue', image_file);
fileType.value = image_file.type;
fileName.value = image_file.name;
const reader = new FileReader();
reader.onload = (e) => {
photoPreview.value = e.target.result;
};
reader.readAsDataURL(image_file);
};
</script>
<template>
<div class="col-span-6">
<input
ref="photoInput"
type="file"
class="hidden"
:accept="accept"
:required="required"
@change="updatePhotoPreview"
>
<Label
id="image_file"
:title="title"
:required="required"
/>
<div v-show="! photoPreview" class="mt-2">
<!-- si existe una imagen cargada, entonces se muestra en este slot -->
<slot name="previous"/>
</div>
<div v-show="photoPreview" class="mt-2">
<div class="flex overflow-hidden max-w-full">
<GoogleIcon
class="text-gray-400"
name="picture_as_pdf"
:title="$t('crud.edit')"
outline
/>
<div class="ml-2 font-bold text-gray-400 flex-1">
<a
target="_blank"
:href="photoPreview"
>
{{ fileName }}
</a>
</div>
</div>
</div>
<SecondaryButton
v-text="$t('files.select')"
class="mt-2 mr-2"
type="button"
@click.prevent="selectNewPhoto"
/>
</div>
</template>

View File

@ -0,0 +1,67 @@
<script setup>
import { v4 as uuidv4 } from 'uuid';
import { computed } from 'vue';
/** Eventos */
const emit = defineEmits([
'update:checked'
]);
/** Propiedades */
const props = defineProps({
checked: {
default: false,
type: [
Array,
Boolean
]
},
disabled: Boolean,
title: {
default: Lang('active'),
type: String
},
value: {
default: null,
type: String
}
});
const uuid = uuidv4()
/** Propiedades computadas */
const proxyChecked = computed({
get() {
return props.checked;
},
set(val) {
emit('update:checked', val);
},
});
</script>
<template>
<div class="flex items-center">
<div class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
<input
v-model="proxyChecked"
class="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer"
name="toggle"
type="checkbox"
:id="uuid"
:disabled="disabled"
:value="value"
/>
<label
class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"
:for="uuid"
/>
</div>
<label
class="text-xs text-gray-700"
:for="uuid"
>
{{ $t(title) }}
</label>
</div>
</template>

View File

@ -0,0 +1,80 @@
<script setup>
import { v4 as uuidv4 } from 'uuid';
import { computed, onMounted, ref } from 'vue';
import Error from './Elements/Error.vue';
import Label from './Elements/Label.vue';
/** Opciones */
defineOptions({
inheritAttrs: false
})
/** Eventos */
const emit = defineEmits([
'update:modelValue'
]);
/** Propiedades */
const props = defineProps({
class: String,
id: String,
modelValue: Number | String,
onError: String,
placeholder: String,
required: Boolean,
title: String,
});
const input = ref(null);
/** Exposiciones */
defineExpose({
focus: () => input.value.focus()
});
/** Propiedades computadas */
const autoId = computed(() => {
return (props.id)
? props.id
: uuidv4()
})
const autoTitle = computed(() => {
if(props.title) {
return props.title;
}
return props.id;
});
/** Ciclos */
onMounted(() => {
if (input.value.hasAttribute('autofocus')) {
input.value.focus();
}
});
</script>
<template>
<div class="w-full">
<Label
:id="autoId"
:required="required"
:title="autoTitle"
/>
<textarea
ref="input"
v-bind="$attrs"
class="input-primary"
:id="autoId"
:placeholder="placeholder"
:required="required"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
></textarea>
<Error
:onError="onError"
/>
</div>
</template>

View File

@ -0,0 +1,166 @@
<script setup>
import { computed, onMounted, ref, watch, watchEffect } from 'vue'
import { lang } from '@Lang/i18n';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import PrimaryButton from '../../Button/Primary.vue';
import Input from '../../Form/Input.vue';
import Selectable from '../../Form/Selectable.vue';
/** Eventos */
const emit = defineEmits([
'update:modelValue'
]);
/** Propiedades */
const props = defineProps({
itemATitle: String,
itemBTitle: String,
items: Object,
modelValue: Object,
title: String,
type: {
default: 'text',
type: String
}
})
// Elementos primarios (controlador)
const itemA = ref()
const itemsASelected = ref([]);
const itemsAUnselected = ref([]);
// Elementos secundarios
const itemB = ref();
/** Propiedades computadas */
const values = computed({
get() {
return props.modelValue
},
set(val) {
emit('update:modelValue', val)
}
})
/** Métodos */
function add() {
if (itemA.value) {
if(itemB.value) {
values.value.push({
item: {
_id: itemA.value._id,
name: itemA.value.name,
},
value: itemB.value,
});
let x = itemsAUnselected.value.filter((o) => {
return o._id != itemA.value._id
})
itemsAUnselected.value = x
itemsASelected.value.push({...itemA.value}),
itemA.value = null
itemB.value = null
} else {
Notify.warning(Lang('todo.uniqueSub.b.required', {name:Lang('subclassification')}))
}
} else {
Notify.warning(Lang('todo.uniqueSub.a.required', {name:Lang('classification')}))
}
}
function remove(index, provider) {
itemsAUnselected.value.push({...provider})
itemsASelected.value.splice(itemsASelected.value.indexOf(provider), 1)
values.value.splice(index, 1)
}
/** Exposiciones */
defineExpose({
itemA,
itemB,
add,
})
/** Observadores */
watchEffect(() => {
if(props.items.length > 0) {
itemsAUnselected.value = props.items
}
})
watch(itemA, () => {
emit('updateItemsB', itemA.value?.id)
if(!itemA.value) {
itemB.value = null
}
})
/** Ciclos */
onMounted(() => {
if(values.value) {
values.value.forEach((i) => {
itemsASelected.value.push({...i})
})
}
})
</script>
<template>
<div class="rounded-sm border border-primary dark:border-primary-d p-2">
<p>{{ title }}</p>
<div class="w-full grid gap-2 grid-cols-2 dark:bg-primary-d/50 rounded-md">
<Selectable
v-model="itemA"
:title="itemATitle"
:options="itemsAUnselected"
/>
<Input
v-model="itemB"
:title="itemBTitle"
:type="type"
@keyup.enter="add"
/>
<div class="col-span-2 flex justify-center">
<PrimaryButton
type="button"
@click="add"
>
{{ $t('add') }}
</PrimaryButton>
</div>
<div class="col-span-2 text-sm">
<p><b>{{ $t('items') }}</b> ({{ values.length }})</p>
</div>
<div class="col-span-2 space-y-2 ">
<template v-for="item, index in values">
<div class="relative rounded-sm border border-primary/50">
<div class="grid gap-2 grid-cols-2 w-full items-center p-2 dark:bg-primary-d/50">
<Input
v-model="item.item.name"
:title="itemATitle"
disabled
/>
<Input
v-model="item.value"
:title="itemBTitle"
/>
</div>
<div class="absolute right-1 top-1">
<GoogleIcon
class="btn-icon-primary"
name="close"
@click="remove(index, item.item)"
/>
</div>
</div>
</template>
</div>
<slot />
</div>
</div>
</template>

View File

@ -0,0 +1,38 @@
<script setup>
import { computed, useSlots } from 'vue';
import SectionTitle from './SectionTitle.vue';
defineEmits(['submitted']);
const hasActions = computed(() => !! useSlots().actions);
</script>
<template>
<div class="md:grid md:grid-cols-3 md:gap-6">
<SectionTitle>
<template #title>
<slot name="title" />
</template>
<template #description>
<slot name="description" />
</template>
</SectionTitle>
<div class="mt-5 md:mt-0 md:col-span-2">
<form @submit.prevent="$emit('submitted')">
<div
class="p-4 sm:p-6 shadow-sm dark:shadow-xs dark:shadow-white/50"
:class="hasActions ? 'sm:rounded-tl-md sm:rounded-tr-md' : 'sm:rounded-md'"
>
<div class="grid grid-cols-6 gap-6">
<slot name="form" />
</div>
</div>
<div v-if="hasActions" class="flex items-center justify-end px-4 py-3 text-end sm:px-6 shadow-sm dark:shadow-xs dark:shadow-white/50 sm:rounded-bl-md sm:rounded-br-md">
<slot name="actions" />
</div>
</form>
</div>
</div>
</template>

172
src/components/Holos/Inbox.vue Executable file
View File

@ -0,0 +1,172 @@
<script setup>
import { ref } from 'vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import InboxItem from './Inbox/Item.vue';
/** Propiedades */
const props = defineProps({
inboxCtl: Object,
items: Object,
searcherCtl: Object,
withMultiSelection: Boolean
});
/** Propiedades */
const filterMessages = ref(false);
/** Métodos */
const selectThisPage = () => props.inboxCtl.onSelectAll(props.items, false);
const unselectThisPage = () => props.inboxCtl.onUnselectAll(props.items)
const search = url => props.searcherCtl.searchWithInboxPagination(url);
</script>
<template>
<div v-if="inboxCtl.inboxNumberSelected.value.length"
class="w-full p-1 rounded-b-md bg-primary text-white"
>
<span>
({{ inboxCtl.inboxNumberSelected.value.length }}) Seleccionados: {{ inboxCtl.inboxNumberSelected.value.join(", ") }}.
</span>
</div>
<div class="w-full flex overflow-x-auto">
<div class="w-fit">
<div v-if="$slots['main-menu']"
class="w-48 xl:w-64"
>
<slot
name="main-menu"
/>
</div>
<div v-if="$slots['menu']"
class="w-48 xl:w-64 pr-2 pb-8 border-r border-gray-300"
:class="{'mt-16':!$slots['main-menu']}"
>
<ul class="space-y-1">
<slot
name="menu"
/>
</ul>
</div>
</div>
<div class="flex-1">
<div v-if="$slots['actions']"
class="h-16 flex items-center justify-between py-2"
>
<div class="flex items-center">
<div v-if="withMultiSelection"
class="relative flex items-center px-0.5 space-x-0.5"
>
<button class="px-2 pt-1" @click="filterMessages = !filterMessages">
<GoogleIcon
class="text-xl"
name="checklist"
outline
/>
</button>
<div
@click.away="filterMessages = false"
class="bg-gray-200 shadow-2xl absolute left-0 top-6 w-32 py-2 text-gray-900 rounded-sm z-10"
:class="{'hidden':!filterMessages}"
>
<button
type="button"
class="inbox-check-all-option"
@click="selectThisPage()"
>
Seleccionar toda esta página
</button>
<button
type="button"
class="inbox-check-all-option"
@click="unselectThisPage()"
>
Deseleccionar toda esta página
</button>
</div>
</div>
<div class="flex items-center">
<slot name="actions" />
</div>
</div>
<template v-if="items.links">
<div v-if="items.links.length > 3"
class="flex w-full justify-end"
>
<div class="flex w-full justify-end flex-wrap space-x-1 -mb-1">
<template v-for="(link, k) in items.links" :key="k">
<div v-if="link.url === null && k == 0"
class="px-2 py-1 text-sm leading-4 text-gray-400 border rounded"
>
<GoogleIcon
name="arrow_back"
/>
</div>
<button v-else-if="k === 0"
class="px-2 py-1 text-sm leading-4 border rounded"
:class="{ 'bg-primary text-white': link.active }"
@click="search(link.url)"
>
<GoogleIcon
name="arrow_back"
/>
</button>
<div v-else-if="link.url === null && k == (items.links.length - 1)"
class="px-2 py-1 text-sm leading-4 text-gray-400 border rounded"
>
<GoogleIcon
name="arrow_forward"
/>
</div>
<button v-else-if="k === (items.links.length - 1)"
class="px-2 py-1 text-sm leading-4 border rounded"
:class="{ 'bg-primary text-white': link.active }"
@click="search(link.url)"
>
<GoogleIcon
name="arrow_forward"
/>
</button>
<button v-else class="px-2 py-1 text-sm leading-4 border rounded"
v-html="link.label"
:class="{ 'bg-primary text-white': link.active }"
@click="search(link.url)"
></button>
</template>
</div>
</div>
</template>
</div>
<div v-else class="w-full mt-4"></div>
<div v-if="items.total > 0"
class="bg-gray-100 "
>
<ul class="ml-1">
<slot
name="head"
:items="items.data"
/>
</ul>
<ul class="ml-1">
<slot
name="items"
:items="items.data"
/>
</ul>
</div>
<template v-else>
<InboxItem>
<template #item>
<span class="w-28 pr-2 truncate">-</span>
<span class="w-96 truncate">Sin resultados</span>
</template>
<template #date>
-
</template>
</InboxItem>
</template>
</div>
</div>
</template>

View File

@ -0,0 +1,69 @@
<script setup>
import { ref, computed } from 'vue';
/** Propiedades */
const props = defineProps({
inboxCtl: Object, //Controller
item: Object,
selecteds: Object
})
const check = ref(false);
const messageHover = ref(false);
/** Métodos */
const select = () => (!check.value)
? props.inboxCtl.onSelectOne(props.item)
: props.inboxCtl.onUnselectOne(props.item);
const selected = computed(() => {
const status = (props.item)
? props.inboxCtl.inboxIdSelected.value.includes(props.item.id)
: false;
check.value = status;
return status;
});
</script>
<template>
<li
class="flex items-center rounded-sm border-y px-2 min-h-[35px] transition duration-300"
:class="{'bg-secondary text-secondary-t':selected, 'bg-primary/50 text-primary-t hover:bg-secondary/50 hover:text-secondary-t':!selected}"
>
<div class="pr-2">
<input
v-model="check"
class="focus:ring-0 border-2 border-gray-400"
type="checkbox"
@click="select"
>
</div>
<div
class="w-full flex items-center justify-between cursor-pointer"
@mouseover="messageHover = true"
@mouseleave="messageHover = false"
>
<div class="flex items-center">
<slot name="item" />
</div>
<div
class="w-36 flex items-center justify-end"
>
<div
class="flex items-center space-x-2"
:class="{'hidden':!messageHover}"
>
<slot name="actions" :check="check" />
</div>
<div
class="flex space-x-4 text-xs"
:class="{'hidden':messageHover}"
>
<slot name="date" />
</div>
</div>
</div>
</li>
</template>

View File

@ -0,0 +1,11 @@
<template>
<li
class="flex items-center rounded-sm border-y px-2 min-h-[35px] transition duration-300"
>
<div class="pl-5 w-full flex items-center justify-between cursor-pointer">
<div class="flex items-center font-semibold">
<slot />
</div>
</div>
</li>
</template>

View File

@ -0,0 +1,51 @@
<script setup>
import { computed } from 'vue';
import { RouterLink } from 'vue-router';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Propiedades */
const props = defineProps({
icon: String,
counter: Number,
to: String,
toParam: {
default: {},
type: Object
},
title: String,
});
/** Propiedades computadas */
const classes = computed(() => {
let status = route().current(props.to, props.toParam)
? 'bg-secondary/30'
: 'border-transparent hover:bg-secondary/30';
return ` text-primary flex items-center justify-between py-1.5 px-4 rounded-sm cursor-pointer ${status} transition`
});
</script>
<template>
<li>
<RouterLink
v-if="to"
:class="classes"
:to="to"
>
<span class="flex items-center space-x-2">
<GoogleIcon
class="text-lg"
:name="icon"
outline
/>
<span>
{{ title }}
</span>
</span>
<span v-if="counter > 0" class="bg-primary text-gray-100 font-bold px-2 py-0.5 text-xs rounded-sm">
{{ counter }}
</span>
</RouterLink>
</li>
</template>

View File

@ -0,0 +1,42 @@
<script setup>
import { computed } from 'vue';
import { RouterLink } from 'vue-router';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Propiedades */
const props = defineProps({
icon: String,
to: String,
title: String,
type: {
default: 'primary',
type: String
}
});
/** Propiedades computadas */
const classes = computed(() => {
return `inbox-menu-button-${props.type}`;
});
</script>
<template>
<div class="h-16 flex items-center pr-2">
<RouterLink
:class="classes"
:to="to"
>
<span class="flex items-center space-x-2 ">
<GoogleIcon
class="text-lg text-white font-bold"
:name="icon"
outline
/>
<span>
{{ title }}
</span>
</span>
</RouterLink>
</div>
</template>

View File

@ -0,0 +1,12 @@
<script setup>
defineProps({
value: String,
});
</script>
<template>
<label class="block font-medium text-sm text-gray-700">
<span v-if="value">{{ value }}</span>
<span v-else><slot /></span>
</label>
</template>

View File

@ -0,0 +1,58 @@
<script setup>
import { onMounted } from 'vue';
import useDarkMode from '@Stores/DarkMode'
import useLeftSidebar from '@Stores/LeftSidebar'
import useNotificationSidebar from '@Stores/NotificationSidebar'
import useNotifier from '@Stores/Notifier'
import Header from '../Skeleton/Header.vue';
import LeftSidebar from '../Skeleton/Sidebar/Left.vue';
import NotificationSidebar from '../Skeleton/Sidebar/Notification.vue';
/** Definidores */
const darkMode = useDarkMode();
const leftSidebar = useLeftSidebar();
const notificationSidebar = useNotificationSidebar();
const notifier = useNotifier();
/** Propiedades */
defineProps({
title: String,
});
/** Ciclos */
onMounted(() => {
leftSidebar.boot();
darkMode.boot();
notifier.boot();
});
</script>
<template>
<div class="flex w-full h-screen bg-page text-page-t dark:bg-page-d dark:text-page-dt">
<LeftSidebar
@open="leftSidebar.toggle()"
>
<slot name="leftSidebar"/>
</LeftSidebar>
<NotificationSidebar
@open="notificationSidebar.toggle()"
/>
<div
class="flex flex-col w-full transition-all duration-300"
:class="{'md:w-[calc(100vw-rem)] md:ml-64':leftSidebar.isOpened, 'md:w-screen md:ml-0':leftSidebar.isClosed}"
>
<div class="h-2 md:h-14">
<Header
@open="leftSidebar.toggle()"
/>
</div>
<main class="flex h-full justify-center md:p-2">
<div class="mt-14 md:mt-0 w-full shadow-lg dark:shadow-xs md:dark:shadow-white h-[calc(100vh-4.5rem)] px-2 pb-4 md:rounded-sm overflow-y-auto overflow-x-auto transition-colors duration-300">
<slot />
</div>
</main>
</div>
</div>
</template>

View File

@ -0,0 +1,69 @@
<script setup>
import { onMounted } from 'vue'
import { APP_VERSION, APP_COPYRIGHT } from '@/config.js'
import useDarkMode from '@Stores/DarkMode'
import Logo from '@Holos/Logo.vue'
import IconButton from '@Holos/Button/Icon.vue'
/** Definidores */
const darkMode = useDarkMode()
/** Propiedades */
defineProps({
title: String
})
/** Ciclos */
onMounted(() => {
darkMode.boot()
});
</script>
<template>
<div class="h-screen flex bg-primary dark:bg-primary-d">
<div
class="relative flex w-full lg:w-full justify-around items-center with-transition"
:class="{'app-bg-light':darkMode.isLight,'app-bg-dark':darkMode.isDark}"
>
<header class="absolute top-0 flex w-full h-8 px-1 items-center justify-end text-white">
<div>
<IconButton v-if="darkMode.isLight"
icon="light_mode"
:title="$t('app.theme.light')"
@click="darkMode.applyDark()"
/>
<IconButton v-else
icon="dark_mode"
:title="$t('app.theme.dark')"
@click="darkMode.applyLight()"
/>
</div>
</header>
<div class="flex w-full flex-col items-center justify-center space-y-2">
<div class="flex space-x-2 items-center justify-start text-white">
<Logo
class="text-lg inline-flex"
/>
</div>
<main class="bg-white/10 w-full backdrop-blur-xs text-white px-4 py-4 rounded-sm max-w-80">
<RouterView />
</main>
<footer class="absolute bottom-0 flex w-full h-8 px-4 items-center justify-between bg-primary dark:bg-primary-d backdrop-blur-md text-white transition-colors duration-global">
<div>
<span>
&copy;2024 {{ APP_COPYRIGHT }}
</span>
</div>
<div>
<span>
APP {{ APP_VERSION }} API {{ $page.app.version }}
</span>
</div>
</footer>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,69 @@
<script setup>
import { onMounted } from 'vue'
import { APP_VERSION, APP_COPYRIGHT } from '@/config.js'
import useDarkMode from '@Stores/DarkMode'
import IconButton from '../Button/Icon.vue'
import Logo from '../Logo.vue';
/** Definidores */
const darkMode = useDarkMode()
/** Propiedades */
defineProps({
title: String
})
/** Ciclos */
onMounted(() => {
darkMode.boot()
});
</script>
<template>
<div class="min-h-screen flex">
<div
class="relative flex w-full lg:w-full justify-around items-start with-transition"
:class="{'app-bg-light':darkMode.isLight,'app-bg-dark':darkMode.isDark}"
>
<header class="absolute top-0 flex w-full h-8 px-1 items-center justify-end text-white">
<div>
<IconButton v-if="darkMode.isLight"
icon="light_mode"
:title="$t('app.theme.light')"
@click="darkMode.applyDark()"
/>
<IconButton v-else
icon="dark_mode"
:title="$t('app.theme.dark')"
@click="darkMode.applyLight()"
/>
</div>
</header>
<div class="flex w-full flex-col space-y-2">
<div class="flex space-x-2 items-center justify-start text-white">
<Logo
class="text-lg inline-flex"
/>
</div>
<main class="bg-white/10 w-full mx-auto sm:max-w-2xl backdrop-blur-xs text-white px-4 py-8 rounded-md">
<slot />
</main>
<footer class="absolute bottom-0 flex w-full h-8 px-4 items-center justify-between bg-primary dark:bg-primary-d backdrop-blur-xs text-white transition-colors duration-global">
<div>
<span>
&copy;{{ APP_COPYRIGHT }}
</span>
</div>
<div>
<span>
Versión {{ APP_VERSION }}
</span>
</div>
</footer>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,24 @@
<script setup>
import { hasToken } from '@Services/Api';
import { useRouter } from 'vue-router';
/** Definidores */
const router = useRouter();
/** Métodos */
const home = () => {
if(hasToken()) {
router.push({ name: 'dashboard.index' });
} else {
location.replace('/');
}
}
</script>
<template>
<div
class="flex w-full justify-center items-center space-x-2 cursor-pointer"
@click="home"
>
<img :src="$page.app.logo" class="h-20" />
</div>
</template>

View File

@ -0,0 +1,87 @@
<script setup>
import { computed, onMounted, onUnmounted, watch } from 'vue';
/** Eventos */
const emit = defineEmits([
'close'
]);
/** Propiedades */
const props = defineProps({
closeable: {
default: true,
type: Boolean
},
maxWidth: {
default: '2xl',
type: String
},
show: {
default: false,
type: Boolean
},
});
/** Métodos */
const close = () => {
if (props.closeable) {
emit('close');
}
};
const closeOnEscape = (e) => {
if (e.key === 'Escape' && props.show) {
close();
}
};
const maxWidthClass = computed(() => {
return {
'sm': 'sm:max-w-sm',
'md': 'sm:max-w-md',
'lg': 'sm:max-w-lg',
'xl': 'sm:max-w-xl',
'2xl': 'sm:max-w-2xl',
}[props.maxWidth];
});
/** Observadores */
watch(() => props.show, () => {
if (props.show) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = null;
}
});
/** Ciclos */
onMounted(() => document.addEventListener('keydown', closeOnEscape));
onUnmounted(() => {
document.removeEventListener('keydown', closeOnEscape);
document.body.style.overflow = null;
});
</script>
<template>
<teleport to="body">
<transition
enter-active-class="ease-out duration-300"
enter-from-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to-class="opacity-100 translate-y-0 sm:scale-100"
leave-active-class="ease-in duration-300"
leave-from-class="opacity-100 translate-y-0 sm:scale-100"
leave-to-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<div v-show="show" class="fixed inset-0 overflow-y-auto px-4 py-6 sm:px-0 bg-primary/90 z-50 transition-all" scroll-region>
<div
v-show="show"
class="mb-6 bg-page text-page-t dark:bg-page-d dark:text-page-dt rounded-sm overflow-hidden shadow-xl transform transition-all sm:w-full sm:mx-auto"
:class="maxWidthClass"
>
<slot v-if="show" />
</div>
</div>
</transition>
</teleport>
</template>

View File

@ -0,0 +1,57 @@
<script setup>
import DangerButton from '../Button/Danger.vue';
import SecondaryButton from '../Button/Secondary.vue';
import DialogModal from '../DialogModal.vue';
/** Eventos */
defineEmits([
'close',
'destroy',
]);
/** Propiedades */
const props = defineProps({
show: Boolean,
title: {
default: Lang('delete.title'),
type: String
}
});
</script>
<template>
<DialogModal :show="show">
<template #title>
<p
class="font-bold text-xl"
v-text="title"
/>
</template>
<template #content>
<div class="w-full right-0">
<div class="overflow-hidden space-y-2 shadow-lg">
<slot />
<div class="px-4 pb-2">
<p
class="mt-2 p-1 rounded-md text-justify bg-danger text-danger-t"
v-text="$t('delete.confirm')"
/>
</div>
</div>
</div>
</template>
<template #footer>
<div class="space-x-2">
<slot name="buttons" />
<DangerButton
v-text="$t('delete.title')"
@click="$emit('destroy')"
/>
<SecondaryButton
v-text="$t('cancel')"
@click="$emit('close')"
/>
</div>
</template>
</DialogModal>
</template>

View File

@ -0,0 +1,51 @@
<script setup>
import PrimaryButton from '../Button/Primary.vue';
import SecondaryButton from '../Button/Secondary.vue';
import DialogModal from '../DialogModal.vue';
/** Eventos */
const emit = defineEmits([
'close',
'update'
]);
/** Propiedades */
const props = defineProps({
show: Boolean,
title: {
default: Lang('edit'),
type: String
}
});
</script>
<template>
<DialogModal :show="show">
<template #title>
<p
class="font-bold text-xl"
v-text="title"
/>
</template>
<template #content>
<div class="w-full right-0">
<div class="overflow-hidden shadow-lg">
<slot />
</div>
</div>
</template>
<template #footer>
<div class="space-x-2">
<slot name="buttons" />
<PrimaryButton
v-text="$t('update')"
@click="$emit('update')"
/>
<SecondaryButton
v-text="$t('close')"
@click="$emit('close')"
/>
</div>
</template>
</DialogModal>
</template>

View File

@ -0,0 +1,21 @@
<script setup>
/** Propiedades */
defineProps({
subtitle: String,
title: String
})
</script>
<template>
<div class="text-center p-6 bg-primary dark:bg-primary-d">
<slot />
<p class="pt-2 text-lg font-bold text-primary-t dark:text-primary-t-d">
{{ title }}
</p>
<p v-if="subtitle"
class="text-sm text-primary-t dark:text-primary-t-d"
>
{{ subtitle }}
</p>
</div>
</template>

View File

@ -0,0 +1,49 @@
<script setup>
import SecondaryButton from '../Button/Secondary.vue';
import PrimaryButton from '../Button/Primary.vue';
import DialogModal from '../DialogModal.vue';
/** Eventos */
const emit = defineEmits([
'close',
'edit'
]);
/** Propiedades */
const props = defineProps({
editable: Boolean,
show: Boolean,
title: String
});
</script>
<template>
<DialogModal :show="show">
<template #title>
<p
class="font-bold text-xl"
v-text="title ?? $t('details')"
/>
</template>
<template #content>
<div class="w-full right-0">
<div class="overflow-hidden shadow-lg">
<slot />
</div>
</div>
</template>
<template #footer>
<div class="space-x-2">
<slot name="buttons" />
<PrimaryButton v-if="editable"
v-text="$t('update')"
@click="$emit('edit')"
/>
<SecondaryButton
v-text="$t('close')"
@click="$emit('close')"
/>
</div>
</template>
</DialogModal>
</template>

View File

@ -0,0 +1,53 @@
<script setup>
import { api } from '@Services/Api.js';
import DestroyModal from '../Destroy.vue';
import Header from '../Elements/Header.vue';
/** Eventos */
const emit = defineEmits([
'close',
'update'
]);
/** Propiedades */
const props = defineProps({
model: Object,
show: Boolean,
to: Function,
title: {
type: String,
default: 'name'
},
subtitle: {
type: String,
default: 'description'
}
});
/** Métodos */
const destroy = (id) => api.delete(props.to(id), {
onSuccess: () => {
Notify.success(Lang('deleted'));
emit('close');
emit('update');
},
onError: () => {
Notify.info(Lang('notFound'));
emit('close');
}
});
</script>
<template>
<DestroyModal
:show="show"
@close="$emit('close')"
@destroy="destroy(model.id)"
>
<Header
:title="model[title]"
:subtitle="model[subtitle]"
/>
</DestroyModal>
</template>

View File

@ -0,0 +1,32 @@
<script setup>
import { RouterLink } from 'vue-router';
import IconButton from '@Holos/Button/Icon.vue'
defineProps({
title: String
});
</script>
<template>
<div v-if="title" class="flex w-full justify-center">
<h2
class="font-bold text-xl uppercase"
v-text="title"
/>
</div>
<div class="flex w-full justify-end py-[0.31rem] mb-2 border-y-2 border-page-t dark:border-page-dt">
<div id="buttons" class="flex items-center space-x-1 text-sm">
<slot />
<RouterLink :to="$view({ name: 'index' })">
<IconButton
:title="$t('home')"
class="text-white"
icon="home"
filled
/>
</RouterLink>
</div>
</div>
</template>

View File

@ -0,0 +1,87 @@
<script setup>
import GoogleIcon from '../Shared/GoogleIcon.vue';
import Loader from '../Shared/Loader.vue';
/** Eventos */
const emit = defineEmits([
'send-pagination'
]);
/** Propiedades */
const props = defineProps({
items: Object,
processing: Boolean
});
</script>
<template>
<section class="pb-2">
<div class="w-full overflow-hidden rounded-sm shadow-lg">
<div v-if="!processing" class="w-full overflow-x-auto">
<template v-if="items?.total > 0">
<slot
name="body"
:items="items?.data"
/>
</template>
<template v-else>
<template v-if="$slots.empty">
<slot name="empty" />
</template>
<template v-else>
<div class="flex p-2 items-center justify-center">
<p class="text-center text-page-t dark:text-page-dt">{{ $t('noRecords') }}</p>
</div>
</template>
</template>
</div>
<div v-else class="flex items-center justify-center">
<Loader />
</div>
</div>
</section>
<template v-if="items?.links">
<div v-if="items.links.length > 3" class="flex w-full justify-end">
<div class="flex w-full justify-end flex-wrap space-x-1 -mb-1">
<template v-for="(link, k) in items.links" :key="k">
<div v-if="link.url === null && k == 0"
class="px-2 py-1 text-sm leading-4 text-gray-400 border rounded"
>
<GoogleIcon
name="arrow_back"
/>
</div>
<button v-else-if="k === 0" class="px-2 py-1 text-sm leading-4 border rounded"
:class="{ 'bg-primary dark:bg-primary-dark text-white': link.active }"
@click="$emit('send-pagination', link.url)"
>
<GoogleIcon
name="arrow_back"
/>
</button>
<div v-else-if="link.url === null && k == (items.links.length - 1)"
class="px-2 py-1 text-sm leading-4 text-gray-400 border rounded"
>
<GoogleIcon
name="arrow_forward"
/>
</div>
<button v-else-if="k === (items.links.length - 1)" class="px-2 py-1 text-sm leading-4 border rounded"
:class="{ 'bg-primary dark:bg-primary-dark text-white': link.active }"
@click="$emit('send-pagination', link.url)"
>
<GoogleIcon
name="arrow_forward"
/>
</button>
<button v-else class="px-2 py-1 text-sm leading-4 border rounded"
:class="{ 'bg-primary dark:bg-primary-dark text-white': link.active }"
v-html="link.label"
@click="$emit('send-pagination', link.url)"
></button>
</template>
</div>
</div>
</template>
</template>

View File

@ -0,0 +1,82 @@
<script setup>
import { ref } from 'vue';
import IconButton from '@Holos/Button/Icon.vue'
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Eventos */
const emit = defineEmits([
'search'
]);
/** Propiedades */
const props = defineProps({
title: String,
placeholder: {
default: Lang('search'),
type: String
}
})
const query = ref('');
/** Métodos */
const search = () => {
emit('search', query.value);
}
const clear = () => {
query.value = '';
search();
}
</script>
<template>
<div v-if="title" class="flex w-full justify-center">
<h2
class="font-bold text-xl uppercase"
v-text="title"
/>
</div>
<div class="flex w-full justify-between items-center border-y-2 border-page-t dark:border-page-dt">
<div>
<div class="relative py-1 z-0">
<div @click="search" class="absolute inset-y-0 right-2 flex items-center pl-3 cursor-pointer">
<GoogleIcon
:title="$t('search')"
class="text-xl text-gray-700 hover:scale-110 hover:text-danger"
name="search"
/>
<GoogleIcon
v-show="query"
:title="$t('clear')"
class="text-xl text-gray-700 hover:scale-110 hover:text-danger"
name="close"
@click="clear"
/>
</div>
<input
id="search"
class="bg-gray-100 border border-gray-300 text-gray-700 text-sm rounded-sm outline-0 focus:ring-primary focus:border-primary block sm:w-56 md:w-72 lg:w-80 pr-10 px-2.5 py-1"
autocomplete="off"
:placeholder="placeholder"
required
type="text"
v-model="query"
@keyup.enter="search"
/>
</div>
</div>
<div class="flex items-center space-x-1 text-sm" id="buttons">
<slot />
<RouterLink :to="$view({name:'index'})">
<IconButton
:title="$t('home')"
class="text-white"
icon="home"
filled
/>
</RouterLink>
</div>
</div>
</template>

View File

@ -0,0 +1,9 @@
<template>
<div class="hidden sm:block">
<div class="py-8">
<div
class="border-t border-gray-200"
/>
</div>
</div>
</template>

View File

@ -0,0 +1,17 @@
<template>
<div class="md:col-span-1 flex justify-between">
<div class="px-4 sm:px-0">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
<slot name="title" />
</h3>
<p class="mt-1 text-sm text-gray-600 dark:text-white/50">
<slot name="description" />
</p>
</div>
<div class="px-4 sm:px-0">
<slot name="aside" />
</div>
</div>
</template>

View File

@ -0,0 +1,123 @@
<script setup>
import { users } from '@Plugins/AuthUsers'
import { hasPermission } from '@Plugins/RolePermission'
import { logout } from '@Services/Page';
import useDarkMode from '@Stores/DarkMode'
import useLeftSidebar from '@Stores/LeftSidebar'
import useNotificationSidebar from '@Stores/NotificationSidebar'
import useNotifier from '@Stores/Notifier'
import useLoader from '@Stores/Loader';
import Loader from '@Shared/Loader.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Dropdown from '../Dropdown.vue';
import DropdownLink from '../DropdownLink.vue';
/** Eventos */
const emit = defineEmits([
'open'
]);
/** Definidores */
const darkMode = useDarkMode()
const leftSidebar = useLeftSidebar()
const notificationSidebar = useNotificationSidebar()
const notifier = useNotifier()
const loader = useLoader()
</script>
<template>
<header
class="fixed px-2 w-[calc(100vw)] bg-transparent transition-all duration-300 z-50"
:class="{'md:w-[calc(100vw-16rem)]':leftSidebar.isOpened,'md:w-[calc(100vw)]':!leftSidebar.isClosed}"
>
<div class="my-2 flex px-2 items-center justify-between h-[2.75rem] rounded-sm bg-primary dark:bg-primary-d text-white z-20 ">
<GoogleIcon
class="text-2xl mt-1 z-50"
name="list"
:title="$t('menu')"
@click="emit('open')"
outline
/>
<div class="flex w-fit justify-end items-center h-14 header-right">
<ul class="flex items-center space-x-2">
<li v-if="loader.isProcessing" class="flex items-center">
<Loader />
</li>
<template v-if="notifier.isEnabled">
<li v-if="hasPermission('users.online')">
<RouterLink :to="{ name: 'admin.users.online' }" class="flex items-center">
<GoogleIcon
class="text-xl mt-1"
name="connect_without_contact"
:title="$t('notifications.title')"
/>
<span class="text-xs">{{ users.length - 1 }}</span>
</RouterLink>
</li>
</template>
<li class="flex items-center">
<GoogleIcon
class="text-xl mt-1"
name="notifications"
:title="$t('notifications.title')"
@click="notificationSidebar.toggle()"
/>
<span class="text-xs">{{ notifier.counter }}</span>
</li>
<li v-if="darkMode.isDark">
<GoogleIcon
class="text-xl mt-1"
name="light_mode"
:title="$t('notifications.title')"
@click="darkMode.applyLight()"
/>
</li>
<li v-else>
<GoogleIcon
class="text-xl mt-1"
name="dark_mode"
:title="$t('notifications.title')"
@click="darkMode.applyDark()"
/>
</li>
<li>
<div class="relative">
<Dropdown align="right" width="48">
<template #trigger>
<div class="flex space-x-4">
<button
class="flex items-center space-x-4 text-sm border-2 border-transparent rounded-full focus:outline-hidden cursor-pointer transition"
:title="$t('users.menu')"
>
<img
class="h-8 w-8 rounded-sm object-cover"
:alt="$page.user.name"
:src="$page.user.profile_photo_url"
>
</button>
</div>
</template>
<template #content>
<div class="text-center block px-4 py-2 text-sm border-b truncate">
{{ $page.user.name }}
</div>
<DropdownLink to="profile.show">
{{$t('profile')}}
</DropdownLink>
<div class="border-t border-gray-100" />
<form @submit.prevent="logout">
<DropdownLink as="button">
{{$t('auth.logout')}}
</DropdownLink>
</form>
</template>
</Dropdown>
</div>
</li>
</ul>
</div>
</div>
</header>
</template>

View File

@ -0,0 +1,60 @@
<script setup>
import { APP_VERSION, APP_COPYRIGHT } from '@/config.js'
import useLeftSidebar from '@Stores/LeftSidebar'
import Logo from '@Holos/Logo.vue';
/** Definidores */
const leftSidebar = useLeftSidebar()
/** Eventos */
const emit = defineEmits(['open']);
/** Propiedades */
const props = defineProps({
sidebar: Boolean
});
const year = (new Date).getFullYear();
</script>
<template>
<div
class="fixed top-[3.1rem] md:top-0 w-fit h-[calc(100vh-3.1rem)] md:h-screen transition-all duration-300 z-50"
:class="{'-translate-x-0':leftSidebar.isOpened, '-translate-x-64':leftSidebar.isClosed}"
>
<nav
class="flex md:w-64 h-full transition-all duration-300 border-none"
:class="{'w-64': leftSidebar.isClosed, 'w-screen': leftSidebar.isOpened}"
>
<div class="flex flex-col h-full p-2 md:w-64">
<div class="flex h-full flex-col w-[15.5rem] justify-between rounded-sm overflow-y-auto overflow-x-hidden bg-primary dark:bg-primary-d text-white">
<div>
<div class="flex w-full px-2 mt-2">
<Logo
class="text-lg inline-flex"
/>
</div>
<ul class="flex h-full flex-col md:pb-4 space-y-1">
<slot />
</ul>
</div>
<div class="mb-4 px-5 space-y-1">
<p class="block text-center text-xs">
&copy {{year}} {{ APP_COPYRIGHT }}
</p>
<p class="text-center text-xs text-yellow-500 cursor-pointer">
<RouterLink :to="{name:'changelogs.app'}"> APP {{ APP_VERSION }} </RouterLink> <RouterLink :to="{name:'changelogs.core'}"> API {{ $page.app.version }} </RouterLink>
</p>
</div>
</div>
</div>
<div
class="h-full"
:class="{'w-[calc(100vw-17rem)] md:w-0 bg-transparent':leftSidebar.isOpened,'md:w-0':leftSidebar.isClosed}"
@click="leftSidebar.toggle()"
></div>
</nav>
</div>
</template>

View File

@ -0,0 +1,58 @@
<script setup>
import { computed } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
import useLeftSidebar from '@Stores/LeftSidebar';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Definidores */
const leftSidebar = useLeftSidebar();
const vroute = useRoute();
/** Propiedades */
const props = defineProps({
icon: String,
name: String,
to: String
});
const classes = computed(() => {
let status = props.to === vroute.name
? 'bg-secondary/30 dark:bg-secondary-d/30 border-secondary dark:border-secondary-d'
: 'border-transparent';
return `flex items-center h-11 focus:outline-hidden hover:bg-secondary/30 dark:hover:bg-secondary-d/30 border-l-4 hover:border-secondary dark:hover:border-secondary-d pr-6 ${status} transition`
});
const closeSidebar = () => {
if(TwScreen.isDevice('phone') || TwScreen.isDevice('tablet')) {
leftSidebar.close();
}
};
</script>
<template>
<li @click="closeSidebar()">
<RouterLink
:class="classes"
:to="$view({name:to})"
>
<span
v-if="icon"
class="inline-flex justify-center items-center ml-4 mr-2"
>
<GoogleIcon
class="text-xl"
:name="icon"
outline
/>
</span>
<span
v-if="name"
v-text="$t(name)"
class="text-sm tracking-wide truncate"
/>
<slot />
</RouterLink>
</li>
</template>

View File

@ -0,0 +1,93 @@
<script setup>
import { ref } from 'vue';
import { RouterLink } from 'vue-router';
import useNotificationSidebar from '@Stores/NotificationSidebar'
import useNotifier from '@Stores/Notifier'
import ModalController from '@Controllers/ModalController.js';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Item from './Notification/Item.vue';
import PrimaryButton from '@Holos/Button/Primary.vue';
import ShowView from '@Holos/Skeleton/Sidebar/Notification/Show.vue';
/** Definidores */
const notifier = useNotifier();
const notificationSidebar = useNotificationSidebar()
/** Controladores */
const Modal = new ModalController();
/** Eventos */
const emit = defineEmits(['open']);
/** Propiedades */
const props = defineProps({
sidebar: Boolean
});
const showModal = ref(Modal.showModal);
const modelModal = ref(Modal.modelModal);
</script>
<template>
<div
class="fixed top-[3.1rem] right-[0.1rem] md:right-[0.5rem] w-fit h-[calc(100vh-3.2rem)] transition-all duration-300 z-50"
:class="{'translate-x-0':notificationSidebar.isOpened, 'translate-x-64':notificationSidebar.isClosed}"
>
<section
id="notifications"
class="flex md:w-64 h-full transition-all duration-300 border-none"
:class="{'w-64': notificationSidebar.isClosed}"
>
<div class="flex flex-col h-full p-2 md:w-64">
<div class="flex h-full flex-col w-[15.5rem] justify-between rounded-sm overflow-y-auto overflow-x-hidden bg-primary/70 text-primary-t dark:bg-primary-d/70 dark:text-primary-dt">
<div class="flex justify-between px-2 items-center">
<div class="py-1">
<h4 class="text-md font-semibold">
{{ $t('notifications.title') }} <span class="text-xs">({{ notifier.counter }})</span>
</h4>
<h4 class="text-xs font-semibold" v-if="notifier.unreadClosedCounter > 0">
{{ $t('notifications.unreadClosed') }} <span class="text-xs"> ({{ notifier.unreadClosedCounter }})</span>
</h4>
</div>
<GoogleIcon
name="close"
class="text-primary-t dark:text-primary-dt cursor-pointer"
@click="notificationSidebar.close()"
/>
</div>
<div class="flex h-full flex-col space-y-1">
<ul class="px-2 space-y-1 overflow-y-auto"
:class="{
'h-[calc(100vh-10rem)]': notifier.unreadClosedCounter > 0,
'h-[calc(100vh-9rem)]': notifier.unreadClosedCounter === 0
}"
>
<Item v-for="notification in notifier.notifications"
:key="notification.id"
:notification="notification"
@openModal="Modal.switchShowModal(notification)"
/>
</ul>
</div>
<div class="flex justify-center items-center pb-1">
<RouterLink :to="$view({ name: 'profile.notifications.index' })">
<PrimaryButton type="button" @click="notificationSidebar.close()">
{{ $t('notifications.seeAll') }}
</PrimaryButton>
</RouterLink>
</div>
</div>
</div>
</section>
<ShowView
:show="showModal"
:model="modelModal"
@close="Modal.switchShowModal"
@reload="notifier.getUpdates()"
/>
</div>
</template>

View File

@ -0,0 +1,79 @@
<script setup>
import { getDateTime } from '@Controllers/DateController';
import useNotifier from '@Stores/Notifier';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Definidores */
const notifier = useNotifier();
/** Eventos */
const emit = defineEmits([
'openModal'
]);
/** Propiedades */
defineProps({
notification: Object,
});
</script>
<template>
<li class="flex flex-col w-full items-center p-2 bg-primary dark:bg-primary-d text-white rounded-sm shadow-md">
<div class="flex w-full justify-between text-gray-400">
<div>
<h6 class="text-[10px]">{{ getDateTime(notification.created_at) }}</h6>
</div>
<div>
<GoogleIcon
name="close"
class="text-xs text-white cursor-pointer"
@click="notifier.closeNotification(notification.id)"
/>
</div>
</div>
<div class="flex w-full cursor-pointer">
<div class="w-10 space-y-0" @click="emit('openModal', notification)">
<template v-if="notification.user">
<div class="w-10 h-10 bg-transparent rounded-full flex items-center justify-center">
<img v-if="notification.user"
class="rounded-full object-cover"
:alt="notification.user.name"
:src="notification.user.profile_photo_url"
>
</div>
</template>
<template v-else>
<div class="w-10 h-10 bg-secondary dark:bg-secondary-d rounded-sm flex items-center justify-center">
<img v-if="notification.user"
class="rounded-full object-cover"
:alt="notification.user.name"
:src="notification.user.profile_photo_url"
>
<GoogleIcon v-else
name="tag"
class="text-white text-2xl"
/>
</div>
</template>
</div>
<div class="ml-3 w-full">
<div
v-text="notification.data.title"
class="text-sm font-medium truncate"
/>
<div
v-text="notification.data.description"
class="text-xs w-40 font-thin truncate"
/>
<div v-if="notification.user"
v-text="`~ ${notification.user.name} ${notification.user.paternal}`"
class="text-xs text-gray-400 truncate"
/>
<div v-else
v-text="$t('system.title')"
class="text-xs text-gray-400 truncate"
/>
</div>
</div>
</li>
</template>

View File

@ -0,0 +1,74 @@
<script setup>
import { computed, nextTick, onUpdated } from 'vue';
import { getDateTime } from '@Controllers/DateController';
import useNotifier from '@Stores/Notifier';
import Header from '@Holos/Modal/Elements/Header.vue';
import ShowModal from '@Holos/Modal/Show.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Definidores */
const notifier = useNotifier();
/** Eventos */
const emit = defineEmits([
'close',
'reload'
]);
/** Propiedades */
const props = defineProps({
show: Boolean,
model: Object
});
onUpdated(() => {
if(!props.model.read_at && props.show) {
notifier.readNotification(props.model.id);
}
if(!props.model.read_at && !props.show) {
emit('reload');
}
});
</script>
<template>
<ShowModal
:show="show"
@close="$emit('close')"
>
<Header
:title="model.data.title"
>
</Header>
<div class="py-2 border-b">
<div class="flex w-full px-4 py-2">
<GoogleIcon
class="text-xl text-success"
name="contact_mail"
/>
<div class="pl-3">
<p class="font-bold text-lg leading-none pb-2">
{{ $t('details') }}
</p>
<div class="flex flex-col">
<b>{{ $t('description') }}: </b>
{{ model.data.description }}
</div>
<div v-if="model.data.message" class="flex flex-col">
<b>{{ $t('message') }}: </b>
{{ model.data.message }}
</div>
<p>
<b>{{ $t('created_at') }}: </b>
{{ getDateTime(model.created_at) }}
</p>
<p v-if="model.read_at">
<b>{{ $t('read_at') }}: </b>
{{ getDateTime(model.read_at) }}
</p>
</div>
</div>
</div>
</ShowModal>
</template>

View File

@ -0,0 +1,36 @@
<script setup>
import useRightSidebar from '@Stores/RightSidebar'
/** Definidores */
const rightSidebar = useRightSidebar()
/** Eventos */
const emit = defineEmits(['open']);
/** Propiedades */
const props = defineProps({
sidebar: Boolean
});
</script>
<template>
<div
class="fixed top-[3.1rem] right-[0.1rem] md:right-[0.5rem] w-fit h-[calc(100vh-3.1rem)] transition-all duration-300 z-50"
:class="{'translate-x-0':rightSidebar.isOpened, 'translate-x-64':rightSidebar.isClosed}"
>
<nav
class="flex md:w-64 h-full transition-all duration-300 border-none"
:class="{'w-64': rightSidebar.isClosed, 'w-screen': rightSidebar.isOpened}"
>
<div class="flex flex-col h-full p-2 md:w-64">
<div class="flex h-full flex-col w-[15.5rem] justify-between rounded-sm overflow-y-auto overflow-x-hidden bg-primary dark:bg-primary-d text-white">
<div>
<ul class="flex h-full flex-col md:pb-4 space-y-1">
<slot />
</ul>
</div>
</div>
</div>
</nav>
</div>
</template>

View File

@ -0,0 +1,19 @@
<script setup>
/** Propiedades */
const props = defineProps({
name: String
});
</script>
<template>
<ul v-if="$slots['default']">
<li class="px-5 hidden md:block">
<div class="flex flex-row items-center h-8">
<div class="text-sm font-light tracking-wide text-gray-400 uppercase">
{{name}}
</div>
</div>
</li>
<slot />
</ul>
</template>

View File

@ -0,0 +1,106 @@
<script setup>
import GoogleIcon from '../Shared/GoogleIcon.vue';
import Loader from '../Shared/Loader.vue';
/** Eventos */
const emit = defineEmits([
'send-pagination'
]);
/** Propiedades */
const props = defineProps({
items: Object,
processing: Boolean
});
</script>
<template>
<section class="pb-2">
<div class="w-full overflow-hidden rounded-sm shadow-lg dark:shadow-xs dark:shadow-white">
<div class="w-full overflow-x-auto">
<table v-if="!processing" class="w-full">
<thead class="bg-primary text-primary-t dark:bg-primary-d dark:text-primary-dt">
<tr>
<slot name="head" />
</tr>
</thead>
<tbody>
<template v-if="items?.total > 0">
<slot
name="body"
:items="items?.data"
/>
</template>
<template v-else>
<tr>
<slot name="empty" />
</tr>
</template>
</tbody>
</table>
<table v-else class="animate-pulse w-full">
<thead>
<tr>
<th colspan="100%" class="h-8 text-center">
<div class="flex items-center justify-center">
<Loader />
</div>
</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="100%" class="table-cell h-7 text-center">
<div class="w-full h-4 bg-secondary/50 rounded-md"></div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<template v-if="items?.links">
<div v-if="items.links.length > 3" class="flex w-full justify-end">
<div class="flex w-full justify-end flex-wrap space-x-1 -mb-1">
<template v-for="(link, k) in items.links" :key="k">
<div v-if="link.url === null && k == 0"
class="px-2 py-1 text-sm leading-4 text-gray-400 border rounded"
>
<GoogleIcon
name="arrow_back"
/>
</div>
<button v-else-if="k === 0" class="px-2 py-1 text-sm leading-4 border rounded"
:class="{ 'bg-primary dark:bg-primary-dark text-white': link.active }"
@click="$emit('send-pagination', link.url)"
>
<GoogleIcon
name="arrow_back"
/>
</button>
<div v-else-if="link.url === null && k == (items.links.length - 1)"
class="px-2 py-1 text-sm leading-4 text-gray-400 border rounded"
>
<GoogleIcon
name="arrow_forward"
/>
</div>
<button v-else-if="k === (items.links.length - 1)" class="px-2 py-1 text-sm leading-4 border rounded"
:class="{ 'bg-primary dark:bg-primary-dark text-white': link.active }"
@click="$emit('send-pagination', link.url)"
>
<GoogleIcon
name="arrow_forward"
/>
</button>
<button v-else class="px-2 py-1 text-sm leading-4 border rounded"
:class="{ 'bg-primary dark:bg-primary-dark text-white': link.active }"
v-html="link.label"
@click="$emit('send-pagination', link.url)"
></button>
</template>
</div>
</div>
</template>
</template>

View File

@ -0,0 +1,33 @@
<script setup>
/** Eventos */
const emit = defineEmits([
'send-pagination'
]);
/** Propiedades */
const props = defineProps({
items: Object,
});
</script>
<template>
<section class="py-4">
<div class="w-full overflow-hidden rounded-md shadow-lg">
<div class="w-full overflow-x-auto">
<table class="w-full">
<thead class="bg-primary text-primary-t dark:bg-primary-d dark:text-primary-dt">
<tr>
<slot name="head" />
</tr>
</thead>
<tbody class="">
<slot
name="body"
:items="items"
/>
</tbody>
</table>
</div>
</div>
</section>
</template>

View File

@ -0,0 +1,94 @@
<script setup>
import { computed } from 'vue';
import { getDate, getTime } from '@Controllers/DateController';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Eventos */
const emit = defineEmits([
'show',
]);
/** Propiedades */
const props = defineProps({
event: Object,
});
const icons = {
created: 'add',
updated: 'edit',
deleted: 'delete',
restored: 'restore',
};
const colors = {
created: 'primary',
updated: 'primary',
deleted: 'danger',
restored: 'primary',
};
/** Propiedades computadas */
const eventType = computed(() => {
return props.event.event.split('.')[1];
});
const bgColor = computed(() => {
return `bg-${colors[eventType.value]} dark:bg-${colors[eventType.value]}-d text-${colors[eventType.value]}-t dark:text-${colors[eventType.value]}-t`;
});
const borderColor = computed(() => {
return `border-${colors[eventType.value]} dark:border-${colors[eventType.value]}-d`;
});
</script>
<template>
<li class="border-l-2" :class="borderColor">
<div class="relative flex w-full">
<div class="absolute -left-3.5 top-7 h-0.5 w-8" :class="bgColor"></div>
<div
class="absolute -mt-3 -left-3.5 top-7 w-6 h-6 flex items-center justify-center rounded-sm"
:class="bgColor"
@click="emit('show', event.data)"
>
<GoogleIcon
:name="icons[eventType]"
/>
</div>
<div class="w-full rounded-sm shadow-xl dark:shadow-page-dt dark:shadow-xs my-2 mx-4">
<div class="flex justify-between p-2 rounded-t-sm" :class="bgColor">
<span
class="font-medium text-sm cursor-pointer"
@click="emit('show', event.data)"
>
{{ $t('event')}}: <i class="underline">{{ event.event }}</i>
</span>
<span class="font-medium text-sm">
{{ getDate(event.created_at) }}, {{ getTime(event.created_at) }}
</span>
</div>
<div class="p-2">
<div class="flex flex-col justify-center items-center md:flex-row md:justify-start md:space-x-4">
<div v-if="event.user" class="w-32">
<div class="flex flex-col w-full justify-center items-center space-y-2">
<img :src="event.user?.profile_photo_url" alt="Photo" class="w-24 h-24 rounded-sm">
</div>
</div>
<div class="flex w-full flex-col justify-start space-y-2">
<div>
<h4 class="font-semibold">{{ $t('description') }}:</h4>
<p>{{ event.description }}.</p>
</div>
<div>
<h4 class="font-semibold">{{ $t('author') }}:</h4>
<p>{{ event.user?.full_name ?? $t('system.title') }} <span v-if="event.user?.deleted_at" class="text-xs text-gray-500">({{ $t('deleted') }})</span></p>
</div>
</div>
</div>
</div>
</div>
</div>
</li>
</template>

View File

@ -0,0 +1,30 @@
<script setup>
import { computed } from 'vue';
/** Propiedades */
const props = defineProps({
name: String,
fill: Boolean,
style: {
type: String,
default: 'rounded' // outlined, rounded, sharp
},
title: String
})
/** Propiedades computadas */
const classes = computed(() => {
return props.fill
? `font-google-icon-${props.style}-fill`
: `font-google-icon-${props.style}`
});
</script>
<template>
<span
v-text="name"
class="material-symbols cursor-pointer"
:class="classes"
translate="no"
/>
</template>

View File

@ -0,0 +1,6 @@
<template>
<svg class="animate-spin -ml-1 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</template>

11
src/config.js Normal file
View File

@ -0,0 +1,11 @@
import config from '../package.json'
const APP_COPYRIGHT = config.copyright
const APP_NAME = import.meta.env.VITE_APP_NAME
const APP_VERSION = config.version
export {
APP_NAME,
APP_VERSION,
APP_COPYRIGHT
}

View File

@ -0,0 +1,34 @@
import { DateTime } from "luxon";
// Obtener fecha en formato deseado
function getDate(value = null) {
const date = (value)
? DateTime.fromISO(value)
: DateTime.now();
return date.toLocaleString(DateTime.DATE_MED);
}
// Obtener hora en formato deseado
function getTime(value = null) {
const date = (value)
? DateTime.fromISO(value)
: DateTime.now();
return date.toLocaleString(DateTime.TIME_24_SIMPLE);
}
// Obtener fecha y hora
function getDateTime(value) {
const date = (value)
? DateTime.fromISO(value)
: DateTime.now();
return date.toLocaleString(DateTime.DATETIME_SHORT);
}
export {
getDate,
getDateTime,
getTime
}

View File

@ -0,0 +1,102 @@
import { ref } from 'vue';
/**
* Controlador simple de las bandejas
*/
class InboxController
{
inboxIdSelected = ref([]);
inboxNumberSelected = ref([]);
selectAll = ref(false);
constructor() {}
/**
* Selecciona todas las opciones visibles
*/
onSelectAll = (elements) => {
this.inboxIdSelected.value = [];
this.inboxNumberSelected.value = [];
this.selectAll.value = !this.selectAll.value;
if(this.selectAll.value) {
elements.data.forEach(element => {
this.inboxIdSelected.value.push(element.id);
this.inboxNumberSelected.value.push(element.number);
});
}
}
/**
* Selecciona solo una opcion
*/
onSelectOne = (invoice) => {
this.inboxIdSelected.value.push(invoice.id);
this.inboxNumberSelected.value.push(invoice.number);
}
/**
* Permite que siempresea solo una opcion seleccionada
*/
onlyOne = (invoice) => {
this.clear();
this.onSelectOne(invoice);
}
/**
* Quita la seleccion
*/
onUnselectOne = (invoice) => {
this.inboxIdSelected.value = this.inboxIdSelected.value.filter(element => {
return element !== invoice.id;
});
this.inboxNumberSelected.value = this.inboxNumberSelected.value.filter(element => {
return element !== invoice.number;
});
}
/**
* Retorna todos los IDs de los elementos seleccionados
*/
getIdSelections = () => {
return this.inboxIdSelected.value;
}
/**
* Trata al ID como si fueran muchos
*
* Si no se pasa mimgun ID, se devolveran todos los elementos seleccionados almacenados
*/
getAsMany = (id) => {
return (id) ? [ id ] : this.getIdSelections();
}
/**
* Retorna todos los numeros de las facturas/seleccionadas
*/
getNumberSelections = () => {
return this.inboxNumberSelected.value;
}
/**
* Limpia los valores seleccionados
*/
clear = () => {
this.inboxIdSelected.value = [];
this.inboxNumberSelected.value = [];
this.selectAll.value = false;
}
/**
* Limpia los valores seleccionados con una notificacion de exito
*/
clearWithSuccess = (message) => {
this.clear();
Notify.success(message);
}
}
export default InboxController;

View File

@ -0,0 +1,92 @@
import { ref } from 'vue';
/**
* Controlador simple de las bandejas
*/
class ModalController
{
// Modals
confirmModal = ref(false);
destroyModal = ref(false);
editModal = ref(false);
noteModal = ref(false);
manyNotesModal = ref(false);
showModal = ref(false);
importModal = ref(false);
// Models
modelModal = ref({});
constructor() {}
/**
* Controla el cambio entre show y edit
*/
switchShowEditModal = () => {
this.showModal.value = !this.showModal.value
this.editModal.value = !this.editModal.value
};
/**
* Controla el switch de eliminar
*/
switchShowModal = (model) => {
this._setModel(model);
this.showModal.value = !this.showModal.value
};
/**
* Controla el switch de importar
*/
switchImportModal = () => {
this.importModal.value = !this.importModal.value
};
/**
* Controla el switch de eliminar
*/
switchEditModal = (model) => {
this._setModel(model);
this.editModal.value = !this.editModal.value
};
/**
* Controla el switch de eliminar
*/
switchDestroyModal = (model) => {
this._setModel(model);
this.destroyModal.value = !this.destroyModal.value
};
/**
* Controla el switch de nota
*/
switchNoteModal = () => {
this.noteModal.value = !this.noteModal.value
};
/**
* Controla el switch de notas aplicadas a muchos
*/
switchManyNotesModal = () => {
this.manyNotesModal.value = !this.manyNotesModal.value
};
/**
* Controla el switch de nota
*/
switchConfirmModal = () => {
this.confirmModal.value = !this.confirmModal.value
};
/**
* Guarda el modelo
*/
_setModel = (model) => {
if(model) {
this.modelModal.value = model;
}
}
}
export default ModalController;

View File

@ -0,0 +1,51 @@
import { ref } from 'vue';
/**
* Controla la generación de impresiones
*/
class PrintController
{
invoices = ref(false);
constructor({route, meta, params = {}, name = "Comprobante", type='pdf'}) {
this.route = route;
this.meta = meta;
this.params = params;
this.name = name;
this.type = type;
}
/**
* Manda la orden de impresión y descarga
*/
download = (data = {}) => {
Notify.info('Generando archivo, espere ...');
axios({
url: route(this.route, this.params),
method: 'POST',
data: {
meta: this.meta,
data: data
},
responseType: 'blob'
}).then((response) => {
const href = URL.createObjectURL(response.data);
const link = document.createElement('a');
link.href = href;
link.setAttribute('download', `${this.name}.${this.type}`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(href);
Notify.info('Archivo generado');
}).catch(err => {
Notify.error('Error al generar');
});
}
}
export default PrintController;

87
src/css/app.css Normal file
View File

@ -0,0 +1,87 @@
@import "tailwindcss";
@import "../../colors.css";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--font-google-icon-outlined: "Material Symbols Outlined";
--font-google-icon-outlined-fill: "Material Symbols Outlined Fill";
--font-google-icon-rounded: "Material Symbols Rounded";
--font-google-icon-rounded-fill: "Material Symbols rounded-sm Fill";
--font-google-icon-sharp: "Material Symbols Sharp";
--font-google-icon-sharp-fill: "Material Symbols Sharp Fill";
}
.app-bg-light {
@apply bg-primary
}
.app-bg-dark {
@apply bg-primary-d
}
.btn {
@apply inline-flex justify-center items-center w-fit px-1.5 py-1.5 rounded-sm font-medium border border-transparent text-xs text-white uppercase tracking-widest hover:opacity-90 focus:outline-hidden active:saturate-150 disabled:opacity-25 cursor-pointer transition;
}
.btn-primary {
@apply bg-primary dark:bg-primary-d text-primary-t dark:text-primary-dt hover:bg-secondary dark:hover:bg-secondary-d hover:text-secondary-t dark:hover:text-secondary-dt;
}
.btn-secondary {
@apply bg-secondary dark:bg-secondary-d text-secondary-t dark:text-secondary-dt hover:bg-secondary dark:hover:bg-secondary-d hover:text-secondary-t dark:hover:text-secondary-dt;
}
.btn-success {
@apply bg-success dark:bg-success-d text-success-t dark:text-success-dt hover:bg-success dark:hover:bg-success hover:text-success-t dark:hover:text-success-dt;
}
.btn-danger {
@apply bg-danger dark:bg-danger-d text-danger-t dark:text-danger-dt hover:bg-danger dark:hover:bg-danger hover:text-danger-t dark:hover:text-danger-dt;
}
.btn-warning {
@apply bg-warning dark:bg-warning-d text-warning-t dark:text-warning-dt hover:bg-warning dark:hover:bg-warning hover:text-warning-t dark:hover:text-warning-dt;
}
.btn-icon {
@apply flex w-fit min-h-6 px-1.5 py-1.5 rounded-sm font-medium bg-primary dark:bg-primary-d text-primary-t dark:text-primary-dt hover:bg-secondary dark:hover:bg-secondary-d hover:text-secondary-t dark:hover:text-secondary-dt cursor-pointer;
}
.input-primary {
@apply w-full p-[6.5px] border-b border-page-t/50 dark:border-page-dt/50 bg-primary/5 rounded-sm outline-0
}
.nav-item {
@apply p-1
}
.table-row {
@apply hover:bg-secondary/10 dark:hover:bg-secondary-d/10 transition-colors duration-100
}
.table-cell {
@apply px-2 py-0.5 text-sm border border-primary/30 dark:border-primary-dt/30;
}
.table-actions {
@apply flex justify-center items-center space-x-1;
}
nav a.router-link-active {
@apply bg-secondary/30 dark:bg-secondary-d/30 border-secondary dark:border-secondary-d
}
.with-transition {
@apply transition-all duration-300
}
/**
* Switch
*/
.toggle-checkbox:checked {
@apply right-0 border-slate-500
}
.toggle-checkbox:checked + .toggle-label {
@apply bg-slate-500
}

5
src/css/base.css Normal file
View File

@ -0,0 +1,5 @@
@import './app.css';
@import './icons.css';
@import './notifications.css';
@import "vue-multiselect/dist/vue-multiselect.css";
@import './multiselect.css';

77
src/css/icons.css Normal file
View File

@ -0,0 +1,77 @@
/*
* Outlined
*
* https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0
* https://fonts.gstatic.com/s/materialsymbolsoutlined/v170/kJF1BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzByHX9rA6RzaxHMPdY43zj-jCxv3fzvRNU22ZXGJpEpjC_1v-p_4MrImHCIJIZrDCvHOej.woff2
*/
@font-face {
font-family: 'Material Symbols Outlined';
font-style: normal;
font-weight: 400;
src: url(./icons/google/kJF1BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzByHX9rA6RzaxHMPdY43zj-jCxv3fzvRNU22ZXGJpEpjC_1v-p_4MrImHCIJIZrDCvHOej.woff2) format('woff2');
}
/**
* Outlined fill
*
* https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,1,0
* https://fonts.gstatic.com/s/materialsymbolsoutlined/v170/kJF1BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzByHX9rA6RzazHD_dY43zj-jCxv3fzvRNU22ZXGJpEpjC_1v-p_4MrImHCIJIZrDCvHOej.woff2
*/
@font-face {
font-family: 'Material Symbols Outlined Fill';
font-style: normal;
font-weight: 400;
src: url(./icons/google/kJF1BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzByHX9rA6RzazHD_dY43zj-jCxv3fzvRNU22ZXGJpEpjC_1v-p_4MrImHCIJIZrDCvHOej.woff2) format('woff2');
}
/**
* Rounded
*
* https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,400,0,0
* https://fonts.gstatic.com/s/materialsymbolsrounded/v168/syl0-zNym6YjUruM-QrEh7-nyTnjDwKNJ_190FjpZIvDmUSVOK7BDB_Qb9vUSzq3wzLK-P0J-V_Zs-QtQth3-jOcbTCVpeRL2w5rwZu2rIelXxc.woff2
*/
@font-face {
font-family: 'Material Symbols Rounded';
font-style: normal;
font-weight: 400;
src: url(./icons/google/syl0-zNym6YjUruM-QrEh7-nyTnjDwKNJ_190FjpZIvDmUSVOK7BDB_Qb9vUSzq3wzLK-P0J-V_Zs-QtQth3-jOcbTCVpeRL2w5rwZu2rIelXxc.woff2) format('woff2');
}
/**
* rounded-sm fill
*
* https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,400,1,0
* https://fonts.gstatic.com/s/materialsymbolsrounded/v168/syl0-zNym6YjUruM-QrEh7-nyTnjDwKNJ_190FjpZIvDmUSVOK7BDJ_vb9vUSzq3wzLK-P0J-V_Zs-QtQth3-jOcbTCVpeRL2w5rwZu2rIelXxc.woff2
*/
@font-face {
font-family: 'Material Symbols rounded-sm Fill';
font-style: normal;
font-weight: 400;
src: url(./icons/google/syl0-zNym6YjUruM-QrEh7-nyTnjDwKNJ_190FjpZIvDmUSVOK7BDJ_vb9vUSzq3wzLK-P0J-V_Zs-QtQth3-jOcbTCVpeRL2w5rwZu2rIelXxc.woff2) format('woff2');
}
/**
* Sharp
*
* https://fonts.googleapis.com/css2?family=Material+Symbols+Sharp:opsz,wght,FILL,GRAD@24,400,0,0
* https://fonts.gstatic.com/s/materialsymbolssharp/v166/gNNBW2J8Roq16WD5tFNRaeLQk6-SHQ_R00k4c2_whPnoY9ruReaU4bHmz74m0ZkGH-VBYe1x0TV6x4yFH8F-H5OdzEL3sVTgJtfbYxOLojCL.woff2
*/
@font-face {
font-family: 'Material Symbols Sharp';
font-style: normal;
font-weight: 400;
src: url(./icons/google/gNNBW2J8Roq16WD5tFNRaeLQk6-SHQ_R00k4c2_whPnoY9ruReaU4bHmz74m0ZkGH-VBYe1x0TV6x4yFH8F-H5OdzEL3sVTgJtfbYxOLojCL.woff2) format('woff2');
}
/**
* Sharp fill
*
* https://fonts.googleapis.com/css2?family=Material+Symbols+Sharp:opsz,wght,FILL,GRAD@24,400,1,0
* https://fonts.gstatic.com/s/materialsymbolssharp/v166/gNNBW2J8Roq16WD5tFNRaeLQk6-SHQ_R00k4c2_whPnoY9ruReYU3rHmz74m0ZkGH-VBYe1x0TV6x4yFH8F-H5OdzEL3sVTgJtfbYxOLojCL.woff2
*/
@font-face {
font-family: 'Material Symbols Sharp Fill';
font-style: normal;
font-weight: 400;
src: url(./icons/google/gNNBW2J8Roq16WD5tFNRaeLQk6-SHQ_R00k4c2_whPnoY9ruReYU3rHmz74m0ZkGH-VBYe1x0TV6x4yFH8F-H5OdzEL3sVTgJtfbYxOLojCL.woff2) format('woff2');
}

57
src/css/multiselect.css Normal file
View File

@ -0,0 +1,57 @@
.multiselect {
@apply dark:text-white min-h-8;
}
.multiselect__input,
.multiselect__single {
@apply bg-white dark:bg-transparent dark:text-white
}
.multiselect__input::placeholder {
@apply text-gray-300;
}
.multiselect__tag {
@apply bg-success dark:bg-success-d;
}
.multiselect__tag-icon::after {
@apply text-gray-300;
}
.multiselect__tag-icon:focus::after,
.multiselect__tag-icon:hover::after {
@apply text-white;
}
.multiselect__tags {
@apply bg-primary/5 dark:bg-primary-d/5 min-h-8 border-0 border-b border-page-t/50 dark:border-page-dt/50 pt-1;
}
.multiselect__option--highlight {
@apply dark:bg-green-900 dark:text-white outline-0;
}
.multiselect__option--highlight::after {
@apply dark:bg-green-800 dark:text-white;
}
.multiselect__option--selected.multiselect__option--highlight {
@apply bg-red-500 dark:bg-red-600 text-white;
}
.multiselect__option--selected.multiselect__option--highlight::after {
@apply bg-red-400 dark:bg-red-500 text-white;
}
.multiselect__content-wrapper {
@apply bg-page dark:bg-page-d;
}
.multiselect--disabled,
.multiselect--disabled .multiselect__select,
.multiselect--disabled .multiselect__current,
.multiselect__option--disabled.multiselect__option--highlight{
background: none;
}

234
src/css/notifications.css Normal file
View File

@ -0,0 +1,234 @@
.toast-title {
font-weight: bold;
}
.toast-message {
-ms-word-wrap: break-word;
word-wrap: break-word;
}
.toast-message a,
.toast-message label {
color: #FFFFFF;
}
.toast-message a:hover {
color: #CCCCCC;
text-decoration: none;
}
.toast-close-button {
position: relative;
right: -0.3em;
top: -0.3em;
float: right;
font-size: 20px;
font-weight: bold;
color: #FFFFFF;
-webkit-text-shadow: 0 1px 0 #ffffff;
text-shadow: 0 1px 0 #ffffff;
opacity: 0.8;
-ms-filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=80);
filter: alpha(opacity=80);
line-height: 1;
}
.toast-close-button:hover,
.toast-close-button:focus {
color: #000000;
text-decoration: none;
cursor: pointer;
opacity: 0.4;
-ms-filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=40);
filter: alpha(opacity=40);
}
.rtl .toast-close-button {
left: -0.3em;
float: left;
right: 0.3em;
}
/*Additional properties for button version
iOS requires the button element instead of an anchor tag.
If you want the anchor version, it requires `href="#"`.*/
button.toast-close-button {
padding: 0;
cursor: pointer;
background: transparent;
border: 0;
-webkit-appearance: none;
}
.toast-top-center {
top: 0;
right: 0;
width: 100%;
}
.toast-bottom-center {
bottom: 0;
right: 0;
width: 100%;
}
.toast-top-full-width {
top: 0;
right: 0;
width: 100%;
}
.toast-bottom-full-width {
bottom: 0;
right: 0;
width: 100%;
}
.toast-top-left {
top: 12px;
left: 12px;
}
.toast-top-right {
top: 12px;
right: 12px;
}
.toast-bottom-right {
right: 12px;
bottom: 12px;
}
.toast-bottom-left {
bottom: 12px;
left: 12px;
}
#toast-container {
position: fixed;
z-index: 999999;
pointer-events: none;
/*overrides*/
}
#toast-container * {
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
box-sizing: border-box;
}
#toast-container > div {
position: relative;
pointer-events: auto;
overflow: hidden;
margin: 0 0 6px;
padding: 15px 15px 15px 50px;
width: 300px;
-moz-border-radius: 3px 3px 3px 3px;
-webkit-border-radius: 3px 3px 3px 3px;
border-radius: 3px 3px 3px 3px;
background-position: 15px center;
background-repeat: no-repeat;
-moz-box-shadow: 0 0 12px #999999;
-webkit-box-shadow: 0 0 12px #999999;
box-shadow: 0 0 12px #999999;
color: #FFFFFF;
opacity: 0.8;
-ms-filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=80);
filter: alpha(opacity=80);
}
#toast-container > div.rtl {
direction: rtl;
padding: 15px 50px 15px 15px;
background-position: right 15px center;
}
#toast-container > div:hover {
-moz-box-shadow: 0 0 12px #000000;
-webkit-box-shadow: 0 0 12px #000000;
box-shadow: 0 0 12px #000000;
opacity: 1;
-ms-filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=100);
filter: alpha(opacity=100);
cursor: pointer;
}
#toast-container > .toast-info {
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAGwSURBVEhLtZa9SgNBEMc9sUxxRcoUKSzSWIhXpFMhhYWFhaBg4yPYiWCXZxBLERsLRS3EQkEfwCKdjWJAwSKCgoKCcudv4O5YLrt7EzgXhiU3/4+b2ckmwVjJSpKkQ6wAi4gwhT+z3wRBcEz0yjSseUTrcRyfsHsXmD0AmbHOC9Ii8VImnuXBPglHpQ5wwSVM7sNnTG7Za4JwDdCjxyAiH3nyA2mtaTJufiDZ5dCaqlItILh1NHatfN5skvjx9Z38m69CgzuXmZgVrPIGE763Jx9qKsRozWYw6xOHdER+nn2KkO+Bb+UV5CBN6WC6QtBgbRVozrahAbmm6HtUsgtPC19tFdxXZYBOfkbmFJ1VaHA1VAHjd0pp70oTZzvR+EVrx2Ygfdsq6eu55BHYR8hlcki+n+kERUFG8BrA0BwjeAv2M8WLQBtcy+SD6fNsmnB3AlBLrgTtVW1c2QN4bVWLATaIS60J2Du5y1TiJgjSBvFVZgTmwCU+dAZFoPxGEEs8nyHC9Bwe2GvEJv2WXZb0vjdyFT4Cxk3e/kIqlOGoVLwwPevpYHT+00T+hWwXDf4AJAOUqWcDhbwAAAAASUVORK5CYII=") !important;
}
#toast-container > .toast-error {
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAHOSURBVEhLrZa/SgNBEMZzh0WKCClSCKaIYOED+AAKeQQLG8HWztLCImBrYadgIdY+gIKNYkBFSwu7CAoqCgkkoGBI/E28PdbLZmeDLgzZzcx83/zZ2SSXC1j9fr+I1Hq93g2yxH4iwM1vkoBWAdxCmpzTxfkN2RcyZNaHFIkSo10+8kgxkXIURV5HGxTmFuc75B2RfQkpxHG8aAgaAFa0tAHqYFfQ7Iwe2yhODk8+J4C7yAoRTWI3w/4klGRgR4lO7Rpn9+gvMyWp+uxFh8+H+ARlgN1nJuJuQAYvNkEnwGFck18Er4q3egEc/oO+mhLdKgRyhdNFiacC0rlOCbhNVz4H9FnAYgDBvU3QIioZlJFLJtsoHYRDfiZoUyIxqCtRpVlANq0EU4dApjrtgezPFad5S19Wgjkc0hNVnuF4HjVA6C7QrSIbylB+oZe3aHgBsqlNqKYH48jXyJKMuAbiyVJ8KzaB3eRc0pg9VwQ4niFryI68qiOi3AbjwdsfnAtk0bCjTLJKr6mrD9g8iq/S/B81hguOMlQTnVyG40wAcjnmgsCNESDrjme7wfftP4P7SP4N3CJZdvzoNyGq2c/HWOXJGsvVg+RA/k2MC/wN6I2YA2Pt8GkAAAAASUVORK5CYII=") !important;
}
#toast-container > .toast-success {
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAADsSURBVEhLY2AYBfQMgf///3P8+/evAIgvA/FsIF+BavYDDWMBGroaSMMBiE8VC7AZDrIFaMFnii3AZTjUgsUUWUDA8OdAH6iQbQEhw4HyGsPEcKBXBIC4ARhex4G4BsjmweU1soIFaGg/WtoFZRIZdEvIMhxkCCjXIVsATV6gFGACs4Rsw0EGgIIH3QJYJgHSARQZDrWAB+jawzgs+Q2UO49D7jnRSRGoEFRILcdmEMWGI0cm0JJ2QpYA1RDvcmzJEWhABhD/pqrL0S0CWuABKgnRki9lLseS7g2AlqwHWQSKH4oKLrILpRGhEQCw2LiRUIa4lwAAAABJRU5ErkJggg==") !important;
}
#toast-container > .toast-warning {
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAGYSURBVEhL5ZSvTsNQFMbXZGICMYGYmJhAQIJAICYQPAACiSDB8AiICQQJT4CqQEwgJvYASAQCiZiYmJhAIBATCARJy+9rTsldd8sKu1M0+dLb057v6/lbq/2rK0mS/TRNj9cWNAKPYIJII7gIxCcQ51cvqID+GIEX8ASG4B1bK5gIZFeQfoJdEXOfgX4QAQg7kH2A65yQ87lyxb27sggkAzAuFhbbg1K2kgCkB1bVwyIR9m2L7PRPIhDUIXgGtyKw575yz3lTNs6X4JXnjV+LKM/m3MydnTbtOKIjtz6VhCBq4vSm3ncdrD2lk0VgUXSVKjVDJXJzijW1RQdsU7F77He8u68koNZTz8Oz5yGa6J3H3lZ0xYgXBK2QymlWWA+RWnYhskLBv2vmE+hBMCtbA7KX5drWyRT/2JsqZ2IvfB9Y4bWDNMFbJRFmC9E74SoS0CqulwjkC0+5bpcV1CZ8NMej4pjy0U+doDQsGyo1hzVJttIjhQ7GnBtRFN1UarUlH8F3xict+HY07rEzoUGPlWcjRFRr4/gChZgc3ZL2d8oAAAAASUVORK5CYII=") !important;
}
#toast-container.toast-top-center > div,
#toast-container.toast-bottom-center > div {
width: 300px;
margin-left: auto;
margin-right: auto;
}
#toast-container.toast-top-full-width > div,
#toast-container.toast-bottom-full-width > div {
width: 96%;
margin-left: auto;
margin-right: auto;
}
.toast {
background-color: #030303;
}
.toast-success {
@apply bg-success dark:bg-success-d;
}
.toast-error {
@apply bg-danger dark:bg-danger-d;
}
.toast-info {
@apply bg-primary-info dark:bg-primary-info-d;
}
.toast-warning {
@apply bg-warning dark:bg-warning-d;
}
.toast-progress {
position: absolute;
left: 0;
bottom: 0;
height: 4px;
background-color: #000000;
opacity: 0.4;
-ms-filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=40);
filter: alpha(opacity=40);
}
/*Responsive Design*/
@media all and (max-width: 240px) {
#toast-container > div {
padding: 8px 8px 8px 50px;
width: 11em;
}
#toast-container > div.rtl {
padding: 8px 50px 8px 8px;
}
#toast-container .toast-close-button {
right: -0.2em;
top: -0.2em;
}
#toast-container .rtl .toast-close-button {
left: -0.2em;
right: 0.2em;
}
}
@media all and (min-width: 241px) and (max-width: 480px) {
#toast-container > div {
padding: 8px 8px 8px 50px;
width: 18em;
}
#toast-container > div.rtl {
padding: 8px 50px 8px 8px;
}
#toast-container .toast-close-button {
right: -0.2em;
top: -0.2em;
}
#toast-container .rtl .toast-close-button {
left: -0.2em;
right: 0.2em;
}
}
@media all and (min-width: 481px) and (max-width: 768px) {
#toast-container > div {
padding: 15px 15px 15px 50px;
width: 25em;
}
#toast-container > div.rtl {
padding: 15px 50px 15px 15px;
}
}

74
src/index.js Normal file
View File

@ -0,0 +1,74 @@
import './css/base.css'
import axios from 'axios';
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import { useRoute, ZiggyVue } from 'ziggy-js';
import { i18n, lang } from '@Lang/i18n.js';
import router from '@Router/Index'
import Notify from '@Plugins/Notify'
import { bootPermissions, bootRoles } from '@Plugins/RolePermission';
import TailwindScreen from '@Plugins/TailwindScreen'
import { pagePlugin } from '@Services/Page';
import { defineApp, reloadApp, view } from '@Services/Page';
import { apiURL } from '@Services/Api';
import App from '@Components/App.vue'
import Error503 from '@Pages/Errors/503.vue'
import { hasToken } from './services/Api';
// Configurar axios
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
// Elementos globales
window.axios = axios;
window.Lang = lang;
window.Notify = new Notify();
window.TwScreen = new TailwindScreen();
async function boot() {
let initRoutes = false;
// Iniciar rutas
try {
const routes = await axios.get(apiURL('resources/routes'));
const appData = await axios.get(apiURL('resources/app'));
window.Ziggy = routes.data;
defineApp(appData.data);
window.route = useRoute();
window.view = view;
initRoutes = true;
} catch (error) {
window.Notify.error(window.Lang('server.api.noAvailable'));
}
if(initRoutes) {
// Iniciar permisos
if(hasToken()) {
await bootPermissions();
await bootRoles();
// Iniciar broadcast
if(import.meta.env.VITE_REVERB_ACTIVE === 'true') {
await import('@Services/Broadcast')
}
}
reloadApp();
createApp(App)
.use(createPinia())
.use(i18n)
.use(pagePlugin)
.use(router)
.use(ZiggyVue)
.mount('#app');
} else {
createApp(Error503)
.mount('#app');
}
}
// Iniciar aplicación
boot();

205
src/lang/en.js Normal file
View File

@ -0,0 +1,205 @@
export default {
'&':'and',
account: {
delete: {
confirm:'Are you sure you want to delete your account? Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.',
description: 'Permanently delete your account.',
onDelete:'Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.',
title:'Delete Account',
},
email: {
notifySendVerification:'A new verification link has been sent to your email address.',
sendVerification:'Click here to re-send the verification email',
unverify: 'Your email address is unverified.',
},
manage:'Manage Account',
password: {
description:'Ensure your account is using a long, random password to stay secure.',
new:'New password',
reset:'Reset password',
secure:'This is a secure area of the application. Please confirm your password before continuing.',
update: 'Update Password',
verify:'For your security, please confirm your password to continue.',
},
profile: {
description:'Update your account\'s profile information and email address.',
title:'Profile Information',
},
sessions: {
confirm:'Please enter your password to confirm you would like to log out of your other browser sessions across all of your devices.',
description: 'Manage and log out your active sessions on other browsers and devices.',
last:'Last active',
logout:'Log Out Other Browser Sessions',
onLogout:'If necessary, you may log out of all of your other browser sessions across all of your devices. Some of your recent sessions are listed below; however, this list may not be exhaustive. If you feel your account has been compromised, you should also update your password.',
this: 'This device',
title: 'Browser Sessions',
},
twoFactor: {
codes:{
regenerate:'Regenerate Recovery Codes',
show:'Show Recovery Codes',
store:'Store these recovery codes in a secure password manager. They can be used to recover access to your account if your two factor authentication device is lost.',
},
description:'Add additional security to your account using two factor authentication.',
isEnable:'You have enabled two factor authentication.',
isNotEnable:{
title:'You have not enabled two factor authentication.',
description:'When two factor authentication is enabled, you will be prompted for a secure, random token during authentication. You may retrieve this token from your phone\'s Google Authenticator application.',
},
key:'Setup Key',
login: {
onAuth: 'Please confirm access to your account by entering the authentication code provided by your authenticator application.',
onRecovery: 'Please confirm access to your account by entering one of your emergency recovery codes.',
},
onFinish:'Finish enabling two factor authentication.',
qr: {
isConfirmed: 'Two factor authentication is now enabled. Scan the following QR code using your phone\'s authenticator application or enter the setup key.',
onConfirmed: 'To finish enabling two factor authentication, scan the following QR code using your phone\'s authenticator application or enter the setup key and provide the generated OTP code.',
},
recovery: {
code: 'Recovery code',
useAuth: 'Use an authentication code',
useCode: 'Use a recovery code',
},
title:'Two Factor Authentication',
},
},
actions:'Actions',
auth: {
forgotPassword: {
ask: 'Forgot your password?',
description: 'Forgot your password? No problem. Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.',
sendLink: 'Email Password Reset Link',
title: 'Forgot Password',
},
login: 'Log In',
logout: 'Log Out',
register: {
already: 'Already registered?',
me: 'Register me',
},
remember: 'Remember me',
},
code:'Code',
cancel:'Cancel',
changelogs: {
title:'Changelogs',
description: 'List of changes made to the system.',
},
close:"Close",
confirm:'Confirm',
copyright:'All rights reserved.',
contact:'Contact',
description:'Description',
date:'Date',
delete: {
confirm:"By pressing DELETE the record will be permanently deleted and cannot be recovered.",
title:'Deleted',
},
deleted:'Deleted',
details:'Details',
disable:'Disable',
disabled:'Disable',
done:'Done.',
edit:'Edit',
email:'Email',
enable:'Enable',
endDate:'End date',
event:'Event',
help: {
description:'The following is a list of iconography to understand how the system works.',
home: 'Back to home page.',
title:'Help',
},
history: {
title:'Stock history',
description:'History of actions performed by users in chronological order.',
},
home:'Home',
hour:'Hour',
icon:'Icon',
maternal:'Mother\'s last name',
name:'Name',
noRecords:'No records',
notifications: {
deleted:'Notification deleted',
description:'User notifications',
notFound:'Notification not found',
title:'Notifications',
},
password:'Password',
passwordConfirmation:'Confirm Password',
passwordCurrent:'Current Password',
paternal:'Paternal surname',
phone:'Phone Number',
photo: {
new: 'Select A New Photo',
remove:'Remove Photo',
title:'Photo',
},
profile:'Profile',
readed:'Readed',
register: {
agree:'I agree to the',
privacy:'Privacy Policy',
signUp:'Sign Up',
terms:'Terms of Service',
},
role:'Role',
roles:{
create: {
title: 'Create role',
description: 'These roles will be used to give permissions in the system.',
onSuccess: 'Role successfully created',
onError: 'Error when creating the role',
},
deleted:'Role deleted',
title: 'Roles',
},
save:'Save',
saved:'Saved!',
search:'Search',
show: {
all:'Show All',
title:'Show',
},
startDate:'Start date',
status:'Status',
terms: {
agree:'I agree to the',
privacy:'Privacy Policy',
service:'Terms of Service',
},
unknown:'Unknown',
update:'Update',
updated:'Updated',
updateFail:'Error while updating',
unreaded:'Unreaded',
user:'User',
users:{
create:{
title:'Create user',
description:'Allows you to create new users. Don\'t forget to give them roles so that they can access the desired parts of the system.',
onSuccess:'User created',
onError:'An error occurred while creating the user',
},
deleted:'User deleted',
notFount:'User not found',
password: {
description:'Allows users\' passwords to be updated',
title:'Update password',
},
roles: {
description:'Updates user roles, allowing or denying access to certain areas.',
error:{
min:'Select at least one role'
},
title:'Roles',
},
select:'Select a user',
settings:'User setting',
system:'System users',
title:'Users',
},
version:'Version',
}

452
src/lang/es.js Normal file
View File

@ -0,0 +1,452 @@
import { success } from "toastr";
export default {
'&':'y',
account: {
delete: {
confirm:'¿Está seguro de que quiere eliminar su cuenta? Una vez eliminada su cuenta, todos sus recursos y datos se borrarán permanentemente. Por favor, introduzca su contraseña para confirmar que desea eliminar permanentemente su cuenta.',
description:'Eliminar permanentemente su cuenta.',
onDelete:'Una vez eliminada su cuenta, todos sus recursos y datos se borrarán permanentemente. Antes de eliminar su cuenta, descargue los datos o la información que desee conservar.',
title:'Eliminar cuenta',
},
email: {
notifySendVerification:'Se ha enviado un nuevo enlace de verificación a su dirección de correo electrónico.',
sendVerification:'Haga clic aquí para volver a enviar el correo electrónico de verificación.',
unverify: 'Su dirección de correo electrónico no está verificada.',
},
manage:'Administrar cuenta',
password: {
description:'Asegúrese de que su cuenta utiliza una contraseña larga y aleatoria para estar seguro.',
new:'Nueva contraseña',
reset:'Restaurar contraseña',
secure:'Esta es una zona segura de la aplicación. Confirme su contraseña antes de continuar.',
update: 'Actualizar contraseña',
updated:'Contraseña actualizada',
verify:'Por su seguridad, confirme su contraseña para continuar.',
},
profile: {
description:'Actualice la información del perfil de su cuenta y su dirección de correo electrónico.',
title:'Información del perfil',
updated:'Perfil actualizado',
},
sessions: {
confirm:'Por favor, introduzca su contraseña para confirmar que desea salir de sus otras sesiones de navegación en todos sus dispositivos.',
description: 'Gestiona y cierra tus sesiones activas en otros navegadores y dispositivos.',
last:'Último activo',
logout:'Cerrar otras sesiones del navegador',
done:'Sesiones cerradas',
onLogout:'Si es necesario, puede cerrar la sesión de todos sus otros navegadores en todos sus dispositivos. A continuación se enumeran algunas de sus sesiones recientes; sin embargo, esta lista puede no ser exhaustiva. Si crees que tu cuenta ha sido comprometida, también deberías actualizar tu contraseña.',
this: 'Dispositivo actual',
title: 'Sesiones del navegador',
},
twoFactor: {
codes:{
regenerate:'Regenerar los códigos de recuperación',
show:'Mostrar códigos de recuperación',
store:'Guarde estos códigos de recuperación en un gestor de contraseñas seguro. Pueden utilizarse para recuperar el acceso a su cuenta si se pierde su dispositivo de autenticación de dos factores.',
},
description:'Añada seguridad adicional a su cuenta mediante la autenticación de dos factores.',
isEnable:'Ha activado la autenticación de dos factores.',
isNotEnable:{
title:'No ha activado la autenticación de dos factores.',
description:'Cuando la autenticación de dos factores está activada, se le pedirá un token seguro y aleatorio durante la autenticación. Puedes recuperar este token desde la aplicación Google Authenticator de tu teléfono.',
},
key:'Llave de configuración',
login: {
onAuth: 'Por favor, confirme el acceso a su cuenta introduciendo el código de autentificación proporcionado por su aplicación de autentificación.',
onRecovery: 'Confirme el acceso a su cuenta introduciendo uno de sus códigos de recuperación de emergencia.',
},
onFinish:'Termina de habilitar la autenticación de dos factores.',
qr: {
isConfirmed: 'La autenticación de dos factores ya está activada. Escanee el siguiente código QR con la aplicación de autenticación de su teléfono o introduzca la clave de configuración.',
onConfirmed: 'Para terminar de habilitar la autenticación de dos factores, escanea el siguiente código QR utilizando la aplicación de autenticación de tu teléfono o introduce la clave de configuración y proporciona el código OTP generado.',
},
recovery: {
code: 'Código de recuperación',
useAuth: 'Utilizar un código de autentificación',
useCode: 'Utiliza un código de recuperación',
},
title:'Autenticación de dos factores',
},
title: 'Cuenta',
},
actions:'Acciones',
activity:'Actividad',
add: 'Agregar',
admin: {
title: 'Administración',
activity: {
title: 'Historial de acciones',
description: 'Historial de acciones realizadas por los usuarios en orden cronológico.'
}
},
app: {
theme: {
dark: 'Tema oscuro',
light: 'Tema claro'
}
},
assistances: {
create: {
title: 'Generar asistencia',
description: 'Genera una instantánea del Head Count actual de los empleados, por planta y por línea de producción.',
},
title: 'Asistencias',
},
auth: {
confirmPassword: {
description: 'Esta es una zona segura de la aplicación. Por favor, confirma tu contraseña antes de continuar.',
title: 'Confirmar contraseña',
},
forgotPassword: {
ask: '¿Olvidaste tu contraseña?',
description: '¿Ha olvidado su contraseña? No hay problema. Sólo tienes que indicarnos tu dirección de correo electrónico y te enviaremos un enlace para restablecer la contraseña que te permitirá elegir una nueva.',
sendLink: 'Enviar enlace de recuperación',
title: 'Contraseña olvidada',
success: 'Se ha enviado un enlace de recuperación a su dirección de correo electrónico.',
error: 'Error al enviar el enlace de recuperación, intente más tarde.',
},
login: 'Iniciar sesión',
logout: 'Cerrar sesión',
register: {
already: '¿Ya estas registrado?',
me: 'Registrarme',
},
reset: {
success: 'Contraseña actualizada',
title: 'Restaurar contraseña',
},
remember: 'Recuerdame',
verifyEmail: {
beforeContinue: 'Antes de continuar, ¿podrías verificar tu correo electrónico haciendo clic en el enlace que acabamos de enviar a ti? Si no recibiste el correo electrónico, estaremos encantados de enviarte otro.',
sendLink: 'Enviar correo de verificación',
title: 'Verificación de correo electrónico',
notifySendVerification: 'Se ha enviado un nuevo enlace de verificación a su dirección de correo electrónico.',
},
},
author:'Autor',
code:'Código',
contracted_at: 'Fecha contratación',
cancel:'Cancelar',
changes:'Cambios',
changelogs: {
title:'Historial de cambios',
description: 'Lista de los cambios realizados al sistema.',
},
clear: 'Limpiar',
close:"Cerrar",
confirm:'Confirmar',
copyright:'Todos los derechos reservados.',
contact:'Contacto',
create: 'Crear',
created: 'Registro creado',
created_at: 'Fecha creación',
crud: {
create: 'Nuevo registro',
edit: 'Editar registro',
destroy: 'Eliminar registro',
show: 'Más detalles',
import: 'Importación'
},
dashboard: 'Dashboard',
date: 'Fecha',
dates: {
start: 'Fecha Inicial',
end: 'Fecha Final'
},
days: {
title: 'Día',
monday: 'Lunes',
tuesday: 'Martes',
wednesday: 'Miércoles',
thursday: 'Jueves',
friday: 'Viernes',
saturday: 'Sábado',
sunday: 'Domingo'
},
delete:{
confirm: 'Al presionar ELIMINAR el registro se eliminará permanentemente y no podrá recuperarse.',
title: 'Eliminar',
},
deleted:'Registro eliminado',
description:'Descripción',
details:'Detalles',
disable:'Deshabilitar',
disabled:'Deshabilitado',
done:'Hecho.',
edit:'Editar',
edited:'Registro creado',
email:{
title:'Correo',
verification:'Verificar correo'
},
employees: {
create: {
title: 'Crear empleado',
},
edit: {
title: 'Editar empleado',
},
title: 'Empleados'
},
enable:'Habilitar',
enabled:'Habilitado',
endDate:'Fecha Fin',
event:'Evento',
files: {
excel: 'Archivo excel',
select: 'Seleccionar archivo'
},
help: {
description:'A continuación se lista la iconografía para entender el funcionamiento del sistema.',
home: 'Volver a la pagina de inicio.',
title:'Ayuda',
},
history: {
title:'Historial de acciones',
description:'Historial de acciones realizadas por los usuarios en orden cronológico.'
},
home:'Inicio',
hour:'Hora',
icon:'Icono',
import: 'Importar',
items: 'Elementos',
maternal:'Apellido materno',
message:'Mensaje',
menu:'Menú',
name:'Nombre',
noRecords:'Sin registros',
notification:'Notificación',
notifications: {
unreadClosed:'Ocultas',
readed:'Marcar como leído',
deleted:'Notificación eliminada',
description:'Notificaciones del usuario',
notFound:'Notificación no encontrada',
title:'Notificaciones',
seeAll:'Ver todas',
},
omitted:'Omitida',
password:'Contraseña',
passwordConfirmation:'Confirmar contraseña',
passwordCurrent:'Contraseña actual',
passwordReset:'Restaurar contraseña',
paternal:'Apellido paterno',
phone:'Teléfono',
photo: {
new: 'Seleccionar una nueva foto',
remove:'Remover foto',
title:'Foto',
},
plant: 'Continente',
plants: {
create: {
title: 'Crear continente',
},
edit: {
title: 'Editar continente',
},
title: 'Continentes'
},
'production-line': 'Línea de producción',
'production-lines': {
create: {
title: 'Crear línea de producción',
},
edit: {
title: 'Editar línea de producción',
},
title: 'Líneas de producción'
},
profile:'Perfil',
readed:'Leído',
read_at:'Fecha leído',
refresh: 'Recargar',
register: {
create: {
onError: 'Error al crear el registro',
onSuccess: 'Registro creado',
},
edit: {
onError: 'Error al actualizar el registro',
onSuccess: 'Registro actualizado',
},
agree:'Estoy de acuerdo con los',
privacy:'Política de Privacidad',
signUp:'Registrarme',
terms:'Términos de Servicio',
},
registers:{
title:'Registros',
empty:'Sin registros',
},
remove: 'Remover',
reports: {
description: 'Listado de reportes del sistema.',
plants: {
title: 'Reporte de empleados por continente',
description: 'Este reporte muestra el número de empleados contratados, por continente y línea de producción en función del tiempo.',
},
productionLines: {
title: 'Reporte de empleados por línea de producción',
description: 'Este reporte muestra el número de empleados contratados, por línea de producción y continente en función del tiempo.',
},
title: 'Reportes',
},
return: 'Regresar',
role:'Rol',
roles:{
create: {
title: 'Crear rol',
description: 'Este nombre sera necesario para identificar el rol en el sistema. Procura que sea algo simple.',
onSuccess: 'Rol creado exitosamente',
onError: 'Error al crear el role',
},
deleted:'Rol eliminado',
edit: {
title: 'Editar rol',
onSuccess: 'Rol actualizado exitosamente',
onError: 'Error al actualizar el role',
},
update: {
description: 'Si crees necesario, puedes actualizar el nombre del rol. No afecta a los permisos.',
},
title: 'Roles',
description: 'Gestión de roles del sistema. Puedes crear los roles con los permisos que necesites.',
permissions: {
title: 'Permisos',
description: 'Permisos del rol.',
}
},
save:'Guardar',
saved:'¡Guardado!',
search:'Buscar',
selected: 'Seleccionado',
select: 'Seleccionar',
server: {
api: {
noAvailable: 'No se encontró el servidor API.'
}
},
session: {
closed: 'Sesión cerrada',
},
setting: 'Configuración',
settings: {
assistances: {
snapshot: {
title: 'Captura instantánea del Head Count',
description: 'Esta captura genera un resumen del número de empleados contratados, por planta y por línea de producción en un momento específico.',
}
},
title: 'Ajustes',
},
sex: 'Género',
shift: 'Turno',
shifts: {
create: {
title: 'Crear turno'
},
edit: {
title: 'Editar turno'
},
title: 'Turnos'
},
show: {
all:'Mostrar todo',
title:'Mostrar',
},
startDate:'Fecha de inicio',
status:'Estado',
system:{
title:'Núcleo de Holos',
},
target: {
title: 'Meta',
total: 'Meta total'
},
technology: 'Tecnología',
technologies: {
create: {
title: 'Crear tecnología',
},
edit: {
title: 'Editar tecnología',
},
title: 'Tecnologías'
},
terms: {
agree:'Estoy de acuerdo con los',
privacy:'Política de privacidad',
service:'Términos de servicio',
},
time: {
start: 'Hora inicial',
end: 'Hora final',
},
title: 'Título',
total: 'Total',
unknown:'Desconocido',
update:'Actualizar',
updated:'Actualizado',
updated_at:'Fecha actualización',
updateFail:'Error al actualizar',
unreaded:'No leído',
user:'Usuario',
users:{
activity: {
title: 'Actividad del usuario',
description: 'Historial de acciones realizadas por el usuario.',
},
create:{
title:'Crear usuario',
description:'Permite crear nuevos usuarios. No olvides otorgarle roles para que pueda acceder a las partes del sistema deseados.',
onSuccess:'Usuario creado',
onError:'Ocurrió un error al crear el usuario'
},
deleted:'Usuario eliminado',
remove: 'Remover usuario',
edit: {
title: 'Editar usuario'
},
update: {
description: 'Actualiza los datos del usuario.',
onError: 'Error al actualizar el usuario',
onSuccess: 'Usuario actualizado',
},
notFount:'Usuario no encontrado',
password: {
description:'Permite actualizar las contraseñas de los usuarios sobre escribiéndola.',
title:'Actualizar contraseña',
},
roles: {
description:'Actualiza los roles de los usuarios, permitiendo o denegando los accesos a determinadas áreas.',
error:{
min:'Seleccionar mínimo un role'
},
title:'Roles de usuario',
},
online: {
description: 'Lista de usuarios conectados al sistema.',
title: 'Usuarios conectados',
count: 'Usuarios conectados.',
},
menu:'Menú de usuario',
select:'Seleccionar un usuario',
settings:'Ajustes del usuario',
system:'Usuarios del sistema',
title:'Usuarios',
},
version:'Versión',
welcome: 'Bienvenido',
workstation: 'Puesto de trabajo',
workstations: {
create: {
title: 'Crear puesto de trabajo',
},
edit: {
title: 'Editar puesto de trabajo',
},
title: 'Puestos de trabajos'
},
}

29
src/lang/i18n.js Normal file
View File

@ -0,0 +1,29 @@
import { createI18n } from 'vue-i18n';
import en from './en.js';
import es from './es.js';
/**
* Idioma local
*/
const locale = document.documentElement.lang;
const messages = {
en,
es
}
const i18n = createI18n({
legacy: false,
locale,
fallbackLocale: locale,
messages
});
function lang(text, params = {}) {
return i18n.global.t(text, params);
}
export {
i18n,
lang
};

71
src/layouts/AppLayout.vue Normal file
View File

@ -0,0 +1,71 @@
<script setup>
import { onMounted } from 'vue';
import useLoader from '@Stores/Loader';
import { hasPermission } from '@Plugins/RolePermission';
import Layout from '@Holos/Layout/App.vue';
import Link from '@Holos/Skeleton/Sidebar/Link.vue';
import Section from '@Holos/Skeleton/Sidebar/Section.vue';
/** Definidores */
const loader = useLoader()
/** Propiedades */
defineProps({
title: String,
});
/** Ciclos */
onMounted(() => {
loader.boot()
})
</script>
<template>
<Layout
:title="title"
>
<template #leftSidebar>
<Section name="Principal">
<Link
icon="monitoring"
name="dashboard"
to="dashboard.index"
/>
<Link
icon="person"
name="profile"
to="profile.show"
/>
</Section>
<Section
v-if="hasPermission('users.index')"
:name="$t('admin.title')"
>
<Link
v-if="hasPermission('users.index')"
icon="people"
name="users.title"
to="admin.users.index"
/>
<Link
v-if="hasPermission('roles.index')"
icon="license"
name="roles.title"
to="admin.roles.index"
/>
<Link
v-if="hasPermission('activities.index')"
icon="event"
name="history.title"
to="admin.activities.index"
/>
</Section>
</template>
<!-- Contenido -->
<RouterView />
<!-- Fin contenido -->
</Layout>
</template>

View File

@ -0,0 +1,151 @@
<script setup>
import { onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { hasPermission } from '@Plugins/RolePermission';
import { useSearcher } from '@Services/Api';
import { apiTo, transl } from './Module';
import ModalController from '@Controllers/ModalController.js';
import IconButton from '@Holos/Button/Icon.vue'
import PrimaryButton from '@Holos/Button/Primary.vue';
import Input from '@Holos/Form/Input.vue';
import Header from '@Holos/PageHeader.vue';
import Paginable from '@Holos/Paginable.vue';
import Item from '@Holos/Timeline/Item.vue';
import ShowView from './Modals/Event.vue';
/** Definidores */
const vroute = useRoute();
const router = useRouter();
/** Controladores */
const Modal = new ModalController();
/** Propiedades */
const showModal = ref(Modal.showModal);
const modelModal = ref(Modal.modelModal);
const models = ref([]);
const filters = reactive({
search: '',
start_date: '',
end_date: '',
user: ''
});
/** Métodos */
const searcher = useSearcher({
url: apiTo('index'),
filters,
onSuccess: (r) => models.value = r.models,
onError: () => models.value = []
});
const clearFilters = () => {
filters.search = '';
filters.start_date = '';
filters.end_date = '';
searcher.search();
};
const clearUser = () => {
router.replace({ query: {} });
filters.user = '';
searcher.search();
};
/** Ciclos */
onMounted(() => {
if(vroute.query?.user) {
filters.user = vroute.query.user;
}
searcher.search('');
});
</script>
<template>
<div>
<Header :title="transl('title')">
<RouterLink v-if="filters.user && hasPermission('users.index')" :to="$view({ name: 'admin.users.index' })">
<IconButton
class="text-white"
icon="arrow_back"
:title="$t('return')"
filled
/>
</RouterLink>
</Header>
<p class="mt-2 text-sm">{{ transl('description') }}</p>
<div id="filters" class="grid gap-2 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 mt-4">
<Input
v-model="filters.search"
title="event"
@keyup.enter="searcher.search()"
/>
<Input
v-model="filters.start_date"
type="date"
title="dates.start"
/>
<Input
v-model="filters.end_date"
title="dates.end"
type="date"
/>
<div class="flex space-x-2 items-end">
<PrimaryButton
class="!w-full h-12"
@click="searcher.search()"
>
{{ $t('search') }}
</PrimaryButton>
<PrimaryButton
class="!w-full h-12"
@click="clearFilters()"
>
{{ $t('clear') }}
</PrimaryButton>
<PrimaryButton
v-if="filters.user"
class="!w-full h-12"
@click="clearUser()"
>
{{ $t('users.remove') }}
</PrimaryButton>
</div>
</div>
<div class="pb-4">
<Paginable
:items="models"
:processing="searcher.processing"
@send-pagination="(page) => searcher.pagination(page)"
>
<template #body="{ items }">
<ol class="ml-4">
<template v-for="event in items">
<Item
:event="event"
@show="Modal.switchShowModal(event)"
/>
</template>
</ol>
</template>
</Paginable>
</div>
<ShowView
:show="showModal"
:model="modelModal"
@close="Modal.switchShowModal"
/>
</div>
</template>

View File

@ -0,0 +1,59 @@
<script setup>
import { getDateTime } from '@Controllers/DateController';
import Header from '@Holos/Modal/Elements/Header.vue';
import ShowModal from '@Holos/Modal/Show.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Eventos */
defineEmits([
'close',
]);
/** Propiedades */
defineProps({
show: Boolean,
model: Object
});
</script>
<template>
<ShowModal
:show="show"
@close="$emit('close')"
>
<Header
:title="model.event"
/>
<div class="flex w-full p-4">
<GoogleIcon
class="text-xl text-success"
name="contact_mail"
/>
<div class="pl-3 w-full">
<p class="font-bold text-lg leading-none pb-2">
{{ $t('details') }}
</p>
<div class="text-sm">
<h4 class="font-semibold">{{ $t('event') }}:</h4>
<p>{{ model.event }}</p>
</div>
<div class="text-sm">
<h4 class="font-semibold">{{ $t('description') }}:</h4>
<p>{{ model.description }}</p>
</div>
<div class="flex w-full flex-col">
<p class="font-semibold">
{{ $t('changes') }}:
</p>
<div class="w-full text-xs p-2 border rounded-md">
<pre>{{ model.data }}</pre>
</div>
</div>
<div class="text-sm">
<h4 class="font-semibold">{{ $t('created_at') }}:</h4>
<p>{{ getDateTime(model.created_at) }}</p>
</div>
</div>
</div>
</ShowModal>
</template>

View File

@ -0,0 +1,21 @@
import { lang } from '@Lang/i18n';
import { hasPermission } from '@Plugins/RolePermission.js';
// Ruta API
const apiTo = (name, params = {}) => route(`admin.activities.${name}`, params)
// Ruta visual
const viewTo = ({ name = '', params = {}, query = {} }) => view({ name: `admin.activities.${name}`, params, query })
// Obtener traducción del componente
const transl = (str) => lang(`admin.activity.${str}`)
// Determina si un usuario puede hacer algo no en base a los permisos
const can = (permission) => hasPermission(`activities.${permission}`)
export {
can,
viewTo,
apiTo,
transl
}

View File

@ -0,0 +1,45 @@
<script setup>
import { useRouter } from 'vue-router';
import { useForm } from '@Services/Api';
import { apiTo, transl, viewTo } from './Module';
import IconButton from '@Holos/Button/Icon.vue'
import PageHeader from '@Holos/PageHeader.vue';
import Form from './Form.vue'
/** Definidores */
const router = useRouter();
/** Propiedades */
const form = useForm({
description: '',
});
/** Métodos */
function submit() {
form.post(apiTo('store'), {
onSuccess: () => {
Notify.success(Lang('register.create.onSuccess'))
router.push(viewTo({ name: 'index' }));
}
})
}
</script>
<template>
<PageHeader :title="transl('create.title')">
<RouterLink :to="viewTo({ name: 'index' })">
<IconButton
class="text-white"
icon="arrow_back"
:title="$t('return')"
filled
/>
</RouterLink>
</PageHeader>
<Form
action="create"
:form="form"
@submit="submit"
/>
</template>

View File

@ -0,0 +1,54 @@
<script setup>
import { onMounted } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import { api, useForm } from '@Services/Api';
import { viewTo, apiTo , transl } from './Module';
import IconButton from '@Holos/Button/Icon.vue'
import PageHeader from '@Holos/PageHeader.vue';
import Form from './Form.vue'
/** Definiciones */
const vroute = useRoute();
const router = useRouter();
/** Propiedades */
const form = useForm({
id: null,
description: '',
});
/** Métodos */
function submit() {
form.put(apiTo('update', { role: form.id }), {
onSuccess: () => {
Notify.success(Lang('register.edit.onSuccess'))
router.push(viewTo({ name: 'index' }));
},
})
}
onMounted(() => {
api.get(apiTo('show', { role: vroute.params.id }), {
onSuccess: (r) => form.fill(r.model)
});
})
</script>
<template>
<PageHeader :title="transl('edit.title')">
<RouterLink :to="viewTo({ name: 'index' })">
<IconButton
class="text-white"
icon="arrow_back"
:title="$t('return')"
filled
/>
</RouterLink>
</PageHeader>
<Form
action="update"
:form="form"
@submit="submit"
/>
</template>

View File

@ -0,0 +1,50 @@
<script setup>
import { transl } from './Module';
import PrimaryButton from '@Holos/Button/Primary.vue';
import Input from '@Holos/Form/Input.vue';
/** Eventos */
const emit = defineEmits([
'submit'
])
/** Propiedades */
defineProps({
action: {
default: 'create',
type: String
},
form: Object
})
/** Métodos */
function submit() {
emit('submit')
}
</script>
<template>
<div class="w-full pb-2">
<p class="text-justify text-sm" v-text="transl(`${action}.description`)" />
</div>
<div class="w-full">
<form @submit.prevent="submit" class="grid gap-4 grid-cols-1">
<Input
v-model="form.description"
id="name"
:onError="form.errors.description"
autofocus
required
/>
<slot />
<div class="flex flex-col items-center justify-end space-y-4 mt-4">
<PrimaryButton
v-text="$t(action)"
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
/>
</div>
</form>
</div>
</template>

Some files were not shown because too many files have changed in this diff Show More