переименования папок
This commit is contained in:
212
src/component/BasicElements/ButtonGroupElement.tsx
Normal file
212
src/component/BasicElements/ButtonGroupElement.tsx
Normal 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>
|
||||
),
|
||||
}
|
||||
642
src/component/BasicElements/CalibrationConditionsElement.tsx
Normal file
642
src/component/BasicElements/CalibrationConditionsElement.tsx
Normal 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
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}
|
||||
47
src/component/BasicElements/CheckboxElement.tsx
Normal file
47
src/component/BasicElements/CheckboxElement.tsx
Normal 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>
|
||||
),
|
||||
}
|
||||
49
src/component/BasicElements/DateElement.tsx
Normal file
49
src/component/BasicElements/DateElement.tsx
Normal 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}
|
||||
/>
|
||||
),
|
||||
}
|
||||
47
src/component/BasicElements/NumberElement.tsx
Normal file
47
src/component/BasicElements/NumberElement.tsx
Normal 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}
|
||||
/>
|
||||
),
|
||||
}
|
||||
133
src/component/BasicElements/RadioElement.tsx
Normal file
133
src/component/BasicElements/RadioElement.tsx
Normal 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>
|
||||
),
|
||||
}
|
||||
128
src/component/BasicElements/SelectElement.tsx
Normal file
128
src/component/BasicElements/SelectElement.tsx
Normal 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>
|
||||
),
|
||||
}
|
||||
45
src/component/BasicElements/TextElement.tsx
Normal file
45
src/component/BasicElements/TextElement.tsx
Normal 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}
|
||||
/>
|
||||
),
|
||||
}
|
||||
48
src/component/BasicElements/TextareaElement.tsx
Normal file
48
src/component/BasicElements/TextareaElement.tsx
Normal 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"
|
||||
/>
|
||||
),
|
||||
}
|
||||
9
src/component/BasicElements/index.ts
Normal file
9
src/component/BasicElements/index.ts
Normal 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'
|
||||
797
src/component/DualSpreadsheet/DualSpreadsheet.tsx
Normal file
797
src/component/DualSpreadsheet/DualSpreadsheet.tsx
Normal 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
|
||||
163
src/component/DualSpreadsheet/components/Cell.tsx
Normal file
163
src/component/DualSpreadsheet/components/Cell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
)
|
||||
96
src/component/DualSpreadsheet/components/CellRenderer.tsx
Normal file
96
src/component/DualSpreadsheet/components/CellRenderer.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
191
src/component/DualSpreadsheet/components/FormulaBar.tsx
Normal file
191
src/component/DualSpreadsheet/components/FormulaBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
)
|
||||
196
src/component/DualSpreadsheet/components/Spreadsheet.tsx
Normal file
196
src/component/DualSpreadsheet/components/Spreadsheet.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
4
src/component/DualSpreadsheet/components/index.ts
Normal file
4
src/component/DualSpreadsheet/components/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { Cell } from './Cell'
|
||||
export { CellRenderer } from './CellRenderer'
|
||||
export { FormulaBar } from './FormulaBar'
|
||||
export { Spreadsheet } from './Spreadsheet'
|
||||
52
src/component/DualSpreadsheet/hooks/useDraggableDivider.ts
Normal file
52
src/component/DualSpreadsheet/hooks/useDraggableDivider.ts
Normal 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
|
||||
}
|
||||
1
src/component/DualSpreadsheet/index.ts
Normal file
1
src/component/DualSpreadsheet/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as DualSpreadsheet } from './DualSpreadsheet';
|
||||
150
src/component/DualSpreadsheet/types.ts
Normal file
150
src/component/DualSpreadsheet/types.ts
Normal 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 }
|
||||
}
|
||||
51
src/component/DualSpreadsheet/utils.ts
Normal file
51
src/component/DualSpreadsheet/utils.ts
Normal 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 }
|
||||
}
|
||||
186
src/component/StandardsElement/StandardsDialog.tsx
Normal file
186
src/component/StandardsElement/StandardsDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
246
src/component/StandardsElement/StandardsEditor.tsx
Normal file
246
src/component/StandardsElement/StandardsEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
64
src/component/StandardsElement/StandardsPreview.tsx
Normal file
64
src/component/StandardsElement/StandardsPreview.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
228
src/component/StandardsElement/StandardsSelector.tsx
Normal file
228
src/component/StandardsElement/StandardsSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
src/component/StandardsElement/definition.tsx
Normal file
57
src/component/StandardsElement/definition.tsx
Normal 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
|
||||
},
|
||||
}
|
||||
970
src/component/TemplateManager/ElementConstructor.tsx
Normal file
970
src/component/TemplateManager/ElementConstructor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
64
src/component/TemplateManager/ExcelUploadPanel.tsx
Normal file
64
src/component/TemplateManager/ExcelUploadPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
61
src/component/TemplateManager/FormattingToolbar.tsx
Normal file
61
src/component/TemplateManager/FormattingToolbar.tsx
Normal 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>
|
||||
)
|
||||
54
src/component/TemplateManager/HeaderBar.tsx
Normal file
54
src/component/TemplateManager/HeaderBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
src/component/TemplateManager/SpreadsheetViewer.tsx
Normal file
26
src/component/TemplateManager/SpreadsheetViewer.tsx
Normal 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>
|
||||
)
|
||||
77
src/component/TemplateManager/TemplateCard.tsx
Normal file
77
src/component/TemplateManager/TemplateCard.tsx
Normal 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
|
||||
137
src/component/TemplateManager/TemplateEditor.tsx
Normal file
137
src/component/TemplateManager/TemplateEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
390
src/component/TemplateManager/TemplateManager.tsx
Normal file
390
src/component/TemplateManager/TemplateManager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
515
src/component/TemplateManager/VisualLayoutEditor.tsx
Normal file
515
src/component/TemplateManager/VisualLayoutEditor.tsx
Normal 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
2
src/component/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { DualSpreadsheet } from "./DualSpreadsheet";
|
||||
|
||||
36
src/component/ui/badge.tsx
Normal file
36
src/component/ui/badge.tsx
Normal 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 }
|
||||
40
src/component/ui/button.tsx
Normal file
40
src/component/ui/button.tsx
Normal 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
80
src/component/ui/card.tsx
Normal 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 }
|
||||
|
||||
27
src/component/ui/checkbox.tsx
Normal file
27
src/component/ui/checkbox.tsx
Normal 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
110
src/component/ui/dialog.tsx
Normal 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
|
||||
}
|
||||
24
src/component/ui/input.tsx
Normal file
24
src/component/ui/input.tsx
Normal 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 }
|
||||
24
src/component/ui/label.tsx
Normal file
24
src/component/ui/label.tsx
Normal 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 }
|
||||
41
src/component/ui/radio-group.tsx
Normal file
41
src/component/ui/radio-group.tsx
Normal 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
148
src/component/ui/select.tsx
Normal 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
|
||||
}
|
||||
29
src/component/ui/separator.tsx
Normal file
29
src/component/ui/separator.tsx
Normal 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 }
|
||||
23
src/component/ui/textarea.tsx
Normal file
23
src/component/ui/textarea.tsx
Normal 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 }
|
||||
Reference in New Issue
Block a user