first commit
This commit is contained in:
38
resources/js/Components/Accordion/Accordion.vue
Normal file
38
resources/js/Components/Accordion/Accordion.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup>
|
||||
import { ref, provide, onUnmounted } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
// ID элемента, который открыт по умолчанию
|
||||
opened: {
|
||||
type: [String, Number],
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const activeItem = ref(props.opened)
|
||||
const registeredItems = ref(new Set())
|
||||
|
||||
const accordionManager = {
|
||||
activeItem,
|
||||
open: (id) => {
|
||||
activeItem.value = id
|
||||
},
|
||||
close: () => {
|
||||
activeItem.value = null
|
||||
},
|
||||
registerItem: (id) => {
|
||||
registeredItems.value.add(id)
|
||||
},
|
||||
unregisterItem: (id) => {
|
||||
registeredItems.value.delete(id)
|
||||
}
|
||||
};
|
||||
|
||||
provide('accordionManager', accordionManager)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
91
resources/js/Components/Badge/Badge.vue
Normal file
91
resources/js/Components/Badge/Badge.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup>
|
||||
import {computed, ref} from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
validator(value, props) {
|
||||
return ['success', 'warning', 'danger', 'info'].includes(value)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const baseClasses = [
|
||||
'inline-flex',
|
||||
'items-center',
|
||||
'gap-x-1.5',
|
||||
'rounded-md',
|
||||
'px-1.5',
|
||||
'py-0.5',
|
||||
'text-sm/5',
|
||||
'font-medium',
|
||||
'sm:text-xs/5',
|
||||
'forced-colors:outline',
|
||||
]
|
||||
|
||||
const successColorClasses = [
|
||||
'bg-lime-400/20',
|
||||
'text-lime-700',
|
||||
'group-data-hover:bg-lime-400/30',
|
||||
'dark:bg-lime-400/10',
|
||||
'dark:text-lime-300',
|
||||
'dark:group-data-hover:bg-lime-400/15'
|
||||
]
|
||||
const warningColorClasses = [
|
||||
'bg-amber-400/20',
|
||||
'text-amber-700',
|
||||
'group-data-hover:bg-amber-400/25',
|
||||
'dark:bg-amber-400/10',
|
||||
'dark:text-amber-400',
|
||||
'dark:group-data-hover:bg-amber-400/20'
|
||||
]
|
||||
const dangerColorClasses = [
|
||||
'bg-rose-400/20',
|
||||
'text-rose-700',
|
||||
'group-data-hover:bg-rose-400/25',
|
||||
'dark:bg-rose-400/10',
|
||||
'dark:text-rose-400',
|
||||
'dark:group-data-hover:bg-rose-400/20'
|
||||
]
|
||||
const infoColorClasses = [
|
||||
'bg-sky-400/20',
|
||||
'text-sky-700',
|
||||
'group-data-hover:bg-sky-400/25',
|
||||
'dark:bg-sky-400/10',
|
||||
'dark:text-sky-400',
|
||||
'dark:group-data-hover:bg-sky-400/20'
|
||||
]
|
||||
const primaryColorClasses = [
|
||||
'bg-orange-400/20',
|
||||
'text-orange-700',
|
||||
'group-data-hover:bg-orange-400/25',
|
||||
'dark:bg-orange-400/10',
|
||||
'dark:text-orange-400',
|
||||
'dark:group-data-hover:bg-orange-400/20'
|
||||
]
|
||||
|
||||
const colorClasses = {
|
||||
success: successColorClasses,
|
||||
warning: warningColorClasses,
|
||||
danger: dangerColorClasses,
|
||||
info: infoColorClasses,
|
||||
primary: primaryColorClasses
|
||||
}
|
||||
|
||||
const computedClasses = computed(() => {
|
||||
return [
|
||||
...baseClasses,
|
||||
...(colorClasses[props.variant] || [])
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :data-variant="variant" :class="computedClasses">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
212
resources/js/Components/Button/Button.vue
Normal file
212
resources/js/Components/Button/Button.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<script setup>
|
||||
import {computed, ref, watch} from "vue";
|
||||
|
||||
const emits = defineEmits([
|
||||
'click'
|
||||
])
|
||||
|
||||
const props = defineProps({
|
||||
tag: {
|
||||
type: [String, Object],
|
||||
default: 'button'
|
||||
},
|
||||
href: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
icon: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
block: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'default'
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
textAlign: {
|
||||
type: String,
|
||||
default: 'left'
|
||||
},
|
||||
iconLeft: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
iconRight: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
maxWidth: {
|
||||
type: [String, Number],
|
||||
default: 'none'
|
||||
},
|
||||
})
|
||||
|
||||
const baseClasses = [
|
||||
'group', 'cursor-pointer', 'relative', 'block', 'appearance-none', 'rounded-lg', 'text-left', 'text-base/6',
|
||||
'sm:text-sm/6', 'border', 'active:scale-[.99]',
|
||||
'transition-all'
|
||||
]
|
||||
const paddingClasses = [
|
||||
'py-[calc(--spacing(2.5)-1px)]', 'sm:py-[calc(--spacing(1.5)-1px)]',
|
||||
'px-[calc(--spacing(3.5)-1px)]', 'sm:px-[calc(--spacing(2.5)-1px)]',
|
||||
]
|
||||
const paddingClassesIcon = [
|
||||
'py-[calc(--spacing(2.5)-1px)]', 'sm:py-[calc(--spacing(2.5)-1px)]',
|
||||
'px-[calc(--spacing(2.5)-1px)]', 'sm:px-[calc(--spacing(2.5)-1px)]',
|
||||
]
|
||||
|
||||
const variants = {
|
||||
default: [
|
||||
'text-zinc-950', 'placeholder:text-zinc-500', 'dark:text-white', 'border-zinc-950/10',
|
||||
'hover:border-zinc-950/20', 'dark:border-white/10', 'dark:hover:border-white/20', 'bg-transparent',
|
||||
'dark:bg-white/5'
|
||||
],
|
||||
warning: [
|
||||
'text-white', 'placeholder:text-amber-500', 'border-amber-900',
|
||||
'hover:border-amber-400', 'dark:border-amber-700', 'dark:hover:border-amber-600', 'bg-transparent',
|
||||
'dark:bg-amber-800'
|
||||
],
|
||||
danger: [
|
||||
'placeholder:text-rose-500', 'border-rose-900',
|
||||
'hover:border-rose-400', 'dark:border-rose-600', 'dark:hover:border-rose-400', 'bg-transparent',
|
||||
'dark:bg-rose-800'
|
||||
],
|
||||
ghost: [
|
||||
'bg-transparent', 'border-transparent', 'hover:border-zinc-950/20', 'dark:hover:border-white/20',
|
||||
]
|
||||
}
|
||||
|
||||
const textContainerClasses = computed(() => {
|
||||
let base = ['flex', 'flex-row', 'gap-x-2', 'relative']
|
||||
|
||||
if (props.textAlign) {
|
||||
const align = {
|
||||
center: 'justify-center',
|
||||
left: 'justify-start',
|
||||
right: 'justify-end',
|
||||
}
|
||||
|
||||
const textAlign = align[props.textAlign]
|
||||
|
||||
base = base.concat([textAlign])
|
||||
}
|
||||
|
||||
return base
|
||||
})
|
||||
|
||||
const classes = computed(() => {
|
||||
let base = [...baseClasses]
|
||||
|
||||
if (props.icon) {
|
||||
base = base.concat(paddingClassesIcon)
|
||||
} else {
|
||||
base = base.concat(paddingClasses)
|
||||
}
|
||||
|
||||
if (props.block) {
|
||||
base = base.concat(['w-full'])
|
||||
}
|
||||
|
||||
if (props.variant) {
|
||||
base = base.concat(variants[props.variant])
|
||||
}
|
||||
|
||||
return base
|
||||
})
|
||||
|
||||
const textClasses = computed(() => {
|
||||
let base = ['min-w-0', 'w-full']
|
||||
|
||||
// Автоматически рассчитываем максимальную ширину если есть иконки
|
||||
if (props.maxWidth === 'none') {
|
||||
if (props.iconLeft && props.iconRight) {
|
||||
base = base.concat(['max-w-[calc(100%-8rem)]']) // минус 2 иконки
|
||||
} else if (props.iconLeft || props.iconRight) {
|
||||
base = base.concat(['max-w-[calc(100%-1.5rem)]']) // минус 1 иконка
|
||||
}
|
||||
} else if (props.maxWidth) {
|
||||
base = base.concat([`max-w-[${props.maxWidth}]`])
|
||||
}
|
||||
|
||||
base = base.concat(['truncate'])
|
||||
|
||||
return base
|
||||
})
|
||||
|
||||
const handleClick = (event) => {
|
||||
if (props.loading || props.disabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
emits('click', event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="tag"
|
||||
:href="href"
|
||||
@click="handleClick"
|
||||
:class="classes"
|
||||
:disabled="loading || disabled"
|
||||
v-bind="$attrs">
|
||||
<div :class="textContainerClasses">
|
||||
<!-- Спиннер загрузки -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<div class="flex items-center gap-x-2">
|
||||
<!-- Анимированный спиннер -->
|
||||
<div class="flex space-x-1">
|
||||
<div class="w-1.5 h-1.5 bg-current rounded-full animate-bounce" style="animation-delay: 0s"></div>
|
||||
<div class="w-1.5 h-1.5 bg-current rounded-full animate-bounce" style="animation-delay: 0.1s"></div>
|
||||
<div class="w-1.5 h-1.5 bg-current rounded-full animate-bounce" style="animation-delay: 0.2s"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Основной контент кнопки (скрывается при loading) -->
|
||||
<div
|
||||
:class="[
|
||||
'flex flex-row gap-x-2 items-center transition-opacity duration-200 w-full',
|
||||
loading ? 'opacity-0' : 'opacity-100'
|
||||
]"
|
||||
>
|
||||
<div
|
||||
v-if="($slots.iconLeft || (iconLeft && $slots.icon))"
|
||||
class="shrink-0 size-6 stroke-zinc-500 group-disabled:stroke-zinc-600 sm:size-4 dark:stroke-zinc-400"
|
||||
>
|
||||
<slot name="iconLeft">
|
||||
<slot name="icon" />
|
||||
</slot>
|
||||
</div>
|
||||
<div :class="textClasses">
|
||||
<slot />
|
||||
</div>
|
||||
<div
|
||||
v-if="$slots.iconRight || (iconRight && $slots.icon)"
|
||||
class="shrink-0 size-6 stroke-zinc-500 group-disabled:stroke-zinc-600 sm:size-4 dark:stroke-zinc-400"
|
||||
>
|
||||
<slot name="iconRight">
|
||||
<slot name="icon" />
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
433
resources/js/Components/Calendar/Calendar.vue
Normal file
433
resources/js/Components/Calendar/Calendar.vue
Normal file
@@ -0,0 +1,433 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import {
|
||||
format,
|
||||
parse,
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
eachDayOfInterval,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
isSameDay,
|
||||
isSameMonth,
|
||||
isToday,
|
||||
addMonths,
|
||||
subMonths,
|
||||
isValid
|
||||
} from 'date-fns'
|
||||
import { ru } from 'date-fns/locale'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [Date, String, null],
|
||||
default: null
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Выберите дату'
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'default'
|
||||
},
|
||||
format: { // Пользователь вводит любой формат date-fns
|
||||
type: String,
|
||||
default: 'dd.MM.yyyy'
|
||||
},
|
||||
returnFormatted: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
locale: {
|
||||
type: Object,
|
||||
default: () => ru
|
||||
},
|
||||
block: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const baseClasses = [
|
||||
'group', 'cursor-pointer', 'relative', 'block', 'appearance-none', 'rounded-lg', 'text-left', 'text-base/6',
|
||||
'sm:text-sm/6', 'border', 'transition-all', 'disabled:cursor-not-allowed', 'disabled:opacity-50'
|
||||
]
|
||||
|
||||
const paddingClasses = [
|
||||
'py-[calc(--spacing(2.5)-1px)]', 'sm:py-[calc(--spacing(1.5)-1px)]',
|
||||
'px-[calc(--spacing(3.5)-1px)]', 'sm:px-[calc(--spacing(2.5)-1px)]',
|
||||
]
|
||||
|
||||
const variants = {
|
||||
default: [
|
||||
'text-zinc-950', 'placeholder:text-zinc-500', 'dark:text-white', 'border-zinc-950/10',
|
||||
'hover:border-zinc-950/20', 'dark:border-white/10', 'dark:hover:border-white/20', 'bg-transparent',
|
||||
'dark:bg-white/5'
|
||||
],
|
||||
warning: [
|
||||
'text-white', 'placeholder:text-amber-500', 'border-amber-900',
|
||||
'hover:border-amber-400', 'dark:border-amber-700', 'dark:hover:border-amber-600', 'bg-transparent',
|
||||
'dark:bg-amber-800'
|
||||
],
|
||||
danger: [
|
||||
'placeholder:text-rose-500', 'border-rose-900',
|
||||
'hover:border-rose-400', 'dark:border-rose-600', 'dark:hover:border-rose-400', 'bg-transparent',
|
||||
'dark:bg-rose-800'
|
||||
],
|
||||
ghost: [
|
||||
'bg-transparent', 'border-transparent', 'hover:border-zinc-950/20', 'dark:hover:border-white/20',
|
||||
'hover:dark:bg-white/5'
|
||||
]
|
||||
}
|
||||
|
||||
const classes = computed(() => {
|
||||
let base = [...baseClasses, ...paddingClasses]
|
||||
|
||||
if (props.variant) {
|
||||
base = base.concat(variants[props.variant])
|
||||
}
|
||||
|
||||
if (props.block) base.push('w-full')
|
||||
|
||||
return base
|
||||
})
|
||||
|
||||
// Парсинг входящего значения
|
||||
const parseInputValue = (value) => {
|
||||
if (!value) return null
|
||||
|
||||
if (value instanceof Date) {
|
||||
return isValid(value) ? value : null
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
// Пробуем распарсить с текущим форматом
|
||||
const parsed = parse(value, props.format, new Date(), { locale: props.locale })
|
||||
if (isValid(parsed)) return parsed
|
||||
|
||||
// Пробуем стандартные форматы как fallback
|
||||
const standardFormats = [
|
||||
'dd.MM.yyyy',
|
||||
'yyyy-MM-dd',
|
||||
'd MMMM yyyy',
|
||||
'd MMM yyyy',
|
||||
'dd/MM/yyyy',
|
||||
'MM/dd/yyyy'
|
||||
]
|
||||
|
||||
for (const fmt of standardFormats) {
|
||||
const parsed = parse(value, fmt, new Date(), { locale: props.locale })
|
||||
if (isValid(parsed)) return parsed
|
||||
}
|
||||
|
||||
// Последняя попытка - стандартный парсинг
|
||||
const parsedDate = new Date(value)
|
||||
if (isValid(parsedDate)) return parsedDate
|
||||
} catch (e) {
|
||||
console.warn('Не удалось распарсить дату:', value)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Состояния
|
||||
const isOpen = ref(false)
|
||||
const selectedDate = ref(props.modelValue ? parseInputValue(props.modelValue) : null)
|
||||
const currentMonth = ref(selectedDate.value || new Date())
|
||||
const today = new Date()
|
||||
|
||||
// Дни недели
|
||||
const weekDays = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']
|
||||
|
||||
// Функция для экранирования текста в формате
|
||||
const escapeFormat = (formatString) => {
|
||||
// Если формат уже содержит экранированный текст, оставляем как есть
|
||||
if (formatString.includes("'")) {
|
||||
return formatString
|
||||
}
|
||||
|
||||
// Ищем текстовые части и экранируем их
|
||||
const tokens = formatString.split(/(\s+)/)
|
||||
let result = ''
|
||||
let inText = false
|
||||
|
||||
for (const token of tokens) {
|
||||
if (token.trim() === '') {
|
||||
result += token
|
||||
continue
|
||||
}
|
||||
|
||||
// Проверяем, является ли токен текстом (не паттерном date-fns)
|
||||
const isPattern = /^(dd?|MM?M?M?|yy?yy?|EEE?E?|QQ?Q?|ww?|HH?|hh?|mm?|ss?|SSS?|aaaa?|xxxx?|XXXX?|ZZZ?)$/.test(token)
|
||||
|
||||
if (!isPattern && !inText) {
|
||||
result += `'${token}`
|
||||
inText = true
|
||||
} else if (!isPattern && inText) {
|
||||
result += ` ${token}`
|
||||
} else if (isPattern && inText) {
|
||||
result += `' ${token}`
|
||||
inText = false
|
||||
} else {
|
||||
result += token
|
||||
}
|
||||
}
|
||||
|
||||
// Закрываем последнюю текстовую часть
|
||||
if (inText) {
|
||||
result += "'"
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Форматирование даты с помощью date-fns
|
||||
const formatDate = (date) => {
|
||||
if (!date || !isValid(date)) return ''
|
||||
|
||||
try {
|
||||
// Пробуем форматировать как есть
|
||||
return format(date, props.format, { locale: props.locale })
|
||||
} catch (error) {
|
||||
console.warn('Ошибка форматирования даты:', error)
|
||||
|
||||
// Fallback: пробуем заменить проблемные символы
|
||||
try {
|
||||
// Экранируем весь текст, который не является паттерном date-fns
|
||||
const safeFormat = props.format
|
||||
.replace(/([^dMyYwWDEHhmsSaZXx']+|'.*?')/g, "'$1'")
|
||||
.replace(/''/g, "'")
|
||||
|
||||
return format(date, safeFormat, { locale: props.locale })
|
||||
} catch (e) {
|
||||
// Ultimate fallback
|
||||
return format(date, 'dd.MM.yyyy', { locale: props.locale })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Получить дни для календаря
|
||||
const calendarDays = computed(() => {
|
||||
const start = startOfWeek(startOfMonth(currentMonth.value), { weekStartsOn: 1 })
|
||||
const end = endOfWeek(endOfMonth(currentMonth.value), { weekStartsOn: 1 })
|
||||
|
||||
return eachDayOfInterval({ start, end })
|
||||
})
|
||||
|
||||
// Текущий месяц и год
|
||||
const currentMonthYear = computed(() => {
|
||||
return format(currentMonth.value, 'LLLL yyyy', { locale: props.locale })
|
||||
})
|
||||
|
||||
// Форматированная дата для отображения
|
||||
const formattedDate = computed(() => {
|
||||
if (!selectedDate.value) return props.placeholder
|
||||
return formatDate(selectedDate.value)
|
||||
})
|
||||
|
||||
// Методы
|
||||
const selectDate = (date) => {
|
||||
if (!date || props.disabled) return
|
||||
|
||||
selectedDate.value = date
|
||||
|
||||
if (props.returnFormatted) {
|
||||
const formattedValue = formatDate(date)
|
||||
emit('update:modelValue', formattedValue)
|
||||
} else {
|
||||
emit('update:modelValue', date)
|
||||
}
|
||||
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const prevMonth = () => {
|
||||
currentMonth.value = subMonths(currentMonth.value, 1)
|
||||
}
|
||||
|
||||
const nextMonth = () => {
|
||||
currentMonth.value = addMonths(currentMonth.value, 1)
|
||||
}
|
||||
|
||||
// Вспомогательные методы для проверок
|
||||
const isDateToday = (date) => isToday(date)
|
||||
const isDateSelected = (date) => selectedDate.value && isSameDay(date, selectedDate.value)
|
||||
const isDateCurrentMonth = (date) => isSameMonth(date, currentMonth.value)
|
||||
|
||||
|
||||
// Методы для внешнего использования
|
||||
const getFormattedDate = (date = null, customFormat = null) => {
|
||||
const targetDate = date || selectedDate.value
|
||||
if (!targetDate || !isValid(targetDate)) return null
|
||||
|
||||
const formatToUse = customFormat || props.format
|
||||
|
||||
try {
|
||||
return format(targetDate, formatToUse, { locale: props.locale })
|
||||
} catch (error) {
|
||||
console.warn('Ошибка форматирования:', error)
|
||||
return format(targetDate, 'dd.MM.yyyy', { locale: props.locale })
|
||||
}
|
||||
}
|
||||
|
||||
const getDateObject = () => selectedDate.value
|
||||
|
||||
defineExpose({
|
||||
getFormattedDate,
|
||||
getDateObject
|
||||
})
|
||||
|
||||
// Закрытие по клику вне компонента
|
||||
const closeOnClickOutside = (event) => {
|
||||
if (!event.target.closest('.calendar-container')) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', closeOnClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', closeOnClickOutside)
|
||||
})
|
||||
|
||||
// Следим за изменением modelValue
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
const parsed = parseInputValue(newValue)
|
||||
if (parsed && (!selectedDate.value || !isSameDay(parsed, selectedDate.value))) {
|
||||
selectedDate.value = parsed
|
||||
if (parsed) {
|
||||
currentMonth.value = startOfMonth(parsed)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Следим за изменением формата
|
||||
watch(() => props.format, (newFormat) => {
|
||||
// Если дата уже выбрана, переформатируем её
|
||||
if (selectedDate.value && props.returnFormatted) {
|
||||
const formattedValue = formatDate(selectedDate.value)
|
||||
emit('update:modelValue', formattedValue)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="calendar-container relative">
|
||||
<!-- Триггер -->
|
||||
<button
|
||||
:class="classes"
|
||||
@click="isOpen = !isOpen"
|
||||
:disabled="disabled"
|
||||
type="button"
|
||||
>
|
||||
<div class="flex flex-row gap-x-2 items-center justify-between w-full">
|
||||
<span :class="!selectedDate ? 'text-zinc-500 dark:text-zinc-400' : ''">
|
||||
{{ formattedDate }}
|
||||
</span>
|
||||
<svg
|
||||
class="size-4 stroke-zinc-500 group-disabled:stroke-zinc-600 dark:stroke-zinc-400 transition-transform duration-200 flex-shrink-0"
|
||||
:class="{ 'rotate-180': isOpen }"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Выпадающий календарь -->
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="absolute top-full left-0 mt-1 z-50 bg-white dark:bg-zinc-900 border border-zinc-950/10 dark:border-white/10 rounded-lg shadow-lg p-4 min-w-64"
|
||||
>
|
||||
<!-- Заголовок с навигацией -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<button
|
||||
@click="prevMonth"
|
||||
class="p-1 rounded hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<svg class="size-4 stroke-current" fill="none" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<span class="text-sm font-medium text-zinc-900 dark:text-white">
|
||||
{{ currentMonthYear }}
|
||||
</span>
|
||||
|
||||
<button
|
||||
@click="nextMonth"
|
||||
class="p-1 rounded hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<svg class="size-4 stroke-current" fill="none" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Дни недели -->
|
||||
<div class="grid grid-cols-7 gap-1 mb-2">
|
||||
<div
|
||||
v-for="day in weekDays"
|
||||
:key="day"
|
||||
class="text-xs text-center text-zinc-500 dark:text-zinc-400 py-1"
|
||||
>
|
||||
{{ day }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Дни месяца -->
|
||||
<div class="grid grid-cols-7 gap-1">
|
||||
<button
|
||||
v-for="date in calendarDays"
|
||||
:key="date.getTime()"
|
||||
@click="selectDate(date)"
|
||||
:disabled="disabled"
|
||||
class="aspect-square p-1 text-sm rounded transition-all duration-200"
|
||||
:class="[
|
||||
!isDateCurrentMonth(date) ? 'text-zinc-400 dark:text-zinc-600' : '',
|
||||
isDateSelected(date)
|
||||
? 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-900'
|
||||
: isDateToday(date)
|
||||
? 'border border-zinc-900 dark:border-white'
|
||||
: 'hover:bg-zinc-100 dark:hover:bg-zinc-800',
|
||||
disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'
|
||||
]"
|
||||
>
|
||||
{{ format(date, 'd', { locale }) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Быстрый выбор -->
|
||||
<div class="flex justify-between mt-4 pt-3 border-t border-zinc-200 dark:border-zinc-700">
|
||||
<button
|
||||
@click="selectDate(today)"
|
||||
class="text-xs text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors"
|
||||
:disabled="disabled"
|
||||
>
|
||||
Сегодня
|
||||
</button>
|
||||
<button
|
||||
@click="selectDate(null)"
|
||||
class="text-xs text-rose-600 dark:text-rose-400 hover:text-rose-700 dark:hover:text-rose-300 transition-colors"
|
||||
:disabled="disabled"
|
||||
>
|
||||
Очистить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
65
resources/js/Components/Card/Card.vue
Normal file
65
resources/js/Components/Card/Card.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup>
|
||||
import CardHeader from "./CardHeader.vue";
|
||||
import CardBack from "./CardBack.vue";
|
||||
import {computed} from "vue";
|
||||
const props = defineProps({
|
||||
header: String,
|
||||
contentScroll: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
contentRelative: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
mergeContentClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const contentClass = computed(() => {
|
||||
const classes = ['p-3 h-full']
|
||||
|
||||
props.contentRelative ? classes.push('relative') : delete classes.find(cls => cls === 'relative')
|
||||
|
||||
if (props.contentScroll) {
|
||||
classes.push('overflow-y-auto')
|
||||
delete classes.find(cls => cls === 'overflow-y-clip')
|
||||
} else {
|
||||
classes.push('overflow-y-clip')
|
||||
delete classes.find(cls => cls === 'overflow-y-auto')
|
||||
}
|
||||
|
||||
if (props.mergeContentClass) {
|
||||
const mergeClasses = props.mergeContentClass.split(' ')
|
||||
classes.push(...mergeClasses)
|
||||
}
|
||||
|
||||
return classes
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col justify-between overflow-clip rounded-lg bg-white lg:shadow-xs ring-1 ring-zinc-950/5 dark:bg-zinc-900 dark:ring-white/10">
|
||||
<div class="p-3">
|
||||
<CardHeader>
|
||||
<slot v-if="$slots.header" name="header" />
|
||||
<span v-else>
|
||||
{{ header }}
|
||||
</span>
|
||||
</CardHeader>
|
||||
</div>
|
||||
<div :class="contentClass">
|
||||
<slot />
|
||||
</div>
|
||||
<div v-if="$slots.footer" class="p-3">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
32
resources/js/Components/Card/CardBack.vue
Normal file
32
resources/js/Components/Card/CardBack.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup>
|
||||
import Button from "../Button/Button.vue"
|
||||
const emits = defineEmits([
|
||||
'click'
|
||||
])
|
||||
const props = defineProps({
|
||||
tag: {
|
||||
type: [String, Object],
|
||||
default: 'button'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button :tag="tag" v-bind:href="$attrs.href" @click="emits('click')" icon-left>
|
||||
<template #icon>
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14"></path>
|
||||
<path d="M5 12l6 6"></path>
|
||||
<path d="M5 12l6-6"></path>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
Вернуться назад
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
11
resources/js/Components/Card/CardBody.vue
Normal file
11
resources/js/Components/Card/CardBody.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script setup>
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
13
resources/js/Components/Card/CardFooter.vue
Normal file
13
resources/js/Components/Card/CardFooter.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup>
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-sm lg:rounded-lg lg:ring-1 lg:ring-zinc-950/5 dark:lg:bg-zinc-800 dark:lg:ring-white/10 px-3.5 py-2">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
5
resources/js/Components/Card/CardHeader.vue
Normal file
5
resources/js/Components/Card/CardHeader.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="text-sm lg:rounded-lg bg-zinc-100 lg:ring-1 lg:ring-zinc-950/10 dark:lg:bg-zinc-800 dark:lg:ring-white/10 px-3.5 py-2">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
130
resources/js/Components/Collapsible/Collapsible.vue
Normal file
130
resources/js/Components/Collapsible/Collapsible.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
|
||||
<script setup>
|
||||
import {ref, computed, inject, onUnmounted} from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
header: String,
|
||||
defaultOpen: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
contentScroll: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
contentRelative: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
mergeContentClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
id: {
|
||||
type: [String, Number],
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
// Инжектим управление аккордеоном из родителя
|
||||
const accordionManager = inject('accordionManager', null);
|
||||
|
||||
const localOpen = ref(props.defaultOpen)
|
||||
const isOpen = computed(() => {
|
||||
if (accordionManager) {
|
||||
return accordionManager.activeItem.value === props.id;
|
||||
}
|
||||
return localOpen.value;
|
||||
});
|
||||
|
||||
const toggle = () => {
|
||||
if (accordionManager) {
|
||||
if (isOpen.value) {
|
||||
accordionManager.close();
|
||||
} else {
|
||||
accordionManager.open(props.id);
|
||||
}
|
||||
} else {
|
||||
localOpen.value = !localOpen.value
|
||||
}
|
||||
};
|
||||
|
||||
// Регистрируем элемент в аккордеоне при монтировании
|
||||
if (accordionManager && props.id) {
|
||||
accordionManager.registerItem(props.id);
|
||||
|
||||
onUnmounted(() => {
|
||||
accordionManager.unregisterItem(props.id);
|
||||
});
|
||||
}
|
||||
|
||||
const contentClass = computed(() => {
|
||||
const classes = ['h-full transition-all duration-300 ease-in-out'];
|
||||
|
||||
if (isOpen.value) {
|
||||
classes.push('opacity-100 max-h-[1000px]'); // max-h достаточно большой для контента
|
||||
} else {
|
||||
classes.push('opacity-0 max-h-0');
|
||||
}
|
||||
|
||||
props.contentRelative ? classes.push('relative') : null;
|
||||
|
||||
if (props.contentScroll && isOpen.value) {
|
||||
classes.push('overflow-y-auto');
|
||||
} else {
|
||||
classes.push('overflow-y-clip');
|
||||
}
|
||||
|
||||
if (props.mergeContentClass) {
|
||||
const mergeClasses = props.mergeContentClass.split(' ');
|
||||
classes.push(...mergeClasses);
|
||||
}
|
||||
|
||||
return classes;
|
||||
});
|
||||
|
||||
const containerClass = computed(() => {
|
||||
return [
|
||||
'h-full flex flex-col justify-between overflow-clip lg:rounded-lg lg:bg-white lg:shadow-xs lg:ring-1 lg:ring-zinc-950/5 dark:lg:bg-zinc-900 dark:lg:ring-white/10',
|
||||
isOpen.value ? 'min-h-[100px]' : ''
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="containerClass" :data-br-id="props.id">
|
||||
<div class="p-1.5 px-2 pr-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="inline-flex gap-x-2 items-center">
|
||||
<slot v-if="$slots.icon" name="icon" />
|
||||
<div class="flex-1 cursor-pointer" @click="toggle">
|
||||
<slot v-if="$slots.header" name="header" />
|
||||
<span v-else class="block text-sm font-medium">
|
||||
{{ header }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inline-flex gap-x-2">
|
||||
<slot v-if="$slots['header-extra']" name="header-extra" />
|
||||
<div class="flex items-center justify-center w-6 h-6 transition-transform duration-300 cursor-pointer"
|
||||
@click="toggle"
|
||||
:class="{ 'rotate-180': isOpen }">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="contentClass">
|
||||
<div v-if="isOpen" class="p-3 pt-0">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.footer && isOpen" class="p-3">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
45
resources/js/Components/Document/A4.vue
Normal file
45
resources/js/Components/Document/A4.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup>
|
||||
import {computed} from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
cover: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const defaultClasses = [
|
||||
'w-[21cm] h-[29.7cm] bg-white text-black rounded-sm outline-none'
|
||||
]
|
||||
|
||||
const defaultCoverClasses = [
|
||||
'p-[2.5cm]'
|
||||
]
|
||||
|
||||
const coverClasses = [
|
||||
'pt-[2cm] pl-[3cm] pb-[2cm] pr-[1.5cm]'
|
||||
]
|
||||
|
||||
const classes = computed(() => {
|
||||
let base = [...defaultClasses]
|
||||
|
||||
if (props.cover)
|
||||
base = base.concat(coverClasses)
|
||||
else
|
||||
base = base.concat(defaultCoverClasses)
|
||||
|
||||
return base
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="classes">
|
||||
<div class="min-h-[calc(29.7cm-5cm)] relative">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
61
resources/js/Components/Document/DocumentElement.vue
Normal file
61
resources/js/Components/Document/DocumentElement.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script setup>
|
||||
import {computed, onMounted} from "vue"
|
||||
import DocumentWarning from "./DocumentWarning.vue"
|
||||
import DocumentHtml from "./DocumentHtml.vue"
|
||||
import DocumentHeading from "./DocumentHeading.vue"
|
||||
import DocumentParagraph from "./DocumentParagraph.vue";
|
||||
import DocumentTextRun from "./DocumentTextRun.vue";
|
||||
import DocumentLineBreak from "./DocumentLineBreak.vue";
|
||||
import DocumentTable from "./DocumentTable.vue";
|
||||
|
||||
const props = defineProps({
|
||||
element: Object
|
||||
})
|
||||
|
||||
const emit = defineEmits(['variable-click', 'is-mounted'])
|
||||
|
||||
const componentMap = {
|
||||
'heading': DocumentHeading,
|
||||
'html': DocumentHtml,
|
||||
// 'warning': DocumentWarning,
|
||||
'paragraph': DocumentParagraph,
|
||||
'text_run': DocumentTextRun,
|
||||
'line_break': DocumentLineBreak,
|
||||
'table': DocumentTable
|
||||
}
|
||||
|
||||
const componentType = computed(() => {
|
||||
return componentMap[props.element.type] || 'div'
|
||||
})
|
||||
|
||||
const elementClass = computed(() => {
|
||||
return `element-${props.element.type}`
|
||||
})
|
||||
|
||||
const compiledContent = computed(() => {
|
||||
return props.element.compiledContent || props.element.content
|
||||
})
|
||||
|
||||
const handleVariableClick = (variableName) => {
|
||||
emit('variable-click', variableName)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
emit('is-mounted', true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="document-element" :class="elementClass">
|
||||
<component
|
||||
:is="componentType"
|
||||
:content="compiledContent"
|
||||
:element="element"
|
||||
@variable-click="handleVariableClick"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
54
resources/js/Components/Document/DocumentHeading.vue
Normal file
54
resources/js/Components/Document/DocumentHeading.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<script setup>
|
||||
import {computed} from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
content: String,
|
||||
element: Object
|
||||
})
|
||||
|
||||
const styles = computed(() => {
|
||||
const styles = {}
|
||||
const style = props.element.style || {}
|
||||
|
||||
// Выравнивание
|
||||
if (style.align) {
|
||||
styles.textAlign = style.align
|
||||
}
|
||||
|
||||
// Междустрочный интервал
|
||||
if (style.lineHeight) {
|
||||
styles.lineHeight = style.lineHeight
|
||||
}
|
||||
|
||||
// Отступы
|
||||
if (style.spaceBefore) {
|
||||
styles.marginTop = `${style.spaceBefore}pt`
|
||||
}
|
||||
|
||||
if (style.spaceAfter) {
|
||||
styles.marginBottom = `${style.spaceAfter}pt`
|
||||
}
|
||||
|
||||
if (style.indent) {
|
||||
if (style.indent.left) {
|
||||
styles.marginLeft = `${style.indent.left}pt`
|
||||
}
|
||||
if (style.indent.right) {
|
||||
styles.marginRight = `${style.indent.right}pt`
|
||||
}
|
||||
if (style.indent.firstLine) {
|
||||
styles.textIndent = `${style.indent.firstLine}pt`
|
||||
}
|
||||
}
|
||||
|
||||
return styles
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h2 :data-element-id="element.id" :style="styles" class="font-bold" v-html="content"></h2>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
35
resources/js/Components/Document/DocumentHtml.vue
Normal file
35
resources/js/Components/Document/DocumentHtml.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
content: String
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="html-content mb-4" v-html="content"></div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.html-content {
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.html-content ::v-deep(.placeholder) {
|
||||
background-color: #fffacd;
|
||||
border-bottom: 1px dashed #ccc;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.html-content ::v-deep(table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.html-content ::v-deep(table, th, td) {
|
||||
border: 1px solid black;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
17
resources/js/Components/Document/DocumentLineBreak.vue
Normal file
17
resources/js/Components/Document/DocumentLineBreak.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
element: {
|
||||
type: Object,
|
||||
default: {}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<br :data-element-id="element.id" class="block content-[\'\'] m-0 p-0 h-0" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
139
resources/js/Components/Document/DocumentParagraph.vue
Normal file
139
resources/js/Components/Document/DocumentParagraph.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import DocumentText from './DocumentText.vue';
|
||||
// import DocumentVariable from './DocumentVariable.vue';
|
||||
import DocumentTextRun from './DocumentTextRun.vue';
|
||||
import DocumentLineBreak from './DocumentLineBreak.vue';
|
||||
|
||||
const props = defineProps({
|
||||
element: Object,
|
||||
formData: Object
|
||||
});
|
||||
|
||||
defineEmits(['variable-click']);
|
||||
|
||||
const elementComponents = {
|
||||
text: DocumentText,
|
||||
// variable: DocumentVariable,
|
||||
text_run: DocumentTextRun,
|
||||
line_break: DocumentLineBreak
|
||||
};
|
||||
|
||||
const paragraphStyles = computed(() => {
|
||||
const styles = {};
|
||||
const paragraphStyle = props.element.style || {};
|
||||
|
||||
// Выравнивание
|
||||
if (paragraphStyle.align) {
|
||||
styles.textAlign = paragraphStyle.align;
|
||||
}
|
||||
|
||||
// Междустрочный интервал
|
||||
if (paragraphStyle.lineHeight) {
|
||||
styles.lineHeight = paragraphStyle.lineHeight;
|
||||
}
|
||||
|
||||
// Отступы
|
||||
if (paragraphStyle.spaceBefore) {
|
||||
styles.marginTop = `${paragraphStyle.spaceBefore}pt`;
|
||||
}
|
||||
|
||||
if (paragraphStyle.spaceAfter) {
|
||||
styles.marginBottom = `${paragraphStyle.spaceAfter}pt`;
|
||||
}
|
||||
|
||||
if (paragraphStyle.indent) {
|
||||
if (paragraphStyle.indent.left) {
|
||||
styles.marginLeft = `${paragraphStyle.indent.left}pt`;
|
||||
}
|
||||
if (paragraphStyle.indent.right) {
|
||||
styles.marginRight = `${paragraphStyle.indent.right}pt`;
|
||||
}
|
||||
if (paragraphStyle.indent.firstLine) {
|
||||
styles.textIndent = `${paragraphStyle.indent.firstLine}pt`;
|
||||
}
|
||||
}
|
||||
|
||||
return styles;
|
||||
});
|
||||
|
||||
const paragraphClasses = computed(() => {
|
||||
const classes = ['paragraph-element'];
|
||||
|
||||
if (props.element.style?.align) {
|
||||
classes.push(`align-${props.element.style.align}`);
|
||||
}
|
||||
|
||||
return classes;
|
||||
});
|
||||
|
||||
const getComponentForElement = (element) => {
|
||||
return elementComponents[element.type] || 'span';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p
|
||||
class="document-paragraph"
|
||||
:style="paragraphStyles"
|
||||
:class="paragraphClasses"
|
||||
>
|
||||
<template v-if="element.elements" v-for="element in element.elements" :key="element.id">
|
||||
<component
|
||||
:is="getComponentForElement(element)"
|
||||
:element="element"
|
||||
:form-data="formData"
|
||||
:is-inline="true"
|
||||
@variable-click="$emit('variable-click', $event)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ element.content }}
|
||||
</template>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.document-paragraph {
|
||||
margin: 0.5em 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.document-paragraph.align-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.document-paragraph.align-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.document-paragraph.align-justify {
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.document-paragraph.align-distribute {
|
||||
text-align: justify;
|
||||
text-justify: distribute;
|
||||
}
|
||||
|
||||
/* Наследование стилей для вложенных элементов */
|
||||
.document-paragraph ::v-deep(.text-element) {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.document-paragraph ::v-deep(.variable-element) {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.document-paragraph ::v-deep(.text-run) {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Стили для печати */
|
||||
@media print {
|
||||
.document-paragraph {
|
||||
text-align: justify;
|
||||
margin: 0.75em 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
107
resources/js/Components/Document/DocumentRenderer.vue
Normal file
107
resources/js/Components/Document/DocumentRenderer.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<script setup>
|
||||
import DocumentElement from "./DocumentElement.vue"
|
||||
import A4 from "./A4.vue";
|
||||
import {computed, getCurrentInstance, nextTick, onMounted, ref, watch, watchEffect} from "vue";
|
||||
import {useDynamicA4Layout} from "../../Composables/useDynamicA4Layout.js";
|
||||
import {waitForAllComponentsMounted} from "../../Utils/heightCalculator.js";
|
||||
|
||||
const props = defineProps({
|
||||
structure: Array,
|
||||
editable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['variable-click'])
|
||||
|
||||
const {calculateDynamicLayout, componentHeights, a4Pages} = useDynamicA4Layout()
|
||||
|
||||
const allElements = computed(() => props.structure.elements)
|
||||
const componentRefs = ref(new Map())
|
||||
|
||||
const handleVariableClick = (variableName) => {
|
||||
emit('variable-click', variableName)
|
||||
}
|
||||
|
||||
const elementRefs = ref(new Map())
|
||||
const addToRefs = (el, id) => {
|
||||
if (el)
|
||||
elementRefs.value.set(id, el.$el)
|
||||
}
|
||||
|
||||
const hasAllMounted = computed(() => {
|
||||
// Array.from(elementRefs.value.values())
|
||||
// .every(ref => ref.value?.$.vnode.el?.isConnected)
|
||||
|
||||
|
||||
})
|
||||
|
||||
// watch(() => hasAllMounted, async (newSize, oldSize) => {
|
||||
// const expectedCount = props.structure?.elements?.length || 0
|
||||
//
|
||||
// if (newSize === expectedCount && newSize > 0 && newSize !== oldSize) {
|
||||
// // Даём время на полное монтирование
|
||||
// await nextTick()
|
||||
// await nextTick() // Двойной nextTick для надёжности
|
||||
//
|
||||
// console.log(hasAllMounted.value)
|
||||
//
|
||||
// // try {
|
||||
// // await waitForAllComponentsMounted(getCurrentInstance())
|
||||
// // console.log('Все компоненты смонтированы!')
|
||||
// // await calculateDynamicLayout(props.structure.elements, () => elementRefs.value)
|
||||
// // } catch (error) {
|
||||
// // console.error('Ошибка при ожидании монтирования:', error)
|
||||
// // }
|
||||
// }
|
||||
// })
|
||||
onMounted(() => {
|
||||
// console.log(Array.from(elementRefs.value.values())[0].)
|
||||
})
|
||||
watch(() => elementRefs.value.size, async () => {
|
||||
const expectedCount = props.structure?.elements?.length || 0
|
||||
const currentSize = elementRefs.value.size
|
||||
|
||||
if (currentSize > 0 && currentSize === expectedCount) {
|
||||
await nextTick()
|
||||
await new Promise(resolve => setTimeout(resolve, 5000))
|
||||
// console.log('Все компоненты смонтированы!')
|
||||
await calculateDynamicLayout(props.structure.elements, () => elementRefs.value)
|
||||
}
|
||||
}, {
|
||||
flush: 'post'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<A4 class="absolute -left-[9999px] -top-[9999px] section collapse document-content overflow-hidden">
|
||||
<DocumentElement
|
||||
v-for="element in allElements"
|
||||
:ref="el => addToRefs(el, element.id)"
|
||||
:element="element"
|
||||
/>
|
||||
</A4>
|
||||
<A4 v-for="paginatedElement in a4Pages" cover class="section document-content my-2 overflow-hidden">
|
||||
<template v-for="item in paginatedElement.items" :key="item.id">
|
||||
<DocumentElement
|
||||
:contenteditable="editable"
|
||||
:class="editable ? 'overflow-hidden outline-none' : ''"
|
||||
:element="item"
|
||||
@variable-click="handleVariableClick"
|
||||
/>
|
||||
</template>
|
||||
</A4>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.document-content {
|
||||
line-height: 1.2;
|
||||
font-family: 'Times New Roman', serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.section {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
</style>
|
||||
40
resources/js/Components/Document/DocumentTable.vue
Normal file
40
resources/js/Components/Document/DocumentTable.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup>
|
||||
import {computed, ref} from "vue";
|
||||
import DocumentElement from "./DocumentElement.vue";
|
||||
import DocumentTableRow from "./DocumentTableRow.vue";
|
||||
|
||||
const props = defineProps({
|
||||
content: String,
|
||||
element: Object,
|
||||
})
|
||||
|
||||
const rows = computed(() => props.element.rows || [])
|
||||
const cols = computed(() => props.element.cols || 0)
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<table :data-element-id="element.id" class="table-element">
|
||||
<tbody>
|
||||
<DocumentTableRow @is-mounted="" :rows="rows" />
|
||||
<!-- <tr-->
|
||||
<!-- v-for="(row, rowIndex) in rows"-->
|
||||
<!-- :key="`row-${rowIndex}`"-->
|
||||
<!-- >-->
|
||||
<!-- <td-->
|
||||
<!-- v-for="(cell, colIndex) in row.cells"-->
|
||||
<!-- :key="`td-${rowIndex}-${colIndex}`"-->
|
||||
<!-- :style="`width: ${cell.width}px`"-->
|
||||
<!-- >-->
|
||||
<!-- <DocumentElement v-for="element in cell.elements" :element="element" :key="element.id" />-->
|
||||
<!-- </td>-->
|
||||
<!-- </tr>-->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
13
resources/js/Components/Document/DocumentTableCell.vue
Normal file
13
resources/js/Components/Document/DocumentTableCell.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
element: Object
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
29
resources/js/Components/Document/DocumentTableRow.vue
Normal file
29
resources/js/Components/Document/DocumentTableRow.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup>
|
||||
import DocumentElement from "./DocumentElement.vue";
|
||||
import {onMounted} from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
rows: Array
|
||||
})
|
||||
|
||||
const emits = defineEmits(['is-mounted'])
|
||||
|
||||
onMounted(() => {
|
||||
emits('is-mounted', true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr
|
||||
v-for="(row, rowIndex) in rows"
|
||||
:key="`row-${rowIndex}`"
|
||||
>
|
||||
<td v-for="cell in row.cells" :style="`width: ${cell.width}px`">
|
||||
<DocumentElement v-for="element in cell.elements" :element="element" />
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
93
resources/js/Components/Document/DocumentText.vue
Normal file
93
resources/js/Components/Document/DocumentText.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
parentElement: Object,
|
||||
element: Object,
|
||||
formData: Object,
|
||||
isInline: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const displayText = computed(() => {
|
||||
return props.element.content || '';
|
||||
});
|
||||
|
||||
const textStyles = computed(() => {
|
||||
const styles = {};
|
||||
const formatting = props.element.formatting || {};
|
||||
|
||||
if (formatting.fontSize) {
|
||||
styles.fontSize = formatting.fontSize;
|
||||
}
|
||||
|
||||
if (formatting.fontFamily) {
|
||||
styles.fontFamily = formatting.fontFamily;
|
||||
}
|
||||
|
||||
if (formatting.fontColor) {
|
||||
styles.color = formatting.fontColor;
|
||||
}
|
||||
|
||||
return styles;
|
||||
});
|
||||
|
||||
const textClasses = computed(() => {
|
||||
const classes = [];
|
||||
const formatting = props.element.formatting || {};
|
||||
|
||||
if (formatting.bold) {
|
||||
classes.push('text-bold');
|
||||
}
|
||||
|
||||
if (formatting.italic) {
|
||||
classes.push('text-italic');
|
||||
}
|
||||
|
||||
if (formatting.underline !== 'none') {
|
||||
classes.push('text-underline');
|
||||
}
|
||||
|
||||
return classes;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
:data-element-id="element.id"
|
||||
class="text-element"
|
||||
:style="textStyles"
|
||||
:class="textClasses"
|
||||
>
|
||||
{{ displayText }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.text-element {
|
||||
display: inline;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.text-bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.text-italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.text-underline {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Стили для печати */
|
||||
@media print {
|
||||
.text-element {
|
||||
color: black !important;
|
||||
font-family: 'Times New Roman', serif !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
141
resources/js/Components/Document/DocumentTextRun.vue
Normal file
141
resources/js/Components/Document/DocumentTextRun.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<script setup>
|
||||
import {computed, ref} from 'vue';
|
||||
import DocumentText from './DocumentText.vue';
|
||||
// import DocumentVariable from './DocumentVariable.vue';
|
||||
import DocumentLineBreak from './DocumentLineBreak.vue';
|
||||
|
||||
const props = defineProps({
|
||||
element: Object,
|
||||
formData: Object
|
||||
});
|
||||
|
||||
defineEmits(['variable-click']);
|
||||
|
||||
const elementComponents = {
|
||||
text: DocumentText,
|
||||
// variable: DocumentVariable,
|
||||
line_break: DocumentLineBreak
|
||||
}
|
||||
|
||||
const textRunRef = ref()
|
||||
|
||||
const combinedStyles = computed(() => {
|
||||
const styles = {}
|
||||
const formatting = props.element.formatting || {}
|
||||
const paragraphStyle = props.element.style || {}
|
||||
|
||||
// Стили шрифта
|
||||
if (formatting.fontSize) {
|
||||
styles.fontSize = formatting.fontSize;
|
||||
}
|
||||
|
||||
if (formatting.fontFamily) {
|
||||
styles.fontFamily = formatting.fontFamily;
|
||||
}
|
||||
|
||||
if (formatting.fontColor) {
|
||||
styles.color = formatting.fontColor;
|
||||
}
|
||||
|
||||
if (formatting.backgroundColor) {
|
||||
styles.backgroundColor = formatting.backgroundColor;
|
||||
}
|
||||
|
||||
// Стили параграфа (если есть)
|
||||
if (paragraphStyle.align) {
|
||||
styles.textAlign = paragraphStyle.align;
|
||||
}
|
||||
|
||||
if (paragraphStyle.lineHeight) {
|
||||
styles.lineHeight = paragraphStyle.lineHeight;
|
||||
}
|
||||
|
||||
if (paragraphStyle.indent) {
|
||||
// console.log(paragraphStyle.indent)
|
||||
if (paragraphStyle.indent.firstLine)
|
||||
styles['--first-line-indent'] = `${paragraphStyle.indent.firstLine}px`
|
||||
}
|
||||
|
||||
return styles;
|
||||
});
|
||||
|
||||
const textRunClasses = computed(() => {
|
||||
// const classes = []
|
||||
// const style = props.element.style
|
||||
// if (style && style.indent) {
|
||||
// if (style.indent.firstLine)
|
||||
// classes.push(`first:pl-[${style.indent.firstLine}px]`)
|
||||
// }
|
||||
//
|
||||
// return classes
|
||||
// const classes = ['text-run-element']
|
||||
//
|
||||
// const formatting = props.element.formatting || {}
|
||||
// if (formatting.bold) classes.push('text-bold')
|
||||
// if (formatting.italic) classes.push('text-italic')
|
||||
// if (formatting.underline !== 'none') classes.push('text-underline')
|
||||
// if (formatting.strikethrough) classes.push('text-strikethrough')
|
||||
//
|
||||
// return classes;
|
||||
});
|
||||
|
||||
const getComponentForElement = (element) => {
|
||||
return elementComponents[element.type] || 'span';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="textRunRef" :data-element-id="element.id" :style="combinedStyles" >
|
||||
<template v-for="children in element.elements" :key="children.id" >
|
||||
<DocumentText
|
||||
class="first:pl-[var(--first-line-indent)]"
|
||||
:element="children"
|
||||
:form-data="formData"
|
||||
@variable-click="$emit('variable-click', $event)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<style scoped>
|
||||
.text-run {
|
||||
display: inline;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.text-run-element {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.text-bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.text-italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.text-underline {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.text-strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* Наследование стилей для вложенных элементов */
|
||||
.text-run ::v-deep(.text-element) {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.text-run ::v-deep(.variable-element) {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.text-run ::v-deep(.line-break) {
|
||||
display: block;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
24
resources/js/Components/Document/DocumentWarning.vue
Normal file
24
resources/js/Components/Document/DocumentWarning.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
content: String
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="warning-note bg-yellow-50 border-l-4 border-yellow-400 p-4 mb-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-yellow-700" v-html="content"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
136
resources/js/Components/Document/InputVariable/PriceInput.vue
Normal file
136
resources/js/Components/Document/InputVariable/PriceInput.vue
Normal file
@@ -0,0 +1,136 @@
|
||||
<script setup>
|
||||
import Input from "../../Input/Input.vue";
|
||||
import {computed, watch} from "vue";
|
||||
|
||||
const numberModel = defineModel('number')
|
||||
const textModel = defineModel('text')
|
||||
|
||||
// Функция для преобразования числа в текст
|
||||
const numberToWords = (num) => {
|
||||
if (num === 0) return 'ноль рублей'
|
||||
if (num < 0) return 'минус ' + numberToWords(Math.abs(num))
|
||||
|
||||
const units = ['', 'один', 'два', 'три', 'четыре', 'пять', 'шесть', 'семь', 'восемь', 'девять']
|
||||
const teens = ['десять', 'одиннадцать', 'двенадцать', 'тринадцать', 'четырнадцать', 'пятнадцать', 'шестнадцать', 'семнадцать', 'восемнадцать', 'девятнадцать']
|
||||
const tens = ['', '', 'двадцать', 'тридцать', 'сорок', 'пятьдесят', 'шестьдесят', 'семьдесят', 'восемьдесят', 'девяносто']
|
||||
const hundreds = ['', 'сто', 'двести', 'триста', 'четыреста', 'пятьсот', 'шестьсот', 'семьсот', 'восемьсот', 'девятьсот']
|
||||
|
||||
const thousands = ['', 'одна', 'две', 'три', 'четыре', 'пять', 'шесть', 'семь', 'восемь', 'девять']
|
||||
const millions = ['', 'один', 'два', 'три', 'четыре', 'пять', 'шесть', 'семь', 'восемь', 'девять']
|
||||
const billions = ['', 'один', 'два', 'три', 'четыре', 'пять', 'шесть', 'семь', 'восемь', 'девять']
|
||||
|
||||
// Функция для преобразования трехзначного числа
|
||||
const convertThreeDigit = (n, isFemale = false) => {
|
||||
if (n === 0) return ''
|
||||
|
||||
let result = ''
|
||||
const hundred = Math.floor(n / 100)
|
||||
const remainder = n % 100
|
||||
|
||||
if (hundred > 0) {
|
||||
result += hundreds[hundred] + ' '
|
||||
}
|
||||
|
||||
if (remainder >= 20) {
|
||||
const ten = Math.floor(remainder / 10)
|
||||
const unit = remainder % 10
|
||||
result += tens[ten] + ' '
|
||||
if (unit > 0) {
|
||||
if (isFemale && unit <= 2) {
|
||||
result += (unit === 1 ? 'одна' : 'две') + ' '
|
||||
} else {
|
||||
result += units[unit] + ' '
|
||||
}
|
||||
}
|
||||
} else if (remainder >= 10) {
|
||||
result += teens[remainder - 10] + ' '
|
||||
} else if (remainder > 0) {
|
||||
if (isFemale && remainder <= 2) {
|
||||
result += (remainder === 1 ? 'одна' : 'две') + ' '
|
||||
} else {
|
||||
result += units[remainder] + ' '
|
||||
}
|
||||
}
|
||||
|
||||
return result.trim()
|
||||
}
|
||||
|
||||
// Функция для получения правильного окончания
|
||||
const getEnding = (num, forms) => {
|
||||
const lastDigit = num % 10
|
||||
const lastTwoDigits = num % 100
|
||||
|
||||
if (lastTwoDigits >= 11 && lastTwoDigits <= 14) {
|
||||
return forms[2]
|
||||
}
|
||||
if (lastDigit === 1) {
|
||||
return forms[0]
|
||||
}
|
||||
if (lastDigit >= 2 && lastDigit <= 4) {
|
||||
return forms[1]
|
||||
}
|
||||
return forms[2]
|
||||
}
|
||||
|
||||
let result = ''
|
||||
let remaining = num
|
||||
|
||||
// Миллиарды
|
||||
const billionsPart = Math.floor(remaining / 1000000000)
|
||||
if (billionsPart > 0) {
|
||||
result += convertThreeDigit(billionsPart) + ' '
|
||||
result += getEnding(billionsPart, ['миллиард', 'миллиарда', 'миллиардов']) + ' '
|
||||
remaining %= 1000000000
|
||||
}
|
||||
|
||||
// Миллионы
|
||||
const millionsPart = Math.floor(remaining / 1000000)
|
||||
if (millionsPart > 0) {
|
||||
result += convertThreeDigit(millionsPart) + ' '
|
||||
result += getEnding(millionsPart, ['миллион', 'миллиона', 'миллионов']) + ' '
|
||||
remaining %= 1000000
|
||||
}
|
||||
|
||||
// Тысячи
|
||||
const thousandsPart = Math.floor(remaining / 1000)
|
||||
if (thousandsPart > 0) {
|
||||
result += convertThreeDigit(thousandsPart, true) + ' '
|
||||
result += getEnding(thousandsPart, ['тысяча', 'тысячи', 'тысяч']) + ' '
|
||||
remaining %= 1000
|
||||
}
|
||||
|
||||
// Сотни, десятки, единицы
|
||||
if (remaining > 0) {
|
||||
result += convertThreeDigit(remaining) + ' '
|
||||
}
|
||||
|
||||
// Добавляем рубли с правильным окончанием
|
||||
result += getEnding(num, ['рубль', 'рубля', 'рублей'])
|
||||
|
||||
return result.trim().replace(/\s+/g, ' ')
|
||||
}
|
||||
|
||||
// Вычисляемое свойство для текстового представления
|
||||
const amountInWords = computed(() => {
|
||||
const num = Number(numberModel.value) || 0
|
||||
return numberToWords(num)
|
||||
})
|
||||
|
||||
// Следим за изменениями и обновляем модель
|
||||
watch(amountInWords, (newValue) => {
|
||||
textModel.value = `${numberModel.value} руб. (${newValue})`
|
||||
}, { immediate: true })
|
||||
|
||||
// Обработчик изменения ввода
|
||||
const handleInput = (value) => {
|
||||
numberModel.value = value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Input v-model:value="numberModel" @update:value="handleInput" placeholder="Введите сумму" type="number" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
753
resources/js/Components/Editor.vue
Normal file
753
resources/js/Components/Editor.vue
Normal file
@@ -0,0 +1,753 @@
|
||||
<template>
|
||||
<div class="editor" ref="editor">
|
||||
|
||||
<!-- Page overlays (headers, footers, page numbers, ...) -->
|
||||
<div v-if="overlay" class="overlays" ref="overlays">
|
||||
<div v-for="(page, page_idx) in pages" class="overlay" :key="page.uuid+'-overlay'" :ref="(elt) => (pages_overlay_refs[page.uuid] = elt)"
|
||||
v-html="overlay(page_idx+1, pages.length)" :style="page_style(page_idx, false)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Document editor -->
|
||||
<div class="content" ref="content" :contenteditable="editable" :style="page_style(-1)" @input="input" @keyup="e => processElement(e)">
|
||||
<!-- This is a Vue "hoisted" static <div> which contains every page of the document and can be modified by the DOM -->
|
||||
</div>
|
||||
|
||||
<!-- Items related to the document editor (widgets, ...) can be inserted here -->
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {computed, defineCustomElement, onBeforeUpdate, onMounted, onUnmounted, ref, watch} from 'vue';
|
||||
import { move_children_forward_recursively, move_children_backwards_with_merging } from '../Utils/pageTransitionMgmt.js';
|
||||
|
||||
const props = defineProps({
|
||||
// This contains the initial content of the document that can be synced
|
||||
// It must be an Array: each array item is a new set of pages containing the
|
||||
// item (string or component). You can see that as predefined page breaks.
|
||||
// See the Demo.vue file for a good usage example.
|
||||
// content: {
|
||||
// type: Array,
|
||||
// required: true
|
||||
// },
|
||||
|
||||
// Display mode of the pages
|
||||
display: {
|
||||
type: String,
|
||||
default: "grid" // ["grid", "horizontal", "vertical"]
|
||||
},
|
||||
|
||||
// Sets whether document text can be modified
|
||||
editable: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
|
||||
// Overlay function returning page headers and footers in HTML
|
||||
overlay: Function,
|
||||
|
||||
// Pages format in mm (should be an array containing [width, height])
|
||||
page_format_mm: {
|
||||
type: Array,
|
||||
default: () => [210, 297]
|
||||
},
|
||||
|
||||
// Page margins in CSS
|
||||
page_margins: {
|
||||
type: [String, Function],
|
||||
default: "10mm 15mm"
|
||||
},
|
||||
|
||||
// Display zoom. Only acts on the screen display
|
||||
zoom: {
|
||||
type: Number,
|
||||
default: 1.0
|
||||
},
|
||||
|
||||
// "Do not break" test function: should return true on elements you don't want to be split over multiple pages but rather be moved to the next page
|
||||
do_not_break: Function
|
||||
})
|
||||
|
||||
const emits = defineEmits(['update:content', 'update:current-style', 'update:activeElement'])
|
||||
|
||||
const model = defineModel()
|
||||
const editor = ref()
|
||||
const content = ref()
|
||||
const overlays = ref()
|
||||
|
||||
const pages = ref([]) // contains {uuid, content_idx, prev_html, template, props, elt} for each pages of the document
|
||||
const pages_overlay_refs = ref({}) // contains page overlay ref elements indexed by uuid
|
||||
const pages_height = ref(0) // real measured page height in px (corresponding to page_format_mm[1])
|
||||
const editor_width = ref(0) // real measured with of an empty editor <div> in px
|
||||
const prevent_next_content_update_from_parent = ref(false) // workaround to avoid infinite update loop
|
||||
const current_text_style = ref(false) // contains the style at caret position
|
||||
const activeElement = defineModel('active-element')
|
||||
const activeElements = defineModel('active-elements')
|
||||
const printing_mode = ref(false) // flag set when page is rendering in printing mode
|
||||
const reset_in_progress = ref(false)
|
||||
const fit_in_progress = ref(false)
|
||||
const _page_body = ref()
|
||||
|
||||
const css_media_style = computed(() => {
|
||||
const style = document.createElement("style");
|
||||
document.head.appendChild(style);
|
||||
return style;
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
update_editor_width();
|
||||
update_css_media_style();
|
||||
reset_content();
|
||||
window.addEventListener("resize", update_editor_width);
|
||||
window.addEventListener("click", processElement);
|
||||
window.addEventListener("beforeprint", before_print);
|
||||
window.addEventListener("afterprint", after_print);
|
||||
})
|
||||
|
||||
onBeforeUpdate(() => {
|
||||
pages_overlay_refs.value = []
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("resize", update_editor_width);
|
||||
window.removeEventListener("click", processElement);
|
||||
window.removeEventListener("beforeprint", before_print);
|
||||
window.removeEventListener("afterprint", after_print);
|
||||
})
|
||||
|
||||
// Computes a random 5-char UUID
|
||||
const new_uuid = () => Math.random().toString(36).slice(-5)
|
||||
|
||||
// Resets all content from the content property
|
||||
const reset_content = () => {
|
||||
// Prevent launching this function multiple times
|
||||
if(reset_in_progress.value) return;
|
||||
reset_in_progress.value = true;
|
||||
|
||||
// If provided content is empty, initialize it first and exit
|
||||
if(!model.value.length) {
|
||||
reset_in_progress.value = false;
|
||||
model.value = [""]
|
||||
// emits("update:content", [""]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete all pages and set one new page per content item
|
||||
pages.value = model.value.map((content, content_idx) => ({
|
||||
uuid: new_uuid(),
|
||||
content_idx,
|
||||
template: content.template,
|
||||
props: content.props
|
||||
}));
|
||||
update_pages_elts();
|
||||
|
||||
// Get page height from first empty page
|
||||
const first_page_elt = pages.value[0].elt;
|
||||
if(!content.value.contains(first_page_elt)) content.value.appendChild(first_page_elt); // restore page in DOM in case it was removed
|
||||
pages_height.value = first_page_elt.clientHeight + 1; // allow one pixel precision
|
||||
|
||||
// Initialize text pages
|
||||
for(const page of pages.value) {
|
||||
|
||||
// set raw HTML content
|
||||
if(!model.value[page.content_idx]) page.elt.innerHTML = "<div><br></div>"; // ensure empty pages are filled with at least <div><br></div>, otherwise editing fails on Chrome
|
||||
else if(typeof model.value[page.content_idx] == "string") page.elt.innerHTML = "<div>"+model.value[page.content_idx]+"</div>";
|
||||
else if(page.template) {
|
||||
const componentElement = defineCustomElement(page.template);
|
||||
customElements.define('component-'+page.uuid, componentElement);
|
||||
page.elt.appendChild(new componentElement({ modelValue: page.props }));
|
||||
}
|
||||
|
||||
// restore page in DOM in case it was removed
|
||||
if(!content.value.contains(page.elt)) content.value.appendChild(page.elt);
|
||||
}
|
||||
|
||||
// Spread content over several pages if it overflows
|
||||
fit_content_over_pages();
|
||||
|
||||
// Remove the text cursor from the content, if any (its position is lost anyway)
|
||||
content.value.blur();
|
||||
|
||||
// Clear "reset in progress" flag
|
||||
reset_in_progress.value = false;
|
||||
}
|
||||
|
||||
// Spreads the HTML content over several pages until it fits
|
||||
const fit_content_over_pages = () => {
|
||||
// Data variable pages_height.value must have been set before calling this function
|
||||
if(!pages_height.value) return;
|
||||
|
||||
// Prevent launching this function multiple times
|
||||
if(fit_in_progress.value) return;
|
||||
fit_in_progress.value = true;
|
||||
|
||||
// Check pages that were deleted from the DOM (start from the end)
|
||||
for(let page_idx = pages.value.length - 1; page_idx >= 0; page_idx--) {
|
||||
const page = pages.value[page_idx];
|
||||
|
||||
// if user deleted the page from the DOM, then remove it from pages.value array
|
||||
if(!page.elt || !document.body.contains(page.elt)) pages.value.splice(page_idx, 1);
|
||||
}
|
||||
|
||||
// If all the document was wiped out, start a new empty document
|
||||
if(!pages.value.length){
|
||||
fit_in_progress.value = false; // clear "fit in progress" flag
|
||||
model.value = [""]
|
||||
// emits("update:content", [""]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Save current selection (or cursor position) by inserting empty HTML elements at the start and the end of it
|
||||
const selection = window.getSelection();
|
||||
const start_marker = document.createElement("null");
|
||||
const end_marker = document.createElement("null");
|
||||
// don't insert markers in case selection fails (if we are editing in components in the shadow-root it selects the page <div> as anchorNode)
|
||||
if(selection && selection.rangeCount && selection.anchorNode && !(selection.anchorNode.dataset && selection.anchorNode.dataset.isVDEPage != null)) {
|
||||
const range = selection.getRangeAt(0);
|
||||
range.insertNode(start_marker);
|
||||
range.collapse(false);
|
||||
range.insertNode(end_marker);
|
||||
}
|
||||
|
||||
// Browse every remaining page
|
||||
let prev_page_modified_flag = false;
|
||||
for(let page_idx = 0; page_idx < pages.value.length; page_idx++) { // page length can grow inside this loop
|
||||
const page = pages.value[page_idx];
|
||||
let next_page = pages.value[page_idx + 1];
|
||||
let next_page_elt = next_page ? next_page.elt : null;
|
||||
|
||||
// check if this page, the next page, or any previous page content has been modified by the user (don't apply to template pages)
|
||||
if(!page.template && (prev_page_modified_flag || page.elt.innerHTML !== page.prev_innerHTML
|
||||
|| (next_page_elt && !next_page.template && next_page_elt.innerHTML !== next_page.prev_innerHTML))){
|
||||
prev_page_modified_flag = true;
|
||||
|
||||
// BACKWARD-PROPAGATION
|
||||
// check if content doesn't overflow, and that next page exists and has the same content_idx
|
||||
if(page.elt.clientHeight <= pages_height.value && next_page && next_page.content_idx === page.content_idx) {
|
||||
|
||||
// try to append every node from the next page until it doesn't fit
|
||||
move_children_backwards_with_merging(page.elt, next_page_elt, () => page.elt.clientHeight > pages_height.value || !next_page_elt.childNodes.length);
|
||||
}
|
||||
|
||||
// FORWARD-PROPAGATION
|
||||
// check if content overflows
|
||||
if(page.elt.clientHeight > pages_height.value) {
|
||||
|
||||
// if there is no next page for the same content, create it
|
||||
if(!next_page || next_page.content_idx !== page.content_idx) {
|
||||
next_page = { uuid: new_uuid(), content_idx: page.content_idx };
|
||||
pages.value.splice(page_idx + 1, 0, next_page);
|
||||
update_pages_elts();
|
||||
next_page_elt = next_page.elt;
|
||||
}
|
||||
|
||||
console.log(next_page_elt)
|
||||
|
||||
// move the content step by step to the next page, until it fits
|
||||
move_children_forward_recursively(page.elt, next_page_elt, () => (page.elt.clientHeight <= pages_height.value), props.do_not_break);
|
||||
}
|
||||
|
||||
// CLEANING
|
||||
// remove next page if it is empty
|
||||
if(next_page_elt && next_page.content_idx === page.content_idx && !next_page_elt.childNodes.length) {
|
||||
pages.value.splice(page_idx + 1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// update pages in the DOM
|
||||
update_pages_elts();
|
||||
}
|
||||
|
||||
// Normalize pages HTML content
|
||||
for(const page of pages.value) {
|
||||
if(!page.template) page.elt.normalize(); // normalize HTML (merge text nodes) - don't touch template pages or it can break Vue
|
||||
}
|
||||
|
||||
// Restore selection and remove empty elements
|
||||
if(document.body.contains(start_marker)){
|
||||
const range = document.createRange();
|
||||
range.setStart(start_marker, 0);
|
||||
if(document.body.contains(end_marker)) range.setEnd(end_marker, 0);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
if(start_marker.parentElement) start_marker.parentElement.removeChild(start_marker);
|
||||
if(end_marker.parentElement) end_marker.parentElement.removeChild(end_marker);
|
||||
|
||||
// Store pages HTML content
|
||||
for(const page of pages.value) {
|
||||
page.prev_innerHTML = page.elt.innerHTML; // store current pages innerHTML for next call
|
||||
}
|
||||
|
||||
// Clear "fit in progress" flag
|
||||
fit_in_progress.value = false;
|
||||
}
|
||||
|
||||
// Input event
|
||||
const input = (e) => {
|
||||
if(!e) return; // check that event is set
|
||||
fit_content_over_pages(); // fit content according to modifications
|
||||
emit_new_content(); // emit content modification
|
||||
if(e.inputType !== "insertText") processElement(); // update current style if it has changed
|
||||
}
|
||||
|
||||
// Emit content change to parent
|
||||
const emit_new_content = () => {
|
||||
let removed_pages_flag = false; // flag to call reset_content if some pages were removed by the user
|
||||
|
||||
// process the new content
|
||||
const new_content = model.value.map((item, content_idx) => {
|
||||
// select pages that correspond to this content item (represented by its index in the array)
|
||||
const pgs = pages.value.filter(page => (page.content_idx === content_idx));
|
||||
|
||||
// if there are no pages representing this content (because deleted by the user), mark item as false to remove it
|
||||
if(!pgs.length) {
|
||||
removed_pages_flag = true;
|
||||
return false;
|
||||
}
|
||||
// if item is a string, concatenate each page content and set that
|
||||
else if(typeof item == "string") {
|
||||
return pgs.map(page => {
|
||||
// remove any useless <div> surrounding the content
|
||||
let elt = page.elt;
|
||||
while(elt.children.length === 1 && elt.firstChild.tagName && elt.firstChild.tagName.toLowerCase() === "div" && !elt.firstChild.getAttribute("style")) {
|
||||
elt = elt.firstChild;
|
||||
}
|
||||
return ((elt.innerHTML === "<br>" || elt.innerHTML === "<!---->") ? "" : elt.innerHTML); // treat a page containing a single <br> or an empty comment as an empty content
|
||||
}).join('');
|
||||
}
|
||||
// if item is a component, just clone the item
|
||||
else return { template: item.template, props: { ...item.props }};
|
||||
}).filter(item => (item !== false)); // remove empty items
|
||||
|
||||
// avoid calling reset_content after the parent content is updated (infinite loop)
|
||||
if(!removed_pages_flag) prevent_next_content_update_from_parent.value = true;
|
||||
|
||||
// send event to parent to update the synced content
|
||||
model.value = new_content
|
||||
// emits("update:content", new_content);
|
||||
}
|
||||
|
||||
// Sets current_text_style with CSS style at caret position
|
||||
const processElement = (e) => {
|
||||
process_current_text_style()
|
||||
processCurrentElement(e)
|
||||
processSelectedElements(e)
|
||||
}
|
||||
|
||||
const processSelectedElements = () => {
|
||||
const selection = window.getSelection()
|
||||
|
||||
const elements = new Set()
|
||||
|
||||
for (let i = 0; i < selection.rangeCount; i++) {
|
||||
const range = selection.getRangeAt(i)
|
||||
|
||||
// Получаем общий контейнер выделения
|
||||
const commonAncestor = range.commonAncestorContainer
|
||||
|
||||
// Если это текстовый узел, берем его родителя
|
||||
if (commonAncestor.nodeType === Node.TEXT_NODE) {
|
||||
elements.add(commonAncestor.parentElement)
|
||||
} else {
|
||||
// Ищем все текстовые узлы в диапазоне
|
||||
const treeWalker = document.createTreeWalker(
|
||||
commonAncestor,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
{
|
||||
acceptNode: (node) => {
|
||||
return range.intersectsNode(node) ?
|
||||
NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
let textNode
|
||||
while (textNode = treeWalker.nextNode()) {
|
||||
elements.add(textNode.parentElement)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasEditorParent = []
|
||||
|
||||
for (const element of elements) {
|
||||
hasEditorParent.push(checkForEditorParent(element))
|
||||
}
|
||||
|
||||
if (hasEditorParent.every(i => i === true) && elements.size > 1) {
|
||||
activeElements.value = Array.from(elements)
|
||||
return
|
||||
}
|
||||
|
||||
activeElements.value = []
|
||||
}
|
||||
|
||||
const process_current_text_style = () => {
|
||||
let style = false;
|
||||
const sel = window.getSelection();
|
||||
if(sel.focusNode) {
|
||||
const element = sel.focusNode.tagName ? sel.focusNode : sel.focusNode.parentElement;
|
||||
if(element && element.isContentEditable) {
|
||||
style = window.getComputedStyle(element);
|
||||
|
||||
// compute additional properties
|
||||
style.textDecorationStack = []; // array of text-decoration strings from parent elements
|
||||
style.headerLevel = 0;
|
||||
style.isList = false;
|
||||
let parent = element;
|
||||
while(parent){
|
||||
const parent_style = window.getComputedStyle(parent);
|
||||
// stack CSS text-decoration as it is not overridden by children
|
||||
style.textDecorationStack.push(parent_style.textDecoration);
|
||||
// check if one parent is a list-item
|
||||
if(parent_style.display === "list-item") style.isList = true;
|
||||
// get first header level, if any
|
||||
if(!style.headerLevel){
|
||||
for(let i = 1; i <= 6; i++){
|
||||
if(parent.tagName.toUpperCase() === "H"+i) {
|
||||
style.headerLevel = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emits('update:current-style', style)
|
||||
current_text_style.value = style;
|
||||
}
|
||||
const processCurrentElement = (e) => {
|
||||
let element = false;
|
||||
const sel = window.getSelection();
|
||||
if(sel.focusNode) {
|
||||
element = sel.focusNode.tagName ? sel.focusNode : sel.focusNode.parentElement;
|
||||
}
|
||||
|
||||
const hasEditorParent = checkForEditorParent(element)
|
||||
|
||||
// console.log(element)
|
||||
if (hasEditorParent) {
|
||||
emits('update:activeElement', element)
|
||||
activeElement.value = element
|
||||
}
|
||||
}
|
||||
|
||||
const checkForEditorParent = (element) => {
|
||||
if (!element) return false;
|
||||
|
||||
let currentElement = element;
|
||||
|
||||
// Поднимаемся вверх по DOM дереву и проверяем родителей
|
||||
while (currentElement && currentElement !== document.body) {
|
||||
// Проверяем классы
|
||||
if (currentElement.classList && currentElement.classList.contains('editor')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Проверяем id
|
||||
if (currentElement.id === 'editor') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Переходим к родительскому элементу
|
||||
currentElement = currentElement.parentElement;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Process the specific style (position and size) of each page <div> and content <div>
|
||||
const page_style = (page_idx, allow_overflow) => {
|
||||
const px_in_mm = 0.2645833333333;
|
||||
const page_width = props.page_format_mm[0] / px_in_mm;
|
||||
const page_spacing_mm = 10;
|
||||
const page_with_plus_spacing = (page_spacing_mm + props.page_format_mm[0]) * props.zoom / px_in_mm;
|
||||
const view_padding = 20;
|
||||
const inner_width = editor_width.value - 2 * view_padding;
|
||||
let nb_pages_x = 1, page_column, x_pos, x_ofx, left_px, top_mm, bkg_width_mm, bkg_height_mm;
|
||||
if(props.display === "horizontal") {
|
||||
if(inner_width > (pages.value.length * page_with_plus_spacing)){
|
||||
nb_pages_x = Math.floor(inner_width / page_with_plus_spacing);
|
||||
left_px = inner_width / (nb_pages_x * 2) * (1 + page_idx * 2) - page_width / 2;
|
||||
} else {
|
||||
nb_pages_x = pages.value.length;
|
||||
left_px = page_with_plus_spacing * page_idx + page_width / 2 * (props.zoom - 1);
|
||||
}
|
||||
top_mm = 0;
|
||||
bkg_width_mm = props.zoom * (props.page_format_mm[0] * nb_pages_x + (nb_pages_x - 1) * page_spacing_mm);
|
||||
bkg_height_mm = props.page_format_mm[1] * props.zoom;
|
||||
} else { // "grid", vertical
|
||||
nb_pages_x = Math.floor(inner_width / page_with_plus_spacing);
|
||||
if(nb_pages_x < 1 || props.display === "vertical") nb_pages_x = 1;
|
||||
page_column = (page_idx % nb_pages_x);
|
||||
x_pos = inner_width / (nb_pages_x * 2) * (1 + page_column * 2) - page_width / 2;
|
||||
x_ofx = Math.max(0, (page_width * props.zoom - inner_width) / 2);
|
||||
left_px = x_pos + x_ofx;
|
||||
top_mm = ((props.page_format_mm[1] + page_spacing_mm) * props.zoom) * Math.floor(page_idx / nb_pages_x);
|
||||
const nb_pages_y = Math.ceil(pages.value.length / nb_pages_x);
|
||||
bkg_width_mm = props.zoom * (props.page_format_mm[0] * nb_pages_x + (nb_pages_x - 1) * page_spacing_mm);
|
||||
bkg_height_mm = props.zoom * (props.page_format_mm[1] * nb_pages_y + (nb_pages_y - 1) * page_spacing_mm);
|
||||
}
|
||||
if(page_idx >= 0) {
|
||||
const style = {
|
||||
position: "absolute",
|
||||
left: "calc("+ left_px +"px + "+ view_padding +"px)",
|
||||
top: "calc("+ top_mm +"mm + "+ view_padding +"px)",
|
||||
width: props.page_format_mm[0]+"mm",
|
||||
// "height" is set below
|
||||
padding: (typeof props.page_margins == "function") ? props.page_margins(page_idx + 1, pages.value.length) : props.page_margins,
|
||||
transform: "scale("+ props.zoom +")"
|
||||
};
|
||||
style[allow_overflow ? "minHeight" : "height"] = props.page_format_mm[1]+"mm";
|
||||
return style;
|
||||
} else {
|
||||
// Content/background <div> is sized so it lets a margin around pages when scrolling at the end
|
||||
return { width: "calc("+ bkg_width_mm +"mm + "+ (2*view_padding) +"px)", height: "calc("+ bkg_height_mm +"mm + "+ (2*view_padding) +"px)" };
|
||||
}
|
||||
}
|
||||
|
||||
// Utility to convert page_style to CSS string
|
||||
const css_to_string = (css) => Object.entries(css).map(([k, v]) => k.replace(/[A-Z]/g, match => ("-"+match.toLowerCase()))+":"+v).join(';')
|
||||
|
||||
// Update pages <div> from pages.value data
|
||||
const update_pages_elts = () => {
|
||||
// Removing deleted pages
|
||||
const deleted_pages = [...content.value.children].filter((page_elt) => !pages.value.find(page => (page.elt === page_elt)));
|
||||
for(const page_elt of deleted_pages) { page_elt.remove(); }
|
||||
|
||||
// Adding / updating pages
|
||||
for(const [page_idx, page] of pages.value.entries()) {
|
||||
// Get either existing page_elt or create it
|
||||
if(!page.elt) {
|
||||
page.elt = document.createElement("div");
|
||||
page.elt.className = "page";
|
||||
page.elt.dataset.isVDEPage = "";
|
||||
const next_page = pages.value[page_idx + 1];
|
||||
content.value.insertBefore(page.elt, next_page ? next_page.elt : null);
|
||||
}
|
||||
// Update page properties
|
||||
page.elt.dataset.contentIdx = page.content_idx;
|
||||
if(!printing_mode.value) page.elt.style = Object.entries(page_style(page_idx, page.template ? false : true)).map(([k, v]) => k.replace(/[A-Z]/g, match => ("-"+match.toLowerCase()))+":"+v).join(';'); // (convert page_style to CSS string)
|
||||
page.elt.contentEditable = (props.editable && !page.template) ? true : false;
|
||||
}
|
||||
}
|
||||
|
||||
// Get and store empty editor <div> width
|
||||
const update_editor_width = () => {
|
||||
editor.value.classList.add("hide_children");
|
||||
editor_width.value = editor.value.clientWidth;
|
||||
update_pages_elts();
|
||||
editor.value.classList.remove("hide_children");
|
||||
}
|
||||
|
||||
const update_css_media_style = () => {
|
||||
css_media_style.innerHTML = "@media print { @page { size: "+props.page_format_mm[0]+"mm "+props.page_format_mm[1]+"mm; margin: 0 !important; } .hidden-print { display: none !important; } }";
|
||||
}
|
||||
|
||||
// Prepare content before opening the native print box
|
||||
const before_print = () => {
|
||||
// set the printing mode flag
|
||||
printing_mode.value = true;
|
||||
|
||||
console.log('start printing')
|
||||
|
||||
// store the current body aside
|
||||
_page_body.value = document.body;
|
||||
|
||||
// create a new body for the print and overwrite CSS
|
||||
const print_body = document.createElement("body");
|
||||
print_body.style.margin = "0";
|
||||
print_body.style.padding = "0";
|
||||
print_body.style.background = "white";
|
||||
print_body.style.font = window.getComputedStyle(editor.value).font;
|
||||
print_body.className = editor.value.className;
|
||||
|
||||
// move each page to the print body
|
||||
for(const [page_idx, page] of pages.value.entries()){
|
||||
//const page_clone = page_elt.cloneNode(true);
|
||||
page.elt.style = ""; // reset page style for the clone
|
||||
page.elt.style.position = "relative";
|
||||
page.elt.style.padding = (typeof props.page_margins == "function") ? props.page_margins(page_idx + 1, pages.value.length) : props.page_margins;
|
||||
page.elt.style.breakBefore = page_idx ? "page" : "auto";
|
||||
page.elt.style.width = "calc("+props.page_format_mm[0]+"mm - 2px)";
|
||||
page.elt.style.height = "calc("+props.page_format_mm[1]+"mm - 2px)";
|
||||
page.elt.style.boxSizing = "border-box";
|
||||
page.elt.style.overflow = "hidden";
|
||||
|
||||
// add overlays if any
|
||||
const overlay_elt = pages_overlay_refs[page.uuid];
|
||||
if(overlay_elt){
|
||||
overlay_elt.style.position = "absolute";
|
||||
overlay_elt.style.left = "0";
|
||||
overlay_elt.style.top = "0";
|
||||
overlay_elt.style.transform = "none";
|
||||
overlay_elt.style.padding = "0";
|
||||
overlay_elt.style.overflow = "hidden";
|
||||
page.elt.prepend(overlay_elt);
|
||||
}
|
||||
|
||||
print_body.append(page.elt);
|
||||
}
|
||||
|
||||
// display a return arrow to let the user restore the original body in case the navigator doesn't call after_print() (it happens sometimes in Chrome)
|
||||
// const return_overlay = document.createElement("div");
|
||||
// return_overlay.className = "hidden-print"; // css managed in update_css_media_style method
|
||||
// return_overlay.style.position = "fixed";
|
||||
// return_overlay.style.left = "0";
|
||||
// return_overlay.style.top = "0";
|
||||
// return_overlay.style.right = "0";
|
||||
// return_overlay.style.bottom = "0";
|
||||
// return_overlay.style.display = "flex";
|
||||
// return_overlay.style.alignItems = "center";
|
||||
// return_overlay.style.justifyContent = "center";
|
||||
// return_overlay.style.background = "rgba(255, 255, 255, 0.95)";
|
||||
// return_overlay.style.cursor = "pointer";
|
||||
// return_overlay.innerHTML = '<svg width="220" height="220"><path fill="rgba(0, 0, 0, 0.7)" d="M120.774,179.271v40c47.303,0,85.784-38.482,85.784-85.785c0-47.3-38.481-85.782-85.784-85.782H89.282L108.7,28.286L80.417,0L12.713,67.703l67.703,67.701l28.283-28.284L89.282,87.703h31.492c25.246,0,45.784,20.538,45.784,45.783C166.558,158.73,146.02,179.271,120.774,179.271z"/></svg>'
|
||||
// return_overlay.addEventListener("click", after_print);
|
||||
// print_body.append(return_overlay);
|
||||
|
||||
// replace current body by the print body
|
||||
document.body = print_body;
|
||||
}
|
||||
|
||||
// Restore content after closing the native print box
|
||||
const after_print = () => {
|
||||
// clear the printing mode flag
|
||||
printing_mode.value = false;
|
||||
|
||||
// restore pages and overlays
|
||||
for(const [page_idx, page] of pages.value.entries()){
|
||||
page.elt.style = css_to_string(page_style(page_idx, page.template ? false : true));
|
||||
content.value.append(page.elt);
|
||||
const overlay_elt = pages_overlay_refs[page.uuid];
|
||||
if(overlay_elt) {
|
||||
overlay_elt.style = css_to_string(page_style(page_idx, false));
|
||||
overlays.value.append(overlay_elt);
|
||||
}
|
||||
}
|
||||
document.body = _page_body.value;
|
||||
|
||||
// recompute editor with and reposition elements
|
||||
update_editor_width();
|
||||
}
|
||||
|
||||
watch(model, () => {
|
||||
if(prevent_next_content_update_from_parent.value) {
|
||||
prevent_next_content_update_from_parent.value = false;
|
||||
} else reset_content();
|
||||
}, {
|
||||
deep: true
|
||||
})
|
||||
|
||||
watch(props.display, () => {
|
||||
update_pages_elts()
|
||||
})
|
||||
|
||||
watch(props.page_format_mm, () => {
|
||||
update_css_media_style()
|
||||
reset_content()
|
||||
})
|
||||
|
||||
watch(props.page_margins, () => {
|
||||
reset_content()
|
||||
})
|
||||
|
||||
watch(props.zoom, () => {
|
||||
update_pages_elts()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
/* Enable printing of background colors */
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
</style>
|
||||
<style scoped>
|
||||
.editor {
|
||||
display: block;
|
||||
font-family: 'Times New Roman', serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
cursor: default;
|
||||
}
|
||||
.editor ::-webkit-scrollbar {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.editor ::-webkit-scrollbar-track,
|
||||
.editor ::-webkit-scrollbar-corner {
|
||||
display: none;
|
||||
}
|
||||
.editor ::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
border: 5px solid transparent;
|
||||
border-radius: 16px;
|
||||
background-clip: content-box;
|
||||
}
|
||||
.editor ::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
.editor .hide_children > * {
|
||||
display: none;
|
||||
}
|
||||
.editor > .content {
|
||||
position: relative;
|
||||
outline: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-width: 100%;
|
||||
pointer-events: none;
|
||||
color: black;
|
||||
}
|
||||
.editor > .content > :deep(.page) {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
left: 50%;
|
||||
transform-origin: center top;
|
||||
background: var(--page-background, white);
|
||||
box-shadow: var(--page-box-shadow, 0 1px 3px 1px rgba(60, 64, 67, 0.15));
|
||||
border: var(--page-border);
|
||||
border-radius: var(--page-border-radius);
|
||||
transition: left 0.3s, top 0.3s;
|
||||
overflow: hidden;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
/* Переменные */
|
||||
.editor > .content[brs-variable],
|
||||
.editor > .content :deep(*[brs-variable]) {
|
||||
background-color: yellow;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
}
|
||||
|
||||
.editor > .content[contenteditable],
|
||||
.editor > .content :deep(*[contenteditable]) {
|
||||
cursor: text;
|
||||
}
|
||||
.editor > .content :deep(*[contenteditable=false]) {
|
||||
cursor: default;
|
||||
}
|
||||
.editor > .overlays {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-width: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
.editor > .overlays > .overlay {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
left: 50%;
|
||||
transform-origin: center top;
|
||||
transition: left 0.3s, top 0.3s;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
43
resources/js/Components/Form/FormGroup.vue
Normal file
43
resources/js/Components/Form/FormGroup.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup>
|
||||
import {computed} from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
position: {
|
||||
type: String,
|
||||
default: 'top'
|
||||
}
|
||||
})
|
||||
|
||||
const labelPositions = {
|
||||
left: [
|
||||
'flex flex-row gap-x-2 items-center'
|
||||
],
|
||||
top: [
|
||||
'flex flex-col gap-y-1'
|
||||
]
|
||||
}
|
||||
|
||||
const labelPositionClass = computed(() => {
|
||||
if (props.label)
|
||||
return labelPositions[props.position]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="labelPositionClass">
|
||||
<label v-if="label" class="text-base/6 text-zinc-950 select-none data-disabled:opacity-50 sm:text-sm/6 dark:text-white">
|
||||
{{ label }}
|
||||
</label>
|
||||
<div class="grow">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
35
resources/js/Components/Input/FileUpload.vue
Normal file
35
resources/js/Components/Input/FileUpload.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup>
|
||||
import Input from "./Input.vue"
|
||||
import {ref} from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
accept: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const fileRef = ref(null)
|
||||
const file = defineModel('file')
|
||||
const fileList = defineModel('fileList')
|
||||
|
||||
const onFileChanged = (e) => {
|
||||
const target = e.target
|
||||
if (target && target.files) {
|
||||
fileList.value = target.files
|
||||
file.value = target.files[0]
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Input ref="fileRef"
|
||||
type="file"
|
||||
@change="(e) => onFileChanged(e)"
|
||||
:accept="accept"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
27
resources/js/Components/Input/Input.vue
Normal file
27
resources/js/Components/Input/Input.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup>
|
||||
const value = defineModel('value')
|
||||
const props = defineProps({
|
||||
label: String,
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<span v-if="label" class="text-sm mb-0.5">
|
||||
{{ label }}
|
||||
</span>
|
||||
<input v-model="value"
|
||||
v-bind="$attrs"
|
||||
:disabled="disabled"
|
||||
:data-disabled="disabled"
|
||||
class="relative block w-full appearance-none rounded-lg px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)] text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white border border-zinc-950/10 hover:border-zinc-950/20 focus:border-zinc-950/20 dark:border-white/10 dark:hover:border-white/20 dark:focus:border-white/20 bg-transparent dark:bg-white/5 focus:outline-hidden data-invalid:border-red-500 data-invalid:data-hover:border-red-500 dark:data-invalid:border-red-600 dark:data-invalid:data-hover:border-red-600 data-[disabled=true]:border-zinc-950/20 dark:data-[disabled=true]:border-white/15 data-[disabled=true]:text-zinc-950/35 dark:data-[disabled=true]:text-white/35 dark:data-[disabled=true]:bg-white/2.5 dark:data-hover:data-disabled:border-white/15 dark:scheme-dark" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
92
resources/js/Components/Input/Search/AnimateSearch.vue
Normal file
92
resources/js/Components/Input/Search/AnimateSearch.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import {useDebounceFn} from "@vueuse/core";
|
||||
|
||||
const props = defineProps({
|
||||
placeholders: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
'Поиск по шаблонам...',
|
||||
]
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const value = ref(props.modelValue)
|
||||
const currentPlaceholderIndex = ref(0)
|
||||
const isAnimating = ref(false)
|
||||
const showPlaceholder = ref(true)
|
||||
|
||||
const debounceUpdate = useDebounceFn((value) => {
|
||||
emit('update:modelValue', value)
|
||||
}, 800)
|
||||
|
||||
// Автоматическое обновление modelValue
|
||||
watch(value, (newVal) => {
|
||||
debounceUpdate(newVal)
|
||||
showPlaceholder.value = newVal === ''
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
value.value = newVal
|
||||
})
|
||||
|
||||
// Анимация placeholder
|
||||
const animatePlaceholder = () => {
|
||||
if (isAnimating.value) return
|
||||
|
||||
isAnimating.value = true
|
||||
|
||||
setTimeout(() => {
|
||||
currentPlaceholderIndex.value = (currentPlaceholderIndex.value + 1) % props.placeholders.length
|
||||
isAnimating.value = false
|
||||
}, 3000) // Меняем каждые 3 секунды
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
animatePlaceholder()
|
||||
setInterval(animatePlaceholder, 3500) // Интервал анимации
|
||||
})
|
||||
|
||||
const currentPlaceholder = computed(() => props.placeholders[currentPlaceholderIndex.value])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative w-full">
|
||||
<input
|
||||
v-model="value"
|
||||
type="text"
|
||||
:placeholder="currentPlaceholder"
|
||||
class="relative block w-full appearance-none rounded-lg px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)] text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white border border-zinc-950/10 hover:border-zinc-950/20 focus:border-zinc-950/20 dark:border-white/10 dark:hover:border-white/20 dark:focus:border-white/20 bg-transparent dark:bg-white/5 focus:outline-hidden data-invalid:border-red-500 data-invalid:data-hover:border-red-500 dark:data-invalid:border-red-600 dark:data-invalid:data-hover:border-red-600 data-disabled:border-zinc-950/20 dark:data-disabled:border-white/15 dark:data-disabled:bg-white/2.5 dark:data-hover:data-disabled:border-white/15 dark:scheme-dark"
|
||||
/>
|
||||
|
||||
<div v-if="showPlaceholder" class="absolute left-1 inset-0 pointer-events-none overflow-hidden">
|
||||
<div
|
||||
v-for="(ph, index) in placeholders"
|
||||
:key="ph"
|
||||
:class="[
|
||||
'absolute inset-0 flex items-center px-[calc(--spacing(3.5)-1px)] sm:px-[calc(--spacing(3)-1px)] text-zinc-500 transition-all duration-500',
|
||||
index === currentPlaceholderIndex ? 'translate-y-0 opacity-100' : 'translate-y-6 opacity-0'
|
||||
]"
|
||||
>
|
||||
<span class="text-base/6 sm:text-sm/6">{{ ph }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Дополнительные стили для плавной анимации */
|
||||
.absolute > div {
|
||||
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
opacity: 0; /* Скрываем стандартный placeholder */
|
||||
}
|
||||
</style>
|
||||
42
resources/js/Components/Input/TextArea.vue
Normal file
42
resources/js/Components/Input/TextArea.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup>
|
||||
const value = defineModel('value')
|
||||
const props = defineProps({
|
||||
label: String,
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
rows: {
|
||||
type: Number,
|
||||
default: 4
|
||||
},
|
||||
maxLength: Number,
|
||||
resize: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<span v-if="label" class="text-sm mb-0.5">
|
||||
{{ label }}
|
||||
</span>
|
||||
<textarea v-model="value"
|
||||
v-bind="$attrs"
|
||||
:disabled="disabled"
|
||||
:data-disabled="disabled"
|
||||
:rows="rows"
|
||||
:maxlength="maxLength"
|
||||
:class="{ 'resize-none': !resize }"
|
||||
class="relative block w-full appearance-none rounded-lg px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)] text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white border border-zinc-950/10 hover:border-zinc-950/20 focus:border-zinc-950/20 dark:border-white/10 dark:hover:border-white/20 dark:focus:border-white/20 bg-transparent dark:bg-white/5 focus:outline-hidden data-invalid:border-red-500 data-invalid:data-hover:border-red-500 dark:data-invalid:border-red-600 dark:data-invalid:data-hover:border-red-600 data-[disabled=true]:border-zinc-950/20 dark:data-[disabled=true]:border-white/15 data-[disabled=true]:text-zinc-950/35 dark:data-[disabled=true]:text-white/35 dark:data-[disabled=true]:bg-white/2.5 dark:data-hover:data-disabled:border-white/15 dark:scheme-dark resize-vertical min-h-[80px]">
|
||||
</textarea>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
35
resources/js/Components/List/List.vue
Normal file
35
resources/js/Components/List/List.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup>
|
||||
import {computed} from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
vertical: {
|
||||
type: String,
|
||||
default: 'vertical'
|
||||
}
|
||||
})
|
||||
|
||||
const verticalClasses = [
|
||||
'flex', 'flex-col', 'gap-y-2'
|
||||
]
|
||||
|
||||
const horizontalClasses = [
|
||||
'grid', 'grid-cols-3', 'gap-y-2', 'gap-x-2', 'grow', 'items-start'
|
||||
]
|
||||
|
||||
const classes = computed(() => {
|
||||
if (props.vertical)
|
||||
return verticalClasses
|
||||
|
||||
return horizontalClasses
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="classes">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
31
resources/js/Components/List/ListItem.vue
Normal file
31
resources/js/Components/List/ListItem.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup>
|
||||
import {onMounted, ref} from "vue";
|
||||
|
||||
const componentRef = ref()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="componentRef"
|
||||
@mouseenter="componentRef.setAttribute('data-hover', '')"
|
||||
@mouseleave="componentRef.removeAttribute('data-hover')"
|
||||
class="flex flex-col max-lg:flex-row gap-y-1 px-2 sm:py-2 py-2.5 rounded-md transition-all data-hover:bg-zinc-950/5 dark:data-hover:bg-white/5 active:scale-[.99]">
|
||||
<div v-if="$slots.header || $slots.actions" class="flex justify-between items-center">
|
||||
<div v-if="$slots.header" class="text-sm font-medium">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<div v-if="$slots.actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="$slots.default">
|
||||
<slot />
|
||||
</div>
|
||||
<div v-if="$slots.footer">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
21
resources/js/Components/List/ListStrate.vue
Normal file
21
resources/js/Components/List/ListStrate.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
header: {
|
||||
type: String,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-3 pt-2 pb-4 first:rounded-t-lg first:rounded-b-sm lg:ring-1 lg:ring-zinc-950/10 dark:lg:ring-white/10 last:rounded-b-lg last:rounded-t-sm not-last:not-first:rounded-sm not-last:not-first:my-1 bg-white dark:text-white lg:bg-zinc-100 dark:bg-zinc-900 dark:lg:bg-zinc-800">
|
||||
<slot v-if="$slots.header" name="header" />
|
||||
<span v-else class="block text-sm font-medium mb-2">
|
||||
{{ header }}
|
||||
</span>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
180
resources/js/Components/Modal/Modal.vue
Normal file
180
resources/js/Components/Modal/Modal.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<script setup>
|
||||
import {computed, ref, watch} from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
titleId: {
|
||||
type: String,
|
||||
default: () => `modal-title-${Math.random().toString(36).substr(2, 9)}`
|
||||
},
|
||||
descriptionId: {
|
||||
type: String,
|
||||
default: () => `modal-description-${Math.random().toString(36).substr(2, 9)}`
|
||||
},
|
||||
panelId: {
|
||||
type: String,
|
||||
default: () => `modal-panel-${Math.random().toString(36).substr(2, 9)}`
|
||||
},
|
||||
closeButton: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
default: 512
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'beforeClose', 'afterClose'])
|
||||
|
||||
// Блокировка скролла при открытии модального окна
|
||||
watch(() => props.open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
})
|
||||
|
||||
// Ширина модального окна
|
||||
const modalWidth = computed(() => {
|
||||
if (props.width === 0 || props.width === null)
|
||||
return `width: 512px`
|
||||
else
|
||||
return `width: ${props.width}px`
|
||||
})
|
||||
|
||||
// Очистка при размонтировании
|
||||
import { onUnmounted } from 'vue'
|
||||
import Button from "../Button/Button.vue";
|
||||
onUnmounted(() => {
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
|
||||
// Стили модального окна
|
||||
const styles = computed(() => [
|
||||
modalWidth.value
|
||||
])
|
||||
|
||||
const close = () => {
|
||||
emit('beforeClose')
|
||||
emit('close')
|
||||
emit('afterClose')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="open"
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
aria-modal="true"
|
||||
:data-headlessui-state="open ? 'open' : 'closed'"
|
||||
:aria-labelledby="titleId"
|
||||
:aria-describedby="descriptionId"
|
||||
class="fixed inset-0 z-50"
|
||||
>
|
||||
<!-- Backdrop -->
|
||||
<transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="open"
|
||||
class="fixed inset-0 flex w-screen justify-center overflow-y-auto bg-zinc-950/25 px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-16 dark:bg-zinc-950/50"
|
||||
aria-hidden="true"
|
||||
:data-headlessui-state="open ? 'open' : 'closed'"
|
||||
@click="$emit('close')"
|
||||
/>
|
||||
</transition>
|
||||
|
||||
<!-- Modal content -->
|
||||
<div class="fixed inset-0 w-screen overflow-y-auto pt-6 sm:pt-0">
|
||||
<div class="grid min-h-full grid-rows-[1fr_auto] justify-items-center sm:grid-rows-[1fr_auto_3fr] sm:p-4">
|
||||
<transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="translate-y-12 opacity-0 sm:translate-y-0 sm:scale-95"
|
||||
enter-to-class="translate-y-0 opacity-100 sm:scale-100"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-from-class="translate-y-0 opacity-100 sm:scale-100"
|
||||
leave-to-class="translate-y-12 opacity-0 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="open"
|
||||
class="transition-[width] delay-0 duration-300 row-start-2 w-full min-w-0 rounded-t-3xl bg-white p-8 shadow-lg ring-1 ring-zinc-950/10 sm:mb-auto sm:rounded-2xl dark:bg-zinc-900 dark:ring-white/10 forced-colors:outline will-change-transform"
|
||||
:style="styles"
|
||||
:id="panelId"
|
||||
:data-headlessui-state="open ? 'open' : 'closed'"
|
||||
>
|
||||
<div class="flex flex-row justify-between items-center">
|
||||
<!-- Title slot -->
|
||||
<slot name="title" :titleId="titleId">
|
||||
<h2
|
||||
v-if="title"
|
||||
class="text-lg/6 font-semibold text-balance text-zinc-950 sm:text-base/6 dark:text-white"
|
||||
:id="titleId"
|
||||
>
|
||||
{{ title }}
|
||||
</h2>
|
||||
</slot>
|
||||
<slot name="close-button">
|
||||
<Button icon v-if="closeButton" @click="close">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-3.5 w-3.5"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M18 6l-12 12" /><path d="M6 6l12 12" /></svg>
|
||||
</Button>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<!-- Description slot -->
|
||||
<slot name="description" :descriptionId="descriptionId">
|
||||
<p
|
||||
v-if="description"
|
||||
class="mt-2 text-pretty text-base/6 text-zinc-500 sm:text-sm/6 dark:text-zinc-400"
|
||||
:id="descriptionId"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
</slot>
|
||||
|
||||
<!-- Default content slot -->
|
||||
<div class="mt-6 max-h-[520px] overflow-y-auto p-0.5 pr-2">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<!-- Actions slot -->
|
||||
<div class="mt-8 flex flex-col-reverse items-center justify-end gap-3 *:w-full sm:flex-row sm:*:w-auto">
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
142
resources/js/Components/Notifications/SentryNotification.vue
Normal file
142
resources/js/Components/Notifications/SentryNotification.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import Card from '../Card/Card.vue'
|
||||
import Button from "../Button/Button.vue";
|
||||
import Collapsible from "../Collapsible/Collapsible.vue";
|
||||
|
||||
// Пропсы
|
||||
const props = defineProps({
|
||||
customTitle: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
customDescription: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
autoShow: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
// Реактивные данные
|
||||
const isVisible = ref(false)
|
||||
const headerText = 'Уведомление о сборе данных'
|
||||
|
||||
const title = computed(() => props.customTitle || 'Мониторинг ошибок')
|
||||
const description = computed(() => props.customDescription || 'Мы используем Sentry для отслеживания и исправления ошибок на сайте. Это помогает нам улучшать качество сервиса.')
|
||||
|
||||
// Собираемые данные
|
||||
const collectedData = ref([
|
||||
'Текст ошибки и стектрейс',
|
||||
'Тип браузера и версия',
|
||||
'Операционная система',
|
||||
'URL страницы где произошла ошибка',
|
||||
'Временная метка ошибки',
|
||||
'Действия пользователя перед ошибкой',
|
||||
'Размер экрана устройства',
|
||||
'Анонимизированный идентификатор сессии'
|
||||
])
|
||||
|
||||
// События
|
||||
const emit = defineEmits(['accept', 'learnMore', 'close'])
|
||||
|
||||
const handleAccept = () => {
|
||||
localStorage.setItem('sentry-notification-accepted', 'true')
|
||||
isVisible.value = false
|
||||
emit('accept')
|
||||
}
|
||||
|
||||
const handleLearnMore = () => {
|
||||
emit('learnMore')
|
||||
window.open('https://sentry.io/features/error-monitoring/', '_blank')
|
||||
}
|
||||
|
||||
// Показывать уведомление только если пользователь еще не соглашался
|
||||
onMounted(() => {
|
||||
if (props.autoShow && !localStorage.getItem('sentry-notification-accepted')) {
|
||||
isVisible.value = true
|
||||
}
|
||||
})
|
||||
|
||||
// Экспортируем методы для управления видимостью
|
||||
defineExpose({
|
||||
show: () => isVisible.value = true,
|
||||
hide: () => isVisible.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isVisible" class="fixed bottom-12 right-5 z-50 w-96">
|
||||
<Card
|
||||
:header="headerText"
|
||||
:content-scroll="false"
|
||||
>
|
||||
<!-- Компактный заголовок -->
|
||||
<div class="flex items-center space-x-3 p-3">
|
||||
<div class="flex-shrink-0 w-8 h-8 bg-white rounded-lg flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4 text-slate-900">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M3 18a1.93 1.93 0 0 0 .306 1.076a2 2 0 0 0 1.584 .924c.646 .033 -.537 0 .11 0h3a4.992 4.992 0 0 0 -3.66 -4.81c.558 -.973 1.24 -2.149 2.04 -3.531a9 9 0 0 1 5.62 8.341h4c.663 0 2.337 0 3 0a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-1.84 3.176c4.482 2.05 7.6 6.571 7.6 11.824" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-sm font-semibold text-slate-900 dark:text-white truncate">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p class="text-xs text-slate-600 dark:text-slate-300 truncate">
|
||||
Мы используем Sentry для анализа ошибок
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Собираемые данные в аккордеоне -->
|
||||
<div class="px-3 pb-3">
|
||||
<Collapsible header="Какие данные собираем">
|
||||
<div class="space-y-1 max-h-32 overflow-y-auto">
|
||||
<div
|
||||
v-for="(item, index) in collectedData"
|
||||
:key="index"
|
||||
class="flex items-start space-x-2 text-xs"
|
||||
>
|
||||
<div class="flex-shrink-0 w-3 h-3 text-emerald-500 mt-0.5">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-slate-600 dark:text-slate-300 leading-relaxed">
|
||||
{{ item }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
<!-- Футер с действиями -->
|
||||
<template #footer>
|
||||
<div class="flex space-x-2">
|
||||
<!-- <Button-->
|
||||
<!-- variant="ghost"-->
|
||||
<!-- text-align="center"-->
|
||||
<!-- block-->
|
||||
<!-- @click="handleLearnMore"-->
|
||||
<!-- >-->
|
||||
<!-- Подробнее-->
|
||||
<!-- </Button>-->
|
||||
<Button
|
||||
text-align="center"
|
||||
block
|
||||
@click="handleAccept"
|
||||
>
|
||||
Понятно
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Дополнительные кастомные стили если нужно */
|
||||
</style>
|
||||
14
resources/js/Components/Page/Page.vue
Normal file
14
resources/js/Components/Page/Page.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup>
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<slot name="header" />
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
13
resources/js/Components/Page/PageBody.vue
Normal file
13
resources/js/Components/Page/PageBody.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup>
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="py-2">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
16
resources/js/Components/Page/PageHeader.vue
Normal file
16
resources/js/Components/Page/PageHeader.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup>
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-2xl/8 font-semibold text-zinc-950 sm:text-xl/8 dark:text-white">
|
||||
<slot />
|
||||
</h1>
|
||||
<hr role="presentation" class="mt-6 w-full border-t border-zinc-950/10 dark:border-white/10">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
347
resources/js/Components/PageElement.vue
Normal file
347
resources/js/Components/PageElement.vue
Normal file
@@ -0,0 +1,347 @@
|
||||
<script setup>
|
||||
import {computed, nextTick, ref} from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
element: Object,
|
||||
pageIndex: Number,
|
||||
elementIndex: Number
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update', 'remove', 'split']);
|
||||
|
||||
const isEditing = ref(false);
|
||||
const elementRef = ref()
|
||||
|
||||
const isTextElement = computed(() =>
|
||||
['paragraph', 'heading'].includes(props.element.type)
|
||||
);
|
||||
|
||||
const elementClasses = computed(() => [
|
||||
`element-${props.element.type}`,
|
||||
{ 'editing': isEditing.value }
|
||||
]);
|
||||
|
||||
const elementStyles = computed(() => {
|
||||
const styles = {};
|
||||
|
||||
if (props.element.type === 'heading') {
|
||||
styles.fontSize = `${props.element.level * 4 + 12}px`;
|
||||
styles.fontWeight = 'bold';
|
||||
}
|
||||
|
||||
return styles;
|
||||
});
|
||||
|
||||
const displayContent = computed(() => {
|
||||
if (typeof props.element.content === 'string') {
|
||||
return props.element.content;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const tableRows = computed(() => {
|
||||
if (props.element.type === 'table' && props.element.content.rows) {
|
||||
return props.element.content.rows;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
// Обработчики текстовых элементов
|
||||
const onTextInput = (event) => {
|
||||
isEditing.value = true;
|
||||
console.log(event.target.textContent)
|
||||
emit('update', {
|
||||
...props.element,
|
||||
content: event.target.textContent
|
||||
});
|
||||
};
|
||||
|
||||
const onTextBlur = () => {
|
||||
isEditing.value = false;
|
||||
};
|
||||
|
||||
// Обработчики таблиц
|
||||
const onTableCellInput = (rowIndex, cellIndex, event) => {
|
||||
const newRows = [...tableRows.value];
|
||||
newRows[rowIndex][cellIndex] = event.target.textContent;
|
||||
|
||||
emit('update', {
|
||||
...props.element,
|
||||
content: {
|
||||
...props.element.content,
|
||||
rows: newRows
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onTableBlur = () => {
|
||||
// Автосохранение таблицы
|
||||
};
|
||||
|
||||
const addTableRow = () => {
|
||||
const cols = props.element.content.cols || 2;
|
||||
const newRow = Array(cols).fill('Новая ячейка');
|
||||
const newRows = [...tableRows.value, newRow];
|
||||
|
||||
emit('update', {
|
||||
...props.element,
|
||||
content: {
|
||||
...props.element.content,
|
||||
rows: newRows
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const addTableColumn = () => {
|
||||
const newRows = tableRows.value.map(row => [...row, 'Новая ячейка']);
|
||||
|
||||
emit('update', {
|
||||
...props.element,
|
||||
content: {
|
||||
rows: newRows,
|
||||
cols: (props.element.content.cols || 2) + 1
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const removeTableRow = () => {
|
||||
if (tableRows.value.length > 1) {
|
||||
const newRows = tableRows.value.slice(0, -1);
|
||||
emit('update', {
|
||||
...props.element,
|
||||
content: {
|
||||
...props.element.content,
|
||||
rows: newRows
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const removeTableColumn = () => {
|
||||
if (props.element.content.cols > 1) {
|
||||
const newRows = tableRows.value.map(row => row.slice(0, -1));
|
||||
emit('update', {
|
||||
...props.element,
|
||||
content: {
|
||||
rows: newRows,
|
||||
cols: props.element.content.cols - 1
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчики переменных
|
||||
const onVariableUpdate = () => {
|
||||
emit('update', {
|
||||
...props.element,
|
||||
content: `{{${props.element.variableName}}}`
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="page-element"
|
||||
:class="elementClasses"
|
||||
:style="elementStyles"
|
||||
@dblclick="$emit('split')"
|
||||
>
|
||||
<div class="element-content">
|
||||
<!-- Текстовые элементы -->
|
||||
<div
|
||||
v-if="isTextElement"
|
||||
ref="elementRef"
|
||||
class="text-element"
|
||||
contenteditable="true"
|
||||
@input="onTextInput"
|
||||
@blur="onTextBlur"
|
||||
v-html="displayContent"
|
||||
></div>
|
||||
|
||||
<!-- Таблица -->
|
||||
<div v-else-if="element.type === 'table'" class="table-element">
|
||||
<table class="element-table">
|
||||
<tbody>
|
||||
<tr v-for="(row, rowIndex) in tableRows" :key="rowIndex">
|
||||
<td
|
||||
v-for="(cell, cellIndex) in row.cells"
|
||||
ref="elementRef"
|
||||
:key="cellIndex"
|
||||
contenteditable="true"
|
||||
@input="onTableCellInput(rowIndex, cellIndex, $event)"
|
||||
@blur="onTableBlur"
|
||||
>
|
||||
{{ cell }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="table-controls">
|
||||
<button @click="addTableRow" class="btn btn-sm">➕ Строка</button>
|
||||
<button @click="addTableColumn" class="btn btn-sm">➕ Столбец</button>
|
||||
<button @click="removeTableRow" class="btn btn-sm btn-danger">➖ Строка</button>
|
||||
<button @click="removeTableColumn" class="btn btn-sm btn-danger">➖ Столбец</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Переменная -->
|
||||
<div v-else-if="element.type === 'variable'" class="variable-element">
|
||||
<span class="variable-tag">{{ element.variableName }}</span>
|
||||
<input
|
||||
v-model="element.variableName"
|
||||
@blur="onVariableUpdate"
|
||||
class="variable-input"
|
||||
placeholder="имя_переменной"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="element-controls">
|
||||
<button @click="$emit('remove')" class="control-btn" title="Удалить">🗑️</button>
|
||||
<button @click="$emit('split')" class="control-btn" title="Разорвать">✂️</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-element {
|
||||
position: relative;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.page-element:hover {
|
||||
border-color: #e1e5e9;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.page-element.editing {
|
||||
border-color: #007bff;
|
||||
background: #e3f2fd;
|
||||
}
|
||||
|
||||
.element-content {
|
||||
min-height: 1.5em;
|
||||
}
|
||||
|
||||
.text-element {
|
||||
outline: none;
|
||||
min-height: 1.5em;
|
||||
}
|
||||
|
||||
.text-element:focus {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.table-element {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.element-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.element-table td {
|
||||
border: 1px solid #dee2e6;
|
||||
padding: 0.5rem;
|
||||
min-width: 100px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.element-table td:focus {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.table-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.variable-element {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: #fffacd;
|
||||
border: 1px dashed #c4a657;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.variable-tag {
|
||||
font-weight: bold;
|
||||
color: #8b6f1d;
|
||||
}
|
||||
|
||||
.variable-input {
|
||||
border: 1px solid #c4a657;
|
||||
border-radius: 3px;
|
||||
padding: 0.25rem;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.element-controls {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
right: -10px;
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.page-element:hover .element-controls {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid #e1e5e9;
|
||||
border-radius: 3px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.2rem 0.4rem;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
border-color: #dc3545;
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #fee;
|
||||
}
|
||||
</style>
|
||||
162
resources/js/Components/Select/Select.vue
Normal file
162
resources/js/Components/Select/Select.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<script setup>
|
||||
import {computed, onMounted, onUnmounted, ref} from "vue";
|
||||
import {normalizeOptions} from "../../Utils/optionsFormatter.js";
|
||||
|
||||
const props = defineProps({
|
||||
options: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => []
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Выберите вариант'
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const model = defineModel('value')
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const isOpen = ref(false)
|
||||
const containerRef = ref(null)
|
||||
|
||||
// Формирование options
|
||||
const normalizedOptions = computed(() => {
|
||||
return normalizeOptions(props.options)
|
||||
})
|
||||
|
||||
// Находим выбранную опцию
|
||||
const selectedOption = computed(() => {
|
||||
if (!model.value) return null
|
||||
|
||||
return normalizedOptions.value.find(opt => opt.value === model.value) || null
|
||||
})
|
||||
|
||||
// Проверяем, выбрана ли опция
|
||||
const isOptionSelected = (option) => {
|
||||
return model.value === option.value
|
||||
}
|
||||
|
||||
// Переключаем dropdown
|
||||
const toggleDropdown = () => {
|
||||
if (!props.disabled) {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
}
|
||||
|
||||
// Выбираем опцию
|
||||
const selectOption = (option) => {
|
||||
model.value = option.value
|
||||
emit('change', option)
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
// Закрываем dropdown при клике вне компонента
|
||||
const handleClickOutside = (event) => {
|
||||
if (containerRef.value && !containerRef.value.contains(event.target)) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Закрываем dropdown при нажатии Escape
|
||||
const handleEscapeKey = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Устанавливаем обработчики событий
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
document.addEventListener('keydown', handleEscapeKey)
|
||||
})
|
||||
|
||||
// Убираем обработчики при размонтировании
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
document.removeEventListener('keydown', handleEscapeKey)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative" ref="containerRef">
|
||||
<!-- Кнопка-триггер -->
|
||||
<button
|
||||
type="button"
|
||||
class="group relative block w-full before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm dark:before:hidden focus:outline-hidden after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset data-focus:after:ring-2 data-focus:after:ring-blue-500 data-disabled:opacity-50 data-disabled:before:bg-zinc-950/5 data-disabled:before:shadow-none"
|
||||
:class="{
|
||||
'opacity-50 cursor-not-allowed': disabled,
|
||||
'ring-2 ring-blue-500 rounded-lg': isOpen
|
||||
}"
|
||||
@click="toggleDropdown"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<span class="relative block w-full appearance-none rounded-lg py-[calc(--spacing(2.5)-1px)] sm:py-[calc(--spacing(1.5)-1px)] min-h-11 sm:min-h-9 pr-[calc(--spacing(7)-1px)] pl-[calc(--spacing(3.5)-1px)] sm:pl-[calc(--spacing(3)-1px)] text-left text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white border border-zinc-950/10 group-hover:border-zinc-950/20 dark:border-white/10 dark:group-hover:border-white/20 bg-transparent dark:bg-white/5">
|
||||
<div class="flex min-w-0 items-center">
|
||||
<span class="ml-2.5 truncate first:ml-0 sm:ml-2 sm:first:ml-0">
|
||||
{{ selectedOption?.label || placeholder }}
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
|
||||
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<svg
|
||||
class="size-5 stroke-zinc-500 group-disabled:stroke-zinc-600 sm:size-4 dark:stroke-zinc-400"
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
:class="{ 'transform rotate-180': isOpen }"
|
||||
>
|
||||
<path d="M5.75 10.75L8 13L10.25 10.75" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
<path d="M10.25 5.25L8 3L5.75 5.25" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Dropdown меню -->
|
||||
<transition
|
||||
enter-active-class="transition duration-100 ease-out"
|
||||
enter-from-class="transform scale-95 opacity-0"
|
||||
enter-to-class="transform scale-100 opacity-100"
|
||||
leave-active-class="transition duration-75 ease-in"
|
||||
leave-from-class="transform scale-100 opacity-100"
|
||||
leave-to-class="transform scale-95 opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="absolute z-50 mt-1 w-full rounded-lg bg-white shadow-lg border border-zinc-200 dark:bg-zinc-900 dark:border-zinc-700 max-h-60 overflow-auto"
|
||||
>
|
||||
<ul class="py-1">
|
||||
<li
|
||||
v-for="option in normalizedOptions"
|
||||
:key="option.value"
|
||||
class="cursor-pointer select-none relative py-2 pl-3 pr-9 hover:bg-zinc-600/10 hover:text-white text-zinc-900 dark:text-white dark:hover:bg-zinc-700"
|
||||
@click="selectOption(option)"
|
||||
>
|
||||
<span class="block truncate text-sm">
|
||||
{{ option.label }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="isOptionSelected(option)"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-4 text-white/20 hover:text-white/20"
|
||||
>
|
||||
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
11
resources/js/Components/Select/SelectOption.vue
Normal file
11
resources/js/Components/Select/SelectOption.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script setup>
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
13
resources/js/Components/Sidebar/Sidebar.vue
Normal file
13
resources/js/Components/Sidebar/Sidebar.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup>
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="fixed inset-y-0 left-0 w-64 max-lg:hidden">
|
||||
<slot />
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
13
resources/js/Components/Sidebar/SidebarBody.vue
Normal file
13
resources/js/Components/Sidebar/SidebarBody.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup>
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-1 flex-col overflow-y-auto p-4 [&>[data-slot=section]+[data-slot=section]]:mt-8">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
13
resources/js/Components/Sidebar/SidebarFooter.vue
Normal file
13
resources/js/Components/Sidebar/SidebarFooter.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup>
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-lg:hidden flex flex-col border-t border-zinc-950/5 p-4 dark:border-white/5 [&>[data-slot=section]+[data-slot=section]]:mt-2.5">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
13
resources/js/Components/Sidebar/SidebarHeader.vue
Normal file
13
resources/js/Components/Sidebar/SidebarHeader.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup>
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col border-b border-zinc-950/5 p-4 dark:border-white/5 [&>[data-slot=section]+[data-slot=section]]:mt-2.5">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
19
resources/js/Components/Sidebar/SidebarItem.vue
Normal file
19
resources/js/Components/Sidebar/SidebarItem.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup>
|
||||
import {Link} from '@inertiajs/vue3'
|
||||
import {ref} from "vue";
|
||||
|
||||
const componentRef = ref()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Link ref="componentRef"
|
||||
@mouseenter="componentRef.$el.setAttribute('data-hover', '')"
|
||||
@mouseleave="componentRef.$el.removeAttribute('data-hover')"
|
||||
class="flex w-full items-center gap-3 rounded-lg px-2 py-2.5 text-left text-base/6 font-medium text-zinc-950 sm:py-2 sm:text-sm/5 *:data-[slot=icon]:size-6 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:fill-zinc-500 sm:*:data-[slot=icon]:size-5 *:last:data-[slot=icon]:ml-auto *:last:data-[slot=icon]:size-5 sm:*:last:data-[slot=icon]:size-4 *:data-[slot=avatar]:-m-0.5 *:data-[slot=avatar]:size-7 sm:*:data-[slot=avatar]:size-6 data-hover:bg-zinc-950/5 data-hover:*:data-[slot=icon]:fill-zinc-950 data-active:bg-zinc-950/5 data-active:*:data-[slot=icon]:fill-zinc-950 data-current:*:data-[slot=icon]:fill-zinc-950 dark:text-white dark:*:data-[slot=icon]:fill-zinc-400 dark:data-hover:bg-white/5 dark:data-hover:*:data-[slot=icon]:fill-white dark:data-active:bg-white/5 dark:data-active:*:data-[slot=icon]:fill-white dark:data-current:*:data-[slot=icon]:fill-white">
|
||||
<slot />
|
||||
</Link>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
13
resources/js/Components/Sidebar/SidebarLabel.vue
Normal file
13
resources/js/Components/Sidebar/SidebarLabel.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup>
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="truncate">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
13
resources/js/Components/Sidebar/SidebarSection.vue
Normal file
13
resources/js/Components/Sidebar/SidebarSection.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup>
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-slot="section" class="flex flex-col gap-0.5">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
Reference in New Issue
Block a user