INIT: Commit inicial

This commit is contained in:
Moisés de Jesús Cortés Castellanos 2024-12-03 09:21:35 -06:00
commit 97b85a1f65
123 changed files with 10489 additions and 0 deletions

1
.env.example Normal file
View File

@ -0,0 +1 @@
VITE_API_URL=http://backend.holos.test

25
.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# 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
# 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).

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>Vite + Vue</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

2835
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "holos.frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"axios": "^1.7.8",
"pinia": "^2.2.8",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.15",
"toastr": "^2.1.4",
"vite": "^6.0.1",
"vue": "^3.5.13",
"vue-i18n": "^10.0.5",
"vue-router": "^4.5.0",
"ziggy-js": "^2.4.1"
},
"devDependencies": {
"autoprefixer": "^10.4.20"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

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

12
src/App.vue Normal file
View File

@ -0,0 +1,12 @@
<script setup>
console.log(route('api.routes'));
</script>
<template>
<h1 class="text-3xl font-bold underline">
Hello world!
</h1>
{{ route('api.routes') }}
</template>

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

View File

@ -0,0 +1,43 @@
<script setup>
import { ref } from 'vue'
defineProps({
msg: String,
})
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

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 dark:shadow-sm dark:shadow-white/50 sm:rounded-lg">
<slot name="content" />
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,11 @@
<template>
<div class="min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-gray-100">
<div>
<slot name="logo" />
</div>
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white shadow-md overflow-hidden sm:rounded-lg">
<slot />
</div>
</div>
</template>

View File

@ -0,0 +1,17 @@
<script setup>
import { Link } from '@inertiajs/vue3';
</script>
<template>
<Link :href="'/'">
<svg
class="w-16 h-16"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M11.395 44.428C4.557 40.198 0 32.632 0 24 0 10.745 10.745 0 24 0a23.891 23.891 0 0113.997 4.502c-.2 17.907-11.097 33.245-26.602 39.926z" fill="#6875F5" />
<path d="M14.134 45.885A23.914 23.914 0 0024 48c13.255 0 24-10.745 24-24 0-3.516-.756-6.856-2.115-9.866-4.659 15.143-16.608 27.092-31.75 31.751z" fill="#6875F5" />
</svg>
</Link>
</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,
title: String,
style: {
type: String,
default: 'rounded'
},
type: {
type: String,
default: 'button'
}
});
</script>
<template>
<button
class="flex justify-center items-center h-7 w-7 rounded-md btn-icon"
:title="title"
:type="type"
>
<GoogleIcon
:name="icon"
:fill="fill"
: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 { Link } from '@inertiajs/vue3';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Propiedades */
defineProps({
to: String,
title: String,
value: Number,
icon: String
});
</script>
<template>
<Link
class="relative flex-1 flex flex-col gap-2 p-4 rounded -md bg-gray-200 dark:bg-transparent dark:border"
:href="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>
</Link>
</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 border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500"
>
</template>

View File

@ -0,0 +1,113 @@
<script setup>
import { ref, reactive, nextTick } from 'vue';
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 = reactive({
password: '',
error: '',
processing: false,
});
const passwordInput = ref(null);
const startConfirmingPassword = () => {
axios.get(route('password.confirmation')).then(response => {
if (response.data.confirmed) {
emit('confirmed');
} else {
confirmingPassword.value = true;
setTimeout(() => passwordInput.value.focus(), 250);
}
});
};
const confirmPassword = () => {
form.processing = true;
axios.post(route('password.confirm'), {
password: form.password,
}).then(() => {
form.processing = false;
closeModal();
nextTick().then(() => emit('confirmed'));
}).catch(error => {
form.processing = false;
form.error = error.response.data.errors.password[0];
passwordInput.value.focus();
});
};
const closeModal = () => {
confirmingPassword.value = false;
form.password = '';
form.error = '';
};
</script>
<template>
<span>
<span @click="startConfirmingPassword">
<slot />
</span>
<DialogModal :show="confirmingPassword" @close="closeModal">
<template #title>
{{ title }}
</template>
<template #content>
{{ content }}
<div class="mt-4">
<Input
v-model="form.password"
id="password"
type="password"
:onError="form.error"
/>
</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,46 @@
<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 class="p-4">
<div class="text-lg font-medium">
<slot name="title" />
</div>
<div class="mt-4 text-sm">
<slot name="content" />
</div>
</div>
<div class="flex flex-row justify-center p-4 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-md ring-1 ring-black ring-opacity-5" :class="contentClasses">
<slot name="content" />
</div>
</div>
</transition>
</div>
</template>

View File

@ -0,0 +1,39 @@
<script setup>
import { Link } from '@inertiajs/vue3';
defineProps({
as: String,
href: String
});
const style = 'block px-4 py-2 text-sm leading-5 hover:bg-secondary/80 dark:hover:bg-secondary-d/80 focus:outline-none focus:bg-gray-100 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>
<Link
v-else
:href="href"
:class="style"
>
<slot />
</Link>
</div>
</template>

View File

@ -0,0 +1,14 @@
<script setup>
/** Propiedades */
defineProps({
onError: String
});
</script>
<template>
<p v-if="onError"
class="mt-1 pl-2 text-xs text-red-500 dark:text-red-300"
>
{{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"
:for="id"
class="block text-sm font-medium text-page-t dark:text-page-dt"
>
{{ $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({
class: String,
required: Boolean,
accept: {
default: 'image/png, image/jpeg',
type: String
},
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"
type="file"
class="hidden"
:accept="accept"
:required="required"
@change="updatePhotoPreview"
>
<Label
class="dark:text-gray-800"
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 />
</div>
<div v-show="photoPreview" class="mt-2">
<div v-if="fileType == 'application/pdf'" class="flex overflow-hidden max-w-full">
<GoogleIcon
:title="$t('crud.edit')"
class="text-gray-400"
name="picture_as_pdf"
outline
/>
<div class="ml-2 font-bold text-gray-400 flex-1">
{{ photoPreview }}
</div>
</div>
<div v-else>
<span
:class="class"
class="block rounded-lg h-40 bg-cover bg-no-repeat bg-center"
:style="'background-image: url(\'' + photoPreview + '\');'"
/>
</div>
</div>
<SecondaryButton
class="mt-2 mr-2"
type="button"
v-text="$t('photo.new')"
@click.prevent="selectNewPhoto"
/>
</div>
</template>

View File

@ -0,0 +1,85 @@
<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,
type: {
default: 'text',
type: String
}
});
const input = ref(null);
/** Exposiciones */
defineExpose({
focus: () => input.value.focus()
});
/** Propiedades calculadas */
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"
/>
<input
:id="autoId"
class="input-primary"
:placeholder="placeholder"
ref="input"
:required="required"
:type="type"
:value="modelValue"
v-bind="$attrs"
@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,
id: String,
icon: String,
modelValue: Number | String,
onError: String,
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()
})
/** Propiedades computadas */
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-2xl">
<GoogleIcon
:name="icon"
/>
<input
ref="input"
v-bind="$attrs"
class="pl-2 w-full outline-none border-none bg-transparent"
v-model="value"
: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,
trackBy: {
default: 'id',
type: String
},
label: {
default: 'name',
type: String
},
modelValue: String | Number,
title: String,
options: Object,
onError: String,
placeholder: {
default: 'Buscar ...',
type: String
},
required: Boolean,
multiple: Boolean,
disabled: Boolean
});
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
:title="title"
:required="required"
/>
<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"
:multiple="multiple"
:label="label"
: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, onMounted } 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({
modelValue:Object|String,
class: String,
required: Boolean,
accept: {
default: 'image/png, image/jpeg',
type: String
},
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
:title="$t('crud.edit')"
class="text-gray-400"
name="picture_as_pdf"
outline
/>
<div class="ml-2 font-bold text-gray-400 flex-1">
<a
target="_blank"
:href="photoPreview"
>
{{ fileName }}
</a>
</div>
</div>
</div>
<SecondaryButton
class="mt-2 mr-2"
type="button"
v-text="$t('files.select')"
@click.prevent="selectNewPhoto"
/>
</div>
</template>

View File

@ -0,0 +1,60 @@
<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
]
},
title: {
default: lang('active'),
type: String
},
value: {
default: null,
type: String
},
disabled: Boolean
});
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
.id="uuid"
class="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer"
type="checkbox"
name="toggle"
:value="value"
v-model="proxyChecked"
:disabled="disabled"
/>
<label :for="uuid" class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"></label>
</div>
<label :for="uuid" class="text-xs text-gray-700">{{ $t(title) }}</label>
</div>
</template>

View File

@ -0,0 +1,78 @@
<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
:id="autoId"
class="input-primary"
:placeholder="placeholder"
ref="input"
:required="required"
:value="modelValue"
v-bind="$attrs"
@input="$emit('update:modelValue', $event.target.value)"
></textarea>
<Error :onError="onError"/>
</div>
</template>

View File

@ -0,0 +1,162 @@
<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 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
:title="itemATitle"
v-model="itemA"
:options="itemsAUnselected"
/>
<Input
:title="itemBTitle"
v-model="itemB"
: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 border border-primary/50">
<div class="grid gap-2 grid-cols-2 w-full items-center p-2 dark:bg-primary-d/50">
<Input
:title="itemATitle"
v-model="item.item.name"
disabled
/>
<Input
:title="itemBTitle"
v-model="item.value"
/>
</div>
<div class="absolute right-1 top-1">
<GoogleIcon
type="button"
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 dark:shadow-sm 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 dark:shadow-sm 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 check = ref(false);
const filterMessages = ref(false);
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 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,70 @@
<script setup>
import { ref, computed } from 'vue';
// Propiedades
const props = defineProps({
inboxCtl: Object, //Controller
item: Object,
selecteds: Object
})
// Variables generales
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-lg 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
type="checkbox"
class="focus:ring-0 border-2 border-gray-400"
v-model="check"
@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-lg 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,46 @@
<script setup>
import { computed } from 'vue';
import { Link } from '@inertiajs/vue3';
import GoogleIcon from '@Shared/GoogleIcon.vue';
const props = defineProps({
icon: String,
counter: Number,
to: String,
toParam: {
default: {},
type: Object
},
title: String,
});
const classes = computed(() => {
let status = route().current(props.to, props.toParam)
? 'bg-secondary bg-opacity-30'
: 'border-transparent hover:bg-secondary hover:bg-opacity-30';
return ` text-primary flex items-center justify-between py-1.5 px-4 rounded cursor-pointer ${status} transition`
});
</script>
<template>
<li>
<Link v-if="to" :href="route(to, toParam)" :class="classes">
<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-lg">
{{ counter }}
</span>
</Link>
</li>
</template>

View File

@ -0,0 +1,38 @@
<script setup>
import { computed } from 'vue';
import { Link } from '@inertiajs/vue3';
import GoogleIcon from '@Shared/GoogleIcon.vue';
const props = defineProps({
icon: String,
to: String,
title: String,
type: {
default: 'primary',
type: String
}
});
const classes = computed(() => {
return `inbox-menu-button-${props.type}`;
});
</script>
<template>
<div class="h-16 flex items-center pr-2">
<Link :href="route(to)" :class="classes">
<span class="flex items-center space-x-2 ">
<GoogleIcon
class="text-lg text-white font-bold"
:name="icon"
outline
/>
<span>
{{ title }}
</span>
</span>
</Link>
</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,71 @@
<script setup>
import { onBeforeMount, onMounted } from 'vue';
import { Head } from '@inertiajs/vue3';
import { bootPermissions } from '@Plugins/RolePermission.js';
import useDarkMode from '@Stores/DarkMode'
import useLeftSidebar from '@Stores/LeftSidebar'
import useNotificationSidebar from '@Stores/NotificationSidebar'
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();
/** Propiedades */
defineProps({
title: String,
titlePage: {
default: true,
type: Boolean
}
});
/** Ciclos */
onBeforeMount(() => {
bootPermissions()
})
onMounted(()=> {
leftSidebar.boot()
darkMode.boot()
});
</script>
<template>
<Head :title="title" />
<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-sm md:dark:shadow-white h-[calc(100vh-4.5rem)] px-2 md:rounded-lg md:overflow-y-auto md:overflow-x-auto transition-colors duration-300">
<div v-if="titlePage" class="flex w-full justify-center">
<h2
class="font-bold text-xl uppercase"
v-text="title"
/>
</div>
<slot />
</div>
</main>
</div>
</div>
</template>

View File

@ -0,0 +1,72 @@
<script setup>
import { onMounted } from 'vue'
import { Head } from '@inertiajs/vue3';
import useDarkMode from '@Stores/DarkMode'
import IconButton from '@Holos/Button/Icon.vue'
import Logo from '@Holos/Logo.vue';
/** Definidores */
const darkMode = useDarkMode()
/** Propiedades */
defineProps({
title: String
})
/**
* Ciclos
*/
onMounted(() => {
darkMode.boot()
});
</script>
<template>
<Head :title="title" />
<div class="h-screen flex">
<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"
:title="$t('app.theme.light')"
icon="light_mode"
@click="darkMode.applyDark()"
/>
<IconButton v-else
:title="$t('app.theme.dark')"
icon="dark_mode"
@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-sm text-white px-4 py-8 rounded-md max-w-80">
<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-sm text-white transition-colors duration-global">
<div>
<span>
&copy;2024 {{ $page.props.copyright }}
</span>
</div>
<div>
<span>
Versión {{ $page.version }}
</span>
</div>
</footer>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,72 @@
<script setup>
import { onMounted } from 'vue'
import { Head } from '@inertiajs/vue3';
import useDarkMode from '@Stores/DarkMode'
import IconButton from '@Holos/Button/Icon.vue'
import Logo from '@Holos/Logo.vue';
/** Definidores */
const darkMode = useDarkMode()
/** Propiedades */
defineProps({
title: String
})
/**
* Ciclos
*/
onMounted(() => {
darkMode.boot()
});
</script>
<template>
<Head :title="title" />
<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"
:title="$t('app.theme.light')"
icon="light_mode"
@click="darkMode.applyDark()"
/>
<IconButton v-else
:title="$t('app.theme.dark')"
icon="dark_mode"
@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-sm 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-sm text-white transition-colors duration-global">
<div>
<span>
&copy;2024 {{ $page.props.copyright }}
</span>
</div>
<div>
<span>
Versión {{ $page.version }}
</span>
</div>
</footer>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,9 @@
<script setup>
import { Link } from '@inertiajs/vue3';
</script>
<template>
<Link :href="'/'" class="flex w-full justify-center items-center space-x-2">
<img src="/images/logo.png" class="h-20" />
</Link>
</template>

View File

@ -0,0 +1,102 @@
<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 leave-active-class="duration-300">
<div v-show="show" class="fixed inset-0 overflow-y-auto px-4 py-6 sm:px-0 z-50" scroll-region>
<transition
enter-active-class="ease-out duration-300"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="ease-in duration-300"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div v-show="show" class="fixed inset-0 transform transition-all" @click="close">
<div class="absolute inset-0 bg-primary text-primary-t dark:bg-primary-d dark:text-primary-dt opacity-75" />
</div>
</transition>
<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="mb-6 bg-page text-page-t dark:bg-page-d dark:text-page-dt rounded-lg overflow-hidden shadow-xl transform transition-all sm:w-full sm:mx-auto"
:class="maxWidthClass"
>
<slot v-if="show" />
</div>
</transition>
</div>
</transition>
</teleport>
</template>

View File

@ -0,0 +1,55 @@
<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 mt-2">
<div class="rounded overflow-hidden shadow-lg">
<slot />
</div>
</div>
<p
class="mt-2 p-1 rounded-md text-justify bg-danger text-danger-t"
v-text="$t('delete.confirm')"
/>
</template>
<template #footer>
<div class="space-x-2">
<slot name="buttons" />
<DangerButton
@click="$emit('destroy')"
v-text="$t('delete.title')"
/>
<SecondaryButton
@click="$emit('close')"
v-text="$t('cancel')"
/>
</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 mt-2">
<div class="rounded overflow-hidden">
<slot />
</div>
</div>
</template>
<template #footer>
<div class="space-x-2">
<slot name="buttons" />
<PrimaryButton
@click="$emit('update')"
v-text="$t('update')"
/>
<SecondaryButton
@click="$emit('close')"
v-text="$t('close')"
/>
</div>
</template>
</DialogModal>
</template>

View File

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

View File

@ -0,0 +1,53 @@
<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: {
default: lang('details'),
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 mt-2">
<div class="rounded overflow-hidden shadow-lg">
<slot />
</div>
</div>
</template>
<template #footer>
<div class="space-x-2">
<slot name="buttons" />
<PrimaryButton
v-if="editable"
@click="$emit('edit')"
v-text="$t('update')"
/>
<SecondaryButton
@click="$emit('close')"
v-text="$t('close')"
/>
</div>
</template>
</DialogModal>
</template>

View File

@ -0,0 +1,46 @@
<script setup>
import { router } from '@inertiajs/vue3';
import DestroyModal from '../Destroy.vue';
import Header from '../Elements/Header.vue';
/** Eventos */
const emit = defineEmits([
'close',
'switchModal'
]);
/** Propiedades */
const props = defineProps({
model: Object,
show: Boolean,
to: Function,
});
/** Métodos */
const destroy = (id) => router.delete(props.to(id), {
preserveScroll: true,
onSuccess: () => {
props.model.pop;
Notify.success(lang('deleted'));
emit('close');
},
onError: () => {
Notify.info(lang('notFound'));
emit('close');
}
});
</script>
<template>
<DestroyModal
:show="show"
@close="$emit('close')"
@destroy="destroy(model.id)"
>
<Header
:title="model.name"
:subtitle="model.full_last_name"
/>
</DestroyModal>
</template>

View File

@ -0,0 +1,74 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import GoogleIcon from '@/components/Shared/GoogleIcon.vue'
const emit = defineEmits([
'close'
])
const props = defineProps({
item: Object,
})
const isOpen = ref(false)
const close = () => {
isOpen.value = false
setTimeout(() => {
emit('close')
}, 500)
}
const typeClasses = computed(() => {
let nameClass = 'w-64 rounded-md text-white p-2';
switch (props.item.type) {
case 'info':
nameClass += ' bg-blue-500'
break;
case 'success':
nameClass += ' bg-green-500'
break;
case 'warning':
nameClass += ' bg-yellow-500'
break;
case 'error':
nameClass += ' bg-red-500'
break;
default:
nameClass += ' bg-blue-500'
break;
}
return nameClass
})
onMounted(() => {
isOpen.value = true
})
</script>
<template>
<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="isOpen" :class="typeClasses">
<div class="flex justify-between items-center">
<h4 class="font-bold text-sm truncate">{{ item.title }}</h4>
<GoogleIcon
class="cursor-pointer"
name="close"
@click="close()"
/>
</div>
<h4 class="text-sm ">{{ item.message }}</h4>
</div>
</Transition>
</template>

View File

@ -0,0 +1,22 @@
<script setup>
import { Link } from '@inertiajs/vue3';
import IconButton from '@Holos/Button/Icon.vue'
</script>
<template>
<div class="flex w-full justify-end py-[0.31rem] border-y-2 border-page-t dark:border-page-dt">
<div id="buttons" class="flex items-center space-x-2 text-sm py-0.5">
<slot />
<Link :href="route('dashboard.index')">
<IconButton
:title="$t('home')"
class="text-white"
icon="home"
filled
/>
</Link>
</div>
</div>
</template>

View File

@ -0,0 +1,60 @@
<script setup>
import { ref } from 'vue';
import { Link } from '@inertiajs/vue3';
import IconButton from '@Holos/Button/Icon.vue'
import GoogleIcon from '@Shared/GoogleIcon.vue';
const emit = defineEmits([
'search'
]);
const query = ref('');
const props = defineProps({
placeholder: {
default: lang('search'),
type: String
}
})
const search = () => {
emit('search', query.value);
}
</script>
<template>
<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 text-gray-700 hover:scale-110 hover:text-danger">
<GoogleIcon
:title="$t('search')"
class="text-xl"
name="search"
/>
</div>
<input
id="search"
class="bg-gray-100 border border-gray-300 text-gray-700 text-sm rounded-lg 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-2 text-sm" id="buttons">
<slot />
<Link :href="route('dashboard.index')">
<IconButton
:title="$t('home')"
class="text-white"
icon="home"
filled
/>
</Link>
</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,124 @@
<script setup>
import { onMounted} from 'vue';
import { router } from '@inertiajs/vue3';
import { resetPermissions } from '@/Plugins/RolePermission';
import useDarkMode from '@Stores/DarkMode'
import useLeftSidebar from '@Stores/LeftSidebar'
import useNotificationSidebar from '@Stores/NotificationSidebar'
import useNotifier from '@Stores/Notifier'
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Dropdown from '../Dropdown.vue';
import DropdownLink from '../DropdownLink.vue';
// import NotificationLink from '.NotificationLink.vue';
/** Eventos */
const emit = defineEmits([
'open'
]);
/** Definidores */
const darkMode = useDarkMode()
const leftSidebar = useLeftSidebar()
const notificationSidebar = useNotificationSidebar()
const notifier = useNotifier()
// Métodos
const logout = () => {
resetPermissions()
router.post(route('logout'), {}, {
onBefore: () => {
}
});
};
/** Ciclos */
onMounted(()=>{
});
</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-6 items-center justify-between h-[2.75rem] rounded-lg bg-primary dark:bg-primary-d text-white z-20 ">
<GoogleIcon
:title="$t('menu')"
class="text-2xl mt-1 z-50"
name="list"
@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 class="flex items-center">
<GoogleIcon
:title="$t('notifications.title')"
class="text-xl mt-1"
name="notifications"
@click="notificationSidebar.toggle()"
/>
<span class="text-xs">{{ notifier.counter }}</span>
</li>
<li v-if="darkMode.isDark">
<GoogleIcon
:title="$t('notifications.title')"
class="text-xl mt-1"
name="light_mode"
@click="darkMode.applyLight()"
/>
</li>
<li v-else>
<GoogleIcon
:title="$t('notifications.title')"
class="text-xl mt-1"
name="dark_mode"
@click="darkMode.applyDark()"
/>
</li>
<li>
<div class="relative">
<Dropdown align="right" width="48">
<template #trigger>
<div class="flex space-x-4">
<button
v-if="$page.props.jetstream.managesProfilePhotos"
:title="$t('users.menu')"
class="flex items-center space-x-4 text-sm border-2 border-transparent rounded-full focus:outline-none transition"
>
<img
class="h-8 w-8 rounded-full object-cover"
:alt="$page.props.auth.user.name"
:src="$page.props.auth.user.profile_photo_url"
>
</button>
</div>
</template>
<template #content>
<div class="text-center block px-4 py-2 text-sm border-b truncate">
{{ $page.props.auth.user.name }}
</div>
<DropdownLink :href="route('profile.show')">
{{$t('profile')}}
</DropdownLink>
<DropdownLink v-if="$page.props.jetstream.hasApiFeatures" :href="route('api-tokens.index')">
API Tokens
</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,67 @@
<script setup>
import { computed } from 'vue';
import { Link } from '@inertiajs/vue3';
import GoogleIcon from '@Shared/GoogleIcon.vue';
const props = defineProps({
as: String,
href: String,
icon: {
default: 'notifications_active',
type: String
},
readAt: String
});
const classes = computed(()=> {
return 'inline-flex space-x-2 w-full px-4 py-2 text-sm leading-5 text-gray-700 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 transition';
});
const readed = computed(()=> {
return (props.readAt)
? 'text-primary'
: 'text-warning';
});
</script>
<template>
<div>
<button
v-if="as == 'button'"
:class="classes"
type="button"
>
<GoogleIcon
:class="readed"
:name="icon"
/>
<slot />
</button>
<a
v-else-if="as =='a'"
:class="classes"
:href="href"
>
<GoogleIcon
:class="readed"
:name="icon"
/>
<slot />
</a>
<Link
v-else
:class="classes"
:href="href"
>
<GoogleIcon
class="text-primary hover:text-secondary"
:class="readed"
:name="icon"
/>
<slot />
</Link>
</div>
</template>

View File

@ -0,0 +1,55 @@
<script setup>
import useLeftSidebar from '@Stores/LeftSidebar'
import Logo from '@Holos/Logo.vue';
const emit = defineEmits(['open']);
const props = defineProps({
sidebar: Boolean
});
const leftSidebar = useLeftSidebar()
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-lg 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}} {{$page.props.copyright}}
</p>
<p class="text-center text-xs text-yellow-500 cursor-pointer">
V{{$page.version}}
</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,55 @@
<script setup>
import { computed } from 'vue';
import { Link } from '@inertiajs/vue3';
import useLeftSidebar from '@/Stores/LeftSidebar';
import GoogleIcon from '@/Components/Shared/GoogleIcon.vue';
/** Definidores */
const leftSidebar = useLeftSidebar();
/** Propiedades */
const props = defineProps({
icon: String,
name: String,
to: String
});
const classes = computed(() => {
let status = route().current(props.to)
? 'bg-secondary/30 dark:bg-secondary-d/30 border-secondary dark:border-secondary-d'
: 'border-transparent';
return `flex items-center h-11 focus:outline-none 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()">
<Link :href="route(to)" :class="classes">
<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"
class="text-sm tracking-wide truncate"
>
{{$t(name)}}
</span>
<slot />
</Link>
</li>
</template>

View File

@ -0,0 +1,65 @@
<script setup>
import { onMounted } from 'vue';
import useNotificationSidebar from '@Stores/NotificationSidebar'
import useNotifier from '@Stores/Notifier'
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Item from './Notification/Item.vue';
/**
* Definidores
*/
const notifier = useNotifier();
const notificationSidebar = useNotificationSidebar()
/** Eventos */
const emit = defineEmits(['open']);
/** Propiedades */
const props = defineProps({
sidebar: Boolean
});
/**
* Ciclos
*/
onMounted(() => {
notifier.boot();
});
</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-lg 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">
<h4 class="text-md text-center py-1 font-semibold">
{{ $t('notifications.title') }} <span class="text-xs">({{ notifier.counter }})</span>
</h4>
<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-2 h-[calc(100vh-6.5rem)] overflow-y-auto">
<Item v-for="notification in notifier.notifications"
:key="notification.id"
:notification="notification"
/>
</ul>
</div>
</div>
</div>
</section>
</div>
</template>

View File

@ -0,0 +1,64 @@
<script setup>
import { getDateTime } from '@Controllers/DateController';
import useNotifier from '@Stores/Notifier';
/** Definidores */
const notifier = useNotifier();
import GoogleIcon from '@Shared/GoogleIcon.vue';
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-lg 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.readNotification(notification.id)"
/>
</div>
</div>
<div class="flex w-full">
<div class="w-10 space-y-0">
<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-xl 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 class="text-sm font-medium truncate">{{ notification.data.title }}</div>
<div v-if="notification.user" class="text-xs text-gray-400 truncate">~ {{ `${notification.user.name} ${notification.user.paternal}` }} </div>
<div v-else class="text-xs text-gray-400 truncate">~ {{ $t('system') }} </div>
</div>
</div>
</li>
</template>

View File

@ -0,0 +1,36 @@
<script setup>
import useRightSidebar from '@Stores/RightSidebar'
/** Eventos */
const emit = defineEmits(['open']);
/** Propiedades */
const props = defineProps({
sidebar: Boolean
});
/** Definidores */
const rightSidebar = useRightSidebar()
</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-lg 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,18 @@
<script setup>
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,86 @@
<script setup>
import GoogleIcon from '../Shared/GoogleIcon.vue';
/** 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>
<tr>
<slot name="head" />
</tr>
</thead>
<tbody class="">
<template v-if="items?.total > 0">
<slot
name="body"
:items="items?.data"
/>
</template>
<template v-else>
<tr>
<slot name="empty" />
</tr>
</template>
</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,36 @@
<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>
<tr>
<slot name="head" />
</tr>
</thead>
<tbody class="">
<slot
name="body"
:items="items"
/>
<tr>
<slot name="empty" />
</tr>
</tbody>
</table>
</div>
</div>
</section>
</template>

View File

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

View File

@ -0,0 +1,30 @@
import { DateTime } from "luxon";
/* Obtiene la fecha actual en el formato deseado */
function getDate(value = null) {
const date = (value)
? DateTime.fromISO(value)
: DateTime.now();
return date.toLocaleString(DateTime.DATE_MED);
}
/* Obtiene la horaa actual en el 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, getTime, getDateTime }

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,52 @@
import { ref } from 'vue';
import axios from 'axios';
/**
* 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;

View File

@ -0,0 +1,67 @@
import { ref } from 'vue';
import { router } from '@inertiajs/vue3';
/**
* Controlador simple de las bandejas
*/
class SearcherController
{
query = ref('');
constructor(route, params) {
this.route = route;
this.params = params;
}
/**
* Búsqueda simple
*/
search = (q = '', params) => {
this.query.value = q;
router.get(this._getRoute(), {
q,
...params
}, {preserveState: true});
};
/**
* Paginación simple
*/
withPagination = (page, params) => {
router.get(this._getRoute(), {
page,
...params
}, {preserveState: true});
}
/**
* Búsqueda con paginación en tablas
*/
searchWithPagination = (page, params) => {
router.get(page, {
q: this.query.value,
...params
}, {preserveState: true});
}
/**
* Búsqueda con páginación en bandejas
*/
searchWithInboxPagination = (page, params) => {
router.get(page, {
q: this.query.value,
...params
}, {preserveState: true});
}
/**
* Obtiene la ruta segun los parametros
*/
_getRoute = () => {
return (this.params)
? route(this.route, this.params)
: route(this.route);
}
}
export default SearcherController;

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

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

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',
}

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

@ -0,0 +1,406 @@
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',
add: 'Agregar',
admin: {
title: 'Administración',
},
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',
},
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.',
},
},
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',
menu:'Menú',
name:'Nombre',
noRecords:'Sin registros',
notification:'Notificación',
notifications: {
readed:'Marcar como leído',
deleted:'Notificación eliminada',
description:'Notificaciones del usuario',
notFound:'Notificación no encontrada',
title:'Notificaciones',
},
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',
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: 'Estos roles serán usados para dar permisos en el sistema.',
onSuccess: 'Rol creado exitosamente',
onError: 'Error al crear el role',
},
deleted:'Rol eliminado',
edit: {
title: 'Editar rol',
description: 'Actualiza los permisos del rol.',
onSuccess: 'Rol actualizado exitosamente',
onError: 'Error al actualizar el role',
},
title: 'Roles',
},
save:'Guardar',
saved:'¡Guardado!',
search:'Buscar',
selected: 'Seleccionado',
select: 'Seleccionar',
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:'Sistema',
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',
},
total: 'Total',
unknown:'Desconocido',
update:'Actualizar',
updated:'Actualizado',
updateFail:'Error al actualizar',
unreaded:'No leído',
user:'Usuario',
users:{
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',
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 escribiendola.',
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',
},
menu:'Menú de usuario',
select:'Seleccionar un usuario',
settings:'Ajustes del usuario',
system:'Usuarios del sistema',
title:'Usuarios',
},
version:'Versión',
workstation: 'Puesto de trabajo',
workstations: {
create: {
title: 'Crear puesto de trabajo',
},
edit: {
title: 'Editar puesto de trabajo',
},
title: 'Puestos de trabajos'
},
}

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

@ -0,0 +1,23 @@
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({
locale,
fallbackLocale: locale,
messages
});
const lang = (text) => i18n.global.t(text);
export {i18n, lang};

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

@ -0,0 +1,58 @@
<script setup>
import { hasPermission } from '@Plugins/RolePermission.js';
import Layout from '@Holos/Layout/AppLayout.vue';
import Link from '@Holos/Skeleton/Sidebar/Link.vue';
import Section from '@Holos/Skeleton/Sidebar/Section.vue';
/** Propiedades */
defineProps({
title: String,
titlePage: {
default: true,
type: Boolean
}
});
</script>
<template>
<Layout
:title="title"
:titlePage="titlePage"
>
<template #leftSidebar>
<Section name="Principal">
<Link
icon="monitoring"
name="dashboard"
to="dashboard.index"
/>
</Section>
<Section :name="$t('account.title')">
<Link
icon="manage_accounts"
name="profile"
to="profile.show"
/>
<Link
icon="notifications"
:name="$t('notifications.title')"
to="notifications.index"
/>
</Section>
<Section :name="$t('admin.title')">
<Link
v-if="hasPermission('users.index')"
icon="people"
name="users.title"
to="admin.users.index"
/>
</Section>
</template>
<!-- Contenido -->
<slot />
<!-- Fin contenido -->
</Layout>
</template>

42
src/main.js Normal file
View File

@ -0,0 +1,42 @@
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 Notify from '@Plugins/Notify'
import TailwindScreen from '@Plugins/TailwindScreen'
import App from './App.vue'
// Configurar axios
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
axios.defaults.baseURL = import.meta.env.VITE_API_URL;
// Crear instancias globales
window.Lang = lang;
window.Notify = new Notify();
window.TwScreen = new TailwindScreen();
async function boot() {
try {
const { data } = await axios.get('/api/routes');
// Iniciar rutas
window.Ziggy = data;
window.route = useRoute();
} catch (error) {
console.error(error);
alert('Failed to load routes');
}
createApp(App)
.use(createPinia())
.use(i18n)
.use(ZiggyVue)
.mount('#app');
}
// Iniciar aplicación
boot();

View File

@ -0,0 +1,76 @@
<script setup>
import { Link, useForm } from '@inertiajs/vue3';
import { goTo, transl } from './Module';
import IconButton from '@Holos/Button/Icon.vue'
import Input from '@Holos/Form/Input.vue';
import Selectable from '@Holos/Form/Selectable.vue';
import PageHeader from '@Holos/PageHeader.vue';
import DashboardLayout from '@Layouts/AppLayout.vue';
import Form from './Form.vue'
/** Propiedades */
defineProps({
roles: Object
});
const form = useForm({
_id: null,
name: '',
paternal: '',
maternal: '',
email: '',
phone: '',
password: '',
roles: []
});
/** Métodos */
function submit() {
form.transform(data => ({
...data,
roles: data.roles.map(role => role.id)
})).post(route(goTo('store')), {
onSuccess: () => Notify.success(lang('register.create.onSuccess')),
onError: () => Notify.error(lang('register.create.onError')),
onFinish: () => form.reset('password')
})
}
</script>
<template>
<DashboardLayout :title="transl('create.title')">
<PageHeader>
<Link :href="route(goTo('index'))">
<IconButton
class="text-white"
icon="arrow_back"
:title="$t('return')"
filled
/>
</Link>
</PageHeader>
<Form
action="create"
:form="form"
@submit="submit"
>
<Input
v-model="form.password"
class="col-span-2"
id="password"
type="password"
:onError="form.errors.password"
required
/>
<Selectable
v-model="form.roles"
label="description"
title="Roles"
:options="roles"
multiple
/>
</Form>
</DashboardLayout>
</template>

View File

@ -0,0 +1,52 @@
<script setup>
import { Link, useForm } from '@inertiajs/vue3';
import { goTo, transl } from './Module';
import IconButton from '@Holos/Button/Icon.vue'
import PageHeader from '@Holos/PageHeader.vue';
import Layout from '@/Layouts/AppLayout.vue';
import Form from './Form.vue'
/** Propiedades */
const props = defineProps({
model: Object,
});
/** Propiedades */
const form = useForm({
name: props.model.name,
paternal: props.model.paternal,
maternal: props.model.maternal,
email: props.model.email,
phone: props.model.phone,
});
/** Métodos */
function submit() {
form.put(route(goTo('update'), {user:props.model.id}), {
onSuccess: () => Notify.success(lang('register.edit.onSuccess')),
onError: () => Notify.error(lang('register.edit.onError')),
onFinish: () => form.reset('password')
})
}
</script>
<template>
<Layout :title="transl('edit.title')">
<PageHeader>
<Link :href="route(goTo('index'))">
<IconButton
class="text-white"
icon="arrow_back"
:title="$t('return')"
filled
/>
</Link>
</PageHeader>
<Form
action="update"
:form="form"
@submit="submit"
/>
</Layout>
</template>

View File

@ -0,0 +1,78 @@
<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 py-4">
<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 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<Input
v-model="form.name"
id="name"
:onError="form.errors.name"
autofocus
required
/>
<Input
v-model="form.paternal"
id="paternal"
:onError="form.errors.paternal"
autofocus
required
/>
<Input
v-model="form.maternal"
id="maternal"
:onError="form.errors.maternal"
autofocus
/>
<Input
v-model="form.phone"
id="phone"
:onError="form.errors.phone"
type="number"
autofocus
/>
<Input
v-model="form.email"
id="email.title"
type="email"
:onError="form.errors.email"
autofocus
required
/>
<slot />
<div class="col-span-1 md:col-span-2 lg:col-span-3 xl:col-span-4 flex flex-col items-center justify-end space-y-4 mt-4">
<PrimaryButton
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
v-text="$t(action)"
/>
</div>
</form>
</div>
</template>

View File

@ -0,0 +1,159 @@
<script setup>
import { ref } from 'vue';
import { Link } from '@inertiajs/vue3';
import { transl, can, goTo } from './Module'
import ModalController from '@/Controllers/ModalController.js';
import SearcherController from '@/Controllers/SearcherController.js';
import IconButton from '@Holos/Button/Icon.vue'
import DestroyView from '@Holos/Modal/Template/Destroy.vue';
import SearcherHead from '@Holos/Searcher.vue';
import Table from '@Holos/Table.vue';
import DashboardLayout from '@Layouts/AppLayout.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import ShowView from './Modals/Show.vue';
/** Eventos */
const props = defineProps({
models: Object
});
/** Controladores */
const Modal = new ModalController();
const Searcher = new SearcherController(goTo('index'));
/** Propiedades */
const destroyModal = ref(Modal.destroyModal);
const showModal = ref(Modal.showModal);
const modelModal = ref(Modal.modelModal);
</script>
<template>
<DashboardLayout :title="transl('system')">
<SearcherHead @search="Searcher.search">
<Link
v-if="can('create')"
:href="route(goTo('create'))"
>
<IconButton
class="text-white"
icon="add"
:title="$t('crud.create')"
filled
/>
</Link>
</SearcherHead>
<div class="pt-2 w-full">
<Table
:items="models"
@send-pagination="Searcher.searchWithPagination"
>
<template #head>
<th v-text="$t('user')" />
<th v-text="$t('contact')" />
<th
v-text="$t('actions')"
class="w-32 text-center"
/>
</template>
<template #body="{items}">
<tr v-for="model in items">
<td class="table-item border">
{{ `${model.name} ${model.paternal}` }}
</td>
<td class="table-item border">
<p>
<a
class="hover:underline"
target="_blank"
:href="`mailto:${model.email}`"
>
{{ model.email }}
</a>
</p>
<p v-if="model.phone" class="font-semibold text-xs">
<b>Teléfono: </b>
<a
class="hover:underline"
target="_blank"
:href="`tel:${model.phone}`"
>
{{ model.phone }}
</a>
</p>
</td>
<td class="table-item">
<div class="table-actions">
<GoogleIcon
class="btn-icon"
name="visibility"
:title="$t('crud.show')"
@click="Modal.switchShowModal(model)"
outline
/>
<Link
v-if="can('edit')"
class="h-fit"
:href="route(goTo('edit'), model.id)"
>
<GoogleIcon
class="btn-icon"
name="edit"
:title="$t('crud.edit')"
outline
/>
</Link>
<GoogleIcon
v-if="can('destroy')"
class="btn-icon"
name="delete"
:title="$t('crud.destroy')"
@click="Modal.switchDestroyModal(model)"
outline
/>
<Link
v-if="can('settings')"
class="h-fit"
:href="route('admin.users.settings', model.id)"
>
<GoogleIcon
class="btn-icon"
name="settings"
:title="$t('setting')"
/>
</Link>
</div>
</td>
</tr>
</template>
<template #empty>
<td class="table-item border">
<div class="flex items-center text-sm">
<p class="font-semibold">
{{ $t('registers.empty') }}
</p>
</div>
</td>
<td class="table-item border">-</td>
<td class="table-item border">-</td>
</template>
</Table>
</div>
<ShowView
v-if="can('index')"
:show="showModal"
:model="modelModal"
@close="Modal.switchShowModal"
/>
<DestroyView
v-if="can('destroy')"
:model="modelModal"
:show="destroyModal"
:to="(user) => route(goTo('destroy'), {user})"
@close="Modal.switchDestroyModal"
/>
</DashboardLayout>
</template>

View File

@ -0,0 +1,53 @@
<script setup>
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.name"
:subtitle="model.full_last_name"
>
</Header>
<div class="py-2 border-b">
<div class="px-4 py-2 flex">
<GoogleIcon
class="text-xl text-success"
name="contact_mail"
/>
<div class="pl-3">
<p class="font-bold text-lg leading-none pb-2">
{{ $t('contact') }}
</p>
<p>
<b>{{ $t('phone') }}: </b>
<a :href="`tel:${model.phone}`" target="_blank" class="hover:text-danger">
{{ model.phone }}
</a>
</p>
<p>
<b>{{ $t('email') }}: </b>
<a :href="`mailto:${model.email}`" target="_blank" class="hover:text-danger">
{{ model.email }}
</a>
</p>
</div>
</div>
</div>
</ShowModal>
</template>

View File

@ -0,0 +1,15 @@
import { lang } from '@/Lang/i18n';
import { hasPermission } from '@Plugins/RolePermission.js';
// Obtener ruta
const goTo = (route) => `admin.users.${route}`
// Obtener traducción del componente
const transl = (str) => lang(`users.${str}`)
// Determina si un usuario puede hacer algo no en base a los permisos
const can = (permission) => hasPermission(`users.${permission}`)
export {
can,
goTo,
transl
}

View File

@ -0,0 +1,61 @@
<script setup>
import { ref } from 'vue';
import { useForm } from '@inertiajs/vue3';
import { goTo, transl } from './Module';
import PrimaryButton from '@Holos/Button/Primary.vue';
import FormSection from '@Holos/FormSection.vue';
import Selectable from '@Holos/Form/Selectable.vue';
/** Propiedades */
const props = defineProps({
role: Object,
roles: Object,
user: Object
});
const form = useForm({
roles: props.role
});
/** Métodos */
function updateProfileInformation() {
form.transform(data => ({
roles: data.roles.map(role => role.id)
})).post(route(goTo('sync-roles'), {user:props.user.id}), {
preserveScroll: true,
onSuccess: () => Notify.success(lang('roles.edit.onSuccess')),
onError: () => Notify.error(lang('roles.edit.onError'))
});
};
</script>
<template>
<FormSection @submitted="updateProfileInformation">
<template #title>
{{ transl('roles.title') }}
</template>
<template #description>
{{ transl('roles.description') }}
</template>
<template #form>
<div class="col-span-6 sm:col-span-4 space-y-4">
<Selectable
v-model="form.roles"
label="description"
title="Roles"
:options="roles"
multiple
/>
</div>
</template>
<template #actions>
<PrimaryButton
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
>
{{ $t('update') }}
</PrimaryButton>
</template>
</FormSection>
</template>

View File

@ -0,0 +1,58 @@
<script setup>
import { goTo, transl } from './Module';
import { Link } from '@inertiajs/vue3';
import IconButton from '@Holos/Button/Icon.vue';
import PageHeader from '@Holos/PageHeader.vue';
import SectionBorder from '@Holos/SectionBorder.vue';
import DashboardLayout from '@Layouts/AppLayout.vue';
import Roles from './Roles.vue';
import UpdatePassword from './UpdatePassword.vue';
/**
* Propiedades
*/
defineProps({
role: Object,
roles: Object,
user: Object
});
</script>
<template>
<DashboardLayout :title="transl('settings')">
<PageHeader>
<Link :href="route(goTo('index'))">
<IconButton
class="text-white"
icon="arrow_back"
:title="$t('return')"
outline
/>
</Link>
</PageHeader>
<div class="flex w-full pt-2">
<div class="w-full text-center p-2 bg-primary dark:bg-primary-d border-b rounded-lg">
<p class="pt-2 text-lg font-bold text-gray-50">
{{ user.name }}
</p>
<p class="text-sm text-gray-100">
{{ user.full_last_name }}
</p>
</div>
</div>
<div class="w-full mt-12 space-y-4">
<UpdatePassword
:user="user"
/>
<SectionBorder />
<Roles
:role="role"
:roles="roles"
:user="user"
/>
<SectionBorder />
</div>
</DashboardLayout>
</template>

View File

@ -0,0 +1,70 @@
<script setup>
import { goTo, transl } from './Module';
import { useForm } from '@inertiajs/vue3';
import PrimaryButton from '@Holos/Button/Primary.vue';
import Input from '@Holos/Form/Input.vue';
import FormSection from '@Holos/FormSection.vue';
const props = defineProps({
user: Object
});
const form = useForm({
_method: 'POST',
password: '',
password_confirmation: '',
});
const updateProfileInformation = () => {
form.post(route(goTo('password'), props.user.id), {
errorBag: 'updateProfileInformation',
preserveScroll: true,
onSuccess: () => {
Notify.success(lang('account.password.updated'));
form.reset();
},
onError: () => Notify.error(lang('updateFail'))
});
};
</script>
<template>
<FormSection @submitted="updateProfileInformation">
<template #title>
{{ transl('password.title') }}
</template>
<template #description>
{{ transl('password.description') }}
</template>
<template #form>
<div class="col-span-6 sm:col-span-4 space-y-4">
<Input
id="password"
title="account.password.new"
type="password"
v-model="form.password"
:onError="form.errors.password"
autocomplete="off"
required
/>
<Input
icon="password"
id="passwordConfirmation"
type="password"
v-model="form.password_confirmation"
:onError="form.errors.password_confirmation"
required
/>
</div>
</template>
<template #actions>
<PrimaryButton
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
>
{{ $t('update') }}
</PrimaryButton>
</template>
</FormSection>
</template>

View File

@ -0,0 +1,53 @@
<script setup>
import { ref } from 'vue';
import { useForm } from '@inertiajs/vue3';
import Layout from '@Holos/Layout/AuthLayout.vue';
import Input from '@Holos/Form/InputWithIcon.vue';
import PrimaryButton from '@Holos/Button/Primary.vue';
const form = useForm({
password: '',
});
const passwordInput = ref(null);
const submit = () => {
form.post(route('password.confirm'), {
onFinish: () => {
form.reset();
passwordInput.value.focus();
},
});
};
</script>
<template>
<Layout :title="$t('auth.confirmPassword.title')">
<div class="mb-4 text-sm text-justify">
{{ $t('auth.confirmPassword.description') }}
</div>
<form @submit.prevent="submit">
<Input
icon="password"
id="password"
type="password"
v-model="form.password"
:onError="form.errors.password"
:placeholder="$t('password')"
/>
<div class="flex justify-end mt-4">
<PrimaryButton
class="!w-full"
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
>
{{ $t('auth.confirmPassword.title') }}
</PrimaryButton>
</div>
</form>
</Layout>
</template>

View File

@ -0,0 +1,53 @@
<script setup>
import { useForm } from '@inertiajs/vue3';
import Input from '@Holos/Form/InputWithIcon.vue'
import PrimaryButton from '@Holos/Button/Primary.vue'
import Layout from '@Holos/Layout/AuthLayout.vue'
defineProps({
status: String,
});
const form = useForm({
email: '',
});
const submit = () => {
form.post(route('password.email'));
};
</script>
<template>
<Layout :title="$t('auth.forgotPassword.title')">
<div class="mb-4 text-sm text-justify">
{{ $t('auth.forgotPassword.description') }}
</div>
<div v-if="status" class="mb-4 font-medium text-sm text-green-600">
{{ status }}
</div>
<form @submit.prevent="submit">
<Input
icon="mail"
id="email"
type="email"
v-model="form.email"
:onError="form.errors.email"
:placeholder="$t('email.title')"
/>
<div class="flex items-center justify-end mt-4">
<PrimaryButton
class="!w-full"
:class="{ 'opacity-25': form.processing }"
type="submit"
:disabled="form.processing"
>
{{ $t('auth.forgotPassword.sendLink') }}
</PrimaryButton>
</div>
</form>
</Layout>
</template>

64
src/pages/Auth/Login.vue Normal file
View File

@ -0,0 +1,64 @@
<script setup>
import { Link, useForm } from '@inertiajs/vue3';
import PrimaryButton from '@Holos/Button/Primary.vue'
import Input from '@Holos/Form/InputWithIcon.vue'
import Layout from '@Holos/Layout/AuthLayout.vue'
/** Propiedades */
defineProps({
canResetPassword: Boolean,
status: String,
});
const form = useForm({
email: '',
password: '',
remember: false,
});
/** Métodos */
const login = () => {
form.transform(data => ({
...data,
remember: form.remember ? 'on' : '',
})).post(route('login'), {
onFinish: () => form.reset('password'),
});
};
</script>
<template>
<Layout :title="$t('auth.login')">
<form @submit.prevent="login">
<Input
icon="mail"
id="email"
type="email"
v-model="form.email"
:onError="form.errors.email"
:placeholder="$t('email.title')"
/>
<Input
v-model="form.password"
icon="password"
id="password"
type="password"
:onError="form.errors.password"
:placeholder="$t('password')"
/>
<PrimaryButton class="!w-full">
{{ $t('auth.login') }}
</PrimaryButton>
<div class="flex justify-end mt-4">
<Link
v-if="canResetPassword"
class="text-sm ml-2 hover:text-blue-200 cursor-pointer hover:-translate-y-1 duration-500 transition-all"
:href="route('password.request')"
>
{{ $t('auth.forgotPassword.ask') }}
</Link>
</div>
</form>
</Layout>
</template>

121
src/pages/Auth/Register.vue Normal file
View File

@ -0,0 +1,121 @@
<script setup>
import { Head, Link, useForm } from '@inertiajs/vue3';
import Checkbox from '@Holos/Checkbox.vue';
import InputLabel from '@Holos/InputLabel.vue';
import Error from '@Holos/Form/Elements/Error.vue';
import PrimaryButton from '@Holos/Button/Primary.vue';
import Input from '@Holos/Form/InputWithIcon.vue'
import Layout from '@Holos/Layout/AuthLayout.vue'
const form = useForm({
name: '',
paternal: '',
maternal: '',
phone: '',
email: '',
password: '',
password_confirmation: '',
terms: false,
});
const submit = () => {
form.post(route('register'), {
onFinish: () => form.reset('password', 'password_confirmation'),
});
};
</script>
<template>
<Layout :title="$t('auth.register.me')">
<form @submit.prevent="submit">
<Input
icon="people"
id="name"
type="text"
v-model="form.name"
:onError="form.errors.name"
:placeholder="$t('name')"
/>
<Input
icon="people"
id="paternal"
type="text"
v-model="form.paternal"
:onError="form.errors.paternal"
:placeholder="$t('paternal')"
/>
<Input
icon="people"
id="maternal"
type="text"
v-model="form.maternal"
:onError="form.errors.maternal"
:placeholder="$t('maternal')"
/>
<Input
icon="phone"
id="phone"
type="number"
v-model="form.phone"
:onError="form.errors.phone"
:placeholder="$t('phone')"
/>
<Input
icon="mail"
id="email"
type="email"
v-model="form.email"
:onError="form.errors.email"
:placeholder="$t('email.title')"
/>
<Input
icon="password"
id="password"
type="password"
v-model="form.password"
:onError="form.errors.password"
:placeholder="$t('password')"
/>
<Input
icon="password"
id="passwordConfirmation"
type="password"
v-model="form.password_confirmation"
:onError="form.errors.password_confirmation"
:placeholder="$t('passwordConfirmation')"
/>
<div v-if="$page.props.jetstream.hasTermsAndPrivacyPolicyFeature" class="mt-4">
<InputLabel for="terms">
<div class="flex items-center">
<Checkbox id="terms" v-model:checked="form.terms" name="terms" required />
<div class="ms-2 text-primary-t dark:text-primary-dt">
I agree to the
<a target="_blank" :href="route('terms.show')" class="underline text-sm rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
{{ $t('terms.service') }}
</a>
and
<a target="_blank" :href="route('policy.show')" class="underline text-sm rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
{{ $t('policy.privacy') }}
</a>
</div>
</div>
<Error class="mt-2" :onError="form.errors.terms" />
</InputLabel>
</div>
<div class="flex items-center justify-end mt-4">
<Link :href="route('login')" class="underline text-sm bg-page-text hover:bg-page-background text-page-background hover:text-page-text rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
{{ $t('auth.register.already') }}
</Link>
<PrimaryButton class="ms-4" :class="{ 'opacity-25': form.processing }" :disabled="form.processing">
{{ $t('auth.register.me') }}
</PrimaryButton>
</div>
</form>
</Layout>
</template>

View File

@ -0,0 +1,65 @@
<script setup>
import { useForm } from '@inertiajs/vue3';
import PrimaryButton from '@Holos/Button/Primary.vue';
import Input from '@Holos/Form/InputWithIcon.vue'
import Layout from '@Holos/Layout/AuthLayout.vue'
const props = defineProps({
email: String,
token: String,
});
const form = useForm({
token: props.token,
email: props.email,
password: '',
password_confirmation: '',
});
const submit = () => {
form.post(route('password.update'), {
onSuccess: () => {
Notify.success(lang('auth.reset.success'));
},
onFinish: () => form.reset('password', 'password_confirmation'),
});
};
</script>
<template>
<Layout :title="$t('auth.reset.title')">
<form @submit.prevent="submit">
<Input
icon="mail"
id="email"
type="email"
v-model="form.email"
:onError="form.errors.email"
:placeholder="$t('email.title')"
/>
<Input
icon="password"
id="password"
type="password"
v-model="form.password"
:onError="form.errors.password"
:placeholder="$t('password')"
/>
<Input
icon="password"
id="passwordConfirmation"
type="password"
v-model="form.password_confirmation"
:onError="form.errors.password_confirmation"
:placeholder="$t('passwordConfirmation')"
/>
<div class="flex items-center justify-end mt-4">
<PrimaryButton class="!w-full" :class="{ 'opacity-25': form.processing }" :disabled="form.processing">
{{ $t('account.password.update') }}
</PrimaryButton>
</div>
</form>
</Layout>
</template>

View File

@ -0,0 +1,100 @@
<script setup>
import { nextTick, ref } from 'vue';
import { useForm } from '@inertiajs/vue3';
import PrimaryButton from '@Holos/Button/Primary.vue'
import Input from '@Holos/Form/InputWithIcon.vue'
import Layout from '@Holos/Layout/AuthLayout.vue'
const recovery = ref(false);
const form = useForm({
code: '',
recovery_code: '',
});
const recoveryCodeInput = ref(null);
const codeInput = ref(null);
const toggleRecovery = async () => {
recovery.value ^= true;
await nextTick();
if (recovery.value) {
recoveryCodeInput.value.focus();
form.code = '';
} else {
codeInput.value.focus();
form.recovery_code = '';
}
};
const submit = () => {
form.post(route('two-factor.login'));
};
</script>
<template>
<Layout :title="$t('account.twoFactor.title')">
<div class="mb-4 text-sm text-justify text-white">
<template v-if="! recovery">
{{ $t('account.twoFactor.login.onAuth')}}
</template>
<template v-else>
{{ $t('account.twoFactor.login.onRecovery')}}
</template>
</div>
<form @submit.prevent="submit" class="text-white">
<div v-if="! recovery">
<Input
ref="codeInput"
icon="password"
id="code"
type="text"
inputmode="numeric"
autofocus
autocomplete="one-time-code"
v-model="form.code"
:onError="form.errors.code"
/>
</div>
<div v-else>
<Input
ref="recoveryCodeInput"
icon="password"
autocomplete="one-time-code"
id="recovery_code"
v-model="form.recovery_code"
:onError="form.errors.recovery_code"
/>
</div>
<PrimaryButton
class="!w-full"
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
>
{{ $t('auth.login') }}
</PrimaryButton>
<div class="flex w-full items-center justify-end mt-4">
<button
type="button"
class="text-sm text-white underline cursor-pointer"
@click.prevent="toggleRecovery"
>
<template v-if="! recovery">
{{ $t('account.twoFactor.recovery.useCode')}}
</template>
<template v-else>
{{ $t('account.twoFactor.recovery.useAuth')}}
</template>
</button>
</div>
</form>
</Layout>
</template>

View File

@ -0,0 +1,54 @@
<script setup>
import { computed } from 'vue';
import { Link, useForm } from '@inertiajs/vue3';
import PrimaryButton from '@Holos/Button/Primary.vue';
import Layout from '@Holos/Layout/AuthLayout.vue';
const props = defineProps({
status: String,
});
const form = useForm({});
const submit = () => {
form.post(route('verification.send'));
};
const verificationLinkSent = computed(() => props.status === 'verification-link-sent');
</script>
<template>
<Layout :title="$t('auth.verifyEmail.title')">
<div class="mb-4 text-sm text-justify">
{{ $t('auth.verifyEmail.beforeContinue') }}
</div>
<div v-if="verificationLinkSent" class="mb-4 font-medium text-sm text-green-600">
{{ $t('auth.verifyEmail.notifySendVerification') }}
</div>
<form @submit.prevent="submit">
<div class="mt-4 flex flex-col space-y-1 items-center justify-between">
<PrimaryButton class="!w-full" :class="{ 'opacity-25': form.processing }" :disabled="form.processing">
{{ $t('auth.verifyEmail.sendLink') }}
</PrimaryButton>
<Link
:href="route('profile.show')"
class="btn btn-primary !w-full"
>
{{ $t('profile') }}
</Link>
<Link
:href="route('logout')"
class="btn btn-primary !w-full"
method="post"
>
{{ $t('auth.logout') }}
</Link>
</div>
</form>
</Layout>
</template>

View File

@ -0,0 +1,14 @@
<script setup>
import AppLayout from '@Layouts/AppLayout.vue';
</script>
<template>
<AppLayout title="Dashboard">
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ $t('dashboard') }}
</h2>
</template>
</AppLayout>
</template>

View File

@ -0,0 +1,99 @@
<script setup>
import { ref } from 'vue';
import { Link } from '@inertiajs/vue3';
import { transl, can, goTo } from './Module'
import ModalController from '@Controllers/ModalController.js';
import SearcherController from '@Controllers/SearcherController.js';
import InboxController from '@Controllers/InboxController.js';
import Inbox from '@Holos/Inbox.vue';
import InboxItem from '@Holos/Inbox/Item.vue';
import SearcherHead from '@Holos/Searcher.vue';
import DashboardLayout from '@Layouts/AppLayout.vue';
import Menu from './Partials/Menu.vue';
import ItemRow from './Partials/ItemRow.vue';
import ShowView from './Modals/Show.vue';
import GoogleIcon from '@/Components/Shared/GoogleIcon.vue';
/** Definidores */
const inboxCtl = new InboxController();
const searcherCtl = new SearcherController(goTo('index'));
/** Eventos */
const props = defineProps({
models: Object
});
/** Controladores */
const Modal = new ModalController();
/** Propiedades */
const showModal = ref(Modal.showModal);
const modelModal = ref(Modal.modelModal);
</script>
<template>
<DashboardLayout :title="transl('system')">
<SearcherHead @search="searcherCtl.search" />
<Inbox
:inboxCtl="inboxCtl"
:items="models"
:searcherCtl="searcherCtl"
withMultiSelection
>
<template #menu>
<Menu
:counters="counters"
/>
</template>
<template #actions>
<div class="flex items-center ml-3">
<GoogleIcon
class="inbox-icon"
title="Enviar a almacén"
name="warehouse"
outline
@click="sendToWarehouse()"
/>
</div>
</template>
<template #head>
</template>
<template #items="{items}">
<template v-for="model in items" :key="model.id">
<InboxItem
:inboxCtl="inboxCtl"
:item="model"
>
<template #item>
<ItemRow
:model="model"
/>
</template>
<template #actions="{check}">
</template>
<template #date>
<span>{{ model.tracker}}</span>
</template>
</InboxItem>
</template>
</template>
</Inbox>
<ShowView
v-if="can('index')"
:show="showModal"
:model="modelModal"
@close="Modal.switchShowModal"
/>
</DashboardLayout>
</template>

View File

@ -0,0 +1,53 @@
<script setup>
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.name"
:subtitle="model.full_last_name"
>
</Header>
<div class="py-2 border-b">
<div class="px-4 py-2 flex">
<GoogleIcon
class="text-xl text-success"
name="contact_mail"
/>
<div class="pl-3">
<p class="font-bold text-lg leading-none pb-2">
{{ $t('contact') }}
</p>
<p>
<b>{{ $t('phone') }}: </b>
<a :href="`tel:${model.phone}`" target="_blank" class="hover:text-danger">
{{ model.phone }}
</a>
</p>
<p>
<b>{{ $t('email') }}: </b>
<a :href="`mailto:${model.email}`" target="_blank" class="hover:text-danger">
{{ model.email }}
</a>
</p>
</div>
</div>
</div>
</ShowModal>
</template>

View File

@ -0,0 +1,15 @@
import { lang } from '@/Lang/i18n';
import { hasPermission } from '@Plugins/RolePermission.js';
// Obtener ruta
const goTo = (route) => `admin.users.${route}`
// Obtener traducción del componente
const transl = (str) => lang(`notifications.${str}`)
// Determina si un usuario puede hacer algo no en base a los permisos
const can = (permission) => hasPermission(`users.${permission}`)
export {
can,
goTo,
transl
}

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