first commit
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user