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

@@ -3,35 +3,12 @@
</template>
<script setup lang="ts">
import { inject, onMounted } from 'vue'
import type { WebApp } from '@twa-dev/types'
import { useI18n } from 'vue-i18n'
const { locale } = useI18n()
import { onMounted } from 'vue'
import { useSettingsStore } from 'stores/settings'
const settingsStore = useSettingsStore()
const tg = inject('tg') as WebApp
const getLocale = (): string => {
const localeMap = {
ru: 'ru-RU',
en: 'en-US'
} as const satisfies Record<string, string>
type LocaleCode = keyof typeof localeMap
const normLocale = (locale?: string): string | undefined => {
if (!locale) return undefined
const code = locale.split('-')[0] as LocaleCode
return localeMap[code] ?? undefined
}
const tgLang = tg?.initDataUnsafe?.user?.language_code
const normalizedTgLang = normLocale(tgLang)
return normalizedTgLang ?? normLocale(navigator.language) ?? 'en-US'
}
onMounted(() => {
locale.value = getLocale()
onMounted(async () => {
await settingsStore.init()
})
</script>

View File

@@ -3,4 +3,4 @@ import { useAuthStore } from 'stores/auth'
export default async () => {
const authStore = useAuthStore()
await authStore.initialize()
}
}

View File

@@ -3,16 +3,29 @@ import pnPageCard from 'components/pnPageCard.vue'
import pnScrollList from 'components/pnScrollList.vue'
import pnAutoAvatar from 'components/pnAutoAvatar.vue'
import pnOverlay from 'components/pnOverlay.vue'
import pnMagicOverlay from 'components/pnMagicOverlay.vue'
import pnSmallDialog from 'components/pnSmallDialog.vue'
import pnImageSelector from 'components/pnImageSelector.vue'
import pnShadowScroll from 'components/pnShadowScroll.vue'
import pnMagicOverlay from 'components/pnMagicOverlay.vue'
import pnAccountBlockName from 'components/pnAccountBlockName.vue'
import pnOnboardBtn from 'components/pnOnboardBtn.vue'
export default boot(async ({ app }) => { // eslint-disable-line
app.component('pnPageCard', pnPageCard)
app.component('pnScrollList', pnScrollList)
app.component('pnAutoAvatar', pnAutoAvatar)
app.component('pnOverlay', pnOverlay)
app.component('pnMagicOverlay', pnMagicOverlay)
app.component('pnImageSelector', pnImageSelector)
app.component('pnAccountBlockName', pnAccountBlockName)
const components = {
pnPageCard,
pnScrollList,
pnAutoAvatar,
pnOverlay,
pnImageSelector,
pnSmallDialog,
pnShadowScroll,
pnMagicOverlay,
pnAccountBlockName,
pnOnboardBtn
}
export default boot(({ app }) => {
Object.entries(components).forEach(([name, component]) => {
app.component(name, component)
})
})

View File

@@ -1,73 +0,0 @@
function isDirty (
obj1: Record<string, unknown> | null | undefined,
obj2: Record<string, unknown> | null | undefined
): boolean {
const actualObj1 = obj1 ?? {}
const actualObj2 = obj2 ?? {}
const filteredObj1 = filterIgnored(actualObj1)
const filteredObj2 = filterIgnored(actualObj2)
const allKeys = new Set([...Object.keys(filteredObj1), ...Object.keys(filteredObj2)])
for (const key of allKeys) {
const hasKey1 = Object.hasOwn(filteredObj1, key)
const hasKey2 = Object.hasOwn(filteredObj2, key)
if (hasKey1 !== hasKey2) return false
if (hasKey1 && hasKey2) {
const val1 = filteredObj1[key]
const val2 = filteredObj2[key]
if (typeof val1 === 'string' && typeof val2 === 'string') {
if (val1.trim() !== val2.trim()) return false
} else if (val1 !== val2) {
return false
}
}
}
return true
}
function filterIgnored(obj: Record<string, unknown>): Record<string, string | number | boolean> {
const filtered: Record<string, string | number | boolean> = {}
for (const key in obj) {
const originalValue = obj[key]
// Пропускаем значения, которые не string, number или boolean
if (
typeof originalValue !== 'string' &&
typeof originalValue !== 'number' &&
typeof originalValue !== 'boolean'
) {
continue
}
let value = originalValue
if (typeof value === 'string') {
value = value.trim()
if (value === '') continue
}
if (value === 0 || value === false) continue
filtered[key] = value
}
return filtered
}
function parseIntString (s: string | string[] | undefined) :number | null {
if (typeof s !== 'string') return null
const regex = /^[+-]?\d+$/
return regex.test(s) ? Number(s) : null
}
export {
isDirty,
parseIntString
}

View File

@@ -1,33 +1,13 @@
import { defineBoot } from '#q-app/wrappers'
import { createI18n } from 'vue-i18n'
import messages from 'src/i18n'
export type MessageLanguages = keyof typeof messages
// Type-define 'en-US' as the master schema for the resource
export type MessageSchema = typeof messages['en-US']
// See https://vue-i18n.intlify.dev/guide/advanced/typescript.html#global-resource-schema-type-definition
/* eslint-disable @typescript-eslint/no-empty-object-type */
declare module 'vue-i18n' {
// define the locale messages schema
export interface DefineLocaleMessage extends MessageSchema {}
// define the datetime format schema
export interface DefineDateTimeFormat {}
// define the number format schema
export interface DefineNumberFormat {}
}
/* eslint-enable @typescript-eslint/no-empty-object-type */
export const i18n = createI18n({
legacy: false,
locale: 'en-US',
messages
})
export default defineBoot(({ app }) => {
const i18n = createI18n<{ message: MessageSchema }, MessageLanguages>({
locale: 'en-US',
legacy: false,
messages,
})
// Set i18n instance on app
app.use(i18n)
})

View File

@@ -1,18 +1,19 @@
import type { BootParams } from '@quasar/app'
import { defineBoot } from '#q-app/wrappers'
import type { WebApp } from "@twa-dev/types"
export default ({ app }: BootParams) => {
// Инициализация Telegram WebApp
declare global {
interface Window {
Telegram: {
WebApp: WebApp
}
}
}
export default defineBoot(({ app }) => {
if (window.Telegram?.WebApp) {
const webApp = window.Telegram.WebApp
// Помечаем приложение как готовое
webApp.ready()
// window.Telegram.WebApp.requestFullscreen()
// Опционально: сохраняем объект в Vue-приложение для глобального доступа
// webApp.SettingsButton.isVisible = true
// webApp.BackButton.isVisible = true
app.config.globalProperties.$tg = webApp
// Для TypeScript: объявляем тип для инжекции
app.provide('tg', webApp)
}
}
})

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>

View File

@@ -18,5 +18,5 @@ export function useNotify() {
})
}
return { notifyError}
return { notifyError }
}

View File

@@ -34,6 +34,15 @@ $base-height: 100;
body {
overflow: hidden !important;
}
.main-content {
max-width: 600px;
margin: 0 auto;
}
.fix-fab-offset {
margin-right: calc(max((100vw - var(--body-width))/2, 0px) + 18px) !important;
}
.projects-header {
background-color: #eee;
@@ -43,4 +52,16 @@ body {
border-top-left-radius: var(--top-raduis);
border-top-right-radius: var(--top-raduis);
}
.orline {
display: flex;
flex-direction: row;
}
.orline:before,
.orline:after {
content: "";
flex: 1 1;
border-bottom: 1px solid grey;
margin: auto;
}

98
src/helpers/helpers.ts Normal file
View File

@@ -0,0 +1,98 @@
function isDirty(
obj1: Record<string, unknown> | null | undefined,
obj2: Record<string, unknown> | null | undefined
): boolean {
const actualObj1 = obj1 ?? {}
const actualObj2 = obj2 ?? {}
const filteredObj1 = filterIgnored(actualObj1)
const filteredObj2 = filterIgnored(actualObj2)
const allKeys = new Set([...Object.keys(filteredObj1), ...Object.keys(filteredObj2)])
for (const key of allKeys) {
const hasKey1 = Object.hasOwn(filteredObj1, key)
const hasKey2 = Object.hasOwn(filteredObj2, key)
// Различие в наличии ключа
if (hasKey1 !== hasKey2) return true
if (hasKey1 && hasKey2) {
const val1 = filteredObj1[key]
const val2 = filteredObj2[key]
// Сравнение массивов
if (Array.isArray(val1) && Array.isArray(val2)) {
if (val1.length !== val2.length) return true
const set2 = new Set(val2)
if (!val1.every(item => set2.has(item))) return true
}
// Один массив, другой - нет
else if (Array.isArray(val1) || Array.isArray(val2)) {
return true
}
// Сравнение строк
else if (typeof val1 === 'string' && typeof val2 === 'string') {
if (val1.trim() !== val2.trim()) return true
}
// Сравнение примитивов
else if (val1 !== val2) {
return true
}
}
}
return false
}
function filterIgnored(obj: Record<string, unknown>): Record<string, string | number | boolean | (string | number)[]> {
const filtered: Record<string, string | number | boolean | (string | number)[]> = {}
for (const key in obj) {
const value = obj[key]
// Обработка массивов
if (Array.isArray(value)) {
// Отбрасываем пустые массивы
if (value.length === 0) continue
// Фильтруем массивы с некорректными элементами
if (value.every(item =>
typeof item === 'string' ||
typeof item === 'number'
)) {
filtered[key] = value
}
continue
}
// Обработка примитивов
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
continue
}
// Обработка строк
if (typeof value === 'string') {
const trimmed = value.trim()
if (trimmed === '') continue
filtered[key] = trimmed
}
// Обработка чисел и boolean
else if (value !== 0 && value !== false) {
filtered[key] = value
}
}
return filtered
}
function parseIntString (s: string | string[] | undefined) :number | null {
if (typeof s !== 'string') return null
const regex = /^[+-]?\d+$/
return regex.test(s) ? Number(s) : null
}
export {
isDirty,
parseIntString
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,60 +1,24 @@
<template>
<q-layout
view="lHr lpR lFr"
fit
class="fit no-scroll bg-transparent"
>
<q-drawer
v-if="existDrawer"
show-if-above
side="left"
class="drawer no-scroll"
:width="drawerWidth"
:breakpoint="bodyWidth"
/>
<q-drawer
v-if="existDrawer"
show-if-above
side="right"
class="drawer no-scroll"
:width="drawerWidth"
:breakpoint="bodyWidth"
/>
<q-layout
view="lHr lpr lFr"
class="no-scroll bg-transparent"
>
<q-page-container
class="q-pa-none q-ma-none no-scroll bg-transparent page-width"
class="main-content q-pa-none q-ma-none no-scroll bg-transparent"
>
<router-view />
<q-page class="no-scroll column">
<router-view />
</q-page>
</q-page-container>
<meshBackground/>
<q-resize-observer @resize="onResize"></q-resize-observer>
</q-layout>
<meshBackground/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import meshBackground from 'components/meshBackground.vue'
const existDrawer = ref<boolean>(true)
function getCSSVar (varName: string) {
const root = document.documentElement
return getComputedStyle(root).getPropertyValue(varName).trim()
}
const bodyWidth = parseInt(getCSSVar('--body-width'))
const drawerWidth = ref<number>(300)
function onResize () {
const clientWidth = document.documentElement.clientWidth;
drawerWidth.value = (clientWidth - bodyWidth)/2
existDrawer.value = clientWidth > bodyWidth
}
</script>
<style>
aside {
background-color: transparent !important;
}
</style>

View File

@@ -0,0 +1,20 @@
<template>
<pn-page-card>
<template #title>
{{$t('account__change_auth_method')}}
</template>
<pn-scroll-list>
<account-helper :type/>
</pn-scroll-list>
</pn-page-card>
</template>
<script setup lang="ts">
import accountHelper from 'components/accountHelper.vue'
import { useAuthStore } from 'stores/auth'
const authStore= useAuthStore()
const type = 'changeMethod'
</script>

View File

@@ -1,17 +1,283 @@
<template>
<pn-page-card>
<template #title>
<div class="col-grow">
{{$t('account__change_password')}}
</div>
{{$t('account_change_email__title')}}
</template>
<pn-scroll-list>
<account-helper :type />
<q-stepper
v-model="step"
vertical
color="primary"
animated
flat
class="bg-transparent"
>
<q-step
:name="1"
:title="$t('account_change_email__current_email')"
:done="step > 1"
>
<q-input
v-model="login"
autofocus
dense
filled
:label = "$t('account_change_email__current_email')"
disable
/>
<q-stepper-navigation>
<q-btn
@click="handleSubmit"
color="primary"
:label="$t('continue')"
/>
</q-stepper-navigation>
</q-step>
<q-step
:name="2"
:title="$t('account_change_email__confirm_current_email')"
:done="step > 2"
>
<div class="q-pb-md">{{$t('account_change_email__confirm_email_message')}}</div>
<q-input
v-model="code"
dense
filled
autofocus
hide-bottom-space
:label = "$t('account_change_email__code')"
num="30"
/>
<q-stepper-navigation>
<q-btn
@click="handleSubmit"
color="primary"
:label="$t('continue')"
:disable="code.length === 0"
/>
<q-btn
flat
@click="step = 1"
color="primary"
:label="$t('back')"
class="q-ml-sm"
/>
</q-stepper-navigation>
</q-step>
<q-step
:name="3"
:title="$t('account_change_email__new_email')"
:done="step > 2"
>
<q-input
v-model="newLogin"
autofocus
dense
filled
:label = "$t('account_change_email__new_email')"
:rules="validationRules.email"
lazy-rules
no-error-icon
@focus="($refs.emailInput as typeof QInput)?.resetValidation()"
ref="emailInput"
/>
<q-stepper-navigation>
<q-btn
@click="handleSubmit"
color="primary"
:label="$t('continue')"
:disabled="!isEmailValid"
/>
<q-btn
flat
@click="step = 2"
color="primary"
:label="$t('back')"
class="q-ml-sm"
/>
</q-stepper-navigation>
</q-step>
<q-step
:name="4"
:title="$t('account_change_email__confirm_new_email')"
:done="step > 3"
>
<div class="q-pb-md">{{$t('account_change_email__confirm_email_message')}}</div>
<q-input
v-model="newCode"
dense
filled
autofocus
hide-bottom-space
:label = "$t('account_change_email__code')"
num="30"
/>
<q-stepper-navigation>
<q-btn
@click="handleSubmit"
color="primary"
:label="$t('continue')"
:disable="newCode.length === 0"
/>
<q-btn
flat
@click="step = 3"
color="primary"
:label="$t('back')"
class="q-ml-sm"
/>
</q-stepper-navigation>
</q-step>
<q-step
:name="5"
:title="$t('account_change_email__set_password')"
>
<q-input
v-model="password"
dense
filled
:label = "$t('account_change_email__password')"
:type="isPwd ? 'password' : 'text'"
hide-hint
:hint="passwordHint"
:rules="validationRules.password"
lazy-rules
no-error-icon
@focus="($refs.passwordInput as typeof QInput)?.resetValidation()"
ref="passwordInput"
>
<template #append>
<q-icon
color="grey-5"
:name="isPwd ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
class="cursor-pointer"
@click="isPwd = !isPwd"
/>
</template>
</q-input>
<q-stepper-navigation>
<q-btn
@click="handleSubmit"
color="primary"
:label="$t('account_change_email__finish')"
:disabled = "!isPasswordValid"
/>
<q-btn
flat
@click="step = 4"
color="primary"
:label="$t('back')"
class="q-ml-sm"
/>
</q-stepper-navigation>
</q-step>
</q-stepper>
<pn-magic-overlay
v-if="showSuccessOverlay"
icon="mdi-check-circle-outline"
message1="account_change_email__ok_message1"
message2="account_change_email__ok_message2"
route-name="account"
/>
</pn-scroll-list>
</pn-page-card>
</template>
<script setup lang="ts">
import accountHelper from 'src/components/accountHelper.vue'
const type = 'forgotPwd'
import { ref, computed } from 'vue'
import type { AxiosError } from 'axios'
import { useQuasar } from 'quasar'
import { useI18n } from "vue-i18n"
import { QInput } from 'quasar'
import { useAuthStore } from 'stores/auth'
const $q = useQuasar()
const { t } = useI18n()
const authStore = useAuthStore()
type ValidationRule = (val: string) => boolean | string
type Step = 1 | 2 | 3 | 4 | 5
const step = ref<Step>(1)
const login = authStore.customer?.email
const code = ref<string>('')
const newLogin = ref<string>('')
const newCode = ref<string>('')
const password = ref<string>('')
const showSuccessOverlay = ref(false)
const isPwd = ref<boolean>(true)
const validationRules = {
email: [(val: string) => /.+@.+\..+/.test(val) || t('login__incorrect_email')] as [ValidationRule],
password: [(val: string) => val.length >= 8 || t('login__password_require')] as [ValidationRule]
}
const isEmailValid = computed(() =>
validationRules.email.every(f => f(newLogin.value) === true)
)
const isPasswordValid = computed(() =>
validationRules.password.every(f => f(password.value) === true)
)
const passwordHint = computed(() => {
const result = validationRules.password[0](password.value)
return typeof result === 'string' ? result : ''
})
const stepActions: Record<Step, () => Promise<void>> = {
1: async () => {
await authStore.getCodeCurrentEmail()
},
2: async () => {
await authStore.confirmCurrentEmailCode(code.value)
console.log(code.value)
},
3: async () => {
await authStore.getCodeNewEmail(code.value, newLogin.value)
},
4: async () => {
await authStore.confirmNewEmailCode(code.value, newCode.value, newLogin.value,)
},
5: async () => {
await authStore.setNewEmailPassword(code.value, newCode.value, newLogin.value, password.value)
await authStore.loginWithCredentials(newLogin.value, password.value)
}
}
const handleError = (err: AxiosError) => {
const error = err as AxiosError<{ error?: { message?: string } }>
const message = error.response?.data?.error?.message || t('unknown_error')
$q.notify({
message: `${t('error')}: ${message}`,
type: 'negative',
position: 'bottom',
timeout: 2500
})
if (step.value > 1) {
code.value = ''
password.value = ''
}
}
const handleSubmit = async () => {
try {
await stepActions[step.value]()
if (step.value < 5) {
step.value++
} else {
showSuccessOverlay.value = true
}
} catch (error) {
handleError(error as AxiosError)
}
}
</script>

View File

@@ -1,17 +1,21 @@
<template>
<pn-page-card>
<template #title>
<div class="col-grow">
{{$t('account__change_password')}}
</div>
{{$t('account__change_password')}}
</template>
<pn-scroll-list>
<account-helper :type />
<account-helper :type :email/>
</pn-scroll-list>
</pn-page-card>
</template>
<script setup lang="ts">
import accountHelper from 'src/components/accountHelper.vue'
const type = 'change'
import accountHelper from 'components/accountHelper.vue'
import { useAuthStore } from 'stores/auth'
const authStore= useAuthStore()
const type = 'changePwd'
const email = authStore.customer?.email ? authStore.customer?.email : '???'
</script>

View File

@@ -1,9 +1,7 @@
<template>
<pn-page-card>
<template #title>
<div class="col-grow">
{{$t('login__register')}}
</div>
{{$t('login__register')}}
</template>
<pn-scroll-list>
<account-helper :type :email/>
@@ -13,7 +11,7 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import accountHelper from 'src/components/accountHelper.vue'
import accountHelper from 'components/accountHelper.vue'
const type = 'register'
const email = ref(sessionStorage.getItem('pendingLogin') || '')

View File

@@ -1,9 +1,7 @@
<template>
<pn-page-card>
<template #title>
<div class="col-grow">
{{$t('forgot_password__password_recovery')}}
</div>
{{$t('forgot_password__password_recovery')}}
</template>
<pn-scroll-list>
<account-helper :type :email/>
@@ -13,7 +11,7 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import accountHelper from 'src/components/accountHelper.vue'
import accountHelper from 'components/accountHelper.vue'
const type = 'forgotPwd'
const email = ref(sessionStorage.getItem('pendingLogin') || '')

View File

@@ -1,22 +1,19 @@
<template>
<pn-page-card>
<template #title>
<div class="flex items-center no-wrap w100">
<pn-account-block-name/>
<q-btn
@click="logout()"
flat
round
icon="mdi-logout"
class="q-ml-md"
/>
</div>
<pn-account-block-name/>
<q-btn
@click="logout()"
flat
round
icon="mdi-logout"
/>
</template>
<pn-scroll-list>
<q-list separator>
<q-item
v-for="item in items"
v-for="item in displayItems"
:key="item.id"
@click="goTo(item.pathName)"
clickable
@@ -35,7 +32,7 @@
<q-item-label>
{{ $t(item.name) }}
</q-item-label>
<q-item-label class="text-caption">
<q-item-label class="text-caption" v-if="$te(item.description)">
{{ $t(item.description) }}
</q-item-label>
</q-item-section>
@@ -53,18 +50,33 @@
const router = useRouter()
const authStore = useAuthStore()
interface ItemList {
id: number
name: string
description?: string
icon: string
iconColor?: string
pathName: string
display?: boolean
}
const items = computed(() => ([
{ id: 1, name: 'account__subscribe', description: 'account__subscribe_description', icon: 'mdi-crown-circle-outline', iconColor: 'orange', pathName: 'subscribe' },
{ id: 2, name: 'account__auth_change_method', description: 'account__auth_change_method_description', icon: 'mdi-account-sync-outline', iconColor: 'primary', pathName: '' },
{ id: 3, name: 'account__auth_change_password', description: 'account__auth_change_password_description', icon: 'mdi-account-key-outline', iconColor: 'primary', pathName: 'change_account_password' },
{ id: 4, name: 'account__auth_change_account', description: 'account__auth_change_account_description', icon: 'mdi-account-switch-outline', iconColor: 'primary', pathName: 'change_account_email' },
{ id: 2, name: 'account__auth_change_method', description: 'account__auth_change_method_description', icon: 'mdi-account-sync-outline', iconColor: 'primary', pathName: 'change_account_auth_method', display: !authStore.customer?.email },
{ id: 3, name: 'account__auth_change_password', description: 'account__auth_change_password_description', icon: 'mdi-account-key-outline', iconColor: 'primary', pathName: 'change_account_password', display: !!authStore.customer?.email },
{ id: 4, name: 'account__auth_change_account', description: 'account__auth_change_account_description', icon: 'mdi-account-switch-outline', iconColor: 'primary', pathName: 'change_account_email', display: !!authStore.customer?.email },
{ id: 5, name: 'account__company_data', icon: 'mdi-account-group-outline', description: 'account__company_data_description', pathName: 'your_company' },
{ id: 6, name: 'account__settings', icon: 'mdi-cog-outline', description: 'account__settings_description', iconColor: 'info', pathName: 'settings' },
{ id: 7, name: 'account__support', icon: 'mdi-lifebuoy', description: 'account__support_description', iconColor: 'info', pathName: 'support' },
{ id: 8, name: 'account__terms_of_use', icon: 'mdi-book-open-variant-outline', description: '', iconColor: 'grey', pathName: 'terms' },
{ id: 9, name: 'account__privacy', icon: 'mdi-lock-outline', description: '', iconColor: 'grey', pathName: 'privacy' }
{ id: 8, name: 'account__3rd_party_software', icon: 'mdi-scale-balance', description: 'account__3rd_party_software_description', iconColor: 'grey', pathName: '3software' },
{ id: 9, name: 'account__terms_of_use', icon: 'mdi-book-open-variant-outline', description: '', iconColor: 'grey', pathName: 'terms' },
{ id: 10, name: 'account__privacy', icon: 'mdi-lock-outline', description: '', iconColor: 'grey', pathName: 'privacy' }
]))
const displayItems = computed(() => (
items.value.filter((item: ItemList) => !('display' in item) || item.display === true)
))
async function goTo (path: string) {
await router.push({ name: path })
}

View File

@@ -0,0 +1,35 @@
<template>
<company-block
v-model="newCompany"
title="company_create__title_card"
btnText="company_create__btn"
@update = "addCompany"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import companyBlock from 'components/companyBlock.vue'
import { useCompaniesStore } from 'stores/companies'
import type { CompanyParams } from 'types/Company'
const router = useRouter()
const companiesStore = useCompaniesStore()
const newCompany = ref(<CompanyParams>{
name: '',
logo: '',
description: '',
site: '',
address: '',
phone: '',
email: ''
})
async function addCompany () {
await companiesStore.add(newCompany.value)
router.go(-1)
}
</script>

View File

@@ -1,39 +0,0 @@
<template>
<pn-page-card>
<template #title>
<div class="flex items-center justify-between col-grow">
<div>
{{$t('company_create__title_card')}}
</div>
<q-btn
v-if="(Object.keys(companyMod).length !== 0)"
@click = "addCompany(companyMod)"
flat round
icon="mdi-check"
/>
</div>
</template>
<pn-scroll-list>
<company-info-block v-model="companyMod"/>
</pn-scroll-list>
</pn-page-card>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import companyInfoBlock from 'src/components/companyInfoBlock.vue'
import { useCompaniesStore } from 'stores/companies'
import type { Company } from 'src/types'
const router = useRouter()
const companiesStore = useCompaniesStore()
const companyMod = ref(<Company>{})
function addCompany (data: Company) {
companiesStore.addCompany(data)
router.go(-1)
}
</script>

View File

@@ -0,0 +1,58 @@
<template>
<company-block
v-if="companyMod"
v-model="companyMod"
title="company_edit__title_card"
btnText="company_edit__btn"
@update="updateCompany"
>
<template #myCompany v-if="companyMod.is_own">
<div class="q-mb-md flex w100 justify-center">
<div class="flex items-center text-amber-10">
<q-icon name="star" class="q-pr-xs"/>
{{ $t('company_edit__my_company') }}
</div>
<div class="text-caption" align="center" style="white-space: pre-wrap;">
{{ $t('company_edit__my_company_hint') }}
</div>
</div>
</template>
</company-block>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import companyBlock from 'components/companyBlock.vue'
import { useCompaniesStore } from 'stores/companies'
import { parseIntString } from 'helpers/helpers'
import type { CompanyParams } from 'types/Company'
const router = useRouter()
const route = useRoute()
const companiesStore = useCompaniesStore()
const companyMod = ref<CompanyParams | null>(null)
const companyId = computed(() => parseIntString(route.params.companyId))
if (companiesStore.isInit) {
companyMod.value = companyId.value
? { ...companiesStore.companyById(companyId.value) } as CompanyParams
: null
}
watch(() => companiesStore.isInit, (isInit) => {
if (isInit && companyId.value && !companyMod.value) {
companyMod.value = { ...companiesStore.companyById(companyId.value) as CompanyParams }
}
})
async function updateCompany () {
if (companyId.value && companyMod.value) {
await companiesStore.update(companyId.value, companyMod.value)
router.go(-1)
}
}
</script>

View File

@@ -1,74 +0,0 @@
<template>
<pn-page-card>
<template #title>
<div class="flex items-center justify-between col-grow">
<div>
{{$t('company_info__title_card')}}
</div>
<q-btn
v-if="isFormValid && isDirty()"
@click = "updateCompany()"
flat round
icon="mdi-check"
/>
</div>
</template>
<pn-scroll-list>
<company-info-block
v-if="company"
v-model="company"
@valid="isFormValid = $event"
/>
<company-info-persons/>
</pn-scroll-list>
</pn-page-card>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import companyInfoBlock from 'src/components/companyInfoBlock.vue'
import companyInfoPersons from 'src/components/companyInfoPersons.vue'
import { useCompaniesStore } from 'stores/companies'
import type { Company } from 'src/types'
import { parseIntString, isObjEqual } from 'boot/helpers'
const router = useRouter()
const route = useRoute()
const companiesStore = useCompaniesStore()
const company = ref<Company>()
const companyId = parseIntString(route.params.companyId)
const isFormValid = ref(false)
const originalCompany = ref<Company>({} as Company)
const isDirty = () => {
return company.value && !isObjEqual(originalCompany.value, company.value)
}
onMounted(async () => {
if (companyId && companiesStore.companyById(companyId)) {
const initial = companiesStore.companyById(companyId)
company.value = { ...initial } as Company
originalCompany.value = JSON.parse(JSON.stringify(company.value))
} else {
await abort()
}
})
function updateCompany () {
if (companyId && company.value) {
companiesStore.updateCompany(companyId, company.value)
router.back()
}
}
async function abort () {
await router.replace({name: 'projects'})
}
</script>

View File

@@ -1,102 +1,150 @@
<template>
<pn-page-card>
<template #title>
<div class="col-grow">
{{$t('company__mask')}}
</div>
{{$t('company__mask')}}
<q-btn
v-if="!checkIsDirty(mask, originalMask)"
@click = "updateCompanies()"
flat round
icon="mdi-check"
/>
</template>
<pn-scroll-list>
<template #card-body-header>
<div style="min-height: var(--top-raduis);"/>
</template>
<q-list
separator
<q-table
flat
:rows="displayCompanies"
:columns="tableColumns"
hide-pagination
:pagination="{ rowsPerPage: 0 }"
dense
row-key="id"
style="max-width: 100% !important"
>
<q-item>
<q-item-section>
<div>
<q-btn flat round color="primary" icon="mdi-help-circle-outline" @click="showDialogHelp = true" />
</div>
</q-item-section>
<q-item-section></q-item-section>
<q-item-section align="end" class="col-grow">
{{ $t('mask__title_table') }}
</q-item-section>
</q-item>
<q-item
v-for = "company in companies"
:key="company.id"
class="w100"
>
<q-item-section>
<q-checkbox
v-model="company.masked"
:label="company.name"
unchecked-icon="mdi-drama-masks"
checked-icon="mdi-drama-masks"
:class="company.masked ? 'masked' : 'unmasked'"
/>
</q-item-section>
<q-item-section>
<q-select
v-if="company.masked"
v-model="company.unmasked"
multiple
:options="companiesSelect(company.id)"
dense
borderless
dropdown-icon="mdi-plus"
class="fix-select"
>
<template #selected>
<div
v-for="(comp, idx) in company.unmasked"
:key=idx
class="q-pa-xs"
>
<q-avatar rounded size="md">
<img v-if="comp['logo']" :src="comp['logo']"/>
<pn-auto-avatar v-else :name="comp['name']"/>
</q-avatar>
</div>
<template #header>
<q-tr>
<q-th style="width: 10%">
<q-icon name="mdi-domino-mask" size="sm"/>
</q-th>
<q-th style="width: 45%">
<span class="text-bold">
{{ $t('mask__table_header_company') }}
</span>
</q-th>
<q-th style="width: 45%">
<span class="text-bold">
{{ $t('mask__table_header_visible') }}
<q-btn
flat
@click="showDialogHelp=true"
color="primary"
dense round
size="sm"
icon="mdi-help-circle-outline"
/>
</span>
</q-th>
</q-tr>
</template>
<template #body="props">
<q-tr :props="props">
<q-td key="checkbox" :props="props">
<q-toggle
v-model="props.row.masked"
size="sm"
:color="props.row.masked ? 'red' : 'brand'"
/>
</q-td>
<q-td key="name" :props="props">
{{ props.row.name }}
</q-td>
<q-td key="visible" :props="props">
<q-select
v-if="props.row.masked"
v-model="props.row.company_list"
multiple
:options="companiesSelect(props.row.id)"
option-value="id"
emit-value
dense
borderless
dropdown-icon="mdi-plus"
class="fix-select"
:label-slot="!props.row.hasFocus && !props.row.isMenuOpen && (props.row.company_list.length === 0)"
@popup-show="props.row.isMenuOpen = true"
@popup-hide="props.row.isMenuOpen = false"
@focus="props.row.hasFocus = true"
@blur="props.row.hasFocus = false"
>
<template #label>
<span
v-if="(!props.row.hasFocus || !props.row.isMenuOpen) && (props.row.company_list.length === 0)"
class="q-field__label"
>
{{ $t('mask__table_visible_none') }}
</span>
</template>
<template #option="scope">
<q-item v-bind="scope.itemProps">
<q-item-section avatar>
<q-avatar rounded size="md">
<img v-if="scope.opt.logo" :src="scope.opt.logo"/>
<pn-auto-avatar v-else :name="scope.opt.name"/>
</q-avatar>
</q-item-section>
<q-item-section>
<q-item-label>{{ scope.opt.name }}</q-item-label>
</q-item-section>
</q-item>
</template>
</q-select>
</q-item-section>
</q-item>
<q-item
class="q-pa-none q-ma-none"
style="min-height: 18px"
>
<div class="q-py-none flex column w100"/>
</q-item>
</q-list>
<template #selected>
<div
v-for="id in props.row.company_list"
:key=id
class="q-pa-xs"
>
<pn-auto-avatar
:img="getCompanyById(id)?.logo"
:name="getCompanyById(id)?.name"
size="md"
type="rounded"
/>
</div>
</template>
<template #option="scope">
<q-item v-bind="scope.itemProps">
<q-item-section avatar>
<pn-auto-avatar
:img="scope.opt.logo"
:name="scope.opt.name"
size="md"
type="rounded"
/>
</q-item-section>
<q-item-section>
<q-item-label>{{ scope.opt.name }}</q-item-label>
</q-item-section>
</q-item>
</template>
</q-select>
<span
v-else
>
{{ $t('mask__table_visible_all') }}
</span>
</q-td>
</q-tr>
</template>
</q-table>
<div class="q-py-none flex column w100" style="min-height: 18px"/>
</pn-scroll-list>
<q-dialog v-model="showDialogHelp">
<q-card class="q-ma-sm w100">
<q-card-section class="row items-center q-pb-none">
<span class="text-h6">
<q-icon name="mdi-drama-masks"/>
<q-icon name="mdi-domino-mask"/>
{{ $t('mask__help_title')}}
</span>
<q-space />
<q-btn icon="close" flat round dense v-close-popup />
</q-card-section>
<q-card-section class="q-pt-sm">
{{ $t('mask__help_message')}}
<p>{{ $t('mask__help_message1')}}</p>
<p>{{ $t('mask__help_message2')}}</p>
<p>{{ $t('mask__help_message3')}}</p>
</q-card-section>
</q-card>
</q-dialog>
@@ -104,26 +152,53 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
// import { useCompaniesStore } from 'src/stores/companies'
import { ref, computed, onMounted } from 'vue'
import { useCompaniesStore } from 'stores/companies'
import type { CompanyMask } from 'types/Company'
import { useRouter } from 'vue-router'
const showDialogHelp = ref<boolean>(false)
// const companiesStore = useCompaniesStore()
const companiesStore = useCompaniesStore()
const router = useRouter()
// const companies = computed(() => companiesStore.companies)
const companies = computed(() => companiesStore.companies)
const companiesMask = computed(() => companiesStore.companiesMask)
const companies = ref([
{id: "com11", name: 'Рога и копытца1', logo: '', description: 'Монтажники вывески', qtyPersons: 3, masked: false, unmasked: [] },
{id: "com21", name: 'ООО "Василек1"', logo: 'https://cdn.quasar.dev/img/avatar5.jpg', qtyPersons: 2, masked: true, unmasked: [] },
{id: "ch13", name: 'Откат и деньги1', logo: 'https://cdn.quasar.dev/img/avatar4.jpg', description: 'Договариваются с администрацией', qtyPersons: 5, masked: false, unmasked: [] },
{id: "ch14", name: 'Откат и деньги2', logo: '', description: 'Договариваются о чем-то', qtyPersons: 5, masked: false, unmasked: [] },
{id: "com111", name: 'Рога и копытца2', logo: '', description: 'Монтажники вывески', qtyPersons: 3, masked: false, unmasked: []},
{id: "com211", name: 'ООО "Василек2"', logo: '', qtyPersons: 2, masked: true, unmasked: [] },
{id: "ch131", name: 'Откат и деньги3', logo: '', description: 'Договариваются с администрацией', qtyPersons: 5, masked: false, unmasked: [] },
])
interface DisplayCompany {
id: number
name: string
logo?: string
masked: boolean
company_list: number[],
isMenuOpen: boolean
hasFocus: boolean
}
function companiesSelect (id :string) {
return companies.value
//
const displayCompanies = ref<DisplayCompany[]>([])
const originalMask = ref<CompanyMask[]>([])
const tableColumns = [
{ name: 'checkbox', field: 'checkbox', label: 'checkbox', sortable: false, style: "width: 10%" },
{ name: 'name', field: 'name', align: 'left' as const, label: 'name', sortable: false, style: "width: 45% !important; text-wrap: auto" },
{ name: 'visible', field: 'visible', align: 'center' as const, label: 'visible', sortable: false, style: "width: 45% !important" }
]
const mask = computed(() => displayCompanies.value
.filter(el => el.masked)
.map(el => ({
company_id: el.id,
company_list: el.company_list.sort(compareNumbers)
}))
.sort((a, b) => compareNumbers(a.company_id, b.company_id))
)
function getCompanyById(id: number) {
return companies.value.find(c => c.id === id);
}
function companiesSelect (id :number) {
return displayCompanies.value
.map(el => ({
id: el.id,
name: el.name,
@@ -132,26 +207,103 @@
.filter(el => el.id !== id)
}
async function updateCompanies () {
await companiesStore.updateMask(mask.value)
router.back()
}
function compareNumbers (a: number, b: number): number {
if (Number.isNaN(a)) return Number.isNaN(b) ? 0 : 1
if (Number.isNaN(b)) return -1
if (a === b) return 0
return a < b ? -1 : 1
}
function checkIsDirty (arr1: CompanyMask[], arr2: CompanyMask[]): boolean {
// Проверка длины массивов
if (arr1.length !== arr2.length) return false
// Функция для нормализации объекта в строковый ключ
const getKey = (obj: { company_id: number; company_list: number[] }): string => {
const sortedList = [...obj.company_list].sort(compareNumbers)
return `${obj.company_id}|${sortedList.join(',')}`
}
// Создаем Map для подсчета объектов в первом массиве
const countMap = new Map<string, number>()
for (const item of arr1) {
const key = getKey(item)
countMap.set(key, (countMap.get(key) || 0) + 1)
}
// Проверяем объекты второго массива
for (const item of arr2) {
const key = getKey(item)
const count = countMap.get(key) || 0
if (count === 0) return false
countMap.set(key, count - 1)
}
return true
}
onMounted(() => {
function getList (companyId: number) {
const company = companiesMask.value.find(el => el.company_id === companyId)
return company?.company_list ?? []
}
displayCompanies.value = companies.value
.filter(el => !el.is_own)
.map(el => ({
id: el.id,
name: el.name,
logo: el?.logo ?? '',
masked: companiesStore.checkCompanyMasked(el.id),
company_list: getList(el.id),
isMenuOpen: false,
hasFocus: false
}))
console.log(companies.value)
console.log(displayCompanies.value)
originalMask.value = [ ...(companiesMask.value.sort((a, b) => compareNumbers(a.company_id, b.company_id))) ]
})
</script>
<style scoped lang="scss">
:deep(.fix-select .q-field__control) {
.fix-select :deep(.q-field__control) {
align-items: center;
}
.fix-select :deep(.q-field__native) {
justify-content: flex-end;
}
.fix-select :deep(.q-field__label.no-pointer-events) {
left: 50% !important;
transform: translateX(-50%) !important;
}
:deep(.fix-select .q-icon) {
color: $green-14 !important;
}
:deep(.masked .q-icon) {
color: red;
opacity: 1;
}
:deep(.unmasked .q-icon) {
color: grey;
opacity: 0.5;
color: 'brand';
}
</style>

View File

@@ -1,39 +1,46 @@
<template>
<pn-page-card>
<template #title>
<div class="flex items-center justify-between col-grow">
<div>
{{$t('Your_company__title_card')}}
</div>
<q-btn
v-if="(Object.keys(companyMod).length !== 0)"
@click = "addCompany(companyMod)"
flat round
icon="mdi-check"
/>
</div>
</template>
<pn-scroll-list>
<company-info-block v-model="companyMod"/>
</pn-scroll-list>
</pn-page-card>
<company-block
v-if="companyMod"
v-model="companyMod"
title="account_company__title_card"
btnText="account_company__btn"
@update="updateMyCompany"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import companyInfoBlock from 'src/components/companyInfoBlock.vue'
import { useCompaniesStore } from 'stores/companies'
import type { Company } from 'src/types'
import companyBlock from 'components/companyBlock.vue'
import { useAuthStore } from 'stores/auth'
import type { CompanyParams } from 'types/Company'
const router = useRouter()
const companiesStore = useCompaniesStore()
const authStore = useAuthStore()
const companyMod = ref(<Company>{})
const companyMod = ref<CompanyParams | null>(null)
function addCompany (data: Company) {
companiesStore.addCompany(data)
router.go(-1)
async function updateMyCompany () {
if (companyMod.value) {
await authStore.updateMyCompany(companyMod.value)
router.go(-1)
}
}
onMounted(() => {
if (authStore.customer?.company) {
companyMod.value = authStore.customer?.company as CompanyParams
} else {
companyMod.value = {
name: '',
logo: '',
description: '',
site: '',
address: '',
phone: '',
email: ''
}
}
})
</script>

View File

@@ -6,22 +6,11 @@
</div>
<div class="text-h2" style="opacity:.4">
Oops. Nothing here...
{{ $t('error404') }}
</div>
<q-btn
class="q-mt-xl"
color="white"
text-color="blue"
unelevated
to="/"
label="Go Home"
no-caps
/>
</div>
</div>
</template>
<script setup lang="ts">
//
</script>

View File

@@ -274,19 +274,6 @@
max-width: 300px;
}
.orline {
display: flex;
flex-direction: row;
}
.orline:before,
.orline:after {
content: "";
flex: 1 1;
border-bottom: 1px solid;
margin: auto;
}
.login-card {
opacity: 0.9 !important;
border-radius: var(--top-raduis);

View File

@@ -1,113 +0,0 @@
<template>
<pn-page-card>
<template #title>
<div class="flex items-center justify-between col-grow">
<div>
{{ $t('person_card__title') }}
</div>
<q-btn
@click = "goProject()"
flat round
icon="mdi-check"
/>
</div>
</template>
<pn-scroll-list>
<div class="flex column items-center q-ma-lg">
<q-avatar size="100px">
<q-img :src="person.logo"/>
</q-avatar>
<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">{{ person.tname }}</div>
<div caption class="text-blue text-caption">{{ person.tusername }}</div>
</div>
</div>
<div class="q-gutter-y-lg w100">
<q-input
v-model.trim="person.name"
dense
filled
class = "w100"
:label = "$t('person_card__name')"
/>
<q-select
v-if="companies"
v-model="person.company"
:options="companies"
dense
filled
class="w100"
:label = "$t('person_card__company')"
>
<template #option="scope">
<q-item v-bind="scope.itemProps">
<q-item-section avatar>
<q-avatar rounded size="md">
<img v-if="scope.opt.logo" :src="scope.opt.logo"/>
<pn-auto-avatar v-else :name="scope.opt.name"/>
</q-avatar>
</q-item-section>
<q-item-section>
<q-item-label>{{ scope.opt.name }}</q-item-label>
</q-item-section>
</q-item>
</template>
<template #selected>
{{ JSON.parse(JSON.stringify(person.company)).name }}
</template>
</q-select>
<q-input
v-model.trim="person.department"
dense
filled
class = "w100"
:label = "$t('person_card__department')"
/>
<q-input
v-model.trim="person.role"
dense
filled
class = "w100"
:label = "$t('person_card__role')"
/>
</div>
</div>
</pn-scroll-list>
</pn-page-card>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const person = ref({id: "p1", name: 'Кирюшкин Андрей', logo: 'https://cdn.quasar.dev/img/avatar4.jpg', tname: 'Kir_AA', tusername: '@kiruha90', role: 'DevOps', company: '', department: 'test' })
const companies = ref([
{id: "com11", value: "com11", name: 'Рога и копытца1', logo: '', description: 'Монтажники вывески', qtyPersons: 3, masked: false, unmasked: [] },
{id: "com21", name: 'ООО "Василек1"', logo: 'https://cdn.quasar.dev/img/avatar5.jpg', qtyPersons: 2, masked: true, unmasked: [] },
{id: "ch13", name: 'Откат и деньги1', logo: 'https://cdn.quasar.dev/img/avatar4.jpg', description: 'Договариваются с администрацией', qtyPersons: 5, masked: false, unmasked: [] },
{id: "ch14", name: 'Откат и деньги2', logo: '', description: 'Договариваются о чем-то', qtyPersons: 5, masked: false, unmasked: [] },
{id: "com111", name: 'Рога и копытца2', logo: '', description: 'Монтажники вывески', qtyPersons: 3, masked: false, unmasked: []},
{id: "com211", name: 'ООО "Василек2"', logo: '', qtyPersons: 2, masked: true, unmasked: [] },
{id: "ch131", name: 'Откат и деньги3', logo: '', description: 'Договариваются с администрацией', qtyPersons: 5, masked: false, unmasked: [] },
])
async function goProject () {
await router.push({ name: 'project' })
}
</script>
<style>
</style>

View File

@@ -1,22 +0,0 @@
<template>
<pn-page-card>
<template #title>
<div class="flex items-center justify-between col-grow">
<div>
{{ $t('privacy__title') }}
</div>
</div>
</template>
<pn-scroll-list>
{{ $t('under_construction') }}
</pn-scroll-list>
</pn-page-card>
</template>
<script setup lang="ts">
</script>
<style>
</style>

View File

@@ -0,0 +1,32 @@
<template>
<project-block
v-model="newProject"
title="project_create__title_card"
btnText="project_create__btn"
@update="addProject"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import projectBlock from 'components/projectBlock.vue'
import { useProjectsStore } from 'stores/projects'
import type { ProjectParams } from 'types/Project'
const router = useRouter()
const projectsStore = useProjectsStore()
const newProject = ref(<ProjectParams>{
name: '',
logo: '',
description: '',
is_logo_bg: false
})
async function addProject () {
const newDataProject = await projectsStore.add(newProject.value)
await router.replace({ name: 'chats', params: { id: newDataProject.id }})
}
</script>

View File

@@ -1,66 +0,0 @@
<template>
<pn-page-card>
<template #title>
<div class="flex items-center justify-between col-grow">
<div>
{{$t('project_card__add_project')}}
</div>
<q-btn
v-if="isFormValid && isDirty"
@click = "addProject(project)"
flat round
icon="mdi-check"
/>
</div>
</template>
<pn-scroll-list>
<project-info-block
v-model="project"
@valid="isFormValid = $event"
/>
</pn-scroll-list>
</pn-page-card>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import projectInfoBlock from 'components/projectInfoBlock.vue'
import { useProjectsStore } from 'stores/projects'
import type { ProjectParams } from 'types/Project'
import { useNotify, type ServerError } from 'composables/useNotify'
const { notifyError } = useNotify()
const router = useRouter()
const projectsStore = useProjectsStore()
const initialProject: ProjectParams = {
name: '',
logo: '',
description: '',
is_logo_bg: false
}
const project = ref<ProjectParams>({ ...initialProject })
const isFormValid = ref(false)
const isDirty = computed(() => {
return (
project.value.name !== initialProject.name ||
project.value.logo !== initialProject.logo ||
project.value.description !== initialProject.description ||
project.value.is_logo_bg !== initialProject.is_logo_bg
)
})
async function addProject (data: ProjectParams) {
try {
const newProject = await projectsStore.add(data)
await router.replace({ name: 'chats', params: { id: newProject.id }})
console.log(newProject)
} catch (error) {
notifyError(error as ServerError)
}
}
</script>

View File

@@ -0,0 +1,43 @@
<template>
<project-block
v-if="projectMod"
v-model="projectMod"
title="project_edit__title_card"
btnText="project_edit__btn"
@update="updateProject"
/>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import projectBlock from 'components/projectBlock.vue'
import { useProjectsStore } from 'stores/projects'
import type { ProjectParams } from 'types/Project'
const router = useRouter()
const route = useRoute()
const projectsStore = useProjectsStore()
const projectId = computed(() => projectsStore.currentProjectId)
const projectMod = ref<ProjectParams | null>(null)
if (projectsStore.isInit) {
projectMod.value = projectId.value
? { ...projectsStore.projectById(projectId.value) } as ProjectParams
: null
}
watch(() => projectsStore.isInit, (isInit) => {
if (isInit && projectId.value && !projectMod.value) {
projectMod.value = { ...projectsStore.projectById(projectId.value) as ProjectParams }
}
})
const updateProject = async () => {
if (!projectId.value || !projectMod.value) return
await projectsStore.update(projectId.value, projectMod.value)
router.go(-1)
}
</script>

View File

@@ -1,69 +0,0 @@
<template>
<pn-page-card>
<template #title>
<div class="flex items-center justify-between col-grow">
<div>
<span>{{ $t('project_card__project_card') }}</span>
</div>
<q-btn
v-if="isFormValid && (!isDirty(originalProject, project))"
@click="updateProject()"
flat
round
icon="mdi-check"
/>
</div>
</template>
<pn-scroll-list>
<project-info-block
v-if="project"
v-model="project"
@valid="isFormValid = $event"
/>
</pn-scroll-list>
</pn-page-card>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useProjectsStore } from 'stores/projects'
import projectInfoBlock from 'components/projectInfoBlock.vue'
import { parseIntString, isDirty } from 'boot/helpers'
import type { ProjectParams } from 'types/Project'
const router = useRouter()
const route = useRoute()
const projectsStore = useProjectsStore()
const project = ref<ProjectParams>()
const originalProject = ref<ProjectParams>()
const id = parseIntString(route.params.id)
const isFormValid = ref(false)
onMounted(async () => {
if (id && projectsStore.projectById(id)) {
const initial = projectsStore.projectById(id)
project.value = { ...initial } as ProjectParams
originalProject.value = {...project.value}
} else {
await abort()
}
})
async function updateProject () {
if (id && project.value) {
await projectsStore.update(id, project.value)
router.back()
}
}
async function abort () {
await router.replace({name: 'projects'})
}
</script>
<style>
</style>

View File

@@ -6,7 +6,6 @@
<q-tab-panels
v-model="tabSelect"
animated
keep-alive
class="tab-panel-color full-height-panel w100 flex column col-grow no-scroll"
>
@@ -20,9 +19,8 @@
<router-view/>
</q-tab-panel>
</q-tab-panels>
<template #footer>
<q-footer class="bg-grey-1 text-grey">
<q-footer class="bg-grey-1 text-grey main-content">
<q-tabs
style = "z-index: 1000"
v-model="tabSelect"
@@ -38,23 +36,25 @@
no-caps
dense
:class="tabSelect === tab.name ? 'active' : ''"
class="w100 flex column"
class="flex column w100"
>
<template #default>
<div class="flex column items-center">
<q-icon :name="tab.icon" size="sm">
<q-badge
color="brand" align="top"
rounded floating
style="font-style: normal;"
>
{{ currentProject?.[tab.name as keyof typeof currentProject] ?? 0 }}
</q-badge>
</q-icon>
<span class="text-caption">{{$t(tab.label)}}</span>
<q-icon :name="tab.icon" :size="maxTabWidth < baseTabWidth ? 'sm' : 'md'"/>
<div
class="text-caption flex justify-center"
:style="{ width: (baseTabWidth - 32) + 'px'}"
v-if="maxTabWidth < baseTabWidth"
>
<span>
<q-resize-observer @resize="size => tab.width = size.width"/>
{{$t(tab.label)}}
</span>
</div>
</div>
</template>
</q-route-tab>
<q-resize-observer @resize="size => tabsWidth = size.width" />
</q-tabs>
</q-footer>
</template>
@@ -63,25 +63,37 @@
<script setup lang="ts">
import { ref, onBeforeMount, computed } from 'vue'
import { useProjectsStore } from 'stores/projects'
import { useChatsStore } from 'stores/chats'
import { useCompaniesStore } from 'stores/companies'
import { useUsersStore } from 'stores/users'
import projectPageHeader from 'pages/project-page/ProjectPageHeader.vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const projectStore = useProjectsStore()
const currentProject = computed(() => projectStore.getCurrentProject() )
const chatsStore = useChatsStore()
const usersStore = useUsersStore()
const companiesStore = useCompaniesStore()
const tabs = [
{name: 'chats', label: 'project__chats', icon: 'mdi-chat-outline', to: { name: 'chats'} },
{name: 'persons', label: 'project__persons', icon: 'mdi-account-outline', to: { name: 'persons'} },
{name: 'companies', label: 'project__companies', icon: 'mdi-account-group-outline', to: { name: 'companies'} },
]
const chatsQty = computed(() => chatsStore.getChats.length)
const usersQty = computed(() => usersStore.getUsers.length)
const companiesQty = computed(() => companiesStore.getCompanies.length)
const tabs = ref([
{name: 'chats', label: 'project__chats', icon: 'mdi-chat-outline', to: { name: 'chats'}, qty: chatsQty.value, width: 0 },
{name: 'users', label: 'project__users', icon: 'mdi-account-outline', to: { name: 'users'}, qty: usersQty.value, width: 0 },
{name: 'companies', label: 'project__companies', icon: 'mdi-account-group-outline', to: { name: 'companies'}, qty: companiesQty.value, width: 0 }
])
// hidden icon name if overflow - with resize-observer
const tabsWidth = ref(0)
const baseTabWidth = computed(() => Math.floor(tabsWidth.value / 3))
const maxTabWidth = computed(() => Math.max(...tabs.value.map(el => el.width)))
const tabSelect = ref<string>()
onBeforeMount(() => {
const initialTab = tabs.find(t => t.to.name === route.name)?.name || tabs[0]?.name || ''
const initialTab = tabs.value.find(t => t.to.name === route.name)?.name || tabs.value[0]?.name || ''
tabSelect.value = initialTab
})

View File

@@ -1,30 +1,27 @@
<template>
<pn-page-card>
<template #title>
<div class="flex items-center justify-between col-grow">
<div>{{ $t('projects__projects') }}</div>
<div class="flex items-center">
<q-btn
@click="goAccount()"
flat
no-caps
icon-right="mdi-chevron-right"
align="right"
dense
class="fix-btn"
>
<pn-account-block-name/>
</q-btn>
</div>
</div>
{{ $t('projects__projects') }}
<q-btn
@click="goAccount()"
flat rounded
no-caps
icon-right="mdi-chevron-right"
align="right"
dense
class="fix-btn ellipsis"
>
<pn-account-block-name/>
</q-btn>
</template>
<pn-scroll-list>
<template #card-body-header v-if="projects.length !== 0">
<template
#card-body-header
v-if="projects.length !== 0 || archiveProjects.length !== 0"
>
<q-input
v-if="projects.length !== 0"
v-model="searchProject"
clearable
clear-icon="close"
@@ -38,155 +35,189 @@
</q-input>
</template>
<div id="projects-wrapper">
<q-list separator v-if="projects.length !== 0">
<q-item
v-for = "item in activeProjects"
:key="item.id"
<q-list separator v-if="projects.length !== 0">
<template
v-for = "item in activeProjects"
:key="item.id"
>
<q-slide-item
@right="handleSlideRight($event, item.id)"
clickable
v-ripple
@click="goProject(item.id)"
class="w100"
right-color="red"
left-color="green"
>
<template #right>
<q-icon size="lg" name="mdi-briefcase-remove-outline"/>
</template>
<q-item>
<q-item-section avatar>
<q-avatar rounded >
<q-img v-if="item.logo" :src="item.logo" fit="cover" style="height: 40px"/>
<pn-auto-avatar v-else :name="item.name"/>
</q-avatar>
<pn-auto-avatar
:img="item.logo"
:name="item.name"
type="rounded"
size="lg"
/>
</q-item-section>
<q-item-section>
<q-item-label lines="1" class="text-bold">{{ item.name }}</q-item-label>
<q-item-label caption lines="2">{{item.description}}</q-item-label>
</q-item-section>
<q-item-section side class="text-caption ">
<div class="flex items-center column">
<div class="flex items-center">
<q-icon name="mdi-chat-outline"/>
<span>{{ item.chat_count }} </span>
</div>
<div class="flex items-center">
<q-icon name="mdi-account-outline"/>
<span>{{ item.user_count }}</span>
</div>
</div>
</q-item-section>
</q-item>
</q-list>
<div v-if="archiveProjects.length !== 0" class="flex column items-center w100" :class="showArchive ? 'bg-grey-12' : ''">
<div id="btn_show_archive">
<q-btn-dropdown color="grey" flat no-caps @click="showArchive = !showArchive" dropdown-icon="arrow_drop_up">
<template #label>
<span class="text-caption">
<span v-if="!showArchive">{{ $t('projects__show_archive') }}</span>
<span v-else>{{ $t('projects__hide_archive') }}</span>
</span>
</template>
</q-btn-dropdown>
</div>
<q-list separator v-if="showArchive" class="w100">
<q-item
v-for = "item in archiveProjects"
:key="item.id"
clickable
v-ripple
@click="handleArchiveList(item.id)"
class="w100 text-grey"
>
<q-item-section avatar>
<q-avatar rounded >
<q-img v-if="item.logo" :src="item.logo" fit="cover" style="height: 40px"/>
<pn-auto-avatar v-else :name="item.name"/>
</q-avatar>
<q-item-label
caption lines="2"
style="max-width: -webkit-fill-available; white-space: pre-line"
>
{{item.description}}
</q-item-label>
</q-item-section>
<q-item-section>
<q-item-label lines="1" class="text-bold">{{ item.name }}</q-item-label>
<q-item-label caption lines="2">{{item.description}}</q-item-label>
<q-item-section side class="text-caption ">
<div class="flex items-center column">
<div class="flex items-center">
<q-icon name="mdi-chat-outline"/>
<span>{{ item.chat_count }} </span>
</div>
<div class="flex items-center">
<q-icon name="mdi-account-outline"/>
<span>{{ item.user_count }}</span>
</div>
</div>
</q-item-section>
</q-item>
</q-list>
</q-slide-item>
</template>
</q-list>
<div
v-if="archiveProjects.length!==0"
class="flex column items-center w100"
:class="showArchive ? 'bg-grey-12' : ''"
>
<q-btn-dropdown
class="w100 fix-rotate-arrow"
color="grey"
flat no-caps
@click="showArchive=!showArchive"
dropdown-icon="arrow_drop_up"
>
<template #label>
<span class="text-caption">
{{ !showArchive
? $t('projects__show_archive') + ' (' + archiveProjects.length +')'
: $t('projects__hide_archive')
}}
</span>
</template>
</q-btn-dropdown>
<div class="w100" style="overflow: hidden">
<transition
appear
enter-active-class="animated slideInDown"
leave-active-class="animated slideOutUp"
>
<q-list separator v-if="showArchive" class="w100">
<q-item
v-for = "item in archiveProjects"
:key="item.id"
clickable
v-ripple
@click="handleArchiveList(item.id)"
class="w100 text-grey"
>
<q-item-section avatar>
<pn-auto-avatar
:img="item.logo"
:name="item.name"
type="rounded"
size="lg"
/>
</q-item-section>
<q-item-section>
<q-item-label lines="1" class="text-bold">{{ item.name }}</q-item-label>
<q-item-label
caption lines="2"
style="max-width: -webkit-fill-available; white-space: pre-line"
>
{{item.description}}
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</transition>
</div>
</div>
<pn-onboard-btn
v-if="projects.length === 0 && projectsInit"
icon="mdi-briefcase-plus-outline"
:message1="$t('projects__lets_start')"
:message2="$t('projects__lets_start_description')"
@btn-click="createNewProject"
/>
<div
v-if="projects.length === 0"
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="createNewProject">
<div class="flex column justify-center col-grow items-center">
<q-icon name="mdi-briefcase-plus-outline" size="160px" class="q-pb-md"/>
<div class="text-h6 text-brand">
{{$t('projects__lets_start')}}
</div>
</div>
</q-btn>
<div class="text-caption" align="center">
{{$t('projects__lets_start_description')}}
</div>
</div>
</div>
class="flex column justify-center items-center w100"
style="position: absolute; bottom: 0;"
v-if="!projectsInit"
>
<q-linear-progress indeterminate />
</div>
</pn-scroll-list>
<q-page-sticky
position="bottom-right"
:offset="[18, 18]"
:offset="[0, 18]"
class="fix-fab-offset"
>
<q-btn
fab
icon="add"
color="brand"
@click="createNewProject"
<transition
appear
enter-active-class="animated zoomIn"
>
<q-btn
fab
icon="add"
color="brand"
@click="createNewProject"
/>
</transition>
</q-page-sticky>
</pn-page-card>
<q-dialog v-model="showDialogArchive">
<q-card class="q-pa-none q-ma-none">
<q-card-section align="center">
<div class="text-h6 text-negative ">{{ $t('projects__restore_archive_warning') }}</div>
</q-card-section>
<q-card-section class="q-pt-none" align="center">
{{ $t('projects__restore_archive_warning_message') }}
</q-card-section>
<pn-small-dialog
v-model="showDialogArchiveProject"
icon="mdi-briefcase-remove-outline"
color="negative"
title="projects__dialog_archive_title"
message1="projects__dialog_archive_message"
mainBtnLabel="projects__dialog_archive_ok"
@clickMainBtn="onConfirmArchiveProject()"
@close="onCancel()"
@before-hide="onDialogBeforeHide()"
/>
<q-card-actions align="center">
<q-btn
flat
:label="$t('back')"
color="primary"
v-close-popup
/>
<q-btn
flat
:label="$t('continue')"
color="primary"
v-close-popup
@click="restoreFromArchive()"
/>
</q-card-actions>
</q-card>
</q-dialog>
<pn-small-dialog
v-model="showDialogRestoreArchive"
icon="mdi-briefcase-upload-outline"
color="green"
title="projects__dialog_restore_title"
message1="projects__dialog_restore_message"
mainBtnLabel="projects__dialog_cancel_ok"
@clickMainBtn="restoreFromArchive()"
/>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useProjectsStore } from 'stores/projects'
import { useSettingsStore } from 'stores/settings'
import type { Project } from 'types/Project'
const router = useRouter()
const projectsStore = useProjectsStore()
const settingsStore = useSettingsStore()
const projects = projectsStore.projects
const projects = projectsStore.getProjects
const projectsInit = computed(() => projectsStore.isInit)
const searchProject = ref('')
const showArchive = ref(false)
const showDialogArchive = ref(false)
const archiveProjectId = ref<number | undefined> (undefined)
const showDialogRestoreArchive = ref(false)
const restoreProjectId = ref<number | undefined> (undefined)
async function goProject (id: number) {
await router.push({ name: 'chats', params: { id }})
@@ -201,12 +232,12 @@
}
function handleArchiveList (id: number) {
showDialogArchive.value = true
archiveProjectId.value = id
showDialogRestoreArchive.value = true
restoreProjectId.value = id
}
function restoreFromArchive () {
if (archiveProjectId.value) projectsStore.restore(archiveProjectId.value)
async function restoreFromArchive () {
if (restoreProjectId.value) await projectsStore.restore(restoreProjectId.value)
}
const displayProjects = computed(() => {
@@ -232,19 +263,63 @@
if (!projectsStore.isInit) {
await projectsStore.init()
}
if (!settingsStore.isInit) {
await settingsStore.init()
}
})
watch(showDialogArchive, (newD :boolean) => {
if (!newD) archiveProjectId.value = undefined
watch(showDialogRestoreArchive, (newD :boolean) => {
if (!newD) restoreProjectId.value = undefined
})
interface SlideEvent {
reset: () => void
}
const currentSlideEvent = ref<SlideEvent | null>(null)
const showDialogArchiveProject = ref<boolean>(false)
const archiveProjectId = ref<number | undefined>(undefined)
const closedByUserAction = ref(false)
function handleSlideRight (event: SlideEvent, id: number) {
currentSlideEvent.value = event
showDialogArchiveProject.value = true
archiveProjectId.value = id
}
function onDialogBeforeHide () {
if (!closedByUserAction.value) {
onCancel()
}
closedByUserAction.value = false
}
async function onConfirmArchiveProject () {
closedByUserAction.value = true
if (archiveProjectId.value) {
await projectsStore.archive(archiveProjectId.value)
}
currentSlideEvent.value = null
}
function onCancel() {
closedByUserAction.value = true
if (currentSlideEvent.value) {
currentSlideEvent.value.reset()
currentSlideEvent.value = null
}
}
</script>
<style scoped>
:deep(.q-slide-item__right) {
align-self: center;
height: 98%;
}
.fix-btn :deep(.q-btn__content) {
flex-wrap: nowrap;
}
.fix-rotate-arrow :deep(.q-btn-dropdown--simple) {
margin-left: 0;
}
</style>

View File

@@ -1,22 +0,0 @@
<template>
<pn-page-card>
<template #title>
<div class="flex items-center justify-between col-grow">
<div>
{{ $t('terms__title') }}
</div>
</div>
</template>
<pn-scroll-list>
{{ $t('under_construction') }}
</pn-scroll-list>
</pn-page-card>
</template>
<script setup lang="ts">
</script>
<style>
</style>

View File

@@ -0,0 +1,45 @@
<template>
<user-block
v-if="userMod"
v-model="userMod"
title="user_edit__title_card"
btnText="user_edit__btn"
@update="updateUser"
/>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import userBlock from 'components/userBlock.vue'
import { useUsersStore } from 'stores/users'
import { parseIntString } from 'helpers/helpers'
import type { User } from 'types/Users'
const router = useRouter()
const route = useRoute()
const usersStore = useUsersStore()
const userMod = ref<User | null>(null)
const userId = computed(() => parseIntString(route.params.userId))
if (usersStore.isInit) {
userMod.value = userId.value
? { ...usersStore.userById(userId.value) } as User
: null
}
watch(() => usersStore.isInit, (isInit) => {
if (isInit && userId.value && !userMod.value) {
userMod.value = { ...usersStore.userById(userId.value) as User }
}
})
async function updateUser () {
if (userId.value && userMod.value) {
await usersStore.update(userId.value, userMod.value)
router.go(-1)
}
}
</script>

View File

@@ -0,0 +1,234 @@
<template>
<pn-page-card>
<template #title>
{{ $t('software__title') }}
</template>
<pn-scroll-list>
<template #card-body-header>
<div class="q-pa-md">
{{ $t('software__description') }}
</div>
</template>
<q-list separator>
<q-expansion-item
v-for="item in software"
group="somegroup"
>
<template #header>
<q-item-section avatar>
<q-avatar square size="sm" v-if="item.logo">
<img :src="'3software/logo/' + item.logo">
</q-avatar>
</q-item-section>
<q-item-section>
<div class="flex items-baseline">
<span class="text-h6">
{{ item.name }}
</span>
<span v-if = "item.ver" class="text-caption q-pl-xs">
{{ 'v.' + item.ver }}
</span>
</div>
</q-item-section>
</template>
<div class="w100 flex column q-px-md q-gutter-y-md q-py-sm">
<div class="flex row no-wrap items-center">
<q-icon name="mdi-scale-balance" size="sm" class="q-pr-lg" color="grey"/>
<div
@click="downloadFile('3software/license/' + item.license_file)"
class="flex w100 column q-pl-sm cursor-pointer"
>
<span> {{ item.license }} </span>
<span
class="text-caption"
style="white-space: pre-line;"
>
{{ item.license_copyright }}
</span>
</div>
</div>
<div class="flex row no-wrap items-center" v-if="item.web">
<q-icon name="mdi-web" size="sm" class="q-pr-lg" color="grey"/>
<span
class="q-pl-sm cursor-pointer"
@click="tg.openLink(item.web_url)"
>
{{ item.web }}
</span>
</div>
<div class="flex row no-wrap items-center" v-if="item.git">
<q-icon name="mdi-github" size="sm" class="q-pr-lg" color="grey"/>
<span
class="q-pl-sm cursor-pointer"
@click="tg.openLink(item.git_url)"
>
{{ item.git }}
</span>
</div>
</div>
</q-expansion-item>
</q-list>
</pn-scroll-list>
</pn-page-card>
</template>
<script setup lang="ts">
import { inject } from 'vue'
import type { WebApp } from '@twa-dev/types'
const tg = inject('tg') as WebApp
const software = [
{
id: 1,
name: 'Vue',
ver: '3.4',
logo: 'vue.webp',
web: 'vuejs.org',
web_url: 'https://vuejs.org/',
git: 'vuejs/core',
git_url: 'https://github.com/vuejs/core',
license: 'MIT License',
license_copyright: 'Copyright (c) 2018-present, Yuxi (Evan) You and Vue contributors',
license_file: 'vue/LICENSE.txt'
},
{
id: 2,
name: 'Quasar',
ver: '2.x',
logo: 'quasar.png',
web: 'quasar.dev',
web_url: 'https://quasar.dev/',
git: 'quasarframework/quasar',
git_url: 'https://github.com/quasarframework/quasar',
license: 'MIT License',
license_copyright: 'Copyright (c) 2015-present Razvan Stoenescu',
license_file: 'quasar/LICENSE.txt'
},
{
id: 3,
name: 'Pinia',
ver: '2.x',
logo: 'pinia.svg',
web: 'pinia.vuejs.org',
web_url: 'https://pinia.vuejs.org/',
git: 'vuejs/pinia',
git_url: 'https://github.com/vuejs/pinia',
license: 'MIT License',
license_copyright: 'Copyright (c) 2019-present Eduardo San Martin Morote',
license_file: 'pinia/LICENSE.txt'
},
{
id: 4,
name: 'Vue Router',
ver: '4.x',
logo: 'vue.webp',
web: 'router.vuejs.org',
web_url: 'https://router.vuejs.org/',
git: 'vuejs/router',
git_url: 'https://github.com/vuejs/router',
license: 'MIT License',
license_copyright: 'Copyright (c) 2019-present Eduardo San Martin Morote',
license_file: 'vue-router/LICENSE.txt'
},
{
id: 5,
name: 'Vue i18n',
ver: '9.x',
logo: 'vue-i18n.svg',
web: 'vue-i18n.intlify.dev',
web_url: 'https://vue-i18n.intlify.dev/',
git: 'intlify/vue-i18n',
git_url: 'https://github.com/intlify/vue-i18n',
license: 'MIT License',
license_copyright: 'Copyright (c) 2016-present kazuya kawaguchi and contributors',
license_file: 'vue-i18n/LICENSE.txt'
},
{
id: 6,
name: 'Axios',
ver: '1.x',
logo: 'axios.svg',
web: 'axios-http.com',
web_url: 'https://axios-http.com/',
git: 'axios/axios',
git_url: 'https://github.com/axios/axios',
license: 'MIT License',
license_copyright: 'Copyright (c) 2014-present Matt Zabriskie & Collaborators',
license_file: 'axios/LICENSE.txt'
},
{
id: 7,
name: 'better-sqlite3',
ver: '11.x',
logo: '',
web: '',
web_url: '',
git: 'WiseLibs/better-sqlite3',
git_url: 'https://github.com/WiseLibs/better-sqlite3',
license: 'MIT License',
license_copyright: 'Copyright (c) 2017 Joshua Wise',
license_file: 'better-sqlite3/LICENSE.txt'
},
{
id: 8,
name: 'Express',
ver: '4.x',
logo: 'express.svg',
web: 'expressjs.com',
web_url: 'https://expressjs.com/',
git: 'expressjs/express',
git_url: 'https://github.com/expressjs/express',
license: 'MIT License',
license_copyright: `Copyright (c) 2009-2014 TJ Holowaychuk <tj@vision-media.ca>
Copyright (c) 2013-2014 Roman Shtylman <shtylman+expressjs@gmail.com>
Copyright (c) 2014-2015 Douglas Christopher Wilson <doug@somethingdoug.com>`,
license_file: 'express/LICENSE.txt'
}
]
const downloadFile = async (url: string) => {
try {
const fullUrl = '/admin/' + url;
const fileUrl = new URL(fullUrl, window.location.origin).href;
const response = await fetch(fileUrl)
if (!response.ok) throw new Error(`HTTP error: ${response.status}`)
const blob = await response.blob()
const downloadUrl = URL.createObjectURL(blob)
const link = document.createElement('a')
const extractFileName = (url: string): string => {
return url.substring(url.lastIndexOf('/') + 1)
}
link.href = downloadUrl
link.download = extractFileName(url)
link.style.display = 'none'
document.body.appendChild(link)
link.click()
setTimeout(() => {
document.body.removeChild(link)
URL.revokeObjectURL(downloadUrl)
}, 100)
} catch (error) {
console.error('Download error:', error)
}
}
</script>
<style scope>
</style>

View File

@@ -0,0 +1,10 @@
<template>
<doc-block type="privacy"/>
</template>
<script setup lang="ts">
import docBlock from 'components/docBlock.vue'
</script>
<style>
</style>

View File

@@ -1,11 +1,7 @@
<template>
<pn-page-card>
<template #title>
<div class="flex items-center justify-between col-grow">
<div>
{{ $t('settings__title') }}
</div>
</div>
{{ $t('settings__title') }}
</template>
<pn-scroll-list>
@@ -62,13 +58,12 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { computed } from 'vue'
import { useSettingsStore } from 'stores/settings'
const localeOptions = ref([
{ value: 'en-US', label: 'English' },
{ value: 'ru-RU', label: 'Русский' }
])
const settingsStore = useSettingsStore()
const localeOptions = settingsStore.supportLocale
const locale = computed({
get: () => settingsStore.settings.locale,
@@ -76,8 +71,6 @@
set: (value: string) => settingsStore.updateLocale(value)
})
const settingsStore = useSettingsStore()
</script>
<style scoped>

View File

@@ -1,11 +1,7 @@
<template>
<pn-page-card>
<template #title>
<div class="flex items-center justify-between col-grow">
<div>
{{$t('subscribe__title')}}
</div>
</div>
{{$t('subscribe__title')}}
</template>
<pn-scroll-list class="q-px-md">

View File

@@ -0,0 +1,64 @@
<template>
<pn-page-card>
<template #title>
{{ $t('support__title') }}
</template>
<pn-scroll-list>
<div class="q-pa-md flex column w100 items-center">
<div align="center" >
<div>{{ $t('support__work_time_text') }}</div>
<div class="flex items-center justify-center text-caption">
<q-icon name="mdi-clock-time-four-outline"/>
<div>{{ $t('support__work_time_time') }}</div>
</div>
</div>
<q-btn
no-caps
icon="telegram"
color="primary"
rounded
@click="openSupport"
class="q-ma-lg"
>
<span class="q-pl-xs no-wrap">{{ $t('support__ask_question') }}</span>
</q-btn>
<div class="orline w100 text-grey">
<span class="q-mx-sm text-caption">{{ $t('support__or') }}</span>
</div>
<div class="flex items-center justify-center text-primary q-ma-md">
<q-icon name="mail" class="q-mr-xs"/>
{{ supportEmailAddress }}
</div>
</div>
</pn-scroll-list>
</pn-page-card>
</template>
<script setup lang="ts">
import { inject } from 'vue'
import type { WebApp } from '@twa-dev/types'
const tg = inject('tg') as WebApp
const supportEmailAddress = 'a-mart@ya.ru'
const supportTelegramUser = 'alexmart80'
const telegramUrl = `https://t.me/${supportTelegramUser}`
const openSupport = () => {
if (tg?.platform !== 'unknown') {
tg?.openLink(telegramUrl, { try_instant_view: true })
} else {
window.open(telegramUrl, '_blank')
}
}
</script>
<style scope>
.hidden-href{
display: none;
}
</style>

View File

@@ -0,0 +1,10 @@
<template>
<doc-block type="terms_of_use"/>
</template>
<script setup lang="ts">
import docBlock from 'components/docBlock.vue'
</script>
<style>
</style>

View File

@@ -3,7 +3,7 @@
<pn-scroll-list>
<template #card-body-header>
<div class="flex row q-ma-md justify-between">
<div class="flex row q-ma-md justify-between" v-if="chats.length !== 0">
<q-input
v-model="search"
clearable
@@ -11,6 +11,7 @@
:placeholder="$t('project_chats__search')"
dense
class="col-grow"
v-if="chats.length !== 0"
>
<template #prepend>
<q-icon name="mdi-magnify" />
@@ -18,26 +19,30 @@
</q-input>
</div>
</template>
<q-list bordered separator>
<q-list separator v-if="chats.length !== 0">
<q-slide-item
v-for="item in displayChats"
:key="item.id"
@right="handleSlide($event, item.id)"
right-color="red"
@click="goChat(item.invite_link)"
>
<template #right>
<q-icon size="lg" name="mdi-link-off"/>
</template>
<q-item
:key="item.id"
:clickable="false"
clickable
v-ripple
>
<q-item-section avatar>
<q-avatar rounded>
<q-img v-if="item.logo" :src="item.logo"/>
<pn-auto-avatar v-else :name="item.name"/>
</q-avatar>
<pn-auto-avatar
:img="item.logo"
:name="item.name"
type="rounded"
size="lg"
/>
</q-item-section>
<q-item-section>
<q-item-label lines="1" class="text-bold">
@@ -47,14 +52,14 @@
{{ item.description }}
</q-item-label>
<q-item-label caption lines="1">
<div class = "flex justify-start items-center">
<div class="q-mr-sm flex items-center">
<q-icon name="mdi-account-outline" class="q-mx-sm"/>
<span>{{ item.persons }}</span>
<div class = "flex justify-start items-center no-wrap">
<div class="q-mr-sm flex items-center no-wrap">
<q-icon name="mdi-account-multiple-outline" class="q-mr-xs"/>
<span>{{ item.user_count }}</span>
</div>
<div class="q-mx-sm flex items-center">
<q-icon name="mdi-key" class="q-mr-sm"/>
<span>{{ item.owner_id }} </span>
<div class="q-mx-sm flex items-center no-wrap ellipsis" v-if="item.owner_id">
<q-icon name="mdi-key" class="q-mr-xs"/>
<span class="ellipsis">{{ usersStore.userNameById(item.owner_id) }} </span>
</div>
</div>
</q-item-label>
@@ -63,111 +68,130 @@
</q-slide-item>
</q-list>
<pn-onboard-btn
v-if="chats.length === 0 && chatsInit"
icon="mdi-chat-plus-outline"
:message1="$t('project_chat__onboard_msg1')"
:message2="$t('project_chat__onboard_msg2')"
@click="showOverlay=true; fabState=true"
/>
<div
class="flex column justify-center items-center w100"
style="position: absolute; bottom: 0;"
v-if="!chatsInit"
>
<q-linear-progress indeterminate />
</div>
</pn-scroll-list>
</div>
<q-page-sticky
:style="{ zIndex: !showOverlay ? 'inherit' : '5100 !important' }"
position="bottom-right"
:offset="[18, 18]"
:style="{ zIndex: !showOverlay ? 'inherit' : '5100 !important' }"
position="bottom-right"
:offset="[0, 18]"
class="fix-fab-offset"
>
<transition
appear
enter-active-class="animated zoomIn"
>
<transition
appear
enter-active-class="animated slideInUp"
<q-fab
v-model="fabState"
v-if="showFab"
icon="add"
color="brand"
direction="up"
vertical-actions-align="right"
@click="showOverlay = !showOverlay"
:disable="!tg.initData"
>
<q-fab
v-if="showFab"
icon="add"
color="brand"
direction="up"
vertical-actions-align="right"
@click="showOverlay = !showOverlay"
<template #tooltip>
<q-tooltip
v-if="!tg.initData"
anchor="center left" self="center end"
style="width: calc(min(100vw, var(--body-width)) - 102px) !important;"
>
<q-fab-action
v-for="item in fabMenu"
:key="item.id"
square
clickable
v-ripple
class="bg-white change-fab-action"
>
<template #icon>
<q-item class="q-pa-xs w100">
<q-item-section avatar class="items-center">
<q-avatar color="brand" rounded text-color="white" :icon="item.icon" />
</q-item-section>
{{ $t('project_chats_disabled_FAB')}}
</q-tooltip>
</template>
<q-fab-action
v-for="item in fabMenu"
:key="item.id"
square
clickable
v-ripple
class="bg-white change-fab-action"
@click="item.func()"
>
<template #icon>
<q-item class="q-pa-xs w100">
<q-item-section avatar class="items-center">
<q-avatar color="brand" rounded text-color="white" :icon="item.icon" />
</q-item-section>
<q-item-section class="items-start">
<q-item-label class="fab-action-item">
{{ $t(item.name) }}
</q-item-label>
<q-item-label caption class="fab-action-item">
{{ $t(item.description) }}
</q-item-label>
<q-item-section class="items-start">
<q-item-label class="fab-action-item">
{{ $t(item.name) }}
</q-item-label>
<q-item-label caption class="fab-action-item">
{{ $t(item.description) }}
</q-item-label>
</q-item-section>
</q-item>
</template>
</q-fab-action>
</q-fab>
</transition>
</q-page-sticky>
</q-item-section>
</q-item>
</template>
</q-fab-action>
</q-fab>
</transition>
</q-page-sticky>
</div>
<pn-overlay v-if="showOverlay"/>
<q-dialog v-model="showDialogDeleteChat" @before-hide="onDialogBeforeHide()">
<q-card class="q-pa-none q-ma-none">
<q-card-section align="center">
<div class="text-h6 text-negative ">{{ $t('project_chat__delete_warning') }}</div>
</q-card-section>
<pn-overlay v-if="showOverlay"/>
<q-card-section class="q-pt-none" align="center">
{{ $t('project_chat__delete_warning_message') }}
</q-card-section>
<pn-small-dialog
v-model="showDialogDeleteChat"
icon="mdi-link-off"
color="negative"
title="project_chat__delete_warning"
message1="project_chat__delete_warning_message"
mainBtnLabel="project_chat__dialog_cancel_ok"
@clickMainBtn="onConfirm()"
@close="onCancel()"
@before-hide="onDialogBeforeHide()"
/>
<q-card-actions align="center">
<q-btn
flat
:label="$t('back')"
color="primary"
v-close-popup
@click="onCancel()"
/>
<q-btn
flat
:label="$t('delete')"
color="primary"
v-close-popup
@click="onConfirm()"
/>
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { ref, computed, onActivated, onDeactivated, onBeforeUnmount } from 'vue'
import { ref, computed, onActivated, onDeactivated, onBeforeUnmount, inject } from 'vue'
import { useChatsStore } from 'stores/chats'
import { useUsersStore } from 'stores/users'
import type { WebApp } from '@twa-dev/types'
import { useI18n } from "vue-i18n"
const { t } = useI18n()
const search = ref('')
const showOverlay = ref<boolean>(false)
const chatsStore = useChatsStore()
const usersStore = useUsersStore()
const showDialogDeleteChat = ref<boolean>(false)
const deleteChatId = ref<number | undefined>(undefined)
const currentSlideEvent = ref<SlideEvent | null>(null)
const closedByUserAction = ref(false)
const tg = inject('tg') as WebApp
const fabState = ref(false)
interface SlideEvent {
reset: () => void
}
const chats = chatsStore.chats
const chats = chatsStore.getChats
const chatsInit = computed(() => chatsStore.isInit)
const fabMenu = [
{id: 1, icon: 'mdi-chat-plus-outline', name: 'project_chats__attach_chat', description: 'project_chats__attach_chat_description', func: 'attachChat'},
{id: 2, icon: 'mdi-share-outline', name: 'project_chats__send_chat', description: 'project_chats__send_chat_description', func: 'sendChat'},
{id: 1, icon: 'mdi-chat-plus-outline', name: 'project_chats__attach_chat', description: 'project_chats__attach_chat_description', func: attachChat},
{id: 2, icon: 'mdi-share-outline', name: 'project_chats__send_chat', description: 'project_chats__send_chat_description', func: sendChat},
]
const displayChats = computed(() => {
@@ -181,7 +205,7 @@
return arrOut
})
function handleSlide (event: SlideEvent, id: number) {
function handleSlide (event: SlideEvent, id: number) {
currentSlideEvent.value = event
showDialogDeleteChat.value = true
deleteChatId.value = id
@@ -202,14 +226,42 @@
}
}
function onConfirm() {
async function onConfirm() {
closedByUserAction.value = true
if (deleteChatId.value) {
chatsStore.deleteChat(deleteChatId.value)
await chatsStore.unlink(deleteChatId.value)
}
currentSlideEvent.value = null
}
const botName = 'ready_or_not_2025_bot'
const urlAdmin = 'https://t.me/' + botName + '?startgroup='
const urlAdminPermission='&admin=' +
'post_messages+' +
'edit_messages+' +
'delete_messages+' +
'pin_messages+' +
'restrict_members+' +
'invite_users'
async function attachChat () {
const key = await chatsStore.getKey()
tg.openTelegramLink(urlAdmin + key + urlAdminPermission)
}
async function sendChat () {
const key = await chatsStore.getKey()
const message = urlAdmin + key + urlAdminPermission
const tgShareUrl = 'https://t.me/share/url?url=' +
encodeURIComponent( t('project_chats__send_chat_title')) +
'&text=' + `${encodeURIComponent(message)}`
tg.openTelegramLink(tgShareUrl)
}
function goChat (invite: string) {
tg.openTelegramLink(invite)
}
// fix fab jumping
const showFab = ref(false)
const timerId = ref<ReturnType<typeof setTimeout> | null>(null)
@@ -236,6 +288,7 @@
</script>
<style scoped>
/* width of choose element */
.change-fab-action .q-fab__label--internal {
max-height: none;
}
@@ -255,10 +308,4 @@
align-self: center;
height: 98%;
}
.fix-fab {
top: calc(100vh - 92px);
left: calc(100vw - 92px);
padding: 18px;
}
</style>

View File

@@ -2,29 +2,37 @@
<div class="q-pa-none flex column col-grow no-scroll">
<pn-scroll-list>
<template #card-body-header>
<div class="w100 flex items-center justify-end q-pa-sm">
<q-btn color="primary" flat no-caps dense @click="maskCompany()">
<q-icon
left
size="sm"
name="mdi-drama-masks"
/>
<div>
{{ $t('company__mask')}}
</div>
</q-btn>
</div>
</template>
<div class="flex items-center justify-end q-pa-sm w100" v-if="companies.length !== 0">
<q-btn
:color="companies.length <= 2 ? 'grey-6' : 'primary'"
flat
no-caps
@click="maskCompany()"
:disable="companies.length <= 2"
class="q-pr-md"
rounded
>
<q-icon
left
size="sm"
name="mdi-domino-mask"
/>
<div>
{{ $t('company__mask')}}
</div>
</q-btn>
</div>
</template>
<q-list separator>
<q-list separator v-if="companies.length !== 0">
<q-slide-item
v-for="item in companies"
v-for="item in displayCompanies"
:key="item.id"
@right="handleSlide($event, item.id)"
right-color="red"
>
<template #right>
<q-icon size="lg" name="mdi-delete-outline"/>
<template #right v-if="item.id !== myCompany?.id">
<q-icon size="lg" name="mdi-account-multiple-minus-outline"/>
</template>
<q-item
:key="item.id"
@@ -33,34 +41,79 @@
class="w100"
@click="goCompanyInfo(item.id)"
>
<q-item-section avatar>
<q-avatar rounded>
<q-img v-if="item.logo" :src="item.logo" fit="cover" style="max-width: unset; height:40px;"/>
<pn-auto-avatar v-else :name="item.name"/>
</q-avatar>
</q-item-section>
<q-item-section>
<q-item-label lines="1" class="text-bold">{{ item.name }}</q-item-label>
<q-item-label caption lines="2">{{ item.description }}</q-item-label>
</q-item-section>
<!-- <q-item-section side top>
<div class="flex items-center">
<q-icon v-if="item.masked" name="mdi-drama-masks" color="black" size="sm"/>
<q-item-section avatar>
<pn-auto-avatar
:img="item.logo"
:name="item.name"
type="rounded"
size="lg"
/>
</q-item-section>
<q-item-section>
<q-item-label lines="1" class="text-caption text-amber-10" v-if="item.id === myCompany?.id">
<div class="flex items-center">
<q-icon name="star" class="q-pr-xs"/>
{{ $t('company__my_company') }}
</div>
</q-item-label>
<q-item-label lines="1" class="text-bold">{{ item.name }}</q-item-label>
<q-item-label
caption lines="2"
style="max-width: -webkit-fill-available; white-space: pre-line"
>
{{ item.description }}
</q-item-label>
</q-item-section>
<q-item-section side top>
<div class="flex items-end column">
<span class="text-caption flex items-center">
<q-icon name="mdi-account-outline" color="grey" />
<span>{{ item.qtyPersons }}</span>
<span>{{ getQtyUsers(item.id) }}</span>
</span>
<q-icon
v-if="companiesStore.checkCompanyMasked(item.id)"
name="mdi-domino-mask"
color="grey"
size="xs"
/>
<div class="flex items-center row text-caption">
<q-icon v-if="item.site" name="mdi-web"/>
<q-icon v-if="item.address" name="mdi-map-marker-outline"/>
<q-icon v-if="item.phone" name="mdi-phone-outline"/>
<q-icon v-if="item.email" name="mdi-email-outline"/>
</div>
</q-item-section> -->
</div>
</q-item-section>
</q-item>
</q-slide-item>
</q-list>
<pn-onboard-btn
v-if="companies.length <= 1 && companiesInit"
icon="mdi-account-multiple-plus-outline"
:message1="$t('company__onboard_msg1')"
:message2="$t('company__onboard_msg2')"
@btn-click="createCompany()"
/>
<div
class="flex column justify-center items-center w100"
style="position: absolute; bottom: 0;"
v-if="!companiesInit"
>
<q-linear-progress indeterminate />
</div>
</pn-scroll-list>
<q-page-sticky
position="bottom-right"
:offset="[18, 18]"
:offset="[0, 18]"
class="fix-fab-offset"
>
<transition
appear
enter-active-class="animated slideInUp"
enter-active-class="animated zoomIn"
>
<q-btn
v-if="showFab"
@@ -72,63 +125,58 @@
</transition>
</q-page-sticky>
</div>
<q-dialog v-model="showDialogDeleteCompany" @before-hide="onDialogBeforeHide()">
<q-card class="q-pa-none q-ma-none">
<q-card-section align="center">
<div class="text-h6 text-negative ">{{ $t('company__delete_warning') }}</div>
</q-card-section>
<q-card-section class="q-pt-none" align="center">
{{ $t('company__delete_warning_message') }}
</q-card-section>
<q-card-actions align="center">
<q-btn
flat
:label="$t('back')"
color="primary"
v-close-popup
@click="onCancel()"
/>
<q-btn
flat
:label="$t('delete')"
color="primary"
v-close-popup
@click="onConfirm()"
/>
</q-card-actions>
</q-card>
</q-dialog>
<pn-small-dialog
v-model="showDialogDeleteCompany"
icon="mdi-account-multiple-minus-outline"
color="negative"
title="company__dialog_delete_title"
message1="company__dialog_delete_message"
mainBtnLabel="company__dialog_delete_ok"
@clickMainBtn="onConfirm()"
@close="onCancel()"
@before-hide="onDialogBeforeHide()"
/>
</template>
<script setup lang="ts">
import { ref, computed, onActivated, onDeactivated, onBeforeUnmount } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useRouter, useRoute } from 'vue-router'
import { useCompaniesStore } from 'stores/companies'
import { parseIntString } from 'boot/helpers'
import { useUsersStore } from 'stores/users'
const router = useRouter()
const route = useRoute()
const companiesStore = useCompaniesStore()
const usersStore = useUsersStore()
const showDialogDeleteCompany = ref<boolean>(false)
const deleteCompanyId = ref<number | undefined>(undefined)
const currentSlideEvent = ref<SlideEvent | null>(null)
const closedByUserAction = ref(false)
const projectId = computed(() => parseIntString(route.params.id))
interface SlideEvent {
reset: () => void
}
const companies = companiesStore.companies
const users = computed(() => usersStore.users)
const companies = companiesStore.getCompanies
const companiesInit = computed(() => companiesStore.isInit)
const myCompany = computed(() => companies.find(el => el.is_own))
const displayCompanies = computed(() => {
const otherComp = companies.filter(el => !el.is_own)
return myCompany.value
? [myCompany.value, ...otherComp]
: otherComp
})
async function maskCompany () {
await router.push({ name: 'company_mask' })
}
async function goCompanyInfo (id :number) {
await router.push({ name: 'company_info', params: { id: projectId.value, companyId: id }})
async function goCompanyInfo (companyId: number) {
await router.push({ name: 'company_info', params: { id: route.params.id, companyId }})
}
async function createCompany () {
@@ -156,14 +204,19 @@
}
}
function onConfirm() {
async function onConfirm() {
closedByUserAction.value = true
if (deleteCompanyId.value) {
companiesStore.deleteCompany(deleteCompanyId.value)
await companiesStore.remove(deleteCompanyId.value)
}
currentSlideEvent.value = null
}
function getQtyUsers (companyId: number) {
const arr = users.value.filter(el => el.company_id === companyId)
return arr.length
}
// fix fab jumping
const showFab = ref(false)
const timerId = ref<ReturnType<typeof setTimeout> | null>(null)
@@ -195,5 +248,5 @@
{
align-self: center;
}
</style>
</style>

View File

@@ -1,189 +1,119 @@
<template>
<div
id="project-info"
:style="{ height: headerHeight + 'px' }"
class="flex row items-center justify-between no-wrap q-my-sm w100"
style="overflow: hidden; transition: height 0.3s ease-in-out;"
id="project-info"
:style="{ height: headerHeight + 'px', minHeight: '48px' }"
class="flex row items-center justify-between no-wrap w100 q-gutter-x-sm"
style="overflow: hidden; transition: height 0.3s ease-in-out; margin-left: 0"
>
<div class="ellipsis overflow-hidden">
<div class="ellipsis overflow-hidden q-pa-none q-ma-none">
<q-resize-observer @resize="onResize" />
<transition
enter-active-class="animated slideInUp"
leave-active-class="animated slideOutUp"
mode="out-in"
>
>
<div
v-if="!expandProjectInfo"
@click="toggleExpand"
class="text-h6 ellipsis no-wrap w100"
class="text-h6 ellipsis no-wrap w100 q-pa-none q-ma-none"
key="compact"
>
{{project.name}}
>
{{ project.name }}
</div>
<div
v-else
class="flex items-center no-wrap q-hoverable q-animate--slideUp"
class="flex items-center no-wrap q-hoverable q-animate--slideUp q-py-sm"
@click="toggleExpand"
key="expanded"
>
<q-avatar rounded>
<q-img v-if="project.logo" :src="project.logo" fit="cover" style="height: 100%;"/>
<pn-auto-avatar v-else :name="project.name"/>
</q-avatar>
>
<pn-auto-avatar
:img="project.logo"
:name="project.name"
type="rounded"
size="lg"
/>
<div class="q-px-md flex column text-white fit">
<div class="flex column text-white fit">
<div
class="text-h6"
:style="{ maxWidth: '-webkit-fill-available', whiteSpace: 'normal' }"
>
{{project.name}}
class="text-h6 q-pl-sm text-field"
>
{{ project.name }}
</div>
<div class="text-caption" :style="{ maxWidth: '-webkit-fill-available', whiteSpace: 'normal' }">
{{project.description}}
<div
class="text-caption q-pl-sm text-field"
>
{{ project.description }}
</div>
</div>
</div>
</transition>
</div>
<q-btn flat round color="white" icon="mdi-pencil" size="sm" class="q-ml-xl q-mr-sm">
<q-menu anchor="bottom right" self="top right">
<q-list>
<q-item
v-for="item in menuItems"
:key="item.id"
@click="item.func"
clickable
v-close-popup
class="flex items-center"
>
<q-icon :name="item.icon" size="sm" :color="item.iconColor"/>
<span class="q-ml-xs">{{ $t(item.title) }}</span>
</q-item>
</q-list>
</q-menu>
</q-btn>
<q-btn
@click="editProject"
flat round
color="white"
icon="edit"
size="md"
/>
</div>
<q-dialog v-model="showDialog">
<q-card class="q-pa-none q-ma-none">
<q-card-section align="center">
<div class="text-h6 text-negative ">
{{ $t('project__archive_warning')}}
</div>
</q-card-section>
<q-card-section class="q-pt-none" align="center">
{{ $t('project__archive_warning_message')}}
</q-card-section>
<q-card-actions align="center">
<q-btn
flat
:label="$t('back')"
color="primary"
v-close-popup
/>
<q-btn
flat
:label="$t('project__archive')"
color="negative"
v-close-popup
@click="archiveProject"
/>
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useProjectsStore } from 'stores/projects'
import { parseIntString } from 'boot/helpers'
const router = useRouter()
const route = useRoute()
const projectsStore = useProjectsStore()
const expandProjectInfo = ref<boolean>(false)
const showDialog = ref<boolean>(false)
const dialogType = ref<null | 'archive'>(null)
const headerHeight = ref<number>(0)
const menuItems = [
{ id: 1, title: 'project__edit', icon: 'mdi-square-edit-outline', iconColor: '', func: editProject },
// { id: 2, title: 'project__backup', icon: 'mdi-content-save-outline', iconColor: '', func: () => {} },
{ id: 3, title: 'project__archive', icon: 'mdi-archive-outline', iconColor: 'red', func: () => { showDialog.value = true; dialogType.value = 'archive' }}
]
const projectId = computed(() => parseIntString(route.params.id))
const project =ref({
name: '',
description: '',
logo: '',
is_logo_bg: false
const currentProjectId = computed(() => projectsStore.currentProjectId)
const project = computed(() => {
const currentProject =
currentProjectId.value && projectsStore.projectById(currentProjectId.value)
return currentProject
? {
name: currentProject.name,
description: currentProject.description ?? '',
logo: currentProject.logo ?? ''
}
: {
name: '',
description: '',
logo: ''
}
})
const loadProjectData = async () => {
if (!projectId.value) {
await abort()
return
} else {
const projectFromStore = projectsStore.projectById(projectId.value)
if (!projectFromStore) {
await abort()
return
}
project.value = {
name: projectFromStore.name,
description: projectFromStore.description || '',
logo: projectFromStore.logo || '',
is_logo_bg: projectFromStore.is_logo_bg || false
}
function toggleExpand () {
expandProjectInfo.value = !expandProjectInfo.value
}
}
async function abort () {
await router.replace({ name: 'projects' })
}
async function editProject () {
if (currentProjectId.value)
await router.push({ name: 'project_info', params: { id: currentProjectId.value } })
}
async function editProject () {
if (projectId.value) void projectsStore.update(projectId.value, project.value)
await router.push({ name: 'project_info' })
}
async function archiveProject () {
if (projectId.value) void projectsStore.archive(projectId.value)
await router.replace({ name: 'projects' })
}
function toggleExpand () {
expandProjectInfo.value = !expandProjectInfo.value
}
interface sizeParams {
interface sizeParams {
height: number,
width: number
}
function onResize (size :sizeParams) {
headerHeight.value = size.height
}
const headerHeight = ref<number>(0)
watch(projectId, loadProjectData)
watch(showDialog, () => {
if (showDialog.value === false) dialogType.value = null
})
onMounted(() => loadProjectData())
function onResize (size :sizeParams) {
headerHeight.value = size.height
}
</script>
<style>
<style scoped>
.text-field {
max-width: -webkit-fill-available;
white-space: pre-line;
}
</style>

View File

@@ -1,90 +0,0 @@
<template>
<div class="q-pa-none flex column col-grow no-scroll">
<pn-scroll-list>
<template #card-body-header>
<div class="flex row q-ma-md justify-between">
<q-input
v-model="search"
clearable
clear-icon="close"
:placeholder="$t('project_persons__search')"
dense
class="col-grow"
>
<template #prepend>
<q-icon name="mdi-magnify" />
</template>
</q-input>
</div>
</template>
<q-list separator>
<q-item
v-for="item in displayPersons"
:key="item.id"
v-ripple
clickable
@click="goPersonInfo()"
>
<q-item-section avatar>
<q-avatar>
<img v-if="item.logo" :src="item.logo"/>
<pn-auto-avatar v-else :name="item.name"/>
</q-avatar>
</q-item-section>
<q-item-section>
<q-item-label lines="1" class="text-bold">
{{item.name}}
</q-item-label>
<q-item-label caption lines="2">
<span>{{item.tname}}</span>
<span class="text-blue q-ml-sm">{{item.tusername}}</span>
</q-item-label>
<q-item-label lines="1">
{{ item.company.name +', ' + item.role }}
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</pn-scroll-list>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
defineOptions({ inheritAttrs: false })
const router = useRouter()
const search = ref('')
const persons = [
{id: "p1", name: 'Кирюшкин Андрей', logo: 'https://cdn.quasar.dev/img/avatar4.jpg', tname: 'Kir_AA', tusername: '@kiruha90', role: 'DevOps', company: {id: "com11", name: 'Рога и копытца', logo: '', description: 'Монтажники вывески', qtyPersons: 3, masked: false }},
{id: "p2", name: 'Пупкин Василий Александрович', logo: '', tname: 'Pupkin', tusername: '@super_pupkin', role: 'Руководитель проекта', company: {id: "com11", name: 'Рога и копытца', logo: '', description: 'Монтажники вывески', qtyPersons: 3, masked: false }},
{id: "p3", name: 'Макарова Полина', logo: 'https://cdn.quasar.dev/img/avatar6.jpg', tname: 'Unikorn', tusername: '@unicorn_stars', role: 'Администратор', company: {id: "com21", name: 'ООО "Василек"', logo: '', qtyPersons: 2, masked: true }},
{id: "p4", name: 'Жабов Максим', logo: '', tname: 'Zhaba', tusername: '@Zhabchenko', role: 'Аналитик', company: {id: "com21", name: 'ООО "Василек"', logo: 'https://cdn.quasar.dev/img/avatar4.jpg', qtyPersons: 2, masked: true }},
]
const displayPersons = computed(() => {
if (!search.value || !(search.value && search.value.trim())) return persons
const searchValue = search.value.trim().toLowerCase()
const arrOut = persons
.filter(el =>
el.name.toLowerCase().includes(searchValue) ||
el.tname && el.tname.toLowerCase().includes(searchValue) ||
el.tusername && el.tusername.toLowerCase().includes(searchValue) ||
el.role && el.role.toLowerCase().includes(searchValue) ||
el.company.name && el.company.name.toLowerCase().includes(searchValue)
)
return arrOut
})
async function goPersonInfo () {
console.log('update')
await router.push({ name: 'person_info' })
}
</script>
<style>
</style>

View File

@@ -0,0 +1,297 @@
<template>
<div class="q-pa-none flex column col-grow no-scroll">
<pn-scroll-list>
<template #card-body-header>
<div class="flex row q-ma-md justify-between" v-if="users.length !== 0">
<q-input
v-model="search"
clearable
clear-icon="close"
:placeholder="$t('project_users__search')"
dense
class="col-grow"
>
<template #prepend>
<q-icon name="mdi-magnify" />
</template>
</q-input>
</div>
</template>
<q-list separator v-if="users.length !== 0">
<q-slide-item
v-for="item in displayUsers"
:key="item.id"
@right="handleSlide($event, item.id)"
right-color="red"
>
<template #right>
<q-icon size="lg" name="mdi-account-remove-outline"/>
</template>
<q-item
:key="item.id"
v-ripple
clickable
class="w100"
@click="goUserInfo(item.id)"
>
<q-item-section avatar>
<pn-auto-avatar
:img="item.photo"
:name="item.section1"
/>
</q-item-section>
<q-item-section>
<q-item-label lines="1" class="text-bold" v-if="item.section1">
{{item.section1}}
</q-item-label>
<q-item-label lines="1" caption v-if="item.section3">
{{item.section3}}
</q-item-label>
<q-item-label caption lines="2">
<div class="flex items-center">
<q-icon name="telegram" v-if="item.section2_1 || item.section2_2" class="q-pr-xs" style="color: #27a7e7"/>
<div v-if="item.section2_1" class="q-mr-sm text-bold">{{item.section2_1}}</div>
<div class="text-blue" v-if="item.section2_2">{{'@' + item.section2_2}}</div>
</div>
</q-item-label>
</q-item-section>
</q-item>
</q-slide-item>
</q-list>
<div
v-if="blockedUsers.length!==0"
class="flex column items-center w100"
:class="showBlockedUsers ? 'bg-grey-12' : ''"
>
<q-btn-dropdown
class="w100 fix-rotate-arrow"
color="grey"
flat no-caps
@click="showBlockedUsers=!showBlockedUsers"
dropdown-icon="arrow_drop_up"
>
<template #label>
<span class="text-caption">
{{ !showBlockedUsers
? $t('users__show_archive') + ' (' + blockedUsers.length +')'
: $t('user__hide_archive')
}}
</span>
</template>
</q-btn-dropdown>
<div class="w100" style="overflow: hidden">
<transition
appear
enter-active-class="animated slideInDown"
leave-active-class="animated slideOutUp"
>
<q-list separator v-if="showBlockedUsers" class="w100">
<q-item
v-for = "item in blockedUsers"
:key="item.id"
clickable
v-ripple
@click="handleUnblockUser(item.id)"
class="w100 text-grey"
>
<q-item-section avatar>
<pn-auto-avatar
:img="item.photo"
:name="item.section1"
/>
</q-item-section>
<q-item-section>
<q-item-label lines="1" class="text-bold" v-if="item.section1">
{{item.section1}}
</q-item-label>
<q-item-label lines="1" caption v-if="item.section3">
{{item.section3}}
</q-item-label>
<q-item-label caption lines="2">
<div class="flex items-center">
<q-icon name="telegram" v-if="item.section2_1 || item.section2_2" class="q-pr-xs" style="color: #27a7e7"/>
<div v-if="item.section2_1" class="q-mr-sm text-bold">{{item.section2_1}}</div>
<div class="text-blue" v-if="item.section2_2">{{'@' + item.section2_2}}</div>
</div>
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</transition>
</div>
</div>
<pn-onboard-btn
v-if="users.length === 0 && usersInit"
icon="mdi-account-outline"
:message1="$t('project_users__onboard_msg1')"
:message2="$t('project_users__onboard_msg2')"
noBtn
/>
<div
class="flex column justify-center items-center w100"
style="position: absolute; bottom: 0;"
v-if="!usersInit"
>
<q-linear-progress indeterminate />
</div>
</pn-scroll-list>
</div>
<pn-small-dialog
v-model="showDialogDeleteUser"
icon="mdi-account-remove-outline"
color="negative"
title="user__dialog_delete_title"
message1="user__dialog_delete_message"
mainBtnLabel="user__dialog_delete_ok"
@clickMainBtn="onConfirmDeleteUser()"
@close="onCancel()"
@before-hide="onDialogBeforeHide()"
/>
<pn-small-dialog
v-model="showDialogRestoreUser"
icon="mdi-account-reactivate-outline"
color="green"
title="user__dialog_restore_title"
message1="user__dialog_restore_message"
mainBtnLabel="user__dialog_restore_ok"
@clickMainBtn="onConfirmRestoreUser()"
/>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUsersStore } from 'stores/users'
import { useCompaniesStore } from 'stores/companies'
import type { User } from 'types/Users'
defineOptions({ inheritAttrs: false })
const router = useRouter()
const route = useRoute()
const search = ref('')
const usersStore = useUsersStore()
const companiesStore = useCompaniesStore()
const users = usersStore.getUsers
const usersInit = computed(() => usersStore.isInit)
const deleteUserId = ref<number | undefined>(undefined)
const showDialogDeleteUser = ref<boolean>(false)
const currentSlideEvent = ref<SlideEvent | null>(null)
const closedByUserAction = ref(false)
const mapUsers = computed(() => users.map(el => ({...el, ...userSection(el)})))
interface SlideEvent {
reset: () => void
}
const displayUsersAll = computed(() => {
if (!search.value || !(search.value && search.value.trim())) return mapUsers.value
const searchValue = search.value.trim().toLowerCase()
const arrOut = mapUsers.value
.filter(el =>
el.section1.toLowerCase().includes(searchValue) ||
el.section2_1.toLowerCase().includes(searchValue) ||
el.section2_2.toLowerCase().includes(searchValue) ||
el.section3.toLowerCase().includes(searchValue)
)
return arrOut
})
const displayUsers = computed(() => displayUsersAll.value.filter(el => !el.is_block))
function userSection (user: User) {
const tname = () => {
return user.firstname
? user.lastname
? user.firstname + ' ' + user.lastname
: user.firstname
: user.lastname ?? ''
}
const section1 = user.fullname ?? tname()
const section2_1 = user.fullname ? tname() : ''
const section2_2 = user.username ?? ''
const section3 = (
user.company_id && companiesStore.companyById(user.company_id)
? companiesStore.companyById(user.company_id)?.name + ((user.role || user.department ) ? ' / ' :'')
: '') +
(user.department ? user.department + (user.role ? ' / ' : '') : '') +
(user.role ?? '')
return {
section1,
section2_1, section2_2,
section3
}
}
async function goUserInfo (id: number) {
await router.push({ name: 'user_info', params: { id: route.params.id, userId: id }})
}
function handleSlide (event: SlideEvent, id: number) {
currentSlideEvent.value = event
showDialogDeleteUser.value = true
deleteUserId.value = id
}
function onDialogBeforeHide () {
if (!closedByUserAction.value) {
onCancel()
}
closedByUserAction.value = false
}
function onCancel() {
closedByUserAction.value = true
if (currentSlideEvent.value) {
currentSlideEvent.value.reset()
currentSlideEvent.value = null
}
}
async function onConfirmDeleteUser() {
closedByUserAction.value = true
if (deleteUserId.value) {
await usersStore.blockUser(deleteUserId.value)
}
currentSlideEvent.value = null
}
const showBlockedUsers = ref(false)
const blockedUsers = computed(() => displayUsersAll.value.filter(el => el.is_block))
const unblockUserId = ref<number | undefined> (undefined)
const showDialogRestoreUser = ref(false)
function handleUnblockUser (id: number) {
showDialogRestoreUser.value = true
unblockUserId.value = id
}
async function onConfirmRestoreUser () {
if (unblockUserId.value) await usersStore.restore(unblockUserId.value)
}
watch(showDialogRestoreUser, (newD :boolean) => {
if (!newD) unblockUserId.value = undefined
})
</script>
<style>
</style>

View File

@@ -8,6 +8,7 @@ import {
import routes from './routes'
import { useAuthStore } from 'stores/auth'
import { useProjectsStore } from 'stores/projects'
import { computed } from 'vue'
const tg = window.Telegram?.WebApp
declare module 'vue-router' {
@@ -32,8 +33,9 @@ export default defineRouter(function (/* { store, ssrContext } */) {
history: createHistory(process.env.VUE_ROUTER_BASE)
})
Router.beforeEach((to) => {
Router.beforeEach(async (to) => {
const authStore = useAuthStore()
const projectsStore = useProjectsStore()
if (to.meta.guestOnly && authStore.isAuth) {
return { name: 'projects' }
@@ -45,6 +47,22 @@ export default defineRouter(function (/* { store, ssrContext } */) {
replace: true
}
}
if (to.params.id) {
if (!projectsStore.isInit) await projectsStore.init()
const currentProjectId = computed(() => projectsStore.currentProjectId)
if (to.params.id) {
if (currentProjectId.value !== Number(to.params.id)) {
if (!projectsStore.projectById(Number(to.params.id)))
projectsStore.setCurrentProjectId(Number(to.params.id))
}
} else {
projectsStore.setCurrentProjectId(null)
return { name: 'projects' }
}
}
})
const handleBackButton = async () => {
@@ -67,9 +85,7 @@ export default defineRouter(function (/* { store, ssrContext } */) {
}
}
if (!to.params.id) {
useProjectsStore().setCurrentProjectId(null)
}
useProjectsStore().setCurrentProjectId(to.params.id ? Number(to.params.id) : null)
})
return Router

View File

@@ -1,13 +1,4 @@
import type { RouteRecordRaw, RouteLocationNormalized } from 'vue-router'
import { useProjectsStore } from 'stores/projects'
const setProjectBeforeEnter = (to: RouteLocationNormalized) => {
const id = Number(to.params.id)
const projectsStore = useProjectsStore()
projectsStore.setCurrentProjectId(
!isNaN(id) && projectsStore.projectById(id) ? id : null
)
}
import type { RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
@@ -30,27 +21,24 @@ const routes: RouteRecordRaw[] = [
{
name: 'project_add',
path: '/project/add',
component: () => import('pages/ProjectCreatePage.vue'),
component: () => import('pages/ProjectAddPage.vue'),
meta: { requiresAuth: true }
},
{
name: 'project_info',
path: '/project/:id(\\d+)/info',
component: () => import('pages/ProjectInfoPage.vue'),
beforeEnter: setProjectBeforeEnter,
component: () => import('pages/ProjectEditPage.vue'),
meta: { requiresAuth: true }
},
{
name: 'company_mask',
path: '/project/:id(\\d+)/company-mask',
component: () => import('pages/CompanyMaskPage.vue'),
beforeEnter: setProjectBeforeEnter,
meta: { requiresAuth: true }
},
{
path: '/project/:id(\\d+)',
component: () => import('pages/ProjectPage.vue'),
beforeEnter: setProjectBeforeEnter,
meta: { requiresAuth: true },
children: [
{
@@ -68,9 +56,9 @@ const routes: RouteRecordRaw[] = [
}
},
{
name: 'persons',
path: 'persons',
component: () => import('pages/project-page/ProjectPagePersons.vue'),
name: 'users',
path: 'users',
component: () => import('pages/project-page/ProjectPageUsers.vue'),
meta: {
backRoute: 'projects',
requiresAuth: true
@@ -88,17 +76,21 @@ const routes: RouteRecordRaw[] = [
]
},
{
name: 'company_info',
path: '/project/:id(\\d+)/company/:companyId',
component: () => import('pages/CompanyInfoPage.vue'),
beforeEnter: setProjectBeforeEnter,
name: 'add_company',
path: '/project/:id(\\d+)/add-company',
component: () => import('pages/CompanyAddPage.vue'),
meta: { requiresAuth: true }
},
{
name: 'person_info',
path: '/project/:id(\\d+)/person/:personId',
component: () => import('pages/PersonInfoPage.vue'),
beforeEnter: setProjectBeforeEnter,
name: 'company_info',
path: '/project/:id(\\d+)/company/:companyId',
component: () => import('pages/CompanyEditPage.vue'),
meta: { requiresAuth: true }
},
{
name: 'user_info',
path: '/project/:id(\\d+)/user/:userId',
component: () => import('pages/UserEditPage.vue'),
meta: { requiresAuth: true }
},
{
@@ -110,7 +102,7 @@ const routes: RouteRecordRaw[] = [
{
name: 'create_account',
path: '/create-account',
component: () => import('src/pages/AccountCreatePage.vue'),
component: () => import('pages/AccountCreatePage.vue'),
meta: { guestOnly: true }
},
{
@@ -125,26 +117,42 @@ const routes: RouteRecordRaw[] = [
component: () => import('pages/AccountChangeEmailPage.vue'),
meta: { requiresAuth: true }
},
{
name: 'change_account_auth_method',
path: '/change-auth-method',
component: () => import('pages/AccountChangeAuthMethodPage.vue'),
meta: { requiresAuth: true }
},
{
name: 'subscribe',
path: '/subscribe',
component: () => import('pages/SubscribePage.vue'),
component: () => import('pages/account/SubscribePage.vue'),
meta: { requiresAuth: true }
},
{
name: '3software',
path: '/3software',
component: () => import('pages/account/3Software.vue'),
},
{
name: 'terms',
path: '/terms-of-use',
component: () => import('pages/TermsPage.vue'),
component: () => import('pages/account/TermsPage.vue'),
},
{
name: 'privacy',
path: '/privacy',
component: () => import('pages/PrivacyPage.vue'),
component: () => import('pages/account/PrivacyPage.vue'),
},
{
name: 'support',
path: '/support',
component: () => import('src/pages/account/SupportPage.vue'),
},
{
name: 'your_company',
path: '/your-company',
component: () => import('src/pages/CompanyYourPage.vue'),
component: () => import('pages/CompanyYourPage.vue'),
meta: { requiresAuth: true }
},
{
@@ -159,19 +167,13 @@ const routes: RouteRecordRaw[] = [
{
name: 'recovery_password',
path: '/recovery-password',
component: () => import('src/pages/AccountForgotPasswordPage.vue'),
component: () => import('pages/AccountForgotPasswordPage.vue'),
meta: { guestOnly: true }
},
{
name: 'add_company',
path: '/add-company',
component: () => import('src/pages/CompanyCreatePage.vue'),
meta: { requiresAuth: true }
},
{
name: 'settings',
path: '/settings',
component: () => import('pages/SettingsPage.vue'),
component: () => import('pages/account/SettingsPage.vue'),
meta: { requiresAuth: true }
}
]

View File

@@ -1,38 +1,64 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { api, type ServerError } from 'boot/axios'
import { api } from 'boot/axios'
import type { CompanyParams } from 'types/Company'
import { useProjectsStore } from 'stores/projects'
interface User {
interface Customer {
id: string
email?: string
username: string
first_name?: string
last_name?: string
avatar?: string
company?: CompanyParams
}
const ENDPOINT_MAP = {
register: '/auth/email/register',
forgot: '/auth/forgot',
change: '/auth/change'
changePwd: '/auth/email/change-password',
changeMethod: '/auth/email/upgrade'
} as const
export type AuthFlowType = keyof typeof ENDPOINT_MAP
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const isInitialized = ref(false)
const customer = ref<Customer | null>(null)
const projectsStore = useProjectsStore()
const isAuth = computed(() => !!user.value)
const isAuth = computed(() => !!customer.value)
const initialize = async () => {
try {
const { data } = await api.get('/customer/profile')
user.value = data.data
} catch (error) { if (isAuth.value) console.log(error) }
finally {
isInitialized.value = true
}
customer.value = data.data
const socket = new WebSocket("wss://946gp81j-9000.euw.devtunnels.ms/api/admin")
console.log(socket)
socket.onopen = function() {
console.log("Соединение установлено.");
};
socket.onclose = function(event) {
if (event.wasClean) {
console.log('Соединение закрыто чисто');
} else {
console.log('Обрыв соединения'); // например, "убит" процесс сервера
}
console.log('Код: ' + event.code + ' причина: ' + event.reason);
};
socket.onmessage = function(event) {
console.log("Получены данные " + event.data);
};
socket.onerror = function(error) {
console.log("Ошибка " + error.message);
};
} catch (error) {
if (isAuth.value) console.log(error)
}
}
const loginWithCredentials = async (email: string, password: string) => {
@@ -42,18 +68,22 @@ export const useAuthStore = defineStore('auth', () => {
const loginWithTelegram = async (initData: string) => {
await api.post('/auth/telegram?'+ initData, {}, { withCredentials: true })
console.log(initData)
await initialize()
}
const logout = async () => {
await api.get('/auth/logout', {})
user.value = null
isInitialized.value = false
customer.value = null
projectsStore.reset()
}
const initRegistration = async (flowType: AuthFlowType, email: string) => {
await api.post(ENDPOINT_MAP[flowType], { email })
if (flowType !== 'changePwd')
await api.post(ENDPOINT_MAP[flowType], { email })
else
{console.log(222)
await api.post(ENDPOINT_MAP[flowType])
}
}
const confirmCode = async (flowType: AuthFlowType, email: string, code: string) => {
@@ -69,16 +99,49 @@ export const useAuthStore = defineStore('auth', () => {
await api.post(ENDPOINT_MAP[flowType], { email, code, password })
}
//change email of account
const getCodeCurrentEmail = async () => {
await api.post('/auth/email/change-email')
}
const confirmCurrentEmailCode = async (code: string) => {
await api.post('/auth/email/change-email', { code })
}
const getCodeNewEmail = async (code: string, email: string) => {
await api.post('/auth/email/change-email', { code, email })
}
const confirmNewEmailCode = async (code: string, code2: string, email: string) => {
await api.post('/auth/email/change-email', { code, code2, email })
}
const setNewEmailPassword = async (code: string, code2: string, email: string, password: string) => {
await api.post('/auth/email/change-email', { code, code2, email, password })
}
// user data company
const updateMyCompany = async (companyData: CompanyParams) => {
const response = await api.put('/customer/profile', { company: companyData })
console.log(response)
if (response.status === 200 && customer.value) customer.value.company = companyData
}
return {
user,
customer,
isAuth,
isInitialized,
initialize,
loginWithCredentials,
loginWithTelegram,
logout,
initRegistration,
confirmCode,
setPassword
setPassword,
updateMyCompany,
getCodeCurrentEmail,
confirmCurrentEmailCode,
getCodeNewEmail,
confirmNewEmailCode,
setNewEmailPassword
}
})

View File

@@ -1,36 +1,55 @@
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import type { Chat } from '../types'
import { api } from 'boot/axios'
import type { Chat } from 'types/Chat'
import { useProjectsStore } from 'stores/projects'
export const useChatsStore = defineStore('chats', () => {
const projectsStore = useProjectsStore()
const chats = ref<Chat[]>([])
const isInit = ref<boolean>(false)
chats.value.push(
{id: 11, name: 'Аудит ИБ', logo: 'https://cdn.quasar.dev/img/avatar5.jpg', persons: 8, owner_id: 111},
{id: 12, name: 'Разработка BI', logo: '', persons: 2, owner_id: 111},
{id: 3, name: '-Обсуждение дашбордов', logo: '', description: 'Какой-то кратенькое описание', persons: 4, owner_id: 112},
{id: 4, name: 'Расстрел нерадивых', logo: '', persons: 3, owner_id: 113},
{id: 15, name: 'фыфыы Расстрел нерадивых', logo: '', persons: 5, owner_id: 112},
{id: 16, name: 'Разработка BI', logo: '', persons: 6, owner_id: 114},
{id: 17, name: '-Обсуждение дашбордов', logo: '', description: 'Какой-то кратенькое описание', persons: 58, owner_id: 111},
{id: 18, name: 'Расстрел нерадивых', logo: '', persons: 3, owner_id: 112},
{id: 19, name: 'фыфыы Расстрел нерадивых', logo: '', persons: 11, owner_id: 113},
{id: 20, name: 'Разработка BI', logo: '', persons: 18, owner_id: 114},
{id: 113, name: '-Обсуждение дашбордов', logo: '', description: 'Какой-то кратенькое описание', persons: 11, owner_id: 115},
{id: 124, name: 'Расстрел нерадивых', logo: '', persons: 12, owner_id: 113},
{id: 217, name: 'фыфыы Расстрел нерадивых', logo: '', persons: 5, owner_id: 112},
{id: 2113, name: '-Обсуждение дашбордов', logo: '', description: 'Какой-то кратенькое описание', persons: 4, owner_id: 111},
{id: 124, name: 'Расстрел нерадивых', logo: '', persons: 3, owner_id: 112},
{id: 2117, name: 'фыфыы Расстрел нерадивых', logo: '', persons: 5, owner_id: 111},
)
const currentProjectId = computed(() => projectsStore.currentProjectId)
function chatById (id :number) {
return chats.value.find(el =>el.id === id)
async function init () {
const response = await api.get('/project/' + currentProjectId.value + '/chat')
const chatsAPI = response.data.data
chats.value.push(...chatsAPI)
isInit.value = true
}
function deleteChat (id :number) {
const idx = chats.value.findIndex(item => item.id === id)
function reset () {
chats.value = []
isInit.value = false
}
async function unlink (chatId: number) {
const response = await api.get('/project/' + currentProjectId.value + '/chat/' + chatId)
const chatAPIid = response.data.data.id
const idx = chats.value.findIndex(item => item.id === chatAPIid)
chats.value.splice(idx, 1)
}
return { chats, deleteChat, chatById }
async function getKey () {
const response = await api.get('/project/' + currentProjectId.value + '/token')
const key = <string>response.data.data
return key
}
const getChats = computed(() => chats.value)
function chatById (id: number) {
return chats.value.find(el =>el.id === id)
}
return {
chats,
isInit,
init,
reset,
unlink,
getKey,
getChats,
chatById
}
})

View File

@@ -1,37 +1,94 @@
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import type { Company, CompanyParams } from '../types'
import { api } from 'boot/axios'
import type { Company, CompanyParams, CompanyMask } from 'types/Company'
import { useProjectsStore } from 'stores/projects'
export const useCompaniesStore = defineStore('companies', () => {
const projectsStore = useProjectsStore()
const companies = ref<Company[]>([])
const companiesMask = ref<CompanyMask[]>([])
companies.value.push(
{id: 11, project_id: 11, name: 'Рога и копытца', logo: '', description: 'Монтажники вывески' },
{id: 21, project_id: 12, name: 'ООО "Василек33"', logo: '' },
{id: 13, project_id: 13, name: 'Откат и деньги', logo: '', description: 'Договариваются с администрацией' },
)
const isInit = ref<boolean>(false)
const currentProjectId = computed(() => projectsStore.currentProjectId)
function companyById (id :number) {
async function init () {
const { data }= await api.get('/project/' + currentProjectId.value + '/company')
const companiesAPI = data.data
companies.value.push(...(companiesAPI.sort((a: Company, b: Company) => (a.id - b.id))))
companiesMask.value = [
{ "company_id": 11, "company_list": [ 9, 12 ] },
{ "company_id": 9, "company_list": [ 11, 12 ] },
{ "company_id": 12, "company_list": [ 9, 10, 13 ] }
]
// await getCompanyMasked()
isInit.value = true
}
function reset () {
companies.value = []
companiesMask.value = []
isInit.value = false
}
async function add (companyData: CompanyParams) {
const { data } = await api.post('/project/' + currentProjectId.value + '/company', companyData)
const newCompanyAPI = data.data
companies.value.push(newCompanyAPI)
return newCompanyAPI
}
async function update (companyId: number, companyData: CompanyParams) {
const { data } = await api.put('/project/' + currentProjectId.value + '/company/' + companyId, companyData)
const companyAPI = data.data
const idx = companies.value.findIndex(item => item.id === companyAPI.id)
if (companies.value[idx]) Object.assign(companies.value[idx], companyAPI)
}
async function remove (companyId: number) {
const { data } = await api.delete('/project/' + currentProjectId.value + '/company/' + companyId)
const companyAPIid = data.data.id
const idx = companies.value.findIndex(item => item.id === companyAPIid)
companies.value.splice(idx, 1)
await getCompanyMasked()
}
function companyById (id: number) {
return companies.value.find(el =>el.id === id)
}
function addCompany (company: CompanyParams) {
companies.value.push({
id: Date.now(),
project_id: Date.now() * 1000,
...company
})
async function getCompanyMasked () {
const { data } = await api.get('/project/' + currentProjectId.value + '/company/mapping')
const companiesMaskAPI = data.data
companiesMask.value = companiesMaskAPI
}
function updateCompany (id :number, company: CompanyParams) {
const idx = companies.value.findIndex(item => item.id === id)
Object.assign(companies.value[idx] || {}, company)
function checkCompanyMasked (id: number) {
return companiesMask.value.some(el => el.company_id === id)
}
function deleteCompany (id :number) {
const idx = companies.value.findIndex(item => item.id === id)
companies.value.splice(idx, 1)
async function updateMask (mask: CompanyMask[]) {
const { data } = await api.post('/project/' + currentProjectId.value + '/company/mapping', mask)
const maskAPI = data.data
companiesMask.value = maskAPI
}
return { companies, addCompany, updateCompany, deleteCompany, companyById }
const getCompanies = computed(() => companies.value)
return {
companies,
companiesMask,
isInit,
init,
reset,
add,
update,
remove,
companyById,
checkCompanyMasked,
updateMask,
getCompanies
}
})

View File

@@ -1,72 +1,110 @@
import { ref } from 'vue'
import { ref, watch, computed } from 'vue'
import { defineStore } from 'pinia'
import { api } from 'boot/axios'
import { clientConverter, serverConverter } from 'types/booleanConvertor'
import type { Project, ProjectParams, RawProject, RawProjectParams } from 'types/Project'
import { useChatsStore } from 'stores/chats'
import { useUsersStore } from 'stores/users'
import { useCompaniesStore } from 'stores/companies'
import type { Project, ProjectParams } from 'types/Project'
export const useProjectsStore = defineStore('projects', () => {
const projects = ref<Project[]>([])
const currentProjectId = ref<number | null>(null)
const isInit = ref<boolean>(false)
const chatsStore = useChatsStore()
const usersStore = useUsersStore()
const companiesStore = useCompaniesStore()
async function init() {
const response = await api.get('/project')
const projectsAPI = response.data.data.map((el: RawProject) => clientConverter<Project, RawProject>(el, ['is_logo_bg', 'is_archived']))
async function init () {
const { data } = await api.get('/project')
const projectsAPI = data.data
projects.value.push(...projectsAPI)
isInit.value = true
}
function projectById (id :number) {
return projects.value.find(el =>el.id === id)
function reset () {
projects.value = []
isInit.value = false
currentProjectId.value = null
}
async function add (projectData: ProjectParams) {
const response = await api.post('/project', serverConverter<ProjectParams, RawProjectParams>(projectData, ['is_logo_bg']))
const newProject = clientConverter<Project, RawProject>(response.data.data, ['is_logo_bg', 'is_archived'])
projects.value.push(newProject)
return newProject
const { data }= await api.post('/project', projectData)
const newProjectAPI = data.data
projects.value.push(newProjectAPI)
return newProjectAPI
}
async function update (id :number, projectData :ProjectParams) {
const response = await api.put('/project/'+ id, serverConverter<ProjectParams, RawProjectParams>(projectData, ['is_logo_bg']))
const projectAPI = clientConverter<Project, RawProject>(response.data.data, ['is_logo_bg', 'is_archived'])
async function update (id: number, projectData: ProjectParams) {
const { data } = await api.put('/project/'+ id, projectData)
const projectAPI =data.data
const idx = projects.value.findIndex(item => item.id === id)
if (projects.value[idx]) Object.assign(projects.value[idx], projectAPI)
}
async function archive (id :number) {
const response = await api.put('/project/'+ id + '/archive')
const projectAPI = clientConverter<Project, RawProject>(response.data.data, ['is_logo_bg', 'is_archived'])
async function archive (id: number) {
const { data } = await api.put('/project/'+ id + '/archive')
const projectAPI = data.data
const idx = projects.value.findIndex(item => item.id === projectAPI.id)
if (projects.value[idx] && projectAPI.is_archived) Object.assign(projects.value[idx], projectAPI)
}
async function restore (id :number) {
const response = await api.put('/project/'+ id + '/restore')
const projectAPI = clientConverter<Project, RawProject>(response.data.data, ['is_logo_bg', 'is_archived'])
async function restore (id: number) {
const { data } = await api.put('/project/'+ id + '/restore')
const projectAPI = data.data
const idx = projects.value.findIndex(item => item.id === projectAPI.id)
if (projects.value[idx] && !projectAPI.is_archived) Object.assign(projects.value[idx], projectAPI)
}
function setCurrentProjectId (id: number | null) {
currentProjectId.value = id
function projectById (id: number) {
return projects.value.find(el =>el.id === id)
}
function getCurrentProject () {
return currentProjectId.value ? projectById(currentProjectId.value) : <Project>{}
function setCurrentProjectId (id: number | null | undefined) {
currentProjectId.value = (typeof id ==='number') ? id : null
}
async function initStores () {
resetStores()
await Promise.all([
chatsStore.init(),
usersStore.init(),
companiesStore.init()
])
}
function resetStores () {
chatsStore.reset()
usersStore.reset()
companiesStore.reset()
}
const getProjects = computed(() => projects.value)
watch (currentProjectId, async (newId) => {
if (newId) {
await initStores()
} else {
resetStores()
}
}, { flush: 'sync' })
return {
init,
reset,
isInit,
projects,
currentProjectId,
projectById,
add,
update,
archive,
restore,
projectById,
setCurrentProjectId,
getCurrentProject
initStores,
resetStores,
getProjects
}
})

View File

@@ -1,36 +1,62 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { ref, watch, computed, inject } from 'vue'
import { api } from 'boot/axios'
import { useAuthStore } from 'stores/auth'
import { useI18n } from 'vue-i18n'
import type { WebApp } from '@twa-dev/types'
interface AppSettings {
fontSize?: number
locale?: string
fontSize: number
locale: string
}
const defaultFontSize = 16
const minFontSize = 12
const maxFontSize = 20
const minFontSize = 10
const maxFontSize = 22
const fontSizeStep = 2
const defaultSettings: AppSettings = {
fontSize: defaultFontSize,
locale: 'en-US'
}
export const useSettingsStore = defineStore('settings', () => {
const { locale: i18nLocale } = useI18n()
const settings = ref<AppSettings>({
fontSize: defaultFontSize,
locale: i18nLocale.value // Инициализация из i18n
})
// State
const authStore = useAuthStore()
const settings = ref<AppSettings>({ ...defaultSettings })
const tg = inject<WebApp>('tg')
const isInit = ref(false)
// Getters
const currentFontSize = computed(() => settings.value.fontSize ?? defaultFontSize)
const currentFontSize = computed(() => settings.value?.fontSize ?? defaultFontSize)
const canIncrease = computed(() => currentFontSize.value < maxFontSize)
const canDecrease = computed(() => currentFontSize.value > minFontSize)
const supportLocale = [
{ value: 'en-US', label: 'English' },
{ value: 'ru-RU', label: 'Русский' }
]
const detectLocale = (): string => {
const localeMap = {
ru: 'ru-RU',
en: 'en-US'
} as const satisfies Record<string, string>
// Helpers
const clampFontSize = (size: number) =>
Math.max(minFontSize, Math.min(size, maxFontSize))
type LocaleCode = keyof typeof localeMap
const normLocale = (locale?: string): string | undefined => {
if (!locale) return undefined
const code = locale.split('-')[0] as LocaleCode
return localeMap[code] ?? undefined
}
const tgLang = tg?.initDataUnsafe?.user?.language_code
const normalizedTgLang = normLocale(tgLang)
return normalizedTgLang ?? normLocale(navigator.language) ?? 'en-US'
}
const updateCssVariable = () => {
document.documentElement.style.setProperty(
@@ -45,26 +71,36 @@ export const useSettingsStore = defineStore('settings', () => {
}
}
const saveSettings = async () => {
await api.put('/custome/settings', settings.value)
const init = async () => {
if (authStore.isAuth) {
try {
const response = await api.get('/customer/settings')
settings.value = {
fontSize: response.data.data.settings.fontSize || defaultSettings.fontSize,
locale: response.data.data.settings.locale || detectLocale()
}
} catch {
settings.value.locale = detectLocale()
}
} else {
settings.value = {
...defaultSettings,
locale: detectLocale()
}
}
updateCssVariable()
applyLocale()
isInit.value = true
}
// Actions
const init = async () => {
if (isInit.value) return
try {
const { data } = await api.get<AppSettings>('/customer/settings')
settings.value = {
...settings.value,
...data
}
updateCssVariable()
applyLocale()
} finally {
isInit.value = true
}
const updateLocale = async (newLocale: string) => {
settings.value.locale = newLocale
applyLocale()
await saveSettings()
}
const saveSettings = async () => {
await api.put('/customer/settings', { settings: settings.value })
}
const updateSettings = async (newSettings: Partial<AppSettings>) => {
@@ -74,11 +110,8 @@ export const useSettingsStore = defineStore('settings', () => {
await saveSettings()
}
const updateLocale = async (newLocale: string) => {
settings.value.locale = newLocale
applyLocale()
await saveSettings()
}
const clampFontSize = (size: number) =>
Math.max(minFontSize, Math.min(size, maxFontSize))
const increaseFontSize = async () => {
const newSize = clampFontSize(currentFontSize.value + fontSizeStep)
@@ -90,8 +123,13 @@ export const useSettingsStore = defineStore('settings', () => {
await updateSettings({ fontSize: newSize })
}
watch(() => authStore.isAuth, (newVal) => {
if (newVal !== undefined) void init()
}, { immediate: true })
return {
settings,
supportLocale,
isInit,
currentFontSize,
canIncrease,

66
src/stores/users.ts Normal file
View File

@@ -0,0 +1,66 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { api } from 'boot/axios'
import type { User, UserParams } from 'types/Users'
import { useProjectsStore } from 'stores/projects'
export const useUsersStore = defineStore('users', () => {
const projectsStore = useProjectsStore()
const users = ref<User[]>([])
const isInit = ref<boolean>(false)
const currentProjectId = computed(() => projectsStore.currentProjectId)
async function init () {
const { data } = await api.get('/project/' + currentProjectId.value + '/user')
const usersAPI = data.data
users.value.push(...usersAPI)
isInit.value = true
}
function reset () {
users.value = []
isInit.value = false
}
async function update (userId: number, userData: UserParams) {
const { data } = await api.put('/project/' + currentProjectId.value + '/user/' + userId, userData)
console.log('update', data.data)
const userAPI = data.data
const idx = users.value.findIndex(item => item.id === userAPI.id)
if (users.value[idx]) Object.assign(users.value[idx], userAPI)
}
async function blockUser (userId: number) {
const { data } = await api.put('/project/' + currentProjectId.value + '/user/' + userId, { is_blocked: true })
const userAPI = data.data
const idx = users.value.findIndex(item => item.id === userAPI.id)
if (users.value[idx]) Object.assign(users.value[idx], userAPI)
}
function userById (id: number) {
return users.value.find(el =>el.id === id)
}
function userNameById (id: number) {
const user = userById(id)
return user?.fullname
|| [user?.firstname, user?.lastname].filter(Boolean).join(' ').trim()
|| user?.username
|| '---'
}
const getUsers = computed(() => users.value)
return {
users,
isInit,
init,
reset,
update,
blockUser,
userById,
userNameById,
getUsers
}
})

View File

@@ -1,56 +0,0 @@
import type { WebApp } from "@twa-dev/types"
declare global {
interface Window {
Telegram: {
WebApp: WebApp
}
}
}
interface ProjectParams {
name: string
description?: string
logo?: string
is_logo_bg: boolean
}
interface Project extends ProjectParams {
id: number
is_archived: boolean
chat_count: number
user_count: number
}
interface Chat {
id: number
// project_id: number
name: string
description?: string
logo?: string
persons: number
owner_id: number
}
interface CompanyParams {
name: string
description?: string
address?: string
site?: string
phone?: string
email?: string
logo?: string
}
interface Company extends CompanyParams {
id: number
project_id: number
}
export type {
Project,
ProjectParams,
Chat,
Company,
CompanyParams
}

View File

@@ -7,9 +7,10 @@ interface Chat {
bot_can_ban: boolean
user_count: number
last_update_time: number
description?: string
logo?: string
owner_id: number
description: string | null
logo: string | null
owner_id?: number
invite_link: string
[key: string]: unknown
}

View File

@@ -1,21 +1,28 @@
interface User {
interface CompanyParams {
name: string
description: string
address: string
site: string
phone: string
email: string
logo: string
[key: string]: string | number
}
interface Company extends CompanyParams {
id: number
project_id: number
telegram_id: number
firstname?: string
lastname?: string
username?: string
photo: string
phone: string
settings?: {
language?: string
fontSize?: number
timezone: number
}
[key: string]: string | number
}
interface CompanyMask {
company_id: number
company_list: number[]
[key: string]: unknown
}
export type {
Company,
CompanyParams
CompanyParams,
CompanyMask
}

View File

@@ -1,7 +1,7 @@
interface ProjectParams {
name: string
description?: string
logo?: string
description: string
logo: string
is_logo_bg: boolean
[key: string]: unknown
}
@@ -14,25 +14,7 @@ interface Project extends ProjectParams {
[key: string]: unknown
}
interface RawProjectParams {
name: string
description?: string
logo?: string
is_logo_bg: number
[key: string]: unknown
}
interface RawProject extends RawProjectParams{
id: number
is_archived: number
chat_count: number
user_count: number
[key: string]: unknown
}
export type {
Project,
ProjectParams,
RawProject,
RawProjectParams
ProjectParams
}

View File

@@ -1,20 +1,26 @@
interface User {
interface UserParams {
fullname: string
department: string
role: string
phone: string
email: string
is_blocked: boolean
company_id: number | null
}
interface User extends UserParams {
id: number
project_id: number
telegram_id: number
firstname?: string
lastname?: string
username?: string
photo: string
phone: string
settings?: {
language?: string
fontSize?: number
timezone: number
}
firstname: string | null
lastname: string | null
username: string | null
photo: string | null
is_leave: boolean
[key: string]: unknown
}
export type {
User
User,
UserParams
}

View File

@@ -1,38 +0,0 @@
export function clientConverter<TClient, TServer extends Record<string, unknown>>(
data: TServer | null | undefined,
booleanFields: Array<keyof TClient>
): TClient {
if (!data) {
throw new Error("Invalid data: null or undefined");
}
return Object.entries(data).reduce((acc, [key, value]) => {
const typedKey = key as keyof TClient;
return {
...acc,
[typedKey]: booleanFields.includes(typedKey)
? Boolean(value)
: value
};
}, {} as TClient);
}
export function serverConverter<TClient, TServer extends Record<string, unknown>>(
data: TClient | null | undefined,
booleanFields: Array<keyof TClient>
): TServer {
if (!data) {
throw new Error("Invalid data: null or undefined");
}
return Object.entries(data).reduce((acc, [key, value]) => {
const typedKey = key as keyof TClient;
return {
...acc,
[key]: booleanFields.includes(typedKey)
? value ? 1 : 0
: value
};
}, {} as TServer);
}