603 lines
22 KiB
Vue
603 lines
22 KiB
Vue
<script setup>
|
||
import { ref, computed, reactive, onMounted, watch } from 'vue'
|
||
import { Head, router, useForm } from '@inertiajs/vue3'
|
||
import {
|
||
NForm, NFormItem, NInput, NInputNumber, NSelect,
|
||
NSwitch, NButton, NSpace, NCard, NH1, NH3,
|
||
NText, NTag, NProgress, NDivider, NGrid, NGi,
|
||
NAlert, NStatistic, NIcon, NTooltip, NRadioGroup,
|
||
NRadio, NCheckbox, NDatePicker, NTimePicker
|
||
} from 'naive-ui'
|
||
import {useNotification} from "../../../Composables/useNotification.js";
|
||
|
||
const props = defineProps({
|
||
groupId: {
|
||
type: [String, Number],
|
||
required: true
|
||
},
|
||
initialData: {
|
||
type: Object,
|
||
default: () => ({})
|
||
}
|
||
})
|
||
|
||
const { errorApi } = useNotification()
|
||
|
||
// Состояние формы
|
||
const formRef = ref(null)
|
||
const form = ref({
|
||
group_id: props.groupId,
|
||
comment: '',
|
||
// Динамические поля будут добавлены позже
|
||
})
|
||
const formProcessing = ref(false)
|
||
const formSections = ref([])
|
||
const formRules = ref({})
|
||
const formTitle = ref('')
|
||
const formDescription = ref('')
|
||
const isSubmitting = ref(false)
|
||
const isSavingDraft = ref(false)
|
||
const lastSavedTime = ref(null)
|
||
|
||
// Используем useForm из Inertia
|
||
// const { data: form, defaults, post, processing, errors: formErrors, reset } = useForm()
|
||
|
||
|
||
// Загрузка формы
|
||
onMounted(async () => {
|
||
await loadFormData()
|
||
await loadExistingData()
|
||
})
|
||
|
||
// Загрузка структуры формы
|
||
const loadFormData = async () => {
|
||
try {
|
||
const response = await axios.get(`/api/metric-forms/${props.groupId}`)
|
||
const formData = response.data
|
||
|
||
console.log(formData)
|
||
|
||
formTitle.value = formData.group.name
|
||
formDescription.value = formData.group.description
|
||
formSections.value = formData.form.sections
|
||
|
||
// Инициализируем значения формы
|
||
const initialValues = { group_id: props.groupId, comment: '' }
|
||
for (let fieldName in formData.form.default) {
|
||
initialValues[fieldName] = formData.form.default[fieldName]
|
||
}
|
||
|
||
form.value = {
|
||
...form.value,
|
||
...initialValues
|
||
}
|
||
|
||
// Настраиваем правила валидации
|
||
formRules.value = formData.form.validation
|
||
|
||
} catch (error) {
|
||
console.error('Ошибка загрузки формы:', error)
|
||
// errorApi()
|
||
}
|
||
}
|
||
|
||
// Загрузка существующих данных
|
||
const loadExistingData = async () => {
|
||
try {
|
||
const response = await axios.get(`/api/metric-forms/${props.groupId}/existing`)
|
||
if (response.data.existing_data) {
|
||
const existing = response.data.existing_data
|
||
|
||
// Заполняем форму существующими данными
|
||
Object.keys(existing.data).forEach(key => {
|
||
form[key] = existing.data[key]
|
||
})
|
||
|
||
form.comment = existing.comment || ''
|
||
lastSavedTime.value = new Date(existing.submitted_at).toLocaleTimeString('ru-RU')
|
||
|
||
}
|
||
} catch (error) {
|
||
console.error('Ошибка загрузки существующих данных:', error)
|
||
}
|
||
}
|
||
|
||
// Вычисляемые свойства
|
||
const currentDate = computed(() => {
|
||
return new Date().toLocaleDateString('ru-RU', {
|
||
weekday: 'long',
|
||
year: 'numeric',
|
||
month: 'long',
|
||
day: 'numeric'
|
||
})
|
||
})
|
||
|
||
const totalFieldsCount = computed(() => {
|
||
return formSections.value.reduce((total, section) => total + section.fields.length, 0)
|
||
})
|
||
|
||
const filledFieldsCount = computed(() => {
|
||
return Object.keys(form.value).filter(key =>
|
||
key.startsWith('metrika_item_') &&
|
||
form.value[key] !== null &&
|
||
form.value[key] !== '' &&
|
||
form.value[key] !== undefined
|
||
).length
|
||
})
|
||
|
||
const progressPercentage = computed(() => {
|
||
if (totalFieldsCount.value === 0) return 0
|
||
return Math.round((filledFieldsCount.value / totalFieldsCount.value) * 100)
|
||
})
|
||
|
||
const requiredFieldsCount = computed(() => {
|
||
return formSections.value.reduce((total, section) => {
|
||
return total + section.fields.filter(f => f.is_required).length
|
||
}, 0)
|
||
})
|
||
|
||
const filledRequiredFieldsCount = computed(() => {
|
||
let count = 0
|
||
formSections.value.forEach(section => {
|
||
section.fields.forEach(field => {
|
||
if (field.is_required) {
|
||
const fieldName = `metrika_item_${field.metrika_item_id}`
|
||
if (form.value[fieldName] !== null && form.value[fieldName] !== '' && form.value[fieldName] !== undefined) {
|
||
count++
|
||
}
|
||
}
|
||
})
|
||
})
|
||
return count
|
||
})
|
||
|
||
const requiredFieldsStatus = computed(() => {
|
||
const filled = filledRequiredFieldsCount.value
|
||
const total = requiredFieldsCount.value
|
||
|
||
if (filled === 0) return { type: 'error', text: '0%' }
|
||
if (filled === total) return { type: 'success', text: '100%' }
|
||
|
||
const percent = Math.round((filled / total) * 100)
|
||
return {
|
||
type: percent > 50 ? 'warning' : 'error',
|
||
text: `${percent}%`
|
||
}
|
||
})
|
||
|
||
const formStatus = computed(() => {
|
||
if (filledFieldsCount.value === 0) return 'empty'
|
||
if (filledRequiredFieldsCount.value < requiredFieldsCount.value) return 'incomplete'
|
||
// if (Object.keys(formErrors).length > 0) return 'invalid'
|
||
return 'ready'
|
||
})
|
||
|
||
const formStatusText = computed(() => {
|
||
const texts = {
|
||
'empty': 'Не заполнено',
|
||
'incomplete': 'Не все обязательные поля',
|
||
'invalid': 'Есть ошибки',
|
||
'ready': 'Готово к отправке'
|
||
}
|
||
return texts[formStatus.value] || 'Неизвестно'
|
||
})
|
||
|
||
const formStatusType = computed(() => {
|
||
const types = {
|
||
'empty': 'default',
|
||
'incomplete': 'warning',
|
||
'invalid': 'error',
|
||
'ready': 'success'
|
||
}
|
||
return types[formStatus.value] || 'default'
|
||
})
|
||
|
||
const canSubmit = computed(() => {
|
||
return formStatus.value === 'ready' && !formProcessing.value
|
||
})
|
||
|
||
const hasData = computed(() => {
|
||
return filledFieldsCount.value > 0
|
||
})
|
||
|
||
const showStatistics = computed(() => {
|
||
return totalFieldsCount.value > 0
|
||
})
|
||
|
||
// Методы
|
||
const getFieldComponent = (dataType) => {
|
||
const components = {
|
||
'integer': NInputNumber,
|
||
'float': NInputNumber,
|
||
'string': NInput,
|
||
'text': NInput,
|
||
'textarea': NInput,
|
||
'boolean': NSwitch,
|
||
'select': NSelect,
|
||
'radio': NRadioGroup,
|
||
'checkbox': NCheckbox,
|
||
'date': NDatePicker,
|
||
'time': NTimePicker
|
||
}
|
||
return components[dataType] || NInput
|
||
}
|
||
|
||
const getFieldOptions = (field) => {
|
||
if (field.options && Array.isArray(field.options)) {
|
||
return field.options.map(opt => ({
|
||
label: opt.label || opt,
|
||
value: opt.value || opt
|
||
}))
|
||
}
|
||
return []
|
||
}
|
||
|
||
const getFieldName = (fieldKey) => {
|
||
const itemId = fieldKey.replace('metrika_item_', '')
|
||
for (const section of formSections.value) {
|
||
for (const field of section.fields) {
|
||
if (field.metrika_item_id.toString() === itemId) {
|
||
return field.metrika_item_name
|
||
}
|
||
}
|
||
}
|
||
return fieldKey
|
||
}
|
||
|
||
const getStatusTagType = (status) => {
|
||
const types = {
|
||
'empty': 'default',
|
||
'incomplete': 'warning',
|
||
'invalid': 'error',
|
||
'ready': 'success'
|
||
}
|
||
return types[status] || 'default'
|
||
}
|
||
|
||
const submitForm = async () => {
|
||
if (!formRef.value) return
|
||
|
||
// Валидация формы
|
||
await formRef.value.validate()
|
||
|
||
// if (Object.keys(formErrors).length > 0) {
|
||
// // notification.warning({
|
||
// // title: 'Ошибки в форме',
|
||
// // content: 'Исправьте ошибки перед отправкой',
|
||
// // duration: 4000
|
||
// // })
|
||
// return
|
||
// }
|
||
|
||
isSubmitting.value = true
|
||
|
||
try {
|
||
// const response = await post(route('metric-forms.save', { group: props.groupId }), {
|
||
// onSuccess: () => {
|
||
// notification.success({
|
||
// title: 'Успешно!',
|
||
// content: 'Отчет сохранен',
|
||
// duration: 5000
|
||
// })
|
||
// lastSavedTime.value = new Date().toLocaleTimeString('ru-RU')
|
||
// },
|
||
// onError: (errors) => {
|
||
// notification.error({
|
||
// title: 'Ошибка сохранения',
|
||
// content: 'Проверьте правильность заполнения данных',
|
||
// duration: 5000
|
||
// })
|
||
// }
|
||
// })
|
||
const response = await axios.post(`/api/metric-forms/${props.groupId}/save`, formRef.value)
|
||
.then(res => {
|
||
// notification.success({
|
||
// title: 'Успешно!',
|
||
// content: 'Отчет сохранен',
|
||
// duration: 5000
|
||
// })
|
||
router.visit('/')
|
||
lastSavedTime.value = new Date().toLocaleTimeString('ru-RU')
|
||
})
|
||
.catch(e => {
|
||
// notification.error({
|
||
// title: 'Ошибка сохранения',
|
||
// content: 'Проверьте правильность заполнения данных',
|
||
// duration: 5000
|
||
// })
|
||
})
|
||
|
||
} catch (error) {
|
||
console.error('Ошибка сохранения:', error)
|
||
// notification.error({
|
||
// title: 'Ошибка',
|
||
// content: error.message || 'Произошла ошибка при сохранении',
|
||
// duration: 5000
|
||
// })
|
||
} finally {
|
||
isSubmitting.value = false
|
||
}
|
||
}
|
||
|
||
const saveDraft = async () => {
|
||
isSavingDraft.value = true
|
||
|
||
try {
|
||
// Сохраняем в localStorage
|
||
const draftData = {
|
||
form: form.value,
|
||
timestamp: new Date().toISOString(),
|
||
groupId: props.groupId
|
||
}
|
||
|
||
localStorage.setItem(`metric_form_draft_${props.groupId}`, JSON.stringify(draftData))
|
||
|
||
lastSavedTime.value = new Date().toLocaleTimeString('ru-RU')
|
||
// message.success('Черновик сохранен')
|
||
|
||
} catch (error) {
|
||
console.error('Ошибка сохранения черновика:', error)
|
||
// message.error('Не удалось сохранить черновик')
|
||
} finally {
|
||
isSavingDraft.value = false
|
||
}
|
||
}
|
||
|
||
const resetForm = () => {
|
||
if (confirm('Вы уверены, что хотите сбросить форму? Все несохраненные данные будут потеряны.')) {
|
||
reset()
|
||
loadFormData()
|
||
// message.info('Форма сброшена')
|
||
}
|
||
}
|
||
|
||
const exportFormData = () => {
|
||
const exportData = {
|
||
group: formTitle.value,
|
||
date: currentDate.value,
|
||
data: {},
|
||
comment: form.comment
|
||
}
|
||
|
||
// Собираем данные полей
|
||
formSections.value.forEach(section => {
|
||
section.fields.forEach(field => {
|
||
const fieldName = `metrika_item_${field.metrika_item_id}`
|
||
exportData.data[field.metrika_item_name] = form.value[fieldName]
|
||
})
|
||
})
|
||
|
||
// Создаем и скачиваем файл
|
||
const dataStr = JSON.stringify(exportData, null, 2)
|
||
const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr)
|
||
const link = document.createElement('a')
|
||
link.setAttribute('href', dataUri)
|
||
link.setAttribute('download', `metric_report_${formTitle.value}_${new Date().toISOString().split('T')[0]}.json`)
|
||
document.body.appendChild(link)
|
||
link.click()
|
||
document.body.removeChild(link)
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div>
|
||
<Head :title="`Ввод данных: ${formTitle}`" />
|
||
|
||
<!-- Заголовок формы -->
|
||
<div class="max-w-6xl mx-auto mt-4 mb-4">
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<n-h1 class="!mb-2">{{ formTitle }}</n-h1>
|
||
<n-text depth="3" v-if="formDescription">
|
||
{{ formDescription }}
|
||
</n-text>
|
||
<div class="flex items-center gap-2 mt-2">
|
||
<n-tag :type="getStatusTagType(formStatus)" size="small" round>
|
||
{{ formStatusText }}
|
||
</n-tag>
|
||
<n-text depth="3" class="text-sm">
|
||
{{ currentDate }}
|
||
</n-text>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- <n-space>-->
|
||
<!-- <n-button @click="$inertia.visit(route('dashboard'))" secondary>-->
|
||
<!-- Назад-->
|
||
<!-- </n-button>-->
|
||
<!-- <n-button @click="exportFormData" :disabled="!hasData">-->
|
||
<!-- Экспорт-->
|
||
<!-- </n-button>-->
|
||
<!-- </n-space>-->
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Прогресс заполнения -->
|
||
<div class="max-w-6xl mx-auto mb-2">
|
||
<n-card size="small">
|
||
<n-space justify="space-between" align="center">
|
||
<n-text>Прогресс заполнения</n-text>
|
||
<n-text strong>{{ progressPercentage }}%</n-text>
|
||
</n-space>
|
||
<n-progress
|
||
:percentage="progressPercentage"
|
||
:height="6"
|
||
:border-radius="0"
|
||
:show-indicator="false"
|
||
class="mt-2"
|
||
/>
|
||
</n-card>
|
||
</div>
|
||
|
||
<!-- Основная форма -->
|
||
<div class="max-w-6xl mx-auto">
|
||
<n-card>
|
||
<n-form
|
||
ref="formRef"
|
||
:model="form"
|
||
:rules="formRules"
|
||
@submit.prevent="submitForm"
|
||
>
|
||
<!-- Секции формы -->
|
||
<div v-for="(section, sectionIndex) in formSections" :key="sectionIndex">
|
||
<n-divider v-if="sectionIndex > 0" />
|
||
|
||
<div class="mb-6">
|
||
<n-h3>{{ section.name }}</n-h3>
|
||
<n-text depth="3" class="text-sm">
|
||
Заполните данные по секции
|
||
</n-text>
|
||
</div>
|
||
|
||
<n-grid :cols="section.fields.length > 5 ? 2 : 1" :x-gap="24" :y-gap="16">
|
||
<n-gi
|
||
v-for="field in section.fields"
|
||
:key="field.metrika_item_id"
|
||
:span="field.span || 1"
|
||
>
|
||
<n-form-item
|
||
:label="field.metrika_item_name"
|
||
:path="`metrika_item_${field.metrika_item_id}`"
|
||
:required="field.is_required"
|
||
>
|
||
<!-- <template #label>-->
|
||
<!-- <div class="flex items-center gap-2">-->
|
||
<!-- <span>{{ field.metrika_item_name }}</span>-->
|
||
<!-- <n-tooltip v-if="field.metrika_item_description" trigger="hover">-->
|
||
<!-- <template #trigger>-->
|
||
<!-- <n-icon size="14" class="cursor-help">-->
|
||
<!-- <InfoCircle />-->
|
||
<!-- </n-icon>-->
|
||
<!-- </template>-->
|
||
<!-- {{ field.metrika_item_description }}-->
|
||
<!-- </n-tooltip>-->
|
||
<!-- </div>-->
|
||
<!-- </template>-->
|
||
|
||
<!-- Динамические поля ввода -->
|
||
<component
|
||
:is="getFieldComponent(field.metrika_item_data_type)"
|
||
v-model:value="form[`metrika_item_${field.metrika_item_id}`]"
|
||
:placeholder="field.placeholder || `Введите ${field.metrika_item_name.toLowerCase()}`"
|
||
:options="getFieldOptions(field)"
|
||
:min="field.validation_rules?.min"
|
||
:max="field.validation_rules?.max"
|
||
:step="field.metrika_item_data_type === 'float' ? 0.1 : 1"
|
||
:rows="3"
|
||
clearable
|
||
class="w-full"
|
||
/>
|
||
|
||
<!-- <template #feedback>-->
|
||
<!-- <div v-if="formErrors[`metric_${field.metrika_item_id}`]" class="text-red-500 text-xs mt-1">-->
|
||
<!-- {{ formErrors[`metric_${field.metrika_item_id}`][0] }}-->
|
||
<!-- </div>-->
|
||
<!-- </template>-->
|
||
</n-form-item>
|
||
</n-gi>
|
||
</n-grid>
|
||
</div>
|
||
|
||
<!-- Комментарий -->
|
||
<n-form-item label="Комментарий" path="comment">
|
||
<n-input
|
||
v-model:value="form.comment"
|
||
type="textarea"
|
||
placeholder="Дополнительные заметки и комментарии..."
|
||
:autosize="{ minRows: 3, maxRows: 6 }"
|
||
/>
|
||
</n-form-item>
|
||
|
||
<!-- Валидационные ошибки -->
|
||
<!-- <n-alert-->
|
||
<!-- v-if="Object.keys(formErrors).length > 0"-->
|
||
<!-- title="Ошибки заполнения"-->
|
||
<!-- type="error"-->
|
||
<!-- class="mb-4"-->
|
||
<!-- >-->
|
||
<!-- <ul class="list-disc pl-5">-->
|
||
<!-- <li v-for="(errors, field) in formErrors" :key="field">-->
|
||
<!-- {{ getFieldName(field) }}: {{ errors[0] }}-->
|
||
<!-- </li>-->
|
||
<!-- </ul>-->
|
||
<!-- </n-alert>-->
|
||
|
||
<!-- Статистика -->
|
||
<!-- <n-card v-if="showStatistics" title="Статистика" size="small" class="mb-4">-->
|
||
<!-- <n-grid :cols="4" responsive="screen">-->
|
||
<!-- <n-gi>-->
|
||
<!-- <n-statistic label="Заполнено полей">-->
|
||
<!-- {{ filledFieldsCount }} / {{ totalFieldsCount }}-->
|
||
<!-- </n-statistic>-->
|
||
<!-- </n-gi>-->
|
||
<!-- <n-gi>-->
|
||
<!-- <n-statistic label="Обязательные поля">-->
|
||
<!-- <n-tag :type="requiredFieldsStatus.type" size="small">-->
|
||
<!-- {{ requiredFieldsStatus.text }}-->
|
||
<!-- </n-tag>-->
|
||
<!-- </n-statistic>-->
|
||
<!-- </n-gi>-->
|
||
<!-- <n-gi>-->
|
||
<!-- <n-statistic label="Последнее сохранение">-->
|
||
<!-- {{ lastSavedTime || 'Не сохранено' }}-->
|
||
<!-- </n-statistic>-->
|
||
<!-- </n-gi>-->
|
||
<!-- <n-gi>-->
|
||
<!-- <n-statistic label="Статус">-->
|
||
<!-- <n-tag :type="formStatusType" size="small">-->
|
||
<!-- {{ formStatusText }}-->
|
||
<!-- </n-tag>-->
|
||
<!-- </n-statistic>-->
|
||
<!-- </n-gi>-->
|
||
<!-- </n-grid>-->
|
||
<!-- </n-card>-->
|
||
|
||
<!-- Кнопки действий -->
|
||
<n-space justify="end">
|
||
<!-- <n-button @click="resetForm" :disabled="isSubmitting">-->
|
||
<!-- Сбросить-->
|
||
<!-- </n-button>-->
|
||
<!-- <n-button-->
|
||
<!-- @click="saveDraft"-->
|
||
<!-- :loading="isSavingDraft"-->
|
||
<!-- secondary-->
|
||
<!-- >-->
|
||
<!-- Сохранить черновик-->
|
||
<!-- </n-button>-->
|
||
<n-button
|
||
type="primary"
|
||
@click="submitForm"
|
||
:loading="isSubmitting"
|
||
:disabled="!canSubmit"
|
||
>
|
||
Сохранить отчет
|
||
</n-button>
|
||
</n-space>
|
||
</n-form>
|
||
</n-card>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.n-form-item :deep(.n-form-item-label__text) {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
.n-divider {
|
||
margin: 32px 0;
|
||
}
|
||
|
||
.n-statistic :deep(.n-statistic-label) {
|
||
font-size: 0.875rem;
|
||
color: #6b7280;
|
||
}
|
||
|
||
.n-statistic :deep(.n-statistic-value) {
|
||
font-size: 1.125rem;
|
||
font-weight: 600;
|
||
}
|
||
</style>
|