This commit is contained in:
2025-07-16 21:52:57 +03:00
parent b51a472738
commit 3e43efc70d
31 changed files with 801 additions and 391 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,30 +0,0 @@
Software SURVy (the “Tool”) to facilitate your selection CCTV products based on your needs.
Before you start using the Tool, we ask you to carefully read through the below terms of use ("Terms of Use") and make sure that you have understood them prior to any use of the Tool. By downloading, installing, activating, accessing or otherwise using the Tool, you agree to be bound by the terms and conditions of the Terms of Use. If you are executing the Terms of Use on behalf of an entity, you represent that you have authority to legally bind that entity. If you do not have such authority or you do not agree to the terms and conditions of the Terms of Use, neither you nor the entity is permitted to and must not download, install, access or use the Tool.
The Tool developer ("Developer") - Individual Entrepreneur/Sole Proprietor Martyshkin Alexey Alexandrovich (Russia, Moscow, PSRNSP/Primary State Registration Number of the Sole Proprietor 318774600262084, ITN/Individual Taxpayer Number 366316608346). All rights to software SURVy belong to Developer.
By "Developer Representatives" in this document means to the circle of persons involved by Developer for development and support of the Tool.
TERMS OF USE
The Tool is provided for guidance only. The estimates, recommendations and calculation results (collectively the "Deliverables") produced by the use of the Tool are only orientational.
DEVELOPER AND/OR ITS REPRESENTATIVES WILL IN NO EVENT BE RESPONSIBLE FOR DAMAGES OF ANY NATURE WHATSOEVER RESULTING FROM THE USE OF, OR RELIANCE UPON, THE TOOL AND THE DELIVERABLES.
The Tool for authorization use HttpOnly cookies-files. Also in local memory of your web browser (known as "local storage") stored language settings.
Consent to Use of Data
Your projects will be stored on Developer servers. By agreeing to these terms of use, you accept that your project data will be used by Developer and/or its representatives for internal purposes (such as subsequent improving the Tool).
Rest assured that will Developer and/or its representatives intentionally not share or transfer about your projects to anyone.
Developer can use email address specified in the account to send notifications about changes to these Terms, request feedback on the use of the Tool and provide technical support.
Restrictions
You may not (and you may not allow anyone else to):
(i) reverse engineer, decompile, disassemble or otherwise attempt to derive access to the source code of the Tool, or any part thereof,
(ii) misuse the Tool by interfering with its normal operation, or attempting to access it using a method other than through the interfaces and instructions that provide,
(iii) submit or upload any data or content that is illegal or violates these Terms of Use,
(iv) use the Tool on the territory of Russian Federation for objects of state-owned enterprises/institutions (including companies with state participation), as well as for those objects whose data can be identified as classified information.
The data submitted by you by use of the Tool may not exceed 1 GB.
Developer reserves the right to delete your data (account and projects) in its sole discretion.
You undertake to indemnify and hold Developer and/or its representatives harmless for all damages and losses incurred due to any breach of the undertakings in this Section.
DISCLAIMER
THE TOOL AND ANY DELIVERABLES ARE DELIVERED FREE OF CHARGE AND 'AS IS' WITHOUT WARRANTY OF ANY KIND. THE ENTIRE RISK AS TO THE RESULTS AND PERFORMANCE OF THE TOOL AND THE DELIVERABLES IS ASSUMED BY YOU/THE USER. DEVELOPER DISCLAIMS ALL WARRANTIES, WHETHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT, OR ANY WARRANTY ARISING OUT OF ANY PROPOSAL, SPECIFICATION OR SAMPLE WITH RESPECT TO THE TOOL AND DELIVERABLES. DEVELOPER AND/OR ITS REPRESENTATIVES SHALL NOT BE LIABLE FOR LOSS OF DATA, LOSS OF PRODUCTION, LOSS OF PROFIT, LOSS OF USE, LOSS OF CONTRACTS OR FOR ANY OTHER CONSEQUENTIAL, ECONOMIC OR INDIRECT LOSS WHATSOEVER IN RESPECT OF DELIVERY, USE OR DISPOSITION OF THE TOOL AND THE DELIVERABLES. DEVELOPER AND/OR ITS REPRESENTATIVES TOTAL LIABILITY FOR ANY AND ALL CLAIMS, DAMAGES AND LIABILITY IN ACCORDANCE WITH THE DELIVERY AND USE OF THE TOOL AND THE DELIVERABLES SHALL NOT EXCEED THE PRICE PAID FOR THE TOOL.
Governing law and dispute resolution
These Terms of Use shall be deemed performed in and shall be construed and governed by the laws of Russian Federation.

1
public/telegram_star.svg Normal file
View File

@@ -0,0 +1 @@
<svg width="14" height="15" viewBox="0 0 14 15" fill="000" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M6.63869 12.1902L3.50621 14.1092C3.18049 14.3087 2.75468 14.2064 2.55515 13.8807C2.45769 13.7216 2.42864 13.5299 2.47457 13.3491L2.95948 11.4405C3.13452 10.7515 3.60599 10.1756 4.24682 9.86791L7.6642 8.22716C7.82352 8.15067 7.89067 7.95951 7.81418 7.80019C7.75223 7.67116 7.61214 7.59896 7.47111 7.62338L3.66713 8.28194C2.89387 8.41581 2.1009 8.20228 1.49941 7.69823L0.297703 6.69116C0.00493565 6.44581 -0.0335059 6.00958 0.211842 5.71682C0.33117 5.57442 0.502766 5.48602 0.687982 5.47153L4.35956 5.18419C4.61895 5.16389 4.845 4.99974 4.94458 4.75937L6.36101 1.3402C6.5072 0.987302 6.91179 0.819734 7.26469 0.965925C7.43413 1.03612 7.56876 1.17075 7.63896 1.3402L9.05539 4.75937C9.15496 4.99974 9.38101 5.16389 9.6404 5.18419L13.3322 5.47311C13.713 5.50291 13.9975 5.83578 13.9677 6.2166C13.9534 6.39979 13.8667 6.56975 13.7269 6.68896L10.9114 9.08928C10.7131 9.25826 10.6267 9.52425 10.6876 9.77748L11.5532 13.3733C11.6426 13.7447 11.414 14.1182 11.0427 14.2076C10.8642 14.2506 10.676 14.2208 10.5195 14.1249L7.36128 12.1902C7.13956 12.0544 6.8604 12.0544 6.63869 12.1902Z" fill="000"></path></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,5 +1,6 @@
import { defineBoot } from '#q-app/wrappers' import { defineBoot } from '#q-app/wrappers'
import axios, { type AxiosError } from 'axios' import axios, { type AxiosError } from 'axios'
import { Notify } from 'quasar'
class ServerError extends Error { class ServerError extends Error {
constructor( constructor(
@@ -19,6 +20,7 @@ const api = axios.create({
api.interceptors.response.use( api.interceptors.response.use(
response => response, response => response,
async (error: AxiosError<{ error?: { code: string; message: string } }>) => { async (error: AxiosError<{ error?: { code: string; message: string } }>) => {
console.log(error)
const errorData = error.response?.data?.error || { const errorData = error.response?.data?.error || {
code: 'ZERO', code: 'ZERO',
message: error.message || 'Unknown error' message: error.message || 'Unknown error'
@@ -29,6 +31,12 @@ api.interceptors.response.use(
errorData.message errorData.message
) )
Notify.create({
type: 'negative',
message: errorData.code + ': ' + errorData.message,
icon: 'mdi-alert-outline',
position: 'bottom'
})
return Promise.reject(serverError) return Promise.reject(serverError)
} }
) )

View File

@@ -1,96 +0,0 @@
<template>
<div class="q-pt-md" v-if="mapUsers.length !==0 ">
<span class="q-pl-md text-h6">
{{ $t('company_info__users') }}
</span>
<q-list separator>
<q-item
v-for="item in mapUsers"
:key="item.id"
v-ripple
clickable
@click="goPersonInfo(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 caption lines="2">
<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" v-if="item.section3">
{{item.section3}}
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</div>
</template>
<script setup lang="ts">
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 currentCompanyId = Number(route.params.companyId)
const users = usersStore.users
const mapUsers = users
.filter(el => el.company_id === currentCompanyId)
.map(el => ({...el, ...userSection(el)}))
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>
<style scoped>
</style>

View File

@@ -34,37 +34,46 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useSettingsStore } from 'stores/settings' 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<{ const props = defineProps<{
type: 'terms_of_use' | 'privacy' type: 'terms_of_use' | 'privacy'
}>() }>()
function parseLocale(locale: string): string { const settingsStore = useSettingsStore()
return locale.split(/[-_]/)[0] ?? '' const fileText = ref<string | null>('')
const isLoading = ref(true)
const DEFAULT_LANG = 'ru'
const baseDocName = props.type === 'terms_of_use'
? 'Terms_of_use'
: 'Privacy'
const parseLocale = (locale: string) => locale.split(/[-_]/)[0] || DEFAULT_LANG
const fetchDocument = async (language: string) => {
try {
const response = await fetch(`/admin/doc/${baseDocName}_${language}.txt`)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return await response.text()
} catch (error) {
console.error(`Failed to load ${language} version:`, error)
return null
}
} }
const baseDocName =
props.type ==='terms_of_use' ? 'Terms_of_use' : 'Privacy'
onMounted(async () => { onMounted(async () => {
const locale = settingsStore.settings.locale
lang.value = parseLocale(locale)
try { try {
const response = await fetch('/admin/doc/' + baseDocName + '_' + lang.value +'.txt') const lang = parseLocale(settingsStore.settings.locale)
if (!response.ok) { fileText.value = await fetchDocument(lang)
throw new Error(`HTTP error! Status: ${response.status}`)
if (!fileText.value && lang !== DEFAULT_LANG) {
fileText.value = await fetchDocument(DEFAULT_LANG)
} }
fileText.value = await response.text() if (!fileText.value) throw new Error('All loading attempts failed')
} catch (err) {
console.error('File load error:', err) } catch (error) {
error.value = true console.error('Document loading failed:', error)
} finally { } finally {
isLoading.value = false isLoading.value = false
} }

View File

@@ -0,0 +1,125 @@
<template>
<q-item
@click="showDialog=true"
clickable
v-ripple
>
<q-item-section avatar>
<q-avatar
rounded
text-color="white"
:icon
:color="iconColor"
size="lg"
/>
</q-item-section>
<q-item-section>
<q-item-label>
{{ $t(title) }}
</q-item-label>
<q-item-label caption>
<slot name="value"/>
</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon name="mdi-chevron-right" color="grey"/>
</q-item-section>
</q-item>
<q-dialog
v-model="showDialog"
maximized
transition-show="slide-up"
transition-hide="slide-down"
position="bottom"
>
<q-card
class="fix-card-width flex column no-scroll no-wrap q-px-none"
style="
border-top-left-radius: var(--top-raduis) !important;
border-top-right-radius: var(--top-raduis) !important;
"
>
<div
ref="cardHeaderRef"
class="flex items-center no-wrap justify-between w100 q-my-none q-pa-md"
>
<div>
<div class="flex column q-mx-xs">
<span class="text-h6 ellipsis">{{ $t(title) }}</span>
<span v-if="caption" class="text-grey text-caption">{{ $t(caption) }}</span>
</div>
</div>
<div class="flex items-center justify-between no-wrap">
<q-btn
icon="mdi-close"
@click="showDialog=false"
flat round
/>
</div>
</div>
<div
ref="cardBodyRef"
class="q-px-none q-ma-none"
>
<pn-shadow-scroll
:hideShadows="false"
:height="bodyHeight"
>
<div ref="cardBodyInnerRef" class="q-px-md q-ma-none">
<q-resize-observer @resize="updateDimensions" />
<slot/>
</div>
</pn-shadow-scroll>
</div>
<div
ref="cardFooterRef"
class="q-pa-md"
>
<slot name="footer"/>
</div>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useSlots } from 'vue'
defineProps<{
title: string
caption?: string
icon: string
iconColor: string
}>()
const showDialog=ref<boolean>(false)
const slots = useSlots()
const cardHeaderRef = ref<HTMLElement | null>(null)
const cardFooterRef = ref<HTMLElement | null>(null)
const cardBodyRef = ref<HTMLElement | null>(null)
const cardBodyInnerRef = ref<HTMLElement | null>(null)
const headerHeight = ref(0)
const footerHeight = ref(0)
const bodyInnerHeight = ref(0)
const bodyHeight = ref(0)
const updateDimensions = () => {
headerHeight.value = cardHeaderRef.value?.offsetHeight || 0
footerHeight.value = cardFooterRef.value?.offsetHeight || 0
bodyInnerHeight.value = cardBodyInnerRef.value?.offsetHeight || 0
bodyHeight.value = window.innerHeight - headerHeight.value - footerHeight.value - 48
}
watch(() => slots.body?.(), updateDimensions, { flush: 'post' })
</script>
<style scoped>
.fix-card-width {
width: var(--body-width) !important;
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<q-list class="q-gutter-y-sm">
<q-btn
v-for="(option, index) in options"
:key="index"
flat
no-caps dense
class="w100"
align="left"
@click="selectItem(option)"
:class="isSelected(option.value) ? 'text-primary' : ''"
>
<q-icon
:name="isSelected(option.value) ? 'mdi-check' : ''"
color="primary"
class="q-pr-sm"
/>
<span
:class="!isSelected(option.value) ? 'text-weight-regular' : ''"
>
{{ $te(option.label) ? $t(option.label) : option.label }}
</span>
</q-btn>
</q-list>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface ListOption {
label: string
value: string | number
}
defineProps<{
options: ListOption[]
}>()
const model = defineModel<string | number | null>({
required: true
})
const isSelected = computed(() => (value: string | number) => {
return model.value === value
})
const selectItem = (option: ListOption) => {
model.value = option.value
}
</script>
<style scoped>
</style>

View File

@@ -1,8 +1,9 @@
<template> <template>
<q-dialog <q-dialog v-model="modelValue">
v-model="modelValue" <q-card
> class="q-pa-none q-ma-none w100 no-scroll"
<q-card class="q-pa-none q-ma-none w100 no-scroll" align="center"> align="center"
>
<q-card-section> <q-card-section>
<q-avatar :color :icon size="60px" font-size="45px" text-color="white"/> <q-avatar :color :icon size="60px" font-size="45px" text-color="white"/>
</q-card-section> </q-card-section>
@@ -12,50 +13,55 @@
style="overflow-wrap: break-word" style="overflow-wrap: break-word"
> >
<div class="text-h6 text-bold "> <div class="text-h6 text-bold ">
{{ $t(title)}} {{ $t(title) }}
</div> </div>
<div v-if="message1"> <div v-if="message1">
{{ $t(message1)}} {{ $t(message1) }}
</div> </div>
<div v-if="message2"> <div v-if="message2">
{{ $t(message2)}} {{ $t(message2) }}
</div> </div>
</q-card-section> </q-card-section>
<q-card-actions align="center" vertical> <q-card-section>
<div class="flex q-mt-lg no-wrap w100 justify-center q-gutter-x-md"> <div class="flex column w100 q-mt-lg q-px-sm">
<q-btn <div class="flex q-gutter-md">
v-if="auxBtnLabel" <div class="col-grow" v-if="auxBtnLabel">
:label="$t(auxBtnLabel)" <q-btn
outline :label="$t(auxBtnLabel)"
color="grey" outline
v-close-popup color="grey"
rounded class="w100"
class="w50" v-close-popup
@click="emit('clickAuxBtn')" rounded
/> @click="emit('clickAuxBtn')"
/>
</div>
<div class="col-grow">
<q-btn
:label="$t(mainBtnLabel)"
:color="color"
class="w100"
v-close-popup
rounded
@click="emit('clickMainBtn')"
/>
</div>
</div>
<q-btn <q-btn
:label="$t(mainBtnLabel)" class="w100 q-mt-md q-mb-sm" flat
:color="color" v-close-popup rounded
v-close-popup @click="emit('close')"
rounded >
:class="auxBtnLabel ? 'w50' : 'w80'" <div class="flex items-center">
@click="emit('clickMainBtn')" <q-icon name="close"/>
/> {{$t('close')}}
</div>
</q-btn>
</div> </div>
<q-btn </q-card-section>
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-card>
</q-dialog> </q-dialog>
</template> </template>
@@ -83,5 +89,5 @@
</script> </script>
<style> <style scoped>
</style> </style>

View File

@@ -0,0 +1,140 @@
<template>
<div class="w100 flex column">
<div class="flex row w100">
<q-input
v-model="search"
clearable
clear-icon="close"
:placeholder="$t('settings__timezone_search')"
dense
class="col-grow"
>
<template #prepend>
<q-icon name="mdi-magnify" />
</template>
</q-input>
</div>
<q-list class="q-gutter-y-sm q-my-sm">
<q-btn
v-for="tz in displayTimeZones"
:key="tz.value"
flat
no-caps
dense
class="w100"
align="left"
@click="selectTz(tz)"
:class="isSelected(tz.value) ? 'text-primary' : ''"
>
<div class="flex w100 no-wrap">
<q-icon
:name="isSelected(tz.value) ? 'mdi-check' : ''"
color="primary"
class="q-pr-sm"
/>
<div class="flex row no-wrap justify-between w100">
<div>
<span class="text-grey text-weight-regular">{{ tz.continent + ' / ' }}</span>
<span>{{ tz.city + ' ' }}</span>
</div>
<span class="text-grey text-weight-regular">{{ tz.offsetDisplay }}</span>
</div>
</div>
</q-btn>
</q-list>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watchEffect } from 'vue'
interface TimeZone {
value: string
continent: string
city: string
offsetDisplay: string
offsetHours: number
}
const props = defineProps<{
locale: string
}>()
const modelValue = defineModel<{ tz: string; offset: number, offsetString: string }>({
required: true
})
const search = ref('')
const timeZonesBase = Intl.supportedValuesOf('timeZone')
const timeZones = ref<TimeZone[]>([])
watchEffect(() => {
const date = Date.now()
timeZones.value = timeZonesBase.map((tz) => {
const [continent, ...cityParts] = tz.split('/')
const city = cityParts.join('/').replace(/_/g, ' ')
const formatter = new Intl.DateTimeFormat('en', {
timeZone: tz,
timeZoneName: 'longOffset'
})
const offsetPart = formatter
.formatToParts(date)
.find(part => part.type === 'timeZoneName')?.value || 'UTC'
const cleanOffset = offsetPart.replace(/^GMT|^UTC/, '') || '+00:00'
let sign = 1
let offsetString = cleanOffset
if (cleanOffset.startsWith('-')) {
sign = -1
offsetString = cleanOffset.substring(1)
} else if (cleanOffset.startsWith('+')) {
offsetString = cleanOffset.substring(1)
}
const [hours = '0', minutes = '0'] = offsetString.split(':')
const totalHours = sign * (parseInt(hours) + parseInt(minutes) / 60)
const absHours = Math.abs(parseInt(hours))
const absMinutes = Math.abs(parseInt(minutes))
const displaySign = sign === -1 ? '-' : '+'
const offsetDisplay = `${displaySign}${String(absHours).padStart(2, '0')}:${String(absMinutes).padStart(2, '0')}`
return {
value: tz,
continent: continent?.replace(/_/g, ' ') || '',
city: city,
offsetDisplay,
offsetHours: totalHours
}
})
})
const displayTimeZones = computed(() => {
if (!search.value || !(search.value && search.value.trim())) return timeZones.value
const searchValue = search.value.trim().toLowerCase()
return timeZones.value.filter(
el =>
el.continent.toLowerCase().includes(searchValue) ||
el.city.toLowerCase().includes(searchValue) ||
el.offsetDisplay.includes(searchValue) ||
el.offsetDisplay.replace(':', '').includes(searchValue)
)
})
const isSelected = (value: string) => {
return modelValue.value.tz === value
}
const selectTz = (tz: TimeZone) => {
modelValue.value = { tz: tz.value, offset: tz.offsetHours, offsetString: tz.offsetDisplay }
}
</script>
<style scoped>
</style>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -68,7 +68,6 @@
{ id: 5, name: 'account__company_data', icon: 'mdi-account-group-outline', description: 'account__company_data_description', pathName: 'your_company' }, { 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: 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: 7, name: 'account__support', icon: 'mdi-lifebuoy', description: 'account__support_description', iconColor: 'info', pathName: 'support' },
{ 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: 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' } { id: 10, name: 'account__privacy', icon: 'mdi-lock-outline', description: '', iconColor: 'grey', pathName: 'privacy' }
])) ]))

View File

@@ -1,8 +1,8 @@
<template> <template>
<company-block <company-block
v-model="newCompany" v-model="newCompany"
title="company_create__title_card" title="company_add__title_card"
btnText="company_create__btn" btnText="company_add__btn"
@update = "addCompany" @update = "addCompany"
/> />
</template> </template>

View File

@@ -25,7 +25,7 @@
v-model="searchProject" v-model="searchProject"
clearable clearable
clear-icon="close" clear-icon="close"
:placeholder="$t('project_chats__search')" :placeholder="$t('projects__search')"
dense dense
class="col-grow q-px-md q-py-md" class="col-grow q-px-md q-py-md"
> >
@@ -91,22 +91,22 @@
class="flex column items-center w100" class="flex column items-center w100"
:class="showArchive ? 'bg-grey-12' : ''" :class="showArchive ? 'bg-grey-12' : ''"
> >
<q-btn-dropdown <q-btn
class="w100 fix-rotate-arrow" class="w100 rotate-icon-btn"
color="grey" color="grey"
flat no-caps flat
no-caps
@click="showArchive=!showArchive" @click="showArchive=!showArchive"
dropdown-icon="arrow_drop_up" icon-right="arrow_drop_down"
:class="{ 'rotate-icon': showArchive }"
> >
<template #label> <span class="text-caption">
<span class="text-caption"> {{ !showArchive
{{ !showArchive ? $t('projects__show_archive') + ' (' + archiveProjects.length +')'
? $t('projects__show_archive') + ' (' + archiveProjects.length +')' : $t('projects__hide_archive')
: $t('projects__hide_archive') }}
}} </span>
</span> </q-btn>
</template>
</q-btn-dropdown>
<div class="w100" style="overflow: hidden"> <div class="w100" style="overflow: hidden">
<transition <transition
@@ -186,6 +186,7 @@
color="negative" color="negative"
title="projects__dialog_archive_title" title="projects__dialog_archive_title"
message1="projects__dialog_archive_message" message1="projects__dialog_archive_message"
message2="projects__dialog_archive_message2"
mainBtnLabel="projects__dialog_archive_ok" mainBtnLabel="projects__dialog_archive_ok"
@clickMainBtn="onConfirmArchiveProject()" @clickMainBtn="onConfirmArchiveProject()"
@close="onCancel()" @close="onCancel()"
@@ -198,7 +199,8 @@
color="green" color="green"
title="projects__dialog_restore_title" title="projects__dialog_restore_title"
message1="projects__dialog_restore_message" message1="projects__dialog_restore_message"
mainBtnLabel="projects__dialog_cancel_ok" message2="projects__dialog_restore_message2"
mainBtnLabel="projects__dialog_restore_ok"
@clickMainBtn="restoreFromArchive()" @clickMainBtn="restoreFromArchive()"
/> />
</template> </template>
@@ -319,7 +321,17 @@
flex-wrap: nowrap; flex-wrap: nowrap;
} }
.fix-rotate-arrow :deep(.q-btn-dropdown--simple) { .rotate-icon-btn {
margin-left: 0; transition: transform 0.3s;
}
.rotate-icon-btn.rotate-icon :deep(.q-icon) {
transform: rotate(180deg);
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.5, 1);
}
.rotate-icon-btn :deep(.q-icon) {
transform: rotate(0deg);
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.5, 1);
} }
</style> </style>

View File

@@ -6,75 +6,120 @@
<pn-scroll-list> <pn-scroll-list>
<q-list separator> <q-list separator>
<q-item> <q-item-label header>{{ $t('settings__software_title') }}</q-item-label>
<q-item-section avatar>
<q-avatar color="primary" rounded text-color="white" icon="mdi-translate" size="md" /> <pn-item-btm-dialog
</q-item-section> title="settings__language"
<q-item-section> caption="settings__software_title"
<span>{{ $t('settings__language') }}</span> icon="mdi-translate"
</q-item-section> iconColor="primary"
<q-item-section> >
<q-select <template #value>
class="fix-input-right text-body1" {{ localeOptions.find(el => el.value === locale)?.label }}
v-model="locale" </template>
:options="localeOptions" <pn-list-selector
dense v-model="locale"
borderless :options="localeOptions"
emit-value />
map-options </pn-item-btm-dialog>
hide-bottom-space
/> <pn-item-btm-dialog
</q-item-section> title="settings__font_size"
</q-item> caption="settings__software_title"
<q-item> icon="mdi-format-size"
<q-item-section avatar> iconColor="primary"
<q-avatar color="primary" rounded text-color="white" icon="mdi-format-size" size="md" /> >
</q-item-section> <template #value>
<q-item-section> {{ $t(fontSizeLabel) }}
<span>{{ $t('settings__font_size') }}</span> </template>
</q-item-section> <pn-list-selector
<q-item-section> v-model="fontSize"
<div class="flex justify-end"> :options="fontSizeOptions"
<q-btn />
@click="settingsStore.decreaseFontSize()" </pn-item-btm-dialog>
color="negative" flat
icon="mdi-format-font-size-decrease" <q-item-label class="q-mt-md" header>{{ $t('settings__bot_title') }}</q-item-label>
class="q-pa-sm q-mx-xs"
:disable="!settingsStore.canDecrease" <pn-item-btm-dialog
/> title="settings__language"
<q-btn caption="settings__bot_title"
@click="settingsStore.increaseFontSize()" icon="mdi-translate"
color="positive" flat iconColor="primary"
icon="mdi-format-font-size-increase" >
class="q-pa-sm q-mx-xs" <template #value>
:disable="!settingsStore.canIncrease" {{ localeOptions.find(el => el.value === localeBot)?.label }}
/> </template>
</div> <pn-list-selector
</q-item-section> v-model="localeBot"
</q-item> :options="localeOptions"
/>
</pn-item-btm-dialog>
<pn-item-btm-dialog
title="settings__timezone"
caption="settings__bot_title"
icon="mdi-map-clock-outline"
iconColor="primary"
>
<template #value>
{{ timeZoneBot.tz }}
{{ timeZoneBot.offsetString }}
</template>
<pn-time-zone-selector
v-model="timeZoneBot"
:locale
/>
</pn-item-btm-dialog>
</q-list> </q-list>
</pn-scroll-list> </pn-scroll-list>
</pn-page-card> </pn-page-card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { ref, watch, computed, onMounted } from 'vue'
import { useSettingsStore } from 'stores/settings' import { useSettingsStore } from 'stores/settings'
import pnItemBtmDialog from 'components/pnItemBtmDialog.vue'
import pnListSelector from 'components/pnListSelector.vue'
import pnTimeZoneSelector from 'components/pnTimeZoneSelector.vue'
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
const locale = ref('')
const localeBot = ref('')
const localeOptions = settingsStore.supportLocale const localeOptions = settingsStore.supportLocale
const locale = computed({ watch(locale, async (newValue) => {
get: () => settingsStore.settings.locale, await settingsStore.updateSettings({ locale: newValue })
// eslint-disable-next-line @typescript-eslint/no-misused-promises })
set: (value: string) => settingsStore.updateLocale(value)
watch(localeBot, async (newValue) => {
await settingsStore.updateSettings({ localeBot: newValue })
})
const fontSize = ref(14)
const fontSizeOptions = settingsStore.supportFontSizes
const fontSizeLabel = computed(() =>
fontSizeOptions.find(el => el.value === fontSize.value)?.label ?? ''
)
watch(fontSize, async (newValue) => {
await settingsStore.updateSettings({ fontSize: newValue })
})
const timeZoneBot = ref<{ tz: string, offset: number }>({ tz: '', offset: 1 })
watch(timeZoneBot, async (newValue) => {
if (newValue) await settingsStore.updateSettings({ timeZoneBot: newValue })
})
onMounted(() => {
locale.value = settingsStore.settings.locale
localeBot.value = settingsStore.settings.localeBot
fontSize.value = settingsStore.settings.fontSize
timeZoneBot.value = settingsStore.settings.timeZoneBot
}) })
</script> </script>
<style scoped> <style scoped>
.fix-input-right :deep(.q-field__native) {
justify-content: end;
}
</style> </style>

View File

@@ -8,7 +8,7 @@
v-model="search" v-model="search"
clearable clearable
clear-icon="close" clear-icon="close"
:placeholder="$t('project_chats__search')" :placeholder="$t('chats__search')"
dense dense
class="col-grow" class="col-grow"
v-if="chats.length !== 0" v-if="chats.length !== 0"
@@ -71,8 +71,8 @@
<pn-onboard-btn <pn-onboard-btn
v-if="chats.length === 0 && chatsInit" v-if="chats.length === 0 && chatsInit"
icon="mdi-chat-plus-outline" icon="mdi-chat-plus-outline"
:message1="$t('project_chat__onboard_msg1')" :message1="$t('chats__onboard_msg1')"
:message2="$t('project_chat__onboard_msg2')" :message2="$t('chats__onboard_msg2')"
@click="showOverlay=true; fabState=true" @click="showOverlay=true; fabState=true"
/> />
<div <div
@@ -110,7 +110,7 @@
anchor="center left" self="center end" anchor="center left" self="center end"
style="width: calc(min(100vw, var(--body-width)) - 102px) !important;" style="width: calc(min(100vw, var(--body-width)) - 102px) !important;"
> >
{{ $t('project_chats_disabled_FAB')}} {{ $t('chats_disabled_FAB')}}
</q-tooltip> </q-tooltip>
</template> </template>
<q-fab-action <q-fab-action
@@ -152,9 +152,10 @@
v-model="showDialogDeleteChat" v-model="showDialogDeleteChat"
icon="mdi-link-off" icon="mdi-link-off"
color="negative" color="negative"
title="project_chat__delete_warning" title="chats__dialog_unlink_title"
message1="project_chat__delete_warning_message" message1="chats__dialog_unlink_message"
mainBtnLabel="project_chat__dialog_cancel_ok" message2="chats__dialog_unlink_message2"
mainBtnLabel="chats__dialog_unlink_ok"
@clickMainBtn="onConfirm()" @clickMainBtn="onConfirm()"
@close="onCancel()" @close="onCancel()"
@before-hide="onDialogBeforeHide()" @before-hide="onDialogBeforeHide()"
@@ -190,8 +191,8 @@
const chatsInit = computed(() => chatsStore.isInit) const chatsInit = computed(() => chatsStore.isInit)
const fabMenu = [ const fabMenu = [
{id: 1, icon: 'mdi-chat-plus-outline', name: 'project_chats__attach_chat', description: 'project_chats__attach_chat_description', func: attachChat}, {id: 1, icon: 'mdi-chat-plus-outline', name: 'chats__attach_chat', description: '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: 2, icon: 'mdi-share-outline', name: 'chats__send_chat', description: 'chats__send_chat_description', func: sendChat},
] ]
const displayChats = computed(() => { const displayChats = computed(() => {
@@ -253,7 +254,7 @@
const key = await chatsStore.getKey() const key = await chatsStore.getKey()
const message = urlAdmin + key + urlAdminPermission const message = urlAdmin + key + urlAdminPermission
const tgShareUrl = 'https://t.me/share/url?url=' + const tgShareUrl = 'https://t.me/share/url?url=' +
encodeURIComponent( t('project_chats__send_chat_title')) + encodeURIComponent( t('chats__send_chat_title')) +
'&text=' + `${encodeURIComponent(message)}` '&text=' + `${encodeURIComponent(message)}`
tg.openTelegramLink(tgShareUrl) tg.openTelegramLink(tgShareUrl)
} }

View File

@@ -7,7 +7,7 @@
:color="companies.length <= 2 ? 'grey-6' : 'primary'" :color="companies.length <= 2 ? 'grey-6' : 'primary'"
flat flat
no-caps no-caps
@click="maskCompany()" @click="maskCompany"
:disable="companies.length <= 2" :disable="companies.length <= 2"
class="q-pr-md" class="q-pr-md"
rounded rounded
@@ -16,9 +16,10 @@
left left
size="sm" size="sm"
name="mdi-domino-mask" name="mdi-domino-mask"
class="q-mr-xs"
/> />
<div> <div>
{{ $t('company__mask')}} {{ $t('companies__mask')}}
</div> </div>
</q-btn> </q-btn>
</div> </div>
@@ -53,7 +54,7 @@
<q-item-label lines="1" class="text-caption text-amber-10" v-if="item.id === myCompany?.id"> <q-item-label lines="1" class="text-caption text-amber-10" v-if="item.id === myCompany?.id">
<div class="flex items-center"> <div class="flex items-center">
<q-icon name="star" class="q-pr-xs"/> <q-icon name="star" class="q-pr-xs"/>
{{ $t('company__my_company') }} {{ $t('companies__my_company') }}
</div> </div>
</q-item-label> </q-item-label>
<q-item-label lines="1" class="text-bold">{{ item.name }}</q-item-label> <q-item-label lines="1" class="text-bold">{{ item.name }}</q-item-label>
@@ -93,9 +94,9 @@
<pn-onboard-btn <pn-onboard-btn
v-if="companies.length <= 1 && companiesInit" v-if="companies.length <= 1 && companiesInit"
icon="mdi-account-multiple-plus-outline" icon="mdi-account-multiple-plus-outline"
:message1="$t('company__onboard_msg1')" :message1="$t('companies__onboard_msg1')"
:message2="$t('company__onboard_msg2')" :message2="$t('companies__onboard_msg2')"
@btn-click="createCompany()" @btn-click="createCompany"
/> />
<div <div
class="flex column justify-center items-center w100" class="flex column justify-center items-center w100"
@@ -130,9 +131,10 @@
v-model="showDialogDeleteCompany" v-model="showDialogDeleteCompany"
icon="mdi-account-multiple-minus-outline" icon="mdi-account-multiple-minus-outline"
color="negative" color="negative"
title="company__dialog_delete_title" title="companies__dialog_delete_title"
message1="company__dialog_delete_message" message1="companies__dialog_delete_message"
mainBtnLabel="company__dialog_delete_ok" message2="companies__dialog_delete_message2"
mainBtnLabel="companies__dialog_delete_ok"
@clickMainBtn="onConfirm()" @clickMainBtn="onConfirm()"
@close="onCancel()" @close="onCancel()"
@before-hide="onDialogBeforeHide()" @before-hide="onDialogBeforeHide()"

View File

@@ -7,7 +7,7 @@
v-model="search" v-model="search"
clearable clearable
clear-icon="close" clear-icon="close"
:placeholder="$t('project_users__search')" :placeholder="$t('users__search')"
dense dense
class="col-grow" class="col-grow"
> >
@@ -60,27 +60,92 @@
</q-slide-item> </q-slide-item>
</q-list> </q-list>
<!-- LEAVE USERS SECTION -->
<div
v-if="leaveUsers.length!==0"
class="flex column items-center w100"
:class="showLeaveUsers ? 'bg-grey-12' : ''"
>
<q-btn
class="w100 rotate-icon-btn"
color="grey"
flat
no-caps
@click="showLeaveUsers=!showLeaveUsers"
icon-right="arrow_drop_down"
:class="{ 'rotate-icon': showLeaveUsers }"
>
<span class="text-caption">
{{ !showLeaveUsers
? $t('users__show_left_users') + ' (' + leaveUsers.length +')'
: $t('users__hide_left_users')
}}
</span>
</q-btn>
<div class="w100" style="overflow: hidden">
<transition
appear
enter-active-class="animated slideInDown"
leave-active-class="animated slideOutUp"
>
<q-list separator v-if="showLeaveUsers" class="w100">
<q-item
v-for = "item in leaveUsers"
:key="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>
<!-- END LEAVE USERS SECTION -->
<!-- BLOCKED USERS SECTION -->
<div <div
v-if="blockedUsers.length!==0" v-if="blockedUsers.length!==0"
class="flex column items-center w100" class="flex column items-center w100"
:class="showBlockedUsers ? 'bg-grey-12' : ''" :class="showBlockedUsers ? 'bg-grey-12' : ''"
> >
<q-btn-dropdown <q-btn
class="w100 fix-rotate-arrow" class="w100 rotate-icon-btn"
color="grey" color="grey"
flat no-caps flat
no-caps
@click="showBlockedUsers=!showBlockedUsers" @click="showBlockedUsers=!showBlockedUsers"
dropdown-icon="arrow_drop_up" icon-right="arrow_drop_down"
:class="{ 'rotate-icon': showBlockedUsers }"
> >
<template #label> <span class="text-caption">
<span class="text-caption"> {{ !showBlockedUsers
{{ !showBlockedUsers ? $t('users__show_blocked_users') + ' (' + blockedUsers.length +')'
? $t('users__show_archive') + ' (' + blockedUsers.length +')' : $t('users__hide_blocked_users')
: $t('user__hide_archive') }}
}} </span>
</span> </q-btn>
</template>
</q-btn-dropdown>
<div class="w100" style="overflow: hidden"> <div class="w100" style="overflow: hidden">
<transition <transition
@@ -123,12 +188,13 @@
</transition> </transition>
</div> </div>
</div> </div>
<!-- END BLOCKED USERS SECTION -->
<pn-onboard-btn <pn-onboard-btn
v-if="users.length === 0 && usersInit" v-if="users.length === 0 && usersInit"
icon="mdi-account-outline" icon="mdi-account-outline"
:message1="$t('project_users__onboard_msg1')" :message1="$t('users__onboard_msg1')"
:message2="$t('project_users__onboard_msg2')" :message2="$t('users__onboard_msg2')"
noBtn noBtn
/> />
<div <div
@@ -142,13 +208,14 @@
</div> </div>
<pn-small-dialog <pn-small-dialog
v-model="showDialogDeleteUser" v-model="showDialogBlockUser"
icon="mdi-account-remove-outline" icon="mdi-account-remove-outline"
color="negative" color="negative"
title="user__dialog_delete_title" title="users__dialog_block_title"
message1="user__dialog_delete_message" message1="users__dialog_block_message"
mainBtnLabel="user__dialog_delete_ok" message2="users__dialog_block_message2"
@clickMainBtn="onConfirmDeleteUser()" mainBtnLabel="users__dialog_block_ok"
@clickMainBtn="onConfirmBlockUser()"
@close="onCancel()" @close="onCancel()"
@before-hide="onDialogBeforeHide()" @before-hide="onDialogBeforeHide()"
/> />
@@ -157,9 +224,10 @@
v-model="showDialogRestoreUser" v-model="showDialogRestoreUser"
icon="mdi-account-reactivate-outline" icon="mdi-account-reactivate-outline"
color="green" color="green"
title="user__dialog_restore_title" title="users__dialog_restore_title"
message1="user__dialog_restore_message" message1="users__dialog_restore_message"
mainBtnLabel="user__dialog_restore_ok" message2="users__dialog_restore_message2"
mainBtnLabel="users__dialog_restore_ok"
@clickMainBtn="onConfirmRestoreUser()" @clickMainBtn="onConfirmRestoreUser()"
/> />
@@ -183,12 +251,18 @@
const users = usersStore.getUsers const users = usersStore.getUsers
const usersInit = computed(() => usersStore.isInit) const usersInit = computed(() => usersStore.isInit)
const deleteUserId = ref<number | undefined>(undefined) const blockUserId = ref<number | undefined>(undefined)
const showDialogDeleteUser = ref<boolean>(false) const showDialogBlockUser = ref<boolean>(false)
const currentSlideEvent = ref<SlideEvent | null>(null) const currentSlideEvent = ref<SlideEvent | null>(null)
const closedByUserAction = ref(false) const closedByUserAction = ref(false)
const mapUsers = computed(() => users.map(el => ({...el, ...userSection(el)}))) const mapUsers = computed(() => users.map(el => ({
...el,
...userSection(el),
companyName: el.company_id && companiesStore.companyById(el.company_id)
? companiesStore.companyById(el.company_id)?.name
: null
})))
interface SlideEvent { interface SlideEvent {
reset: () => void reset: () => void
@@ -202,12 +276,13 @@
el.section1.toLowerCase().includes(searchValue) || el.section1.toLowerCase().includes(searchValue) ||
el.section2_1.toLowerCase().includes(searchValue) || el.section2_1.toLowerCase().includes(searchValue) ||
el.section2_2.toLowerCase().includes(searchValue) || el.section2_2.toLowerCase().includes(searchValue) ||
el.section3.toLowerCase().includes(searchValue) el.section3.toLowerCase().includes(searchValue) ||
el.companyName && el.companyName.toLowerCase().includes(searchValue)
) )
return arrOut return arrOut
}) })
const displayUsers = computed(() => displayUsersAll.value.filter(el => !el.is_block)) const displayUsers = computed(() => displayUsersAll.value.filter(el => !el.is_blocked))
function userSection (user: User) { function userSection (user: User) {
const tname = () => { const tname = () => {
@@ -244,8 +319,8 @@
function handleSlide (event: SlideEvent, id: number) { function handleSlide (event: SlideEvent, id: number) {
currentSlideEvent.value = event currentSlideEvent.value = event
showDialogDeleteUser.value = true showDialogBlockUser.value = true
deleteUserId.value = id blockUserId.value = id
} }
function onDialogBeforeHide () { function onDialogBeforeHide () {
@@ -263,26 +338,29 @@
} }
} }
async function onConfirmDeleteUser() { async function onConfirmBlockUser() {
closedByUserAction.value = true closedByUserAction.value = true
if (deleteUserId.value) { if (blockUserId.value) {
await usersStore.blockUser(deleteUserId.value) await usersStore.blockUser(blockUserId.value)
} }
currentSlideEvent.value = null currentSlideEvent.value = null
} }
const showBlockedUsers = ref(false) const showBlockedUsers = ref(false)
const blockedUsers = computed(() => displayUsersAll.value.filter(el => el.is_block)) const blockedUsers = computed(() => displayUsersAll.value.filter(el => el.is_blocked))
const unblockUserId = ref<number | undefined> (undefined) const unblockUserId = ref<number | undefined> (undefined)
const showDialogRestoreUser = ref(false) const showDialogRestoreUser = ref(false)
const showLeaveUsers = ref(false)
const leaveUsers = computed(() => displayUsersAll.value.filter(el => el.is_leave))
function handleUnblockUser (id: number) { function handleUnblockUser (id: number) {
showDialogRestoreUser.value = true showDialogRestoreUser.value = true
unblockUserId.value = id unblockUserId.value = id
} }
async function onConfirmRestoreUser () { async function onConfirmRestoreUser () {
if (unblockUserId.value) await usersStore.restore(unblockUserId.value) if (unblockUserId.value) await usersStore.unblockUser(unblockUserId.value)
} }
watch(showDialogRestoreUser, (newD :boolean) => { watch(showDialogRestoreUser, (newD :boolean) => {
@@ -293,5 +371,18 @@
</script> </script>
<style> <style scoped>
.rotate-icon-btn {
transition: transform 0.3s;
}
.rotate-icon-btn.rotate-icon :deep(.q-icon) {
transform: rotate(180deg);
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.5, 1);
}
.rotate-icon-btn :deep(.q-icon) {
transform: rotate(0deg);
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.5, 1);
}
</style> </style>

View File

@@ -129,11 +129,6 @@ const routes: RouteRecordRaw[] = [
component: () => import('pages/account/SubscribePage.vue'), component: () => import('pages/account/SubscribePage.vue'),
meta: { requiresAuth: true } meta: { requiresAuth: true }
}, },
{
name: '3software',
path: '/3software',
component: () => import('pages/account/3Software.vue'),
},
{ {
name: 'terms', name: 'terms',
path: '/terms-of-use', path: '/terms-of-use',

View File

@@ -14,6 +14,14 @@ interface Customer {
company?: CompanyParams company?: CompanyParams
} }
interface WSMessage {
id: number
action: string
entity: string
entity_id?: number
needUpdateStores: string[]
}
const ENDPOINT_MAP = { const ENDPOINT_MAP = {
register: '/auth/email/register', register: '/auth/email/register',
forgot: '/auth/forgot', forgot: '/auth/forgot',
@@ -25,6 +33,7 @@ export type AuthFlowType = keyof typeof ENDPOINT_MAP
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const customer = ref<Customer | null>(null) const customer = ref<Customer | null>(null)
const wsEvent = ref<WSMessage | null>(null)
const projectsStore = useProjectsStore() const projectsStore = useProjectsStore()
const isAuth = computed(() => !!customer.value) const isAuth = computed(() => !!customer.value)
@@ -35,29 +44,26 @@ export const useAuthStore = defineStore('auth', () => {
customer.value = data.data customer.value = data.data
const socket = new WebSocket("wss://946gp81j-9000.euw.devtunnels.ms/api/admin") const socket = new WebSocket("wss://946gp81j-9000.euw.devtunnels.ms/api/admin")
console.log(socket) console.log(socket)
socket.onopen = function() { socket.onopen = () => console.log("Connection ws create.")
console.log("Соединение установлено.");
};
socket.onclose = function(event) { socket.onclose = (event) => {
if (event.wasClean) { if (event.wasClean) console.log('Connection ws close.')
console.log('Соединение закрыто чисто'); else console.log('Connection ws failure!')
} else { console.log('Code: ' + event.code + ', reason : ' + event.reason)
console.log('Обрыв соединения'); // например, "убит" процесс сервера }
socket.onmessage = (event) => {
if (wsEvent.value) {
wsEvent.value.needUpdateStores = []
wsEvent.value = JSON.parse(event.data)
if (wsEvent.value?.entity === 'chat') wsEvent.value.needUpdateStores = ['users']
} }
console.log('Код: ' + event.code + ' причина: ' + event.reason);
};
socket.onmessage = function(event) { socket.onerror = (event) => console.error("Ошибка ", event)
console.log("Получены данные " + event.data); }
};
socket.onerror = function(error) {
console.log("Ошибка " + error.message);
};
} catch (error) { } catch (error) {
if (isAuth.value) console.log(error) if (isAuth.value) console.error(error)
} }
} }
@@ -81,9 +87,7 @@ export const useAuthStore = defineStore('auth', () => {
if (flowType !== 'changePwd') if (flowType !== 'changePwd')
await api.post(ENDPOINT_MAP[flowType], { email }) await api.post(ENDPOINT_MAP[flowType], { email })
else else
{console.log(222)
await api.post(ENDPOINT_MAP[flowType]) await api.post(ENDPOINT_MAP[flowType])
}
} }
const confirmCode = async (flowType: AuthFlowType, email: string, code: string) => { const confirmCode = async (flowType: AuthFlowType, email: string, code: string) => {
@@ -100,36 +104,32 @@ export const useAuthStore = defineStore('auth', () => {
} }
//change email of account //change email of account
const getCodeCurrentEmail = async () => { const getCodeCurrentEmail = async () =>
await api.post('/auth/email/change-email') await api.post('/auth/email/change-email')
}
const confirmCurrentEmailCode = async (code: string) => {
const confirmCurrentEmailCode = async (code: string) =>
await api.post('/auth/email/change-email', { code }) await api.post('/auth/email/change-email', { code })
}
const getCodeNewEmail = async (code: string, email: string) => { const getCodeNewEmail = async (code: string, email: string) =>
await api.post('/auth/email/change-email', { code, email }) await api.post('/auth/email/change-email', { code, email })
}
const confirmNewEmailCode = async (code: string, code2: string, email: string) => { const confirmNewEmailCode = async (code: string, code2: string, email: string) =>
await api.post('/auth/email/change-email', { code, code2, email }) await api.post('/auth/email/change-email', { code, code2, email })
}
const setNewEmailPassword = async (code: string, code2: string, email: string, password: string) => { const setNewEmailPassword = async (code: string, code2: string, email: string, password: string) =>
await api.post('/auth/email/change-email', { code, code2, email, password }) await api.post('/auth/email/change-email', { code, code2, email, password })
}
// user data company // user data company
const updateMyCompany = async (companyData: CompanyParams) => { const updateMyCompany = async (companyData: CompanyParams) => {
const response = await api.put('/customer/profile', { company: companyData }) const response = await api.put('/customer/profile', { company: companyData })
console.log(response)
if (response.status === 200 && customer.value) customer.value.company = companyData if (response.status === 200 && customer.value) customer.value.company = companyData
} }
return { return {
customer, customer,
isAuth, isAuth,
wsEvent,
initialize, initialize,
loginWithCredentials, loginWithCredentials,
loginWithTelegram, loginWithTelegram,

View File

@@ -1,8 +1,9 @@
import { ref, computed } from 'vue' import { ref, computed, watch } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { api } from 'boot/axios' import { api } from 'boot/axios'
import type { Chat } from 'types/Chat' import type { Chat } from 'types/Chat'
import { useProjectsStore } from 'stores/projects' import { useProjectsStore } from 'stores/projects'
import { useAuthStore } from 'stores/auth'
export const useChatsStore = defineStore('chats', () => { export const useChatsStore = defineStore('chats', () => {
const projectsStore = useProjectsStore() const projectsStore = useProjectsStore()
@@ -12,8 +13,9 @@ export const useChatsStore = defineStore('chats', () => {
const currentProjectId = computed(() => projectsStore.currentProjectId) const currentProjectId = computed(() => projectsStore.currentProjectId)
async function init () { async function init () {
const response = await api.get('/project/' + currentProjectId.value + '/chat') reset()
const chatsAPI = response.data.data const { data } = await api.get('/project/' + currentProjectId.value + '/chat')
const chatsAPI = data.data
chats.value.push(...chatsAPI) chats.value.push(...chatsAPI)
isInit.value = true isInit.value = true
} }
@@ -42,6 +44,12 @@ export const useChatsStore = defineStore('chats', () => {
return chats.value.find(el =>el.id === id) return chats.value.find(el =>el.id === id)
} }
const authStore = useAuthStore()
watch(() => authStore.wsEvent, async (event) => {
if (!event || event.entity !== 'chat' || !event.needUpdateStores.includes('chats')) return
await init()
}, { deep: true })
return { return {
chats, chats,
isInit, isInit,

View File

@@ -14,6 +14,7 @@ export const useCompaniesStore = defineStore('companies', () => {
const currentProjectId = computed(() => projectsStore.currentProjectId) const currentProjectId = computed(() => projectsStore.currentProjectId)
async function init () { async function init () {
reset()
const { data }= await api.get('/project/' + currentProjectId.value + '/company') const { data }= await api.get('/project/' + currentProjectId.value + '/company')
const companiesAPI = data.data const companiesAPI = data.data
companies.value.push(...(companiesAPI.sort((a: Company, b: Company) => (a.id - b.id)))) companies.value.push(...(companiesAPI.sort((a: Company, b: Company) => (a.id - b.id))))

View File

@@ -9,16 +9,17 @@ import type { WebApp } from '@twa-dev/types'
interface AppSettings { interface AppSettings {
fontSize: number fontSize: number
locale: string locale: string
timeZoneBot: { tz: string, offset: number, offsetString: string }
localeBot: string
} }
const defaultFontSize = 16 const defaultFontSize = 16
const minFontSize = 10
const maxFontSize = 22
const fontSizeStep = 2
const defaultSettings: AppSettings = { const defaultSettings: AppSettings = {
fontSize: defaultFontSize, fontSize: defaultFontSize,
locale: 'en-US' locale: 'en-US',
timeZoneBot: { tz: 'Europe/Moscow', offset: 3, offsetString: '+03:00' },
localeBot: 'en-US'
} }
export const useSettingsStore = defineStore('settings', () => { export const useSettingsStore = defineStore('settings', () => {
@@ -30,14 +31,18 @@ export const useSettingsStore = defineStore('settings', () => {
const isInit = ref(false) const isInit = ref(false)
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 = [ const supportLocale = [
{ value: 'en-US', label: 'English' }, { value: 'en-US', label: 'English' },
{ value: 'ru-RU', label: 'Русский' } { value: 'ru-RU', label: 'Русский' }
] ]
const supportFontSizes = [
{ value: 12, label: 'settings__fontsize_small' },
{ value: 16, label: 'settings__fontsize_medium' },
{ value: 20, label: 'settings__fontsize_large' }
]
const detectLocale = (): string => { const detectLocale = (): string => {
const localeMap = { const localeMap = {
ru: 'ru-RU', ru: 'ru-RU',
@@ -74,10 +79,12 @@ export const useSettingsStore = defineStore('settings', () => {
const init = async () => { const init = async () => {
if (authStore.isAuth) { if (authStore.isAuth) {
try { try {
const response = await api.get('/customer/settings') const { data } = await api.get('/customer/settings')
settings.value = { settings.value = {
fontSize: response.data.data.settings.fontSize || defaultSettings.fontSize, fontSize: data.data.settings.fontSize || defaultSettings.fontSize,
locale: response.data.data.settings.locale || detectLocale() locale: data.data.settings.locale || detectLocale(),
timeZoneBot: data.data.settings.timeZone || defaultSettings.timeZoneBot,
localeBot: data.data.settings.localeBot || detectLocale()
} }
} catch { } catch {
settings.value.locale = detectLocale() settings.value.locale = detectLocale()
@@ -93,12 +100,6 @@ export const useSettingsStore = defineStore('settings', () => {
isInit.value = true isInit.value = true
} }
const updateLocale = async (newLocale: string) => {
settings.value.locale = newLocale
applyLocale()
await saveSettings()
}
const saveSettings = async () => { const saveSettings = async () => {
await api.put('/customer/settings', { settings: settings.value }) await api.put('/customer/settings', { settings: settings.value })
} }
@@ -110,19 +111,6 @@ export const useSettingsStore = defineStore('settings', () => {
await saveSettings() await saveSettings()
} }
const clampFontSize = (size: number) =>
Math.max(minFontSize, Math.min(size, maxFontSize))
const increaseFontSize = async () => {
const newSize = clampFontSize(currentFontSize.value + fontSizeStep)
await updateSettings({ fontSize: newSize })
}
const decreaseFontSize = async () => {
const newSize = clampFontSize(currentFontSize.value - fontSizeStep)
await updateSettings({ fontSize: newSize })
}
watch(() => authStore.isAuth, (newVal) => { watch(() => authStore.isAuth, (newVal) => {
if (newVal !== undefined) void init() if (newVal !== undefined) void init()
}, { immediate: true }) }, { immediate: true })
@@ -130,14 +118,10 @@ export const useSettingsStore = defineStore('settings', () => {
return { return {
settings, settings,
supportLocale, supportLocale,
supportFontSizes,
isInit, isInit,
currentFontSize, currentFontSize,
canIncrease,
canDecrease,
init, init,
increaseFontSize, updateSettings
decreaseFontSize,
updateSettings,
updateLocale
} }
}) })

View File

@@ -1,8 +1,9 @@
import { ref, computed } from 'vue' import { ref, computed, watch } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { api } from 'boot/axios' import { api } from 'boot/axios'
import type { User, UserParams } from 'types/Users' import type { User, UserParams } from 'types/Users'
import { useProjectsStore } from 'stores/projects' import { useProjectsStore } from 'stores/projects'
import { useAuthStore } from 'stores/auth'
export const useUsersStore = defineStore('users', () => { export const useUsersStore = defineStore('users', () => {
const projectsStore = useProjectsStore() const projectsStore = useProjectsStore()
@@ -12,6 +13,7 @@ export const useUsersStore = defineStore('users', () => {
const currentProjectId = computed(() => projectsStore.currentProjectId) const currentProjectId = computed(() => projectsStore.currentProjectId)
async function init () { async function init () {
reset()
const { data } = await api.get('/project/' + currentProjectId.value + '/user') const { data } = await api.get('/project/' + currentProjectId.value + '/user')
const usersAPI = data.data const usersAPI = data.data
users.value.push(...usersAPI) users.value.push(...usersAPI)
@@ -38,6 +40,13 @@ export const useUsersStore = defineStore('users', () => {
if (users.value[idx]) Object.assign(users.value[idx], userAPI) if (users.value[idx]) Object.assign(users.value[idx], userAPI)
} }
async function unblockUser (userId: number) {
const { data } = await api.put('/project/' + currentProjectId.value + '/user/' + userId, { is_blocked: false })
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) { function userById (id: number) {
return users.value.find(el =>el.id === id) return users.value.find(el =>el.id === id)
} }
@@ -52,6 +61,12 @@ export const useUsersStore = defineStore('users', () => {
const getUsers = computed(() => users.value) const getUsers = computed(() => users.value)
const authStore = useAuthStore()
watch(() => authStore.wsEvent, async (event) => {
if (!event || event.entity !== 'user' || !event.needUpdateStores.includes('users')) return
await init()
}, { deep: true })
return { return {
users, users,
isInit, isInit,
@@ -59,6 +74,7 @@ export const useUsersStore = defineStore('users', () => {
reset, reset,
update, update,
blockUser, blockUser,
unblockUser,
userById, userById,
userNameById, userNameById,
getUsers getUsers

40
todo_all.txt Normal file
View File

@@ -0,0 +1,40 @@
1. Приложение Администратора:
1.1. Внедрение web-socket
1.2. Страница подписки и внедрение оплаты звездами
1.3. Юридические документы:
- Лицензионное соглашение (EN / RU)
- Обработка персональных данных (RU)
- Privacy police (EN)
1.4. Маскировка компаний
1.5. Взаимодействие с архивным чатом
2. Приложение Пользователя:
2.1. Авторизация с флагом принятия документов
2.2. При выборе пользователя в задаче или совещании - предоставлять
только список пользователей в чате
2.3. Отображение владельца чата (?техническая возможность)
2.4. В файлах добавить новую сущность URL
2.5. Работа с архивом задач
2.6. Механизм передачи vCard (через медиашаринг https://telegram.tips/blog/mini-apps-media-sharing/ )
2.7. Внедрение web-socket
3. Чаты:
3.1. Внедрение inline-режима создания форматированного сообщения, задачи и совещания
3.2. Проверить реализацию всех фич.
4. Сайт:
4.1. Создание сайта-лендинга
4.2. Оплата адреса
5. Инфраструктура:
5.1. Разворачивание гипервизора
5.2. Развертывание виртуальных машин
5.3. Бекапирование
5.4. Настройка nginx
6. Прочее:
6.1. Подача заявления в Роскомнадзор о работе с персональными данными.