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

View File

@@ -1,96 +0,0 @@
<template>
<div class="q-pt-md" v-if="mapUsers.length !==0 ">
<span class="q-pl-md text-h6">
{{ $t('company_info__users') }}
</span>
<q-list separator>
<q-item
v-for="item in mapUsers"
:key="item.id"
v-ripple
clickable
@click="goPersonInfo(item.id)"
>
<q-item-section avatar>
<pn-auto-avatar
:img="item.photo"
:name="item.section1"
/>
</q-item-section>
<q-item-section>
<q-item-label lines="1" class="text-bold" v-if="item.section1">
{{item.section1}}
</q-item-label>
<q-item-label caption lines="2">
<span v-if="item.section2_1" class="q-mr-sm">{{item.section2_1}}</span>
<span class="text-blue" v-if="item.section2_2">{{'@' + item.section2_2}}</span>
</q-item-label>
<q-item-label lines="1" v-if="item.section3">
{{item.section3}}
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</div>
</template>
<script setup lang="ts">
import { useRouter, useRoute } from 'vue-router'
import { useUsersStore } from 'stores/users'
import type { User } from 'types/Users'
const usersStore = useUsersStore()
const router = useRouter()
const route = useRoute()
const currentCompanyId = Number(route.params.companyId)
const users = usersStore.users
const mapUsers = users
.filter(el => el.company_id === currentCompanyId)
.map(el => ({...el, ...userSection(el)}))
async function goPersonInfo (userId: number) {
await router.push({ name: 'user_info', params: { id: route.params.id, userId }})
}
// copy from 'pages/project-page/ProjectPageUsers.vue' кроме company.name
function userSection (user: User) {
const tname = () => {
return user.firstname
? user.lastname
? user.firstname + ' ' + user.lastname
: user.firstname
: user.lastname ?? ''
}
const section1 = user.name
? user.name
: tname()
const section2_1 = user.name
? tname()
: ''
const section2_2 = user.username ?? ''
const section3 = (
user.department
? user.department + ' '
: ''
) + (
user.role ?? ''
)
return {
section1,
section2_1, section2_2,
section3
}
}
</script>
<style scoped>
</style>

View File

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

View File

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

View File

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

View File

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

View File

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