переименования папок

This commit is contained in:
2025-07-21 15:02:10 +03:00
parent d0c79bd334
commit 84f0c9c6aa
72 changed files with 245 additions and 249 deletions

View File

@@ -0,0 +1,212 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ElementDefinition } from '@/lib/element-registry'
import { ElementOption } from '@/type/template'
import { Plus, Square, Trash2 } from 'lucide-react'
interface ButtonGroupConfig {
placeholder?: string
required?: boolean
options: ElementOption[]
targetCells: any[]
layout?: 'horizontal' | 'vertical'
buttonStyle?: 'default' | 'outline' | 'ghost'
}
export const buttonGroupDefinition: ElementDefinition<
ButtonGroupConfig,
string
> = {
type: 'button-group',
label: 'Группа кнопок',
icon: <Square className="h-4 w-4" />,
defaultConfig: {
placeholder: '',
required: false,
options: [],
targetCells: [],
layout: 'horizontal',
buttonStyle: 'default',
},
Editor: ({ config, onChange }) => {
const handleAddOption = () => {
onChange({
...config,
options: [...config.options, { value: '', label: '' }],
})
}
const handleUpdateOption = (
index: number,
field: 'value' | 'label',
value: string
) => {
onChange({
...config,
options: config.options.map((option, i) =>
i === index ? { ...option, [field]: value } : option
),
})
}
const handleRemoveOption = (index: number) => {
onChange({
...config,
options: config.options.filter((_, i) => i !== index),
})
}
return (
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Расположение кнопок</label>
<div className="flex gap-2">
<Button
variant={config.layout === 'horizontal' ? 'default' : 'outline'}
size="sm"
onClick={() => onChange({ ...config, layout: 'horizontal' })}
>
Горизонтально
</Button>
<Button
variant={config.layout === 'vertical' ? 'default' : 'outline'}
size="sm"
onClick={() => onChange({ ...config, layout: 'vertical' })}
>
Вертикально
</Button>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Стиль кнопок</label>
<div className="flex gap-2">
<Button
variant={config.buttonStyle === 'default' ? 'default' : 'outline'}
size="sm"
onClick={() => onChange({ ...config, buttonStyle: 'default' })}
>
Обычный
</Button>
<Button
variant={config.buttonStyle === 'outline' ? 'default' : 'outline'}
size="sm"
onClick={() => onChange({ ...config, buttonStyle: 'outline' })}
>
Контурный
</Button>
<Button
variant={config.buttonStyle === 'ghost' ? 'default' : 'outline'}
size="sm"
onClick={() => onChange({ ...config, buttonStyle: 'ghost' })}
>
Призрачный
</Button>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Варианты кнопок</label>
<Button variant="outline" size="sm" onClick={handleAddOption}>
<Plus className="mr-1 h-4 w-4" />
Добавить кнопку
</Button>
</div>
<div className="max-h-32 space-y-2 overflow-y-auto">
{config.options.map((option, index) => (
<div key={index} className="flex gap-2">
<Input
placeholder="Значение"
value={option.value}
onChange={e =>
handleUpdateOption(index, 'value', e.target.value)
}
/>
<Input
placeholder="Текст кнопки"
value={option.label}
onChange={e =>
handleUpdateOption(index, 'label', e.target.value)
}
/>
<Button
variant="outline"
size="icon"
onClick={() => handleRemoveOption(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
</div>
)
},
Preview: ({ config }) => (
<div className="space-y-3">
<div className="text-sm font-medium text-gray-700">Предпросмотр</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<div
className={`flex ${
config.layout === 'vertical' ? 'flex-col' : 'flex-wrap'
} gap-1.5`}
>
{config.options.length > 0 ? (
config.options.map((option, index) => (
<button
key={index}
className={`rounded-md border px-3 py-1.5 text-sm font-medium transition-all duration-200 ${
config.buttonStyle === 'outline'
? 'border-border bg-background text-foreground hover:border-primary/30 hover:bg-primary/5'
: config.buttonStyle === 'ghost'
? 'border-transparent bg-transparent text-foreground hover:bg-accent hover:text-accent-foreground'
: 'border-primary bg-primary text-primary-foreground shadow-sm'
}`}
>
{option.label || `Кнопка ${index + 1}`}
</button>
))
) : (
<div className="rounded-md border border-dashed bg-muted/30 px-3 py-1.5 text-sm text-muted-foreground">
{config.placeholder || 'Добавьте кнопки'}
</div>
)}
</div>
</div>
</div>
),
Render: ({ config, value, onChange }) => (
<div className="space-y-2">
<div
className={`flex ${
config.layout === 'vertical' ? 'flex-col' : 'flex-wrap'
} gap-1.5`}
>
{config.options.map((option, index) => (
<button
key={index}
onClick={() => onChange?.(option.value)}
className={`rounded-md border px-3 py-1.5 text-sm font-medium transition-all duration-200 ${
value === option.value
? 'border-primary bg-primary text-primary-foreground shadow-sm'
: config.buttonStyle === 'outline'
? 'border-border bg-background text-foreground hover:border-primary/30 hover:bg-primary/5'
: config.buttonStyle === 'ghost'
? 'border-transparent bg-transparent text-foreground hover:bg-accent hover:text-accent-foreground'
: 'border-border bg-background text-foreground hover:border-primary/30 hover:bg-primary/5'
}`}
>
{option.label}
</button>
))}
</div>
{config.required && !value && (
<p className="text-sm text-red-500">
Это поле обязательно для заполнения
</p>
)}
</div>
),
}

View File

@@ -0,0 +1,642 @@
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ElementDefinition } from '@/lib/element-registry'
import {
AlertCircle,
Check,
Droplets,
Edit3,
Gauge,
Radio,
Thermometer,
X,
Zap,
} from 'lucide-react'
import React, { useEffect, useState } from 'react'
interface CalibrationConditions {
temperature: string
humidity: string
pressure: string
voltage: string
frequency: string
lastUpdated: string
updatedBy?: string
}
interface ValidationResult {
isValid: boolean
message?: string
}
interface CalibrationCondInfoProps {
conditions: CalibrationConditions
isOutdated: boolean
onUpdate: (conditions: CalibrationConditions) => Promise<boolean>
}
// Валидационные правила
const validateField = (field: string, value: string): ValidationResult => {
const numValue = parseFloat(value)
if (!value.trim()) {
return { isValid: false, message: 'Поле обязательно' }
}
if (isNaN(numValue)) {
return { isValid: false, message: 'Введите число' }
}
switch (field) {
case 'temperature':
if (numValue < -40 || numValue > 85) {
return { isValid: false, message: 'Диапазон: -40...+85°C' }
}
break
case 'humidity':
if (numValue < 0 || numValue > 100) {
return { isValid: false, message: 'Диапазон: 0...100%' }
}
break
case 'pressure':
if (numValue < 80 || numValue > 120) {
return { isValid: false, message: 'Диапазон: 80...120 кПа' }
}
break
case 'voltage':
if (numValue < 180 || numValue > 250) {
return { isValid: false, message: 'Диапазон: 180...250 В' }
}
break
case 'frequency':
if (numValue < 45 || numValue > 65) {
return { isValid: false, message: 'Диапазон: 45...65 Гц' }
}
break
}
return { isValid: true }
}
const formatLastUpdated = (dateString: string) => {
const date = new Date(dateString)
const today = new Date()
const yesterday = new Date(today)
yesterday.setDate(yesterday.getDate() - 1)
const weekdayRu = date.toLocaleString('ru-RU', { weekday: 'long' })
const monthShortRu = date
.toLocaleString('ru-RU', { month: 'short' })
.replace(/\./, '')
.replace(/^./, (s) => s.toUpperCase())
const day = date.getDate()
const time: string = date.toLocaleString('ru-RU', {
hour: '2-digit',
minute: '2-digit',
})
if (date.toDateString() === today.toDateString()) {
return `Сегодня в ${time}`
} else if (date.toDateString() === yesterday.toDateString()) {
return `Вчера в ${time}`
} else {
return `${weekdayRu.replace(/^./, (s) =>
s.toUpperCase(),
)} ${day} ${monthShortRu} ${time}`
}
}
function CalibrationCondInfo({
conditions,
isOutdated,
onUpdate,
}: CalibrationCondInfoProps) {
const [showModal, setShowModal] = useState(false)
const [tempConditions, setTempConditions] =
useState<CalibrationConditions>(conditions)
const [validationErrors, setValidationErrors] = useState<
Record<string, string>
>({})
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
setTempConditions(conditions)
}, [conditions])
const validateForm = () => {
const errors: Record<string, string> = {}
const fields = [
'temperature',
'humidity',
'pressure',
'voltage',
'frequency',
]
fields.forEach((field) => {
const validation = validateField(
field,
tempConditions[field as keyof CalibrationConditions] as string,
)
if (!validation.isValid && validation.message) {
errors[field] = validation.message
}
})
setValidationErrors(errors)
return Object.keys(errors).length === 0
}
const handleFieldChange = (field: string, value: string) => {
setTempConditions((prev) => ({ ...prev, [field]: value }))
// Валидация в реальном времени
const validation = validateField(field, value)
setValidationErrors((prev) => ({
...prev,
[field]: validation.isValid ? '' : validation.message || '',
}))
}
const handleSave = async () => {
if (!validateForm()) return
setIsLoading(true)
const newConditions = {
...tempConditions,
lastUpdated: new Date().toISOString(),
}
const success = await onUpdate(newConditions)
setIsLoading(false)
if (success) {
setShowModal(false)
} else {
alert('Не удалось сохранить условия поверки')
}
}
const openModal = () => {
setTempConditions(conditions)
setValidationErrors({})
setShowModal(true)
// Устанавливаем фокус на первое поле через небольшую задержку
setTimeout(() => {
const firstInput = document.getElementById(
'modal-temperature',
) as HTMLInputElement
if (firstInput) {
firstInput.focus()
firstInput.select()
}
}, 100)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (
e.key === 'Enter' &&
!Object.keys(validationErrors).some((key) => validationErrors[key])
) {
handleSave()
}
if (e.key === 'Escape') {
setShowModal(false)
}
}
const fieldConfigs = [
{
key: 'temperature',
label: 'Температура',
icon: Thermometer,
unit: '°C',
placeholder: '20',
},
{
key: 'humidity',
label: 'Влажность',
icon: Droplets,
unit: '%',
placeholder: '65',
},
{
key: 'pressure',
label: 'Давление',
icon: Gauge,
unit: 'кПа',
placeholder: '101.3',
},
{
key: 'voltage',
label: 'Напряжение',
icon: Zap,
unit: 'В',
placeholder: '220',
},
{
key: 'frequency',
label: 'Частота',
icon: Radio,
unit: 'Гц',
placeholder: '50',
},
]
return (
<>
{/* Компактное отображение */}
<div className="flex items-center gap-3 rounded-lg border bg-muted/50 px-3 py-2">
<div className="flex items-center gap-3 text-xs">
{fieldConfigs.map(({ key, icon: Icon, unit }) => (
<div key={key} className="flex items-center gap-1">
<Icon className="h-3 w-3 text-muted-foreground" />
<span className="text-foreground">
{conditions[key as keyof CalibrationConditions]}
{unit}
</span>
</div>
))}
</div>
<Button
variant="ghost"
size="icon"
onClick={openModal}
className={`h-6 w-6 p-0 ${
isOutdated ? 'text-yellow-600 hover:text-yellow-700' : ''
}`}
>
<Edit3 className="h-3 w-3" />
</Button>
{isOutdated && (
<div className="h-2 w-2 animate-pulse rounded-full bg-yellow-500" />
)}
</div>
{/* Модальное окно */}
{showModal && (
<div
className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto bg-black/50 p-4"
onKeyDown={handleKeyDown}
>
<div className="flex min-h-full items-center justify-center p-4">
<Card className="mx-auto my-8 w-full max-w-2xl">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<CardTitle className="text-lg">Условия поверки</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => setShowModal(false)}
>
<X className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{fieldConfigs.map(
({ key, label, icon: Icon, unit, placeholder }) => {
const hasError = validationErrors[key]
const isValid =
tempConditions[key as keyof CalibrationConditions] &&
!hasError
return (
<div key={key} className="space-y-2">
<Label
htmlFor={`modal-${key}`}
className="flex items-center gap-2"
>
<Icon className="h-4 w-4 text-muted-foreground" />
{label}
</Label>
<div className="space-y-1">
<div className="flex items-center gap-2">
<Input
id={`modal-${key}`}
value={
tempConditions[
key as keyof CalibrationConditions
] as string
}
onChange={(e) =>
handleFieldChange(key, e.target.value)
}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSave()
}
}}
className={`text-center [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none ${
hasError
? 'border-destructive focus-visible:ring-destructive'
: isValid
? 'border-green-500 focus-visible:ring-green-500'
: ''
}`}
placeholder={placeholder}
type="number"
step="0.1"
/>
<div className="flex min-w-[3rem] items-center gap-1">
<span className="text-sm text-muted-foreground">
{unit}
</span>
{isValid && (
<Check className="h-4 w-4 text-green-500" />
)}
{hasError && (
<div className="group relative">
<AlertCircle className="h-4 w-4 cursor-help text-destructive" />
<div className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 -translate-x-1/2 transform whitespace-nowrap rounded border bg-popover px-2 py-1 text-xs text-popover-foreground opacity-0 shadow-md transition-opacity group-hover:opacity-100">
{hasError}
</div>
</div>
)}
</div>
</div>
</div>
</div>
)
},
)}
</div>
<div className="flex items-center justify-between border-t pt-4 text-xs">
<div className="flex items-center gap-4">
<span className="text-muted-foreground">
Обновлено: {formatLastUpdated(conditions.lastUpdated)}
</span>
</div>
{isOutdated && (
<Badge variant="destructive" className="text-xs">
Устарели
</Badge>
)}
</div>
<div className="flex gap-3 pt-2">
<Button
onClick={handleSave}
className="flex-1"
disabled={
isLoading ||
Object.keys(validationErrors).some(
(key) => validationErrors[key],
)
}
>
{isLoading ? 'Сохранение...' : 'Сохранить'}
</Button>
<Button
variant="outline"
onClick={() => setShowModal(false)}
className="flex-1"
disabled={isLoading}
>
Отмена
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
)}
</>
)
}
interface CalibrationConditionsConfig {
targetCells: any[]
defaultConditions?: CalibrationConditions
}
export const calibrationConditionsDefinition: ElementDefinition<
CalibrationConditionsConfig,
CalibrationConditions
> = {
type: 'calibration-conditions',
label: 'Условия калибровки',
icon: <Thermometer className="h-4 w-4" />,
defaultConfig: {
targetCells: [],
defaultConditions: {
temperature: '20',
humidity: '65',
pressure: '101.3',
voltage: '220',
frequency: '50',
lastUpdated: new Date().toISOString(),
},
},
mapToCells: () => {
// Генерируем ячейки для каждого параметра калибровки
const cells = [
{ sheet: 'L', cell: 'B2', displayName: 'Температура' },
{ sheet: 'L', cell: 'B3', displayName: 'Влажность' },
{ sheet: 'L', cell: 'B4', displayName: 'Давление' },
{ sheet: 'L', cell: 'B5', displayName: 'Напряжение' },
{ sheet: 'L', cell: 'B6', displayName: 'Частота' },
{ sheet: 'L', cell: 'B7', displayName: 'Дата обновления' },
]
return cells
},
Editor: ({ config, onChange }) => (
<div className="space-y-4">
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
<p className="text-sm text-gray-600">
Элемент "Условия калибровки" позволяет вводить и отслеживать параметры
окружающей среды при проведении измерений. Поддерживает валидацию
значений и отслеживание времени обновления.
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Параметры по умолчанию</label>
<div className="grid grid-cols-2 gap-4 rounded-lg border border-gray-200 p-4">
<div className="space-y-2">
<label className="text-xs font-medium text-gray-600">
Температура (°C)
</label>
<Input
type="number"
value={config.defaultConditions?.temperature || '20'}
onChange={(e) =>
onChange({
...config,
defaultConditions: {
...config.defaultConditions,
temperature: e.target.value,
humidity: config.defaultConditions?.humidity || '65',
pressure: config.defaultConditions?.pressure || '101.3',
voltage: config.defaultConditions?.voltage || '220',
frequency: config.defaultConditions?.frequency || '50',
lastUpdated:
config.defaultConditions?.lastUpdated ||
new Date().toISOString(),
},
})
}
placeholder="20"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-gray-600">
Влажность (%)
</label>
<Input
type="number"
value={config.defaultConditions?.humidity || '65'}
onChange={(e) =>
onChange({
...config,
defaultConditions: {
...config.defaultConditions,
temperature: config.defaultConditions?.temperature || '20',
humidity: e.target.value,
pressure: config.defaultConditions?.pressure || '101.3',
voltage: config.defaultConditions?.voltage || '220',
frequency: config.defaultConditions?.frequency || '50',
lastUpdated:
config.defaultConditions?.lastUpdated ||
new Date().toISOString(),
},
})
}
placeholder="65"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-gray-600">
Давление (кПа)
</label>
<Input
type="number"
value={config.defaultConditions?.pressure || '101.3'}
onChange={(e) =>
onChange({
...config,
defaultConditions: {
...config.defaultConditions,
temperature: config.defaultConditions?.temperature || '20',
humidity: config.defaultConditions?.humidity || '65',
pressure: e.target.value,
voltage: config.defaultConditions?.voltage || '220',
frequency: config.defaultConditions?.frequency || '50',
lastUpdated:
config.defaultConditions?.lastUpdated ||
new Date().toISOString(),
},
})
}
placeholder="101.3"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-gray-600">
Напряжение (В)
</label>
<Input
type="number"
value={config.defaultConditions?.voltage || '220'}
onChange={(e) =>
onChange({
...config,
defaultConditions: {
...config.defaultConditions,
temperature: config.defaultConditions?.temperature || '20',
humidity: config.defaultConditions?.humidity || '65',
pressure: config.defaultConditions?.pressure || '101.3',
voltage: e.target.value,
frequency: config.defaultConditions?.frequency || '50',
lastUpdated:
config.defaultConditions?.lastUpdated ||
new Date().toISOString(),
},
})
}
placeholder="220"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-gray-600">
Частота (Гц)
</label>
<Input
type="number"
value={config.defaultConditions?.frequency || '50'}
onChange={(e) =>
onChange({
...config,
defaultConditions: {
...config.defaultConditions,
temperature: config.defaultConditions?.temperature || '20',
humidity: config.defaultConditions?.humidity || '65',
pressure: config.defaultConditions?.pressure || '101.3',
voltage: config.defaultConditions?.voltage || '220',
frequency: e.target.value,
lastUpdated:
config.defaultConditions?.lastUpdated ||
new Date().toISOString(),
},
})
}
placeholder="50"
/>
</div>
</div>
</div>
</div>
),
Preview: ({ config }) => (
<div className="space-y-3">
<div className="text-sm font-medium text-gray-700">Предпросмотр</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<CalibrationCondInfo
conditions={
config.defaultConditions || {
temperature: '20',
humidity: '65',
pressure: '101.3',
voltage: '220',
frequency: '50',
lastUpdated: new Date().toISOString(),
}
}
isOutdated={false}
onUpdate={async () => true}
/>
</div>
</div>
),
Render: ({ config, value, onChange }) => {
const conditions = value ||
config.defaultConditions || {
temperature: '20',
humidity: '65',
pressure: '101.3',
voltage: '220',
frequency: '50',
lastUpdated: new Date().toISOString(),
}
// Проверяем, устарели ли условия (больше 24 часов)
const lastUpdated = new Date(conditions.lastUpdated)
const now = new Date()
const isOutdated =
now.getTime() - lastUpdated.getTime() > 24 * 60 * 60 * 1000
return (
<CalibrationCondInfo
conditions={conditions}
isOutdated={isOutdated}
onUpdate={async (newConditions) => {
onChange?.(newConditions)
return true
}}
/>
)
},
}

View File

@@ -0,0 +1,47 @@
import { ElementDefinition } from '@/lib/element-registry'
import { CheckSquare } from 'lucide-react'
interface CheckboxConfig {
placeholder?: string
required?: boolean
targetCells: any[]
}
export const checkboxDefinition: ElementDefinition<CheckboxConfig, boolean> = {
type: 'checkbox',
label: 'Чекбокс',
icon: <CheckSquare className="h-4 w-4" />,
defaultConfig: {
placeholder: '',
required: false,
targetCells: [],
},
Editor: () => (
<div className="text-sm text-gray-600">
Дополнительные настройки отсутствуют
</div>
),
Preview: ({ config }) => (
<div className="space-y-3">
<div className="text-sm font-medium text-gray-700">Предпросмотр</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<div className="flex items-center space-x-2">
<div className="h-4 w-4 rounded border border-gray-300" />
<span className="text-sm">{config.placeholder || 'Отметить'}</span>
</div>
</div>
</div>
),
Render: ({ config, value, onChange }) => (
<div className="flex items-center space-x-2">
<input
type="checkbox"
checked={value || false}
onChange={(e) => onChange?.(e.target.checked)}
required={config.required}
className="h-4 w-4"
/>
<span className="text-sm">{config.placeholder || 'Отметить'}</span>
</div>
),
}

View File

@@ -0,0 +1,49 @@
import { Input } from '@/components/ui/input'
import { ElementDefinition } from '@/lib/element-registry'
import { Calendar } from 'lucide-react'
interface DateConfig {
required?: boolean
targetCells: any[]
}
export const dateDefinition: ElementDefinition<DateConfig, string> = {
type: 'date',
label: 'Дата',
icon: <Calendar className="h-4 w-4" />,
defaultConfig: {
required: false,
targetCells: [],
},
Editor: ({ config, onChange }) => (
<div className="space-y-4">
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="required"
checked={config.required}
onChange={(e) => onChange({ ...config, required: e.target.checked })}
/>
<label htmlFor="required" className="text-sm font-medium">
Обязательное поле
</label>
</div>
</div>
),
Preview: () => (
<div className="space-y-3">
<div className="text-sm font-medium text-gray-700">Предпросмотр</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<Input type="date" disabled className="w-full" />
</div>
</div>
),
Render: ({ config, value, onChange }) => (
<Input
type="date"
value={value || ''}
onChange={(e) => onChange?.(e.target.value)}
required={config.required}
/>
),
}

View File

@@ -0,0 +1,47 @@
import { Input } from '@/components/ui/input'
import { ElementDefinition } from '@/lib/element-registry'
import { Hash } from 'lucide-react'
interface NumberConfig {
placeholder?: string
required?: boolean
targetCells: any[]
}
export const numberDefinition: ElementDefinition<NumberConfig, number> = {
type: 'number',
label: 'Число',
icon: <Hash className="h-4 w-4" />,
defaultConfig: {
placeholder: '',
required: false,
targetCells: [],
},
Editor: () => (
<div className="text-sm text-gray-600">
Дополнительные настройки отсутствуют
</div>
),
Preview: ({ config }) => (
<div className="space-y-3">
<div className="text-sm font-medium text-gray-700">Предпросмотр</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<Input
type="number"
placeholder={config.placeholder || '0'}
disabled
className="w-full"
/>
</div>
</div>
),
Render: ({ config, value, onChange }) => (
<Input
type="number"
placeholder={config.placeholder || '0'}
value={value || ''}
onChange={(e) => onChange?.(parseFloat(e.target.value) || 0)}
required={config.required}
/>
),
}

View File

@@ -0,0 +1,133 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ElementDefinition } from '@/lib/element-registry'
import { ElementOption } from '@/type/template'
import { CheckSquare, Plus, Trash2 } from 'lucide-react'
interface RadioConfig {
placeholder?: string
required?: boolean
options: ElementOption[]
targetCells: any[]
}
export const radioDefinition: ElementDefinition<RadioConfig, string> = {
type: 'radio',
label: 'Радиокнопки',
icon: <CheckSquare className="h-4 w-4" />,
defaultConfig: {
placeholder: '',
required: false,
options: [],
targetCells: [],
},
Editor: ({ config, onChange }) => {
const handleAddOption = () => {
onChange({
...config,
options: [...config.options, { value: '', label: '' }],
})
}
const handleUpdateOption = (
index: number,
field: 'value' | 'label',
value: string
) => {
onChange({
...config,
options: config.options.map((option, i) =>
i === index ? { ...option, [field]: value } : option
),
})
}
const handleRemoveOption = (index: number) => {
onChange({
...config,
options: config.options.filter((_, i) => i !== index),
})
}
return (
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Варианты</label>
<Button variant="outline" size="sm" onClick={handleAddOption}>
<Plus className="mr-1 h-4 w-4" />
Добавить
</Button>
</div>
<div className="max-h-32 space-y-2 overflow-y-auto">
{config.options.map((option, index) => (
<div key={index} className="flex gap-2">
<Input
placeholder="Значение"
value={option.value}
onChange={e =>
handleUpdateOption(index, 'value', e.target.value)
}
/>
<Input
placeholder="Подпись"
value={option.label}
onChange={e =>
handleUpdateOption(index, 'label', e.target.value)
}
/>
<Button
variant="outline"
size="icon"
onClick={() => handleRemoveOption(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
</div>
)
},
Preview: ({ config }) => (
<div className="space-y-3">
<div className="text-sm font-medium text-gray-700">Предпросмотр</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<div className="space-y-2">
{config.options.length > 0 ? (
config.options.map((option, index) => (
<div key={index} className="flex items-center space-x-2">
<div className="h-4 w-4 rounded-full border border-gray-300" />
<span className="text-sm">{option.label}</span>
</div>
))
) : (
<div className="flex items-center space-x-2">
<div className="h-4 w-4 rounded-full border border-gray-300" />
<span className="text-sm text-gray-500">Вариант 1</span>
</div>
)}
</div>
</div>
</div>
),
Render: ({ config, value, onChange }) => (
<div className="space-y-2">
{config.options.map((option, index) => (
<div key={index} className="flex items-center space-x-2">
<input
type="radio"
name="radio-group"
value={option.value}
checked={value === option.value}
onChange={e => onChange?.(e.target.value)}
required={config.required}
className="h-4 w-4"
/>
<span className="text-sm">{option.label}</span>
</div>
))}
</div>
),
}

View File

@@ -0,0 +1,128 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { ElementDefinition } from '@/lib/element-registry'
import { ElementOption } from '@/type/template'
import { ChevronDown, Plus, Trash2 } from 'lucide-react'
interface SelectConfig {
placeholder?: string
required?: boolean
options: ElementOption[]
targetCells: any[]
}
export const selectDefinition: ElementDefinition<SelectConfig, string> = {
type: 'select',
label: 'Выпадающий список',
icon: <ChevronDown className="h-4 w-4" />,
defaultConfig: {
placeholder: '',
required: false,
options: [],
targetCells: [],
},
Editor: ({ config, onChange }) => {
const handleAddOption = () => {
onChange({
...config,
options: [...config.options, { value: '', label: '' }],
})
}
const handleUpdateOption = (
index: number,
field: 'value' | 'label',
value: string
) => {
onChange({
...config,
options: config.options.map((option, i) =>
i === index ? { ...option, [field]: value } : option
),
})
}
const handleRemoveOption = (index: number) => {
onChange({
...config,
options: config.options.filter((_, i) => i !== index),
})
}
return (
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Варианты</label>
<Button variant="outline" size="sm" onClick={handleAddOption}>
<Plus className="mr-1 h-4 w-4" />
Добавить
</Button>
</div>
<div className="max-h-32 space-y-2 overflow-y-auto">
{config.options.map((option, index) => (
<div key={index} className="flex gap-2">
<Input
placeholder="Значение"
value={option.value}
onChange={e =>
handleUpdateOption(index, 'value', e.target.value)
}
/>
<Input
placeholder="Подпись"
value={option.label}
onChange={e =>
handleUpdateOption(index, 'label', e.target.value)
}
/>
<Button
variant="outline"
size="icon"
onClick={() => handleRemoveOption(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
</div>
)
},
Preview: ({ config }) => (
<div className="space-y-3">
<div className="text-sm font-medium text-gray-700">Предпросмотр</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<Select disabled>
<SelectTrigger className="w-full">
<span className="text-gray-500">
{config.placeholder || 'Выберите значение'}
</span>
</SelectTrigger>
</Select>
</div>
</div>
),
Render: ({ config, value, onChange }) => (
<Select value={value} onValueChange={onChange}>
<SelectTrigger>
<SelectValue placeholder={config.placeholder || 'Выберите значение'} />
</SelectTrigger>
<SelectContent>
{config.options.map((option, index) => (
<SelectItem key={index} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
),
}

View File

@@ -0,0 +1,45 @@
import { Input } from '@/components/ui/input'
import { ElementDefinition } from '@/lib/element-registry'
import { Type } from 'lucide-react'
interface TextConfig {
placeholder?: string
required?: boolean
targetCells: any[]
}
export const textDefinition: ElementDefinition<TextConfig, string> = {
type: 'text',
label: 'Текстовое поле',
icon: <Type className="h-4 w-4" />,
defaultConfig: {
placeholder: '',
required: false,
targetCells: [],
},
Editor: () => (
<div className="text-sm text-gray-600">
Дополнительные настройки отсутствуют
</div>
),
Preview: ({ config }) => (
<div className="space-y-3">
<div className="text-sm font-medium text-gray-700">Предпросмотр</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<Input
placeholder={config.placeholder || 'Введите текст'}
disabled
className="w-full"
/>
</div>
</div>
),
Render: ({ config, value, onChange }) => (
<Input
placeholder={config.placeholder || 'Введите текст'}
value={value || ''}
onChange={(e) => onChange?.(e.target.value)}
required={config.required}
/>
),
}

View File

@@ -0,0 +1,48 @@
import { Textarea } from '@/components/ui/textarea'
import { ElementDefinition } from '@/lib/element-registry'
import { FileText } from 'lucide-react'
interface TextareaConfig {
placeholder?: string
required?: boolean
targetCells: any[]
}
export const textareaDefinition: ElementDefinition<TextareaConfig, string> = {
type: 'textarea',
label: 'Многострочный текст',
icon: <FileText className="h-4 w-4" />,
defaultConfig: {
placeholder: '',
required: false,
targetCells: [],
},
Editor: () => (
<div className="text-sm text-gray-600">
Дополнительные настройки отсутствуют
</div>
),
Preview: ({ config }) => (
<div className="space-y-3">
<div className="text-sm font-medium text-gray-700">Предпросмотр</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<Textarea
placeholder={config.placeholder || 'Введите текст'}
disabled
rows={3}
className="w-full resize-none"
/>
</div>
</div>
),
Render: ({ config, value, onChange }) => (
<Textarea
placeholder={config.placeholder || 'Введите текст'}
value={value || ''}
onChange={(e) => onChange?.(e.target.value)}
required={config.required}
rows={3}
className="w-full resize-none"
/>
),
}

View File

@@ -0,0 +1,9 @@
export { buttonGroupDefinition } from './ButtonGroupElement'
export { calibrationConditionsDefinition } from './CalibrationConditionsElement'
export { checkboxDefinition } from './CheckboxElement'
export { dateDefinition } from './DateElement'
export { numberDefinition } from './NumberElement'
export { radioDefinition } from './RadioElement'
export { selectDefinition } from './SelectElement'
export { textareaDefinition } from './TextareaElement'
export { textDefinition } from './TextElement'

View File

@@ -0,0 +1,797 @@
import { useMultiSheetEngine } from '@/hook/useMultiSheetEngine'
import { useSheetAutoSave } from '@/hook/useSheetAutoSave'
import { getFileData, getLatestFileForTemplate } from '@/service/fileApiSevice'
import { produce } from 'immer'
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
// Импорты из новых модулей
import { FormulaBar, Spreadsheet } from './components'
import { useDraggableDivider } from './hooks/useDraggableDivider'
import {
CachedCellData,
COL_COUNT,
DualSpreadsheetProps,
ROW_COUNT,
SHEET_NAMES,
SHEET_TYPES,
SheetType,
} from './types'
import { getColumnLabel } from './utils'
const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
templateData = {},
mergedCells = [],
templateId,
onEngineReady,
}) => {
const { revision, updateRevision, setTemplateData, ...multiSheetEngine } =
useMultiSheetEngine({
sheets: [
{ name: SHEET_NAMES.report, rows: ROW_COUNT, cols: COL_COUNT },
{ name: SHEET_NAMES.calculations, rows: ROW_COUNT, cols: COL_COUNT },
],
initialData: templateData || {},
})
// Передаем методы движка родительскому компоненту
useEffect(() => {
if (onEngineReady) {
onEngineReady({
...multiSheetEngine,
updateRevision,
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [onEngineReady])
const [activeSheet, setActiveSheet] = useState<SheetType>('report')
const [selectedCell, setSelectedCell] = useState<{
row: number
col: number
} | null>(null)
const [isEditing, setIsEditing] = useState(false)
const [editingValue, setEditingValue] = useState<string>('') // Локальное значение во время редактирования
const [isInitialized, setIsInitialized] = useState(false)
const [baseFileData, setBaseFileData] = useState<
Record<string, Record<string, any>>
>({}) // Базовые данные файла
const [columnWidths, setColumnWidths] = useState<Record<SheetType, number[]>>(
() => ({
report: Array(COL_COUNT).fill(96),
calculations: Array(COL_COUNT).fill(96),
})
)
const gridRefs = useRef<Record<SheetType, any>>({
report: null,
calculations: null,
})
const containerRefs = useRef<Record<SheetType, HTMLDivElement | null>>({
report: null,
calculations: null,
})
const activeCellInputRef = useRef<HTMLInputElement>(null)
const formulaBarRef = useRef<HTMLInputElement | null>(null)
const headerRefs = useRef<Record<SheetType, HTMLDivElement | null>>({
report: null,
calculations: null,
})
const rowHeaderRefs = useRef<Record<SheetType, HTMLDivElement | null>>({
report: null,
calculations: null,
})
const initialTemplateValues = useRef<Record<string, string>>({})
const cellDataCache = useRef<Record<string, Record<string, CachedCellData>>>(
{}
)
const lastRevision = useRef<number>(-1)
useEffect(() => {
// Используем baseFileData как источник базовых значений для сравнения
const reportBaseData = baseFileData?.[SHEET_NAMES.report]
if (reportBaseData && Object.keys(reportBaseData).length > 0) {
// Очищаем старые значения
initialTemplateValues.current = {}
Object.entries(reportBaseData).forEach(([cellName, value]) => {
initialTemplateValues.current[cellName] = String(value)
})
console.log(
'✅ Загружено базовых значений файла для сравнения:',
Object.keys(initialTemplateValues.current).length
)
} else {
// Fallback: используем templateData, если baseFileData еще не загружен
const reportTemplate = templateData?.[SHEET_NAMES.report]
if (reportTemplate && Object.keys(reportTemplate).length > 0) {
// Очищаем старые значения
initialTemplateValues.current = {}
Object.entries(reportTemplate).forEach(([cellName, value]) => {
initialTemplateValues.current[cellName] = String(value)
})
console.log(
'✅ Загружено значений templateData для сравнения (fallback):',
Object.keys(initialTemplateValues.current).length
)
} else {
console.log('⚠️ DualSpreadsheet: Нет базовых данных для сравнения')
}
}
}, [baseFileData, templateData])
useEffect(() => {
if (selectedCell && activeSheet) {
const grid = gridRefs.current[activeSheet]
grid?.scrollToItem({
rowIndex: selectedCell.row,
columnIndex: selectedCell.col,
align: 'auto',
})
}
}, [selectedCell, activeSheet])
const getCachedCellData = useCallback(
(sheetType: SheetType): Record<string, CachedCellData> => {
const cacheKey = `${sheetType}-${revision}-${JSON.stringify(columnWidths[sheetType])}`
if (cellDataCache.current[cacheKey]) {
return cellDataCache.current[cacheKey]
}
const sheetName = SHEET_NAMES[sheetType]
const cachedData: Record<string, CachedCellData> = {}
const allCellValues: Record<string, string> = {}
for (let row = 0; row < ROW_COUNT; row++) {
for (let col = 0; col < COL_COUNT; col++) {
const cellKey = `${row}-${col}`
const displayValue = String(
multiSheetEngine.getCellValue(sheetName, row, col) || ''
)
allCellValues[cellKey] = displayValue.trim()
}
}
// 1. Находим ближайшую занятую колонку справа для каждой ячейки строки
const nextContentInRow: Record<number, number[]> = {}
for (let row = 0; row < ROW_COUNT; row++) {
nextContentInRow[row] = Array(COL_COUNT).fill(COL_COUNT)
let next = COL_COUNT
for (let col = COL_COUNT - 1; col >= 0; col--) {
const cellKey = `${row}-${col}`
nextContentInRow[row][col] = next // сохранить для использования ниже
if (allCellValues[cellKey]) {
next = col // текущая колонка занята → "новый" предел
}
}
}
// 2. Заполняем cache
for (let row = 0; row < ROW_COUNT; row++) {
for (let col = 0; col < COL_COUNT; col++) {
const cellKey = `${row}-${col}`
const rawValue = multiSheetEngine.getCellRawValue(sheetName, row, col)
const displayValue = allCellValues[cellKey]
const nextCol = nextContentInRow[row][col]
// ширина от текущей колонки до nextCol-1 включительно
const availablePx = columnWidths[sheetType]
.slice(col, nextCol)
.reduce((sum, w) => sum + w, 0)
const cellName = getColumnLabel(col) + (row + 1)
const baseFileValue = initialTemplateValues.current[cellName] ?? '' // Базовое значение из файла
// Отладка только для измененных ячеек
if (
sheetType === 'report' &&
baseFileValue &&
rawValue !== baseFileValue
) {
console.log(`🔧 Измененная ячейка ${cellName}:`, {
baseFileValue,
rawValue,
isModified: true,
})
}
// Нормализуем значения для сравнения
const normalizeValue = (value: any): string => {
if (value === null || value === undefined) return ''
const str = String(value).trim()
// Считаем "0" и пустую строку эквивалентными
if (str === '0' || str === '') return ''
return str
}
const normalizedBaseFile = normalizeValue(baseFileValue)
const normalizedRaw = normalizeValue(rawValue)
const isModified =
sheetType === 'report' && normalizedRaw !== normalizedBaseFile
// Отладка только для измененных ячеек
if (sheetType === 'report' && isModified) {
console.log(`🔍 Измененная ячейка ${cellName}:`, {
baseFileValue,
rawValue,
isModified,
})
}
cachedData[cellKey] = {
displayValue,
rawValue,
isModified,
nextContentCol: nextCol,
availablePx,
}
}
}
if (lastRevision.current !== revision) {
Object.keys(cellDataCache.current).forEach(key => {
if (!key.endsWith(`-${revision}`)) {
delete cellDataCache.current[key]
}
})
lastRevision.current = revision
}
cellDataCache.current[cacheKey] = cachedData
return cachedData
},
[revision, multiSheetEngine, initialTemplateValues, columnWidths]
)
const formulaBarValue = useMemo(() => {
if (isEditing) {
return editingValue
}
if (selectedCell) {
const sheetName = SHEET_NAMES[activeSheet]
return multiSheetEngine.getCellRawValue(
sheetName,
selectedCell.row,
selectedCell.col
)
}
return ''
}, [
isEditing,
editingValue,
selectedCell,
activeSheet,
multiSheetEngine,
revision,
])
useEffect(() => {
if (isEditing && activeCellInputRef.current) {
const input = activeCellInputRef.current
input.focus()
const length = input.value.length
input.setSelectionRange(length, length)
}
}, [isEditing])
// Синхронизация между formula bar и ячейкой при редактировании
useEffect(() => {
if (isEditing && formulaBarRef.current) {
formulaBarRef.current.value = editingValue
}
}, [editingValue, isEditing])
const startEditing = useCallback(() => {
if (!selectedCell) return
const sheetName = SHEET_NAMES[activeSheet]
const rawValue = multiSheetEngine.getCellRawValue(
sheetName,
selectedCell.row,
selectedCell.col
)
setIsEditing(true)
setEditingValue(rawValue)
}, [activeSheet, selectedCell, multiSheetEngine])
const stopEditing = useCallback(
(save: boolean) => {
if (save && selectedCell) {
const sheetName = SHEET_NAMES[activeSheet]
const cellName =
getColumnLabel(selectedCell.col) + (selectedCell.row + 1)
const oldValue = multiSheetEngine.getCellRawValue(
sheetName,
selectedCell.row,
selectedCell.col
)
const baseFileValue = initialTemplateValues.current[cellName] ?? '' // Базовое значение из файла
console.log('💾 Сохранение ячейки:', {
cellName,
oldValue,
newValue: editingValue,
baseFileValue,
isChanged: oldValue !== editingValue,
isModifiedFromBaseFile: baseFileValue !== editingValue,
})
multiSheetEngine.setCellValueWithoutRecalc(
sheetName,
selectedCell.row,
selectedCell.col,
editingValue
)
updateRevision()
multiSheetEngine.debouncedRecalc()
}
setIsEditing(false)
setEditingValue('')
containerRefs.current[activeSheet]?.focus({ preventScroll: true })
},
[activeSheet, selectedCell, editingValue, multiSheetEngine, updateRevision]
)
const handleCellChange = useCallback(
(newValue: string) => {
if (isEditing) {
setEditingValue(newValue)
}
},
[isEditing]
)
const handleClearCell = useCallback(() => {
if (!selectedCell) return
const sheetName = SHEET_NAMES[activeSheet]
multiSheetEngine.setCellValueWithoutRecalc(
sheetName,
selectedCell.row,
selectedCell.col,
''
)
updateRevision()
multiSheetEngine.debouncedRecalc()
}, [activeSheet, selectedCell, multiSheetEngine, updateRevision])
const moveSelection = useCallback(
(deltaRow: number, deltaCol: number) => {
if (!selectedCell) {
setSelectedCell({ row: 0, col: 0 })
return
}
if (isEditing) {
stopEditing(true)
}
const newRow = Math.max(
0,
Math.min(ROW_COUNT - 1, selectedCell.row + deltaRow)
)
const newCol = Math.max(
0,
Math.min(COL_COUNT - 1, selectedCell.col + deltaCol)
)
setSelectedCell({ row: newRow, col: newCol })
},
[selectedCell, isEditing, stopEditing]
)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (isEditing) {
return
}
if (!selectedCell && e.key.match(/^(Arrow|Enter|F2|Delete|Backspace)$/)) {
moveSelection(0, 0)
e.preventDefault()
return
}
if (!selectedCell) return
const keyHandlers: Record<string, () => void> = {
ArrowUp: () => moveSelection(-1, 0),
ArrowDown: () => moveSelection(1, 0),
ArrowLeft: () => moveSelection(0, -1),
ArrowRight: () => moveSelection(0, 1),
Enter: () => startEditing(),
F2: () => startEditing(),
Delete: handleClearCell,
Backspace: handleClearCell,
}
if (keyHandlers[e.key]) {
e.preventDefault()
keyHandlers[e.key]()
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
e.preventDefault()
setEditingValue(e.key)
setIsEditing(true)
}
},
[selectedCell, isEditing, moveSelection, startEditing, handleClearCell]
)
const handleCellClick = useCallback(
(row: number, col: number) => {
if (isEditing) {
stopEditing(true)
}
setSelectedCell({ row, col })
containerRefs.current[activeSheet]?.focus({ preventScroll: true })
},
[isEditing, stopEditing, activeSheet]
)
const handleCellDoubleClick = useCallback(
(row: number, col: number) => {
setSelectedCell({ row, col })
startEditing()
},
[startEditing]
)
const handleColumnResize = useCallback(
(sheetType: SheetType, colIndex: number, newWidth: number) => {
setColumnWidths(
produce(draft => {
draft[sheetType][colIndex] = Math.max(60, newWidth)
const grid = gridRefs.current[sheetType]
if (grid) {
grid.resetAfterColumnIndex(colIndex)
}
})
)
},
[]
)
const handleFormulaBarChange = useCallback(
(newValue: string) => {
if (selectedCell) {
if (!isEditing) {
setIsEditing(true)
}
setEditingValue(newValue)
}
},
[selectedCell, isEditing]
)
const handleFormulaBarFocus = useCallback(() => {
if (selectedCell && !isEditing) {
startEditing()
}
}, [selectedCell, isEditing, startEditing])
const handleFormulaBarBlur = useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
// Проверяем, не переходит ли фокус в ячейку редактирования
const relatedTarget = e.relatedTarget as HTMLElement
if (relatedTarget && relatedTarget.closest('input[type="text"]')) {
return // Не завершаем редактирование
}
// Завершаем редактирование только если фокус ушел полностью
if (isEditing) {
stopEditing(true)
}
},
[isEditing, stopEditing]
)
const handleFormulaBarKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
stopEditing(true)
moveSelection(1, 0)
setTimeout(() => {
containerRefs.current[activeSheet]?.focus({ preventScroll: true })
}, 0)
} else if (e.key === 'Escape') {
e.preventDefault()
stopEditing(false)
containerRefs.current[activeSheet]?.focus({ preventScroll: true })
}
},
[stopEditing, moveSelection, activeSheet]
)
const onGridScroll = useCallback(
(
sheetType: SheetType,
{ scrollLeft, scrollTop }: { scrollLeft: number; scrollTop: number }
) => {
if (headerRefs.current[sheetType]) {
headerRefs.current[sheetType]!.scrollLeft = scrollLeft
}
if (rowHeaderRefs.current[sheetType]) {
rowHeaderRefs.current[sheetType]!.scrollTop = scrollTop
}
},
[]
)
const [spreadsheetWidths, setSpreadsheetWidths] = useState({
L: 0.5,
R: 0.5,
})
// Используем новый хук автосохранения
const {
isSaving,
isLoading,
hasUnsavedChanges,
lastSaveError,
manualSave,
clearError,
loadSavedSheets,
loadSheets, // Добавляем асинхронную функцию загрузки
} = useSheetAutoSave(
multiSheetEngine.getAllModifiedCells,
multiSheetEngine.isCalculating,
revision,
{
templateId,
autoSaveDelay: 2000,
enableAutoSave: true,
}
)
// Отладка templateId
useEffect(() => {
console.log('🆔 Текущий templateId в DualSpreadsheet:', {
templateId,
type: typeof templateId,
isValid: templateId
? /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
templateId
)
: false,
})
}, [templateId])
// Используем хук для перетаскивания разделителя
const handleDividerDrag = useCallback((newLeftPercentage: number) => {
// Используем requestAnimationFrame для более плавного обновления
requestAnimationFrame(() => {
setSpreadsheetWidths({
L: newLeftPercentage,
R: 1 - newLeftPercentage,
})
})
}, [])
const separatorRef = useDraggableDivider(handleDividerDrag)
// Загружаем сохраненные формулы при инициализации
useEffect(() => {
if (!templateId || isInitialized) return
// Асинхронная функция для загрузки и слияния данных
const initializeData = async () => {
let finalBaseData: Record<string, Record<string, any>> = {}
// 1. ЗАГРУЖАЕМ БАЗОВЫЕ ДАННЫЕ ФАЙЛА
if (templateId) {
try {
console.log('🔍 Ищем файлы для templateId:', templateId)
const latestFile = await getLatestFileForTemplate(templateId)
if (latestFile) {
console.log(
'📄 Найден файл:',
latestFile.name,
'ID:',
latestFile.id
)
const fileData = await getFileData(latestFile.id)
// TODO: Пока getFileData возвращает пустые данные, используем templateData
// Когда API будет готов, здесь будут реальные данные файла
if (Object.keys(fileData.data).length > 0) {
finalBaseData = { [SHEET_NAMES.report]: fileData.data }
setBaseFileData(finalBaseData)
// Сохраняем baseline, чтобы в L попадали только пользовательские изменения
setTemplateData(finalBaseData)
console.log('📄 Загружены базовые данные файла:', finalBaseData)
} else {
console.log(
'⚠️ getFileData вернул пустые данные, используем templateData'
)
}
} else {
console.log('⚠️ Файл не найден для templateId:', templateId)
}
} catch (error) {
console.error('❌ Ошибка при загрузке данных файла:', error)
}
}
// 2. ИСПОЛЬЗУЕМ templateData КАК FALLBACK или основные данные
const hasTemplateData =
Object.keys(templateData).length > 0 &&
Object.values(templateData).some(sheet => Object.keys(sheet).length > 0)
if (hasTemplateData && Object.keys(finalBaseData).length === 0) {
finalBaseData = templateData
setBaseFileData(finalBaseData)
console.log(
'📄 Используем templateData как базовые данные:',
finalBaseData
)
setTemplateData(templateData)
}
// 3. ЗАГРУЖАЕМ ПОЛЬЗОВАТЕЛЬСКИЕ ИЗМЕНЕНИЯ ИЗ API
try {
console.log('🔍 Загружаем пользовательские изменения из API...')
const apiSheets = await loadSheets()
// Преобразуем листы API в формат данных
const userChanges: Record<string, Record<string, any>> = {}
Object.entries(apiSheets).forEach(([sheetName, sheet]) => {
// Нормализуем имя листа: "Report" -> "L"
const normalizedName =
sheetName === 'Report'
? SHEET_NAMES.report
: sheetName === 'Calculations'
? SHEET_NAMES.calculations
: sheetName
// Интересуют только листы L и R
if (
(normalizedName === SHEET_NAMES.report ||
normalizedName === SHEET_NAMES.calculations) &&
sheet.cells &&
Object.keys(sheet.cells).length > 0
) {
userChanges[normalizedName] = sheet.cells
}
})
console.log(
'📂 Загружены пользовательские изменения:',
Object.keys(userChanges)
)
// 4. ОБЪЕДИНЯЕМ БАЗОВЫЕ ДАННЫЕ С ПОЛЬЗОВАТЕЛЬСКИМИ ИЗМЕНЕНИЯМИ
if (Object.keys(userChanges).length > 0) {
console.log(
'🔄 Объединяем базовые данные с пользовательскими изменениями'
)
// Создаем объединенные данные: базовые данные файла + пользовательские изменения
const mergedData: Record<string, Record<string, any>> = {}
// Копируем базовые данные файла как основу
Object.entries(finalBaseData).forEach(([sheetName, sheetData]) => {
mergedData[sheetName] = { ...sheetData }
})
// Применяем пользовательские изменения поверх базовых данных
Object.entries(userChanges).forEach(([sheetName, sheetData]) => {
if (!mergedData[sheetName]) {
mergedData[sheetName] = {}
}
Object.entries(sheetData).forEach(([cellAddress, value]) => {
mergedData[sheetName][cellAddress] = value
})
})
console.log(
'📊 Загружаем объединенные данные (базовые + пользовательские)'
)
multiSheetEngine.loadData(mergedData)
// Обновляем baseline, включая уже сохранённые изменения пользователя,
// чтобы их не сохранять повторно
} else if (Object.keys(finalBaseData).length > 0) {
console.log(
'📋 Используем только базовые данные (нет пользовательских изменений)'
)
multiSheetEngine.loadData(finalBaseData)
} else {
console.log('⚠️ Нет данных для загрузки')
}
} catch (error) {
console.error(
'❌ Ошибка при загрузке пользовательских изменений:',
error
)
// В случае ошибки используем только базовые данные
if (Object.keys(finalBaseData).length > 0) {
console.log('📋 Ошибка API, используем только базовые данные')
multiSheetEngine.loadData(finalBaseData)
}
}
setIsInitialized(true)
}
initializeData()
}, [
templateId,
templateData,
loadSheets,
multiSheetEngine,
setTemplateData,
isInitialized,
])
return (
<div className="flex h-full flex-col bg-background">
<FormulaBar
selectedCell={selectedCell}
formulaBarValue={formulaBarValue}
isCalculating={multiSheetEngine.isCalculating}
isEditing={isEditing}
onValueChange={handleFormulaBarChange}
onFocus={handleFormulaBarFocus}
onBlur={handleFormulaBarBlur}
onKeyDown={handleFormulaBarKeyDown}
formulaBarRef={el => {
formulaBarRef.current = el
}}
isSaving={isSaving}
isLoading={isLoading}
hasUnsavedChanges={hasUnsavedChanges}
lastSaveError={lastSaveError}
onManualSave={manualSave}
onClearError={clearError}
/>
<div
className="flex min-w-0 flex-1 gap-1 overflow-hidden"
style={{ transition: 'none' }}
>
{SHEET_TYPES.map((sheetType, index) => (
<Spreadsheet
key={sheetType}
sheetType={sheetType}
columnWidths={columnWidths[sheetType]}
spreadsheetWidths={spreadsheetWidths}
getCachedCellData={getCachedCellData}
selectedCell={selectedCell}
activeSheet={activeSheet}
isEditing={isEditing}
editingValue={editingValue}
handleCellClick={handleCellClick}
handleCellDoubleClick={handleCellDoubleClick}
handleCellChange={handleCellChange}
stopEditing={stopEditing}
setActiveSheet={setActiveSheet}
activeCellInputRef={activeCellInputRef}
containerRefs={containerRefs}
handleKeyDown={handleKeyDown}
headerRefs={headerRefs}
rowHeaderRefs={rowHeaderRefs}
gridRefs={gridRefs}
onGridScroll={onGridScroll}
handleColumnResize={handleColumnResize}
mergedCells={mergedCells}
/>
))}
<div
ref={separatorRef}
className="flex w-2 cursor-col-resize items-center justify-center bg-border transition-colors hover:bg-primary/20"
style={{ userSelect: 'none' }}
>
<div className="h-4 w-0.5 rounded-full bg-muted-foreground/50" />
</div>
</div>
</div>
)
}
export default DualSpreadsheet

View File

@@ -0,0 +1,163 @@
import { CSSProperties, memo, useCallback } from 'react'
import { CellProps, ROW_HEIGHT } from '../types'
export const Cell = memo(
({
sheetType,
rowIndex,
colIndex,
columnWidth,
columnWidths,
isSelected,
isEditing,
displayValue,
rawValue,
isModified,
nextContentCol,
availablePx,
onCellClick,
onCellDoubleClick,
onCellValueChange,
stopEditing,
setActiveSheet,
activeCellRef,
style, // Добавляем style для react-window
mergedCellInfo,
}: CellProps) => {
// Если ячейка объединена, но не является основной, не рендерим её
if (mergedCellInfo?.isMerged && !mergedCellInfo.isMainCell) {
return null
}
const className = `border border-border ${isModified && !mergedCellInfo?.isMerged ? 'bg-yellow-200' : ''}`
// Отладка стилей для измененных ячеек
if (isModified) {
console.log(`🎨 Стили для измененной ячейки ${rowIndex},${colIndex}:`, {
className,
isModified,
isSelected,
displayValue,
})
}
// Для объединенных ячеек нужно рассчитать правильные размеры
let cellStyle: CSSProperties = {
...style,
overflow: 'visible',
padding: 0,
}
// Если это основная ячейка объединенной группы, расширяем её размеры
if (
mergedCellInfo?.isMainCell &&
mergedCellInfo.rowSpan &&
mergedCellInfo.colSpan
) {
const { rowSpan, colSpan } = mergedCellInfo
// Рассчитываем общую ширину для объединенной ячейки
let totalWidth = 0
for (let i = 0; i < colSpan; i++) {
const currentColIndex = colIndex + i
if (currentColIndex < columnWidths.length) {
totalWidth += columnWidths[currentColIndex]
}
}
// Рассчитываем общую высоту для объединенной ячейки
const totalHeight = ROW_HEIGHT * rowSpan
cellStyle = {
...cellStyle,
width: totalWidth,
height: totalHeight,
zIndex: 10, // Поднимаем выше обычных ячеек
backgroundColor: isSelected
? 'hsl(var(--accent))'
: isModified
? '#fef08a' /* тот же жёлтый оттенок, что и bg-yellow-200 */
: 'white',
}
}
// Используем обычное значение displayValue для всех ячеек, включая объединенные
const cellDisplayValue = displayValue
const handleClick = useCallback(() => {
setActiveSheet(sheetType)
onCellClick(rowIndex, colIndex)
}, [setActiveSheet, sheetType, onCellClick, rowIndex, colIndex])
const handleDoubleClick = useCallback(
() => onCellDoubleClick(rowIndex, colIndex),
[onCellDoubleClick, rowIndex, colIndex]
)
return (
<div
className={className}
style={cellStyle}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
>
{isEditing && isSelected ? (
<input
ref={activeCellRef}
type="text"
value={rawValue} // Во время редактирования показываем текущее редактируемое значение
onChange={e => onCellValueChange(e.target.value)}
style={{ width: columnWidth - 4 }}
onBlur={e => {
// Проверяем, не переходит ли фокус в formula bar
const relatedTarget = e.relatedTarget as HTMLElement
if (relatedTarget && relatedTarget.closest('.formula-bar')) {
return // Не завершаем редактирование
}
stopEditing(true)
}}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault()
stopEditing(true)
} else if (e.key === 'Escape') {
e.preventDefault()
stopEditing(false)
}
}}
className="absolute left-0 top-0 z-20 box-border h-full border-2 border-primary px-2 py-1"
/>
) : (
// Контейнер, который допускает перепрыгивание текста, если впереди есть свободное место
<div
className="relative flex h-full w-full items-center px-2"
style={{
overflow: availablePx > columnWidth ? 'visible' : 'hidden',
whiteSpace: 'nowrap',
...(isSelected
? { boxShadow: 'inset 0 0 0 2px hsl(var(--primary))' }
: {}),
}}
>
<div
className="absolute left-1 top-0 flex h-full items-center"
style={{
width:
availablePx > columnWidth
? availablePx - 8
: columnWidth - 16,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
pointerEvents: 'none', // чтобы клики шли в "родную" ячейку
zIndex: 1,
}}
>
{cellDisplayValue}
</div>
</div>
)}
</div>
)
}
)

View File

@@ -0,0 +1,96 @@
import { CellRendererProps } from '../types'
import { findMergedCellInfo } from '../utils'
import { Cell } from './Cell'
// Рендер-проп для ячеек грида
export const CellRenderer = ({
columnIndex,
rowIndex,
style,
data,
}: CellRendererProps) => {
const {
sheetType,
widths,
selectedCell,
activeSheet,
isEditing,
editingValue,
cachedData,
mergedCells,
handleCellClick,
handleCellDoubleClick,
handleCellChange,
stopEditing,
setActiveSheet,
activeCellInputRef,
} = data
const isCellSelected =
selectedCell?.row === rowIndex &&
selectedCell?.col === columnIndex &&
activeSheet === sheetType
const isCellEditing = isCellSelected && isEditing
const cellKey = `${rowIndex}-${columnIndex}`
const cellData = cachedData[cellKey]
const rawValue = isCellEditing ? editingValue : cellData?.rawValue || ''
const displayValue = isCellEditing
? '' // Не показывать вычисленное значение во время редактирования
: cellData?.displayValue || ''
// Отладка для измененных ячеек
if (cellData?.isModified) {
console.log(
`🔍 CellRenderer: Измененная ячейка ${rowIndex},${columnIndex}:`,
{
cellKey,
isModified: cellData.isModified,
displayValue,
rawValue,
}
)
}
// Получаем информацию об объединенных ячейках только для листа отчета
const mergedCellInfo =
sheetType === 'report' && mergedCells
? findMergedCellInfo(rowIndex, columnIndex, mergedCells)
: { isMerged: false, isMainCell: false }
const mergedCellProps = mergedCellInfo.isMerged
? {
isMerged: true,
isMainCell: mergedCellInfo.isMainCell,
rowSpan: mergedCellInfo.spans?.rowSpan,
colSpan: mergedCellInfo.spans?.colSpan,
// Убираем mergedValue - будем использовать актуальные данные из движка
}
: undefined
return (
<Cell
style={style}
sheetType={sheetType}
rowIndex={rowIndex}
colIndex={columnIndex}
columnWidth={widths[columnIndex]}
columnWidths={widths}
isSelected={isCellSelected}
isEditing={isCellEditing}
displayValue={displayValue}
rawValue={rawValue}
isModified={cellData?.isModified || false}
nextContentCol={cellData?.nextContentCol || 0}
availablePx={cellData?.availablePx || widths[columnIndex]}
onCellClick={handleCellClick}
onCellDoubleClick={handleCellDoubleClick}
onCellValueChange={handleCellChange}
stopEditing={stopEditing}
setActiveSheet={setActiveSheet}
activeCellRef={activeCellInputRef}
mergedCellInfo={mergedCellProps}
/>
)
}

View File

@@ -0,0 +1,191 @@
import { memo } from 'react'
import { FormulaBarProps } from '../types'
import { getColumnLabel } from '../utils'
export const FormulaBar = memo(
({
selectedCell,
formulaBarValue,
isCalculating,
isEditing,
onValueChange,
onFocus,
onBlur,
onKeyDown,
formulaBarRef,
isSaving = false,
isLoading = false,
hasUnsavedChanges = false,
lastSaveError = null,
onManualSave,
onClearError,
}: Omit<FormulaBarProps, 'sheetType' | 'activeSheet'>) => {
return (
<div className="formula-bar flex-shrink-0 border-b bg-background px-3 py-1">
<div className="flex items-center space-x-2">
<div className="flex min-w-[50px] items-center justify-center rounded-md border bg-muted px-2 py-1">
<span className="text-xs font-medium text-muted-foreground">
{selectedCell
? `${getColumnLabel(selectedCell.col)}${selectedCell.row + 1}`
: 'A1'}
</span>
</div>
<div className="flex flex-1 items-center space-x-2">
<div className="flex items-center text-muted-foreground">
<svg
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
</div>
<input
ref={formulaBarRef}
type="text"
className={`flex-1 rounded-md border border-input bg-background px-2 py-1 text-sm ring-offset-background transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${
isEditing ? 'ring-2 ring-ring ring-offset-2' : ''
} ${isCalculating ? 'animate-pulse' : ''}`}
placeholder="Введите формулу или значение..."
value={formulaBarValue}
onChange={e => onValueChange(e.target.value)}
onFocus={onFocus}
onBlur={e => {
// Проверяем, не переходит ли фокус в ячейку редактирования
const relatedTarget = e.relatedTarget as HTMLElement
if (
relatedTarget &&
relatedTarget.closest('input[type="text"]')
) {
return // Не завершаем редактирование
}
onBlur(e)
}}
onKeyDown={onKeyDown}
/>
{(isCalculating || isSaving || isLoading) && (
<div className="flex items-center text-muted-foreground">
<svg
className="h-3.5 w-3.5 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span className="ml-1 text-xs">
{isSaving
? 'Сохранение...'
: isLoading
? 'Загрузка...'
: 'Расчет...'}
</span>
</div>
)}
{/* Ошибка сохранения */}
{lastSaveError && (
<div className="flex items-center gap-1 rounded-md bg-destructive/10 px-2 py-1">
<svg
className="h-3 w-3 text-destructive"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
<span
className="text-xs text-destructive"
title={lastSaveError}
>
Ошибка сохранения
</span>
{onClearError && (
<button
onClick={onClearError}
className="ml-1 text-destructive/60 hover:text-destructive"
>
<svg
className="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
)}
</div>
)}
{/* Кнопка ручного сохранения */}
{onManualSave && (
<button
onClick={onManualSave}
disabled={isSaving}
className={`flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors ${
hasUnsavedChanges
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: lastSaveError
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
: 'bg-muted text-muted-foreground'
} disabled:opacity-50`}
title={
lastSaveError
? 'Повторить сохранение'
: hasUnsavedChanges
? 'Есть несохраненные изменения'
: 'Все изменения сохранены'
}
>
<svg
className="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
{lastSaveError
? 'Повторить'
: hasUnsavedChanges
? 'Сохранить'
: 'Сохранено'}
</button>
)}
</div>
</div>
</div>
)
}
)

View File

@@ -0,0 +1,196 @@
import { useEffect, useRef, useState } from 'react'
import { VariableSizeGrid } from 'react-window'
import { COL_COUNT, ROW_COUNT, SHEET_NAMES, SpreadsheetProps } from '../types'
import { getColumnLabel } from '../utils'
import { CellRenderer } from './CellRenderer'
// --- Spreadsheet Component ---
export const Spreadsheet = ({
sheetType,
columnWidths,
spreadsheetWidths,
getCachedCellData,
selectedCell,
activeSheet,
isEditing,
editingValue,
handleCellClick,
handleCellDoubleClick,
handleCellChange,
stopEditing,
setActiveSheet,
activeCellInputRef,
containerRefs,
handleKeyDown,
headerRefs,
rowHeaderRefs,
gridRefs,
onGridScroll,
handleColumnResize,
mergedCells,
}: SpreadsheetProps) => {
const widths = columnWidths
const cachedData = getCachedCellData(sheetType)
const itemData = {
sheetType,
widths,
selectedCell,
activeSheet,
isEditing,
editingValue,
cachedData,
mergedCells,
handleCellClick,
handleCellDoubleClick,
handleCellChange,
stopEditing,
setActiveSheet,
activeCellInputRef,
}
const parentRef = useRef<HTMLDivElement>(null)
const [gridSize, setGridSize] = useState({ width: 0, height: 0 })
useEffect(() => {
const parent = parentRef.current
if (!parent) return
const resizeObserver = new ResizeObserver(() => {
const { width, height } = parent.getBoundingClientRect()
const headerHeight =
parent.querySelector('.column-headers')?.clientHeight || 0
setGridSize({ width, height: height - headerHeight })
})
resizeObserver.observe(parent)
return () => resizeObserver.disconnect()
}, [])
return (
<div
ref={parentRef}
className="flex min-w-0 flex-col overflow-hidden"
style={{
flexBasis: `${spreadsheetWidths[SHEET_NAMES[sheetType]] * 100}%`,
transition: 'none',
}}
onClick={() => setActiveSheet(sheetType)}
>
<div
ref={el => (containerRefs.current[sheetType] = el)}
className="flex h-full flex-col overflow-hidden border bg-card shadow-sm"
onKeyDown={handleKeyDown}
tabIndex={0}
>
{/* Spreadsheet Body */}
<div className="flex flex-1">
{/* Row Headers */}
<div className="flex-shrink-0 bg-muted">
<div className="relative flex h-7 w-10 items-center justify-center border-b border-r border-border">
{/* Компактное обозначение листа */}
<div className="absolute inset-0 flex items-end justify-end p-0.5">
<span
className={`flex h-3 w-3 items-center justify-center rounded-sm text-[9px] font-medium text-white ${
sheetType === 'report'
? 'bg-primary/70'
: 'bg-emerald-500/70'
}`}
>
{SHEET_NAMES[sheetType]}
</span>
</div>
</div>
<div
ref={el => (rowHeaderRefs.current[sheetType] = el)}
className="overflow-y-hidden"
style={{ height: gridSize.height }}
>
<div>
{Array.from({ length: ROW_COUNT }).map((_, rowIndex) => {
const isActiveRow =
selectedCell?.row === rowIndex && activeSheet === sheetType
return (
<div
key={rowIndex}
className={`flex h-7 w-10 items-center justify-center border-b border-r border-border text-xs font-medium ${isActiveRow ? 'bg-primary/15 font-medium text-foreground' : 'text-muted-foreground'}`}
>
{rowIndex + 1}
</div>
)
})}
</div>
</div>
</div>
<div className="flex-1 overflow-hidden">
{/* Column Headers */}
<div
ref={el => (headerRefs.current[sheetType] = el)}
className="column-headers overflow-x-hidden"
>
<div className="flex">
{Array.from({ length: COL_COUNT }).map((_, i) => {
const isActiveColumn =
selectedCell?.col === i && activeSheet === sheetType
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault()
const startX = e.clientX
const startWidth = widths[i]
const handleMouseMove = (me: MouseEvent) => {
const newWidth = startWidth + (me.clientX - startX)
handleColumnResize(sheetType, i, newWidth)
}
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
return (
<div
key={i}
className={`relative flex h-7 flex-shrink-0 items-center justify-center border-b border-r border-border bg-muted text-center text-xs font-medium ${isActiveColumn ? 'bg-primary/15 font-medium text-foreground' : 'text-muted-foreground'}`}
style={{ width: widths[i] }}
>
{getColumnLabel(i)}
<div
className="resizer absolute right-0 top-0 h-full w-1 cursor-col-resize hover:bg-primary hover:bg-opacity-50"
onMouseDown={handleMouseDown}
/>
</div>
)
})}
</div>
</div>
{/* Grid */}
<div className="min-h-0 flex-1">
<VariableSizeGrid
ref={(el: VariableSizeGrid | null) =>
(gridRefs.current[sheetType] = el)
}
columnCount={COL_COUNT}
rowCount={ROW_COUNT}
columnWidth={(index: number) => widths[index]}
rowHeight={() => 28}
height={gridSize.height}
width={gridSize.width}
itemData={itemData}
overscanColumnCount={20}
overscanRowCount={10}
onScroll={e => onGridScroll(sheetType, e)}
>
{CellRenderer}
</VariableSizeGrid>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,4 @@
export { Cell } from './Cell'
export { CellRenderer } from './CellRenderer'
export { FormulaBar } from './FormulaBar'
export { Spreadsheet } from './Spreadsheet'

View File

@@ -0,0 +1,52 @@
import { useEffect, useRef } from 'react'
// --- Хук для перетаскивания разделителя ---
export const useDraggableDivider = (onDrag: (delta: number) => void) => {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = ref.current
if (!el) return
const handleMouseDown = (e: MouseEvent) => {
e.preventDefault()
document.body.style.cursor = 'col-resize'
const container = el.parentElement
if (!container) return
const bounds = container.getBoundingClientRect()
const handleMouseMove = (me: MouseEvent) => {
// Получаем актуальные границы контейнера
const currentBounds = container.getBoundingClientRect()
const newLeftWidth = me.clientX - currentBounds.left
const totalWidth = currentBounds.width
// Рассчитываем процент напрямую от позиции мыши
let newLeftPercentage = newLeftWidth / totalWidth
newLeftPercentage = Math.max(0.2, Math.min(0.8, newLeftPercentage))
// Вызываем callback с новыми процентами
onDrag(newLeftPercentage)
}
const handleMouseUp = () => {
document.body.style.cursor = 'auto'
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
el.addEventListener('mousedown', handleMouseDown)
return () => {
el.removeEventListener('mousedown', handleMouseDown)
}
}, [onDrag])
return ref
}

View File

@@ -0,0 +1 @@
export { default as DualSpreadsheet } from './DualSpreadsheet';

View File

@@ -0,0 +1,150 @@
import { MergedCell } from '@/type/template'
import { CSSProperties } from 'react'
import { VariableSizeGrid } from 'react-window'
export const ROW_COUNT = 90
export const COL_COUNT = 20
export const ROW_HEIGHT = 28
export const SHEET_NAMES = {
report: 'L',
calculations: 'R',
} as const
export type SheetType = keyof typeof SHEET_NAMES
// Добавляем массив типов листов для итерации
export const SHEET_TYPES: SheetType[] = ['report', 'calculations']
// Кэшированные данные ячеек для оптимизации
export interface CachedCellData {
displayValue: string
rawValue: string
isModified: boolean
nextContentCol: number // индекс ближайшей занятой колонки (COL_COUNT, если нет)
availablePx: number // суммарная ширина, куда можно расширяться
}
export interface DualSpreadsheetProps {
templateData?: Record<string, Record<string, any>>
mergedCells?: MergedCell[] // Добавляем поддержку объединенных ячеек
templateId?: string // Добавляем ID шаблона для сохранения
onEngineReady?: (engine: any) => void // Коллбэк, который отдаёт родителю методы движка
}
export interface CellProps {
sheetType: SheetType
rowIndex: number
colIndex: number
columnWidth: number
columnWidths: number[] // Добавляем массив всех ширин колонок
isSelected: boolean
isEditing: boolean
displayValue: string // Отображаемое значение (вычисленное)
rawValue: string // "Сырое" значение (формула)
isModified: boolean
nextContentCol: number // индекс ближайшей занятой колонки
availablePx: number // суммарная ширина, куда можно расширяться
onCellClick: (row: number, col: number) => void
onCellDoubleClick: (row: number, col: number) => void
onCellValueChange: (newValue: string) => void // Изменение значения во время редактирования
stopEditing: (save: boolean) => void
setActiveSheet: (type: SheetType) => void
activeCellRef: React.RefObject<HTMLInputElement>
style: CSSProperties // Добавляем style для react-window
mergedCellInfo?: {
// Информация об объединенных ячейках
isMerged: boolean
isMainCell: boolean
rowSpan?: number
colSpan?: number
}
}
export interface FormulaBarProps {
sheetType: SheetType
activeSheet: SheetType
selectedCell: { row: number; col: number } | null
formulaBarValue: string
isCalculating: boolean
isEditing: boolean
onValueChange: (value: string) => void
onFocus: () => void
onBlur: (e: React.FocusEvent<HTMLInputElement>) => void
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void
formulaBarRef: (el: HTMLInputElement | null) => void
// Обновляем props для сохранения
isSaving?: boolean
isLoading?: boolean
hasUnsavedChanges?: boolean
lastSaveError?: string | null
onManualSave?: () => void
onClearError?: () => void
}
export interface CellRendererData {
sheetType: SheetType
widths: number[]
selectedCell: { row: number; col: number } | null
activeSheet: SheetType
isEditing: boolean
editingValue: string
cachedData: Record<string, CachedCellData>
mergedCells?: MergedCell[]
handleCellClick: (row: number, col: number) => void
handleCellDoubleClick: (row: number, col: number) => void
handleCellChange: (newValue: string) => void
stopEditing: (save: boolean) => void
setActiveSheet: (type: SheetType) => void
activeCellInputRef: React.RefObject<HTMLInputElement>
}
export interface CellRendererProps {
columnIndex: number
rowIndex: number
style: CSSProperties
data: CellRendererData
}
export interface SpreadsheetProps {
sheetType: SheetType
columnWidths: number[]
spreadsheetWidths: { L: number; R: number }
getCachedCellData: (sheetType: SheetType) => Record<string, CachedCellData>
selectedCell: { row: number; col: number } | null
activeSheet: SheetType
isEditing: boolean
editingValue: string
handleCellClick: (row: number, col: number) => void
handleCellDoubleClick: (row: number, col: number) => void
handleCellChange: (newValue: string) => void
stopEditing: (save: boolean) => void
setActiveSheet: (type: SheetType) => void
activeCellInputRef: React.RefObject<HTMLInputElement>
containerRefs: React.MutableRefObject<
Record<SheetType, HTMLDivElement | null>
>
handleKeyDown: (e: React.KeyboardEvent) => void
headerRefs: React.MutableRefObject<Record<SheetType, HTMLDivElement | null>>
rowHeaderRefs: React.MutableRefObject<
Record<SheetType, HTMLDivElement | null>
>
gridRefs: React.MutableRefObject<Record<SheetType, VariableSizeGrid | null>>
onGridScroll: (
sheetType: SheetType,
params: { scrollLeft: number; scrollTop: number }
) => void
handleColumnResize: (
sheetType: SheetType,
colIndex: number,
newWidth: number
) => void
mergedCells?: MergedCell[] // Добавляем поддержку объединенных ячеек
}
export interface MergedCellInfo {
isMerged: boolean
isMainCell: boolean
mergedCell?: MergedCell
spans?: { rowSpan: number; colSpan: number }
}

View File

@@ -0,0 +1,51 @@
import { MergedCell } from '@/type/template'
import { MergedCellInfo } from './types'
// --- Helper Functions ---
export const getColumnLabel = (index: number) => String.fromCharCode(65 + index)
// Функция для преобразования адреса ячейки в координаты
export const cellAddressToCoords = (
address: string
): { row: number; col: number } => {
const match = address.match(/^([A-Z]+)(\d+)$/)
if (!match) return { row: 0, col: 0 }
const col = match[1].charCodeAt(0) - 65 // Простое преобразование для одной буквы
const row = parseInt(match[2]) - 1
return { row, col }
}
// Функция для проверки, является ли ячейка частью объединенной группы
export const findMergedCellInfo = (
row: number,
col: number,
mergedCells: MergedCell[]
): MergedCellInfo => {
for (const merged of mergedCells) {
const fromCoords = cellAddressToCoords(merged.from)
const toCoords = cellAddressToCoords(merged.to)
// Проверяем, попадает ли текущая ячейка в диапазон объединения
if (
row >= fromCoords.row &&
row <= toCoords.row &&
col >= fromCoords.col &&
col <= toCoords.col
) {
const isMainCell = row === fromCoords.row && col === fromCoords.col
const rowSpan = toCoords.row - fromCoords.row + 1
const colSpan = toCoords.col - fromCoords.col + 1
return {
isMerged: true,
isMainCell,
mergedCell: merged,
spans: { rowSpan, colSpan },
}
}
}
return { isMerged: false, isMainCell: false }
}

View File

@@ -0,0 +1,186 @@
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import { Calendar, Check, FileText, Settings } from 'lucide-react'
import { useState } from 'react'
interface MeasurementStandard {
id: string
name: string
shortName: string
type: string
registryNumber: string
range: string
accuracy: string
certificateNumber: string
validUntil: string
}
interface StandardsConfig {
registryId: string
selectedStandards: string[]
lastUpdated: string
}
interface StandardsDialogProps {
isOpen: boolean
onClose: () => void
standards: MeasurementStandard[]
config: StandardsConfig
onSave: (config: StandardsConfig) => void
registryNumber: string
}
export function StandardsDialog({
isOpen,
onClose,
standards,
config,
onSave,
registryNumber,
}: StandardsDialogProps) {
const [selectedStandards, setSelectedStandards] = useState<string[]>(
config.selectedStandards,
)
if (!isOpen) return null
const toggleStandard = (standardId: string) => {
setSelectedStandards((prev) => {
if (prev.includes(standardId)) {
return prev.filter((id) => id !== standardId)
} else if (prev.length < 7) {
return [...prev, standardId]
}
return prev
})
}
const handleSave = () => {
onSave({
...config,
selectedStandards,
lastUpdated: new Date().toISOString(),
})
onClose()
}
const isExpiringSoon = (validUntil: string) => {
const expiryDate = new Date(validUntil)
const now = new Date()
const monthsUntilExpiry =
(expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24 * 30)
return monthsUntilExpiry <= 3
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<Card className="max-h-[90vh] w-full max-w-4xl overflow-hidden">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-lg">
<Settings className="h-5 w-5" />
Измерительные эталоны
</CardTitle>
<p className="mt-1 text-sm text-muted-foreground">
ГРСИ: {registryNumber} Выбрано: {selectedStandards.length}/7
</p>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600 scrollbar-track-transparent grid max-h-96 grid-cols-1 gap-3 overflow-y-auto md:grid-cols-2">
{standards.map((standard) => {
const isSelected = selectedStandards.includes(standard.id)
const isExpiring = isExpiringSoon(standard.validUntil)
return (
<div
key={standard.id}
onClick={() => toggleStandard(standard.id)}
className={`cursor-pointer rounded-lg border p-3 transition-all duration-200 ${
isSelected
? 'border-primary bg-primary/10 shadow-sm'
: 'border-border hover:border-primary/50 hover:bg-primary/5'
}`}
>
<div className="flex items-start gap-3">
<div
className={`mt-1 flex h-5 w-5 items-center justify-center rounded-full border-2 transition-colors ${
isSelected
? 'border-primary bg-primary'
: 'border-border'
}`}
>
{isSelected && (
<Check className="h-3 w-3 text-primary-foreground" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<div className="text-sm font-medium leading-tight text-foreground">
{standard.shortName}
</div>
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">
{standard.name}
</div>
</div>
<div className="flex shrink-0 flex-col gap-1">
<Badge variant="secondary" className="text-xs">
{standard.accuracy}
</Badge>
{isExpiring && (
<Badge variant="destructive" className="text-xs">
<Calendar className="mr-1 h-3 w-3" />
Истекает
</Badge>
)}
</div>
</div>
<div className="mt-2 space-y-1">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<FileText className="h-3 w-3" />
{standard.registryNumber}
</div>
<div className="text-xs text-muted-foreground">
Диапазон: {standard.range}
</div>
<div className="text-xs text-muted-foreground">
Действует до:{' '}
{new Date(standard.validUntil).toLocaleDateString(
'ru-RU',
)}
</div>
</div>
</div>
</div>
</div>
)
})}
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
Максимум 7 эталонов. Выбрано: {selectedStandards.length}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose}>
Отмена
</Button>
<Button onClick={handleSave}>Сохранить</Button>
</div>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,246 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { CellTarget } from '@/type/template'
import { Calendar, Settings, Trash2 } from 'lucide-react'
interface StandardsConfig {
registryNumber: string // ГРСИ
sheet: string // Лист Excel
startCell: string // Первая ячейка (например "A10")
maxItems: number // Обычно 7
targetCells: CellTarget[]
}
interface StandardsEditorProps {
config: StandardsConfig
onChange: (config: StandardsConfig) => void
}
const CellTargetEditor: React.FC<{
targets: CellTarget[]
onChange: (targets: CellTarget[]) => void
}> = ({ targets, onChange }) => {
const handleAddTarget = () => {
onChange([...targets, { sheet: 'L', cell: '', displayName: '' }])
}
const handleUpdateTarget = (
index: number,
field: keyof CellTarget,
value: string
) => {
const updatedTargets = targets.map((target, i) =>
i === index ? { ...target, [field]: value } : target
)
onChange(updatedTargets)
}
const handleRemoveTarget = (index: number) => {
onChange(targets.filter((_, i) => i !== index))
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Целевые ячейки</label>
<Button variant="outline" size="sm" onClick={handleAddTarget}>
<Calendar className="mr-1 h-4 w-4" />
Добавить ячейку
</Button>
</div>
<div className="max-h-48 space-y-3 overflow-y-auto">
{targets.map((target, index) => (
<div key={index} className="rounded-lg border bg-gray-50 p-3">
<div className="mb-2 grid grid-cols-3 gap-2">
<div className="space-y-1">
<label className="text-xs font-medium text-gray-600">
Лист
</label>
<Select
value={target.sheet}
onValueChange={value =>
handleUpdateTarget(index, 'sheet', value)
}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="L">L (Левый)</SelectItem>
<SelectItem value="R">R (Правый)</SelectItem>
<SelectItem value="Report">Report</SelectItem>
<SelectItem value="Calculations">Calculations</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-gray-600">
Ячейка
</label>
<Input
placeholder="A1, B5..."
value={target.cell}
onChange={e =>
handleUpdateTarget(index, 'cell', e.target.value)
}
className="h-8"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-gray-600">
Название
</label>
<Input
placeholder="Описание"
value={target.displayName || ''}
onChange={e =>
handleUpdateTarget(index, 'displayName', e.target.value)
}
className="h-8"
/>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => handleRemoveTarget(index)}
className="h-6 text-xs text-red-600 hover:text-red-700"
>
<Trash2 className="mr-1 h-3 w-3" />
Удалить
</Button>
</div>
))}
{targets.length === 0 && (
<p className="py-4 text-center text-sm text-gray-500">
Добавьте ячейки для связи с элементом
</p>
)}
</div>
</div>
)
}
export const StandardsEditor: React.FC<StandardsEditorProps> = ({
config,
onChange,
}) => {
// Обеспечиваем значения по умолчанию
const safeConfig = {
registryNumber: config.registryNumber || '',
sheet: config.sheet || 'Report',
startCell: config.startCell || 'A1',
maxItems: config.maxItems || 7,
targetCells: config.targetCells || [],
}
const updateConfig = (updates: Partial<StandardsConfig>) => {
onChange({ ...safeConfig, ...updates })
}
return (
<div className="space-y-6">
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium">
<Settings className="h-4 w-4" />
Настройки эталонов
</label>
<p className="text-xs text-gray-500">
Настройте параметры для выбора измерительных эталонов
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Номер ГРСИ</label>
<Input
placeholder="Введите номер ГРСИ"
value={safeConfig.registryNumber}
onChange={e => updateConfig({ registryNumber: e.target.value })}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Максимум эталонов</label>
<Select
value={safeConfig.maxItems.toString()}
onValueChange={value => updateConfig({ maxItems: parseInt(value) })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="3">3 эталона</SelectItem>
<SelectItem value="5">5 эталонов</SelectItem>
<SelectItem value="7">7 эталонов</SelectItem>
<SelectItem value="10">10 эталонов</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Лист Excel</label>
<Select
value={safeConfig.sheet}
onValueChange={value => updateConfig({ sheet: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="L">L (Левый)</SelectItem>
<SelectItem value="R">R (Правый)</SelectItem>
<SelectItem value="Report">Report</SelectItem>
<SelectItem value="Calculations">Calculations</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Начальная ячейка</label>
<Input
placeholder="A1, B5..."
value={safeConfig.startCell}
onChange={e => updateConfig({ startCell: e.target.value })}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Целевые ячейки</label>
<p className="text-xs text-gray-500">
Ячейки для записи выбранных эталонов (автоматически генерируются)
</p>
<CellTargetEditor
targets={safeConfig.targetCells}
onChange={targets => updateConfig({ targetCells: targets })}
/>
</div>
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3">
<div className="flex items-start gap-2">
<Calendar className="mt-0.5 h-4 w-4 text-blue-600" />
<div className="text-sm">
<div className="font-medium text-blue-800">
Автоматическое заполнение
</div>
<div className="mt-1 text-xs text-blue-600">
При выборе эталонов данные будут записаны в указанные ячейки. Если
выбрано меньше эталонов, чем максимальное количество, оставшиеся
ячейки останутся пустыми.
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,64 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Calendar, Settings } from "lucide-react";
interface StandardsConfig {
registryNumber: string;
sheet: string;
startCell: string;
maxItems: number;
targetCells: any[];
}
interface StandardsPreviewProps {
config: StandardsConfig;
}
export const StandardsPreview: React.FC<StandardsPreviewProps> = ({ config }) => {
return (
<div className="space-y-3">
<div className="text-sm font-medium text-gray-700">Предпросмотр</div>
<div className="p-4 border border-gray-200 rounded-lg bg-white">
<div className="space-y-3">
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-gray-500" />
<span className="text-sm font-medium">Измерительные эталоны</span>
</div>
<div className="space-y-2">
<div className="text-xs text-gray-500">
ГРСИ: {config.registryNumber || "Не указан"}
</div>
<div className="text-xs text-gray-500">
Максимум: {config.maxItems} эталонов
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" disabled className="text-xs">
<Calendar className="h-3 w-3 mr-1" />
Выбрать эталоны
</Button>
<Badge variant="secondary" className="text-xs">
0/{config.maxItems} выбрано
</Badge>
</div>
{config.targetCells && config.targetCells.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{config.targetCells.map((target, index) => (
<span
key={index}
className="inline-block px-1.5 py-0.5 bg-blue-100 text-blue-700 text-xs rounded"
title={target.displayName}
>
{target.sheet}!{target.cell}
</span>
))}
</div>
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,228 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Calendar, Check, FileText, Settings, X } from "lucide-react";
import { useState } from "react";
interface MeasurementStandard {
id: string;
name: string;
shortName: string;
type: string;
registryNumber: string;
range: string;
accuracy: string;
certificateNumber: string;
validUntil: string;
}
interface StandardsSelectorProps {
isOpen: boolean;
onClose: () => void;
value: string[];
onChange: (value: string[]) => void;
registryNumber?: string;
}
// Моковые данные для эталонов
const mockStandards: MeasurementStandard[] = [
{
id: "1",
name: "Эталон массы 1 кг",
shortName: "ЭМ-1кг",
type: "Масса",
registryNumber: "ГРСИ 12345-01",
range: "0.5-2 кг",
accuracy: "±0.001 г",
certificateNumber: "СИ-2024-001",
validUntil: "2025-12-31",
},
{
id: "2",
name: "Эталон длины 1 м",
shortName: "ЭД-1м",
type: "Длина",
registryNumber: "ГРСИ 12345-02",
range: "0.5-2 м",
accuracy: "±0.001 мм",
certificateNumber: "СИ-2024-002",
validUntil: "2025-06-30",
},
{
id: "3",
name: "Эталон температуры 20°C",
shortName: "ЭТ-20°C",
type: "Температура",
registryNumber: "ГРСИ 12345-03",
range: "15-25°C",
accuracy: "±0.01°C",
certificateNumber: "СИ-2024-003",
validUntil: "2025-03-15",
},
{
id: "4",
name: "Эталон давления 1 Па",
shortName: "ЭД-1Па",
type: "Давление",
registryNumber: "ГРСИ 12345-04",
range: "0.5-2 Па",
accuracy: "±0.001 Па",
certificateNumber: "СИ-2024-004",
validUntil: "2025-09-20",
},
{
id: "5",
name: "Эталон времени 1 с",
shortName: "ЭВ-1с",
type: "Время",
registryNumber: "ГРСИ 12345-05",
range: "0.5-2 с",
accuracy: "±0.000001 с",
certificateNumber: "СИ-2024-005",
validUntil: "2025-12-01",
},
];
export function StandardsSelector({
isOpen,
onClose,
value = [],
onChange,
registryNumber = "ГРСИ 12345"
}: StandardsSelectorProps) {
const [selectedStandards, setSelectedStandards] = useState<string[]>(value);
const toggleStandard = (standardId: string) => {
setSelectedStandards(prev => {
if (prev.includes(standardId)) {
return prev.filter(id => id !== standardId);
} else if (prev.length < 7) {
return [...prev, standardId];
}
return prev;
});
};
const handleSave = () => {
onChange(selectedStandards);
onClose();
};
const handleClose = () => {
setSelectedStandards(value); // Сбрасываем к исходному значению
onClose();
};
const isExpiringSoon = (validUntil: string) => {
const expiryDate = new Date(validUntil);
const now = new Date();
const monthsUntilExpiry = (expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24 * 30);
return monthsUntilExpiry <= 3;
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden">
<DialogHeader>
<div className="flex items-center justify-between">
<div>
<DialogTitle className="text-lg flex items-center gap-2">
<Settings className="w-5 h-5" />
Измерительные эталоны
</DialogTitle>
<p className="text-sm text-muted-foreground mt-1">
ГРСИ: {registryNumber} Выбрано: {selectedStandards.length}/7
</p>
</div>
<Button variant="ghost" size="sm" onClick={handleClose}>
<X className="w-4 h-4" />
</Button>
</div>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 max-h-96 overflow-y-auto">
{mockStandards.map((standard) => {
const isSelected = selectedStandards.includes(standard.id);
const isExpiring = isExpiringSoon(standard.validUntil);
return (
<div
key={standard.id}
onClick={() => toggleStandard(standard.id)}
className={`p-3 rounded-lg border cursor-pointer transition-all duration-200 ${
isSelected
? "border-primary bg-primary/10 shadow-sm"
: "border-border hover:border-primary/50 hover:bg-primary/5"
}`}
>
<div className="flex items-start gap-3">
<div className={`mt-1 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors ${
isSelected
? "border-primary bg-primary"
: "border-border"
}`}>
{isSelected && <Check className="w-3 h-3 text-primary-foreground" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<div className="font-medium text-sm text-foreground leading-tight">
{standard.shortName}
</div>
<div className="text-xs text-muted-foreground mt-1 line-clamp-2">
{standard.name}
</div>
</div>
<div className="flex flex-col gap-1 shrink-0">
<Badge variant="secondary" className="text-xs">
{standard.accuracy}
</Badge>
{isExpiring && (
<Badge variant="destructive" className="text-xs">
<Calendar className="w-3 h-3 mr-1" />
Истекает
</Badge>
)}
</div>
</div>
<div className="mt-2 space-y-1">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<FileText className="w-3 h-3" />
{standard.registryNumber}
</div>
<div className="text-xs text-muted-foreground">
Диапазон: {standard.range}
</div>
<div className="text-xs text-muted-foreground">
Действует до: {new Date(standard.validUntil).toLocaleDateString('ru-RU')}
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
<div className="flex items-center justify-between pt-4 border-t">
<div className="text-sm text-muted-foreground">
Максимум 7 эталонов. Выбрано: {selectedStandards.length}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleClose}>
Отмена
</Button>
<Button onClick={handleSave}>
Сохранить
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,57 @@
import { ElementDefinition } from '@/lib/element-registry'
import { parseCellAddress } from '@/lib/utils'
import { CellTarget } from '@/type/template'
import { Weight } from 'lucide-react'
import { StandardsEditor } from './StandardsEditor'
import { StandardsPreview } from './StandardsPreview'
interface StandardsConfig {
registryNumber: string // ГРСИ
sheet: string // Лист Excel
startCell: string // Первая ячейка (например "A10")
maxItems: number // Обычно 7
targetCells: CellTarget[]
}
export const standardsDefinition: ElementDefinition<StandardsConfig, string[]> =
{
type: 'standards',
label: 'Измерительные эталоны',
icon: <Weight className="h-4 w-4" />,
defaultConfig: {
registryNumber: '',
sheet: 'Report',
startCell: 'A1',
maxItems: 7,
targetCells: [],
},
Editor: StandardsEditor,
Preview: StandardsPreview,
Render: () => {
// Здесь нужно создать обертку для StandardsDialog
// Пока возвращаем null, так как StandardsDialog требует дополнительные пропсы
return null
},
mapToCells: cfg => {
const res: CellTarget[] = []
const parsed = parseCellAddress(cfg.startCell)
if (!parsed) {
// Если не удалось распарсить, возвращаем пустой массив
return res
}
const { column, row } = parsed
// Генерируем ячейки вертикально (A1, A2, A3...)
for (let i = 0; i < cfg.maxItems; i++) {
res.push({
sheet: cfg.sheet,
cell: `${column}${row + i}`,
displayName: `Эталон ${i + 1}`,
})
}
return res
},
}

View File

@@ -0,0 +1,970 @@
import {
getAvailableElementTypes,
getElementDefinition,
} from '@/lib/element-registry'
import {
autoArrangeElements,
findFreePosition,
generateDefaultLayout,
} from '@/lib/utils'
import {
CellTarget,
ElementType,
FormLayoutSettings,
Template,
TemplateElement,
} from '@/type/template'
import {
Calendar,
Droplets,
Edit3,
Gauge,
Plus,
Radio,
Thermometer,
Trash2,
Zap,
} from 'lucide-react'
import React, { useState } from 'react'
import { Button } from '../ui/button'
import { Checkbox } from '../ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '../ui/dialog'
import { Input } from '../ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../ui/select'
import { Textarea } from '../ui/textarea'
import { VisualLayoutEditor } from './VisualLayoutEditor'
interface ElementFormProps {
formData: Partial<TemplateElement>
setFormData: React.Dispatch<React.SetStateAction<Partial<TemplateElement>>>
}
const CellTargetEditor: React.FC<{
targets: CellTarget[]
onChange: (targets: CellTarget[]) => void
}> = ({ targets, onChange }) => {
const handleAddTarget = () => {
onChange([...targets, { sheet: 'L', cell: '' }])
}
const handleUpdateTarget = (
index: number,
field: keyof CellTarget,
value: string
) => {
const updatedTargets = targets.map((target, i) =>
i === index ? { ...target, [field]: value } : target
)
onChange(updatedTargets)
}
const handleRemoveTarget = (index: number) => {
onChange(targets.filter((_, i) => i !== index))
}
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Целевые ячейки</label>
<Button
variant="outline"
size="sm"
onClick={handleAddTarget}
className="h-7 px-2 text-xs"
>
<Plus className="mr-1 h-3 w-3" />
Добавить
</Button>
</div>
<div className="max-h-40 space-y-2 overflow-y-auto">
{targets.map((target, index) => (
<div key={index} className="rounded border bg-gray-50/80 p-2">
<div className="mb-1.5 grid grid-cols-2 gap-2">
<div>
<label className="text-xs font-medium text-gray-600">
Лист
</label>
<Select
value={target.sheet}
onValueChange={value =>
handleUpdateTarget(index, 'sheet', value)
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="L">L (Левый)</SelectItem>
<SelectItem value="R">R (Правый)</SelectItem>
<SelectItem value="Report">Report</SelectItem>
<SelectItem value="Calculations">Calculations</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs font-medium text-gray-600">
Ячейка
</label>
<Input
placeholder="A1, B5..."
value={target.cell}
onChange={e =>
handleUpdateTarget(index, 'cell', e.target.value)
}
className="h-7 text-xs"
/>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveTarget(index)}
className="h-5 px-1 text-xs text-red-600 hover:bg-red-50 hover:text-red-700"
>
<Trash2 className="mr-1 h-2.5 w-2.5" />
Удалить
</Button>
</div>
))}
{targets.length === 0 && (
<p className="py-3 text-center text-xs text-gray-500">
Добавьте ячейки для связи с элементом
</p>
)}
</div>
</div>
)
}
// Live Preview компонент
const ElementPreview: React.FC<{ element: Partial<TemplateElement> }> = ({
element,
}) => {
const elementDefinition = element.type
? getElementDefinition(element.type)
: null
if (elementDefinition && elementDefinition.Preview) {
return <elementDefinition.Preview config={element as any} />
}
// Fallback для старых элементов
const renderPreviewInput = () => {
switch (element.type) {
case 'text':
return (
<Input
placeholder={element.placeholder || 'Введите текст'}
disabled
className="w-full"
/>
)
case 'textarea':
return (
<Textarea
placeholder={element.placeholder || 'Введите текст'}
disabled
rows={3}
className="w-full resize-none"
/>
)
case 'number':
return (
<Input
type="number"
placeholder={element.placeholder || '0'}
disabled
className="w-full"
/>
)
case 'date':
return <Input type="date" disabled className="w-full" />
case 'select':
return (
<Select disabled>
<SelectTrigger className="w-full">
<span className="text-gray-500">
{element.placeholder || 'Выберите значение'}
</span>
</SelectTrigger>
</Select>
)
case 'radio':
return (
<div className="space-y-2">
{element.options?.length ? (
element.options.map((option, index) => (
<div key={index} className="flex items-center space-x-2">
<div className="h-4 w-4 rounded-full border border-gray-300" />
<span className="text-sm">{option.label}</span>
</div>
))
) : (
<div className="flex items-center space-x-2">
<div className="h-4 w-4 rounded-full border border-gray-300" />
<span className="text-sm text-gray-500">Вариант 1</span>
</div>
)}
</div>
)
case 'checkbox':
return (
<div className="flex items-center space-x-2">
<div className="h-4 w-4 rounded border border-gray-300" />
<span className="text-sm">
{element.placeholder || element.label || 'Отметить'}
</span>
</div>
)
case 'standards':
return (
<div className="space-y-2">
<div className="flex items-center justify-between rounded-md border border-input bg-background p-3">
<span className="text-sm text-muted-foreground">
{element.placeholder || 'Выберите эталоны'}
</span>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 opacity-50" />
<span className="text-xs text-muted-foreground">Выбрать</span>
</div>
</div>
<div className="text-xs text-muted-foreground">
Выбрано эталонов: 0
</div>
</div>
)
case 'calibration-conditions':
return (
<div className="space-y-2">
<div className="flex items-center gap-3 rounded-lg border bg-muted/50 px-3 py-2">
<div className="flex items-center gap-3 text-xs">
<div className="flex items-center gap-1">
<Thermometer className="h-3 w-3 text-muted-foreground" />
<span className="text-foreground">20°C</span>
</div>
<div className="flex items-center gap-1">
<Droplets className="h-3 w-3 text-muted-foreground" />
<span className="text-foreground">65%</span>
</div>
<div className="flex items-center gap-1">
<Gauge className="h-3 w-3 text-muted-foreground" />
<span className="text-foreground">101.3кПа</span>
</div>
<div className="flex items-center gap-1">
<Zap className="h-3 w-3 text-muted-foreground" />
<span className="text-foreground">220В</span>
</div>
<div className="flex items-center gap-1">
<Radio className="h-3 w-3 text-muted-foreground" />
<span className="text-foreground">50Гц</span>
</div>
</div>
<Button variant="ghost" size="icon" className="h-6 w-6 p-0">
<Edit3 className="h-3 w-3" />
</Button>
</div>
</div>
)
case 'button-group':
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-1.5">
{element.options?.length ? (
element.options.map((option, index) => (
<button
key={index}
className="rounded-md border border-border bg-background px-3 py-1.5 text-sm font-medium text-foreground transition-all duration-200 hover:border-primary/30 hover:bg-primary/5"
>
{option.label || `Кнопка ${index + 1}`}
</button>
))
) : (
<div className="rounded-md border border-dashed bg-muted/30 px-3 py-1.5 text-sm text-muted-foreground">
{element.placeholder || 'Добавьте кнопки'}
</div>
)}
</div>
</div>
)
default:
return (
<div className="rounded border border-gray-200 bg-gray-50 p-3 text-center text-sm text-gray-500">
Выберите тип элемента
</div>
)
}
}
return (
<div className="space-y-3">
<div className="text-sm font-medium text-gray-700">Предпросмотр</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
{element.label && (
<label className="mb-2 block text-sm font-medium text-gray-700">
{element.label}
{element.required && <span className="ml-1 text-red-500">*</span>}
</label>
)}
{renderPreviewInput()}
{element.targetCells && element.targetCells.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{element.targetCells.map((target, index) => (
<span
key={index}
className="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700"
>
{target.sheet}!{target.cell}
</span>
))}
</div>
)}
</div>
</div>
)
}
const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
const elementDefinition = formData.type
? getElementDefinition(formData.type)
: null
const handleAddOption = () => {
setFormData(prev => ({
...prev,
options: [...(prev.options || []), { value: '', label: '' }],
}))
}
const handleUpdateOption = (
index: number,
field: 'value' | 'label',
value: string
) => {
setFormData(prev => ({
...prev,
options: prev.options?.map((option, i) =>
i === index ? { ...option, [field]: value } : option
),
}))
}
const handleRemoveOption = (index: number) => {
setFormData(prev => ({
...prev,
options: prev.options?.filter((_, i) => i !== index),
}))
}
const needsOptions =
formData.type === 'select' ||
formData.type === 'radio' ||
formData.type === 'button-group'
// Если есть определение элемента в реестре, используем его редактор
if (elementDefinition && elementDefinition.Editor) {
return (
<div className="grid grid-cols-2 gap-6">
{/* Форма настроек */}
<div className="space-y-4">
<div className="space-y-1.5">
<label className="text-sm font-medium">Тип элемента</label>
<Select
value={formData.type}
onValueChange={value => {
const newType = value as ElementType
console.log('Element type changed to:', newType)
setFormData(prev => {
const updates: Partial<TemplateElement> = { type: newType }
// Если выбран тип "standards" или "calibration-conditions", устанавливаем значения по умолчанию
if (
newType === 'standards' ||
newType === 'calibration-conditions'
) {
console.log(
`${newType} type selected, getting element definition`
)
const elementDefinition = getElementDefinition(newType)
console.log('Element definition:', elementDefinition)
if (elementDefinition && elementDefinition.defaultConfig) {
const defaultConfig =
elementDefinition.defaultConfig as any
console.log('Default config:', defaultConfig)
updates.targetCells =
elementDefinition.mapToCells?.(defaultConfig) || []
console.log('Generated targetCells:', updates.targetCells)
}
}
const newFormData = { ...prev, ...updates }
console.log('New form data:', newFormData)
return newFormData
})
}}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{getAvailableElementTypes().map(({ type, label, icon }) => (
<SelectItem key={type} value={type}>
<div className="flex items-center gap-2">
{icon}
{label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-sm font-medium">Подпись</label>
<Input
placeholder="Название поля"
value={formData.label}
onChange={e =>
setFormData(prev => ({ ...prev, label: e.target.value }))
}
className="h-9"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">Подсказка</label>
<Input
placeholder="Введите значение..."
value={formData.placeholder}
onChange={e =>
setFormData(prev => ({
...prev,
placeholder: e.target.value,
}))
}
className="h-9"
/>
</div>
</div>
<div className="flex items-center space-x-2 py-1">
<Checkbox
id="required"
checked={formData.required}
onCheckedChange={checked =>
setFormData(prev => ({ ...prev, required: !!checked }))
}
/>
<label htmlFor="required" className="text-sm font-medium">
Обязательное поле
</label>
</div>
<CellTargetEditor
targets={formData.targetCells || []}
onChange={targets =>
setFormData(prev => ({ ...prev, targetCells: targets }))
}
/>
{/* Специфичный редактор элемента */}
<elementDefinition.Editor
config={formData as any}
onChange={newConfig =>
setFormData(prev => ({ ...prev, ...newConfig }))
}
/>
</div>
{/* Live Preview */}
<div className="sticky top-0 flex min-h-[50vh] items-center">
<ElementPreview element={formData} />
</div>
</div>
)
}
// Fallback для старых элементов
return (
<div className="grid grid-cols-2 gap-6">
{/* Форма настроек */}
<div className="space-y-4">
<div className="space-y-1.5">
<label className="text-sm font-medium">Тип элемента</label>
<Select
value={formData.type}
onValueChange={value => {
const newType = value as ElementType
console.log('Element type changed to (fallback):', newType)
setFormData(prev => {
const updates: Partial<TemplateElement> = { type: newType }
// Если выбран тип "standards" или "calibration-conditions", устанавливаем значения по умолчанию
if (
newType === 'standards' ||
newType === 'calibration-conditions'
) {
console.log(
`${newType} type selected (fallback), getting element definition`
)
const elementDefinition = getElementDefinition(newType)
console.log(
'Element definition (fallback):',
elementDefinition
)
if (elementDefinition && elementDefinition.defaultConfig) {
const defaultConfig = elementDefinition.defaultConfig as any
console.log('Default config (fallback):', defaultConfig)
updates.targetCells =
elementDefinition.mapToCells?.(defaultConfig) || []
console.log(
'Generated targetCells (fallback):',
updates.targetCells
)
}
}
const newFormData = { ...prev, ...updates }
console.log('New form data (fallback):', newFormData)
return newFormData
})
}}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{getAvailableElementTypes().map(({ type, label, icon }) => (
<SelectItem key={type} value={type}>
<div className="flex items-center gap-2">
{icon}
{label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">Подпись</label>
<Input
placeholder="Название поля"
value={formData.label}
onChange={e =>
setFormData(prev => ({ ...prev, label: e.target.value }))
}
className="h-9"
/>
</div>
<CellTargetEditor
targets={formData.targetCells || []}
onChange={targets =>
setFormData(prev => ({ ...prev, targetCells: targets }))
}
/>
{needsOptions && (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Варианты</label>
<Button
variant="outline"
size="sm"
onClick={handleAddOption}
className="h-7 px-2 text-xs"
>
<Plus className="mr-1 h-3 w-3" />
Добавить
</Button>
</div>
<div className="max-h-28 space-y-1.5 overflow-y-auto">
{formData.options?.map((option, index) => (
<div key={index} className="flex gap-1.5">
<Input
placeholder="Значение"
value={option.value}
onChange={e =>
handleUpdateOption(index, 'value', e.target.value)
}
className="h-7 text-xs"
/>
<Input
placeholder="Подпись"
value={option.label}
onChange={e =>
handleUpdateOption(index, 'label', e.target.value)
}
className="h-7 text-xs"
/>
<Button
variant="ghost"
size="icon"
onClick={() => handleRemoveOption(index)}
className="h-7 w-7 text-red-600 hover:bg-red-50 hover:text-red-700"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
)}
</div>
{/* Live Preview */}
<div className="sticky top-0 flex min-h-[50vh] items-center">
<ElementPreview element={formData} />
</div>
</div>
)
}
interface CanvasSettingsProps {
settings: FormLayoutSettings
onChange: (settings: FormLayoutSettings) => void
}
const CanvasSettings: React.FC<CanvasSettingsProps> = ({
settings,
onChange,
}) => {
return (
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Размер сетки (px)</label>
<Select
value={settings.gridSize.toString()}
onValueChange={value =>
onChange({ ...settings, gridSize: parseInt(value) })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10px</SelectItem>
<SelectItem value="20">20px</SelectItem>
<SelectItem value="25">25px</SelectItem>
<SelectItem value="50">50px</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showGrid"
checked={settings.showGrid}
onCheckedChange={checked =>
onChange({ ...settings, showGrid: !!checked })
}
/>
<label htmlFor="showGrid" className="text-sm font-medium">
Показывать сетку
</label>
</div>
</div>
)
}
interface ElementConstructorProps {
template: Template
onElementAdd: (element: TemplateElement) => void
// Принимаем частичное обновление элемента, чтобы корректно обновлять только изменённые поля
onElementUpdate: (
elementId: string,
updates: Partial<TemplateElement>
) => void
onElementDelete: (elementId: string) => void
disabled?: boolean
}
export const ElementConstructor: React.FC<ElementConstructorProps> = ({
template,
onElementAdd,
onElementUpdate,
onElementDelete,
disabled = false,
}) => {
const elements = template.elements
const layoutSettings = template.layoutSettings || {
gridSize: 20,
showGrid: true,
}
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [isSettingsOpen, setIsSettingsOpen] = useState(false)
const [editingElement, setEditingElement] = useState<TemplateElement | null>(
null
)
const [formData, setFormData] = useState<Partial<TemplateElement>>({
type: 'text',
name: '',
label: '',
targetCells: [],
placeholder: '',
required: false,
options: [],
})
const resetForm = () => {
setFormData({
type: 'text',
label: '',
targetCells: [],
placeholder: '',
required: false,
options: [],
})
}
const handleAddElement = () => {
console.log('handleAddElement called with formData:', formData)
if (!formData.label) {
console.log('Validation failed: missing label')
return
}
// Для элементов типа "standards", "calibration-conditions" и "button-group" не требуем targetCells, так как они генерируются автоматически или не нужны
if (
formData.type !== 'standards' &&
formData.type !== 'calibration-conditions' &&
formData.type !== 'button-group' &&
!formData.targetCells?.length
) {
console.log(
'Validation failed: missing targetCells for element that requires manual targetCells'
)
return
}
// Генерируем layout для нового элемента
const existingLayouts = elements.map(e => e.layout).filter(Boolean)
const defaultLayout = generateDefaultLayout(elements.length)
const layout = findFreePosition(
defaultLayout,
existingLayouts as any[],
layoutSettings.gridSize
)
// Специальная обработка для элементов с автоматической генерацией targetCells
let targetCells = formData.targetCells || []
if (
formData.type === 'standards' ||
formData.type === 'calibration-conditions'
) {
// Генерируем targetCells автоматически, если их нет
if (!targetCells.length) {
const elementDefinition = getElementDefinition(formData.type)
if (elementDefinition && elementDefinition.defaultConfig) {
const defaultConfig = elementDefinition.defaultConfig as any
targetCells = elementDefinition.mapToCells?.(defaultConfig) || []
}
}
}
// Для button-group не нужны targetCells, так как это UI элемент
if (formData.type === 'button-group') {
targetCells = []
}
const newElement: TemplateElement = {
id: Date.now().toString(),
type: formData.type as ElementType,
name: formData.label?.toLowerCase().replace(/\s+/g, '_') || '',
label: formData.label,
targetCells,
placeholder: formData.placeholder,
required: formData.required,
options: formData.options,
order: elements.length,
layout,
}
onElementAdd(newElement)
resetForm()
setIsAddDialogOpen(false)
}
const handleEditElement = (element: TemplateElement) => {
setEditingElement(element)
setFormData(element)
}
const handleUpdateElement = () => {
if (!editingElement || !formData.label) return
// Для элементов типа "standards", "calibration-conditions" и "button-group" не требуем targetCells, так как они генерируются автоматически или не нужны
if (
formData.type !== 'standards' &&
formData.type !== 'calibration-conditions' &&
formData.type !== 'button-group' &&
!formData.targetCells?.length
)
return
const updatedData = {
...formData,
name:
formData.label?.toLowerCase().replace(/\s+/g, '_') ||
formData.name ||
'',
}
onElementUpdate(editingElement.id, updatedData)
setEditingElement(null)
resetForm()
}
const handleCancelEdit = () => {
setEditingElement(null)
resetForm()
}
const handleAutoArrange = () => {
const arrangedElements = autoArrangeElements(
elements,
layoutSettings.gridSize
)
arrangedElements.forEach((element, index) => {
onElementUpdate(element.id, {
...element,
layout: element.layout,
order: index,
})
})
}
const handleDeleteAndSelect = (elementId: string) => {
onElementDelete(elementId)
setEditingElement(null)
resetForm()
}
// const reordered = (reorderedElements: TemplateElement[]) => {
// onElementsReorder(reorderedElements);
// onLayoutSettingsChange({ ...layoutSettings, showGrid: true });
// };
return (
<div className="flex h-full w-full">
{/* Main Content */}
<div className="flex flex-1 flex-col bg-gray-50">
<div className="flex items-center justify-between border-b bg-white p-2">
<div className="px-2">
<h3 className="text-sm font-medium">Холст</h3>
<p className="text-xs text-gray-500">
Перетаскивайте элементы для настройки их расположения
</p>
</div>
<div className="flex items-center gap-2">
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" disabled={disabled}>
<Plus className="mr-2 h-4 w-4" />
Добавить элемент
</Button>
</DialogTrigger>
<DialogContent className="max-h-[80vh] max-w-4xl overflow-y-auto">
<DialogHeader>
<DialogTitle>Добавить новый элемент</DialogTitle>
<DialogDescription>
Настройте параметры нового элемента формы.
</DialogDescription>
</DialogHeader>
<ElementForm formData={formData} setFormData={setFormData} />
<div className="flex justify-end gap-2 pt-4">
<Button
onClick={() => setIsAddDialogOpen(false)}
variant="outline"
>
Отмена
</Button>
<Button
onClick={handleAddElement}
disabled={
disabled ||
!formData.label ||
(formData.type !== 'standards' &&
formData.type !== 'calibration-conditions' &&
formData.type !== 'button-group' &&
!formData.targetCells?.length)
}
>
<Plus className="mr-2 h-4 w-4" />
Добавить элемент
</Button>
</div>
</DialogContent>
</Dialog>
{editingElement && (
<Dialog
open={!!editingElement}
onOpenChange={() => handleCancelEdit()}
>
<DialogContent className="max-h-[80vh] max-w-4xl overflow-y-auto">
<DialogHeader>
<DialogTitle>Редактировать элемент</DialogTitle>
<DialogDescription>
Измените параметры элемента формы.
</DialogDescription>
</DialogHeader>
<ElementForm formData={formData} setFormData={setFormData} />
<div className="flex justify-end gap-2 pt-4">
<Button onClick={handleCancelEdit} variant="outline">
Отмена
</Button>
<Button onClick={handleUpdateElement} disabled={disabled}>
Сохранить изменения
</Button>
</div>
</DialogContent>
</Dialog>
)}
</div>
</div>
<div className="flex-1 overflow-hidden">
<VisualLayoutEditor
elements={elements}
layoutSettings={layoutSettings}
onElementUpdate={onElementUpdate}
onElementDelete={handleDeleteAndSelect}
onLayoutSettingsChange={() => {}} // Настройки макета только для чтения
onElementEdit={handleEditElement}
/>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,64 @@
import { Button } from '@/components/ui/button'
import { FileText, Upload } from 'lucide-react'
import React, { useRef } from 'react'
interface ExcelUploadPanelProps {
fileName?: string
isLoading: boolean
onUploadClick: () => void
onFileChange: (e: React.ChangeEvent<HTMLInputElement>) => void
}
export const ExcelUploadPanel: React.FC<ExcelUploadPanelProps> = ({
fileName,
isLoading,
onUploadClick,
onFileChange,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null)
return (
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.xls"
onChange={onFileChange}
className="hidden"
/>
<div
className={`flex items-center gap-1.5 rounded-md border px-2 py-1 ${
fileName
? 'border-emerald-200 bg-emerald-50'
: 'border-gray-200 bg-gray-50'
}`}
>
<FileText
className={`h-3 w-3 ${fileName ? 'text-emerald-600' : 'text-gray-400'}`}
/>
<span
className={`max-w-32 truncate text-xs font-medium ${
fileName ? 'text-emerald-700' : 'text-gray-500'
}`}
>
{fileName || 'Файл не выбран'}
</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
className="h-7 rounded-md px-2"
disabled={isLoading}
>
{isLoading ? (
<div className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<Upload className="h-3 w-3" />
)}
</Button>
</div>
)
}

View File

@@ -0,0 +1,61 @@
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import {
AlignCenter,
AlignLeft,
AlignRight,
Bold,
Grid,
Italic,
Palette,
Type,
Underline,
} from 'lucide-react'
import React from 'react'
export const FormattingToolbar: React.FC = () => (
<div className="flex items-center gap-0.5 rounded-lg border bg-background p-0.5">
<div className="flex items-center">
{[Bold, Italic, Underline].map((Icon, i) => (
<Button
key={i}
variant="ghost"
size="sm"
className="h-6 w-6 rounded p-0"
>
<Icon className="h-3 w-3" />
</Button>
))}
</div>
<Separator orientation="vertical" className="mx-0.5 h-4" />
<div className="flex items-center">
{[AlignLeft, AlignCenter, AlignRight].map((Icon, i) => (
<Button
key={i}
variant="ghost"
size="sm"
className="h-6 w-6 rounded p-0"
>
<Icon className="h-3 w-3" />
</Button>
))}
</div>
<Separator orientation="vertical" className="mx-0.5 h-4" />
<div className="flex items-center">
{[Palette, Type, Grid].map((Icon, i) => (
<Button
key={i}
variant="ghost"
size="sm"
className="h-6 w-6 rounded p-0"
>
<Icon className="h-3 w-3" />
</Button>
))}
</div>
</div>
)

View File

@@ -0,0 +1,54 @@
import { Button } from '@/components/ui/button'
import { Template } from '@/type/template'
import { ArrowLeft, FileText, Settings, Wrench } from 'lucide-react'
import React from 'react'
import { useNavigate } from 'react-router-dom'
interface HeaderBarProps {
template: Template
onBack: () => void
}
export const HeaderBar: React.FC<HeaderBarProps> = ({ template, onBack }) => {
const navigate = useNavigate()
return (
<div className="flex-shrink-0 border-b bg-card px-4 py-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex items-center space-x-2">
<Settings className="h-5 w-5" />
<h1 className="text-lg font-semibold">Редактор шаблонов</h1>
<span className="text-sm text-muted-foreground"></span>
<span className="text-sm text-muted-foreground">
{template.name}
</span>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/templates/${template.id}/elements`)}
className="flex items-center gap-2"
>
<Wrench className="h-3.5 w-3.5" />
Элементы интерфейса
</Button>
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/templates/${template.id}/protocols`)}
className="flex items-center gap-2"
>
<FileText className="h-3.5 w-3.5" />
Создать протокол
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,26 @@
import DualSpreadsheet from '@/components/DualSpreadsheet/DualSpreadsheet'
import { ExcelParseResult } from '@/service/fileApiSevice'
import React from 'react'
interface SpreadsheetViewerProps {
data: Record<string, any>
mergedCells: ExcelParseResult['mergedCells']
dataVersion: number
templateId: string | undefined
}
export const SpreadsheetViewer: React.FC<SpreadsheetViewerProps> = ({
data,
mergedCells,
dataVersion,
templateId,
}) => (
<div className="min-h-0 flex-1">
<DualSpreadsheet
key={dataVersion}
templateData={{ L: data }}
mergedCells={mergedCells}
templateId={templateId}
/>
</div>
)

View File

@@ -0,0 +1,77 @@
import { FileText, Settings, Wrench } from 'lucide-react'
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { Template } from '../../type/template'
import { Button } from '../ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'
interface TemplateCardProps {
template: Template
onDelete: (template: Template) => void
isSelected?: boolean
}
const TemplateCard: React.FC<TemplateCardProps> = ({
template,
onDelete,
isSelected = false,
}) => {
const navigate = useNavigate()
// Шаблон считается готовым, когда есть хотя бы один элемент и загружена
// Excel-таблица (что подтверждается наличием mergedCells).
const isConfigured =
template.elements.length > 0 && (template.mergedCells?.length ?? 0) > 0
return (
<Card className={isSelected ? 'border-2 border-blue-500' : ''}>
<CardHeader className="pb-4">
<CardTitle className="text-base font-medium">{template.name}</CardTitle>
</CardHeader>
<CardContent className="pt-0">
{template.description && (
<p className="mb-3 line-clamp-2 text-sm text-muted-foreground">
{template.description}
</p>
)}
<div className="mb-3 flex justify-between text-xs text-muted-foreground">
<span>
Создан: {new Date(template.createdAt).toLocaleDateString()}
</span>
<span>
Обновлён: {new Date(template.updatedAt).toLocaleDateString()}
</span>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
className="flex-1"
disabled={!isConfigured}
onClick={() => navigate(`/templates/${template.id}/protocols`)}
>
<FileText className="mr-2 h-3 w-3" />
{isConfigured ? 'Создать протокол' : 'Требует настройки'}
</Button>
<Button
size="sm"
variant="outline"
className="h-8 w-8 p-0"
onClick={() => navigate(`/templates/${template.id}/edit`)}
>
<Settings className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="outline"
className="h-8 w-8 p-0"
onClick={() => navigate(`/templates/${template.id}/elements`)}
>
<Wrench className="h-3 w-3" />
</Button>
</div>
</CardContent>
</Card>
)
}
export default TemplateCard

View File

@@ -0,0 +1,137 @@
import { useTemplateContext } from '@/context/TemplateContext'
import {
getFileData,
getLatestFileForTemplate,
parseExcelFile,
} from '@/service/fileApiSevice'
import { Template } from '@/type/template'
import React, { useEffect, useState } from 'react'
import { ExcelUploadPanel } from './ExcelUploadPanel'
import { FormattingToolbar } from './FormattingToolbar'
import { HeaderBar } from './HeaderBar'
import { SpreadsheetViewer } from './SpreadsheetViewer'
interface TemplateEditorProps {
template: Template
onBack: () => void
}
export const TemplateEditor: React.FC<TemplateEditorProps> = ({
template,
onBack,
}) => {
const [editedTemplate, setEditedTemplate] = useState(template)
const [excelData, setExcelData] = useState<Record<string, any>>(
template.excelData || {}
)
const [isLoading, setIsLoading] = useState(false)
const [dataVersion, setDataVersion] = useState(0)
const [serverFileName, setServerFileName] = useState<string | undefined>()
// Получаем функцию обновления шаблона из контекста
const { updateTemplate } = useTemplateContext()
// При монтировании пытаемся загрузить последний файл для шаблона
useEffect(() => {
let isMounted = true
const loadLatestFile = async () => {
try {
const latestFile = await getLatestFileForTemplate(template.id)
if (!latestFile) return
// Сохраняем имя файла для отображения
if (isMounted) {
setServerFileName(latestFile.name)
}
// Если у нас еще нет данных Excel загружаем их
if (
Object.keys(excelData).length === 0 &&
latestFile &&
latestFile.id
) {
const fileData = await getFileData(latestFile.id)
if (isMounted && Object.keys(fileData.data).length > 0) {
setExcelData(fileData.data)
setEditedTemplate(prev => ({
...prev,
mergedCells: fileData.mergedCells,
}))
setDataVersion(v => v + 1)
}
}
} catch (error) {
console.error('Ошибка при загрузке последнего файла шаблона:', error)
}
}
loadLatestFile()
return () => {
isMounted = false
}
}, [])
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setIsLoading(true)
try {
const { fileId, data, mergedCells } = await parseExcelFile(
file,
template.id
)
setExcelData(data)
const updatedTemplate = {
...template,
excelFile: file,
excelData: data,
mergedCells,
updatedAt: new Date(),
}
setEditedTemplate(updatedTemplate)
// Сохраняем шаблон с новым Excel-файлом, чтобы в списке он больше не
// помечался как «требует настройки»
await updateTemplate({
...updatedTemplate,
updatedAt: new Date(),
})
setDataVersion(v => v + 1)
console.log('✅ Файл загружен с ID:', fileId)
} catch (err) {
console.error(err)
alert('Ошибка при загрузке Excel файла на сервер')
} finally {
setIsLoading(false)
}
}
return (
<div className="flex h-screen flex-col bg-background">
<HeaderBar template={editedTemplate} onBack={onBack} />
<div className="flex-shrink-0 border-b bg-muted/30 px-4 py-2">
<div className="flex items-center justify-between">
<FormattingToolbar />
<ExcelUploadPanel
fileName={serverFileName || editedTemplate.excelFile?.name}
isLoading={isLoading}
onUploadClick={() => {}}
onFileChange={handleFileChange}
/>
</div>
</div>
<SpreadsheetViewer
data={excelData}
mergedCells={editedTemplate.mergedCells || []}
dataVersion={dataVersion}
templateId={editedTemplate.id}
/>
</div>
)
}

View File

@@ -0,0 +1,390 @@
import { Circle, CircleCheck, FileText, Plus, Trash2 } from 'lucide-react'
import React, { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTemplateContext } from '../../context/TemplateContext'
import { Template } from '../../type/template'
import { Button } from '../ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '../ui/dialog'
import { Input } from '../ui/input'
import TemplateCard from './TemplateCard'
import { TemplateEditor } from './TemplateEditor'
interface TemplateManagerProps {
onTemplateSelect?: (template: Template) => void
templateId?: string
}
export const TemplateManager: React.FC<TemplateManagerProps> = ({
templateId,
}) => {
const navigate = useNavigate()
const {
templates,
isLoading,
error,
addTemplate,
updateTemplate,
deleteTemplate,
loadTemplates,
} = useTemplateContext()
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
null
)
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const [isEditMode, setIsEditMode] = useState(false)
const [newTemplateName, setNewTemplateName] = useState('')
const [newTemplateDescription, setNewTemplateDescription] = useState('')
const [isSelectionMode, setIsSelectionMode] = useState(false)
const [selectedTemplates, setSelectedTemplates] = useState<Set<string>>(
new Set()
)
const [templatesToDelete, setTemplatesToDelete] = useState<Template[]>([])
const [isCreating, setIsCreating] = useState(false)
// Автоматически открываем редактирование шаблона, если передан templateId
useEffect(() => {
if (templateId) {
const template = templates.find(t => t.id === templateId)
if (template) {
setSelectedTemplate(template)
setIsEditMode(true)
}
}
}, [templateId, templates])
const handleCreateTemplate = async () => {
if (!newTemplateName.trim()) return
setIsCreating(true)
try {
await addTemplate({
name: newTemplateName.trim(),
description: newTemplateDescription.trim(),
elements: [],
layoutSettings: undefined,
})
// Находим созданный шаблон и открываем его для редактирования
// Поскольку addTemplate может занять время, используем небольшую задержку
setTimeout(() => {
const newTemplate = templates.find(
t => t.name === newTemplateName.trim()
)
if (newTemplate) {
setSelectedTemplate(newTemplate)
setIsEditMode(true)
}
}, 100)
setIsCreateDialogOpen(false)
setNewTemplateName('')
setNewTemplateDescription('')
} catch (error) {
console.error('Ошибка при создании шаблона:', error)
// Здесь можно добавить уведомление пользователю об ошибке
alert('Ошибка при создании шаблона. Попробуйте еще раз.')
} finally {
setIsCreating(false)
}
}
const handleTemplateUpdate = async (updatedTemplate: Template) => {
try {
await updateTemplate(updatedTemplate)
setSelectedTemplate(updatedTemplate)
} catch (error) {
console.error('Ошибка при обновлении шаблона:', error)
alert('Ошибка при обновлении шаблона. Попробуйте еще раз.')
}
}
const handleDeleteTemplates = () => {
templatesToDelete.forEach(template => {
deleteTemplate(template.id)
})
setTemplatesToDelete([])
setIsSelectionMode(false)
setSelectedTemplates(new Set())
}
const toggleTemplateSelection = (templateId: string) => {
const newSelected = new Set(selectedTemplates)
if (newSelected.has(templateId)) {
newSelected.delete(templateId)
} else {
newSelected.add(templateId)
}
setSelectedTemplates(newSelected)
}
const handleSelectionModeToggle = () => {
if (isSelectionMode) {
setIsSelectionMode(false)
setSelectedTemplates(new Set())
setTemplatesToDelete([])
} else {
setIsSelectionMode(true)
}
}
const handleDeleteSelected = () => {
const selectedTemplatesList = templates.filter(t =>
selectedTemplates.has(t.id)
)
setTemplatesToDelete(selectedTemplatesList)
}
const handleRetry = () => {
loadTemplates()
}
if (isEditMode && selectedTemplate) {
return (
<TemplateEditor
template={selectedTemplate}
onBack={() => {
setIsEditMode(false)
setSelectedTemplate(null)
if (templateId) {
navigate('/templates')
}
}}
/>
)
}
// Показываем ошибку, если есть
if (error && !isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<div className="text-center">
<FileText className="mx-auto mb-4 h-16 w-16 text-red-400" />
<h3 className="mb-2 text-lg font-medium text-gray-900">
Ошибка загрузки шаблонов
</h3>
<p className="mb-4 text-gray-600">{error}</p>
<Button onClick={handleRetry}>Попробовать снова</Button>
</div>
</div>
)
}
return (
<div className="mx-auto max-w-7xl p-6">
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Шаблоны протоколов</h1>
<div className="flex gap-3">
{isSelectionMode ? (
<>
<Button
variant="outline"
onClick={handleSelectionModeToggle}
className="flex items-center gap-2"
>
Отменить выбор
</Button>
<Button
variant="destructive"
onClick={handleDeleteSelected}
disabled={selectedTemplates.size === 0}
className="flex items-center gap-2"
>
<Trash2 className="h-4 w-4" />
Удалить выбранные ({selectedTemplates.size})
</Button>
</>
) : (
<>
<Button
variant="outline"
onClick={handleSelectionModeToggle}
disabled={templates.length === 0}
className="flex items-center gap-2"
>
<Circle className="h-4 w-4" />
Выбрать
</Button>
<Dialog
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
>
<DialogTrigger asChild>
<Button className="flex items-center gap-2">
<Plus className="h-4 w-4" />
Создать шаблон
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Создать новый шаблон</DialogTitle>
<DialogDescription>
Введите основную информацию для нового шаблона протокола
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium">
Название шаблона
</label>
<Input
placeholder="Например: Протокол испытаний"
value={newTemplateName}
onChange={e => setNewTemplateName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
handleCreateTemplate()
}
}}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium">
Описание (необязательно)
</label>
<Input
placeholder="Краткое описание назначения шаблона"
value={newTemplateDescription}
onChange={e =>
setNewTemplateDescription(e.target.value)
}
onKeyDown={e => {
if (e.key === 'Enter') {
handleCreateTemplate()
}
}}
/>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setIsCreateDialogOpen(false)}
disabled={isCreating}
>
Отменить
</Button>
<Button
onClick={handleCreateTemplate}
disabled={!newTemplateName.trim() || isCreating}
>
{isCreating ? 'Создание...' : 'Создать'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
)}
</div>
</div>
{/* Показываем индикатор загрузки */}
{isLoading && (
<div className="flex h-64 items-center justify-center">
<div className="text-center">
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent"></div>
<p className="text-gray-600">Загрузка шаблонов...</p>
</div>
</div>
)}
{/* Список шаблонов */}
{!isLoading && (
<>
{templates.length === 0 ? (
<div className="flex h-64 items-center justify-center">
<div className="text-center">
<FileText className="mx-auto mb-4 h-16 w-16 text-gray-400" />
<h3 className="mb-2 text-lg font-medium text-gray-900">
Нет шаблонов
</h3>
<p className="mb-4 text-gray-600">
Создайте первый шаблон для начала работы
</p>
<Dialog
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
Создать шаблон
</Button>
</DialogTrigger>
</Dialog>
</div>
</div>
) : (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{templates.map(template => (
<div key={template.id} className="relative">
{isSelectionMode && (
<div className="absolute right-2 top-2 z-10">
<button
onClick={() => toggleTemplateSelection(template.id)}
className="rounded-full bg-white p-1 shadow-md"
>
{selectedTemplates.has(template.id) ? (
<CircleCheck className="h-5 w-5 text-blue-600" />
) : (
<Circle className="h-5 w-5 text-gray-400" />
)}
</button>
</div>
)}
<TemplateCard
template={template}
onDelete={() => deleteTemplate(template.id)}
isSelected={selectedTemplates.has(template.id)}
/>
</div>
))}
</div>
)}
</>
)}
{/* Диалог подтверждения удаления */}
{templatesToDelete.length > 0 && (
<Dialog
open={templatesToDelete.length > 0}
onOpenChange={() => setTemplatesToDelete([])}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Подтверждение удаления</DialogTitle>
<DialogDescription>
Вы действительно хотите удалить выбранные шаблоны? Это действие
нельзя отменить.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
{templatesToDelete.map(template => (
<div key={template.id} className="text-sm text-gray-600">
{template.name}
</div>
))}
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setTemplatesToDelete([])}
>
Отменить
</Button>
<Button variant="destructive" onClick={handleDeleteTemplates}>
Удалить
</Button>
</div>
</DialogContent>
</Dialog>
)}
</div>
)
}

View File

@@ -0,0 +1,515 @@
import {
Calendar,
ChevronDown,
Droplets,
Edit,
Edit3,
Eye,
EyeOff,
Gauge,
Grid,
Move,
Radio,
Thermometer,
Trash2,
Zap,
} from 'lucide-react'
import React, { useCallback, useMemo, useState } from 'react'
import { Rnd } from 'react-rnd'
import {
ElementLayout,
FormLayoutSettings,
TemplateElement,
} from '../../type/template'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { Textarea } from '../ui/textarea'
// Минимальные размеры для разных типов элементов.
// Если понадобится скорректировать размеры для конкретного элемента,
// достаточно изменить эту таблицу.
const ELEMENT_MIN_SIZE: Record<string, { width: number; height: number }> = {
text: { width: 120, height: 40 },
textarea: { width: 160, height: 80 },
number: { width: 120, height: 40 },
date: { width: 140, height: 40 },
select: { width: 140, height: 40 },
radio: { width: 160, height: 80 },
checkbox: { width: 140, height: 40 },
standards: { width: 200, height: 50 },
'calibration-conditions': { width: 250, height: 60 },
'button-group': { width: 180, height: 50 },
}
interface VisualLayoutEditorProps {
elements: TemplateElement[]
layoutSettings: FormLayoutSettings
onElementUpdate: (
elementId: string,
updates: Partial<TemplateElement>
) => void
onElementDelete: (elementId: string) => void
onLayoutSettingsChange: (settings: FormLayoutSettings) => void
onElementEdit?: (element: TemplateElement) => void
}
interface DraggableElementProps {
element: TemplateElement
isSelected: boolean
onSelect: () => void
onUpdate: (layout: ElementLayout) => void
onDelete: () => void
onEdit?: () => void
gridSize: number
}
// const ELEMENT_ICONS: Record<ElementType, React.ReactNode> = {
// text: <Type className="h-4 w-4" />,
// textarea: <FileText className="h-4 w-4" />,
// number: <Hash className="h-4 w-4" />,
// date: <Calendar className="h-4 w-4" />,
// select: <ChevronDown className="h-4 w-4" />,
// radio: <CheckSquare className="h-4 w-4" />,
// checkbox: <CheckSquare className="h-4 w-4" />,
// };
// const ELEMENT_COLORS: Record<ElementType, string> = {
// text: 'bg-blue-100 border-blue-300 text-blue-800',
// textarea: 'bg-green-100 border-green-300 text-green-800',
// number: 'bg-purple-100 border-purple-300 text-purple-800',
// date: 'bg-orange-100 border-orange-300 text-orange-800',
// select: 'bg-indigo-100 border-indigo-300 text-indigo-800',
// radio: 'bg-pink-100 border-pink-300 text-pink-800',
// checkbox: 'bg-teal-100 border-teal-300 text-teal-800',
// };
const DraggableElement: React.FC<DraggableElementProps> = React.memo(
({ element, isSelected, onSelect, onUpdate, onDelete, onEdit, gridSize }) => {
const layout = element.layout || {
x: 50,
y: 50,
width: 300,
height: 80,
zIndex: 1,
}
const handleDragStop = useCallback(
(_e: any, d: { x: number; y: number }) => {
// Вызываем onUpdate только если позиция действительно изменилась,
// чтобы избежать ложных срабатываний при клике.
if (d.x !== layout.x || d.y !== layout.y) {
onUpdate({ ...layout, x: d.x, y: d.y })
}
},
[layout, onUpdate]
)
const handleResizeStop = useCallback(
(
_e: any,
_dir: any,
ref: HTMLElement,
_delta: any,
pos: { x: number; y: number }
) => {
onUpdate({
...layout,
width: parseInt(ref.style.width, 10),
height: parseInt(ref.style.height, 10),
x: pos.x,
y: pos.y,
})
},
[layout, onUpdate]
)
const handleDeleteClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
onDelete()
},
[onDelete]
)
const handleEditClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
onEdit?.()
},
[onEdit]
)
/*
* Render a lightweight, disabled preview of the element so that the user can
* see how the real control looks directly on the canvas instead of a plain
* colored square. Keeping the elements disabled and wrapped with
* `pointer-events-none` guarantees that they do not interfere with the drag
* behaviour of Rnd and keeps rendering costs minimal.
*/
const previewControl = useMemo(() => {
switch (element.type) {
case 'text':
return (
<Input
readOnly
placeholder={element.placeholder || 'Введите текст'}
className="pointer-events-none w-full bg-white"
/>
)
case 'textarea':
return (
<Textarea
readOnly
rows={3}
placeholder={element.placeholder || 'Введите текст'}
className="pointer-events-none w-full resize-none bg-white"
/>
)
case 'number':
return (
<Input
type="number"
readOnly
placeholder={element.placeholder || '0'}
className="pointer-events-none w-full bg-white"
/>
)
case 'date':
return (
<Input
type="date"
readOnly
className="pointer-events-none w-full bg-white"
/>
)
case 'select':
return (
<div className="pointer-events-none w-full">
<div className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-white px-3 py-2 text-sm">
<span className="text-gray-500">
{element.placeholder || 'Выберите значение'}
</span>
<ChevronDown className="h-4 w-4 opacity-50" />
</div>
</div>
)
case 'radio':
return (
<div className="pointer-events-none space-y-2">
{(element.options?.length
? element.options
: [{ label: 'Вариант 1' }]
).map((opt, idx) => (
<div key={idx} className="flex items-center gap-2">
<div className="h-4 w-4 rounded-full border-2 border-gray-300 bg-white" />
<span className="text-sm text-gray-900">
{opt.label || `Вариант ${idx + 1}`}
</span>
</div>
))}
</div>
)
case 'checkbox':
return (
<div className="pointer-events-none flex items-center gap-2">
<div className="h-4 w-4 rounded border-2 border-gray-300 bg-white" />
<span className="text-sm text-gray-900">
{element.placeholder || element.label || 'Отметить'}
</span>
</div>
)
case 'standards':
return (
<div className="pointer-events-none w-full">
<div className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-white px-3 py-2 text-sm">
<span className="text-gray-500">
{element.placeholder || 'Выберите эталоны'}
</span>
<Calendar className="h-4 w-4 opacity-50" />
</div>
</div>
)
case 'calibration-conditions':
return (
<div className="pointer-events-none w-full">
<div className="flex items-center gap-3 rounded-lg border bg-muted/50 px-3 py-2">
<div className="flex items-center gap-3 text-xs">
<div className="flex items-center gap-1">
<Thermometer className="h-3 w-3 text-muted-foreground" />
<span className="text-foreground">20°C</span>
</div>
<div className="flex items-center gap-1">
<Droplets className="h-3 w-3 text-muted-foreground" />
<span className="text-foreground">65%</span>
</div>
<div className="flex items-center gap-1">
<Gauge className="h-3 w-3 text-muted-foreground" />
<span className="text-foreground">101.3кПа</span>
</div>
<div className="flex items-center gap-1">
<Zap className="h-3 w-3 text-muted-foreground" />
<span className="text-foreground">220В</span>
</div>
<div className="flex items-center gap-1">
<Radio className="h-3 w-3 text-muted-foreground" />
<span className="text-foreground">50Гц</span>
</div>
</div>
<Button variant="ghost" size="icon" className="h-6 w-6 p-0">
<Edit3 className="h-3 w-3" />
</Button>
</div>
</div>
)
case 'button-group':
return (
<div className="pointer-events-none w-full">
<div className="flex flex-wrap gap-1.5">
{(element.options?.length
? element.options
: [{ label: 'Кнопка 1' }, { label: 'Кнопка 2' }]
).map((opt, idx) => (
<button
key={idx}
className="rounded-md border border-border bg-background px-3 py-1.5 text-sm font-medium text-foreground transition-all duration-200"
>
{opt.label || `Кнопка ${idx + 1}`}
</button>
))}
</div>
</div>
)
default:
return null
}
}, [element])
return (
<Rnd
dragGrid={[gridSize, gridSize]}
resizeGrid={[gridSize, gridSize]}
default={{
x: layout.x,
y: layout.y,
width: layout.width,
height: layout.height,
}}
onDragStop={handleDragStop}
onResizeStop={handleResizeStop}
minWidth={ELEMENT_MIN_SIZE[element.type]?.width ?? 120}
minHeight={ELEMENT_MIN_SIZE[element.type]?.height ?? 32}
bounds="parent"
className={`group ${isSelected ? 'z-20 ring-2 ring-blue-500' : 'z-10'}`}
onClick={onSelect}
>
<div
className={`h-full cursor-move ${
isSelected
? 'rounded-md bg-blue-50/10 p-1'
: 'rounded-md p-1 hover:bg-gray-50/10'
}`}
>
{/* Label and controls (only visible when selected or hovered) */}
<div
className={`mb-1 flex items-center justify-between ${
isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-60'
}`}
>
<div className="flex items-start gap-2">
<div className="flex items-center gap-1">
<span className="truncate text-xs font-medium text-gray-600">
{element.label || element.name || `${element.type} элемент`}
</span>
{element.required && (
<span className="-mt-1.5 inline-block h-1 w-1 rounded-full bg-red-500" />
)}
</div>
</div>
<div className="flex items-center gap-1">
{/* Индикатор ячеек */}
{element.targetCells && element.targetCells.length > 0 && (
<div className="group/cells relative">
<Grid className="h-3 w-3 cursor-help text-gray-500 opacity-60 transition-opacity hover:opacity-100" />
<div className="pointer-events-none absolute right-0 top-full z-50 mt-2 whitespace-nowrap rounded bg-gray-900 p-2 text-xs text-white opacity-0 shadow-lg transition-opacity group-hover/cells:opacity-100">
<div className="mb-1 font-medium">Целевые ячейки:</div>
{element.targetCells.map((cell, i) => (
<div key={i} className="font-mono">
{cell.sheet}!{cell.cell}
{cell.displayName && (
<span className="ml-1 text-gray-300">
({cell.displayName})
</span>
)}
</div>
))}
</div>
</div>
)}
{onEdit && (
<Button
variant="ghost"
size="icon"
className="h-4 w-4 cursor-pointer hover:bg-blue-100"
onClick={handleEditClick}
>
<Edit className="h-2.5 w-2.5 text-blue-600" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-4 w-4 cursor-pointer hover:bg-red-100"
onClick={handleDeleteClick}
>
<Trash2 className="h-2.5 w-2.5 text-red-600" />
</Button>
</div>
</div>
{/* Real element preview */}
{previewControl && (
<div className="flex-1 space-y-1">
{element.label && (
<label className="pointer-events-none block text-sm font-medium text-gray-900">
{element.label}
</label>
)}
{previewControl}
</div>
)}
{/* Additional info (only visible on selection to preserve visual clarity & perf) */}
{isSelected && (
<div className="mt-1 space-y-1 text-xs opacity-75">
{/* Здесь можно добавить дополнительную информацию при необходимости */}
</div>
)}
</div>
</Rnd>
)
}
)
export const VisualLayoutEditor: React.FC<VisualLayoutEditorProps> = ({
elements,
layoutSettings,
onElementUpdate,
onElementDelete,
onLayoutSettingsChange,
onElementEdit,
}) => {
console.log('VisualLayoutEditor render with elements:', elements)
const [selectedElementId, setSelectedElementId] = useState<string | null>(
null
)
const canvasSize = useMemo(() => {
const PADDING_Y = 200
const baseWidth = 1200
const baseHeight = 800
if (elements.length === 0) {
return { width: baseWidth, height: baseHeight }
}
const contentHeight = Math.max(
0,
...elements.map(el => (el.layout?.y || 0) + (el.layout?.height || 0))
)
return {
width: baseWidth,
height: Math.max(baseHeight, contentHeight + PADDING_Y),
}
}, [elements])
const GridPattern = useMemo(() => {
if (!layoutSettings.showGrid) return null
const gridSize = layoutSettings.gridSize
return (
<div
className="pointer-events-none absolute inset-0"
style={{
backgroundImage: `radial-gradient(circle, #e5e7eb 1px, transparent 1px)`,
backgroundSize: `${gridSize}px ${gridSize}px`,
}}
/>
)
}, [layoutSettings.showGrid, layoutSettings.gridSize])
const handleElementUpdateCallback = useCallback(
(elementId: string, layout: ElementLayout) => {
onElementUpdate(elementId, { layout })
},
[onElementUpdate]
)
const handleCanvasClick = useCallback((e: React.MouseEvent) => {
// Снимаем выделение при клике по пустому месту
if (e.target === e.currentTarget) {
setSelectedElementId(null)
}
}, [])
const toggleGrid = useCallback(() => {
onLayoutSettingsChange({
...layoutSettings,
showGrid: !layoutSettings.showGrid,
})
}, [layoutSettings, onLayoutSettingsChange])
return (
<div className="flex h-full flex-col bg-gray-50">
<div className="flex shrink-0 items-center justify-between border-b bg-white p-2">
<span className="px-2 text-sm text-gray-600">
{elements.length} элементов на холсте
</span>
<Button variant="outline" size="sm" onClick={toggleGrid}>
{layoutSettings.showGrid ? (
<EyeOff className="mr-2 h-4 w-4" />
) : (
<Eye className="mr-2 h-4 w-4" />
)}
{layoutSettings.showGrid ? 'Скрыть сетку' : 'Показать сетку'}
</Button>
</div>
<div
className="relative flex-1 overflow-auto"
onClick={handleCanvasClick}
>
<div
className="relative mx-auto my-8 bg-white shadow-lg"
style={{ width: canvasSize.width, height: canvasSize.height }}
onClick={handleCanvasClick}
>
{GridPattern}
{elements.map(element => (
<DraggableElement
key={element.id}
element={element}
isSelected={selectedElementId === element.id}
onSelect={() => setSelectedElementId(element.id)}
onUpdate={layout =>
handleElementUpdateCallback(element.id, layout)
}
onDelete={() => onElementDelete(element.id)}
onEdit={onElementEdit ? () => onElementEdit(element) : undefined}
gridSize={layoutSettings.gridSize}
/>
))}
{elements.length === 0 && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="text-center text-gray-400">
<Move className="mx-auto mb-4 h-16 w-16" />
<p className="mb-2 text-xl font-medium">Холст пуст</p>
<p className="text-sm">Добавьте свой первый элемент</p>
</div>
</div>
)}
</div>
</div>
</div>
)
}

2
src/component/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export { DualSpreadsheet } from "./DualSpreadsheet";

View File

@@ -0,0 +1,36 @@
import { cva, type VariantProps } from 'class-variance-authority'
import * as React from 'react'
import { cn } from '../../lib/utils'
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,40 @@
import * as React from "react"
import { cn } from "../../lib/utils"
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'
size?: 'default' | 'sm' | 'lg' | 'icon'
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'default', size = 'default', ...props }, ref) => {
return (
<button
className={cn(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
'bg-primary text-primary-foreground hover:bg-primary/90': variant === 'default',
'bg-destructive text-destructive-foreground hover:bg-destructive/90': variant === 'destructive',
'border border-input bg-background hover:bg-accent hover:text-accent-foreground': variant === 'outline',
'bg-secondary text-secondary-foreground hover:bg-secondary/80': variant === 'secondary',
'hover:bg-accent hover:text-accent-foreground': variant === 'ghost',
'text-primary underline-offset-4 hover:underline': variant === 'link',
},
{
'h-10 px-4 py-2': size === 'default',
'h-9 rounded-md px-3': size === 'sm',
'h-11 rounded-md px-8': size === 'lg',
'h-10 w-10': size === 'icon',
},
className
)}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button }

80
src/component/ui/card.tsx Normal file
View File

@@ -0,0 +1,80 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }

View File

@@ -0,0 +1,27 @@
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import * as React from "react"
import { cn } from "../../lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

110
src/component/ui/dialog.tsx Normal file
View File

@@ -0,0 +1,110 @@
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import * as React from "react"
import { cn } from "../../lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger
}

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "../../lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,24 @@
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,41 @@
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import * as React from "react"
import { cn } from "../../lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

148
src/component/ui/select.tsx Normal file
View File

@@ -0,0 +1,148 @@
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import * as React from "react"
import { cn } from "../../lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue
}

View File

@@ -0,0 +1,29 @@
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import * as React from "react"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@@ -0,0 +1,23 @@
import * as React from "react"
import { cn } from "../../lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }