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>
|
||||
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>
|
||||
Reference in New Issue
Block a user