first commit

This commit is contained in:
brusnitsyn
2026-01-04 23:15:06 +09:00
commit 0ec04cfb4b
104 changed files with 19072 additions and 0 deletions

View File

@@ -0,0 +1,602 @@
<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>