before delete 3software

This commit is contained in:
2025-06-29 18:55:59 +03:00
parent ebd77a3e66
commit b51a472738
147 changed files with 257326 additions and 3151 deletions

View File

@@ -23,6 +23,7 @@
no-error-icon
@focus="($refs.emailInput as typeof QInput)?.resetValidation()"
ref="emailInput"
:disable="type === 'changePwd'"
/>
<q-stepper-navigation>
<q-btn
@@ -54,6 +55,7 @@
@click="handleSubmit"
color="primary"
:label="$t('continue')"
:disable="code.length === 0"
/>
<q-btn
flat
@@ -73,6 +75,7 @@
v-model="password"
dense
filled
autofocus
:label = "$t('account_helper__password')"
:type="isPwd ? 'password' : 'text'"
hide-hint
@@ -114,15 +117,14 @@
<pn-magic-overlay
v-if="showSuccessOverlay"
icon="mdi-check-circle-outline"
message1="account_helper__ok_message1"
message2="account_helper__ok_message2"
:message1="getHelperMessage1()"
:message2="getHelperMessage2()"
route-name="projects"
/>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import type { AxiosError } from 'axios'
import { useQuasar } from 'quasar'
import { useI18n } from "vue-i18n"
@@ -134,7 +136,9 @@
? 'register'
: props.type === 'forgotPwd'
? 'forgot'
: 'change'
: props.type === 'changePwd'
? 'changePwd'
: 'changeMethod'
})
const $q = useQuasar()
@@ -142,7 +146,7 @@
const authStore = useAuthStore()
const props = defineProps<{
type: 'register' | 'forgotPwd' | 'changePwd'
type: 'register' | 'forgotPwd' | 'changePwd' | 'changeMethod'
email?: string
}>()
@@ -182,7 +186,7 @@
},
3: async () => {
await authStore.setPassword(flowType.value, login.value, code.value, password.value)
if (flowType.value === 'register') {
if (flowType.value === 'register' || flowType.value === 'changeMethod') {
await authStore.loginWithCredentials(login.value, password.value)
}
}
@@ -217,6 +221,28 @@
handleError(error as AxiosError)
}
}
const getHelperMessage1 = () => {
switch (flowType.value) {
case 'register': return 'account_helper__register_message1'
case 'forgot': return 'account_helper__forgot_password_message1'
case 'changePwd': return 'account_helper__change_password_message1'
case 'changeMethod': return 'account_helper__change_method_message1'
default: return ''
}
}
const getHelperMessage2 = () => {
switch (flowType.value) {
case 'register': return 'slogan'
case 'forgot':
case 'changePwd':
case 'changeMethod':
return 'account_helper__go_projects'
default: return ''
}
}
</script>
<style>

View File

@@ -0,0 +1,128 @@
<template>
<pn-page-card>
<template #title>
{{ $t(title) }}
</template>
<template #footer>
<q-btn
rounded color="primary"
class="w100 q-mt-md q-mb-xs"
:disable="!(isFormValid && (isDirty(initialCompany, modelValue)))"
@click = "emit('update')"
>
{{ $t(btnText) }}
</q-btn>
</template>
<pn-scroll-list>
<div class="flex column items-center q-pa-md q-pb-sm">
<slot name="myCompany"/>
<pn-image-selector
v-model="modelValue.logo"
:size="100"
:iconsize="80"
class="q-pb-lg"
/>
<div class="q-gutter-y-lg w100">
<q-input
v-for="input in textInputs"
:key="input.id"
v-model.trim="modelValue[input.val]"
dense
filled
class="w100"
:autogrow="input.val === 'description' || input.val === 'address'"
:class="input.val === 'name'
? 'fix-bottom-padding'
: input.val === 'address'
? 'input-fix q-pt-sm'
: 'q-pt-sm'"
:label="input.label ? $t(input.label) : void 0"
:rules="input.val === 'name' ? [rules[input.val]] : []"
no-error-icon
:label-slot="Boolean(input.label)"
>
<template #prepend>
<q-icon v-if="input.icon" :name="input.icon"/>
</template>
<template #label v-if="input.label">
{{$t(input.label) }}
<span v-if="input.val === 'name'" class="text-red">*</span>
</template>
</q-input>
</div>
</div>
</pn-scroll-list>
</pn-page-card>
</template>
<script setup lang="ts">
import {onMounted, computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { isDirty } from 'helpers/helpers'
import type { CompanyParams } from 'types/Company'
const { t }= useI18n()
const modelValue = defineModel<CompanyParams>({
required: true
})
defineProps<{
title: string,
btnText: string
}>()
const emit = defineEmits(['update'])
interface TextInput {
id: number
label?: string
icon?: string
val: keyof CompanyParams
rules: ((value: string) => boolean | string)[]
}
const textInputs: TextInput[] = [
{ id: 1, val: 'name', label: 'company_block__name', rules: [] },
{ id: 2, val: 'description', label: 'company_block__description', rules: [] },
{ id: 3, val: 'site', icon: 'mdi-web', rules: [] },
{ id: 4, val: 'address', icon: 'mdi-map-marker-outline', rules: [] },
{ id: 5, val: 'phone', icon: 'mdi-phone-outline', rules: [] },
{ id: 6, val: 'email', icon: 'mdi-email-outline', rules: [] }
]
const rulesErrorMessage = {
name: t('company_block__error_name')
}
const rules = {
name: (val: CompanyParams['name']) => !!val?.trim() || rulesErrorMessage['name']
}
const isFormValid = computed(() => {
const validations = {
name: rules.name(modelValue.value.name) === true
}
return Object.values(validations).every(Boolean)
})
const initialCompany = ref({} as CompanyParams)
onMounted(() => {
console.log(111, modelValue.value)
initialCompany.value = { ...modelValue.value }
})
</script>
<style scoped>
.q-field--with-bottom.fix-bottom-padding {
padding-bottom: 0 !important
}
.input-fix :deep(.q-field__prepend.q-field__marginal) {
height: auto !important;
}
</style>

View File

@@ -1,80 +0,0 @@
<template>
<div class="flex column items-center col-grow q-px-lg q-pt-sm">
<pn-image-selector :size="100" :iconsize="80" class="q-pb-xs" v-model="modelValue.logo"/>
<q-input
v-for="input in textInputs"
:key="input.id"
v-model.trim="modelValue[input.val]"
dense
filled
class = "q-mt-md w100"
:label = "input.label ? $t(input.label) : void 0"
:rules="input.val === 'name' ? [rules[input.val]] : []"
>
<template #prepend>
<q-icon v-if="input.icon" :name="input.icon"/>
</template>
</q-input>
</div>
</template>
<script setup lang="ts">
import { watch, computed } from 'vue'
import type { CompanyParams } from 'src/types'
import { useI18n } from 'vue-i18n'
const { t }= useI18n()
const modelValue = defineModel<CompanyParams>({
required: false,
default: () => ({
name: '',
logo: '',
description: '',
site: '',
address: '',
phone: '',
email: ''
})
})
const emit = defineEmits(['valid'])
const rulesErrorMessage = {
name: t('company_card__error_name')
}
const rules = {
name: (val :CompanyParams['name']) => !!val?.trim() || rulesErrorMessage['name']
}
const isValid = computed(() => {
const checkName = rules.name(modelValue.value.name)
return { name: checkName && (checkName !== rulesErrorMessage['name']) }
})
watch(isValid, (newVal) => {
const allValid = Object.values(newVal).every(v => v)
emit('valid', allValid)
}, { immediate: true})
interface TextInput {
id: number
label?: string
icon?: string
val: keyof CompanyParams
rules: ((value: string) => boolean | string)[]
}
const textInputs: TextInput[] = [
{ id: 1, val: 'name', label: 'company_info__name', rules: [] },
{ id: 2, val: 'description', label: 'company_info__description', rules: [] },
{ id: 3, val: 'site', icon: 'mdi-web', rules: [] },
{ id: 4, val: 'address', icon: 'mdi-map-marker-outline', rules: [] },
{ id: 5, val: 'phone', icon: 'mdi-phone-outline', rules: [] },
{ id: 6, val: 'email', icon: 'mdi-email-outline', rules: [] },
]
</script>
<style>
</style>

View File

@@ -1,33 +1,33 @@
<template>
<div class="q-pt-md">
<div class="q-pt-md" v-if="mapUsers.length !==0 ">
<span class="q-pl-md text-h6">
{{ $t('company_info__persons') }}
{{ $t('company_info__users') }}
</span>
<q-list separator>
<q-item
v-for="item in persons"
v-for="item in mapUsers"
:key="item.id"
v-ripple
clickable
@click="goPersonInfo()"
@click="goPersonInfo(item.id)"
>
<q-item-section avatar>
<q-avatar>
<img v-if="item.logo" :src="item.logo"/>
<pn-auto-avatar v-else :name="item.name"/>
</q-avatar>
<pn-auto-avatar
:img="item.photo"
:name="item.section1"
/>
</q-item-section>
<q-item-section>
<q-item-label lines="1" class="text-bold">
{{item.name}}
<q-item-label lines="1" class="text-bold" v-if="item.section1">
{{item.section1}}
</q-item-label>
<q-item-label caption lines="2">
<span>{{item.tname}}</span>
<span class="text-blue q-ml-sm">{{item.tusername}}</span>
<span v-if="item.section2_1" class="q-mr-sm">{{item.section2_1}}</span>
<span class="text-blue" v-if="item.section2_2">{{'@' + item.section2_2}}</span>
</q-item-label>
<q-item-label lines="1">
{{item.role}}
<q-item-label lines="1" v-if="item.section3">
{{item.section3}}
</q-item-label>
</q-item-section>
</q-item>
@@ -36,20 +36,59 @@
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useRouter, useRoute } from 'vue-router'
import { useUsersStore } from 'stores/users'
import type { User } from 'types/Users'
const usersStore = useUsersStore()
const router = useRouter()
const route = useRoute()
const router = useRouter()
const currentCompanyId = Number(route.params.companyId)
const users = usersStore.users
const persons = [
{id: "p1", name: 'Кирюшкин Андрей', logo: 'https://cdn.quasar.dev/img/avatar4.jpg', tname: 'Kir_AA', tusername: '@kiruha90', role: 'DevOps' },
{id: "p2", name: 'Пупкин Василий Александрович', logo: '', tname: 'Pupkin', tusername: '@super_pupkin', role: 'Руководитель проекта' },
{id: "p3", name: 'Макарова Полина', logo: 'https://cdn.quasar.dev/img/avatar6.jpg', tname: 'Unikorn', tusername: '@unicorn_stars', role: 'Администратор' },
{id: "p4", name: 'Жабов Максим', logo: '', tname: 'Zhaba', tusername: '@Zhabchenko', role: 'Аналитик' },
]
const mapUsers = users
.filter(el => el.company_id === currentCompanyId)
.map(el => ({...el, ...userSection(el)}))
async function goPersonInfo () {
console.log('update')
await router.push({ name: 'person_info' })
async function goPersonInfo (userId: number) {
await router.push({ name: 'user_info', params: { id: route.params.id, userId }})
}
// copy from 'pages/project-page/ProjectPageUsers.vue' кроме company.name
function userSection (user: User) {
const tname = () => {
return user.firstname
? user.lastname
? user.firstname + ' ' + user.lastname
: user.firstname
: user.lastname ?? ''
}
const section1 = user.name
? user.name
: tname()
const section2_1 = user.name
? tname()
: ''
const section2_2 = user.username ?? ''
const section3 = (
user.department
? user.department + ' '
: ''
) + (
user.role ?? ''
)
return {
section1,
section2_1, section2_2,
section3
}
}
</script>

View File

@@ -0,0 +1,75 @@
<template>
<pn-page-card>
<template #title>
{{ $t(type + '__title') }}
</template>
<pn-scroll-list>
<div class="q-px-md">
<div
v-if="fileText"
style="white-space: pre-wrap;"
>
{{ fileText }}
</div>
<div
v-else
align="center"
class="text-negative"
>
{{ $t(type + '__not_ready') }}
</div>
</div>
</pn-scroll-list>
<div
class="flex column justify-center items-center w100"
style="position: absolute; bottom: 0;"
v-if="isLoading"
>
<q-linear-progress indeterminate />
</div>
</pn-page-card>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useSettingsStore } from 'stores/settings'
const fileText = ref<string>('')
const isLoading = ref<boolean>(true)
const error = ref<boolean>(false)
const settingsStore = useSettingsStore()
const lang = ref<string>('EN')
const props = defineProps<{
type: 'terms_of_use' | 'privacy'
}>()
function parseLocale(locale: string): string {
return locale.split(/[-_]/)[0] ?? ''
}
const baseDocName =
props.type ==='terms_of_use' ? 'Terms_of_use' : 'Privacy'
onMounted(async () => {
const locale = settingsStore.settings.locale
lang.value = parseLocale(locale)
try {
const response = await fetch('/admin/doc/' + baseDocName + '_' + lang.value +'.txt')
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`)
}
fileText.value = await response.text()
} catch (err) {
console.error('File load error:', err)
error.value = true
} finally {
isLoading.value = false
}
})
</script>
<style>
</style>

View File

@@ -1,12 +1,12 @@
<template>
<div class="flex items-center no-wrap overflow-hidden w100">
<q-icon v-if="user?.email" size="md" class="q-mr-sm" name="mdi-account-circle-outline"/>
<q-icon v-if="customer?.email" size="md" class="q-mr-sm" name="mdi-account-circle-outline"/>
<q-avatar v-else size="32px" class="q-mr-sm">
<q-img v-if="tgUser?.photo_url" :src="tgUser.photo_url"/>
<q-icon v-else size="md" class="q-mr-sm" name="mdi-account-circle-outline"/>
</q-avatar>
<span v-if="user?.email" class="ellipsis">
{{ user.email }}
<span v-if="customer?.email" class="ellipsis">
{{ customer.email }}
</span>
<span v-else class="ellipsis">
{{
@@ -25,7 +25,7 @@
import type { WebApp } from '@twa-dev/types'
const authStore = useAuthStore()
const user = authStore.user
const customer = authStore.customer
const tg = inject('tg') as WebApp
const tgUser = tg.initDataUnsafe.user

View File

@@ -1,19 +1,42 @@
<template>
<div
:style="{ backgroundColor: stringToColour(props.name) } "
class="fit flex items-center justify-center text-white"
>
{{ props.name.substring(0, 1) }}
</div>
<q-avatar
:square="type==='square'"
:rounded="type==='rounded'"
:size="size"
>
<img
v-if="img"
:src="img"
style=" object-fit: cover;"
/>
<div
v-else
:style="{ backgroundColor: stringToColour(name) } "
class="fit flex items-center justify-center text-white"
>
{{ name ? name.substring(0, 1) : '-' }}
</div>
</q-avatar>
</template>
<script setup lang="ts">
const props = defineProps<{
name: string
}>()
interface Props {
img?: string | null
name: string,
size?: string,
type?: 'rounded' | 'square' | ''
}
withDefaults(defineProps<Props>(), {
img: null,
name: '-',
size: 'md',
type: ''
})
const stringToColour = (str: string) => {
if (!str) return '#eee'
let hash = 0
str.split('').forEach(char => {
hash = char.charCodeAt(0) + ((hash << 5) - hash)
@@ -30,3 +53,40 @@
<style>
</style>
<!-- <template>
<div
:style="{ backgroundColor: stringToColour(props.name) } "
class="fit flex items-center justify-center text-white"
>
{{ props.name ? props.name.substring(0, 1) : '' }}
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
name: string
}>()
const stringToColour = (str: string) => {
if (!str) return '#eee'
let hash = 0
str.split('').forEach(char => {
hash = char.charCodeAt(0) + ((hash << 5) - hash)
})
let colour = '#'
for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 0xff
colour += value.toString(16).padStart(2, '0')
}
return colour
}
</script>
<style>
</style> -->

View File

@@ -17,7 +17,7 @@
accept="image/*"
/>
<q-icon
v-if="modelValue === '' || modelValue === undefined"
v-if="modelValue === '' || modelValue === undefined || modelValue === null"
name="mdi-camera-plus-outline"
class="absolute-full fit text-grey-4"
:style="{ fontSize: String(iconsize) + 'px'}"

View File

@@ -4,18 +4,36 @@
maximized
persistent
transition-show="slide-up"
transition-hide="slide-down">
transition-hide="slide-down"
>
<div
class="flex items-center justify-center fullscrean-card column"
>
<q-icon :name = "icon" color="brand" size="160px"/>
<div class="text-h5 q-mb-lg">
<div
id="icon-wrapper"
:style="{ position: 'relative', height: size + 'px', width: size + 'px'}"
>
<div class="animation icon-position"></div>
<transition
appear
enter-active-class="animated zoomIn slow"
>
<q-icon
v-if="showIcon"
:name = "icon"
color="brand"
size="160px"
class="icon-position"
/>
</transition>
</div>
<div class="text-h5 q-mb-lg q-mx-md" align="center">
{{ $t(message1) }}
</div>
<div
v-if="message2"
class="absolute-bottom q-py-lg flex justify-center row"
class="absolute-bottom q-py-lg q-mx-md" align="center"
>
{{ $t(message2) }}
</div>
@@ -24,7 +42,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
const props = defineProps<{
@@ -35,30 +53,35 @@ const props = defineProps<{
}>()
const visible = ref(false)
const showIcon = ref(false)
const router = useRouter()
const timers = ref<number[]>([])
const size = 160
const setupTimers = () => {
visible.value = true
const timer1 = window.setTimeout(() => {
visible.value = false
const timer2 = window.setTimeout(() => {
router.push({ name: props.routeName })
}, 300)
timers.value.push(timer2)
}, 2000)
showIcon.value = true
}, 300)
timers.value.push(timer1)
};
const timer2 = window.setTimeout(() => {
visible.value = false
showIcon.value = false
}, 2000)
timers.value.push(timer2)
}
const clearTimers = () => {
timers.value.forEach(timer => clearTimeout(timer))
timers.value = []
visible.value = false
showIcon.value = false
}
watch(visible, async (newVal) => {
if (newVal === false) await router.push({ name: props.routeName })
})
onMounted(setupTimers)
onUnmounted(clearTimers)
</script>
@@ -68,6 +91,32 @@ onUnmounted(clearTimers)
background-color: white;
}
@property --percentage {
initial-value: 0%;
inherits: false;
syntax: "<percentage>";
}
.animation {
background: conic-gradient(transparent var(--percentage), white 0);
width: 100%;
height: 100%;
animation: timer 1s linear;
animation-fill-mode:forwards;
z-index: 10;
position: absolute;
}
@keyframes timer {
to {
--percentage: 100%;
}
}
.icon-position {
position: absolute;
top:0;
left:0;
}
</style>

View File

@@ -0,0 +1,49 @@
<template>
<div class="flex w100 column q-pt-xl q-pa-md">
<div class="flex column justify-center col-grow items-center text-grey">
<q-btn
flat
no-caps
@click="handleClick"
v-if="!noBtn"
>
<div class="flex column justify-center col-grow items-center">
<q-icon :name="icon" size="160px" class="q-pb-md"/>
<div class="text-h6 text-brand">
{{message1}}
</div>
</div>
</q-btn>
<div v-else>
<div class="flex column justify-center col-grow items-center">
<q-icon :name="icon" size="160px" class="q-pb-md"/>
<div class="text-h6 text-brand">
{{message1}}
</div>
</div>
</div>
<div v-if="message2" class="text-caption" align="center">
{{message2}}
</div>
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
icon: string
message1: string
message2?: string
noBtn?: boolean
}>()
const emit = defineEmits(['btn-click'])
const handleClick = () => {
emit('btn-click')
}
</script>
<style>
</style>

View File

@@ -1,23 +1,18 @@
<template>
<q-page class="column items-center no-scroll">
<div
class="text-white flex items-center w100 q-pl-md q-pr-sm q-ma-none text-h6 no-scroll"
style="min-height: 48px"
>
<slot name="title"/>
</div>
<slot/>
<div
class="flex items-center justify-between q-ma-none q-py-none q-px-md text-white text-h6 no-scroll no-wrap w100"
style="min-height: 48px"
>
<slot name="title"/>
</div>
<slot/>
<div class="bg-white w100 q-ma-none q-px-md">
<slot name="footer"/>
</q-page>
</div>
</template>
<script setup lang="ts">
</script>
<style>
.glass-card {
opacity: 1 !important;
background-color: white;
}
</style>

View File

@@ -1,132 +1,94 @@
<template>
<div id="card-body" class="w100 col-grow flex column" style="position: relative">
<div
class="glass-card fit top-rounded-card flex column"
style="position: absolute; top: 0; left: 0"
/>
<div
id="page-card"
class="w100 flex column glass-card top-rounded-card no-scroll no-wrap"
>
<div
id="card-body-header"
style="min-height: var(--top-raduis);"
style="flex-shrink: 0; min-height: var(--top-raduis);"
>
<q-resize-observer @resize="onHeaderResize"/>
<slot name="card-body-header"/>
</div>
<div class="fit flex column col-grow">
<q-resize-observer @resize="onResize" />
<div id="card-scroll-area" class="noscroll">
<q-scroll-area
ref="scrollArea"
:style="{height: heightCard+'px'}"
class="w100 q-pa-none q-ma-none"
id="scroll-area"
@scroll="onScroll"
:class="{
'shadow-top': hasScrolled,
'shadow-bottom': hasScrolledBottom
}"
>
<slot/>
<div class="q-pa-sm"/>
</q-scroll-area>
</div>
<div id="card-body" >
<q-resize-observer @resize="onBodyResize"/>
<pn-shadow-scroll :hideShadows="isResizing" :height="scrollAreaHeight">
<slot/>
</pn-shadow-scroll>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { QScrollArea } from 'quasar'
const heightCard = ref(100)
const hasScrolled = ref(false)
const hasScrolledBottom = ref(false)
import { ref, watch, nextTick } from 'vue'
interface sizeParams {
height: number,
width: number
}
interface ScrollInfo {
verticalPosition: number;
verticalPercentage: number;
verticalSize: number;
verticalContainerSize: number;
horizontalPosition: number;
horizontalPercentage: number;
}
const scrollArea = ref<InstanceType<typeof QScrollArea> | null>(null)
function onResize (size :sizeParams) {
heightCard.value = size.height
}
function onScroll (info: ScrollInfo) {
hasScrolled.value = info.verticalPosition > 0
const scrollEnd = info.verticalPosition + info.verticalContainerSize >= info.verticalSize - 1
hasScrolledBottom.value = !scrollEnd
const heightCard = ref(100)
const scrollAreaHeight = ref(100)
const headerHeight = ref(0)
interface sizeParams {
height: number,
width: number
}
async function onHeaderResize(size: sizeParams) {
headerHeight.value = size.height
await updateScrollAreaHeight()
}
async function onBodyResize(size: sizeParams) {
heightCard.value = size.height
await updateScrollAreaHeight()
}
async function updateScrollAreaHeight() {
await nextTick(() => {
scrollAreaHeight.value = Math.max(0, heightCard.value)
})
}
watch(heightCard, updateScrollAreaHeight)
watch(headerHeight, updateScrollAreaHeight)
const isResizing = ref(false)
let resizeTimer: ReturnType<typeof setTimeout> | null = null
watch(heightCard, () => {
isResizing.value = true
if (resizeTimer) {
clearTimeout(resizeTimer)
resizeTimer = null
}
resizeTimer = setTimeout(() => {
isResizing.value = false
resizeTimer = null
}, 150)
})
</script>
<style>
#scroll-area div > .q-scrollarea__content {
<style scoped>
.glass-card {
opacity: 1 !important;
background-color: white;
}
#page-card {
flex: 1 0 auto;
min-height: 0;
overflow: hidden;
}
#card-body {
overflow: hidden;
flex: 1 1 0;
min-height: 0;
position: relative;
}
#card-body :deep(.q-scrollarea__content) {
width: 100% !important;
}
</style>
<style scoped>
.q-scrollarea {
position: relative;
transform: translateY(0);
}
.q-scrollarea::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 8px;
background: linear-gradient(to bottom,
rgba(0,0,0,0.12) 0%,
rgba(0,0,0,0.08) 50%,
transparent 100%
);
pointer-events: none;
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 0;
transform: translateY(-8px);
will-change: opacity, transform;
z-index: 1;
}
.q-scrollarea::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 8px;
background: linear-gradient(to top,
rgba(0,0,0,0.12) 0%,
rgba(0,0,0,0.08) 50%,
transparent 100%
);
pointer-events: none;
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 0;
transform: translateY(8px);
will-change: opacity, transform;
z-index: 1;
}
.q-scrollarea.shadow-top::before {
opacity: 1;
transform: translateY(0);
}
.q-scrollarea.shadow-bottom::after {
opacity: 1;
transform: translateY(0);
}
</style>

View File

@@ -0,0 +1,107 @@
<template>
<div :class="{'fix-scroll-area-content': hideShadows }">
<q-scroll-area
:style="{ height: height + 'px' }"
class="w100 q-pa-none q-ma-none"
@scroll="onScroll"
:class=" {
'shadow-top': hasScrolled,
'shadow-bottom': hasScrolledBottom
}"
>
<slot/>
<div class="q-pa-sm"/>
</q-scroll-area>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{
height: number
hideShadows: boolean
}>()
const hasScrolled = ref(false)
const hasScrolledBottom = ref(false)
interface ScrollInfo {
verticalPosition: number;
verticalPercentage: number;
verticalSize: number;
verticalContainerSize: number;
}
function onScroll(info: ScrollInfo) {
hasScrolled.value = info.verticalPosition > 0
const scrollEnd = info.verticalPosition + info.verticalContainerSize >= info.verticalSize - 1
hasScrolledBottom.value = !scrollEnd
}
</script>
<style scoped>
.q-scrollarea {
position: relative;
transform: translateY(0);
}
.q-scrollarea::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(to bottom,
rgba(0,0,0,0.12) 0%,
rgba(0,0,0,0.08) 50%,
transparent 100%
);
pointer-events: none;
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 0;
transform: translateY(-8px);
will-change: opacity, transform;
z-index: 1;
}
.q-scrollarea::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(to top,
rgba(0,0,0,0.12) 0%,
rgba(0,0,0,0.08) 50%,
transparent 100%
);
pointer-events: none;
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 0;
transform: translateY(8px);
will-change: opacity, transform;
z-index: 1;
}
.fix-scroll-area-content:deep(.q-scrollarea::before) {
content: none;
}
.fix-scroll-area-content:deep(.q-scrollarea::after) {
content: none;
}
.q-scrollarea.shadow-top::before {
opacity: 1;
transform: translateY(0);
}
.q-scrollarea.shadow-bottom::after {
opacity: 1;
transform: translateY(0);
}
</style>

View File

@@ -0,0 +1,87 @@
<template>
<q-dialog
v-model="modelValue"
>
<q-card class="q-pa-none q-ma-none w100 no-scroll" align="center">
<q-card-section>
<q-avatar :color :icon size="60px" font-size="45px" text-color="white"/>
</q-card-section>
<q-card-section
class="wrap no-scroll q-gutter-y-lg q-pt-none "
style="overflow-wrap: break-word"
>
<div class="text-h6 text-bold ">
{{ $t(title)}}
</div>
<div v-if="message1">
{{ $t(message1)}}
</div>
<div v-if="message2">
{{ $t(message2)}}
</div>
</q-card-section>
<q-card-actions align="center" vertical>
<div class="flex q-mt-lg no-wrap w100 justify-center q-gutter-x-md">
<q-btn
v-if="auxBtnLabel"
:label="$t(auxBtnLabel)"
outline
color="grey"
v-close-popup
rounded
class="w50"
@click="emit('clickAuxBtn')"
/>
<q-btn
:label="$t(mainBtnLabel)"
:color="color"
v-close-popup
rounded
:class="auxBtnLabel ? 'w50' : 'w80'"
@click="emit('clickMainBtn')"
/>
</div>
<q-btn
class="w80 q-mt-md q-mb-sm" flat
v-close-popup rounded
@click="emit('close')"
>
<div class="flex items-center">
<q-icon name="close"/>
{{$t('cancel')}}
</div>
</q-btn>
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
defineProps<{
icon?: string
color?: string
title: string
message1?: string
message2?: string
mainBtnLabel: string
auxBtnLabel?: string
}>()
const modelValue = defineModel<boolean>({
required: true
})
const emit = defineEmits([
'clickMainBtn',
'clickAuxBtn',
'close'
])
</script>
<style>
</style>

View File

@@ -0,0 +1,110 @@
<template>
<pn-page-card>
<template #title>
{{ $t(title) }}
</template>
<template #footer>
<q-btn
rounded color="primary"
class="w100 q-mt-md q-mb-xs"
:disable="!(isFormValid && (isDirty(initialProject, modelValue)))"
@click = "emit('update')"
>
{{ $t(btnText) }}
</q-btn>
</template>
<pn-scroll-list>
<div class="flex column items-center q-pa-md q-pb-sm">
<pn-image-selector
v-model="modelValue.logo"
:size="100"
:iconsize="80"
class="q-pb-lg"
/>
<div class="q-gutter-y-lg w100">
<q-input
v-model="modelValue.name"
no-error-icon
dense
filled
label-slot
class = "w100 fix-bottom-padding"
:rules="[rules.name]"
>
<template #label>
{{ $t('project_block__project_name') }} <span class="text-red">*</span>
</template>
</q-input>
<q-input
v-model="modelValue.description"
dense
filled
autogrow
class="w100 q-pt-sm"
:label="$t('project_block__project_description')"
/>
<!-- <q-checkbox
v-if="modelValue.logo"
v-model="modelValue.is_logo_bg"
class="w100"
dense
>
{{ $t('project_block__image_use_as_background_chats') }}
</q-checkbox> -->
</div>
</div>
</pn-scroll-list>
</pn-page-card>
</template>
<script setup lang="ts">
import { onMounted, computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { isDirty } from 'helpers/helpers'
import type { ProjectParams } from 'types/Project'
const { t } = useI18n()
const modelValue = defineModel<ProjectParams>({
required: true
})
defineProps<{
title: string,
btnText: string
}>()
const emit = defineEmits(['update'])
const rulesErrorMessage = {
name: t('project_block__error_name')
}
const rules = {
name: (val: ProjectParams['name']) => !!val?.trim() || rulesErrorMessage['name']
}
const isFormValid = computed(() => {
const validations = {
name: rules.name(modelValue.value.name) === true
}
return Object.values(validations).every(Boolean)
})
const initialProject = ref({} as ProjectParams)
onMounted(() => {
initialProject.value = { ...modelValue.value }
})
</script>
<style scoped>
.fix-bottom-padding.q-field--with-bottom {
padding-bottom: 0 !important
}
</style>

View File

@@ -1,78 +0,0 @@
<template>
<div class="flex column items-center q-pa-lg">
<pn-image-selector
v-model="modelValue.logo"
:size="100"
:iconsize="80"
class="q-pb-lg"
/>
<div class="q-gutter-y-lg w100">
<q-input
v-model="modelValue.name"
no-error-icon
dense
filled
label-slot
class = "w100 fix-bottom-padding"
:rules="[rules.name]"
>
<template #label>
{{ $t('project_card__project_name') }} <span class="text-red">*</span>
</template>
</q-input>
<q-input
v-model="modelValue.description"
dense
filled
autogrow
class="w100 q-pt-sm"
:label="$t('project_card__project_description')"
/>
<q-checkbox
v-if="modelValue.logo"
v-model="modelValue.is_logo_bg"
class="w100"
dense
>
{{ $t('project_card__image_use_as_background_chats') }}
</q-checkbox>
</div>
</div>
</template>
<script setup lang="ts">
import { watch, computed } from 'vue'
import type { ProjectParams } from 'types/Project'
import { useI18n } from 'vue-i18n'
const { t }= useI18n()
const modelValue = defineModel<ProjectParams>({ required: true })
const emit = defineEmits(['valid'])
const rulesErrorMessage = {
name: t('project_card__error_name')
}
const rules = {
name: (val :ProjectParams['name']) => !!val?.trim() || rulesErrorMessage['name']
}
const isValid = computed(() => {
const checkName = rules.name(modelValue.value.name)
return { name: checkName && (checkName !== rulesErrorMessage['name']) }
})
watch(isValid, (newVal) => {
const allValid = Object.values(newVal).every(v => v)
emit('valid', allValid)
}, { immediate: true })
</script>
<style>
.q-field--with-bottom.fix-bottom-padding {
padding-bottom: 0 !important
}
</style>

View File

@@ -0,0 +1,180 @@
<template>
<pn-page-card>
<template #title>
{{ $t(title) }}
</template>
<template #footer>
<q-btn
rounded color="primary"
class="w100 q-mt-md q-mb-xs"
:disable="!(isDirty(initialUser, modelValue))"
@click = "emit('update')"
>
{{ $t(btnText) }}
</q-btn>
</template>
<pn-scroll-list>
<div class="flex column items-center q-pa-md q-pb-sm">
<div class="relative-position">
<pn-auto-avatar
:img="modelValue.photo"
:name="tname"
size="100px"
class="q-pb-lg"
:style="!userStatus ? {} : { filter: 'grayscale(100%)'}"
/>
<div
v-if="userStatus"
class="absolute-center text-h4 text-bold q-pa-sm"
:class ="'status-' + userStatus.status"
>
{{ $t(userStatus.text) }}
</div>
</div>
<div class="flex row items-start justify-center no-wrap q-pb-lg">
<div class="flex column justify-center">
<div class="text-bold q-pr-xs text-center" align="center">{{ tname }}</div>
<div caption class="text-blue text-caption" align="center" v-if="modelValue.username">{{ modelValue.username }}</div>
</div>
</div>
<div class="q-gutter-y-lg w100">
<q-input
v-model.trim="modelValue.fullname"
dense
filled
class = "w100"
:label = "$t('user_block__name')"
/>
<q-select
v-model="modelValue.company_id"
:options="displayCompanies"
dense
filled
class="w100 q-pt-sm"
:label = "$t('user_block__company')"
option-value="id"
emit-value
map-options
>
<template #option="scope">
<q-item v-bind="scope.itemProps">
<q-item-section avatar>
<pn-auto-avatar
v-if="scope.opt.id"
:img="scope.opt.logo"
:name="scope.opt.label"
size="md"
type="rounded"
/>
<q-avatar
v-else
rounded
size="md"
>
<q-icon size="32px" color="grey" name="mdi-cancel"/>
</q-avatar>
</q-item-section>
<q-item-section>
<q-item-label>{{ scope.opt.label }}</q-item-label>
</q-item-section>
</q-item>
</template>
</q-select>
<q-input
v-model.trim="modelValue.department"
dense
filled
class = "w100 q-pt-sm"
:label = "$t('user_block__department')"
/>
<q-input
v-model.trim="modelValue.role"
dense
filled
class = "w100 q-pt-sm"
:label = "$t('user_block__role')"
/>
</div>
</div>
</pn-scroll-list>
</pn-page-card>
</template>
<script setup lang="ts">
import { onMounted, computed, ref } from 'vue'
import { useCompaniesStore } from 'stores/companies'
import { useI18n } from 'vue-i18n'
import { isDirty } from 'helpers/helpers'
import type { User } from 'types/Users'
const { t } = useI18n()
const modelValue = defineModel<User>({
required: true
})
defineProps<{
title: string,
btnText: string
}>()
const emit = defineEmits(['update'])
const initialUser = ref({} as User)
onMounted(() => {
initialUser.value = { ...modelValue.value }
})
const companiesStore = useCompaniesStore()
const companies = computed(() => companiesStore.companies)
const displayCompanies = computed(() => [
...companies.value.map(el => ({
id: el.id,
label: el.name,
logo: el.logo
})),
{
id: null,
label: t('user_block__no_company'),
logo: ''
}
])
const tname = computed(() =>
[modelValue.value?.firstname, modelValue.value?.lastname]
.filter(Boolean)
.join(' ')
)
const userStatus = computed(() => {
if (modelValue.value.is_blocked) return { status: 'blocked', text: 'user_block__user_blocked'}
if (modelValue.value.is_leave) return { status: 'leave', text: 'user_block__user_leave'}
return null
})
</script>
<style scoped>
.fix-bottom-padding.q-field--with-bottom {
padding-bottom: 0 !important
}
.status-blocked {
border: 2px solid red;
color: red;
}
.status-leave {
border: 2px solid var(--q-primary);
color: var(--q-primary);
}
</style>