first commit
This commit is contained in:
62
resources/css/app.css
Normal file
62
resources/css/app.css
Normal file
@@ -0,0 +1,62 @@
|
||||
@import 'tailwindcss';
|
||||
@reference "tailwindcss";
|
||||
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../storage/framework/views/*.php';
|
||||
@source '../**/*.blade.php';
|
||||
@source '../**/*.js';
|
||||
|
||||
@plugin 'tailwind-scrollbar';
|
||||
|
||||
@font-face {
|
||||
font-family: "Golos Sans";
|
||||
font-weight: 400;
|
||||
src: url("/assets/fonts/Golos-Text_Regular.woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Golos Sans";
|
||||
font-weight: 500;
|
||||
src: url("/assets/fonts/Golos-Text_Medium.woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Golos Sans";
|
||||
font-weight: 600;
|
||||
src: url("/assets/fonts/Golos-Text_DemiBold.woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Golos Sans";
|
||||
font-weight: 700;
|
||||
src: url("/assets/fonts/Golos-Text_Bold.woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Golos Sans";
|
||||
font-weight: 800;
|
||||
src: url("../fonts/Golos-Text_Black.woff2");
|
||||
}
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Golos Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
}
|
||||
|
||||
/*hover:border-zinc-950/20 dark:border-white/10 dark:hover:border-white/20 bg-transparent dark:bg-white/5*/
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
@apply w-2;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
@apply bg-white/10 dark:bg-white/10 rounded-lg;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-white/10 dark:bg-white/10 rounded-lg;
|
||||
}
|
||||
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>
|
||||
107
resources/js/Composables/useApiForm.js
Normal file
107
resources/js/Composables/useApiForm.js
Normal file
@@ -0,0 +1,107 @@
|
||||
// composables/useSmartApiForm.js
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import axios from 'axios'
|
||||
|
||||
export function useApiForm(initialData = {}) {
|
||||
const loading = ref(false)
|
||||
const errors = ref({})
|
||||
const progress = ref(0)
|
||||
|
||||
// Для обычных полей формы
|
||||
const formData = ref({ ...initialData })
|
||||
// Для файлов
|
||||
const files = ref({})
|
||||
|
||||
const setFile = (fieldName, file) => {
|
||||
files.value[fieldName] = file
|
||||
// Автоматически очищаем ошибку для этого поля
|
||||
clearError(fieldName)
|
||||
}
|
||||
|
||||
const clearError = (field) => {
|
||||
if (errors.value[field]) {
|
||||
const newErrors = { ...errors.value }
|
||||
delete newErrors[field]
|
||||
errors.value = newErrors
|
||||
}
|
||||
}
|
||||
|
||||
const clearAllErrors = () => {
|
||||
errors.value = {}
|
||||
}
|
||||
|
||||
const submit = async (url, method = 'post', config = {}) => {
|
||||
loading.value = true
|
||||
progress.value = 0
|
||||
clearAllErrors()
|
||||
|
||||
try {
|
||||
// Создаем FormData
|
||||
const formDataToSend = new FormData()
|
||||
|
||||
// Добавляем обычные поля формы
|
||||
Object.keys(formData.value).forEach(key => {
|
||||
if (formData.value[key] !== null && formData.value[key] !== undefined) {
|
||||
formDataToSend.append(key, formData.value[key])
|
||||
}
|
||||
})
|
||||
|
||||
// Добавляем файлы
|
||||
Object.keys(files.value).forEach(key => {
|
||||
if (files.value[key]) {
|
||||
formDataToSend.append(key, files.value[key])
|
||||
}
|
||||
})
|
||||
|
||||
const response = await axios({
|
||||
method,
|
||||
url,
|
||||
data: formDataToSend,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
...config.headers
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (progressEvent.total) {
|
||||
progress.value = Math.round(
|
||||
(progressEvent.loaded * 100) / progressEvent.total
|
||||
)
|
||||
}
|
||||
},
|
||||
...config
|
||||
})
|
||||
|
||||
return response.data
|
||||
} catch (error) {
|
||||
if (error.response?.status === 422) {
|
||||
errors.value = error.response.data.errors || {}
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
// Сбрасываем обычные поля
|
||||
Object.keys(initialData).forEach(key => {
|
||||
formData.value[key] = initialData[key]
|
||||
})
|
||||
// Сбрасываем файлы
|
||||
files.value = {}
|
||||
clearAllErrors()
|
||||
}
|
||||
|
||||
return {
|
||||
formData,
|
||||
files,
|
||||
errors,
|
||||
loading,
|
||||
progress,
|
||||
submit,
|
||||
setFile,
|
||||
clearError,
|
||||
clearAllErrors,
|
||||
reset
|
||||
}
|
||||
}
|
||||
113
resources/js/Composables/useDynamicA4Layout.js
Normal file
113
resources/js/Composables/useDynamicA4Layout.js
Normal file
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
ref,
|
||||
reactive,
|
||||
onMounted,
|
||||
onUpdated,
|
||||
nextTick
|
||||
} from "vue"
|
||||
import {useElementSize} from "@vueuse/core";
|
||||
|
||||
export function useDynamicA4Layout() {
|
||||
const A4_HEIGHT = 933
|
||||
const A4_WIDTH = 623
|
||||
const a4Pages = reactive([])
|
||||
const isCalculating = ref(false)
|
||||
const componentHeights = ref(new Map())
|
||||
|
||||
const waitForComponentRender = (componentRef, itemType) => {
|
||||
return new Promise((resolve) => {
|
||||
if (!componentRef) {
|
||||
resolve(0)
|
||||
return
|
||||
}
|
||||
|
||||
const checkRender = () => {
|
||||
requestAnimationFrame(() => {
|
||||
if (componentRef.offsetHeight > 0) {
|
||||
resolve(getComponentHeight(componentRef))
|
||||
} else {
|
||||
resolve(0)
|
||||
// setTimeout(checkRender, 1000)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
checkRender()
|
||||
})
|
||||
}
|
||||
|
||||
const getComponentHeight = (element) => {
|
||||
if (!element) return 0
|
||||
|
||||
|
||||
const styles = getComputedStyle(element)
|
||||
return element.offsetHeight
|
||||
+ parseInt(styles.marginTop)
|
||||
+ parseInt(styles.marginBottom)
|
||||
+ parseInt(styles.borderTopWidth)
|
||||
+ parseInt(styles.borderBottomWidth)
|
||||
}
|
||||
|
||||
const calculateDynamicLayout = async(items, getComponentRefs) => {
|
||||
if (isCalculating.value) return
|
||||
isCalculating.value = true
|
||||
|
||||
try {
|
||||
await nextTick()
|
||||
|
||||
const componentRefs = getComponentRefs()
|
||||
|
||||
a4Pages.splice(0, a4Pages.length)
|
||||
const currentPage = {items: [], totalHeight: 0, id: Date.now()}
|
||||
a4Pages.push(currentPage)
|
||||
|
||||
const heightPromises = items.map(async (item, index) => {
|
||||
const componentRef = componentRefs.get(item.id)
|
||||
const height = await waitForComponentRender(componentRef, item.type)
|
||||
componentHeights.value.set(item.id || index, height)
|
||||
return height
|
||||
})
|
||||
|
||||
const heights = await Promise.all(heightPromises)
|
||||
|
||||
let currentHeight = 0
|
||||
const pageMargin = 0
|
||||
|
||||
items.forEach((item, index) => {
|
||||
// console.log(item)
|
||||
const itemHeight = heights[index]
|
||||
const availableHeight = A4_HEIGHT - (pageMargin * 2) - currentHeight
|
||||
|
||||
console.log(currentHeight > 0 && availableHeight < itemHeight)
|
||||
|
||||
if (currentHeight > 0 && availableHeight < itemHeight) {
|
||||
const newPage = {
|
||||
items: [item],
|
||||
totalHeight: itemHeight,
|
||||
id: Date.now() + index
|
||||
}
|
||||
a4Pages.push(newPage)
|
||||
currentHeight = itemHeight
|
||||
} else {
|
||||
const page = a4Pages[a4Pages.length - 1]
|
||||
page.items.push(item)
|
||||
page.totalHeight += itemHeight
|
||||
currentHeight += itemHeight
|
||||
}
|
||||
})
|
||||
} catch(error) {
|
||||
console.error('Ошибка вычисления размера')
|
||||
} finally {
|
||||
isCalculating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
a4Pages,
|
||||
A4_HEIGHT,
|
||||
A4_WIDTH,
|
||||
calculateDynamicLayout,
|
||||
isCalculating,
|
||||
componentHeights
|
||||
}
|
||||
}
|
||||
66
resources/js/Composables/useFileDownload.js
Normal file
66
resources/js/Composables/useFileDownload.js
Normal file
@@ -0,0 +1,66 @@
|
||||
// composables/useFileDownload.js
|
||||
export const useFileDownload = () => {
|
||||
const downloadFile = async (url, data, filename = 'file', method = 'post') => {
|
||||
try {
|
||||
const response = await axios({
|
||||
method,
|
||||
url,
|
||||
data: method === 'post' ? data : null,
|
||||
params: method === 'get' ? data : null,
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
// Проверяем, что это действительно файл, а не ошибка
|
||||
const contentType = response.headers['content-type'];
|
||||
if (contentType.includes('application/json')) {
|
||||
// Это JSON ошибка, а не файл
|
||||
const errorData = JSON.parse(await response.data.text());
|
||||
throw new Error(errorData.error || 'Download failed');
|
||||
}
|
||||
|
||||
// Создаем blob URL
|
||||
const blob = new Blob([response.data], {
|
||||
type: response.headers['content-type']
|
||||
});
|
||||
const downloadUrl = window.URL.createObjectURL(blob);
|
||||
|
||||
// Создаем временную ссылку для скачивания
|
||||
const link = document.createElement('a');
|
||||
link.href = downloadUrl;
|
||||
|
||||
link.download = filename;
|
||||
|
||||
// Имитируем клик для скачивания
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// Очищаем URL
|
||||
window.URL.revokeObjectURL(downloadUrl);
|
||||
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Download error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const getFileNameFromResponse = (response) => {
|
||||
const contentDisposition = response.headers['content-disposition'];
|
||||
let fileName = 'document.docx';
|
||||
|
||||
if (contentDisposition) {
|
||||
const filenameMatch = contentDisposition.match(/filename="?(.+)"?/);
|
||||
if (filenameMatch && filenameMatch[1]) {
|
||||
fileName = filenameMatch[1].replace(/"/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
return fileName;
|
||||
};
|
||||
|
||||
return {
|
||||
downloadFile
|
||||
};
|
||||
};
|
||||
38
resources/js/Layouts/Sections.vue
Normal file
38
resources/js/Layouts/Sections.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup>
|
||||
|
||||
import SentryNotification from "../Components/Notifications/SentryNotification.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="relative isolate h-screen flex flex-col w-full bg-white dark:text-white lg:bg-zinc-100 dark:bg-zinc-900 dark:lg:bg-zinc-950 p-2 ">
|
||||
<div class="grid lg:grid-cols-[320px_1fr_380px] gap-2 h-[calc(100vh-42px)]">
|
||||
<!-- Left Sidebar -->
|
||||
<div v-if="$slots.leftbar" class="min-h-0">
|
||||
<slot name="leftbar" />
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="h-full flex flex-col min-h-0">
|
||||
<slot />
|
||||
<!-- <div class="flex-1 p-4 lg:rounded-lg lg:bg-white lg:p-8 lg:shadow-xs lg:ring-1 lg:ring-zinc-950/5 dark:lg:bg-zinc-900 dark:lg:ring-white/10 overflow-y-auto">-->
|
||||
<!-- -->
|
||||
<!-- </div>-->
|
||||
</div>
|
||||
|
||||
<!-- Right Sidebar -->
|
||||
<div v-if="$slots.rightbar" class="min-h-0">
|
||||
<slot name="rightbar" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full justify-center mt-2.5 text-sm/4">
|
||||
<a href="https://aokb28.su" target="_blank">
|
||||
ГОСУДАРСТВЕННОЕ АВТОНОМНОЕ УЧРЕЖДЕНИЕ ЗДРАВООХРАНЕНИЯ АМУРСКОЙ ОБЛАСТИ "АМУРСКАЯ ОБЛАСТНАЯ КЛИНИЧЕСКАЯ БОЛЬНИЦА", 2025 год
|
||||
</a>
|
||||
</div>
|
||||
<SentryNotification />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
25
resources/js/Layouts/Workspace.vue
Normal file
25
resources/js/Layouts/Workspace.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
|
||||
import SentryNotification from "../Components/Notifications/SentryNotification.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="relative isolate flex min-h-screen max-h-screen w-full bg-white dark:text-white max-lg:flex-col lg:bg-zinc-100 dark:bg-zinc-900 dark:lg:bg-zinc-950">
|
||||
<slot name="sidebar" />
|
||||
<div class="flex min-h-screen max-h-screen flex-col p-2 w-full">
|
||||
<div class="grow p-4 lg:rounded-lg max-h-[calc(100vh-16px)] lg:bg-white lg:p-8 lg:shadow-xs lg:ring-1 lg:ring-zinc-950/5 dark:lg:bg-zinc-900 dark:lg:ring-white/10">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="flex w-full justify-center mt-2.5 text-sm/4">
|
||||
<a href="https://aokb28.su" target="_blank">
|
||||
ГОСУДАРСТВЕННОЕ АВТОНОМНОЕ УЧРЕЖДЕНИЕ ЗДРАВООХРАНЕНИЯ АМУРСКОЙ ОБЛАСТИ "АМУРСКАЯ ОБЛАСТНАЯ КЛИНИЧЕСКАЯ БОЛЬНИЦА", 2025 год
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<SentryNotification />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
447
resources/js/Pages/ContractGenerator.vue
Normal file
447
resources/js/Pages/ContractGenerator.vue
Normal file
@@ -0,0 +1,447 @@
|
||||
<script setup>
|
||||
import {computed, nextTick, onMounted, ref, useTemplateRef, watch} from "vue"
|
||||
import {useDateFormat, useDebounceFn} from "@vueuse/core"
|
||||
import Sections from "../Layouts/Sections.vue";
|
||||
import Input from '../Components/Input/Input.vue'
|
||||
import Select from "../Components/Select/Select.vue";
|
||||
import Card from "../Components/Card/Card.vue";
|
||||
import Button from "../Components/Button/Button.vue";
|
||||
import ListStrate from "../Components/List/ListStrate.vue";
|
||||
import CardBack from "../Components/Card/CardBack.vue";
|
||||
import {Link, router} from "@inertiajs/vue3";
|
||||
import Editor from "../Components/Editor.vue";
|
||||
import VuePdfEmbed, { useVuePdfEmbed } from 'vue-pdf-embed'
|
||||
import {useFileDownload} from "../Composables/useFileDownload.js";
|
||||
import PriceInput from "../Components/Document/InputVariable/PriceInput.vue";
|
||||
import TextArea from "../Components/Input/TextArea.vue";
|
||||
import Calendar from "../Components/Calendar/Calendar.vue";
|
||||
import Collapsible from "../Components/Collapsible/Collapsible.vue";
|
||||
import Accordion from "../Components/Accordion/Accordion.vue";
|
||||
|
||||
const { downloadFile } = useFileDownload()
|
||||
|
||||
const props = defineProps({
|
||||
template: Object,
|
||||
})
|
||||
|
||||
const editorRef = ref(null)
|
||||
const content = ref(props.template.content ?? [])
|
||||
const formData = ref([])
|
||||
const documentStructure = ref(props.template.content || [])
|
||||
|
||||
const prepareVariables = (variables) => {
|
||||
for (const variable of variables) {
|
||||
formData.value.push(variable)
|
||||
}
|
||||
}
|
||||
|
||||
// Форматируем ключ переменной в читаемый name
|
||||
const formatLabel = (key) => {
|
||||
return key
|
||||
.split('_')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
const viewer = useTemplateRef('viewer')
|
||||
|
||||
onMounted(async() => {
|
||||
await preview()
|
||||
prepareVariables(props.template.variables)
|
||||
})
|
||||
|
||||
const previewLoading = ref(true)
|
||||
const previewUrl = ref()
|
||||
const preview = async () => {
|
||||
previewLoading.value = true
|
||||
await axios.post(`/contract-generator/${props.template.id}/preview`, {
|
||||
variables: formData.value
|
||||
}, {
|
||||
responseType: 'blob'
|
||||
}).then(res => {
|
||||
previewUrl.value = URL.createObjectURL(res.data)
|
||||
})
|
||||
}
|
||||
|
||||
const updatePreview = async () => {
|
||||
await preview()
|
||||
}
|
||||
|
||||
const onChangeVariableTextValue = (variableId, value) => {
|
||||
console.log(variableId, value)
|
||||
changeVariableValue(variableId, value)
|
||||
}
|
||||
|
||||
const onChangeVariableSelectValue = (variableId, option) => {
|
||||
changeVariableValue(variableId, option.value)
|
||||
}
|
||||
|
||||
const changeVariableValue = (variableId, value) => {
|
||||
if (content.value && Array.isArray(content.value)) {
|
||||
const updatedContent = content.value.map(htmlString => {
|
||||
return htmlString.replace(
|
||||
new RegExp(`(<span[^>]*brs-element-id="${variableId}"[^>]*>)[^<]*(</span>)`, 'g'),
|
||||
`$1${value}$2`
|
||||
)
|
||||
})
|
||||
|
||||
content.value = updatedContent
|
||||
}
|
||||
}
|
||||
|
||||
const onPrint = () => {
|
||||
if (viewer.value)
|
||||
viewer.value.print(200, props.template.name, true)
|
||||
}
|
||||
|
||||
const onDownloadDocx = async () => {
|
||||
try {
|
||||
await downloadFile(
|
||||
`/contract-generator/${props.template.id}/download`,
|
||||
{ variables: formData.value },
|
||||
`${props.template.name}.docx`
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('Ошибка при скачивании docx файла: ', e.message)
|
||||
}
|
||||
}
|
||||
|
||||
// function scrollToElementByText(text, options = {}) {
|
||||
// const {
|
||||
// behavior = 'smooth',
|
||||
// block = 'start',
|
||||
// inline = 'nearest',
|
||||
// partialMatch = false,
|
||||
// caseSensitive = false
|
||||
// } = options
|
||||
//
|
||||
// // Ищем все элементы, содержащие текст
|
||||
// const elements = Array.from(document.querySelectorAll('*')).filter(element => {
|
||||
// const elementText = caseSensitive
|
||||
// ? element.textContent
|
||||
// : element.textContent.toLowerCase()
|
||||
// const searchText = caseSensitive
|
||||
// ? text
|
||||
// : text.toLowerCase()
|
||||
//
|
||||
// return partialMatch
|
||||
// ? elementText.includes(searchText)
|
||||
// : elementText.trim() === searchText
|
||||
// })
|
||||
//
|
||||
// if (elements.length > 0) {
|
||||
// elements[0].scrollIntoView({
|
||||
// behavior,
|
||||
// block,
|
||||
// inline
|
||||
// })
|
||||
// return elements[0]
|
||||
// }
|
||||
//
|
||||
// return null
|
||||
// }
|
||||
const searchAndScroll = (targetText) => {
|
||||
if (!targetText.trim()) return
|
||||
|
||||
const result = scrollToElementByText(targetText)
|
||||
|
||||
if (result) {
|
||||
console.log(result)
|
||||
highlightElement(result.element)
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToElementByText = (targetText) => {
|
||||
const elementContainers = document.querySelectorAll('.textLayer')
|
||||
if (!elementContainers) return null
|
||||
|
||||
const elementsOfContainers = []
|
||||
for (const container of elementContainers) {
|
||||
elementsOfContainers.push(...container.children)
|
||||
}
|
||||
|
||||
const allElements = Array.from(elementsOfContainers)
|
||||
.filter(el => el.textContent && el.textContent.trim())
|
||||
.filter(el => {
|
||||
const style = window.getComputedStyle(el)
|
||||
return style.display !== 'none' && style.visibility !== 'hidden'
|
||||
})
|
||||
|
||||
// Сначала ищем точное совпадение
|
||||
for (const el of allElements) {
|
||||
if (el.textContent.trim() === targetText) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
return { element: el, foundText: targetText, isComposite: false }
|
||||
}
|
||||
}
|
||||
|
||||
// Ищем составной текст в соседних элементах
|
||||
for (let i = 0; i < allElements.length - 1; i++) {
|
||||
const current = allElements[i]
|
||||
const next = allElements[i + 1]
|
||||
|
||||
const combined = current.textContent.trim() + next.textContent.trim()
|
||||
|
||||
if (combined === targetText) {
|
||||
current.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
return {
|
||||
element: [current, next],
|
||||
foundText: targetText,
|
||||
isComposite: true,
|
||||
parts: [current.textContent.trim(), next.textContent.trim()]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const highlightElement = (element) => {
|
||||
// Убираем предыдущую подсветку
|
||||
document.querySelectorAll('.search-highlight').forEach(el => {
|
||||
el.classList.remove('search-highlight')
|
||||
})
|
||||
|
||||
if (Array.isArray(element)) {
|
||||
for (const el of element) {
|
||||
el.classList.add('search-highlight')
|
||||
|
||||
setTimeout(() => {
|
||||
el.classList.remove('search-highlight')
|
||||
}, 3000)
|
||||
}
|
||||
} else {
|
||||
element.classList.add('search-highlight')
|
||||
|
||||
setTimeout(() => {
|
||||
element.classList.remove('search-highlight')
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Sections>
|
||||
<template #leftbar>
|
||||
<Card header="Информация о документе">
|
||||
<div>
|
||||
<ListStrate header="Наименование">
|
||||
<span class="block text-sm">
|
||||
{{ template.name }}
|
||||
</span>
|
||||
</ListStrate>
|
||||
<ListStrate header="Дата обновления">
|
||||
<span class="text-sm">
|
||||
{{ useDateFormat(template.updated_at, 'DD.MM.YYYY HH:mm:ss') }}
|
||||
</span>
|
||||
</ListStrate>
|
||||
<ListStrate header="Дата создания">
|
||||
<span class="text-sm">
|
||||
{{ useDateFormat(template.created_at, 'DD.MM.YYYY HH:mm:ss') }}
|
||||
</span>
|
||||
</ListStrate>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex flex-col gap-y-1">
|
||||
<Button block @click="onPrint" :loading="previewLoading" icon-left>
|
||||
<template #icon>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 17h2a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h2"></path><path d="M17 9V5a2 2 0 0 0-2-2H9a2 2 0 0 0-2 2v4"></path><rect x="7" y="13" width="10" height="8" rx="2"></rect></g></svg>
|
||||
</template>
|
||||
Печать документа
|
||||
</Button>
|
||||
<Button block @click="onDownloadDocx" :loading="previewLoading" icon-left>
|
||||
<template #icon>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3v4a1 1 0 0 0 1 1h4"></path><path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z"></path><path d="M12 11v6"></path><path d="M9 14l3 3l3-3"></path></g></svg>
|
||||
</template>
|
||||
Скачать docx
|
||||
</Button>
|
||||
<CardBack :tag="Link" href="/" class="mt-2" />
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<Card header="Предпросмотр" :content-scroll="!previewLoading" :content-relative>
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<VuePdfEmbed width="793.701" text-layer ref="viewer" :source="previewUrl" @rendered="previewLoading = false" />
|
||||
<div v-if="previewLoading" class="absolute inset-0 backdrop-blur-xs h-full flex items-center justify-center z-10">
|
||||
<div class="text-center space-y-4">
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="relative">
|
||||
<div class="w-8 h-8 border-3 border-gray-200 border-t-blue-500 rounded-full animate-spin"></div>
|
||||
<div class="absolute inset-0 w-8 h-8 border-3 border-transparent border-r-blue-300 rounded-full animate-spin" style="animation-duration: 1.5s"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<template #rightbar>
|
||||
<Card header="Свойства документа" :content-relative>
|
||||
<div v-if="previewLoading" class="absolute inset-0 flex items-center justify-center">
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="relative">
|
||||
<div class="w-8 h-8 border-3 border-gray-200 border-t-blue-500 rounded-full animate-spin"></div>
|
||||
<div class="absolute inset-0 w-8 h-8 border-3 border-transparent border-r-blue-300 rounded-full animate-spin" style="animation-duration: 1.5s"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template v-for="(data, key) in formData" :key="key">
|
||||
<div v-if="data.type === 'group'">
|
||||
<Collapsible :header="data.label">
|
||||
<ListStrate v-for="(variable, key) in data.children" :key="key" :header="variable.label">
|
||||
<Input
|
||||
v-if="variable.type === 'text'"
|
||||
:id="key"
|
||||
@focus="searchAndScroll(variable.name)"
|
||||
v-model:value="variable.value"
|
||||
@update:value="value => onChangeVariableTextValue(key, value)"
|
||||
:placeholder="`Введите ${formatLabel(variable.label)}`"
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
v-if="variable.type === 'textarea'"
|
||||
:rows="8"
|
||||
:resize="false"
|
||||
:id="key"
|
||||
@focus="searchAndScroll(variable.name)"
|
||||
v-model:value="variable.value"
|
||||
@update:value="value => onChangeVariableTextValue(key, value)"
|
||||
:placeholder="`Введите ${formatLabel(variable.label)}`"
|
||||
/>
|
||||
|
||||
<!-- Select поле -->
|
||||
<Select
|
||||
v-else-if="variable.type === 'select'"
|
||||
:id="key"
|
||||
@focus="searchAndScroll(variable.name)"
|
||||
@change="value => onChangeVariableSelectValue(key, value)"
|
||||
v-model:value="variable.value"
|
||||
:options="variable.options"
|
||||
/>
|
||||
|
||||
<!-- Radio кнопки -->
|
||||
<div v-else-if="variable.type === 'radio'" class="space-y-2">
|
||||
<label
|
||||
v-for="(optionLabel, optionValue) in variable.options"
|
||||
:key="optionValue"
|
||||
class="flex items-center"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
:name="key"
|
||||
:value="optionValue"
|
||||
v-model="formData[key]"
|
||||
@change="updatePreview"
|
||||
class="mr-2"
|
||||
>
|
||||
{{ optionLabel }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<PriceInput v-else-if="variable.type === 'price-input'"
|
||||
v-model:number="variable.number"
|
||||
v-model:text="variable.value"
|
||||
@focus="searchAndScroll(variable.name)"
|
||||
/>
|
||||
|
||||
<Calendar v-else-if="variable.type === 'calendar'"
|
||||
v-model="variable.value"
|
||||
:format="variable.format"
|
||||
block
|
||||
@focus="searchAndScroll(variable.name)"
|
||||
/>
|
||||
</ListStrate>
|
||||
</Collapsible>
|
||||
</div>
|
||||
<ListStrate v-else :key="key" :header="data.label">
|
||||
<Input
|
||||
v-if="data.type === 'text'"
|
||||
:id="key"
|
||||
@focus="searchAndScroll(data.name)"
|
||||
v-model:value="data.value"
|
||||
@update:value="value => onChangeVariableTextValue(key, value)"
|
||||
:placeholder="`Введите ${formatLabel(data.label)}`"
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
v-if="data.type === 'textarea'"
|
||||
:rows="8"
|
||||
:resize="false"
|
||||
:id="key"
|
||||
@focus="searchAndScroll(data.name)"
|
||||
v-model:value="data.value"
|
||||
@update:value="value => onChangeVariableTextValue(key, value)"
|
||||
:placeholder="`Введите ${formatLabel(data.label)}`"
|
||||
/>
|
||||
|
||||
<!-- Select поле -->
|
||||
<Select
|
||||
v-else-if="data.type === 'select'"
|
||||
:id="key"
|
||||
@focus="searchAndScroll(data.name)"
|
||||
@change="value => onChangeVariableSelectValue(key, value)"
|
||||
v-model:value="data.value"
|
||||
:options="data.options"
|
||||
/>
|
||||
|
||||
<!-- Radio кнопки -->
|
||||
<div v-else-if="data.type === 'radio'" class="space-y-2">
|
||||
<label
|
||||
v-for="(optionLabel, optionValue) in data.options"
|
||||
:key="optionValue"
|
||||
class="flex items-center"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
:name="key"
|
||||
:value="optionValue"
|
||||
v-model="formData[key]"
|
||||
@change="updatePreview"
|
||||
class="mr-2"
|
||||
>
|
||||
{{ optionLabel }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<PriceInput v-else-if="data.type === 'price-input'"
|
||||
v-model:number="data.number"
|
||||
v-model:text="data.value"
|
||||
@focus="searchAndScroll(data.name)"
|
||||
/>
|
||||
|
||||
<Calendar v-else-if="data.type === 'calendar'"
|
||||
v-model="data.value"
|
||||
:format="data.format"
|
||||
block
|
||||
@focus="searchAndScroll(data.name)"
|
||||
/>
|
||||
</ListStrate>
|
||||
</template>
|
||||
<template #footer>
|
||||
<Button :loading="previewLoading" block @click="updatePreview">
|
||||
Обновить предпросмотр
|
||||
</Button>
|
||||
</template>
|
||||
</Card>
|
||||
</template>
|
||||
</Sections>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@reference "tailwindcss";
|
||||
|
||||
:deep(.search-highlight) {
|
||||
@apply border border-dashed border-yellow-500 bg-yellow-200 text-black;
|
||||
}
|
||||
:deep(.vue-pdf-embed) {
|
||||
margin: 0 auto;
|
||||
}
|
||||
:deep(.vue-pdf-embed .vue-pdf-embed__page) {
|
||||
margin-bottom: 20px !important;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
160
resources/js/Pages/Index.vue
Normal file
160
resources/js/Pages/Index.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<script setup>
|
||||
import Workspace from "../Layouts/Workspace.vue";
|
||||
import PageHeader from "../Components/Page/PageHeader.vue";
|
||||
import Page from "../Components/Page/Page.vue";
|
||||
import List from "../Components/List/List.vue";
|
||||
import ListItem from "../Components/List/ListItem.vue";
|
||||
import PageBody from "../Components/Page/PageBody.vue";
|
||||
import Badge from "../Components/Badge/Badge.vue";
|
||||
import {Link} from "@inertiajs/vue3"
|
||||
import AnimateSearch from "../Components/Input/Search/AnimateSearch.vue";
|
||||
import {ref} from "vue";
|
||||
import Button from "../Components/Button/Button.vue";
|
||||
import ImportDocumentModal from "./Parts/ImportDocumentModal.vue";
|
||||
import EditDocumentModal from "./Parts/EditDocumentModal.vue";
|
||||
|
||||
const props = defineProps({
|
||||
templates: {
|
||||
type: Array,
|
||||
default: []
|
||||
}
|
||||
})
|
||||
|
||||
const searchValue = ref()
|
||||
const showModalImport = ref(false)
|
||||
const showModalEdit = ref(false)
|
||||
const editTemplateId = ref(null)
|
||||
const vertical = ref(true)
|
||||
|
||||
const onChangeLayoutList = () => {
|
||||
vertical.value = !vertical.value
|
||||
}
|
||||
const onShowModalEdit = (template) => {
|
||||
showModalEdit.value = true
|
||||
editTemplateId.value = template.id
|
||||
}
|
||||
const onCloseModalEdit = () => {
|
||||
showModalEdit.value = false
|
||||
editTemplateId.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Workspace>
|
||||
<Page>
|
||||
<template #header>
|
||||
<PageHeader>
|
||||
Доступные шаблоны документов
|
||||
</PageHeader>
|
||||
</template>
|
||||
<PageBody>
|
||||
<div class="flex flex-col gap-y-2">
|
||||
<div class="flex flex-row gap-x-2">
|
||||
<!-- <Calendar v-model="date" format="dd MMMM yyyy год" :return-formatted />-->
|
||||
<AnimateSearch v-model="searchValue" />
|
||||
<!-- <Button :tag="Link" icon href="/editor">-->
|
||||
<!-- <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="M14 3v4a1 1 0 0 0 1 1h4"></path>-->
|
||||
<!-- <path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z"></path>-->
|
||||
<!-- <path d="M12 11v6"></path>-->
|
||||
<!-- <path d="M9 14h6"></path>-->
|
||||
<!-- </g>-->
|
||||
<!-- </svg>-->
|
||||
<!-- </template>-->
|
||||
<!-- </Button>-->
|
||||
<Button icon @click="showModalImport = true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24" class="w-3.5 h-3.5">
|
||||
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 3v4a1 1 0 0 0 1 1h4"></path>
|
||||
<path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z"></path>
|
||||
<path d="M12 11v6"></path>
|
||||
<path d="M9 14h6"></path>
|
||||
</g>
|
||||
</svg>
|
||||
</Button>
|
||||
<Button icon @click="onChangeLayoutList">
|
||||
<svg v-if="vertical" xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24" class="w-3.5 h-3.5">
|
||||
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 4h16"></path>
|
||||
<path d="M4 20h16"></path>
|
||||
<rect x="6" y="9" width="12" height="6" rx="2"></rect>
|
||||
</g>
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24" class="w-3.5 h-3.5">
|
||||
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 4v16"></path>
|
||||
<path d="M20 4v16"></path>
|
||||
<rect x="9" y="6" width="6" height="12" rx="2"></rect>
|
||||
</g>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
<List :vertical="vertical" class="h-[calc(100vh-224px)] overflow-y-auto pr-1">
|
||||
<div v-for="template in templates"
|
||||
:key="template.id"
|
||||
class="relative"
|
||||
>
|
||||
<Link :href="`/contract-generator/${template.id}`"
|
||||
class="relative"
|
||||
>
|
||||
<ListItem>
|
||||
<template v-slot:header>
|
||||
{{ template.name }}
|
||||
</template>
|
||||
<div class="relative">
|
||||
<span v-if="template.description">
|
||||
{{ template.description }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- <template v-slot:actions>-->
|
||||
<!-- <Button :tag="Link" variant="ghost" icon :href="`/editor?templateId=${template.id}`">-->
|
||||
<!-- <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="M14 3v4a1 1 0 0 0 1 1h4"></path>-->
|
||||
<!-- <path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z"></path>-->
|
||||
<!-- <path d="M12 11v6"></path>-->
|
||||
<!-- <path d="M9 14h6"></path>-->
|
||||
<!-- </g>-->
|
||||
<!-- </svg>-->
|
||||
<!-- </template>-->
|
||||
<!-- </Button>-->
|
||||
<!-- </template>-->
|
||||
<template v-slot:footer>
|
||||
<div class="flex gap-x-1.5">
|
||||
<Badge variant="primary">
|
||||
Экономический отдел
|
||||
</Badge>
|
||||
</div>
|
||||
</template>
|
||||
</ListItem>
|
||||
</Link>
|
||||
<div class="absolute right-2 top-1/2 -translate-y-1/2 z-10">
|
||||
<Button icon @click="onShowModalEdit(template)">
|
||||
<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-3.5 h-3.5">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M4 20h4l10.5 -10.5a2.828 2.828 0 1 0 -4 -4l-10.5 10.5v4" />
|
||||
<path d="M13.5 6.5l4 4" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</List>
|
||||
</div>
|
||||
</PageBody>
|
||||
</Page>
|
||||
<ImportDocumentModal v-model:open="showModalImport" @close="showModalImport = false" />
|
||||
<EditDocumentModal v-model:open="showModalEdit" :templateId="editTemplateId" @close="onCloseModalEdit" />
|
||||
</Workspace>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
681
resources/js/Pages/Parts/EditDocumentModal.vue
Normal file
681
resources/js/Pages/Parts/EditDocumentModal.vue
Normal file
@@ -0,0 +1,681 @@
|
||||
<script setup>
|
||||
import Modal from "../../Components/Modal/Modal.vue";
|
||||
import {computed, ref, watch} from "vue";
|
||||
import FileUpload from "../../Components/Input/FileUpload.vue";
|
||||
import Button from "../../Components/Button/Button.vue";
|
||||
import {router} from "@inertiajs/vue3";
|
||||
import Select from "../../Components/Select/Select.vue";
|
||||
import ListStrate from "../../Components/List/ListStrate.vue";
|
||||
import Input from "../../Components/Input/Input.vue";
|
||||
import {useApiForm} from "../../Composables/useApiForm.js";
|
||||
import Card from "../../Components/Card/Card.vue";
|
||||
import FormGroup from "../../Components/Form/FormGroup.vue";
|
||||
import Calendar from "../../Components/Calendar/Calendar.vue";
|
||||
import Collapsible from "../../Components/Collapsible/Collapsible.vue";
|
||||
|
||||
const props = defineProps({
|
||||
templateId: {
|
||||
type: [String, Number],
|
||||
default: null
|
||||
}
|
||||
})
|
||||
const open = defineModel('open')
|
||||
const stage = ref('upload')
|
||||
const description = ref('')
|
||||
const uploadedFile = ref(null)
|
||||
const templateVariables = ref(null)
|
||||
const formTitle = ref(null)
|
||||
const isTemplateLoaded = ref(false)
|
||||
const isUpdateFile = ref(false)
|
||||
|
||||
// Drag and drop состояния
|
||||
const dragItem = ref(null)
|
||||
const dragOverItem = ref(null)
|
||||
const dragOverGroup = ref(null)
|
||||
const dragSource = ref(null)
|
||||
const lastDropPosition = ref({ targetIndex: null, group: null }) // Добавляем
|
||||
|
||||
watch(() => props.templateId, async (newTemplateId) => {
|
||||
if (newTemplateId) {
|
||||
await loadTemplateData(newTemplateId)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const loadTemplateData = async (templateId) => {
|
||||
await axios.get(`/api/templates/${templateId}`)
|
||||
.then(async res => {
|
||||
const template = res.data
|
||||
formTitle.value = template.name
|
||||
description.value = template.description || ''
|
||||
uploadForm.value.id = template.id
|
||||
uploadForm.value.name = template.name
|
||||
uploadForm.value.description = template.description || ''
|
||||
uploadForm.value.variables = template.variables || []
|
||||
isTemplateLoaded.value = true
|
||||
})
|
||||
}
|
||||
|
||||
const { formData: uploadForm, errors, reset, loading, submit, setFile: setFileToForm } = useApiForm({
|
||||
name: '',
|
||||
description: '',
|
||||
file: null,
|
||||
variables: []
|
||||
})
|
||||
|
||||
watch(() => stage.value, (value) => {
|
||||
if (value === 'variables') description.value = 'Опишите найденные в документе переменные'
|
||||
else description.value = ''
|
||||
})
|
||||
|
||||
const uploadFile = async () => {
|
||||
if (isUpdateFile.value) {
|
||||
try {
|
||||
setFileToForm('doc_file', uploadedFile.value)
|
||||
await submit('/api/import/variables').then(res => {
|
||||
uploadForm.value.variables = res.variables.map(itm => ({
|
||||
label: itm.label,
|
||||
name: itm.name,
|
||||
type: 'text'
|
||||
}))
|
||||
templateVariables.value = res.variables
|
||||
if (templateVariables.value.length > 0) {
|
||||
stage.value = 'variables'
|
||||
selectedVariable.value = uploadForm.value.variables[0]
|
||||
}
|
||||
else errors.value = {
|
||||
file: [
|
||||
'В документе отсутствуют переменные'
|
||||
]
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
}
|
||||
} else {
|
||||
templateVariables.value = uploadForm.value.variables
|
||||
if (templateVariables.value.length > 0) {
|
||||
stage.value = 'variables'
|
||||
selectedVariable.value = uploadForm.value.variables[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const variableTypes = [
|
||||
{
|
||||
key: 'Однострочное поле',
|
||||
value: 'text'
|
||||
},
|
||||
{
|
||||
key: 'Многострочное поле',
|
||||
value: 'textarea'
|
||||
},
|
||||
{
|
||||
key: 'Поле выбора',
|
||||
value: 'select'
|
||||
},
|
||||
{
|
||||
key: 'Поле ввода стоимости',
|
||||
value: 'price-input'
|
||||
},
|
||||
{
|
||||
key: 'Календарь',
|
||||
value: 'calendar'
|
||||
},
|
||||
]
|
||||
|
||||
const submitForm = () => {
|
||||
uploadForm.value.file = uploadedFile.value
|
||||
router.post(`/templates/update`, uploadForm.value, {
|
||||
onSuccess: () => {
|
||||
open.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const afterCloseModal = () => {
|
||||
stage.value = 'upload'
|
||||
uploadedFile.value = null
|
||||
reset()
|
||||
}
|
||||
|
||||
const widthOfStage = computed(() => {
|
||||
if (stage.value === 'upload')
|
||||
return 0
|
||||
else if (stage.value === 'variables')
|
||||
return 980
|
||||
})
|
||||
const calendarNowDate = ref(new Date())
|
||||
|
||||
const selectedVariable = ref()
|
||||
const activeVariable = computed(() => {
|
||||
for (const item of uploadForm.value.variables) {
|
||||
if (item.name === selectedVariable.value?.name) {
|
||||
return item;
|
||||
}
|
||||
else if (Array.isArray(item.children)) {
|
||||
const foundChild = item.children.find(child => child.name === selectedVariable.value?.name);
|
||||
if (foundChild) {
|
||||
return foundChild;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
|
||||
const inputVariableOptions = (value) => {
|
||||
activeVariable.value.options = value.split(',').map(item => item.trim())
|
||||
}
|
||||
|
||||
const changeTypeValue = (type) => {
|
||||
if (type !== 'select') {
|
||||
delete activeVariable.value.options
|
||||
delete activeVariable.value.textOptions
|
||||
}
|
||||
}
|
||||
|
||||
const clickToVariable = (variable) => {
|
||||
selectedVariable.value = variable
|
||||
calendarNowDate.value = new Date()
|
||||
}
|
||||
|
||||
const createVariableGroup = () => {
|
||||
const groupCount = templateVariables.value.filter(itm => itm.isGroup === true)
|
||||
const group = {
|
||||
label: `Группа ${groupCount.length + 1}`,
|
||||
children: [],
|
||||
isGroup: true,
|
||||
type: 'group',
|
||||
name: `group-${Date.now()}`
|
||||
}
|
||||
templateVariables.value.push(group)
|
||||
uploadForm.value.variables = [...templateVariables.value]
|
||||
}
|
||||
|
||||
// Drag and Drop функции
|
||||
const dragStart = (event, variable, index, sourceGroup = null) => {
|
||||
dragSource.value = sourceGroup ? 'group' : 'root'
|
||||
dragItem.value = { variable, index, sourceGroup }
|
||||
|
||||
// Делаем оригинальный элемент полупрозрачным
|
||||
event.currentTarget.style.opacity = '0.4'
|
||||
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
|
||||
const dragEnd = () => {
|
||||
// Восстанавливаем прозрачность всех элементов
|
||||
document.querySelectorAll('.drag-handle').forEach(el => {
|
||||
el.style.opacity = '1'
|
||||
})
|
||||
|
||||
dragItem.value = null
|
||||
dragOverItem.value = null
|
||||
dragOverGroup.value = null
|
||||
dragSource.value = null
|
||||
lastDropPosition.value = { targetIndex: null, group: null }
|
||||
}
|
||||
|
||||
const dragOver = (event, targetIndex, group = null) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
console.log('dragOver:', { targetIndex, group, dragItem: dragItem.value })
|
||||
|
||||
// Сохраняем последнюю позицию
|
||||
lastDropPosition.value = { targetIndex, group }
|
||||
|
||||
// Не показывать индикатор если перетаскиваемый элемент тот же самый
|
||||
if (dragItem.value) {
|
||||
const isSameElement = group ?
|
||||
(dragItem.value.sourceGroup === group && dragItem.value.index === targetIndex) :
|
||||
(dragItem.value.index === targetIndex)
|
||||
|
||||
if (isSameElement) {
|
||||
dragOverItem.value = null
|
||||
dragOverGroup.value = null
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
dragOverItem.value = targetIndex
|
||||
dragOverGroup.value = group
|
||||
}
|
||||
|
||||
const handleGlobalDrop = (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
if (!dragItem.value) return
|
||||
|
||||
// Используем последнюю сохраненную позицию для глобального дропа
|
||||
const { targetIndex, group } = lastDropPosition.value
|
||||
|
||||
if (targetIndex !== null) {
|
||||
drop(event, targetIndex, group)
|
||||
} else {
|
||||
// Если позиция не определена, сбрасываем
|
||||
dragEnd()
|
||||
}
|
||||
}
|
||||
|
||||
const drop = (event, targetIndex, group = null) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
if (!dragItem.value) return
|
||||
|
||||
// Используем последнюю сохраненную позицию, если текущие параметры null
|
||||
const finalTargetIndex = targetIndex ?? lastDropPosition.value.targetIndex
|
||||
const finalGroup = group ?? lastDropPosition.value.group
|
||||
|
||||
console.log('drop:', {
|
||||
targetIndex,
|
||||
group,
|
||||
finalTargetIndex,
|
||||
finalGroup,
|
||||
lastDropPosition: lastDropPosition.value,
|
||||
dragItem: dragItem.value
|
||||
})
|
||||
|
||||
const sourceVariable = dragItem.value.variable
|
||||
const sourceGroup = dragItem.value.sourceGroup
|
||||
|
||||
// Обработка для targetIndex = -2 (начало группы)
|
||||
const actualTargetIndex = targetIndex === -2 ? 0 : targetIndex
|
||||
|
||||
// Если перетаскиваем ГРУППУ (изменение порядка групп)
|
||||
if (sourceVariable.isGroup && !group) {
|
||||
const sourceIndex = dragItem.value.index
|
||||
if (sourceIndex !== actualTargetIndex) {
|
||||
const [removed] = templateVariables.value.splice(sourceIndex, 1)
|
||||
|
||||
// Корректируем targetIndex если удалили элемент перед целевой позицией
|
||||
const adjustedTargetIndex = sourceIndex < actualTargetIndex
|
||||
? actualTargetIndex - 1
|
||||
: actualTargetIndex
|
||||
|
||||
templateVariables.value.splice(adjustedTargetIndex, 0, removed)
|
||||
}
|
||||
}
|
||||
// Если перетаскиваем из группы в корень
|
||||
else if (sourceGroup && !group) {
|
||||
// Удаляем из группы
|
||||
const sourceIndex = sourceGroup.children.findIndex(v => v.name === sourceVariable.name)
|
||||
if (sourceIndex > -1) {
|
||||
sourceGroup.children.splice(sourceIndex, 1)
|
||||
// Добавляем в корень
|
||||
if (actualTargetIndex === -1) {
|
||||
templateVariables.value.push(sourceVariable)
|
||||
} else {
|
||||
templateVariables.value.splice(actualTargetIndex, 0, sourceVariable)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Если перетаскиваем из корня в группу
|
||||
else if (!sourceGroup && group && !sourceVariable.isGroup) {
|
||||
// Удаляем из корня
|
||||
const sourceIndex = templateVariables.value.findIndex(v => v.name === sourceVariable.name)
|
||||
if (sourceIndex > -1) {
|
||||
templateVariables.value.splice(sourceIndex, 1)
|
||||
// Добавляем в группу
|
||||
if (!group.children) group.children = []
|
||||
if (actualTargetIndex === -1) {
|
||||
group.children.push(sourceVariable)
|
||||
} else {
|
||||
group.children.splice(actualTargetIndex, 0, sourceVariable)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Если перетаскиваем из группы в другую группу
|
||||
else if (sourceGroup && group && sourceGroup !== group && !sourceVariable.isGroup) {
|
||||
// Удаляем из исходной группы
|
||||
const sourceIndex = sourceGroup.children.findIndex(v => v.name === sourceVariable.name)
|
||||
if (sourceIndex > -1) {
|
||||
sourceGroup.children.splice(sourceIndex, 1)
|
||||
// Добавляем в целевую группу
|
||||
if (!group.children) group.children = []
|
||||
if (actualTargetIndex === -1) {
|
||||
group.children.push(sourceVariable)
|
||||
} else {
|
||||
group.children.splice(actualTargetIndex, 0, sourceVariable)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Если перетаскиваем между элементами в корне
|
||||
else if (!sourceGroup && !group && !sourceVariable.isGroup) {
|
||||
const sourceIndex = dragItem.value.index
|
||||
const [removed] = templateVariables.value.splice(sourceIndex, 1)
|
||||
templateVariables.value.splice(actualTargetIndex, 0, removed)
|
||||
}
|
||||
// Если перетаскиваем внутри одной группы
|
||||
else if (sourceGroup && group && sourceGroup === group && !sourceVariable.isGroup) {
|
||||
const sourceIndex = dragItem.value.index
|
||||
const [removed] = group.children.splice(sourceIndex, 1)
|
||||
group.children.splice(actualTargetIndex, 0, removed)
|
||||
}
|
||||
|
||||
// Сбрасываем состояния
|
||||
dragItem.value = null
|
||||
dragOverItem.value = null
|
||||
dragOverGroup.value = null
|
||||
dragSource.value = null
|
||||
}
|
||||
|
||||
// Удаление группы
|
||||
const removeGroup = (group) => {
|
||||
const index = templateVariables.value.findIndex(v => v === group)
|
||||
if (index > -1) {
|
||||
// Перемещаем детей группы в корень
|
||||
if (group.children && group.children.length) {
|
||||
templateVariables.value.splice(index, 1, ...group.children)
|
||||
} else {
|
||||
templateVariables.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Редактирование группы
|
||||
const editGroup = (group) => {
|
||||
selectedVariable.value = group
|
||||
}
|
||||
|
||||
// Получение индекса переменной в корневом списке
|
||||
const getRootIndex = (variable) => {
|
||||
return templateVariables.value.findIndex(v => v.name === variable.name)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal v-model:open="open" :title="formTitle" :description="description" @after-close="afterCloseModal" :width="widthOfStage">
|
||||
<div v-if="!isTemplateLoaded" class="h-[376px] relative">
|
||||
<div class="absolute inset-1/2">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div v-if="stage === 'upload'" class="flex flex-col gap-y-1">
|
||||
<Input v-model:value="uploadForm.name" label="Наименование" />
|
||||
<Input v-model:value="uploadForm.description" label="Описание" />
|
||||
<div class="mt-2.5">
|
||||
<Button block text-align="center" v-if="!isUpdateFile" @click="isUpdateFile = true">
|
||||
Загрузить обновленный шаблон
|
||||
</Button>
|
||||
<FileUpload v-else v-model:file="uploadedFile" accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-[280px_1fr] gap-x-2 ">
|
||||
<Card header="Переменные документа">
|
||||
<Button class="mb-2 -mt-2" block @click="createVariableGroup" icon-left>
|
||||
<template #icon>
|
||||
<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-4 w-4">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M4 4h6v6h-6zm10 0h6v6h-6zm-10 10h6v6h-6zm10 3h6m-3 -3v6" />
|
||||
</svg>
|
||||
</template>
|
||||
Добавить группу
|
||||
</Button>
|
||||
<div class="flex flex-col gap-y-0.5 max-h-[396px] overflow-y-auto">
|
||||
<template v-for="(variable, index) in templateVariables" :key="variable.name || variable.label">
|
||||
|
||||
<!-- Визуальный дубликат перетаскиваемого элемента -->
|
||||
<div v-if="dragItem && dragOverItem === index && !dragOverGroup && dragItem.variable !== variable"
|
||||
@dragover="dragOver($event, index)"
|
||||
@drop="drop($event, index)"
|
||||
class="opacity-60 transform">
|
||||
<Button
|
||||
icon-left
|
||||
block
|
||||
class="cursor-grabbing bg-blue-50 border-blue-200"
|
||||
disabled>
|
||||
<template #icon>
|
||||
<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-4 w-4">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
</svg>
|
||||
</template>
|
||||
{{ dragItem.variable.label }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="variable.isGroup"
|
||||
class="pl-px pt-px pb-px pr-px"
|
||||
:class="{ 'bg-blue-50/10 rounded': dragOverGroup === variable }"
|
||||
@dragover="dragOver($event, index)"
|
||||
@drop="drop($event, index)">
|
||||
<Collapsible class="drag-handle"
|
||||
draggable="true"
|
||||
@dragstart="dragStart($event, variable, index)"
|
||||
@dragend="dragEnd">
|
||||
<template #icon>
|
||||
<div class="cursor-grab active:cursor-grabbing">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center flex-1 drag-handle">
|
||||
<span class="block text-sm font-medium truncate">{{variable.label}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<button @click="editGroup(variable)" class="text-white hover:text-zinc-300 flex-shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M4 20h4l10.5 -10.5a2.828 2.828 0 1 0 -4 -4l-10.5 10.5v4" />
|
||||
<path d="M13.5 6.5l4 4" />
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="removeGroup(variable)" class="text-red-500 hover:text-red-700 flex-shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 6h18"></path>
|
||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
|
||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
<div class="min-h-2 flex flex-col gap-y-0.5"
|
||||
@dragover="dragOver($event, -2, variable)"
|
||||
@drop="drop($event, -2, variable)">
|
||||
|
||||
<template v-if="variable.children && variable.children.length > 0">
|
||||
<div v-for="(child, childIndex) in variable.children"
|
||||
:key="child.name"
|
||||
class="relative"
|
||||
@dragover="dragOver($event, childIndex, variable)"
|
||||
@drop="drop($event, childIndex, variable)"
|
||||
>
|
||||
|
||||
<!-- Визуальный дубликат между элементами в группе -->
|
||||
<div v-if="dragItem && dragOverItem === childIndex && dragOverGroup === variable"
|
||||
class="opacity-60 transform">
|
||||
<Button
|
||||
icon-left
|
||||
block
|
||||
class="cursor-grabbing bg-blue-50 border-blue-200"
|
||||
disabled>
|
||||
<template #icon>
|
||||
<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-4 w-4">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
</svg>
|
||||
</template>
|
||||
{{ dragItem.variable.label }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
icon-left
|
||||
block
|
||||
@click="clickToVariable(child)"
|
||||
draggable="true"
|
||||
@dragstart="dragStart($event, child, childIndex, variable)"
|
||||
@dragend="dragEnd"
|
||||
class="drag-handle cursor-grab active:cursor-grabbing">
|
||||
<template #icon>
|
||||
<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-4 w-4">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
</svg>
|
||||
</template>
|
||||
<span class="truncate">{{ child.label }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
<div v-else
|
||||
class="text-center text-gray-400 py-2 text-sm border-2 border-dashed border-gray-300 rounded mx-2"
|
||||
@dragover="dragOver($event, -2, variable)"
|
||||
@drop="drop($event, -2, variable)">
|
||||
Перетащите переменные сюда
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
<div v-else
|
||||
class="relative"
|
||||
:class="{ 'bg-blue-50/10': dragOverItem === index && !dragOverGroup }"
|
||||
@dragover="dragOver($event, index)"
|
||||
@drop="drop($event, index)">
|
||||
<Button
|
||||
icon-left
|
||||
block
|
||||
@click="clickToVariable(variable)"
|
||||
draggable="true"
|
||||
@dragstart="dragStart($event, variable, index)"
|
||||
@dragend="dragEnd"
|
||||
class="drag-handle cursor-grab active:cursor-grabbing">
|
||||
<template #icon>
|
||||
<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-4 w-4">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
</svg>
|
||||
</template>
|
||||
{{ variable.label }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Визуальный дубликат в конце корневого списка -->
|
||||
<div v-if="dragItem && dragOverItem === -1 && !dragOverGroup"
|
||||
class="opacity-60 transform scale-105">
|
||||
<Button
|
||||
icon-left
|
||||
block
|
||||
class="cursor-grabbing bg-blue-50 border-blue-200"
|
||||
disabled>
|
||||
<template #icon>
|
||||
<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-4 w-4">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
</svg>
|
||||
</template>
|
||||
{{ dragItem.variable.label }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Card>
|
||||
<Card :header="selectedVariable?.label || 'Выберите переменную'">
|
||||
<div class="pr-2" v-if="selectedVariable">
|
||||
<div class="flex flex-col gap-y-1">
|
||||
<FormGroup label="Наименование переменной" position="top">
|
||||
<Input v-model:value="activeVariable.name" disabled />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label="Отображаемое наименование" position="top">
|
||||
<Input v-model:value="activeVariable.label" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="activeVariable.type !== 'group'" label="Тип ввода" position="top">
|
||||
<Select :options="variableTypes" v-model:value="activeVariable.type" @update:value="changeTypeValue(value)" placeholder="Выберите тип" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="activeVariable.type === 'select'" label="Значения для выбора" position="top">
|
||||
<Input v-model:value="activeVariable.textOptions" @update:value="value => inputVariableOptions(value)" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="activeVariable.type === 'calendar'" label="Формат выводимой даты" position="top">
|
||||
<Input v-model:value="activeVariable.format" placeholder="К примеру dd.MM.yyyy" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="activeVariable.type === 'calendar'" label="Предпросмотр даты" position="top">
|
||||
<Calendar v-model="calendarNowDate" :format="activeVariable.format" block disabled />
|
||||
</FormGroup>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-center text-gray-400 py-8">
|
||||
Выберите переменную для редактирования
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div v-if="errors" class="absolute translate-x-full top-2 -right-2 flex flex-col gap-y-1">
|
||||
<template v-for="errorContainer in errors">
|
||||
<template v-for="error in errorContainer">
|
||||
<div class="flex flex-row items-center bg-rose-300 rounded-md gap-x-1.5 py-2 px-3">
|
||||
<div class="h-5 w-5 text-red-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M9 10h.01"></path><path d="M15 10h.01"></path><path d="M9.5 15.25a3.5 3.5 0 0 1 5 0"></path></g></svg>
|
||||
</div>
|
||||
<span class="text-red-500 text-sm">
|
||||
{{ error }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<Button v-if="stage === 'upload'" @click="uploadFile">
|
||||
Далее
|
||||
</Button>
|
||||
<Button v-else @click="submitForm">
|
||||
Завершить
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
203
resources/js/Pages/Parts/ImportDocumentModal.vue
Normal file
203
resources/js/Pages/Parts/ImportDocumentModal.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<script setup>
|
||||
import Modal from "../../Components/Modal/Modal.vue";
|
||||
import {computed, ref, watch} from "vue";
|
||||
import FileUpload from "../../Components/Input/FileUpload.vue";
|
||||
import Button from "../../Components/Button/Button.vue";
|
||||
import {router} from "@inertiajs/vue3";
|
||||
import Select from "../../Components/Select/Select.vue";
|
||||
import ListStrate from "../../Components/List/ListStrate.vue";
|
||||
import Input from "../../Components/Input/Input.vue";
|
||||
import {useApiForm} from "../../Composables/useApiForm.js";
|
||||
import Card from "../../Components/Card/Card.vue";
|
||||
import FormGroup from "../../Components/Form/FormGroup.vue";
|
||||
import Calendar from "../../Components/Calendar/Calendar.vue";
|
||||
|
||||
const open = defineModel('open')
|
||||
const stage = ref('upload')
|
||||
const description = ref('')
|
||||
const uploadedFile = ref(null)
|
||||
const templateVariables = ref(null)
|
||||
|
||||
const { formData: uploadForm, errors, reset, loading, submit, setFile: setFileToForm } = useApiForm({
|
||||
name: '',
|
||||
description: '',
|
||||
file: null,
|
||||
variables: []
|
||||
})
|
||||
|
||||
watch(() => stage.value, (value) => {
|
||||
if (value === 'variables') description.value = 'Опишите найденные в документе переменные'
|
||||
else description.value = ''
|
||||
})
|
||||
|
||||
const uploadFile = async () => {
|
||||
try {
|
||||
setFileToForm('doc_file', uploadedFile.value)
|
||||
await submit('/api/import/variables').then(res => {
|
||||
uploadForm.value.variables = res.variables.map(itm => ({
|
||||
label: itm.label,
|
||||
name: itm.name,
|
||||
type: 'text'
|
||||
}))
|
||||
templateVariables.value = res.variables
|
||||
if (templateVariables.value.length > 0) {
|
||||
stage.value = 'variables'
|
||||
selectedVariable.value = uploadForm.value.variables[0]
|
||||
}
|
||||
else errors.value = {
|
||||
file: [
|
||||
'В документе отсутствуют переменные'
|
||||
]
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
}
|
||||
}
|
||||
|
||||
const variableTypes = [
|
||||
{
|
||||
key: 'Однострочное поле',
|
||||
value: 'text'
|
||||
},
|
||||
{
|
||||
key: 'Многострочное поле',
|
||||
value: 'textarea'
|
||||
},
|
||||
{
|
||||
key: 'Поле выбора',
|
||||
value: 'select'
|
||||
},
|
||||
{
|
||||
key: 'Поле ввода стоимости',
|
||||
value: 'price-input'
|
||||
},
|
||||
{
|
||||
key: 'Календарь',
|
||||
value: 'calendar'
|
||||
},
|
||||
]
|
||||
|
||||
const submitForm = () => {
|
||||
uploadForm.value.file = uploadedFile.value
|
||||
router.post('/templates/import', uploadForm.value, {
|
||||
onSuccess: () => {
|
||||
open.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const afterCloseModal = () => {
|
||||
stage.value = 'upload'
|
||||
uploadedFile.value = null
|
||||
reset()
|
||||
}
|
||||
|
||||
const widthOfStage = computed(() => {
|
||||
if (stage.value === 'upload')
|
||||
return 0
|
||||
else if (stage.value === 'variables')
|
||||
return 980
|
||||
})
|
||||
const calendarNowDate = ref(new Date())
|
||||
|
||||
const selectedVariable = ref()
|
||||
const activeVariable = computed(() => {
|
||||
return uploadForm.value.variables.find(itm => itm.name === selectedVariable.value?.name)
|
||||
})
|
||||
|
||||
const inputVariableOptions = (value) => {
|
||||
activeVariable.value.options = value.split(',').map(item => item.trim())
|
||||
}
|
||||
|
||||
const changeTypeValue = (type) => {
|
||||
if (type !== 'select') {
|
||||
delete activeVariable.value.options
|
||||
delete activeVariable.value.textOptions
|
||||
}
|
||||
}
|
||||
|
||||
const clickToVariable = (variable) => {
|
||||
selectedVariable.value = variable
|
||||
calendarNowDate.value = new Date()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal v-model:open="open" title="Импорт документа" :description="description" @after-close="afterCloseModal" :width="widthOfStage">
|
||||
<div v-if="stage === 'upload'" class="flex flex-col gap-y-1">
|
||||
<Input v-model:value="uploadForm.name" label="Наименование" />
|
||||
<Input v-model:value="uploadForm.description" label="Описание" />
|
||||
<FileUpload class="mt-2.5" v-model:file="uploadedFile" accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document" />
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-[280px_1fr] gap-x-2 ">
|
||||
<Card header="Переменные документа">
|
||||
<div class="flex flex-col gap-y-0.5 max-h-[420px] pr-2 overflow-y-auto">
|
||||
<Button v-for="variable in templateVariables" block @click="clickToVariable(variable)">
|
||||
{{ variable.label }}
|
||||
</Button>
|
||||
</div>
|
||||
<!-- <ListStrate v-for="variable in uploadForm.variables" :header="variable.label">-->
|
||||
<!-- <Select :options="variableTypes" v-model:value="variable.type" placeholder="Выберите тип" />-->
|
||||
<!-- </ListStrate>-->
|
||||
</Card>
|
||||
<Card :header="selectedVariable.label">
|
||||
<div class="pr-2">
|
||||
<div class="flex flex-col gap-y-1">
|
||||
<FormGroup label="Наименование переменной" position="top">
|
||||
<Input v-model:value="activeVariable.name" disabled />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label="Отображаемое наименование" position="top">
|
||||
<Input v-model:value="activeVariable.label" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label="Тип ввода" position="top">
|
||||
<Select :options="variableTypes" v-model:value="activeVariable.type" @update:value="changeTypeValue(value)" placeholder="Выберите тип" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="activeVariable.type === 'select'" label="Значения для выбора" position="top">
|
||||
<Input v-model:value="activeVariable.textOptions" @update:value="value => inputVariableOptions(value)" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="activeVariable.type === 'calendar'" label="Формат выводимой даты" position="top">
|
||||
<Input v-model:value="activeVariable.format" placeholder="К примеру dd.MM.yyyy" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="activeVariable.type === 'calendar'" label="Предпросмотр даты" position="top">
|
||||
<Calendar v-model="calendarNowDate" :format="activeVariable.format" block disabled />
|
||||
</FormGroup>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div v-if="errors" class="absolute translate-x-full top-2 -right-2 flex flex-col gap-y-1">
|
||||
<template v-for="errorContainer in errors">
|
||||
<template v-for="error in errorContainer">
|
||||
<div class="flex flex-row items-center bg-rose-300 rounded-md gap-x-1.5 py-2 px-3">
|
||||
<div class="h-5 w-5 text-red-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M9 10h.01"></path><path d="M15 10h.01"></path><path d="M9.5 15.25a3.5 3.5 0 0 1 5 0"></path></g></svg>
|
||||
</div>
|
||||
<span class="text-red-500 text-sm">
|
||||
{{ error }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<Button v-if="stage === 'upload'" @click="uploadFile">
|
||||
Далее
|
||||
</Button>
|
||||
<Button v-else @click="submitForm">
|
||||
Завершить
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
97
resources/js/Pages/Parts/VariableModal.vue
Normal file
97
resources/js/Pages/Parts/VariableModal.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<script setup>
|
||||
import Modal from "../../Components/Modal/Modal.vue";
|
||||
import Input from "../../Components/Input/Input.vue"
|
||||
import {ref, watch} from "vue";
|
||||
import FormGroup from "../../Components/Form/FormGroup.vue";
|
||||
import Select from "../../Components/Select/Select.vue";
|
||||
import Button from "../../Components/Button/Button.vue";
|
||||
|
||||
const props = defineProps({
|
||||
variable: Object
|
||||
})
|
||||
|
||||
const emit = defineEmits(['save']);
|
||||
|
||||
watch(() => props.variable, (newVal) => {
|
||||
if (newVal) {
|
||||
localVariable.value = { ...newVal }
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const addOption = () => {
|
||||
localVariable.value.options.push({ value: '', label: '' })
|
||||
}
|
||||
|
||||
const removeOption = (index) => {
|
||||
localVariable.value.options.splice(index, 1)
|
||||
}
|
||||
|
||||
const save = () => {
|
||||
emit('save', { ...localVariable.value });
|
||||
};
|
||||
|
||||
const open = defineModel('open', {
|
||||
type: Boolean,
|
||||
default: false
|
||||
})
|
||||
|
||||
const localVariable = ref({
|
||||
name: '',
|
||||
label: '',
|
||||
type: 'text',
|
||||
options: [],
|
||||
default: ''
|
||||
})
|
||||
|
||||
const typeOptions = [
|
||||
{
|
||||
text: 'Текст'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal v-model:open="open" @close="open = false" title="Добавить переменную">
|
||||
<div class="flex flex-col gap-y-2">
|
||||
<FormGroup label="Имя переменной:">
|
||||
<Input v-model:value="localVariable.name" placeholder="Например: your_name" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label="Отображаемое имя:">
|
||||
<Input v-model:value="localVariable.label" placeholder="Например: Ваше имя" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label="Тип:">
|
||||
<Select v-model="localVariable.type" :options="typeOptions" />
|
||||
</FormGroup>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex flex-row justify-end gap-x-2">
|
||||
<Button variant="danger" @click="open = false">
|
||||
<template #icon>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 6L6 18"></path>
|
||||
<path d="M6 6l12 12"></path>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button @click="save">
|
||||
<template #icon>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24">
|
||||
<path d="M5 12l5 5L20 7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
</template>
|
||||
Сохранить
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
406
resources/js/Pages/TemplateEditor.vue
Normal file
406
resources/js/Pages/TemplateEditor.vue
Normal file
@@ -0,0 +1,406 @@
|
||||
<script setup>
|
||||
import Editor from "../Components/Editor.vue";
|
||||
import Sections from "../Layouts/Sections.vue";
|
||||
import Card from "../Components/Card/Card.vue";
|
||||
import {computed, ref, watch} from "vue";
|
||||
import Button from "../Components/Button/Button.vue";
|
||||
import ListStrate from "../Components/List/ListStrate.vue";
|
||||
import Input from "../Components/Input/Input.vue";
|
||||
import {now} from "@vueuse/core";
|
||||
import Select from "../Components/Select/Select.vue";
|
||||
import {Link, router} from "@inertiajs/vue3";
|
||||
import CardBack from "../Components/Card/CardBack.vue";
|
||||
|
||||
const props = defineProps({
|
||||
template: Object
|
||||
})
|
||||
|
||||
const editor = ref()
|
||||
|
||||
const content = ref(props.template?.content ?? [])
|
||||
const zoom = 1
|
||||
const zoom_min = 0.10
|
||||
const zoom_max = 5.0
|
||||
const page_format_mm = [210, 297]
|
||||
const page_margins = "2cm 1.5cm 2cm 3cm"
|
||||
const display = "grid" // ["grid", "vertical", "horizontal"]
|
||||
const mounted = false // will be true after this component is mounted
|
||||
const undo_count = -1 // contains the number of times user can undo (= current position in content_history)
|
||||
const content_history = [] // contains the content states for undo/redo operations
|
||||
const variables = ref(props.template?.variables_config ?? {})
|
||||
const variablesItems = [
|
||||
{
|
||||
key: 'Поле ввода',
|
||||
value: 'text'
|
||||
},
|
||||
{
|
||||
key: 'Поле выбора',
|
||||
value: 'select'
|
||||
}
|
||||
]
|
||||
|
||||
const elementInfo = ref({})
|
||||
const activeElement = ref()
|
||||
const activeElements = ref()
|
||||
const currentTextStyle = ref('')
|
||||
|
||||
const getCurrentTextStyle = (style) => {
|
||||
currentTextStyle.value = style
|
||||
}
|
||||
|
||||
const mappedTags = {
|
||||
'P': 'Текст',
|
||||
'H1': 'Заголовок 1',
|
||||
'H2': 'Заголовок 2',
|
||||
'VAR': 'Переменная'
|
||||
}
|
||||
|
||||
const formatAlignLeft = () => {
|
||||
document.execCommand("justifyLeft")
|
||||
}
|
||||
|
||||
const formatAlignCenter = () => {
|
||||
document.execCommand("justifyCenter")
|
||||
}
|
||||
|
||||
const formatAlignRight = () => {
|
||||
document.execCommand("justifyRight")
|
||||
}
|
||||
|
||||
const formatAlignJustify = () => {
|
||||
document.execCommand("justifyFull")
|
||||
}
|
||||
|
||||
const formatTextBold = () => {
|
||||
document.execCommand("bold")
|
||||
}
|
||||
const formatTextItalic = () => {
|
||||
document.execCommand("italic")
|
||||
}
|
||||
const formatTextUnderline = () => {
|
||||
document.execCommand("underline")
|
||||
}
|
||||
const formatTextStrikethrough = () => {
|
||||
document.execCommand("strikethrough")
|
||||
}
|
||||
|
||||
const formatFirstLine = () => {
|
||||
if (activeElement.value.style.textIndent) {
|
||||
activeElement.value.style.textIndent = ''
|
||||
return
|
||||
} else if (activeElement.value.parentElement.tagName === 'P' &&
|
||||
activeElement.value.parentElement.style.textIndent) {
|
||||
activeElement.value.parentElement.style.textIndent = ''
|
||||
return
|
||||
}
|
||||
|
||||
if (activeElement.value.tagName === 'P') {
|
||||
activeElement.value.style.textIndent = '1.25cm'
|
||||
} else if (activeElement.value.parentElement.tagName === 'P') {
|
||||
activeElement.value.parentElement.style.textIndent = '1.25cm'
|
||||
}
|
||||
}
|
||||
const clearBackground = () => {
|
||||
activeElement.value.style.background = ''
|
||||
}
|
||||
|
||||
const isVariable = computed(() => {
|
||||
return activeElement.value.getAttribute('brs-variable')
|
||||
})
|
||||
const createVariable = () => {
|
||||
const selection = window.getSelection();
|
||||
|
||||
// Проверяем, есть ли выделение
|
||||
if (selection.rangeCount === 0) return;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const selectedText = range.toString();
|
||||
|
||||
// Проверяем, что текст действительно выделен
|
||||
if (!selectedText) return;
|
||||
|
||||
if (isVariable.value === 'true') {
|
||||
const elementId = activeElement.value.getAttribute('brs-element-id')
|
||||
delete variables.value[elementId]
|
||||
activeElement.value.removeAttribute('brs-variable')
|
||||
activeElement.value.removeAttribute('brs-type')
|
||||
activeElement.value.removeAttribute('brs-element-id')
|
||||
} else {
|
||||
// Создаем span элемент
|
||||
const span = document.createElement('span');
|
||||
const spanId = now().toString()
|
||||
span.textContent = selectedText;
|
||||
span.setAttribute('brs-variable', 'true');
|
||||
span.setAttribute('brs-type', 'text');
|
||||
span.setAttribute('brs-element-id', spanId);
|
||||
|
||||
// Удаляем выделенный текст и вставляем span
|
||||
range.deleteContents();
|
||||
range.insertNode(span);
|
||||
|
||||
variables.value = {
|
||||
...variables.value,
|
||||
[spanId]: {
|
||||
name: selectedText,
|
||||
type: 'text',
|
||||
label: selectedText
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Очищаем выделение
|
||||
selection.removeAllRanges();
|
||||
|
||||
console.log('Создан span для текста:', selectedText);
|
||||
}
|
||||
|
||||
watch(activeElement, (element) => {
|
||||
if (!element) return
|
||||
|
||||
elementInfo.value = {}
|
||||
|
||||
if (element.getAttribute('brs-variable') === 'true') {
|
||||
const elementId = element.getAttribute('brs-element-id')
|
||||
elementInfo.value.id = elementId
|
||||
elementInfo.value.element = 'VAR'
|
||||
elementInfo.value.name = mappedTags[elementInfo.value.element]
|
||||
elementInfo.value.type = element.getAttribute('brs-type')
|
||||
elementInfo.value.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
elementInfo.value.name = mappedTags[element.tagName]
|
||||
elementInfo.value.element = element.tagName
|
||||
})
|
||||
|
||||
const updateVariableType = (elementId, type) => {
|
||||
const variableKeys = Object.keys(variables.value[elementId])
|
||||
if (variableKeys.includes('values')) {
|
||||
if (type !== 'select') {
|
||||
delete variables.value[elementId].values
|
||||
delete variables.value[elementId].value
|
||||
}
|
||||
} else if (type === 'select'){
|
||||
variables.value[elementId].values = []
|
||||
}
|
||||
}
|
||||
|
||||
const updateVariableValue = (elementId, value) => {
|
||||
const variableType = variables.value[elementId].type
|
||||
if (variableType === 'select') {
|
||||
variables.value[elementId].value = value
|
||||
variables.value[elementId].values = value.split(',').map(item => item.trim());
|
||||
}
|
||||
}
|
||||
|
||||
const saveTemplate = () => {
|
||||
const data = {
|
||||
...props.template,
|
||||
content: content.value,
|
||||
variables_config: variables.value
|
||||
}
|
||||
|
||||
router.post('/editor', data)
|
||||
}
|
||||
|
||||
const documentPrint = () => {
|
||||
window.print()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Sections>
|
||||
<template #leftbar>
|
||||
<Card>
|
||||
<template #footer>
|
||||
<CardBack :tag="Link" href="/" />
|
||||
</template>
|
||||
</Card>
|
||||
</template>
|
||||
<Card>
|
||||
<template #header>
|
||||
<div class="flex flex-row gap-x-3">
|
||||
<div class="flex flex-row gap-x-1">
|
||||
<Button icon @click="formatAlignLeft">
|
||||
<template #icon>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<path d="M4 6h16"></path>
|
||||
<path d="M4 12h10"></path>
|
||||
<path d="M4 18h14"></path>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
</Button>
|
||||
<Button icon @click="formatAlignCenter">
|
||||
<template #icon>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<path d="M4 6h16"></path>
|
||||
<path d="M8 12h8"></path>
|
||||
<path d="M6 18h12"></path>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
</Button>
|
||||
<Button icon @click="formatAlignRight">
|
||||
<template #icon>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<path d="M4 6h16"></path>
|
||||
<path d="M10 12h10"></path>
|
||||
<path d="M6 18h14"></path>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
</Button>
|
||||
<Button icon @click="formatAlignJustify">
|
||||
<template #icon>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<path d="M4 6h16"></path>
|
||||
<path d="M4 12h16"></path>
|
||||
<path d="M4 18h12"></path>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-row gap-x-1">
|
||||
<Button icon @click="formatTextBold">
|
||||
<template #icon>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<path d="M7 5h6a3.5 3.5 0 0 1 0 7H7z"></path>
|
||||
<path d="M13 12h1a3.5 3.5 0 0 1 0 7H7v-7"></path>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
</Button>
|
||||
<Button icon @click="formatTextItalic">
|
||||
<template #icon>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<path d="M11 5h6"></path>
|
||||
<path d="M7 19h6"></path>
|
||||
<path d="M14 5l-4 14"></path>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
</Button>
|
||||
<Button icon @click="formatTextUnderline">
|
||||
<template #icon>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<path d="M7 5v5a5 5 0 0 0 10 0V5"></path>
|
||||
<path d="M5 19h14"></path>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
</Button>
|
||||
<Button icon @click="formatTextStrikethrough">
|
||||
<template #icon>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
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="M16 6.5A4 2 0 0 0 12 5h-1a3.5 3.5 0 0 0 0 7h2a3.5 3.5 0 0 1 0 7h-1.5a4 2 0 0 1-4-1.5"></path>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-row gap-x-1">
|
||||
<Button icon @click="formatFirstLine">
|
||||
<template #icon>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6H9"></path><path d="M20 12h-7"></path><path d="M20 18H9"></path><path d="M4 8l4 4l-4 4"></path></g></svg>
|
||||
</template>
|
||||
</Button>
|
||||
<Button icon @click="documentPrint">
|
||||
<template #icon>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 17h2a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h2"></path><path d="M17 9V5a2 2 0 0 0-2-2H9a2 2 0 0 0-2 2v4"></path><rect x="7" y="13" width="10" height="8" rx="2"></rect></g></svg>
|
||||
</template>
|
||||
</Button>
|
||||
<Button icon @click="createVariable">
|
||||
<template #icon>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 4C2.5 9 2.5 14 5 20M19 4c2.5 5 2.5 10 0 16M9 9h1c1 0 1 1 2.016 3.527C13 15 13 16 14 16h1"></path><path d="M8 16c1.5 0 3-2 4-3.5S14.5 9 16 9"></path></g></svg>
|
||||
</template>
|
||||
</Button>
|
||||
<Button icon @click="clearBackground">
|
||||
<template #icon>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
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="M16 6.5A4 2 0 0 0 12 5h-1a3.5 3.5 0 0 0 0 7h2a3.5 3.5 0 0 1 0 7h-1.5a4 2 0 0 1-4-1.5"></path>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<Editor id="editor"
|
||||
ref="editor"
|
||||
v-model="content"
|
||||
:zoom="zoom"
|
||||
:page_format_mm="page_format_mm"
|
||||
:page_margins="page_margins"
|
||||
:display="display"
|
||||
@update:current-style="getCurrentTextStyle"
|
||||
v-model:active-element="activeElement"
|
||||
v-model:active-elements="activeElements"
|
||||
/>
|
||||
</Card>
|
||||
<template #rightbar>
|
||||
<Card>
|
||||
<template #header>
|
||||
<span>
|
||||
{{ elementInfo.name ?? 'Нет активного элемента' }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="elementInfo.name">
|
||||
<ListStrate header="Параметры">
|
||||
<div class="flex flex-col gap-y-1">
|
||||
<Input v-if="elementInfo?.id" label="Идентификатор" v-model:value="elementInfo.id" disabled />
|
||||
<!-- <Input v-if="elementInfo?.type" label="Тип" v-model:value="elementInfo.type" disabled />-->
|
||||
<Input v-if="variables[elementInfo.id].label" label="Наименование" v-model:value="variables[elementInfo.id].label" />
|
||||
</div>
|
||||
</ListStrate>
|
||||
<ListStrate v-if="elementInfo.element === 'VAR'" header="Заполнение">
|
||||
<div class="flex flex-col gap-y-1">
|
||||
<Select label="Тип" v-model:value="variables[elementInfo.id].type" @update:value="value => updateVariableType(elementInfo.id, value)" :options="variablesItems" />
|
||||
<Input label="Значения" v-if="variables[elementInfo.id].type === 'select'" v-model:value="variables[elementInfo.id].value" @update:value="value => updateVariableValue(elementInfo.id, value)" />
|
||||
</div>
|
||||
</ListStrate>
|
||||
<Button v-if="elementInfo.element === 'VAR'" block>
|
||||
Создать раздел
|
||||
</Button>
|
||||
</template>
|
||||
<template #footer>
|
||||
<Button block @click="saveTemplate">
|
||||
Сохранить
|
||||
</Button>
|
||||
</template>
|
||||
</Card>
|
||||
</template>
|
||||
</Sections>
|
||||
</template>
|
||||
84
resources/js/Utils/heightCalculator.js
Normal file
84
resources/js/Utils/heightCalculator.js
Normal file
@@ -0,0 +1,84 @@
|
||||
import {nextTick} from "vue";
|
||||
|
||||
export function waitForAllComponentsMounted(vm, timeout = 5000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const startTime = Date.now()
|
||||
|
||||
function checkComponents() {
|
||||
// Проверяем, смонтирован ли текущий компонент
|
||||
if (!vm.isMounted) {
|
||||
if (Date.now() - startTime > timeout) {
|
||||
reject(new Error('Timeout waiting for component to mount'))
|
||||
return
|
||||
}
|
||||
nextTick(checkComponents)
|
||||
return
|
||||
}
|
||||
|
||||
// Рекурсивно проверяем дочерние компоненты
|
||||
const checkChildren = (component) => {
|
||||
const promises = []
|
||||
|
||||
// Проверяем прямых потомков (в Vue 3 используем subTree)
|
||||
if (component.subTree && component.subTree.children) {
|
||||
const processChildren = (children) => {
|
||||
children.forEach(child => {
|
||||
if (child.component) {
|
||||
const childInstance = child.component.proxy
|
||||
if (childInstance && !childInstance.isMounted) {
|
||||
promises.push(new Promise((resolveChild, rejectChild) => {
|
||||
const childStartTime = Date.now()
|
||||
|
||||
function checkChild() {
|
||||
if (childInstance.isMounted) {
|
||||
resolveChild()
|
||||
} else if (Date.now() - childStartTime > timeout) {
|
||||
rejectChild(new Error('Timeout waiting for child component to mount'))
|
||||
} else {
|
||||
nextTick(checkChild)
|
||||
}
|
||||
}
|
||||
|
||||
checkChild()
|
||||
}))
|
||||
}
|
||||
|
||||
// Рекурсивно проверяем потомков
|
||||
if (childInstance.subTree && childInstance.subTree.children) {
|
||||
promises.push(checkChildren(childInstance))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
processChildren(component.subTree.children)
|
||||
}
|
||||
|
||||
return Promise.all(promises)
|
||||
}
|
||||
|
||||
checkChildren(vm)
|
||||
.then(resolve)
|
||||
.catch(reject)
|
||||
}
|
||||
|
||||
checkComponents()
|
||||
})
|
||||
}
|
||||
export const generateElementHTML = (element, formData = {}) => {
|
||||
switch (element.type) {
|
||||
case 'heading':
|
||||
return `<h${element.level} data-element-id="${element.id}">${element.content}</h${element.level}>`
|
||||
case 'paragraph':
|
||||
return `<p data-element-id="${element.id}">${element.content}</p>`
|
||||
case 'variable':
|
||||
return `<span class="variable" data-element-id="${element.id}" data-variable="${element.variableName}">[${element.variableName}]</span>`
|
||||
case 'warning':
|
||||
return `<div class="warning" data-element-id="${element.id}">⚠️ ${element.content}</div>`
|
||||
case 'table':
|
||||
const tableHtml = element.content.replace('<table', `<table data-element-id="${element.id}"`)
|
||||
return tableHtml
|
||||
default:
|
||||
return element.content || ''
|
||||
}
|
||||
}
|
||||
78
resources/js/Utils/optionsFormatter.js
Normal file
78
resources/js/Utils/optionsFormatter.js
Normal file
@@ -0,0 +1,78 @@
|
||||
// utils/optionsFormatter.js
|
||||
|
||||
/**
|
||||
* Преобразует массив [key: value] в массив объектов {value: key, label: value}
|
||||
*/
|
||||
export const formatOptions = (optionsArray) => {
|
||||
if (!optionsArray || !Array.isArray(optionsArray)) return []
|
||||
|
||||
return optionsArray.map((item, index) => {
|
||||
// Если это объект с ключами key и value
|
||||
if (item && typeof item === 'object' && item.key !== undefined && item.value !== undefined) {
|
||||
return {
|
||||
value: item.value,
|
||||
label: item.key
|
||||
}
|
||||
}
|
||||
|
||||
// Если это массив [key, value]
|
||||
if (Array.isArray(item) && item.length >= 2) {
|
||||
return {
|
||||
value: item[0],
|
||||
label: item[1]
|
||||
}
|
||||
}
|
||||
|
||||
// Если это простой массив значений
|
||||
if (typeof item === 'string' || typeof item === 'number') {
|
||||
return {
|
||||
value: item,
|
||||
label: String(item)
|
||||
}
|
||||
}
|
||||
|
||||
// Если это объект с произвольными ключами
|
||||
if (item && typeof item === 'object') {
|
||||
const keys = Object.keys(item)
|
||||
if (keys.length > 0) {
|
||||
return {
|
||||
value: keys[0],
|
||||
label: item[keys[0]]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback для непредвиденных форматов
|
||||
return {
|
||||
value: index,
|
||||
label: String(item)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Преобразует объект {key: value, key: value} в массив {value: key, label: value}
|
||||
*/
|
||||
export const formatObjectOptions = (optionsObject) => {
|
||||
if (!optionsObject || typeof optionsObject !== 'object') return []
|
||||
|
||||
return Object.entries(optionsObject).map(([key, value]) => ({
|
||||
value: key,
|
||||
label: value
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Универсальная функция, которая определяет тип данных и форматирует соответствующим образом
|
||||
*/
|
||||
export const normalizeOptions = (input) => {
|
||||
if (Array.isArray(input)) {
|
||||
return formatOptions(input)
|
||||
}
|
||||
|
||||
if (typeof input === 'object' && input !== null) {
|
||||
return formatObjectOptions(input)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
225
resources/js/Utils/pageTransitionMgmt.js
Normal file
225
resources/js/Utils/pageTransitionMgmt.js
Normal file
@@ -0,0 +1,225 @@
|
||||
|
||||
/**
|
||||
* Utility function that acts like an Array.filter on childNodes of "container"
|
||||
* @param {HTMLElement} container
|
||||
* @param {string} s_tag
|
||||
*/
|
||||
function find_sub_child_sibling_node (container, s_tag){
|
||||
if(!container || !s_tag) return false;
|
||||
const child_nodes = container.childNodes;
|
||||
for(let i = 0; i < child_nodes.length; i++) {
|
||||
if(child_nodes[i].s_tag === s_tag) return child_nodes[i];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* This function moves every sub-child of argument "child" to the start of the "child_sibling"
|
||||
* argument, beginning from the last child, with word splitting and format preserving.
|
||||
* Typically, "child" is the current page which content overflows, and "child_sibling" is the
|
||||
* next page.
|
||||
* @param {HTMLElement} child Element to take children from (current page)
|
||||
* @param {HTMLElement} child_sibling Element to copy children to (next page)
|
||||
* @param {function} stop_condition Check function that returns a boolean if content doesn't overflow anymore
|
||||
* @param {function(HTMLElement):boolean?} do_not_break Optional function that receives the current child element and should return true if the child should not be split over two pages but rather be moved directly to the next page
|
||||
* @param {boolean?} not_first_child Should be unset. Used internally to let at least one child in the page
|
||||
*/
|
||||
function move_children_forward_recursively (child, child_sibling, stop_condition, do_not_break, not_first_child) {
|
||||
|
||||
// if the child still has nodes and the current page still overflows
|
||||
while(child.childNodes.length && !stop_condition()){
|
||||
|
||||
// check if page has only one child tree left
|
||||
not_first_child = not_first_child || (child.childNodes.length !== 1);
|
||||
|
||||
// select the last sub-child
|
||||
const sub_child = child.lastChild;
|
||||
|
||||
// if it is a text node, move its content to next page word(/space) by word
|
||||
if(sub_child.nodeType === Node.TEXT_NODE){
|
||||
const sub_child_hashes = sub_child.textContent.match(/(\s|\S+)/g);
|
||||
const sub_child_continuation = document.createTextNode('');
|
||||
child_sibling.prepend(sub_child_continuation);
|
||||
const l = sub_child_hashes ? sub_child_hashes.length : 0;
|
||||
for(let i = 0; i < l; i++) {
|
||||
if(i === l - 1 && !not_first_child) return; // never remove the first word of the page
|
||||
sub_child.textContent = sub_child_hashes.slice(0, l - i - 1).join('');
|
||||
sub_child_continuation.textContent = sub_child_hashes.slice(l - i - 1, l).join('');
|
||||
if(stop_condition()) return;
|
||||
}
|
||||
}
|
||||
|
||||
// we simply move it to the next page if it is either:
|
||||
// - a node with no content (e.g. <img>)
|
||||
// - a header title (e.g. <h1>)
|
||||
// - a table row (e.g. <tr>)
|
||||
// - any element on whose user-custom `do_not_break` function returns true
|
||||
else if(!sub_child.childNodes.length ||
|
||||
sub_child.tagName.match(/h\d/i) ||
|
||||
sub_child.tagName.match(/tr|table|thead|tbody|tfoot|th|td/i) ||
|
||||
(typeof do_not_break === "function" && do_not_break(sub_child))) {
|
||||
// just prevent moving the last child of the page
|
||||
if(!not_first_child){
|
||||
console.log("Move-forward: first child reached with no stop condition. Aborting");
|
||||
return;
|
||||
}
|
||||
child_sibling.prepend(sub_child);
|
||||
}
|
||||
|
||||
// for every other node that is not text and not the first child, clone it recursively to next page
|
||||
else {
|
||||
// check if sub child has already been cloned before
|
||||
let sub_child_sibling = find_sub_child_sibling_node(child_sibling, sub_child.s_tag);
|
||||
|
||||
// if not, create it and watermark the relationship with a random tag
|
||||
if(!sub_child_sibling) {
|
||||
if(!sub_child.s_tag) {
|
||||
const new_random_tag = Math.random().toString(36).slice(2, 8);
|
||||
sub_child.s_tag = new_random_tag;
|
||||
}
|
||||
sub_child_sibling = sub_child.cloneNode(false);
|
||||
sub_child_sibling.s_tag = sub_child.s_tag;
|
||||
child_sibling.prepend(sub_child_sibling);
|
||||
}
|
||||
|
||||
// then move/clone its children and sub-children recursively
|
||||
move_children_forward_recursively(sub_child, sub_child_sibling, stop_condition, do_not_break, not_first_child);
|
||||
sub_child_sibling.normalize(); // merge consecutive text nodes
|
||||
}
|
||||
|
||||
// if sub_child was a container that was cloned and is now empty, we clean it
|
||||
if(child.contains(sub_child)){
|
||||
if(sub_child.childNodes.length === 0 || sub_child.innerHTML === "") child.removeChild(sub_child);
|
||||
else if(!stop_condition()) {
|
||||
// the only case when it can be non empty should be when stop_condition is now true
|
||||
console.log("sub_child:", sub_child, "that is in child:", child);
|
||||
throw Error("Document editor is trying to remove a non-empty sub-child. This "
|
||||
+ "is a bug and should not happen.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* This function moves the first element from "next_page_html_div" to the end of "page_html_div", with
|
||||
* merging sibling tags previously watermarked by "move_children_forward_recursively", if any.
|
||||
* @param {HTMLElement} page_html_div Current page element
|
||||
* @param {HTMLElement} next_page_html_div Next page element
|
||||
* @param {function} stop_condition Check function that returns a boolean if content overflows
|
||||
*/
|
||||
function move_children_backwards_with_merging (page_html_div, next_page_html_div, stop_condition) {
|
||||
|
||||
// loop until content is overflowing
|
||||
// while(!stop_condition()){
|
||||
//
|
||||
// // find first child of next page
|
||||
// const first_child = next_page_html_div.firstChild;
|
||||
//
|
||||
// // merge it at the end of the current page
|
||||
// // let merge_recursively = (container, elt) => {
|
||||
// // // check if child had been splitted (= has a sibling on previous page)
|
||||
// // const elt_sibling = find_sub_child_sibling_node(container, elt.s_tag);
|
||||
// // if(elt_sibling && elt.childNodes.length) {
|
||||
// // // then dig for deeper children, in case of
|
||||
// // merge_recursively(elt_sibling, elt.firstChild);
|
||||
// // }
|
||||
// // // else move the child inside the right container at current page
|
||||
// // else {
|
||||
// // container.append(elt);
|
||||
// // container.normalize();
|
||||
// // }
|
||||
// // }
|
||||
// let merge_recursively = (container, elt) => {
|
||||
// // check if child had been splitted (= has a sibling on previous page)
|
||||
// const elt_sibling = find_sub_child_sibling_node(container, elt.s_tag);
|
||||
//
|
||||
// if(elt_sibling && elt.childNodes.length) {
|
||||
// // then dig for deeper children, in case of
|
||||
// merge_recursively(elt_sibling, elt.firstChild);
|
||||
// }
|
||||
// else {
|
||||
// // Для текстовых узлов - объединяем с последним текстовым узлом, если возможно
|
||||
// if (elt.nodeType === Node.TEXT_NODE &&
|
||||
// container.lastChild &&
|
||||
// container.lastChild.nodeType === Node.TEXT_NODE) {
|
||||
//
|
||||
// // Объединяем текстовые узлы
|
||||
// container.lastChild.textContent += elt.textContent;
|
||||
// elt.remove(); // удаляем оригинальный узел
|
||||
// }
|
||||
// else if (elt.nodeType === Node.TEXT_NODE &&
|
||||
// container.lastChild &&
|
||||
// container.lastChild.nodeType === Node.ELEMENT_NODE &&
|
||||
// container.lastChild.lastChild &&
|
||||
// container.lastChild.lastChild.nodeType === Node.TEXT_NODE) {
|
||||
//
|
||||
// // Если последний дочерний элемент заканчивается текстовым узлом
|
||||
// container.lastChild.lastChild.textContent += elt.textContent;
|
||||
// elt.remove();
|
||||
// }
|
||||
// else {
|
||||
// // Для остальных случаев - просто перемещаем
|
||||
// container.append(elt);
|
||||
// }
|
||||
// container.normalize(); // merge consecutive text nodes
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// merge_recursively(page_html_div, first_child);
|
||||
//
|
||||
// // Если после перемещения следующая страница пустая, выходим
|
||||
// if (!next_page_html_div.childNodes.length) break;
|
||||
// }
|
||||
while(!stop_condition() && next_page_html_div.firstChild){
|
||||
const first_child = next_page_html_div.firstChild;
|
||||
|
||||
// Для табличных элементов - особая логика
|
||||
if (first_child.tagName && first_child.tagName.match(/table|thead|tbody|tfoot|tr|th|td/i)) {
|
||||
// Таблицы и их части перемещаем целиком
|
||||
page_html_div.append(first_child);
|
||||
page_html_div.normalize();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Для текстовых узлов - пробуем объединить с последним текстовым узлом
|
||||
if (first_child.nodeType === Node.TEXT_NODE) {
|
||||
const last_child = page_html_div.lastChild;
|
||||
if (last_child && last_child.nodeType === Node.TEXT_NODE) {
|
||||
last_child.textContent += first_child.textContent;
|
||||
first_child.remove();
|
||||
} else {
|
||||
page_html_div.append(first_child);
|
||||
}
|
||||
}
|
||||
// Для элементов с s_tag - ищем брата для объединения
|
||||
else if (first_child.s_tag && first_child.childNodes.length) {
|
||||
const elt_sibling = find_sub_child_sibling_node(page_html_div, first_child.s_tag);
|
||||
if (elt_sibling) {
|
||||
// Перемещаем всех детей к брату
|
||||
while(first_child.firstChild && !stop_condition()) {
|
||||
elt_sibling.append(first_child.firstChild);
|
||||
}
|
||||
elt_sibling.normalize();
|
||||
} else {
|
||||
page_html_div.append(first_child);
|
||||
}
|
||||
}
|
||||
// Для остальных случаев - просто перемещаем
|
||||
else {
|
||||
page_html_div.append(first_child);
|
||||
}
|
||||
|
||||
page_html_div.normalize();
|
||||
|
||||
// Быстрая проверка условия остановки
|
||||
if (!next_page_html_div.childNodes.length) break;
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
move_children_forward_recursively,
|
||||
move_children_backwards_with_merging
|
||||
};
|
||||
18
resources/js/app.js
Normal file
18
resources/js/app.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import './bootstrap';
|
||||
import '../css/app.css'
|
||||
import 'vue-pdf-embed/dist/styles/textLayer.css'
|
||||
|
||||
import { createApp, h } from 'vue'
|
||||
import { createInertiaApp } from '@inertiajs/vue3'
|
||||
|
||||
createInertiaApp({
|
||||
resolve: name => {
|
||||
const pages = import.meta.glob('./Pages/**/*.vue', { eager: true })
|
||||
return pages[`./Pages/${name}.vue`]
|
||||
},
|
||||
setup({ el, App, props, plugin }) {
|
||||
createApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.mount(el)
|
||||
},
|
||||
})
|
||||
4
resources/js/bootstrap.js
vendored
Normal file
4
resources/js/bootstrap.js
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
import axios from 'axios';
|
||||
window.axios = axios;
|
||||
|
||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
15
resources/views/app.blade.php
Normal file
15
resources/views/app.blade.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport"
|
||||
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<title>Конструктор документов</title>
|
||||
@vite('resources/js/app.js')
|
||||
@inertiaHead
|
||||
</head>
|
||||
<body>
|
||||
@inertia
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user