before delete 3software
This commit is contained in:
33
src/App.vue
33
src/App.vue
@@ -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>
|
||||
|
||||
@@ -3,4 +3,4 @@ import { useAuthStore } from 'stores/auth'
|
||||
export default async () => {
|
||||
const authStore = useAuthStore()
|
||||
await authStore.initialize()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
128
src/components/companyBlock.vue
Normal file
128
src/components/companyBlock.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
75
src/components/docBlock.vue
Normal file
75
src/components/docBlock.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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> -->
|
||||
|
||||
@@ -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'}"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
49
src/components/pnOnboardBtn.vue
Normal file
49
src/components/pnOnboardBtn.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
107
src/components/pnShadowScroll.vue
Normal file
107
src/components/pnShadowScroll.vue
Normal 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>
|
||||
87
src/components/pnSmallDialog.vue
Normal file
87
src/components/pnSmallDialog.vue
Normal 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>
|
||||
110
src/components/projectBlock.vue
Normal file
110
src/components/projectBlock.vue
Normal 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>
|
||||
@@ -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>
|
||||
180
src/components/userBlock.vue
Normal file
180
src/components/userBlock.vue
Normal 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>
|
||||
@@ -18,5 +18,5 @@ export function useNotify() {
|
||||
})
|
||||
}
|
||||
|
||||
return { notifyError}
|
||||
return { notifyError }
|
||||
}
|
||||
|
||||
@@ -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
98
src/helpers/helpers.ts
Normal 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
@@ -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>
|
||||
|
||||
20
src/pages/AccountChangeAuthMethodPage.vue
Normal file
20
src/pages/AccountChangeAuthMethodPage.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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') || '')
|
||||
|
||||
@@ -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') || '')
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
35
src/pages/CompanyAddPage.vue
Normal file
35
src/pages/CompanyAddPage.vue
Normal 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>
|
||||
@@ -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>
|
||||
58
src/pages/CompanyEditPage.vue
Normal file
58
src/pages/CompanyEditPage.vue
Normal 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
32
src/pages/ProjectAddPage.vue
Normal file
32
src/pages/ProjectAddPage.vue
Normal 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>
|
||||
@@ -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>
|
||||
43
src/pages/ProjectEditPage.vue
Normal file
43
src/pages/ProjectEditPage.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
45
src/pages/UserEditPage.vue
Normal file
45
src/pages/UserEditPage.vue
Normal 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>
|
||||
234
src/pages/account/3Software.vue
Normal file
234
src/pages/account/3Software.vue
Normal 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>
|
||||
10
src/pages/account/PrivacyPage.vue
Normal file
10
src/pages/account/PrivacyPage.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<doc-block type="privacy"/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import docBlock from 'components/docBlock.vue'
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
64
src/pages/account/SupportPage.vue
Normal file
64
src/pages/account/SupportPage.vue
Normal 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>
|
||||
10
src/pages/account/TermsPage.vue
Normal file
10
src/pages/account/TermsPage.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
297
src/pages/project-page/ProjectPageUsers.vue
Normal file
297
src/pages/project-page/ProjectPageUsers.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
66
src/stores/users.ts
Normal 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
|
||||
}
|
||||
})
|
||||
56
src/types.ts
56
src/types.ts
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user